diff --git a/.changeset/curvy-zebras-grab.md b/.changeset/curvy-zebras-grab.md new file mode 100644 index 0000000..16f9975 --- /dev/null +++ b/.changeset/curvy-zebras-grab.md @@ -0,0 +1,9 @@ +--- +'annotation-comments': minor +--- + +Adds the `cleanCode()` option `allowCleaning`. + +By default, `cleanCode()` will clean all annotation comments. If you set `allowCleaning` to a function, you can now control which annotation comments are cleaned. + +The function will be called once per annotation comment, and is expected to return a boolean to indicate whether the comment should be cleaned or not. diff --git a/.changeset/thirty-carpets-sip.md b/.changeset/thirty-carpets-sip.md new file mode 100644 index 0000000..3c013f5 --- /dev/null +++ b/.changeset/thirty-carpets-sip.md @@ -0,0 +1,9 @@ +--- +'annotation-comments': minor +--- + +Renames the `cleanCode()` option `updateTargetRanges` to `updateCodeRanges`. + +The option was renamed because the function is now capable of updating all other code ranges referenced by the annotation comments (e.g. tag ranges, comment ranges, content ranges etc.) in addition to the target ranges. + +In combination with the `allowCleaning` and `removeAnnotationContents` options, this allows multi-step cleaning of the source code, where only a subset of annotation comments is cleaned in each step. This can be useful to create multiple versions of the source code, e.g. one for copying to the clipboard (where only the annotation tags are removed while keeping the rest of the annotation comments visible in the source code), and one for HTML output (where the entire annotation comments are removed so they can be rendered separately). diff --git a/packages/annotation-comments/src/core/clean.ts b/packages/annotation-comments/src/core/clean.ts index aa9450e..44cee54 100644 --- a/packages/annotation-comments/src/core/clean.ts +++ b/packages/annotation-comments/src/core/clean.ts @@ -1,15 +1,23 @@ -import { cloneRange, excludeRangesFromOuterRange, mergeIntersectingOrAdjacentRanges, rangesAreEqual, splitRangeByLines } from '../internal/ranges' +import { cloneRange, excludeRangesFromOuterRange, isEmptyRange, makeRangeEmpty, mergeIntersectingOrAdjacentRanges, rangesAreEqual, splitRangeByLines } from '../internal/ranges' import { excludeWhitespaceRanges, getTextContentInLine } from '../internal/text-content' -import type { AnnotatedCode, AnnotationComment, SourceLocation, SourceRange } from './types' +import type { AnnotatedCode, AnnotationComment, SourceRange } from './types' export type CleanCodeOptions = AnnotatedCode & { - removeAnnotationContents?: boolean | ((context: RemoveAnnotationContentsContext) => boolean) - updateTargetRanges?: boolean + /** + * An optional function that is called for each annotation comment. + * Its return value determines whether the annotation should be cleaned from the code. + */ + allowCleaning?: (context: CleanAnnotationContext) => boolean + removeAnnotationContents?: boolean | ((context: CleanAnnotationContext) => boolean) + /** + * Whether to update all ranges in the annotation comments after applying the changes. + */ + updateCodeRanges?: boolean handleRemoveLine?: (context: HandleRemoveLineContext) => boolean handleEditLine?: (context: HandleEditLineContext) => boolean } -export type RemoveAnnotationContentsContext = { +export type CleanAnnotationContext = { comment: AnnotationComment } @@ -36,12 +44,18 @@ export type EditLine = { type SourceChange = RemoveLine | EditLine export function cleanCode(options: CleanCodeOptions) { - const { codeLines, annotationComments, removeAnnotationContents = false, updateTargetRanges = true, handleRemoveLine, handleEditLine } = options + const { codeLines, annotationComments, removeAnnotationContents = false, updateCodeRanges = true, handleRemoveLine, handleEditLine } = options - // Go through all annotation comments and collect an array of ranges to be removed + // Create a subset of annotation comments to clean + const commentsToClean = annotationComments.filter((comment) => typeof options.allowCleaning !== 'function' || options.allowCleaning({ comment })) + + // Go through all annotation comments to clean and collect an array of ranges to be removed const rangesToBeRemoved: SourceRange[] = [] - annotationComments.forEach((annotationComment) => { - const hasContents = annotationComment.contents.length > 0 + commentsToClean.forEach((annotationComment) => { + if (isEmptyRange(annotationComment.annotationRange)) return + + // Determine whether to remove the entire annotation or just the tag + const hasContents = annotationComment.contentRanges.length && annotationComment.contents.length const removeEntireAnnotation = !hasContents || (typeof removeAnnotationContents === 'function' ? removeAnnotationContents({ comment: annotationComment }) : removeAnnotationContents) const rangeToBeRemoved = cloneRange(removeEntireAnnotation ? annotationComment.annotationRange : annotationComment.tag.range) @@ -78,7 +92,7 @@ export function cleanCode(options: CleanCodeOptions) { // Remove any parent comments that would be empty after removing the annotations const handledRanges: SourceRange[] = [] - annotationComments.forEach(({ commentRange, commentInnerRange }) => { + commentsToClean.forEach(({ commentRange, commentInnerRange }) => { if (handledRanges.some((range) => rangesAreEqual(range, commentInnerRange))) return handledRanges.push(commentInnerRange) // If the outer range is already in the list of ranges to be removed, skip this comment @@ -105,44 +119,66 @@ export function cleanCode(options: CleanCodeOptions) { if (!handleRemoveLine || !handleRemoveLine({ codeLines, ...change })) { codeLines.splice(change.lineIndex, 1) } - if (updateTargetRanges) updateTargetRangesAfterRemoveLine(annotationComments, change) + if (updateCodeRanges) updateCodeRangesAfterChange(annotationComments, change) } else { if (!handleEditLine || !handleEditLine({ codeLines, ...change })) { const line = codeLines[change.lineIndex] codeLines[change.lineIndex] = line.slice(0, change.startColumn) + (change.newText ?? '') + line.slice(change.endColumn) } - if (updateTargetRanges) updateTargetRangesAfterEditLine(annotationComments, change) + if (updateCodeRanges) updateCodeRangesAfterChange(annotationComments, change) } }) } -function updateTargetRangesAfterRemoveLine(annotationComments: AnnotationComment[], change: RemoveLine) { - annotationComments.forEach(({ targetRanges }) => { - targetRanges.forEach((targetRange, index) => { - if (targetRange.start.line === change.lineIndex) { - targetRanges.splice(index, 1) - } else if (targetRange.start.line > change.lineIndex) { - targetRange.start.line-- - targetRange.end.line-- - } - }) +function updateCodeRangesAfterChange(annotationComments: AnnotationComment[], change: RemoveLine | EditLine) { + annotationComments.forEach((comment) => { + updateCodeRange(comment.tag.range, change) + updateCodeRange(comment.annotationRange, change) + updateCodeRange(comment.commentRange, change) + updateCodeRange(comment.commentInnerRange, change) + updateCodeRanges(comment.contentRanges, change) + updateCodeRanges(comment.targetRanges, change) }) } -function updateTargetRangesAfterEditLine(annotationComments: AnnotationComment[], change: EditLine) { - annotationComments.forEach(({ targetRanges }) => { - targetRanges.forEach((targetRange) => { - updateLocationIfNecessary(targetRange.start, change) - updateLocationIfNecessary(targetRange.end, change) - }) - }) +function updateCodeRanges(ranges: SourceRange[], change: RemoveLine | EditLine) { + ranges.forEach((range) => updateCodeRange(range, change)) + for (let i = ranges.length - 1; i >= 0; i--) { + if (isEmptyRange(ranges[i])) ranges.splice(i, 1) + } } -function updateLocationIfNecessary(location: SourceLocation, change: EditLine) { - if (location.line !== change.lineIndex) return - if (!location.column || change.startColumn > location.column) return - const changeDelta = (change.newText?.length ?? 0) - (change.endColumn - change.startColumn) - location.column += changeDelta +export function updateCodeRange(range: SourceRange, change: RemoveLine | EditLine) { + const { start, end } = range + const changeLine = change.lineIndex + if (change.editType === 'removeLine') { + // Range was completely inside the removed line and is now empty + if (start.line === changeLine && end.line === changeLine) return makeRangeEmpty(range) + // Range ended at the removed line, so it now ends at the end of the previous line + if (end.line === changeLine) range.end = { line: changeLine - 1 } + // Range ended after the removed line, so keep column, but move line up + if (end.line > changeLine) end.line-- + // Range started at the removed line, so it now starts at the beginning of the new line + if (start.line === changeLine) range.start = { line: changeLine } + // Range started after the removed line, so keep column, but move line up + if (start.line > changeLine) start.line-- + } else { + // Ignore inline edits that do not affect the start or end line of the range + if (start.line !== changeLine && end.line !== changeLine) return + const changeDelta = change.endColumn - change.startColumn + (change.newText?.length ?? 0) + const rangeStartColumn = start.column ?? 0 + const rangeEndColumn = end.column ?? Infinity + // If the inline edit completely covers the range, the range is now empty + if (start.line === end.line && change.startColumn <= rangeStartColumn && change.endColumn >= rangeEndColumn) return makeRangeEmpty(range) + // Range started at the edited line after the edit, so adjust its start column + if (start.line === changeLine && change.startColumn < rangeStartColumn) { + start.column = rangeStartColumn - Math.min(changeDelta, rangeStartColumn - change.startColumn) + } + // Range ended at the edited line after the edit, so adjust its end column + if (end.line === changeLine && change.startColumn < rangeEndColumn) { + end.column = rangeEndColumn === Infinity ? undefined : rangeEndColumn - Math.min(changeDelta, rangeEndColumn - change.startColumn) + } + } } function getRangeRemovalChanges(codeLines: string[], range: SourceRange): SourceChange[] { diff --git a/packages/annotation-comments/src/internal/ranges.ts b/packages/annotation-comments/src/internal/ranges.ts index 953e299..eafd168 100644 --- a/packages/annotation-comments/src/internal/ranges.ts +++ b/packages/annotation-comments/src/internal/ranges.ts @@ -14,6 +14,15 @@ export function createRange(options: { codeLines: string[]; start: SourceLocatio return range } +export function makeRangeEmpty(range: SourceRange) { + range.end = { line: range.start.line, column: range.start.column ?? 0 } +} + +export function isEmptyRange(range: SourceRange): boolean { + if (range.start.line !== range.end.line) return false + return range.end.column === 0 || range.end.column === (range.start.column ?? 0) +} + /** * Returns a copy of the given source range. */ @@ -117,7 +126,10 @@ export function mergeIntersectingOrAdjacentRanges(ranges: SourceRange[]): Source } // If the new range starts inside or right at the end of the current one, // extend the current range if needed - if (compareRanges(newRange, currentRange, 'start', 'end') <= 0) { + if ( + compareRanges(newRange, currentRange, 'start', 'end') <= 0 || + (currentRange.end.line + 1 == newRange.start.line && currentRange.end.column === undefined && !newRange.start.column) + ) { if (compareRanges(newRange, currentRange, 'end') > 0) currentRange.end = newRange.end continue } @@ -137,7 +149,7 @@ export function mergeIntersectingOrAdjacentRanges(ranges: SourceRange[]): Source * and full line ranges. The array can also be empty if the outer range is completely covered * by the exclusions. */ -export function excludeRangesFromOuterRange(options: { codeLines: string[]; outerRange: SourceRange; rangesToExclude: SourceRange[] }): SourceRange[] { +export function excludeRangesFromOuterRange(options: { codeLines?: string[] | undefined; outerRange: SourceRange; rangesToExclude: SourceRange[] }): SourceRange[] { const { codeLines, outerRange, rangesToExclude } = options const remainingRanges: SourceRange[] = splitRangeByLines(outerRange) @@ -145,7 +157,7 @@ export function excludeRangesFromOuterRange(options: { codeLines: string[]; oute exclusionsSplitByLine.forEach((exclusion) => { const lineIndex = exclusion.start.line - const lineLength = codeLines[lineIndex].length + const lineLength = codeLines?.[lineIndex].length ?? Infinity const exclusionStartColumn = exclusion.start.column ?? 0 const exclusionEndColumn = exclusion.end.column ?? lineLength for (let i = remainingRanges.length - 1; i >= 0; i--) { diff --git a/packages/annotation-comments/test/clean.test.ts b/packages/annotation-comments/test/clean.test.ts index d22dd1f..9a11d41 100644 --- a/packages/annotation-comments/test/clean.test.ts +++ b/packages/annotation-comments/test/clean.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from 'vitest' +import type { AnnotationComment } from '../src/core/types' import { parseAnnotationComments } from '../src/core/parse' import { cleanCode, CleanCodeOptions } from '../src/core/clean' -import { splitCodeLines } from './utils' +import { formatAnnotationComment, formatAnnotationComments, getArrayPermutations, splitCodeLines } from './utils' describe('cleanCode()', () => { describe('Cleans single-line annotation comments', () => { @@ -173,7 +174,7 @@ describe('cleanCode()', () => { // Expect full comment to be removed `console.log('Inserted line')`, ] - validateCleanedCode(lines, expectedResult, true) + validateCleanedCode(lines, expectedResult, { removeAnnotationContents: true }) }) test('After code', () => { const lines = [ @@ -187,7 +188,7 @@ describe('cleanCode()', () => { `console.log('Inserted line')`, `testCode()`, ] - validateCleanedCode(lines, expectedResult, true) + validateCleanedCode(lines, expectedResult, { removeAnnotationContents: true }) }) test('After a regular comment on the same line', () => { const lines = [ @@ -201,7 +202,7 @@ describe('cleanCode()', () => { `console.log('Inserted line') // Some comment`, `testCode() /* Another comment */`, ] - validateCleanedCode(lines, expectedResult, true) + validateCleanedCode(lines, expectedResult, { removeAnnotationContents: true }) }) test('On a separate line', () => { const lines = [ @@ -217,7 +218,7 @@ describe('cleanCode()', () => { `console.log('Inserted line')`, `testCode()`, ] - validateCleanedCode(lines, expectedResult, true) + validateCleanedCode(lines, expectedResult, { removeAnnotationContents: true }) }) test('Chained single-line annotations', () => { const lines = [ @@ -230,7 +231,7 @@ describe('cleanCode()', () => { // Expect both comments to be removed `testCode()`, ] - validateCleanedCode(lines, expectedResult, true) + validateCleanedCode(lines, expectedResult, { removeAnnotationContents: true }) }) }) }) @@ -631,7 +632,7 @@ describe('cleanCode()', () => { 'someCode()', 'someCode()', ] - validateCleanedCode(lines, expectedResult, true) + validateCleanedCode(lines, expectedResult, { removeAnnotationContents: true }) }) test('Comments containing annotations with content and other regular comment text', () => { const lines = [ @@ -675,7 +676,7 @@ describe('cleanCode()', () => { '*/', 'someCode()', ] - validateCleanedCode(lines, expectedResult, true) + validateCleanedCode(lines, expectedResult, { removeAnnotationContents: true }) }) test('Repeated single-line comment syntax', () => { const lines = [ @@ -700,7 +701,7 @@ describe('cleanCode()', () => { `// Regular comment text`, `testCode()`, ] - validateCleanedCode(lines, expectedResult, true) + validateCleanedCode(lines, expectedResult, { removeAnnotationContents: true }) }) test('JSDoc-style comments', () => { const lines = [ @@ -737,16 +738,509 @@ describe('cleanCode()', () => { ' */', 'someCode()', ] - validateCleanedCode(lines, expectedResult, true) + validateCleanedCode(lines, expectedResult, { removeAnnotationContents: true }) }) }) }) - function validateCleanedCode(code: string[], expectedCode: string[], removeAnnotationContents: CleanCodeOptions['removeAnnotationContents'] = false) { + describe('Supports allowCleaning() callback', () => { + describe('When cleaning single-line annotation comments', () => { + describe('Without content', () => { + test('After code', () => { + const lines = [ + // Single-line annotation syntax + `console.log('Inserted line') // [!test]`, + `console.log('Inserted line') // [!ins]`, + // Multi-line annotation syntax + `testCode() /* [!mark] */`, + `testCode() /* [!test] */`, + ] + const expectedResult = [ + // Expect both syntaxes to be removed + `console.log('Inserted line')`, + `console.log('Inserted line') // [!ins]`, + `testCode() /* [!mark] */`, + `testCode()`, + ] + validateCleanedCode(lines, expectedResult, { + allowCleaning: ({ comment }) => comment.tag.name === 'test', + }) + }) + test('After a regular comment on the same line', () => { + const lines = [ + // Single-line annotation syntax + `console.log('Inserted line') // Some comment // [!test]`, + `console.log('Inserted line') // Some comment // [!ins]`, + // Multi-line annotation syntax + `testCode() /* Another comment */ /* [!mark] */`, + `testCode() /* Another comment */ /* [!test] */`, + ] + const expectedResult = [ + // Expect both syntaxes to be removed + `console.log('Inserted line') // Some comment`, + `console.log('Inserted line') // Some comment // [!ins]`, + `testCode() /* Another comment */ /* [!mark] */`, + `testCode() /* Another comment */`, + ] + validateCleanedCode(lines, expectedResult, { + allowCleaning: ({ comment }) => comment.tag.name === 'test', + }) + }) + test('On a separate line', () => { + const lines = [ + // Single-line annotation syntax + `// [!ins]`, + `// [!test]`, + `console.log('Inserted line')`, + // Multi-line annotation syntax + `/* [!test] */`, + `/* [!mark] */`, + `testCode()`, + ] + const expectedResult = [ + // Expect both syntaxes to be removed + `// [!ins]`, + `console.log('Inserted line')`, + `/* [!mark] */`, + `testCode()`, + ] + validateCleanedCode(lines, expectedResult, { + allowCleaning: ({ comment }) => comment.tag.name === 'test', + }) + }) + test('Chained single-line annotations', () => { + const lines = [ + `console.log('Hello world')`, + // Two chained annotations at the end of a line + `testCode() // [!test] // [!mark]`, + `testCode() // [!mark] // [!test]`, + ] + const expectedResult = [ + `console.log('Hello world')`, + // Expect only the "test" annotations to be removed + `testCode() // [!mark]`, + `testCode() // [!mark]`, + ] + validateCleanedCode(lines, expectedResult, { + allowCleaning: ({ comment }) => comment.tag.name === 'test', + }) + }) + test('Removes the entire line if it becomes empty through cleaning', () => { + const lines = [ + // A line consisting of two chained annotations + `// [!ins] // [!test]`, + `// [!test] // [!test]`, + `console.log('Inserted test line')`, + // Another one with some extra indentation + ` // [!ins] // [!test]`, + ` // [!test] // [!test]`, + ` testCode()`, + ] + const expectedResult = [ + // Expect all annotation lines to be removed and the indentation to match + `// [!ins]`, + `console.log('Inserted test line')`, + ` // [!ins]`, + ` testCode()`, + ] + validateCleanedCode(lines, expectedResult, { + allowCleaning: ({ comment }) => comment.tag.name === 'test', + }) + }) + }) + describe('With content not selected for removal', () => { + test('Chained single-line annotations', () => { + const lines = [ + `console.log('Hello world')`, + // Two chained annotations at the end of a line + `testCode() // [!test] Content 1 // [!mark] Content 2`, + `testCode() // [!mark] Content 1 // [!test] Content 2`, + ] + const expectedResult = [ + `console.log('Hello world')`, + // Expect "test" tags to be removed + `testCode() // Content 1 // [!mark] Content 2`, + `testCode() // [!mark] Content 1 // Content 2`, + ] + validateCleanedCode(lines, expectedResult, { + allowCleaning: ({ comment }) => comment.tag.name === 'test', + }) + }) + }) + describe('With content selected for removal', () => { + test('Chained single-line annotations', () => { + const lines = [ + `console.log('Hello world')`, + // Two chained annotations at the end of a line + `testCode() // [!test] Content 1 // [!mark] Content 2`, + `testCode() // [!mark] Content 1 // [!test] Content 2`, + ] + const expectedResult = [ + `console.log('Hello world')`, + // Expect test comments to be removed + `testCode() // [!mark] Content 2`, + `testCode() // [!mark] Content 1`, + ] + validateCleanedCode(lines, expectedResult, { + removeAnnotationContents: true, + allowCleaning: ({ comment }) => comment.tag.name === 'test', + }) + }) + }) + }) + describe('When cleaning multi-line annotation comments', () => { + describe('Without content', () => { + test('Comments containing multiple annotations, but no other regular comment text', () => { + const lines = [ + // Test different line break locations + '/* [!test]', + ' [!ins]', + '*/', + 'someCode()', + '/*', + ' [!ins]', + ' [!test] */', + 'someCode()', + '/*', + ' [!test]', + ' [!ins]', + '*/', + 'someCode()', + // Additional empty lines around the tags + '/*', + '', + ' [!test]', + '', + ' [!ins]', + '', + '*/', + 'someCode()', + ] + const expectedResult = [ + // Expect all [!test] annotations to be removed + '/*', + ' [!ins]', + '*/', + 'someCode()', + '/*', + ' [!ins]', + '*/', + 'someCode()', + '/*', + ' [!ins]', + '*/', + 'someCode()', + '/*', + '', + ' [!ins]', + '', + '*/', + 'someCode()', + ] + validateCleanedCode(lines, expectedResult, { + allowCleaning: ({ comment }) => comment.tag.name === 'test', + }) + }) + }) + describe('With content not selected for removal', () => { + test('Comments containing annotations with content and other regular comment text', () => { + const lines = [ + // Test different line break locations + '/* Regular comment', + ' [!test] This is content', + '*/', + 'someCode()', + '/*', + ' Regular comment', + ' [!ins] This is content', + ' [!test] This is content */', + 'someCode()', + '/*', + ' Regular comment', + ' [!test] This is content', + '*/', + 'someCode()', + '/*', + ' Regular comment', + ' [!test] This is content', + ' [!ins] This is content', + '*/', + 'someCode()', + ] + const expectedResult = [ + // Expect all annotation tags to be removed + '/* Regular comment', + ' This is content', + '*/', + 'someCode()', + '/*', + ' Regular comment', + ' [!ins] This is content', + ' This is content */', + 'someCode()', + '/*', + ' Regular comment', + ' This is content', + '*/', + 'someCode()', + '/*', + ' Regular comment', + ' This is content', + ' [!ins] This is content', + '*/', + 'someCode()', + ] + validateCleanedCode(lines, expectedResult, { + allowCleaning: ({ comment }) => comment.tag.name === 'test', + }) + }) + test('JSDoc-style comments', () => { + const lines = [ + // Test different line break locations + '/** Regular comment', + ' * [!test] This is content', + ' */', + 'someCode()', + '/**', + ' * Regular comment', + ' * [!test] This is content', + ' */', + 'someCode()', + '/**', + ' * Regular comment', + ' * [!test] This is content', + ' * which spans multiple lines', + ' *', + ' * [!ins] This is content', + ' */', + 'someCode()', + ] + const expectedResult = [ + // Expect all annotation comments to be removed + '/** Regular comment', + ' * This is content', + ' */', + 'someCode()', + '/**', + ' * Regular comment', + ' * This is content', + ' */', + 'someCode()', + '/**', + ' * Regular comment', + ' * This is content', + ' * which spans multiple lines', + ' *', + ' * [!ins] This is content', + ' */', + 'someCode()', + ] + validateCleanedCode(lines, expectedResult, { + allowCleaning: ({ comment }) => comment.tag.name === 'test', + }) + }) + }) + describe('With content selected for removal', () => { + test('Comments containing annotations with content and other regular comment text', () => { + const lines = [ + // Test different line break locations + '/* Regular comment', + ' [!test] This is content', + '*/', + 'someCode()', + '/*', + ' Regular comment', + ' [!ins] This is content', + ' [!test] This is content */', + 'someCode()', + '/*', + ' Regular comment', + ' [!test] This is content', + '*/', + 'someCode()', + '/*', + ' Regular comment', + ' [!test] This is content', + ' [!ins] This is content', + '*/', + 'someCode()', + ] + const expectedResult = [ + // Expect annotations to be removed, but not the regular comment text + '/* Regular comment', + '*/', + 'someCode()', + '/*', + ' Regular comment', + ' [!ins] This is content', + '*/', + 'someCode()', + '/*', + ' Regular comment', + '*/', + 'someCode()', + '/*', + ' Regular comment', + ' [!ins] This is content', + '*/', + 'someCode()', + ] + validateCleanedCode(lines, expectedResult, { + removeAnnotationContents: true, + allowCleaning: ({ comment }) => comment.tag.name === 'test', + }) + }) + test('JSDoc-style comments', () => { + const lines = [ + // Test different line break locations + '/** Regular comment', + ' * [!test] This is content', + ' */', + 'someCode()', + '/**', + ' * Regular comment', + ' * [!test] This is content', + ' */', + 'someCode()', + '/**', + ' * Regular comment', + ' * [!test] This is content', + ' * which spans multiple lines', + ' *', + ' * [!ins] This is content', + ' */', + 'someCode()', + ] + const expectedResult = [ + // Expect all annotations to be removed, but not the regular comment text + '/** Regular comment', + ' */', + 'someCode()', + '/**', + ' * Regular comment', + ' */', + 'someCode()', + '/**', + ' * Regular comment', + ' * [!ins] This is content', + ' */', + 'someCode()', + ] + validateCleanedCode(lines, expectedResult, { + removeAnnotationContents: true, + allowCleaning: ({ comment }) => comment.tag.name === 'test', + }) + }) + }) + }) + }) + + function validateCleanedCode(code: string[], expectedCode: string[], options: Omit = {}) { const codeLines = Array.isArray(code) ? code : splitCodeLines(code) const { annotationComments, errorMessages } = parseAnnotationComments({ codeLines }) expect(errorMessages, 'Test code failed to parse without errors').toEqual([]) - cleanCode({ codeLines, annotationComments, removeAnnotationContents }) - expect(codeLines.join('\n'), 'Unexpected cleaned code result').toEqual(expectedCode.join('\n')) + + // First, test cleaning the annotations all at once + const singlePassAnnotations = cloneAnnotationComments(annotationComments) + const singlePassCleaningResult = [...codeLines] + cleanCode({ + ...options, + codeLines: singlePassCleaningResult, + annotationComments: singlePassAnnotations, + }) + expect(singlePassCleaningResult.join('\n'), `Unexpected single-pass code cleaning result`).toEqual(expectedCode.join('\n')) + + // Then, test cleaning all possible subsets of annotations in multiple passes + const allowedIndices = annotationComments + .map((comment, index) => { + const allowed = (typeof options.allowCleaning === 'function' ? options.allowCleaning({ comment }) : options.allowCleaning) ?? true + return allowed ? index : -1 + }) + .filter((index) => index >= 0) + for (let omitCount = 1; omitCount < allowedIndices.length - 1; omitCount++) { + const firstPassIndices = getArrayPermutations(allowedIndices.length, omitCount) + firstPassIndices.forEach((permutation) => { + const indices = permutation.map((index) => allowedIndices[index]) + // Prepare an array of indices that are not in the first pass for one-by-one cleaning + const oneByOneIndices = allowedIndices.filter((index) => !indices.includes(index)) + performMultiPassTest(indices, oneByOneIndices) + // Also test the reverse order + oneByOneIndices.reverse() + performMultiPassTest(indices, oneByOneIndices) + }) + } + + function performMultiPassTest(firstPassIndices: number[], oneByOneIndices: number[]) { + const multiPassAnnotations = cloneAnnotationComments(annotationComments) + const multiPassCleaningResult = [...codeLines] + const snapshots: { code: string; annotations: AnnotationComment[] }[] = [] + const takeSnapshot = () => { + snapshots.push({ + code: multiPassCleaningResult.join('\n'), + annotations: cloneAnnotationComments(multiPassAnnotations), + }) + } + const formatCode = (code: string) => + code + .split('\n') + .map((line, index) => `${index.toString().padStart(2, ' ')}: ${line}`) + .join('\n') + const formatSnapshots = () => + `\n\nSnapshots:\n\n${snapshots.map(({ code, annotations }) => `${formatCode(code)}\n\n${formatAnnotationComments(annotations)}`).join('\n\n---\n\n')}` + takeSnapshot() + + // First clean up to n-1 annotations in a batch + cleanCode({ + ...options, + codeLines: multiPassCleaningResult, + annotationComments: multiPassAnnotations, + allowCleaning: ({ comment }) => firstPassIndices.includes(multiPassAnnotations.indexOf(comment)), + }) + takeSnapshot() + expect( + multiPassCleaningResult.join('\n'), + `First pass ${JSON.stringify(firstPassIndices)} from ${multiPassAnnotations.length} comments in multi-pass code cleaning cleaned too much` + ).not.toEqual(expectedCode.join('\n')) + + // Now clean the rest one by one + oneByOneIndices.forEach((annotationIndex, oneByOneArrayIndex) => { + const annotation = multiPassAnnotations[annotationIndex] + let allowedCleaning = 0 + cleanCode({ + ...options, + codeLines: multiPassCleaningResult, + annotationComments: multiPassAnnotations, + allowCleaning: ({ comment }) => { + const result = comment === annotation + if (result) allowedCleaning++ + return result + }, + }) + takeSnapshot() + expect(allowedCleaning, 'Unexpected number of annotations allowed to clean').toEqual(1) + expect( + snapshots[snapshots.length - 2].code, + `One-by-one pass of annotation ${multiPassAnnotations.indexOf(annotation)} (${formatAnnotationComment(annotation)}) after batch ${JSON.stringify(firstPassIndices)} from ${multiPassAnnotations.length} comments in multi-pass code cleaning did not change the code.${formatSnapshots()}` + ).not.toEqual(snapshots[snapshots.length - 1].code) + if (oneByOneArrayIndex < oneByOneIndices.length - 1) { + expect( + multiPassCleaningResult.join('\n'), + `One-by-one pass of annotation ${multiPassAnnotations.indexOf(annotation)} (${formatAnnotationComment(annotation)}) after batch ${JSON.stringify(firstPassIndices)} from ${multiPassAnnotations.length} comments in multi-pass code cleaning cleaned too much.${formatSnapshots()}` + ).not.toEqual(expectedCode.join('\n')) + } + }) + expect( + multiPassCleaningResult.join('\n'), + `Unexpected final result after multi-pass cleaning test with batch ${JSON.stringify(firstPassIndices)} from ${multiPassAnnotations.length} comments.${formatSnapshots()}` + ).toEqual(expectedCode.join('\n')) + } + } + + function cloneAnnotationComments(input: AnnotationComment[]) { + return input.map((original) => { + const cloned = JSON.parse(JSON.stringify(original)) as AnnotationComment + if (original.commentSyntax.continuationLineStart) cloned.commentSyntax.continuationLineStart = new RegExp(original.commentSyntax.continuationLineStart.source) + return cloned + }) } }) diff --git a/packages/annotation-comments/test/utils.ts b/packages/annotation-comments/test/utils.ts index baa564b..a901020 100644 --- a/packages/annotation-comments/test/utils.ts +++ b/packages/annotation-comments/test/utils.ts @@ -1,7 +1,7 @@ import { expect } from 'vitest' -import type { AnnotationComment, AnnotationTag, SourceRange } from '../src/core/types' +import type { AnnotationComment, AnnotationTag, SourceLocation, SourceRange } from '../src/core/types' import { createGlobalRegExp, findRegExpMatchColumnRanges } from '../src/internal/regexps' -import { createRange } from '../src/internal/ranges' +import { createRange, isEmptyRange } from '../src/internal/ranges' export function splitCodeLines(code: string) { return code.trim().split(/\r?\n/) @@ -71,3 +71,52 @@ export function findRegExpTargetRanges(codeLines: string[], regExp: RegExp) { }) return ranges } + +/** + * Generates all possible permutations of an array of the given length + * with the specified number of elements omitted. Returns arrays of indices. + */ +export function getArrayPermutations(arrayLength: number, omitCount: number): number[][] { + if (omitCount < 0 || omitCount > arrayLength) throw new Error('Invalid omitCount value') + + const result: number[][] = [] + const generate = (start: number, currentSubset: number[]) => { + if (currentSubset.length === arrayLength - omitCount) { + result.push([...currentSubset]) + return + } + + for (let i = start; i < arrayLength; i++) { + currentSubset.push(i) + generate(i + 1, currentSubset) + currentSubset.pop() + } + } + + generate(0, []) + return result +} + +export function formatSourceLocation(location: SourceLocation) { + return `${location.line}${location.column !== undefined ? `:${location.column}` : ''}` +} + +export function formatSourceRange(range: SourceRange) { + const rangeStr = `${formatSourceLocation(range.start)}-${formatSourceLocation(range.end)}` + if (isEmptyRange(range)) return `empty(${rangeStr})` + return rangeStr +} + +export function formatAnnotationComment(comment: AnnotationComment) { + const result: string[] = [] + result.push(`tag=${comment.tag.rawTag}`) + result.push(`tagRange=${formatSourceRange(comment.tag.range)}`) + result.push(`commentRange=${formatSourceRange(comment.commentRange)}`) + result.push(`commentInnerRange=${formatSourceRange(comment.commentInnerRange)}`) + result.push(`annotationRange=${formatSourceRange(comment.annotationRange)}`) + return result.join(',') +} + +export function formatAnnotationComments(comments: AnnotationComment[]) { + return comments.map((comment) => `{${formatAnnotationComment(comment)}}`).join('\n') +}