diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..cf0ac02 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "bierner.comment-tagged-templates" + ] +} diff --git a/package.json b/package.json index 0ba15fd..581c87b 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,11 @@ "type": "boolean", "description": "Wether to enable all types of code fixes for individual problems", "default": true + }, + "insertMissingCommaOnEnter": { + "type": "boolean", + "default": true, + "markdownDescription": "Insert missing comma after \"Enter\". Works only if JSON line ends with `}`, `]`, `\"`, `true`/`false` or number" } } } @@ -109,6 +114,7 @@ "@zardoy/tsconfig": "^1.2.2", "chokidar-cli": "^3.0.0", "cross-env": "^7.0.3", + "escape-string-regexp": "4.0.0", "eslint": "^8.18.0", "eslint-config-zardoy": "^0.2.11", "mocha": "^10.1.0", @@ -117,9 +123,11 @@ }, "dependencies": { "@vscode/test-electron": "^2.2.0", + "@zardoy/utils": "^0.0.10", "chai": "^4.3.7", "glob": "^8.0.3", "string-dedent": "^3.0.1", + "strip-json-comments": "^5.0.0", "vscode-framework": "^0.0.18" }, "prettier": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47c0b03..f370eef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,23 +10,28 @@ specifiers: '@types/vscode': ^1.62.0 '@vscode/test-electron': ^2.2.0 '@zardoy/tsconfig': ^1.2.2 + '@zardoy/utils': ^0.0.10 chai: ^4.3.7 chokidar-cli: ^3.0.0 cross-env: ^7.0.3 + escape-string-regexp: 4.0.0 eslint: ^8.18.0 eslint-config-zardoy: ^0.2.11 glob: ^8.0.3 mocha: ^10.1.0 rimraf: ^3.0.2 string-dedent: ^3.0.1 + strip-json-comments: ^5.0.0 typescript: ^4.5.2 vscode-framework: ^0.0.18 dependencies: '@vscode/test-electron': 2.2.0 + '@zardoy/utils': 0.0.10 chai: 4.3.7 glob: 8.0.3 string-dedent: 3.0.1 + strip-json-comments: 5.0.0 vscode-framework: 0.0.18_l3izlp2w2o7lntemhxm5k54lqq devDependencies: @@ -37,6 +42,7 @@ devDependencies: '@zardoy/tsconfig': 1.2.2_typescript@4.5.2 chokidar-cli: 3.0.0 cross-env: 7.0.3 + escape-string-regexp: 4.0.0 eslint: 8.18.0 eslint-config-zardoy: 0.2.11_5bodlipddqnyzje6uwgbuntqpu mocha: 10.1.0 @@ -654,6 +660,16 @@ packages: typescript: 4.5.2 dev: true + /@zardoy/utils/0.0.10: + resolution: {integrity: sha512-Tgk1RPmKl2HMHDOIoFwrRcPQWgSfoIhJZiatR18GVvvjxQ0zZNSR7ZIT6HLHBDXNsFA2aanyQv4GTPe1V8+iMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + escape-string-regexp: 5.0.0 + lodash.compact: 3.0.1 + rambda: 6.9.0 + type-fest: 2.8.0 + dev: false + /accepts/1.3.7: resolution: {integrity: sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==} engines: {node: '>= 0.6'} @@ -1039,7 +1055,7 @@ packages: engines: {node: '>= 8.10.0'} hasBin: true dependencies: - chokidar: 3.5.3 + chokidar: 3.5.2 lodash.debounce: 4.0.8 lodash.throttle: 4.1.1 yargs: 13.3.2 @@ -1058,7 +1074,6 @@ packages: readdirp: 3.6.0 optionalDependencies: fsevents: 2.3.2 - dev: false /chokidar/3.5.3: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} @@ -1137,7 +1152,7 @@ packages: color-name: 1.1.4 /color-name/1.1.3: - resolution: {integrity: sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=} + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} /color-name/1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -1706,6 +1721,11 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + /escape-string-regexp/5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + dev: false + /escodegen/1.14.3: resolution: {integrity: sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==} engines: {node: '>=4.0'} @@ -1913,7 +1933,7 @@ packages: eslint-plugin-es: 3.0.1_eslint@8.18.0 eslint-utils: 2.1.0 ignore: 5.1.9 - minimatch: 3.1.2 + minimatch: 3.0.4 resolve: 1.20.0 semver: 6.3.0 dev: true @@ -2437,7 +2457,7 @@ packages: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 5.1.0 + minimatch: 5.0.1 once: 1.4.0 dev: false @@ -2812,7 +2832,7 @@ packages: dev: true /isarray/1.0.0: - resolution: {integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=} + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} dev: false /isarray/2.0.5: @@ -2886,7 +2906,7 @@ packages: engines: {node: '>=6'} hasBin: true dependencies: - minimist: 1.2.5 + minimist: 1.2.6 dev: true /jsonc-parser/3.0.0: @@ -3051,6 +3071,10 @@ packages: dependencies: p-locate: 5.0.0 + /lodash.compact/3.0.1: + resolution: {integrity: sha512-2ozeiPi+5eBXW1CLtzjk8XQFhQOEMwwfxblqeq6EGyTxZJ1bPATqilY0e6g2SLQpP4KuMeuioBhEnWz5Pr7ICQ==} + dev: false + /lodash.debounce/4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} dev: true @@ -3169,6 +3193,12 @@ packages: engines: {node: '>=4'} dev: true + /minimatch/3.0.4: + resolution: {integrity: sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==} + dependencies: + brace-expansion: 1.1.11 + dev: true + /minimatch/3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -3179,14 +3209,6 @@ packages: engines: {node: '>=10'} dependencies: brace-expansion: 2.0.1 - dev: true - - /minimatch/5.1.0: - resolution: {integrity: sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==} - engines: {node: '>=10'} - dependencies: - brace-expansion: 2.0.1 - dev: false /minimist/1.2.5: resolution: {integrity: sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==} @@ -3463,7 +3485,7 @@ packages: dev: false /p-try/1.0.0: - resolution: {integrity: sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=} + resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} engines: {node: '>=4'} dev: true @@ -3660,7 +3682,7 @@ packages: dev: false /process-nextick-args/1.0.7: - resolution: {integrity: sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=} + resolution: {integrity: sha512-yN0WQmuCX63LP/TMvAg31nvT6m4vDqJEiiv2CAZqWOGNWutc9DfDk1NPYYmKUFmaVM2UwDowH4u5AHWYP/jxKw==} dev: false /process-nextick-args/2.0.1: @@ -3725,6 +3747,10 @@ packages: through2: 2.0.5 dev: false + /rambda/6.9.0: + resolution: {integrity: sha512-yosVdGg1hNGkXPzqGiOYNEpXKjEOxzUCg2rB0l+NKdyCaSf4z+i5ojbN0IqDSezMMf71YEglI+ZUTgTffn5afw==} + dev: false + /randombytes/2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} dependencies: @@ -4131,6 +4157,11 @@ packages: engines: {node: '>=8'} dev: true + /strip-json-comments/5.0.0: + resolution: {integrity: sha512-V1LGY4UUo0jgwC+ELQ2BNWfPa17TIuwBLg+j1AA/9RPzKINl1lhxVEu2r+ZTTO8aetIsUzE5Qj6LMSBkoGYKKw==} + engines: {node: '>=14.16'} + dev: false + /supports-color/5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -4458,7 +4489,7 @@ packages: dev: false /util-deprecate/1.0.2: - resolution: {integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=} + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: false /v8-compile-cache/2.3.0: diff --git a/src/commaOnEnter.ts b/src/commaOnEnter.ts new file mode 100644 index 0000000..6ab4d48 --- /dev/null +++ b/src/commaOnEnter.ts @@ -0,0 +1,61 @@ +import * as vscode from 'vscode' +import stripJsonComments from 'strip-json-comments' +import { oneOf } from '@zardoy/utils' +import { getExtensionSetting } from 'vscode-framework' +import { getTextByLine, isEoL, isNumber, startsWithComment } from './utils' + +export default () => { + vscode.workspace.onDidChangeTextDocument(({ contentChanges, document, reason }) => { + if (!getExtensionSetting('insertMissingCommaOnEnter')) return + if (!vscode.languages.match(['json', 'jsonc'], document) || contentChanges.length === 0) return + + const editor = vscode.window.activeTextEditor + + contentChanges = [...contentChanges].sort((a, b) => a.range.start.compareTo(b.range.start)) + + if ( + document.uri !== editor?.document.uri || + ['output'].includes(editor.document.uri.scheme) || + vscode.workspace.fs.isWritableFileSystem(document.uri.scheme) === false || + oneOf(reason, vscode.TextDocumentChangeReason.Undo, vscode.TextDocumentChangeReason.Redo) + // eslint-disable-next-line curly + ) { + return + } + + if (contentChanges.some(change => !isEoL(change.text))) return + + let fileContentWithoutComments: string + + void editor.edit( + edit => { + for (const [i, change] of contentChanges.entries()) { + const prevLine = document.lineAt(change.range.start.line + i) + + const currentLineText = document.lineAt(prevLine.lineNumber + 1).text.trim() + + const isCurrentLineEmpty = currentLineText.trim() === '' + const isCurrentLineStartsWithComment = startsWithComment(currentLineText) + + if (!isCurrentLineEmpty && !isCurrentLineStartsWithComment) continue + + fileContentWithoutComments ??= stripJsonComments(document.getText()) + + const prevLineWithoutComments = getTextByLine(fileContentWithoutComments, prevLine.lineNumber)!.trimEnd() + + if (!prevLineWithoutComments) continue + + const isGoodEnding = + ['}', '"', ']', 'true', 'false'].some(char => prevLineWithoutComments.endsWith(char)) || isNumber(prevLineWithoutComments.at(-1)!) + + if (!isGoodEnding) continue + + const insertPostion = new vscode.Position(prevLine.lineNumber, prevLineWithoutComments.length) + + edit.insert(insertPostion, ',') + } + }, + { undoStopAfter: false, undoStopBefore: false }, + ) + }) +} diff --git a/src/extension.ts b/src/extension.ts index 8af1da7..92c6f46 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode' import { getExtensionSetting, registerExtensionCommand } from 'vscode-framework' +import registerCommaOnEnter from './commaOnEnter' export const activate = () => { vscode.languages.registerCodeActionsProvider( @@ -168,4 +169,6 @@ export const activate = () => { if (!workspaceEdit) return await vscode.workspace.applyEdit(workspaceEdit) }) + + registerCommaOnEnter() } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..ec61257 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,15 @@ +export function getTextByLine(text: string, line: number) { + return text.split('\n').at(line) +} + +export function isNumber(text: string) { + return !Number.isNaN(Number.parseInt(text, 10)) +} + +export function isEoL(text: string) { + return text.startsWith('\n') && text.trim() === '' +} + +export function startsWithComment(text: string) { + return text.startsWith('//') || text.startsWith('/*') +} diff --git a/test/integration/index.ts b/test/integration/index.ts index 68779dc..c06ee03 100644 --- a/test/integration/index.ts +++ b/test/integration/index.ts @@ -10,10 +10,17 @@ export const run = async () => { }) const testsRoot = join(__dirname, './suite') await new Promise(resolve => { - glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { + glob('**/**.test.js', { cwd: testsRoot }, (err, files: string[]) => { if (err) throw err - for (const file of files) mocha.addFile(join(testsRoot, file)) + const fixedOrderFiles = ['jsonFixes.test.js', 'commaOnEnter.test.js'] + + for (const file of fixedOrderFiles) { + mocha.addFile(join(testsRoot, file)) + } + for (const file of files.filter(file => !fixedOrderFiles.includes(file))) { + mocha.addFile(join(testsRoot, file)) + } mocha.run(failures => { if (failures > 0) { diff --git a/test/integration/suite/commaOnEnter.test.ts b/test/integration/suite/commaOnEnter.test.ts new file mode 100644 index 0000000..8457f81 --- /dev/null +++ b/test/integration/suite/commaOnEnter.test.ts @@ -0,0 +1,92 @@ +import * as vscode from 'vscode' + +import { expect } from 'chai' +import { clearEditorText, stringWithPositions } from './utils' +import dedent from 'string-dedent' + +describe('Comma on Enter', () => { + let document: vscode.TextDocument + let editor: vscode.TextEditor + + // positions markers description: + // /*|*/ - valid position to insert comma after ctrl+Enter (adding empty newline below) + // /*$*/ - invalid position to insert comma after ctrl+Enter (adding empty newline below) + // use editor text selection highlighting for faster navigation between positions + const FULL_FIXTURE = dedent/* json */ ` + { + "key1": 43,/*$*/ + "key2": 43/*|*/ + /* key description */ "key3": "test"/*|*/ /* test comment */ // another commend + /*$*/ + "key4": true/*|*/ + "key4": false/*|*/ + "key5": []/*|*/ + "key6": {/*$*/ + }/*|*/ + "key7": "value",/*$*/ + // Comment with number 3/*$*/ + } + ` + + const [FULL_FIXTURE_CONTENT, FIXTURE_POSITIONS] = stringWithPositions(FULL_FIXTURE, ['/*|*/', '/*$*/']) + + before(done => { + void vscode.workspace + .openTextDocument({ + content: FULL_FIXTURE_CONTENT, + language: 'jsonc', + }) + .then(async newDocument => { + document = newDocument + editor = await vscode.window.showTextDocument(document) + if (process.env.CI) { + await new Promise(resolve => { + setTimeout(resolve, 1000) + }) + } + }) + .then(done) + }) + + const testPosition = (num: number, offset: number, isExpected: boolean) => { + it(`${isExpected ? 'Valid' : 'Invalid'} position ${num}`, async () => { + await clearEditorText(editor, FULL_FIXTURE_CONTENT) + const pos = document.positionAt(offset) + editor.selection = new vscode.Selection(pos, pos) + await vscode.commands.executeCommand('editor.action.insertLineAfter') + if (isExpected) { + // wait for expected changes to be done + await new Promise(resolve => { + const { dispose } = vscode.workspace.onDidChangeTextDocument(({ document: changedDocument }) => { + if (changedDocument !== document) return + dispose() + resolve() + }) + }) + // assert that comma is inserted at expected marker position + expect(document.getText().at(offset)).to.equal(',') + } else { + // todo would be better to remove timeout in favor of cleaner solution + await new Promise(resolve => { + setTimeout(resolve, 40) + }) + // assert document text remains unchanged (except empty newline that we just added) + expect( + document + .getText() + .split('\n') + .filter((x, i) => i !== editor.selection.active.line) + .join('\n'), + ).to.equal(FULL_FIXTURE_CONTENT) + } + }) + } + + for (const [i, validPosition] of FIXTURE_POSITIONS['/*|*/'].entries()) { + testPosition(i, validPosition, true) + } + + for (const [i, invalidPosition] of FIXTURE_POSITIONS['/*$*/'].entries()) { + testPosition(i, invalidPosition, false) + } +}) diff --git a/test/integration/suite/jsonFixes.test.ts b/test/integration/suite/jsonFixes.test.ts index 8aa7964..e9e7316 100644 --- a/test/integration/suite/jsonFixes.test.ts +++ b/test/integration/suite/jsonFixes.test.ts @@ -28,7 +28,9 @@ describe('Json Fixes', () => { for (const [name, content] of Object.entries(jsonFixesFixtures)) { it(`Fix JSON issues: ${name}`, async () => { const diagnosticsChangePromise = new Promise(resolve => { - vscode.languages.onDidChangeDiagnostics(() => { + vscode.languages.onDidChangeDiagnostics(({ uris }) => { + if (!uris.map(uri => uri.toString()).includes(document.uri.toString())) return + if (vscode.languages.getDiagnostics(document.uri).length === 0) return resolve() }) }) diff --git a/test/integration/suite/utils.ts b/test/integration/suite/utils.ts index 5a2f662..e6640f7 100644 --- a/test/integration/suite/utils.ts +++ b/test/integration/suite/utils.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode' import stringDedent from 'string-dedent' +import escapeStringRegexp from 'escape-string-regexp' export const clearEditorText = async (editor: vscode.TextEditor, resetContent = '') => { await new Promise(resolve => { @@ -23,3 +24,20 @@ export const clearEditorText = async (editor: vscode.TextEditor, resetContent = export const setupFixtureContent = async (editor: vscode.TextEditor, content: string) => { await clearEditorText(editor, stringDedent(content)) } + +export const stringWithPositions = (contents: string, replacements: T[]): [contents: string, positions: Record] => { + const cursorPositions = Object.fromEntries(replacements.map(replacement => [replacement, []])) as Record + const regex = new RegExp(`(?:${replacements.map(replacement => `(${escapeStringRegexp(replacement)})`).join('|')})`) + let currentMatch: RegExpExecArray | null | undefined + while ((currentMatch = regex.exec(contents))) { + const offset = currentMatch.index + const matchLength = currentMatch[0]!.length + contents = contents.slice(0, offset) + contents.slice(offset + matchLength) + for (const [i, val] of currentMatch.slice(1).entries()) { + if (!val) continue + cursorPositions[replacements[i]!]!.push(offset) + break + } + } + return [contents, cursorPositions] +}