diff --git a/packages/hoppscotch-common/src/components/http/RawBody.vue b/packages/hoppscotch-common/src/components/http/RawBody.vue index 57f6b67ae..c8a9b7a60 100644 --- a/packages/hoppscotch-common/src/components/http/RawBody.vue +++ b/packages/hoppscotch-common/src/components/http/RawBody.vue @@ -104,8 +104,8 @@ import { readFileAsText } from "~/helpers/functional/files" import xmlFormat from "xml-formatter" import { useNestedSetting } from "~/composables/settings" import { toggleNestedSetting } from "~/newstore/settings" -import * as LJSON from "lossless-json" import { useAIExperiments } from "~/composables/ai-experiments" +import { prettifyJSONC } from "~/helpers/editor/linting/jsoncPretty" type PossibleContentTypes = Exclude< ValidContentTypes, @@ -205,8 +205,7 @@ const prettifyRequestBody = () => { let prettifyBody = "" try { if (body.value.contentType.endsWith("json")) { - const jsonObj = LJSON.parse(rawParamsBody.value as string) - prettifyBody = LJSON.stringify(jsonObj, undefined, 2) as string + prettifyBody = prettifyJSONC(rawParamsBody.value as string) } else if (body.value.contentType === "application/xml") { prettifyBody = prettifyXML(rawParamsBody.value as string) } diff --git a/packages/hoppscotch-common/src/helpers/editor/linting/jsoncPretty.ts b/packages/hoppscotch-common/src/helpers/editor/linting/jsoncPretty.ts new file mode 100644 index 000000000..392449897 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/linting/jsoncPretty.ts @@ -0,0 +1,258 @@ +import jsonParse, { + JSONArrayValue, + JSONCommentValue, + JSONObjectValue, + JSONValue, +} from "~/helpers/jsoncParse" + +type PrettifyOptions = { + indent?: string | number + maxLength?: number + commentSpace?: boolean + trailingComma?: boolean +} + +const DEFAULT_OPTIONS: Required = { + indent: 2, + maxLength: 80, + commentSpace: true, + trailingComma: true, +} + +function prettify( + ast: JSONObjectValue | JSONArrayValue, + options: PrettifyOptions = {} +): string { + const opts = { ...DEFAULT_OPTIONS, ...options } + const indent = + typeof opts.indent === "number" ? " ".repeat(opts.indent) : opts.indent + return formatValue(ast, opts, 0, indent) +} + +function formatValue( + node: JSONValue, + options: Required, + depth: number, + indent: string +): string { + switch (node.kind) { + case "Object": + return formatObject(node, options, depth, indent) + case "Array": + return formatArray(node, options, depth, indent) + case "String": + return JSON.stringify(node.value) + case "Number": + case "Boolean": + return String(node.value) + case "Null": + return "null" + default: + return "" + } +} + +function formatComments( + comments: JSONCommentValue[] | undefined, + options: Required, + indentation: string, + inline: boolean = false +): string { + if (!comments?.length) return "" + + return comments + .map((comment) => { + if (comment.kind === "SingleLineComment") { + const space = options.commentSpace ? " " : "" + return inline + ? ` //${space}${comment.value}` + : `\n${indentation}//${space}${comment.value}` + } + const space = options.commentSpace ? " " : "" + const commentLines = comment.value.split("\n") + + if (commentLines.length === 1) { + return inline + ? ` /*${space}${comment.value}${space}*/` + : `\n${indentation}/*${space}${comment.value}${space}*/` + } + + return ( + `\n${indentation}/*\n` + + commentLines.map((line) => `${indentation} * ${line}`).join("\n") + + `\n${indentation} */` + ) + }) + .join("") +} + +function formatObject( + node: JSONObjectValue, + options: Required, + depth: number, + indent: string +): string { + if (node.members.length === 0) { + const comments = formatComments(node.comments, options, "", true) + return `{${comments}}` + } + + const indentation = indent.repeat(depth) + const nextIndentation = indent.repeat(depth + 1) + + let result = "{" + + // Leading comments (before any members) + if (node.comments?.length) { + const leadingComments = node.comments.filter( + (c) => c.start < node.members[0].start + ) + if (leadingComments.length) { + result += formatComments(leadingComments, options, nextIndentation) + } + } + + // Format each member + node.members.forEach((member, index) => { + const isLast = index === node.members.length - 1 + + // Member's leading comments + if (member.comments?.length) { + const leadingComments = member.comments.filter( + (c) => c.start < member.key.start + ) + if (leadingComments.length) { + result += formatComments(leadingComments, options, nextIndentation) + } + } + + // Member key-value pair + result += "\n" + nextIndentation + result += JSON.stringify(member.key.value) + ": " + result += formatValue(member.value, options, depth + 1, indent) + + // Inline comments after the value + if (member.comments?.length) { + const inlineComments = member.comments.filter((c) => c.start > member.end) + if (inlineComments.length) { + result += formatComments(inlineComments, options, "", true) + } + } + + // Add comma if not last item or if trailing comma is enabled + if (!isLast || options.trailingComma) { + result += "," + } + + // Comments between members + if (!isLast && node.comments?.length) { + const betweenComments = node.comments.filter( + (c) => c.start > member.end && c.end < node.members[index + 1].start + ) + if (betweenComments.length) { + result += formatComments(betweenComments, options, nextIndentation) + } + } + }) + + // Trailing comments (after last member) + if (node.comments?.length) { + const trailingComments = node.comments.filter( + (c) => + c.start > node.members[node.members.length - 1].end && c.end < node.end + ) + if (trailingComments.length) { + result += formatComments(trailingComments, options, nextIndentation) + } + } + + result += "\n" + indentation + "}" + return result +} + +function formatArray( + node: JSONArrayValue, + options: Required, + depth: number, + indent: string +): string { + if (node.values.length === 0) { + const comments = formatComments(node.comments, options, "", true) + return `[${comments}]` + } + + const indentation = indent.repeat(depth) + const nextIndentation = indent.repeat(depth + 1) + + let result = "[" + + // Leading comments (before any values) + if (node.comments?.length) { + const leadingComments = node.comments.filter( + (c) => c.start < node.values[0].start + ) + if (leadingComments.length) { + result += formatComments(leadingComments, options, nextIndentation) + } + } + + // Format each value + node.values.forEach((value, index) => { + const isLast = index === node.values.length - 1 + + // Value's leading comments + if ("comments" in value && value.comments?.length) { + const leadingComments = value.comments.filter( + (c) => c.start < value.start + ) + if (leadingComments.length) { + result += formatComments(leadingComments, options, nextIndentation) + } + } + + result += "\n" + nextIndentation + result += formatValue(value, options, depth + 1, indent) + + // Inline comments after the value + if ("comments" in value && value.comments?.length) { + const inlineComments = value.comments.filter((c) => c.start > value.end) + if (inlineComments.length) { + result += formatComments(inlineComments, options, "", true) + } + } + + // Add comma if not last item or if trailing comma is enabled + if (!isLast || options.trailingComma) { + result += "," + } + + // Comments between values + if (!isLast && node.comments?.length) { + const betweenComments = node.comments.filter( + (c) => c.start > value.end && c.end < node.values[index + 1].start + ) + if (betweenComments.length) { + result += formatComments(betweenComments, options, nextIndentation) + } + } + }) + + // Trailing comments (after last value) + if (node.comments?.length) { + const trailingComments = node.comments.filter( + (c) => + c.start > node.values[node.values.length - 1].end && c.end < node.end + ) + if (trailingComments.length) { + result += formatComments(trailingComments, options, nextIndentation) + } + } + + result += "\n" + indentation + "]" + return result +} + +export function prettifyJSONC(str: string, options: PrettifyOptions = {}) { + const ast = jsonParse(str) + return prettify(ast, options) +} diff --git a/packages/hoppscotch-common/src/helpers/jsoncParse.ts b/packages/hoppscotch-common/src/helpers/jsoncParse.ts index 8d398e854..ad777514e 100644 --- a/packages/hoppscotch-common/src/helpers/jsoncParse.ts +++ b/packages/hoppscotch-common/src/helpers/jsoncParse.ts @@ -25,6 +25,16 @@ type JSONEOFValue = { end: number } +// First, add the new comment types +type JSONCommentKind = "SingleLineComment" | "MultiLineComment" + +export type JSONCommentValue = { + kind: JSONCommentKind + start: number + end: number + value: string +} + type JSONNullValue = { kind: "Null" start: number @@ -65,6 +75,7 @@ export type JSONObjectValue = { end: number // eslint-disable-next-line no-use-before-define members: JSONObjectMember[] + comments?: JSONCommentValue[] // optional comments array } export type JSONArrayValue = { @@ -73,6 +84,7 @@ export type JSONArrayValue = { end: number // eslint-disable-next-line no-use-before-define values: JSONValue[] + comments?: JSONCommentValue[] // optional comments array } export type JSONValue = JSONObjectValue | JSONArrayValue | JSONPrimitiveValue @@ -83,26 +95,7 @@ export type JSONObjectMember = { end: number key: JSONStringValue value: JSONValue -} - -export default function jsonParse( - str: string -): JSONObjectValue | JSONArrayValue { - string = str - strLen = str.length - start = end = lastEnd = -1 - ch() - lex() // Pass the allowComments flag to lex() - try { - const ast = parseObj() - expect("EOF") - return ast - } catch (e) { - // Try parsing expecting a root array - const ast = parseArr() - expect("EOF") - return ast - } + comments?: JSONCommentValue[] // optional comments array } let string: string @@ -112,57 +105,144 @@ let end: number let lastEnd: number let code: number let kind: string +let pendingComments: JSONCommentValue[] = [] + +export default function jsonParse( + str: string +): JSONObjectValue | JSONArrayValue { + string = str + strLen = str.length + start = end = lastEnd = -1 + pendingComments = [] // Reset pending comments + ch() + lex() + try { + const ast = parseObj() + expect("EOF") + return ast + } catch (e) { + pendingComments = [] // Reset pending comments + const ast = parseArr() + expect("EOF") + return ast + } +} function parseObj(): JSONObjectValue { const nodeStart = start - const members = [] + const members: JSONObjectMember[] = [] + const comments = [...pendingComments] // Capture comments before the object + pendingComments = [] + expect("{") + + let first = true while (!skip("}")) { - members.push(parseMember()) - if (!skip(",")) { - expect("}") - break + if (!first) { + // Expect a comma between members + expect(",") + + // After comma, check for closing brace (handling trailing comma) + if (skip("}")) { + break + } } + first = false + + // Capture any comments before the member + const memberComments = [...pendingComments] + pendingComments = [] + + const member = parseMember() + if (memberComments.length > 0) { + member.comments = memberComments + } + members.push(member) } + + // Capture any trailing comments inside the object + const trailingComments = [...pendingComments] + pendingComments = [] + return { kind: "Object", start: nodeStart, end: lastEnd, members, - } -} - -function parseMember(): JSONObjectMember { - const nodeStart = start - const key = kind === "String" ? (curToken() as JSONStringValue) : null - expect("String") - expect(":") - const value = parseVal() - return { - kind: "Member", - start: nodeStart, - end: lastEnd, - key: key!, - value, + comments: + comments.length > 0 || trailingComments.length > 0 + ? [...comments, ...trailingComments] + : undefined, } } function parseArr(): JSONArrayValue { const nodeStart = start const values: JSONValue[] = [] + const comments = [...pendingComments] // Capture comments before the array + pendingComments = [] + expect("[") + + let first = true while (!skip("]")) { - values.push(parseVal()) - if (!skip(",")) { - expect("]") - break + if (!first) { + // Expect a comma between values + expect(",") + + // After comma, check for closing bracket (handling trailing comma) + if (skip("]")) { + break + } } + first = false + + // Add value and attach any pending comments to it + const value = parseVal() + if (pendingComments.length > 0 && typeof value === "object") { + ;(value as JSONObjectValue).comments = [ + ...((value as JSONObjectValue).comments || []), + ...pendingComments, + ] + pendingComments = [] + } + values.push(value) } + + // Capture any trailing comments inside the array + const trailingComments = [...pendingComments] + pendingComments = [] + return { kind: "Array", start: nodeStart, end: lastEnd, values, + comments: + comments.length > 0 || trailingComments.length > 0 + ? [...comments, ...trailingComments] + : undefined, + } +} + +function parseMember(): JSONObjectMember { + const nodeStart = start + const memberComments = [...pendingComments] // Capture comments before the member + pendingComments = [] + + const key = kind === "String" ? (curToken() as JSONStringValue) : null + expect("String") + expect(":") + + const value = parseVal() + + return { + kind: "Member", + start: nodeStart, + end: lastEnd, + key: key!, + value, + comments: memberComments.length > 0 ? memberComments : undefined, } } @@ -239,41 +319,65 @@ function ch() { function lex() { lastEnd = end - // Skip whitespace and comments while (true) { - // Skip whitespace (space, tab, newline, etc.) + // Skip whitespace while (code === 9 || code === 10 || code === 13 || code === 32) { ch() } - // Check for single-line comment (//) + // Handle single-line comments if (code === 47 && string.charCodeAt(end + 1) === 47) { - // 47 is '/' + const commentStart = end + ch() // Skip first '/' + ch() // Skip second '/' + + let commentText = "" while (code !== 10 && code !== 13 && code !== 0) { - // Skip until newline or EOF + commentText += String.fromCharCode(code) ch() } - continue // After skipping the comment, recheck for more whitespace/comments + + pendingComments.push({ + kind: "SingleLineComment", + start: commentStart, + end, + value: commentText.trim(), + }) + continue } - // Check for multi-line comment (/* */) + // Handle multi-line comments if (code === 47 && string.charCodeAt(end + 1) === 42) { - // 42 is '*' - ch() // Skip the '*' - ch() // Move past the opening '/*' + const commentStart = end + ch() // Skip '/' + ch() // Skip '*' + + let commentText = "" while ( code !== 0 && !(code === 42 && string.charCodeAt(end + 1) === 47) ) { - // Look for '*/' + commentText += String.fromCharCode(code) ch() } - ch() // Skip the '*' - ch() // Move past the closing '*/' - continue // After skipping the comment, recheck for more whitespace/comments + + if (code === 0) { + throw syntaxError("Unterminated multi-line comment") + } + + ch() // Skip '*' + ch() // Skip '/' + + pendingComments.push({ + kind: "MultiLineComment", + start: commentStart, + end, + value: commentText.trim(), + }) + continue } - break // Exit loop when no more comments or whitespace + break } if (code === 0) {