/* * Copyright 2025 the original author or authors. *

* Licensed under the Moderne Source Available License (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* https://docs.moderne.io/licensing/moderne-source-available-license *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {emptyMarkers, Markers} from "../markers"; import {SourceFile, Tree} from "../tree"; export interface Json extends Tree { readonly prefix: Json.Space } export namespace Json { export const Kind = { Array: "org.openrewrite.json.tree.Json$Array", Document: "org.openrewrite.json.tree.Json$Document", Empty: "org.openrewrite.json.tree.Json$Empty", Identifier: "org.openrewrite.json.tree.Json$Identifier", Literal: "org.openrewrite.json.tree.Json$Literal", Member: "org.openrewrite.json.tree.Json$Member", Object: "org.openrewrite.json.tree.Json$JsonObject", Space: "org.openrewrite.json.tree.Space", Comment: "org.openrewrite.json.tree.Comment", RightPadded: "org.openrewrite.json.tree.JsonRightPadded" } as const export type Key = Identifier | Literal export type Value = Array | Empty | Json.Object | Literal export interface Comment { readonly kind: typeof Kind.Comment readonly multiline: boolean readonly text: string readonly suffix: string readonly markers: Markers } export interface Array extends Json { readonly kind: typeof Kind.Array readonly values: RightPadded[] } export interface Document extends SourceFile, Json { readonly kind: typeof Kind.Document readonly value: Value readonly eof: Space } export interface Empty extends Json { readonly kind: typeof Kind.Empty } export interface Identifier extends Json { readonly kind: typeof Kind.Identifier readonly name: string } export interface Literal extends Json { readonly kind: typeof Kind.Literal readonly source: string readonly value?: any } export interface Member extends Json { readonly kind: typeof Kind.Member readonly key: RightPadded readonly value: Value } export interface Object extends Json { readonly kind: typeof Kind.Object readonly members: RightPadded[] } export interface Space { readonly kind: typeof Kind.Space readonly comments: Comment[] readonly whitespace: string } export interface RightPadded { readonly kind: typeof Json.Kind.RightPadded readonly element: T readonly after: Space readonly markers: Markers } } // Cache for Space objects - reuse for common whitespace patterns (flyweight pattern) const spaceCache = new Map(); const SPACE_CACHE_MAX_LENGTH = 100; export const emptySpace: Json.Space = { kind: Json.Kind.Space, comments: [], whitespace: "" }; const singleSpace: Json.Space = { kind: Json.Kind.Space, comments: [], whitespace: " " }; /** * Parse a formatting string into a Space object, extracting comments. * Mirrors the Java Space.format() method. */ export function space(formatting: string): Json.Space { if (formatting.length === 0) { return emptySpace; } if (formatting === " ") { return singleSpace; } // Check cache first for short strings const cacheable = formatting.length <= SPACE_CACHE_MAX_LENGTH; if (cacheable) { const cached = spaceCache.get(formatting); if (cached !== undefined) { return cached; } } // No comment markers means simple whitespace-only Space if (!formatting.includes('/')) { const result: Json.Space = { kind: Json.Kind.Space, comments: [], whitespace: formatting }; if (cacheable) { spaceCache.set(formatting, result); } return result; } // Parse comments out of the formatting string let prefix = ''; let comment = ''; const comments: Json.Comment[] = []; let inSingleLineComment = false; let inMultiLineComment = false; let last = ''; for (let i = 0; i < formatting.length; i++) { const c = formatting[i]; if (c === '/') { if (inSingleLineComment) { comment += c; } else if (last === '/' && !inMultiLineComment) { // Start of single-line comment inSingleLineComment = true; comment = ''; prefix = prefix.slice(0, -1); // Remove the first '/' } else if (last === '*' && inMultiLineComment && comment.length > 0) { // End of multi-line comment inMultiLineComment = false; comment = comment.slice(0, -1); // Trim the last '*' comments.push({ kind: Json.Kind.Comment, multiline: true, text: comment, suffix: prefix.slice(0, -1), markers: emptyMarkers }); prefix = ''; comment = ''; continue; } else if (inMultiLineComment) { comment += c; } else { prefix += c; } } else if (c === '\r' || c === '\n') { if (inSingleLineComment) { // End of single-line comment inSingleLineComment = false; comments.push({ kind: Json.Kind.Comment, multiline: false, text: comment, suffix: prefix, markers: emptyMarkers }); prefix = c; comment = ''; } else if (!inMultiLineComment) { prefix += c; } else { comment += c; } } else if (c === '*') { if (inSingleLineComment) { comment += c; } else if (last === '/' && !inMultiLineComment) { // Start of multi-line comment inMultiLineComment = true; comment = ''; } else { comment += c; } } else { if (inSingleLineComment || inMultiLineComment) { comment += c; } else { prefix += c; } } last = c; } // If a file ends with a single-line comment there may be no terminating newline if (comment.length > 0 || inSingleLineComment) { comments.push({ kind: Json.Kind.Comment, multiline: false, text: comment, suffix: prefix, markers: emptyMarkers }); prefix = ''; } // Shift the whitespace on each comment forward to be a suffix of the comment before it, // and the whitespace on the first comment to be the whitespace of the tree element. // The remaining prefix is the suffix of the last comment. let whitespace = prefix; if (comments.length > 0) { for (let i = comments.length - 1; i >= 0; i--) { const c = comments[i]; const next = c.suffix; comments[i] = { ...c, suffix: whitespace }; whitespace = next; } } const result: Json.Space = { kind: Json.Kind.Space, comments, whitespace }; if (cacheable) { spaceCache.set(formatting, result); } return result; } const jsonKindValues = new Set(Object.values(Json.Kind)); export function isJson(tree: any): tree is Json { return jsonKindValues.has(tree["kind"]); } export function isLiteral(json: Json): json is Json.Literal { return json.kind === Json.Kind.Literal; } export function isObject(json: Json): json is Json.Object { return json.kind === Json.Kind.Object; } export function isArray(json: Json): json is Json.Array { return json.kind === Json.Kind.Array; } export function isIdentifier(json: Json): json is Json.Identifier { return json.kind === Json.Kind.Identifier; } export function isMember(json: Json): json is Json.Member { return json.kind === Json.Kind.Member; } /** * Gets the key name from a Json.Member. * Handles both string literals (quoted keys) and identifiers (unquoted keys). * * @param member The JSON member to extract the key name from * @returns The key name as a string, or undefined if extraction fails */ export function getMemberKeyName(member: Json.Member): string | undefined { const key = member.key.element; if (isLiteral(key)) { const source = key.source; // Remove quotes from string literal if (source.startsWith('"') && source.endsWith('"')) { return source.slice(1, -1); } return source; } else if (isIdentifier(key)) { return key.name; } return undefined; } /** * Detects the indentation used in a JSON document by examining existing members or array elements. * Returns the base indent string (e.g., " " for 2 spaces, " " for 4 spaces). * * @param doc The JSON document to analyze * @returns The detected indent string, or " " (4 spaces) as default */ export function detectIndent(doc: Json.Document): string { const defaultIndent = ' '; // Default to 4 spaces if (isObject(doc.value)) { if (doc.value.members && doc.value.members.length > 0) { // Look at the prefix of the first member to detect indentation const firstMemberRightPadded = doc.value.members[0]; const firstMember = firstMemberRightPadded.element as Json.Member; const prefix = firstMember.prefix.whitespace; // Extract just the spaces/tabs after the newline const match = prefix.match(/\n([ \t]+)/); if (match) { return match[1]; } } } else if (isArray(doc.value)) { if (doc.value.values && doc.value.values.length > 0) { // Look at the prefix of the first array element to detect indentation const firstElement = doc.value.values[0].element; const prefix = firstElement.prefix.whitespace; // Extract just the spaces/tabs after the newline const match = prefix.match(/\n([ \t]+)/); if (match) { return match[1]; } } } return defaultIndent; } /** * Creates a RightPadded wrapper for a JSON element. * * @param element The JSON element to wrap * @param after The trailing space after the element * @param markers Optional markers to attach * @returns A RightPadded wrapper containing the element */ export function rightPadded(element: T, after: Json.Space, markers?: Markers): Json.RightPadded { return { kind: Json.Kind.RightPadded, element, after, markers: markers ?? emptyMarkers }; }