From 5d8337e8e03e9709929b1b5e708ccab264057641 Mon Sep 17 00:00:00 2001 From: fisker Cheung Date: Thu, 20 Mar 2025 23:01:10 +0800 Subject: [PATCH] Allow comment only JSONC files (#17269) --- changelog_unreleased/json/17269.md | 15 + src/language-js/print/estree.js | 2 +- src/language-json/parser-json.js | 93 ++++-- src/main/comments/attach.js | 2 +- .../empty/__snapshots__/format.test.js.snap | 47 +++ tests/format/json/jsonc/empty/format.test.js | 25 ++ .../json/__snapshots__/format.test.js.snap | 292 ++++++++++++++++++ tests/format/misc/errors/json/format.test.js | 19 ++ 8 files changed, 465 insertions(+), 30 deletions(-) create mode 100644 changelog_unreleased/json/17269.md create mode 100644 tests/format/json/jsonc/empty/__snapshots__/format.test.js.snap create mode 100644 tests/format/json/jsonc/empty/format.test.js diff --git a/changelog_unreleased/json/17269.md b/changelog_unreleased/json/17269.md new file mode 100644 index 000000000..844860667 --- /dev/null +++ b/changelog_unreleased/json/17269.md @@ -0,0 +1,15 @@ +#### Allow format comment-only JSONC files (#17269 by @fisker) + + +```jsonc +// Input +// Comment + +// Prettier stable +SyntaxError: Unexpected token (1:11) +> 1 | // Comment + | ^ + +// Prettier main +// Comment +``` diff --git a/src/language-js/print/estree.js b/src/language-js/print/estree.js index 97ce1dc52..3e5c93882 100644 --- a/src/language-js/print/estree.js +++ b/src/language-js/print/estree.js @@ -101,7 +101,7 @@ function printEstree(path, options, print, args) { case "JsExpressionRoot": return print("node"); case "JsonRoot": - return [print("node"), hardline]; + return [printDanglingComments(path, options), print("node"), hardline]; case "File": return printHtmlBinding(path, options, print) ?? print("program"); // Babel extension. diff --git a/src/language-json/parser-json.js b/src/language-json/parser-json.js index c4673438e..9ef5b96d6 100644 --- a/src/language-json/parser-json.js +++ b/src/language-json/parser-json.js @@ -1,35 +1,76 @@ -import { parseExpression } from "@babel/parser"; +import { parse, parseExpression } from "@babel/parser"; import createError from "../common/parser-create-error.js"; import createBabelParseError from "../language-js/parse/utils/create-babel-parse-error.js"; import createParser from "../language-js/parse/utils/create-parser.js"; import wrapBabelExpression from "../language-js/parse/utils/wrap-babel-expression.js"; import isNonEmptyArray from "../utils/is-non-empty-array.js"; -function createJsonParse(options = {}) { - const { allowComments = true } = options; +const babelParseOptions = { + tokens: true, + ranges: true, + attachComment: false, +}; - return function parse(text) { - let ast; - try { - ast = parseExpression(text, { - tokens: true, - ranges: true, - attachComment: false, - }); - } catch (/** @type {any} */ error) { +function parseEmptyJson(text) { + const file = parse(text, babelParseOptions); + + const { program } = file; + + // Not an empty JSON + /* c8 ignore next */ + if ( + !( + program.body.length === 0 && + program.directives.length === 0 && + !program.interpreter + ) + ) { + return; + } + + return file; +} + +function parseJson(text, options = {}) { + const { allowComments = true, allowEmpty = false } = options; + + let ast; + try { + ast = parseExpression(text, babelParseOptions); + } catch (/** @type {any} */ error) { + if ( + allowEmpty && + error.code === "BABEL_PARSER_SYNTAX_ERROR" && + error.reasonCode === "UnexpectedToken" && + error.pos === text.length + ) { + try { + ast = parseEmptyJson(text); + } catch { + // No op + } + } + + if (!ast) { throw createBabelParseError(error); } + } + // @ts-expect-error + if (!allowComments && isNonEmptyArray(ast.comments)) { // @ts-expect-error - if (!allowComments && isNonEmptyArray(ast.comments)) { - // @ts-expect-error - throw createJsonError(ast.comments[0], "Comment"); - } + throw createJsonError(ast.comments[0], "Comment"); + } - assertJsonNode(ast); + ast = wrapBabelExpression(ast, { type: "JsonRoot", text }); - return wrapBabelExpression(ast, { type: "JsonRoot", text }); - }; + if (ast.node.type === "File") { + delete ast.node; + } else { + assertJsonNode(ast.node); + } + + return ast; } function createJsonError(node, description) { @@ -131,19 +172,15 @@ function assertJsonNode(node) { } } -const parseJson = createJsonParse(); - const jsonParsers = { json: createParser({ - parse: parseJson, - hasPragma() { - return true; - }, + parse: (text) => parseJson(text), + hasPragma: () => true, }), - json5: createParser(parseJson), - jsonc: createParser(parseJson), + json5: createParser((text) => parseJson(text)), + jsonc: createParser((text) => parseJson(text, { allowEmpty: true })), "json-stringify": createParser({ - parse: createJsonParse({ allowComments: false }), + parse: (text) => parseJson(text, { allowComments: false }), astFormat: "estree-json", }), }; diff --git a/src/main/comments/attach.js b/src/main/comments/attach.js index c545053f7..6bf14c22d 100644 --- a/src/main/comments/attach.js +++ b/src/main/comments/attach.js @@ -193,7 +193,7 @@ function attachComments(ast, options) { addLeadingComment(ast, comment); continue; } - if (locEnd(comment) - locEnd(ast) >= 0) { + if (locEnd(comment) > locEnd(ast)) { addTrailingComment(ast, comment); continue; } diff --git a/tests/format/json/jsonc/empty/__snapshots__/format.test.js.snap b/tests/format/json/jsonc/empty/__snapshots__/format.test.js.snap new file mode 100644 index 000000000..3ad099f80 --- /dev/null +++ b/tests/format/json/jsonc/empty/__snapshots__/format.test.js.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`snippet: #0 [json] format 1`] = ` +"Unexpected token (4:29) + 2 | /** Block comment 1 */ + 3 | // Line comment 2 +> 4 | /** Block comment 3 */ + | ^ +Cause: Unexpected token (4:28)" +`; + +exports[`snippet: #0 [json-stringify] format 1`] = ` +"Unexpected token (4:29) + 2 | /** Block comment 1 */ + 3 | // Line comment 2 +> 4 | /** Block comment 3 */ + | ^ +Cause: Unexpected token (4:28)" +`; + +exports[`snippet: #0 [json5] format 1`] = ` +"Unexpected token (4:29) + 2 | /** Block comment 1 */ + 3 | // Line comment 2 +> 4 | /** Block comment 3 */ + | ^ +Cause: Unexpected token (4:28)" +`; + +exports[`snippet: #0 format 1`] = ` +====================================options===================================== +parsers: ["jsonc", "json", "json5", "json-stringify"] +printWidth: 80 + | printWidth +=====================================input====================================== +// Line comment 1 + /** Block comment 1 */ + // Line comment 2 + /** Block comment 3 */ +=====================================output===================================== +// Line comment 1 +/** Block comment 1 */ +// Line comment 2 +/** Block comment 3 */ + +================================================================================ +`; diff --git a/tests/format/json/jsonc/empty/format.test.js b/tests/format/json/jsonc/empty/format.test.js new file mode 100644 index 000000000..555a03cec --- /dev/null +++ b/tests/format/json/jsonc/empty/format.test.js @@ -0,0 +1,25 @@ +import { outdent } from "outdent"; + +runFormatTest( + { + importMeta: import.meta, + snippets: [ + // "// Only line comment", + // "/* Only block comment */", + outdent` + // Line comment 1 + /** Block comment 1 */ + // Line comment 2 + /** Block comment 3 */ + `, + ], + }, + ["jsonc", "json", "json5", "json-stringify"], + { + errors: { + json: true, + json5: true, + "json-stringify": true, + }, + }, +); diff --git a/tests/format/misc/errors/json/__snapshots__/format.test.js.snap b/tests/format/misc/errors/json/__snapshots__/format.test.js.snap index e272aad3b..2ef2b12c5 100644 --- a/tests/format/misc/errors/json/__snapshots__/format.test.js.snap +++ b/tests/format/misc/errors/json/__snapshots__/format.test.js.snap @@ -13,18 +13,46 @@ exports[`snippet: #0 [json] format 2`] = ` Cause: A 'set' accessor must have exactly one formal parameter. (1:8)" `; +exports[`snippet: #0 [json] format 3`] = ` +"Unexpected token (1:2) +> 1 | [ + | ^ +Cause: Unexpected token (1:1)" +`; + exports[`snippet: #0 [json-stringify] format 1`] = ` "Shorthand property is not allowed in JSON. (1:2) > 1 | {foo} | ^^^" `; +exports[`snippet: #0 [json-stringify] format 2`] = ` +"Unexpected token (1:2) +> 1 | [ + | ^ +Cause: Unexpected token (1:1)" +`; + exports[`snippet: #0 [json5] format 1`] = ` "Shorthand property is not allowed in JSON. (1:2) > 1 | {foo} | ^^^" `; +exports[`snippet: #0 [json5] format 2`] = ` +"Unexpected token (1:2) +> 1 | [ + | ^ +Cause: Unexpected token (1:1)" +`; + +exports[`snippet: #0 [jsonc] format 1`] = ` +"Unexpected token (1:2) +> 1 | [ + | ^ +Cause: Unexpected token (1:1)" +`; + exports[`snippet: #1 [json] format 1`] = ` "Computed key is not allowed in JSON. (1:3) > 1 | {["foo"]:"bar"} @@ -38,18 +66,46 @@ exports[`snippet: #1 [json] format 2`] = ` Cause: Private name #bar is not defined. (1:20)" `; +exports[`snippet: #1 [json] format 3`] = ` +"Unexpected token (1:1) +> 1 | ; + | ^ +Cause: Unexpected token (1:0)" +`; + exports[`snippet: #1 [json-stringify] format 1`] = ` "Computed key is not allowed in JSON. (1:3) > 1 | {["foo"]:"bar"} | ^^^^^" `; +exports[`snippet: #1 [json-stringify] format 2`] = ` +"Unexpected token (1:1) +> 1 | ; + | ^ +Cause: Unexpected token (1:0)" +`; + exports[`snippet: #1 [json5] format 1`] = ` "Computed key is not allowed in JSON. (1:3) > 1 | {["foo"]:"bar"} | ^^^^^" `; +exports[`snippet: #1 [json5] format 2`] = ` +"Unexpected token (1:1) +> 1 | ; + | ^ +Cause: Unexpected token (1:0)" +`; + +exports[`snippet: #1 [jsonc] format 1`] = ` +"Unexpected token (1:1) +> 1 | ; + | ^ +Cause: Unexpected token (1:0)" +`; + exports[`snippet: #2 [json] format 1`] = ` "Operator '~' is not allowed in JSON. (1:9) > 1 | {"foo": ~1} @@ -63,18 +119,46 @@ exports[`snippet: #2 [json] format 2`] = ` Cause: 'await' is only allowed within async functions and at the top levels of modules. (1:13)" `; +exports[`snippet: #2 [json] format 3`] = ` +"Unexpected token (1:9) +> 1 | "string"; + | ^ +Cause: Unexpected token (1:8)" +`; + exports[`snippet: #2 [json-stringify] format 1`] = ` "Operator '~' is not allowed in JSON. (1:9) > 1 | {"foo": ~1} | ^^" `; +exports[`snippet: #2 [json-stringify] format 2`] = ` +"Unexpected token (1:9) +> 1 | "string"; + | ^ +Cause: Unexpected token (1:8)" +`; + exports[`snippet: #2 [json5] format 1`] = ` "Operator '~' is not allowed in JSON. (1:9) > 1 | {"foo": ~1} | ^^" `; +exports[`snippet: #2 [json5] format 2`] = ` +"Unexpected token (1:9) +> 1 | "string"; + | ^ +Cause: Unexpected token (1:8)" +`; + +exports[`snippet: #2 [jsonc] format 1`] = ` +"Unexpected token (1:9) +> 1 | "string"; + | ^ +Cause: Unexpected token (1:8)" +`; + exports[`snippet: #3 [json] format 1`] = ` "'LogicalExpression' is not allowed in JSON. (1:9) > 1 | {"foo": false || "bar"} @@ -87,18 +171,50 @@ exports[`snippet: #3 [json] format 2`] = ` | ^" `; +exports[`snippet: #3 [json] format 3`] = ` +"Unexpected token (2:1) + 1 | "string" +> 2 | ; + | ^ +Cause: Unexpected token (2:0)" +`; + exports[`snippet: #3 [json-stringify] format 1`] = ` "'LogicalExpression' is not allowed in JSON. (1:9) > 1 | {"foo": false || "bar"} | ^^^^^^^^^^^^^^" `; +exports[`snippet: #3 [json-stringify] format 2`] = ` +"Unexpected token (2:1) + 1 | "string" +> 2 | ; + | ^ +Cause: Unexpected token (2:0)" +`; + exports[`snippet: #3 [json5] format 1`] = ` "'LogicalExpression' is not allowed in JSON. (1:9) > 1 | {"foo": false || "bar"} | ^^^^^^^^^^^^^^" `; +exports[`snippet: #3 [json5] format 2`] = ` +"Unexpected token (2:1) + 1 | "string" +> 2 | ; + | ^ +Cause: Unexpected token (2:0)" +`; + +exports[`snippet: #3 [jsonc] format 1`] = ` +"Unexpected token (2:1) + 1 | "string" +> 2 | ; + | ^ +Cause: Unexpected token (2:0)" +`; + exports[`snippet: #4 [json] format 1`] = ` "'ArrowFunctionExpression' is not allowed in JSON. (1:9) > 1 | {"foo": () => {}} @@ -112,18 +228,46 @@ exports[`snippet: #4 [json] format 2`] = ` Cause: 'import' and 'export' may only appear at the top level. (1:13)" `; +exports[`snippet: #4 [json] format 3`] = ` +"Unexpected token (1:15) +> 1 | /* comment */[ + | ^ +Cause: Unexpected token (1:14)" +`; + exports[`snippet: #4 [json-stringify] format 1`] = ` "'ArrowFunctionExpression' is not allowed in JSON. (1:9) > 1 | {"foo": () => {}} | ^^^^^^^^" `; +exports[`snippet: #4 [json-stringify] format 2`] = ` +"Unexpected token (1:15) +> 1 | /* comment */[ + | ^ +Cause: Unexpected token (1:14)" +`; + exports[`snippet: #4 [json5] format 1`] = ` "'ArrowFunctionExpression' is not allowed in JSON. (1:9) > 1 | {"foo": () => {}} | ^^^^^^^^" `; +exports[`snippet: #4 [json5] format 2`] = ` +"Unexpected token (1:15) +> 1 | /* comment */[ + | ^ +Cause: Unexpected token (1:14)" +`; + +exports[`snippet: #4 [jsonc] format 1`] = ` +"Unexpected token (1:15) +> 1 | /* comment */[ + | ^ +Cause: Unexpected token (1:14)" +`; + exports[`snippet: #5 [json] format 1`] = ` "Expecting Unicode escape sequence \\uXXXX. (1:10) > 1 | packages\\the-hub\\cypress\\fixtures\\gridConfiguration.json @@ -138,6 +282,13 @@ exports[`snippet: #5 [json] format 2`] = ` Cause: Unexpected token (1:0)" `; +exports[`snippet: #5 [json] format 3`] = ` +"Unexpected token (1:14) +> 1 | /* comment */; + | ^ +Cause: Unexpected token (1:13)" +`; + exports[`snippet: #5 [json-stringify] format 1`] = ` "Expecting Unicode escape sequence \\uXXXX. (1:10) > 1 | packages\\the-hub\\cypress\\fixtures\\gridConfiguration.json @@ -145,6 +296,13 @@ exports[`snippet: #5 [json-stringify] format 1`] = ` Cause: Expecting Unicode escape sequence \\uXXXX. (1:9)" `; +exports[`snippet: #5 [json-stringify] format 2`] = ` +"Unexpected token (1:14) +> 1 | /* comment */; + | ^ +Cause: Unexpected token (1:13)" +`; + exports[`snippet: #5 [json5] format 1`] = ` "Expecting Unicode escape sequence \\uXXXX. (1:10) > 1 | packages\\the-hub\\cypress\\fixtures\\gridConfiguration.json @@ -152,6 +310,20 @@ exports[`snippet: #5 [json5] format 1`] = ` Cause: Expecting Unicode escape sequence \\uXXXX. (1:9)" `; +exports[`snippet: #5 [json5] format 2`] = ` +"Unexpected token (1:14) +> 1 | /* comment */; + | ^ +Cause: Unexpected token (1:13)" +`; + +exports[`snippet: #5 [jsonc] format 1`] = ` +"Unexpected token (1:14) +> 1 | /* comment */; + | ^ +Cause: Unexpected token (1:13)" +`; + exports[`snippet: #6 [json] format 1`] = ` "'BinaryExpression' is not allowed in JSON. (1:1) > 1 | 1+2 @@ -165,18 +337,46 @@ exports[`snippet: #6 [json] format 2`] = ` Cause: Unexpected token, expected "," (1:2)" `; +exports[`snippet: #6 [json] format 3`] = ` +"Unexpected token (1:22) +> 1 | /* comment */"string"; + | ^ +Cause: Unexpected token (1:21)" +`; + exports[`snippet: #6 [json-stringify] format 1`] = ` "'BinaryExpression' is not allowed in JSON. (1:1) > 1 | 1+2 | ^^^" `; +exports[`snippet: #6 [json-stringify] format 2`] = ` +"Unexpected token (1:22) +> 1 | /* comment */"string"; + | ^ +Cause: Unexpected token (1:21)" +`; + exports[`snippet: #6 [json5] format 1`] = ` "'BinaryExpression' is not allowed in JSON. (1:1) > 1 | 1+2 | ^^^" `; +exports[`snippet: #6 [json5] format 2`] = ` +"Unexpected token (1:22) +> 1 | /* comment */"string"; + | ^ +Cause: Unexpected token (1:21)" +`; + +exports[`snippet: #6 [jsonc] format 1`] = ` +"Unexpected token (1:22) +> 1 | /* comment */"string"; + | ^ +Cause: Unexpected token (1:21)" +`; + exports[`snippet: #7 [json] format 1`] = ` "Shorthand property is not allowed in JSON. (1:2) > 1 | {Infinity} @@ -189,18 +389,50 @@ exports[`snippet: #7 [json] format 2`] = ` | ^" `; +exports[`snippet: #7 [json] format 3`] = ` +"Unexpected token (2:1) + 1 | /* comment */"string" +> 2 | ; + | ^ +Cause: Unexpected token (2:0)" +`; + exports[`snippet: #7 [json-stringify] format 1`] = ` "Shorthand property is not allowed in JSON. (1:2) > 1 | {Infinity} | ^^^^^^^^" `; +exports[`snippet: #7 [json-stringify] format 2`] = ` +"Unexpected token (2:1) + 1 | /* comment */"string" +> 2 | ; + | ^ +Cause: Unexpected token (2:0)" +`; + exports[`snippet: #7 [json5] format 1`] = ` "Shorthand property is not allowed in JSON. (1:2) > 1 | {Infinity} | ^^^^^^^^" `; +exports[`snippet: #7 [json5] format 2`] = ` +"Unexpected token (2:1) + 1 | /* comment */"string" +> 2 | ; + | ^ +Cause: Unexpected token (2:0)" +`; + +exports[`snippet: #7 [jsonc] format 1`] = ` +"Unexpected token (2:1) + 1 | /* comment */"string" +> 2 | ; + | ^ +Cause: Unexpected token (2:0)" +`; + exports[`snippet: #8 [json] format 1`] = ` "Computed key is not allowed in JSON. (1:3) > 1 | {[key]: 1} @@ -213,18 +445,46 @@ exports[`snippet: #8 [json] format 2`] = ` | ^" `; +exports[`snippet: #8 [json] format 3`] = ` +"Unexpected token (1:1) +> 1 | #!/usr/bin/env node + | ^ +Cause: Unexpected token (1:0)" +`; + exports[`snippet: #8 [json-stringify] format 1`] = ` "Computed key is not allowed in JSON. (1:3) > 1 | {[key]: 1} | ^^^" `; +exports[`snippet: #8 [json-stringify] format 2`] = ` +"Unexpected token (1:1) +> 1 | #!/usr/bin/env node + | ^ +Cause: Unexpected token (1:0)" +`; + exports[`snippet: #8 [json5] format 1`] = ` "Computed key is not allowed in JSON. (1:3) > 1 | {[key]: 1} | ^^^" `; +exports[`snippet: #8 [json5] format 2`] = ` +"Unexpected token (1:1) +> 1 | #!/usr/bin/env node + | ^ +Cause: Unexpected token (1:0)" +`; + +exports[`snippet: #8 [jsonc] format 1`] = ` +"Unexpected token (1:1) +> 1 | #!/usr/bin/env node + | ^ +Cause: Unexpected token (1:0)" +`; + exports[`snippet: #9 [json] format 1`] = ` "Computed key is not allowed in JSON. (1:3) > 1 | {[key()]: 1} @@ -237,18 +497,50 @@ exports[`snippet: #9 [json] format 2`] = ` | ^" `; +exports[`snippet: #9 [json] format 3`] = ` +"Unexpected token (1:1) +> 1 | #!/usr/bin/env node + | ^ + 2 | [] +Cause: Unexpected token (1:0)" +`; + exports[`snippet: #9 [json-stringify] format 1`] = ` "Computed key is not allowed in JSON. (1:3) > 1 | {[key()]: 1} | ^^^^^" `; +exports[`snippet: #9 [json-stringify] format 2`] = ` +"Unexpected token (1:1) +> 1 | #!/usr/bin/env node + | ^ + 2 | [] +Cause: Unexpected token (1:0)" +`; + exports[`snippet: #9 [json5] format 1`] = ` "Computed key is not allowed in JSON. (1:3) > 1 | {[key()]: 1} | ^^^^^" `; +exports[`snippet: #9 [json5] format 2`] = ` +"Unexpected token (1:1) +> 1 | #!/usr/bin/env node + | ^ + 2 | [] +Cause: Unexpected token (1:0)" +`; + +exports[`snippet: #9 [jsonc] format 1`] = ` +"Unexpected token (1:1) +> 1 | #!/usr/bin/env node + | ^ + 2 | [] +Cause: Unexpected token (1:0)" +`; + exports[`snippet: #10 [json] format 1`] = ` "Computed key is not allowed in JSON. (1:3) > 1 | {['CallExpression']: 1} diff --git a/tests/format/misc/errors/json/format.test.js b/tests/format/misc/errors/json/format.test.js index 131b43ff2..b80f424ac 100644 --- a/tests/format/misc/errors/json/format.test.js +++ b/tests/format/misc/errors/json/format.test.js @@ -101,3 +101,22 @@ runFormatTest( }, ["json"], ); + +runFormatTest( + { + importMeta: import.meta, + snippets: [ + "[", + ";", + '"string";', + '"string"\n;', + "/* comment */[", + "/* comment */;", + '/* comment */"string";', + '/* comment */"string"\n;', + "#!/usr/bin/env node", + "#!/usr/bin/env node\n[]", + ], + }, + ["json", "json5", "jsonc", "json-stringify"], +);