diff --git a/.changeset/little-coins-invent.md b/.changeset/little-coins-invent.md new file mode 100644 index 0000000..81f3af8 --- /dev/null +++ b/.changeset/little-coins-invent.md @@ -0,0 +1,28 @@ +--- +'annotation-comments': minor +--- + +Adds support for target ranges defined by matching `start`...`end` annotation comments. This allows you to annotate ranges of code without having to count lines or manually updating the ranges when the code changes. + +The following example shows how to define a simple target line range using the new feature: + +```js +// [!mark:start] +function foo() { + console.log('foo') +} +// [!mark:end] +``` + +You can also combine `start`...`end` ranges with search queries, which limits the search to the range defined by the `start` and `end` annotation comments: + +```js +// [!mark:"log":start] +function foo() { + console.log('The words "log" will be marked both in the method call and this text.') + console.log('Also on this line.') +} +// [!mark:"log":end] + +console.log('As this line is outside the range, "log" will not be marked.') +``` diff --git a/.changeset/spicy-guests-draw.md b/.changeset/spicy-guests-draw.md new file mode 100644 index 0000000..90e767a --- /dev/null +++ b/.changeset/spicy-guests-draw.md @@ -0,0 +1,5 @@ +--- +'annotation-comments': major +--- + +First major release. Mainly to ensure that semver ranges work as expected, but hooray! 🎉 diff --git a/.config/typedoc.preamble.md b/.config/typedoc.preamble.md index c69c240..2a5e946 100644 --- a/.config/typedoc.preamble.md +++ b/.config/typedoc.preamble.md @@ -78,6 +78,7 @@ Annotation tags consist of the following parts: - If omitted, the annotation targets only 1 line or target search query match. Depending on the location of the annotation, this may be above, below, or on the line containing the annotation itself. - The following range types are supported: - A **numeric range** defined by positive or negative numbers, e.g. `:3`, `:-1`. Positive ranges extend downwards, negative ranges extend upwards from the location of the annotation. If the annotation shares a line with code, the range starts at this line. Otherwise, it starts at the first non-annotation line in the direction of the range. The special range `:0` can be used to create standalone annotations that do not target any code. + - A **range between two matching annotations** defined by the suffixes `:start` and `:end`, e.g. `// [!ins:start]`, followed by some code lines, and a matching `// [!ins:end]` to mark the end of the inserted code. - The **closing sequence** `]` ### Annotation content diff --git a/README.md b/README.md index 4e44829..d745c4c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,5 @@ # annotation-comments -> **Warning**: ⚠ This repository has just been made public and is still a work in progress. The documentation and code quality will be improved in the near future. -> -> As the API has not been finalized yet, we recommend waiting until this notice has been removed before attempting to use this package or contributing to it. - This library provides functionality to parse and extract annotation comments from code snippets. Annotation comments allow authors to annotate pieces of source code with additional information (e.g. marking important lines, highlighting changes, adding notes, and more) while keeping it readable and functional: diff --git a/packages/annotation-comments/README.md b/packages/annotation-comments/README.md index b395277..6657158 100644 --- a/packages/annotation-comments/README.md +++ b/packages/annotation-comments/README.md @@ -78,6 +78,7 @@ Annotation tags consist of the following parts: - If omitted, the annotation targets only 1 line or target search query match. Depending on the location of the annotation, this may be above, below, or on the line containing the annotation itself. - The following range types are supported: - A **numeric range** defined by positive or negative numbers, e.g. `:3`, `:-1`. Positive ranges extend downwards, negative ranges extend upwards from the location of the annotation. If the annotation shares a line with code, the range starts at this line. Otherwise, it starts at the first non-annotation line in the direction of the range. The special range `:0` can be used to create standalone annotations that do not target any code. + - A **range between two matching annotations** defined by the suffixes `:start` and `:end`, e.g. `// [!ins:start]`, followed by some code lines, and a matching `// [!ins:end]` to mark the end of the inserted code. - The **closing sequence** `]` ### Annotation content @@ -547,7 +548,7 @@ type AnnotationTag = { name: string; range: SourceRange; rawTag: string; - relativeTargetRange: number; + relativeTargetRange: number | "start" | "end"; targetSearchQuery: string | RegExp; }; ``` @@ -581,7 +582,7 @@ rawTag: string; ##### relativeTargetRange? ```ts -optional relativeTargetRange: number; +optional relativeTargetRange: number | "start" | "end"; ``` The optional relative target range of the annotation, located inside the annotation tag. @@ -593,6 +594,7 @@ If omitted, the annotation targets only 1 line or target search query match. Dep The following range types are supported: - A **numeric range** defined by positive or negative numbers. Positive ranges extend downwards, negative ranges extend upwards from the location of the annotation. If the annotation shares a line with code, the range starts at this line. Otherwise, it starts at the first non-annotation line in the direction of the range. The special range `0` creates standalone annotations that do not target any code. +- A **range between two matching annotations** defined by the keywords `start` and `end`, e.g. `// [!ins:start]`, followed by some code lines, and a matching `// [!ins:end]` to mark the end of the inserted code. ##### targetSearchQuery? @@ -674,7 +676,7 @@ The handler can return `true` to indicate that it has taken care of the change a ##### removeAnnotationContents? ```ts -optional removeAnnotationContents: +optional removeAnnotationContents: | boolean | (context: CleanAnnotationContext) => boolean; ``` diff --git a/packages/annotation-comments/package.json b/packages/annotation-comments/package.json index 3cbb2f4..915e965 100644 --- a/packages/annotation-comments/package.json +++ b/packages/annotation-comments/package.json @@ -25,7 +25,7 @@ "coverage": "vitest run --coverage --coverage.include=src/**/*.ts --coverage.exclude=src/index.ts", "test": "vitest run", "test-short": "vitest run --reporter basic", - "test-watch": "vitest --reporter verbose", + "test-watch": "vitest", "watch": "pnpm build --watch src" } } diff --git a/packages/annotation-comments/src/core/find-targets.ts b/packages/annotation-comments/src/core/find-targets.ts index 5caf9d6..2643b47 100644 --- a/packages/annotation-comments/src/core/find-targets.ts +++ b/packages/annotation-comments/src/core/find-targets.ts @@ -1,9 +1,17 @@ +import { coerceError } from '../internal/errors' import { compareRanges, createSingleLineRange } from '../internal/ranges' import { findSearchQueryMatchesInLine, getFirstNonAnnotationCommentLineContents, getNonAnnotationCommentLineContents } from '../internal/text-content' -import type { AnnotatedCode, AnnotationComment } from './types' +import type { AnnotatedCode, AnnotationComment, AnnotationTag } from './types' -export function findAnnotationTargets(annotatedCode: AnnotatedCode) { +/** + * Attempts to find the targets of all annotations in the given annotated code. + * + * Returns an array of error messages that occurred during the search. + */ +export function findAnnotationTargets(annotatedCode: AnnotatedCode): string[] { const { annotationComments } = annotatedCode + const matchedEndTags = new Set() + const errorMessages: string[] = [] annotationComments.forEach((comment) => { const { tag, commentRange, targetRanges } = comment @@ -16,17 +24,24 @@ export function findAnnotationTargets(annotatedCode: AnnotatedCode) { const commentLineContents = getNonAnnotationCommentLineContents(commentLineIndex, annotatedCode) const { relativeTargetRange, targetSearchQuery } = tag - if (targetSearchQuery === undefined) { - // Handle annotations without a target search query (they target full lines) - findFullLineTargetRanges({ annotatedCode, comment, commentLineContents, commentLineIndex }) - } else { - // A target search query is present, so we need to search for target ranges - findInlineTargetRanges({ annotatedCode, comment, commentLineContents, commentLineIndex }) + try { + if (targetSearchQuery === undefined) { + // Handle annotations without a target search query (they target full lines) + findFullLineTargetRanges({ annotatedCode, comment, commentLineContents, commentLineIndex, matchedEndTags }) + } else { + // A target search query is present, so we need to search for target ranges + findInlineTargetRanges({ annotatedCode, comment, commentLineContents, commentLineIndex, matchedEndTags }) + } + } catch (error) { + const errorMessage = coerceError(error).message + errorMessages.push(`Error while processing annotation tag ${comment.tag.rawTag} in line ${comment.tag.range.start.line + 1}: ${errorMessage}`) } // In case of a negative direction, fix the potentially mixed up order of target ranges if (typeof relativeTargetRange === 'number' && relativeTargetRange < 0) targetRanges.sort((a, b) => compareRanges(a, b, 'start')) }) + + return errorMessages } function findFullLineTargetRanges(options: { @@ -34,12 +49,14 @@ function findFullLineTargetRanges(options: { comment: AnnotationComment commentLineContents: ReturnType commentLineIndex: number + matchedEndTags: Set }) { const { annotatedCode, comment: { tag, targetRanges }, commentLineContents, commentLineIndex, + matchedEndTags, } = options const { relativeTargetRange } = tag @@ -81,6 +98,19 @@ function findFullLineTargetRanges(options: { lineIndex += step } } + + // Handle relative target ranges defined by matching `start` and `end` annotations + const startEndRange = handleStartEndTargetRange({ annotatedCode, tag, commentLineIndex, matchedEndTags }) + if (startEndRange?.endAnnotation) { + const rangeStart = commentLineContents.hasNonWhitespaceContent ? commentLineIndex : commentLineIndex + 1 + const rangeEnd = startEndRange.endAnnotation.commentRange.start.line + for (let lineIndex = rangeStart; lineIndex <= rangeEnd; lineIndex++) { + const lineContents = getNonAnnotationCommentLineContents(lineIndex, annotatedCode) + if (lineContents.contentRanges.length) { + targetRanges.push(createSingleLineRange(lineIndex)) + } + } + } } function findInlineTargetRanges(options: { @@ -88,12 +118,14 @@ function findInlineTargetRanges(options: { comment: AnnotationComment commentLineContents: ReturnType commentLineIndex: number + matchedEndTags: Set }) { const { annotatedCode, comment: { tag, targetRanges }, commentLineContents, commentLineIndex, + matchedEndTags, } = options const { targetSearchQuery } = tag let { relativeTargetRange } = tag @@ -140,4 +172,83 @@ function findInlineTargetRanges(options: { lineIndex += step } } + + // Handle relative target ranges defined by matching `start` and `end` annotations + const startEndRange = handleStartEndTargetRange({ annotatedCode, tag, commentLineIndex, matchedEndTags }) + if (startEndRange?.endAnnotation) { + const rangeStart = commentLineContents.hasNonWhitespaceContent ? commentLineIndex : commentLineIndex + 1 + const rangeEnd = startEndRange.endAnnotation.commentRange.start.line + for (let lineIndex = rangeStart; lineIndex <= rangeEnd; lineIndex++) { + // Search all ranges of the line that are not part of an annotation comment + // for matches of the target search query + const matches = findSearchQueryMatchesInLine(lineIndex, targetSearchQuery, annotatedCode) + matches.forEach((match) => targetRanges.push(match)) + } + } +} + +/** + * Handles relative target ranges defined by a matching `start`...`end` tag pair. + * + * Returns an object if the tag is part of a pair, or `undefined` otherwise. + * If the tag is the start of a pair, the object also contains the matching end annotation. + * + * Throws an error when encountering unmatched `start` or `end` tags on their own. + */ +function handleStartEndTargetRange(options: { + annotatedCode: AnnotatedCode + tag: AnnotationTag + commentLineIndex: number + matchedEndTags: Set +}): { endAnnotation?: AnnotationComment | undefined } | undefined { + const { tag, matchedEndTags } = options + if (tag.relativeTargetRange === 'start') { + const endAnnotation = findTargetRangeEndAnnotation(options) + if (!endAnnotation) throw new Error(`Failed to find a matching end tag, expected "${tag.rawTag.replace(/:\w+\]$/, ':end]')}".`) + matchedEndTags.add(endAnnotation.tag) + return { endAnnotation } + } + if (tag.relativeTargetRange === 'end') { + if (!matchedEndTags.has(tag)) throw new Error('This end tag does not have a matching start tag.') + return {} + } +} + +function findTargetRangeEndAnnotation(options: { + annotatedCode: AnnotatedCode + tag: AnnotationTag + commentLineIndex: number + matchedEndTags: Set +}) { + const { annotatedCode, tag, commentLineIndex, matchedEndTags } = options + + // Determine all matching range annotations below the start tag + // (both start and end are allowed to support nested ranges) + const matchFn = (input: AnnotationComment) => !matchedEndTags.has(input.tag) && input.commentRange.start.line > commentLineIndex && isMatchingRangeTag(tag, input.tag) + const matchingAnnotationsBelow = annotatedCode.annotationComments.filter(matchFn) + if (matchingAnnotationsBelow.length === 0) return + + // Go through the matching annotations in order of appearance, keep track of the nesting level, + // and return the first end tag on the same level as the start tag + matchingAnnotationsBelow.sort((a, b) => compareRanges(a.commentRange, b.commentRange, 'start')) + let nestingLevel = 0 + for (const annotation of matchingAnnotationsBelow) { + if (annotation.tag.relativeTargetRange === 'start') { + nestingLevel++ + continue + } + if (nestingLevel === 0) return annotation + nestingLevel-- + } +} + +function isMatchingRangeTag(a: AnnotationTag, b: AnnotationTag) { + if (b.name !== a.name) return false + if (!isRangeTag(a) || !isRangeTag(b)) return false + const getRawQuery = (query: AnnotationTag['targetSearchQuery']) => (query instanceof RegExp ? query.source : query) + return getRawQuery(b.targetSearchQuery) === getRawQuery(a.targetSearchQuery) +} + +function isRangeTag(tag: AnnotationTag) { + return tag.relativeTargetRange === 'start' || tag.relativeTargetRange === 'end' } diff --git a/packages/annotation-comments/src/core/parse.ts b/packages/annotation-comments/src/core/parse.ts index 7f4d55d..29657da 100644 --- a/packages/annotation-comments/src/core/parse.ts +++ b/packages/annotation-comments/src/core/parse.ts @@ -81,7 +81,8 @@ export function parseAnnotationComments(options: ParseAnnotationCommentsOptions) }) // Find the target ranges for all annotations - findAnnotationTargets({ codeLines, annotationComments }) + const findTargetErrors = findAnnotationTargets({ codeLines, annotationComments }) + if (findTargetErrors.length) errorMessages.push(...findTargetErrors) return { annotationComments, diff --git a/packages/annotation-comments/src/core/types.ts b/packages/annotation-comments/src/core/types.ts index 7c54289..c3ef70e 100644 --- a/packages/annotation-comments/src/core/types.ts +++ b/packages/annotation-comments/src/core/types.ts @@ -87,8 +87,11 @@ export type AnnotationTag = { * of the annotation. If the annotation shares a line with code, the range starts at this * line. Otherwise, it starts at the first non-annotation line in the direction of the range. * The special range `0` creates standalone annotations that do not target any code. + * - A **range between two matching annotations** defined by the keywords `start` and `end`, + * e.g. `// [!ins:start]`, followed by some code lines, and a matching `// [!ins:end]` + * to mark the end of the inserted code. */ - relativeTargetRange?: number | undefined + relativeTargetRange?: number | 'start' | 'end' | undefined rawTag: string /** * The tag's range within the parsed source code. diff --git a/packages/annotation-comments/src/parsers/annotation-tags.ts b/packages/annotation-comments/src/parsers/annotation-tags.ts index 1b9156e..c448437 100644 --- a/packages/annotation-comments/src/parsers/annotation-tags.ts +++ b/packages/annotation-comments/src/parsers/annotation-tags.ts @@ -46,15 +46,6 @@ const annotationTagRegex = new RegExp( delimiter, ].join('') ), - // Last alternative: Non-quoted query string - [ - // It must not start with a digit, optionally preceded by a dash: - '(?!-?\\d)', - // It must contain at least one of the following parts: - // - any character that is not a backslash, colon, or closing bracket - // - a backslash followed by any character - `(?:[^\\\\:\\]]|\\\\.)+?`, - ].join(''), ].join('|'), // End of capture group ')', @@ -68,7 +59,15 @@ const annotationTagRegex = new RegExp( // Colon separator ':', // Relative target range (captured) - '(-?\\d+)', + '(', + [ + // Numeric range, defined by a positive or negative number + '-?\\d+', + // Anything else that is not a closing bracket + '[^\\]]+', + ].join('|'), + // End of relative target range capture group + ')', // End of non-capturing optional group ')?', ], @@ -104,6 +103,13 @@ function parseTargetSearchQuery(rawTargetSearchQuery: string | undefined): strin return unescapedQuery } +function parseRelativeTargetRange(rawInput: string | undefined): AnnotationTag['relativeTargetRange'] { + if (rawInput === undefined) return undefined + if (rawInput === 'start' || rawInput === 'begin') return 'start' + if (rawInput === 'end') return 'end' + return Number(rawInput) +} + export function parseAnnotationTags(options: ParseAnnotationTagsOptions): ParseAnnotationTagsResult { const { codeLines } = options const annotationTags: AnnotationTag[] = [] @@ -113,16 +119,23 @@ export function parseAnnotationTags(options: ParseAnnotationTagsOptions): ParseA const matches = [...line.matchAll(annotationTagRegex)] matches.forEach((match) => { try { - const [, name, rawTargetSearchQuery, relativeTargetRange] = match + const [, name, rawTargetSearchQuery, rawRelativeTargetRange] = match const rawTag = match[0] const startColIndex = match.index const endColIndex = startColIndex + rawTag.length const targetSearchQuery = parseTargetSearchQuery(rawTargetSearchQuery) + const relativeTargetRange = parseRelativeTargetRange(rawRelativeTargetRange) + if (Number.isNaN(relativeTargetRange)) { + if (targetSearchQuery === undefined) + throw new Error(`Unexpected text "${rawRelativeTargetRange}" in tag. If you intended to specify a search query, it must be enclosed in quotes.`) + throw new Error(`Unexpected text "${rawRelativeTargetRange}" in relative target range. Expected a number or the keywords "start", "begin" or "end".`) + } + annotationTags.push({ name, targetSearchQuery, - relativeTargetRange: relativeTargetRange !== undefined ? Number(relativeTargetRange) : undefined, + relativeTargetRange, rawTag, range: { start: { line: lineIndex, column: startColIndex }, diff --git a/packages/annotation-comments/test/annotation-tags.test.ts b/packages/annotation-comments/test/annotation-tags.test.ts index e11d7b3..daa10c6 100644 --- a/packages/annotation-comments/test/annotation-tags.test.ts +++ b/packages/annotation-comments/test/annotation-tags.test.ts @@ -22,6 +22,21 @@ describe('parseAnnotationTags()', () => { }) describe('Returns error messages when invalid annotation tags are found', () => { + test('Unquoted target search query', () => { + const codeLines = [ + // The following regular expression should cause an error to be returned + '// [!tag:search term:5] Note the unquoted target search query.', + 'console.log("Some code");', + ] + const { annotationTags, errorMessages } = parseAnnotationTags({ codeLines }) + + expect(annotationTags).toEqual([]) + expect(errorMessages).toEqual([ + // Expect the line number and search term to be called out, + // including a suggestion to enclose the search term in quotes + expect.stringMatching(/line 1.*search term.*enclosed in quotes/), + ]) + }) test('Single invalid regular expression', () => { const codeLines = [ // The following regular expression should cause an error to be returned @@ -118,46 +133,6 @@ describe('parseAnnotationTags()', () => { }) describe('Parses target search queries', () => { - describe('Tags with unquoted target search query', () => { - test(`[!tag:search-term]`, ({ task }) => { - performTagTest({ - rawTag: task.name, - name: 'tag', - targetSearchQuery: 'search-term', - relativeTargetRange: undefined, - }) - }) - - test(`[!tag:term with spaces and chars like .,;?!"'/-]`, ({ task }) => { - performTagTest({ - rawTag: task.name, - name: 'tag', - targetSearchQuery: `term with spaces and chars like .,;?!"'/-`, - relativeTargetRange: undefined, - }) - }) - }) - - describe('Tags with unquoted target search query and target range', () => { - test(`[!tag:search-term:5]`, ({ task }) => { - performTagTest({ - rawTag: task.name, - name: 'tag', - targetSearchQuery: 'search-term', - relativeTargetRange: 5, - }) - }) - - test(`[!tag:term with spaces and chars like .;/"'?!-, too:-2]`, ({ task }) => { - performTagTest({ - rawTag: task.name, - name: 'tag', - targetSearchQuery: `term with spaces and chars like .;/"'?!-, too`, - relativeTargetRange: -2, - }) - }) - }) - describe('Tags with quoted target search query', () => { test(`[!tag:"double-quoted term"]`, ({ task }) => { performTagTest({ @@ -241,6 +216,15 @@ describe('parseAnnotationTags()', () => { relativeTargetRange: -5, }) }) + + test(`[!tag:"term with spaces and chars like .:;/'?!-, too":-2]`, ({ task }) => { + performTagTest({ + rawTag: task.name, + name: 'tag', + targetSearchQuery: `term with spaces and chars like .:;/'?!-, too`, + relativeTargetRange: -2, + }) + }) }) describe('Tags with RegExp target search query', () => { @@ -330,7 +314,7 @@ ${test.rawTag} Tag in a multi-line comment }, [] as AnnotationTag[]) // Perform test const { annotationTags, errorMessages } = parseAnnotationTags({ codeLines }) - expect(annotationTags).toEqual(expectedAnnotationTags) expect(errorMessages).toEqual([]) + expect(annotationTags).toEqual(expectedAnnotationTags) } }) diff --git a/packages/annotation-comments/test/parse.test.ts b/packages/annotation-comments/test/parse.test.ts index 2dff9d8..1ea9ac8 100644 --- a/packages/annotation-comments/test/parse.test.ts +++ b/packages/annotation-comments/test/parse.test.ts @@ -31,7 +31,7 @@ import { defineConfig } from 'astro/config'; /** * Some JSDoc test. * - * [!note:test] The \`test\` function here is just an example + * [!note:"test"] The \`test\` function here is just an example * and doesn't do anything meaningful. * * Also note that we just added a [!note] tag to an existing @@ -102,7 +102,7 @@ export default defineConfig({ /* [!del] */ color: blue; } body, html, .test[data-size="large"], #id { - /* [!note:linear-gradient] + /* [!note:"linear-gradient"] As this [!note] points out, we let the browser create a gradient for us here. */ background: linear-gradient(to top, #80f 1px, rgb(30, 90, 130) 50%); @@ -152,7 +152,7 @@ const { title } = Astro.props
-

