diff --git a/README.md b/README.md index 11c4786..c048ab2 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,70 @@ $ pnpm jsdoc ../../../build/jsdoc/tomcat-servlet-testing-example-frontend/0.0.0/index.html ``` +## Development + +Uses [pnpm][] and [Vitest][] for building and testing. + +### JSON with comments + +You may want to configure your editor to recognize comments in JSON files, since +this project and [JSDoc][] both support them. + +#### [Vim][] + +Add to your `~/.vimrc`, based on advice from [Stack Overflow: Why does Vim +highlight all my JSON comments in red?][so-vim]: + +```vim +" With a little help from: +" - https://stackoverflow.com/questions/55669954/why-does-vim-highlight-all-my-json-comments-in-red +autocmd FileType json syntax match Comment "//.*" +autocmd FileType json syn region jsonBlockComment start="/\*" end="\*/" fold +autocmd FileType json hi def link jsonBlockComment Comment +``` + +#### [Visual Studio Code][] + +[VS Code supports JSON with Comments][vsc-jsonc]. Following the good advice from +[Stack Overflow: In VS Code, disable error "Comments are not permitted in +JSON"][so-vsc]: + +##### Method 1, verbatim from + +1. Click on the letters JSON in the bottom right corner. (A drop-down will + appear to "Select the Language Mode.") +2. Select "Configure File Association for '.json'..." +3. Type "jsonc" and press Enter. + +##### Method 2, nearly verbatim from + +Add this to your User Settings: + +```json +"files.associations": { + "*.json": "jsonc" +}, +``` + +If you don't already have a user settings file, you can create one. Hit +**⌘, or CTRL-,** (that's a comma) to open your settings, then hit +the Open Settings (JSON) button in the upper right. (It looks like a page with a +little curved arrow over it.) + +- Or invoke the **[Preferences: Open User Settings (JSON)][vsc-settings]** + command. + +#### [IntelliJ IDEA][] + +You can effectively enable comments by [extending the JSON5 syntax to all JSON +files][idea-json5]: + +1. In the Settings dialog (**⌘,** or **CTRL-,**), go to **Editor | File + Types**. +2. In the **Recognized File Types** list, select **JSON5**. +3. In the **File Name Patterns** area, click **+ (Add)** and type `.json` + in the **Add Wildcard** dialog that opens. + ## Background I developed this while experimenting with JSDoc on @@ -150,6 +214,15 @@ Node.js, JSDoc, and [npm packaging][] exercise as well. [pnpm]: https://pnpm.io/ [mbland/tomcat-servlet-testing-example]: https://github.com/mbland/tomcat-servlet-testing-example [Gradle]: https://gradle.org/ +[Vitest]: https://vitest.dev/ +[Vim]: https://www.vim.org/ +[so-vim]: https://stackoverflow.com/questions/55669954/why-does-vim-highlight-all-my-json-comments-in-red +[Visual Studio Code]: https://code.visualstudio.com/ +[vsc-jsonc]: https://code.visualstudio.com/Docs/languages/json#_json-with-comments +[so-vsc]: https://stackoverflow.com/questions/47834825/in-vs-code-disable-error-comments-are-not-permitted-in-json +[vsc-settings]: https://code.visualstudio.com/docs/getstarted/settings#_settingsjson +[IntelliJ IDEA]: https://www.jetbrains.com/idea/ +[idea-json5]: https://www.jetbrains.com/help/idea/json.html#ws_json_choose_version_procedure [Bash]: https://www.gnu.org/software/bash/ [Node.js]: https://nodejs.org/ [npm packaging]: https://docs.npmjs.com/packages-and-modules diff --git a/lib/index.js b/lib/index.js index b067cdf..ace065a 100644 --- a/lib/index.js +++ b/lib/index.js @@ -96,6 +96,9 @@ export async function getPath(cmdName, env, platform) { /** * Analyzes JSDoc CLI args to determine if JSDoc will generate docs and where + * + * Expects any JSON config files specified via -c or --configure to be UTF-8 + * encoded. * @param {string[]} argv - JSDoc command line interface arguments * @returns {Promise} analysis results */ @@ -108,22 +111,20 @@ export async function analyzeArgv(argv) { for (let i = 0; i !== argv.length; ++i) { const arg = argv[i] const nextArg = argv[i+1] - let config = null switch (arg) { case '-c': case '--configure': if (!cmdLineDest && validArg(nextArg)) { - config = JSON.parse(await readFile(nextArg)) - if (config.opts !== undefined) { - destination = config.opts.destination - } + const jsonSrc = await readFile(nextArg, {encoding: 'utf8'}) + const config = JSON.parse(stripJsonComments(jsonSrc)) + if (config.opts !== undefined) destination = config.opts.destination } break case '-d': case '--destination': - if (nextArg !== undefined && validArg(nextArg)) { + if (validArg(nextArg)) { destination = nextArg cmdLineDest = true } @@ -143,6 +144,79 @@ export async function analyzeArgv(argv) { return {willGenerate, destination} } +/** + * Replaces all comments and trailing commas in a JSON string with spaces + * + * Replaces rather than removes characters so that any JSON.parse() errors line + * up with the original. Preserves all existing whitespace as is, including + * newlines, carriage returns, and horizontal tabs. + * + * Details to be aware of: + * + * - Replaces trailing commas before the next ']' or '}' with a space. + * - "/* /" (without the space) is a complete block comment. (Since this + * documentation is in a block comment, the space is necessary here.) + * - If the next character after the end of a block comment ("* /" without the + * space) is: + * - '*': reopens the block comment + * - '/': opens a line comment + * + * If you really want to strip all the extra whitespace out: + * + * ```js + * JSON.stringify(JSON.parse(stripJsonComments(jsonStr))) + * ``` + * + * If you want to reformat it to your liking, e.g., using two space indents: + * + * ```js + * JSON.stringify(JSON.parse(stripJsonComments(jsonStr)), null, 2) + * ``` + * + * This function is necessary because the `jsdoc` command depends upon the + * extremely popular strip-json-comments npm. Otherwise analyzeArgs() would + * choke on config.json files containing comments. + * + * This implementation was inspired by strip-json-comments, but is a completely + * original implementation to avoid adding any dependencies. It may become its + * own separate package one day, likely scoped to avoid conflicts with + * strip-json-comments. + * @param {string} jsonStr - JSON text to strip + * @returns {string} jsonStr with comments, trailing commas replaced by space + */ +export function stripJsonComments(jsonStr) { + let inString = false + let escaped = false + let inComment = null + let result = '' + + for (let i = 0; i !== jsonStr.length; ++i) { + const prevChar = i !== 0 ? jsonStr[i-1] : null + let curChar = jsonStr[i] + + if (inString) { + inString = curChar !== '"' || escaped + escaped = curChar === '\\' && !escaped + } else if (inComment) { + if ((inComment === 'line' && curChar === '\n') || + (inComment === 'block' && prevChar === '*' && curChar === '/')) { + inComment = null + } + if (curChar.trimStart() !== '') curChar = ' ' + } else if (curChar === '"') { + inString = true + } else if (prevChar === '/') { + if (curChar === '/') inComment = 'line' + else if (curChar === '*') inComment = 'block' + if (inComment) curChar = ' ' // otherwise prevChar closed a block comment + } else if (curChar === '/') { + curChar = ' ' // opening a line or block comment + } + result += curChar + } + return result.replaceAll(/,(\s*)([\]}])/g, ' $1$2') +} + /** * Searches for filename within a directory tree via breadth-first search * @param {string} dirname - current directory to search diff --git a/test/fixtures/analyzeArgv/conf-bar.json b/test/fixtures/analyzeArgv/conf-bar.json index 128bdba..a05ecbc 100644 --- a/test/fixtures/analyzeArgv/conf-bar.json +++ b/test/fixtures/analyzeArgv/conf-bar.json @@ -1,3 +1,8 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ { "opts": { "destination": "bar" diff --git a/test/fixtures/analyzeArgv/conf-foo.json b/test/fixtures/analyzeArgv/conf-foo.json index 4a11c30..903cf6a 100644 --- a/test/fixtures/analyzeArgv/conf-foo.json +++ b/test/fixtures/analyzeArgv/conf-foo.json @@ -1,3 +1,8 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ { "opts": { "destination": "foo" diff --git a/test/fixtures/analyzeArgv/conf-undef-dest.json b/test/fixtures/analyzeArgv/conf-undef-dest.json index f3244f4..31cf15a 100644 --- a/test/fixtures/analyzeArgv/conf-undef-dest.json +++ b/test/fixtures/analyzeArgv/conf-undef-dest.json @@ -1,3 +1,8 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ { - "opts": {} + "opts": {} // This will override opts.destination, since opts is defined. } diff --git a/test/fixtures/analyzeArgv/conf-undef-opts.json b/test/fixtures/analyzeArgv/conf-undef-opts.json index 0967ef4..69773dc 100644 --- a/test/fixtures/analyzeArgv/conf-undef-opts.json +++ b/test/fixtures/analyzeArgv/conf-undef-opts.json @@ -1 +1,6 @@ -{} +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +{/* This won't override opts.destination, since opts is undefined. */} diff --git a/test/stripJsonComments.test.js b/test/stripJsonComments.test.js new file mode 100644 index 0000000..db0e2db --- /dev/null +++ b/test/stripJsonComments.test.js @@ -0,0 +1,196 @@ +/* eslint-env vitest */ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { stripJsonComments } from '../lib' +import { describe, expect, test } from 'vitest' + +describe('stripJsonComments', () => { + const BASIC_OBJECT = {opts: {destination: 'foo'}} + + test('handles empty string', () => { + expect(stripJsonComments('')).toBe('') + }) + + test('doesn\'t modify basic object without comments', () => { + const orig = JSON.stringify(BASIC_OBJECT, null, 2) + + expect(stripJsonComments(orig)).toBe(orig) + }) + + test('doesn\'t modify properly escaped strings', () => { + const obj = { + opts: { + first: 'ignores escaped \\" before the end of the string', + second: 'ignores escaped \\ before the end of the string \\\\\\\\' + } + } + const orig = JSON.stringify(obj, null, 2) + + expect(stripJsonComments(orig)).toBe(orig) + }) + + test('doesn\'t modify strings containing comment patterns', () => { + const obj = { + opts: { + line: 'looks like a // line comment, but isn\'t', + block: 'looks like a /* block comment, */ but isn\'t' + } + } + const orig = JSON.stringify(obj, null, 2) + + expect(stripJsonComments(orig)).toBe(orig) + }) + + test('replaces line comments, preserves existing whitespace', () => { + const orig = [ + '// Frist', + '{//\tSecond', + ' // Third\r', + ' "opts": { // Fourth', + ' // Fifth', + ' "destination": "foo" // Sixth', + ' } // Seventh', + ' // Eighth', + '}// Ninth', + '// Tenth' + ].join('\n') + + const result = stripJsonComments(orig) + + expect(result).toBe([ + ' ', + '{ \t ', + ' \r', + ' "opts": { ', + ' ', + ' "destination": "foo" ', + ' } ', + ' ', + '} ', + ' ' + ].join('\n')) + expect(JSON.parse(result)).toStrictEqual(BASIC_OBJECT) + }) + + test('replaces block comments, preserves existing whitespace', () => { + const orig = [ + '/** Frist */', + '{/*\tSecond', + ' * Third\r', + '*/"opts": { /* Fourth', + ' Fifth*/', + '/*/ "destination": "foo"/* Sixth */', + ' } /* Seventh', + ' /*Eighth*/', + '}/* Ninth', + ' Tenth*/' + ].join('\n') + + const result = stripJsonComments(orig) + + expect(result).toBe([ + ' ', + '{ \t ', + ' \r', + ' "opts": { ', + ' ', + ' "destination": "foo" ', + ' } ', + ' ', + '} ', + ' ' + ].join('\n')) + expect(JSON.parse(result)).toStrictEqual(BASIC_OBJECT) + }) + + test('replaces mixed comments and trailing commas before ] or }', () => { + const orig = [ + '// Frist', + '{/* Second', + ' * //Third', + '*/"opts": { // Fourth', + ' //*Fifth', + ' "destinations": [', + ' "foo",', + ' "bar",', + ' "baz", /* Sixth, with trailing comma for future expansion */', + ' ],', // Not a JSON comment, but here's another trailing comma. + ' },// Seventh, also with trailing comma for future expansion', + ' /*Eighth*/', + '} /* Ninth', + ' Tenth*/' + ].join('\n') + + const result = stripJsonComments(orig) + + expect(result).toBe([ + ' ', + '{ ', + ' ', + ' "opts": { ', + ' ', + ' "destinations": [', + ' "foo",', + ' "bar",', + ' "baz" ', + ' ] ', + ' } ', + ' ', + '} ', + ' ' + ].join('\n')) + expect(JSON.parse(result)).toStrictEqual({ + opts: { destinations: ['foo', 'bar', 'baz'] } + }) + }) + + test('reopens block comment if character after "*/" is \'*\'', () => { + const orig = [ + '{/* Frist', + ' */*', + ' "opts": {', + ' "destination": "doesn\'t matter, because commented out"', + ' }*/', + '}' + ].join('\n') + + const result = stripJsonComments(orig) + + expect(result).toBe([ + '{ ', + ' ', + ' ', + ' ', + ' ', + '}' + ].join('\n')) + expect(JSON.parse(result)).toStrictEqual({}) + }) + + test('opens a line comment if character after "*/" is \'/\'', () => { + const orig = [ + '{/* Frist', + ' "opts": {', + ' "destination": "doesn\'t matter, because commented out"', + ' Still commented out here, but next line will open a line comment.', + ' *//}', + '}' + ].join('\n') + + const result = stripJsonComments(orig) + + expect(result).toBe([ + '{ ', + ' ', + ' ', + ' ', + ' ', + '}' + ].join('\n')) + expect(JSON.parse(result)).toStrictEqual({}) + }) +})