{title} & some text

@@ -171,7 +171,7 @@ const { title } = Astro.props commentRange: { start: { line: 5 }, end: { line: 6 } }, annotationRange: { start: { line: 5 }, end: { line: 6 } }, // Expect only the capture group inside the full match to be highlighted - targetRangeRegExp: /(? { + test('[!ignore-tags:"note"] ignores the next occurrence of "note"', () => { const lines = [ `// [!before] This is before any ignores`, - `// [!ignore-tags:note]`, + `// [!ignore-tags:"note"]`, `console.log('Some code')`, `testCode() // [!note] This should not be parsed`, `// [!note] This should be parsed again`, @@ -329,10 +329,10 @@ countdown(9) // This one is out of the target range }, ]) }) - test('[!ignore-tags:note,ins:3] ignores the next 3 occurrences of "note" and "ins"', () => { + test('[!ignore-tags:"note,ins":3] ignores the next 3 occurrences of "note" and "ins"', () => { const lines = [ `// [!before] This is before any ignores`, - `// [!ignore-tags:note,ins:3]`, + `// [!ignore-tags:"note,ins":3]`, `console.log('Some code') // [!ins]`, `testCode() // [!note] This should not be parsed`, `// [!ins] // [!note] Still ignored`, @@ -397,7 +397,7 @@ countdown(9) // This one is out of the target range `// [!ignore-tags:-2]`, `someMoreCode()`, `console.log('Test') // [!ins3]`, - `/* [!ignore-tags:ins3:-1] */`, + `/* [!ignore-tags:"ins3":-1] */`, `// [!note] A regular note`, ] validateParsedComments( @@ -441,7 +441,7 @@ countdown(9) // This one is out of the target range `console.log('Some code') // [!ignore-tags] // [!ins1]`, `// [!ins2]`, `testCode() // [!ignore-tags]`, - `/* [!ignore-tags:ins3] */ someMoreCode()`, + `/* [!ignore-tags:"ins3"] */ someMoreCode()`, `console.log('Test') // [!ins3]`, `// [!note] A regular note`, ] @@ -743,8 +743,192 @@ countdown(9) // This one is out of the target range ]) }) }) + describe('Tag pair defining a start...end target range', () => { + test('[!tag:start] ... [!tag:end] targets all non-annotation comment lines inbetween', () => { + const codeLines = [ + `fail`, + `// [!mark:start]`, + `console.log('This line will be marked.')`, + `console.log('This one, too.')`, + // Empty lines in the range are also targeted + ``, + `console.log('This one, too.')`, + // But annotation lines are not + `// [!ins:1]`, + `console.log('And this one.')`, + `// [!mark:end]`, + `fail`, + ] + validateParsedComments(codeLines, [ + { + tag: { name: 'mark', relativeTargetRange: 'start' }, + targetRanges: createSingleLineRanges(2, 3, 4, 5, 7), + }, + { + tag: { name: 'ins', relativeTargetRange: 1 }, + targetRanges: createSingleLineRanges(7), + }, + { + tag: { name: 'mark', relativeTargetRange: 'end' }, + targetRanges: [], + }, + ]) + }) + test('Range start/end tags at the end of non-empty lines include these lines in the range', () => { + const codeLines = [ + `fail`, + `console.log('This line will be marked.') // [!mark:start]`, + `console.log('This one, too.')`, + // Empty lines in the range are also targeted + ``, + `console.log('This one, too.')`, + // But annotation lines are not + `// [!ins:1]`, + `console.log('And this one.') // [!mark:end]`, + `fail`, + ] + validateParsedComments(codeLines, [ + { + tag: { name: 'mark', relativeTargetRange: 'start' }, + targetRanges: createSingleLineRanges(1, 2, 3, 4, 6), + }, + { + tag: { name: 'ins', relativeTargetRange: 1 }, + targetRanges: createSingleLineRanges(6), + }, + { + tag: { name: 'mark', relativeTargetRange: 'end' }, + targetRanges: [], + }, + ]) + }) + test('Range start/end tags at the beginning of non-empty lines include these lines in the range', () => { + const codeLines = [ + `fail`, + `/* [!mark:start] */ console.log('This line will be marked.')`, + `console.log('This one, too.')`, + // Empty lines in the range are also targeted + ``, + `console.log('This one, too.')`, + // But annotation lines are not + `// [!ins:1]`, + `/* [!mark:end] */ console.log('And this one.')`, + `fail`, + ] + validateParsedComments(codeLines, [ + { + tag: { name: 'mark', relativeTargetRange: 'start' }, + targetRanges: createSingleLineRanges(1, 2, 3, 4, 6), + }, + { + tag: { name: 'ins', relativeTargetRange: 1 }, + targetRanges: createSingleLineRanges(6), + }, + { + tag: { name: 'mark', relativeTargetRange: 'end' }, + targetRanges: [], + }, + ]) + }) + test('Keyword "begin" is automatically converted to "start"', () => { + const codeLines = [ + `fail`, + `// [!mark:begin]`, + `console.log('This line will be marked.')`, + `console.log('This one, too.')`, + // Empty lines in the range are also targeted + ``, + `console.log('This one, too.')`, + // But annotation lines are not + `// [!ins:1]`, + `console.log('And this one.')`, + `// [!mark:end]`, + `fail`, + ] + validateParsedComments(codeLines, [ + { + tag: { name: 'mark', relativeTargetRange: 'start' }, + targetRanges: createSingleLineRanges(2, 3, 4, 5, 7), + }, + { + tag: { name: 'ins', relativeTargetRange: 1 }, + targetRanges: createSingleLineRanges(7), + }, + { + tag: { name: 'mark', relativeTargetRange: 'end' }, + targetRanges: [], + }, + ]) + }) + test('Matching start...end ranges can be nested', () => { + const codeLines = [ + `fail`, + `// [!collapse:start] This is the outer collapse`, + `console.log('This line will be marked.')`, + `console.log('This one, too.')`, + `// [!collapse:start] And this is the inner one`, + `console.log('And this one.')`, + `console.log('This one, too.')`, + `// [!collapse:end]`, + `console.log('And this one.')`, + `// [!collapse:end]`, + `fail`, + ] + validateParsedComments(codeLines, [ + { + tag: { name: 'collapse', relativeTargetRange: 'start' }, + targetRanges: createSingleLineRanges(2, 3, 5, 6, 8), + }, + { + tag: { name: 'collapse', relativeTargetRange: 'start' }, + targetRanges: createSingleLineRanges(5, 6), + }, + { + tag: { name: 'collapse', relativeTargetRange: 'end' }, + targetRanges: [], + }, + { + tag: { name: 'collapse', relativeTargetRange: 'end' }, + targetRanges: [], + }, + ]) + }) + test('Different start...end ranges can overlap', () => { + const codeLines = [ + `fail`, + `// [!mark:start]`, + `console.log('This line will be marked.')`, + `console.log('This one, too.')`, + `// [!collapse:start]`, + `console.log('And this one.')`, + `console.log('This one, too.')`, + `// [!mark:end]`, + `console.log('And this one.')`, + `// [!collapse:end]`, + `fail`, + ] + validateParsedComments(codeLines, [ + { + tag: { name: 'mark', relativeTargetRange: 'start' }, + targetRanges: createSingleLineRanges(2, 3, 5, 6), + }, + { + tag: { name: 'collapse', relativeTargetRange: 'start' }, + targetRanges: createSingleLineRanges(5, 6, 8), + }, + { + tag: { name: 'mark', relativeTargetRange: 'end' }, + targetRanges: [], + }, + { + tag: { name: 'collapse', relativeTargetRange: 'end' }, + targetRanges: [], + }, + ]) + }) + }) describe('Tag with a target search query', () => { - test('[!tag:search term] at the end of a non-empty line starts searching at the current line', () => { + test('[!tag:] at the end of a non-empty line starts searching at the current line', () => { const codeLines = [ `Start of test code`, // Empty lines above and below @@ -771,7 +955,7 @@ countdown(9) // This one is out of the target range { tag: { name: 'mark', targetSearchQuery: /(target.|fail)/ }, targetRangeRegExp: /.*target4/ }, ]) }) - test('[!tag:search term] at the end of a non-empty line always searches downwards', () => { + test('[!tag:] at the end of a non-empty line always searches downwards', () => { const codeLines = [ `Start of test code`, // Empty lines above and below @@ -804,7 +988,7 @@ countdown(9) // This one is out of the target range { tag: { name: 'mark', targetSearchQuery: /(target.|fail)/ }, targetRangeRegExp: /.*target4/ }, ]) }) - test(`[!tag:search term] on its own line searches downwards if the first line below is non-empty`, () => { + test(`[!tag:] on its own line searches downwards if the first line below is non-empty`, () => { const codeLines = [ `Start of test code`, ``, @@ -854,7 +1038,7 @@ countdown(9) // This one is out of the target range { tag: { name: 'note', targetSearchQuery: /(target.|fail)/ }, targetRangeRegExp: /.*target5/ }, ]) }) - test(`[!tag:search term] on its own line searches upwards if only below is empty`, () => { + test(`[!tag:] on its own line searches upwards if only below is empty`, () => { const codeLines = [ `Start of test code`, ``, @@ -900,7 +1084,7 @@ countdown(9) // This one is out of the target range { tag: { name: 'note', targetSearchQuery: /(target.|fail)/ }, targetRangeRegExp: /.*target5/ }, ]) }) - test(`[!tag:search term] on its own line searches downwards if below and above are empty`, () => { + test(`[!tag:] on its own line searches downwards if below and above are empty`, () => { const codeLines = [ `Start of test code`, ``, @@ -953,7 +1137,7 @@ countdown(9) // This one is out of the target range }) }) describe('Tag with a target search query and relative target range', () => { - test(`[!tag:search term:3] on its own line searches 3 matches downwards`, () => { + test(`[!tag::3] on its own line searches 3 matches downwards`, () => { const codeLines = [ `Start of test code`, ``, @@ -1010,7 +1194,7 @@ countdown(9) // This one is out of the target range { tag: { name: 'note', targetSearchQuery: /(target.|fail)/, relativeTargetRange: 3 }, targetRangeRegExp: /.*target4/ }, ]) }) - test(`[!tag:search term:-3] on its own line searches 3 matches upwards`, () => { + test(`[!tag::-3] on its own line searches 3 matches upwards`, () => { const codeLines = [ `Start of test code`, ``, @@ -1067,7 +1251,7 @@ countdown(9) // This one is out of the target range { tag: { name: 'note', targetSearchQuery: /(target.|fail)/, relativeTargetRange: -3 }, targetRangeRegExp: /.*target4/ }, ]) }) - test(`[!tag:search term:3] at the end of a non-empty line searches forwards from the start of the current line`, () => { + test(`[!tag::3] at the end of a non-empty line searches forwards from the start of the current line`, () => { const codeLines = [ `Start of test code`, // Empty lines above and below @@ -1101,7 +1285,7 @@ countdown(9) // This one is out of the target range { tag: { name: 'mark', targetSearchQuery: /(target.|fail)/, relativeTargetRange: 3 }, targetRangeRegExp: /target4/ }, ]) }) - test(`[!tag:search term:3] at the beginning of a non-empty line searches forwards from the start of the current line`, () => { + test(`[!tag::3] at the beginning of a non-empty line searches forwards from the start of the current line`, () => { const codeLines = [ `Start of test code`, // Empty lines above and below @@ -1134,7 +1318,7 @@ countdown(9) // This one is out of the target range { tag: { name: 'mark', targetSearchQuery: /(target.|fail)/, relativeTargetRange: 3 }, targetRangeRegExp: /target4/ }, ]) }) - test(`[!tag:search term:-3] at the end of a non-empty line searches backwards from the end of the current line`, () => { + test(`[!tag::-3] at the end of a non-empty line searches backwards from the end of the current line`, () => { const codeLines = [ `Start of test code`, // Empty lines above and below @@ -1175,7 +1359,7 @@ countdown(9) // This one is out of the target range { tag: { name: 'mark', targetSearchQuery: /(target.|fail)/, relativeTargetRange: -3 }, targetRangeRegExp: /target5/ }, ]) }) - test(`[!tag:search term:-3] at the beginning of a non-empty line searches backwards from the end of the current line`, () => { + test(`[!tag::-3] at the beginning of a non-empty line searches backwards from the end of the current line`, () => { const codeLines = [ `Start of test code`, // Empty lines above and below @@ -1217,11 +1401,77 @@ countdown(9) // This one is out of the target range ]) }) }) + describe('Tag pair with target search query and start...end target range', () => { + test('[!tag::start] ... [!tag::end] targets all matches inbetween', () => { + const codeLines = [ + `fail`, + `// [!mark:/(target.|fail)/:start]`, + `target1`, + `no match`, + `target1`, + `target1`, + `// [!mark:/(target.|fail)/:end]`, + `fail`, + ``, + `fail`, + `// [!note:/(target.|fail)/:start] This adds a note to each match.`, + `no match`, + `target2`, + `no match`, + `target2`, + `target2`, + `// [!note:/(target.|fail)/:end]`, + `fail`, + ``, + `fail`, + `/* [!note:/(target.|fail)/:start] The language's multi-line comment syntax`, + ` can be used. Potential matches like "target" or "fail" are skipped`, + ` inside annotation comments. */`, + ``, + `target3`, + `no match`, + `target3`, + `target3`, + `/* [!note:/(target.|fail)/:end] */`, + `fail`, + ``, + `fail`, + `/*`, + ` [!note:/(target.|fail)/:start]`, + ` Whitespace inside the comments does not matter,`, + ` allowing you to use any formatting you like.`, + `*/`, + ``, + `target4`, + `target4`, + `no match`, + `target4`, + `// [!note:/(target.|fail)/:end]`, + `fail`, + ``, + ] + validateParsedComments(codeLines, [ + { tag: { name: 'mark', targetSearchQuery: /(target.|fail)/, relativeTargetRange: 'start' }, targetRangeRegExp: /.*target1/ }, + { tag: { name: 'mark', targetSearchQuery: /(target.|fail)/, relativeTargetRange: 'end' }, targetRanges: [] }, + + { tag: { name: 'note', targetSearchQuery: /(target.|fail)/, relativeTargetRange: 'start' }, targetRangeRegExp: /.*target2/ }, + { tag: { name: 'note', targetSearchQuery: /(target.|fail)/, relativeTargetRange: 'end' }, targetRanges: [] }, + + { tag: { name: 'note', targetSearchQuery: /(target.|fail)/, relativeTargetRange: 'start' }, targetRangeRegExp: /.*target3/ }, + { tag: { name: 'note', targetSearchQuery: /(target.|fail)/, relativeTargetRange: 'end' }, targetRanges: [] }, + + { tag: { name: 'note', targetSearchQuery: /(target.|fail)/, relativeTargetRange: 'start' }, targetRangeRegExp: /.*target4/ }, + { tag: { name: 'note', targetSearchQuery: /(target.|fail)/, relativeTargetRange: 'end' }, targetRanges: [] }, + ]) + }) + }) }) function validateParsedComments(code: string | string[], expectedComments: ExpectedAnnotationComment[], expectedErrors: RegExp[] = []) { const codeLines = Array.isArray(code) ? code : splitCodeLines(code) const { annotationComments, errorMessages } = parseAnnotationComments({ codeLines }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + expect(errorMessages).toEqual(expectedErrors.map((regExp) => expect.stringMatching(regExp))) const expectedTagNames = expectedComments.map(({ tag }) => tag?.name || 'no tag').join(', ') const actualTagNames = annotationComments.map(({ tag }) => tag?.name || 'no tag').join(', ') expect(actualTagNames, 'Unexpected tags').toBe(expectedTagNames) @@ -1229,7 +1479,5 @@ countdown(9) // This one is out of the target range validateAnnotationComment(annotationComments[index], codeLines, expectedComment) }) expect(annotationComments).toHaveLength(expectedComments.length) - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - expect(errorMessages).toEqual(expectedErrors.map((regExp) => expect.stringMatching(regExp))) } }) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..207125c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + slowTestThreshold: 1000, + }, +})