Skip to content

Commit a5b2179

Browse files
authored
Add start...end target range support, first major release (#8)
1 parent d5798d9 commit a5b2179

File tree

13 files changed

+494
-95
lines changed

13 files changed

+494
-95
lines changed

.changeset/little-coins-invent.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'annotation-comments': minor
3+
---
4+
5+
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.
6+
7+
The following example shows how to define a simple target line range using the new feature:
8+
9+
```js
10+
// [!mark:start]
11+
function foo() {
12+
console.log('foo')
13+
}
14+
// [!mark:end]
15+
```
16+
17+
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:
18+
19+
```js
20+
// [!mark:"log":start]
21+
function foo() {
22+
console.log('The words "log" will be marked both in the method call and this text.')
23+
console.log('Also on this line.')
24+
}
25+
// [!mark:"log":end]
26+
27+
console.log('As this line is outside the range, "log" will not be marked.')
28+
```

.changeset/spicy-guests-draw.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'annotation-comments': major
3+
---
4+
5+
First major release. Mainly to ensure that semver ranges work as expected, but hooray! 🎉

.config/typedoc.preamble.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Annotation tags consist of the following parts:
7878
- 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.
7979
- The following range types are supported:
8080
- 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.
81+
- 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.
8182
- The **closing sequence** `]`
8283

8384
### Annotation content

README.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
# annotation-comments
22

3-
> **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.
4-
>
5-
> 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.
6-
73
This library provides functionality to parse and extract annotation comments from code snippets.
84

95
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:

packages/annotation-comments/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Annotation tags consist of the following parts:
7878
- 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.
7979
- The following range types are supported:
8080
- 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.
81+
- 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.
8182
- The **closing sequence** `]`
8283

8384
### Annotation content
@@ -547,7 +548,7 @@ type AnnotationTag = {
547548
name: string;
548549
range: SourceRange;
549550
rawTag: string;
550-
relativeTargetRange: number;
551+
relativeTargetRange: number | "start" | "end";
551552
targetSearchQuery: string | RegExp;
552553
};
553554
```
@@ -581,7 +582,7 @@ rawTag: string;
581582
##### relativeTargetRange?
582583

583584
```ts
584-
optional relativeTargetRange: number;
585+
optional relativeTargetRange: number | "start" | "end";
585586
```
586587

587588
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
593594
The following range types are supported:
594595

595596
- 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.
597+
- 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.
596598

597599
##### targetSearchQuery?
598600

@@ -674,7 +676,7 @@ The handler can return `true` to indicate that it has taken care of the change a
674676
##### removeAnnotationContents?
675677

676678
```ts
677-
optional removeAnnotationContents:
679+
optional removeAnnotationContents:
678680
| boolean
679681
| (context: CleanAnnotationContext) => boolean;
680682
```

packages/annotation-comments/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"coverage": "vitest run --coverage --coverage.include=src/**/*.ts --coverage.exclude=src/index.ts",
2626
"test": "vitest run",
2727
"test-short": "vitest run --reporter basic",
28-
"test-watch": "vitest --reporter verbose",
28+
"test-watch": "vitest",
2929
"watch": "pnpm build --watch src"
3030
}
3131
}

packages/annotation-comments/src/core/find-targets.ts

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1+
import { coerceError } from '../internal/errors'
12
import { compareRanges, createSingleLineRange } from '../internal/ranges'
23
import { findSearchQueryMatchesInLine, getFirstNonAnnotationCommentLineContents, getNonAnnotationCommentLineContents } from '../internal/text-content'
3-
import type { AnnotatedCode, AnnotationComment } from './types'
4+
import type { AnnotatedCode, AnnotationComment, AnnotationTag } from './types'
45

5-
export function findAnnotationTargets(annotatedCode: AnnotatedCode) {
6+
/**
7+
* Attempts to find the targets of all annotations in the given annotated code.
8+
*
9+
* Returns an array of error messages that occurred during the search.
10+
*/
11+
export function findAnnotationTargets(annotatedCode: AnnotatedCode): string[] {
612
const { annotationComments } = annotatedCode
13+
const matchedEndTags = new Set<AnnotationTag>()
14+
const errorMessages: string[] = []
715

816
annotationComments.forEach((comment) => {
917
const { tag, commentRange, targetRanges } = comment
@@ -16,30 +24,39 @@ export function findAnnotationTargets(annotatedCode: AnnotatedCode) {
1624
const commentLineContents = getNonAnnotationCommentLineContents(commentLineIndex, annotatedCode)
1725
const { relativeTargetRange, targetSearchQuery } = tag
1826

19-
if (targetSearchQuery === undefined) {
20-
// Handle annotations without a target search query (they target full lines)
21-
findFullLineTargetRanges({ annotatedCode, comment, commentLineContents, commentLineIndex })
22-
} else {
23-
// A target search query is present, so we need to search for target ranges
24-
findInlineTargetRanges({ annotatedCode, comment, commentLineContents, commentLineIndex })
27+
try {
28+
if (targetSearchQuery === undefined) {
29+
// Handle annotations without a target search query (they target full lines)
30+
findFullLineTargetRanges({ annotatedCode, comment, commentLineContents, commentLineIndex, matchedEndTags })
31+
} else {
32+
// A target search query is present, so we need to search for target ranges
33+
findInlineTargetRanges({ annotatedCode, comment, commentLineContents, commentLineIndex, matchedEndTags })
34+
}
35+
} catch (error) {
36+
const errorMessage = coerceError(error).message
37+
errorMessages.push(`Error while processing annotation tag ${comment.tag.rawTag} in line ${comment.tag.range.start.line + 1}: ${errorMessage}`)
2538
}
2639

2740
// In case of a negative direction, fix the potentially mixed up order of target ranges
2841
if (typeof relativeTargetRange === 'number' && relativeTargetRange < 0) targetRanges.sort((a, b) => compareRanges(a, b, 'start'))
2942
})
43+
44+
return errorMessages
3045
}
3146

3247
function findFullLineTargetRanges(options: {
3348
annotatedCode: AnnotatedCode
3449
comment: AnnotationComment
3550
commentLineContents: ReturnType<typeof getNonAnnotationCommentLineContents>
3651
commentLineIndex: number
52+
matchedEndTags: Set<AnnotationTag>
3753
}) {
3854
const {
3955
annotatedCode,
4056
comment: { tag, targetRanges },
4157
commentLineContents,
4258
commentLineIndex,
59+
matchedEndTags,
4360
} = options
4461
const { relativeTargetRange } = tag
4562

@@ -81,19 +98,34 @@ function findFullLineTargetRanges(options: {
8198
lineIndex += step
8299
}
83100
}
101+
102+
// Handle relative target ranges defined by matching `start` and `end` annotations
103+
const startEndRange = handleStartEndTargetRange({ annotatedCode, tag, commentLineIndex, matchedEndTags })
104+
if (startEndRange?.endAnnotation) {
105+
const rangeStart = commentLineContents.hasNonWhitespaceContent ? commentLineIndex : commentLineIndex + 1
106+
const rangeEnd = startEndRange.endAnnotation.commentRange.start.line
107+
for (let lineIndex = rangeStart; lineIndex <= rangeEnd; lineIndex++) {
108+
const lineContents = getNonAnnotationCommentLineContents(lineIndex, annotatedCode)
109+
if (lineContents.contentRanges.length) {
110+
targetRanges.push(createSingleLineRange(lineIndex))
111+
}
112+
}
113+
}
84114
}
85115

86116
function findInlineTargetRanges(options: {
87117
annotatedCode: AnnotatedCode
88118
comment: AnnotationComment
89119
commentLineContents: ReturnType<typeof getNonAnnotationCommentLineContents>
90120
commentLineIndex: number
121+
matchedEndTags: Set<AnnotationTag>
91122
}) {
92123
const {
93124
annotatedCode,
94125
comment: { tag, targetRanges },
95126
commentLineContents,
96127
commentLineIndex,
128+
matchedEndTags,
97129
} = options
98130
const { targetSearchQuery } = tag
99131
let { relativeTargetRange } = tag
@@ -140,4 +172,83 @@ function findInlineTargetRanges(options: {
140172
lineIndex += step
141173
}
142174
}
175+
176+
// Handle relative target ranges defined by matching `start` and `end` annotations
177+
const startEndRange = handleStartEndTargetRange({ annotatedCode, tag, commentLineIndex, matchedEndTags })
178+
if (startEndRange?.endAnnotation) {
179+
const rangeStart = commentLineContents.hasNonWhitespaceContent ? commentLineIndex : commentLineIndex + 1
180+
const rangeEnd = startEndRange.endAnnotation.commentRange.start.line
181+
for (let lineIndex = rangeStart; lineIndex <= rangeEnd; lineIndex++) {
182+
// Search all ranges of the line that are not part of an annotation comment
183+
// for matches of the target search query
184+
const matches = findSearchQueryMatchesInLine(lineIndex, targetSearchQuery, annotatedCode)
185+
matches.forEach((match) => targetRanges.push(match))
186+
}
187+
}
188+
}
189+
190+
/**
191+
* Handles relative target ranges defined by a matching `start`...`end` tag pair.
192+
*
193+
* Returns an object if the tag is part of a pair, or `undefined` otherwise.
194+
* If the tag is the start of a pair, the object also contains the matching end annotation.
195+
*
196+
* Throws an error when encountering unmatched `start` or `end` tags on their own.
197+
*/
198+
function handleStartEndTargetRange(options: {
199+
annotatedCode: AnnotatedCode
200+
tag: AnnotationTag
201+
commentLineIndex: number
202+
matchedEndTags: Set<AnnotationTag>
203+
}): { endAnnotation?: AnnotationComment | undefined } | undefined {
204+
const { tag, matchedEndTags } = options
205+
if (tag.relativeTargetRange === 'start') {
206+
const endAnnotation = findTargetRangeEndAnnotation(options)
207+
if (!endAnnotation) throw new Error(`Failed to find a matching end tag, expected "${tag.rawTag.replace(/:\w+\]$/, ':end]')}".`)
208+
matchedEndTags.add(endAnnotation.tag)
209+
return { endAnnotation }
210+
}
211+
if (tag.relativeTargetRange === 'end') {
212+
if (!matchedEndTags.has(tag)) throw new Error('This end tag does not have a matching start tag.')
213+
return {}
214+
}
215+
}
216+
217+
function findTargetRangeEndAnnotation(options: {
218+
annotatedCode: AnnotatedCode
219+
tag: AnnotationTag
220+
commentLineIndex: number
221+
matchedEndTags: Set<AnnotationTag>
222+
}) {
223+
const { annotatedCode, tag, commentLineIndex, matchedEndTags } = options
224+
225+
// Determine all matching range annotations below the start tag
226+
// (both start and end are allowed to support nested ranges)
227+
const matchFn = (input: AnnotationComment) => !matchedEndTags.has(input.tag) && input.commentRange.start.line > commentLineIndex && isMatchingRangeTag(tag, input.tag)
228+
const matchingAnnotationsBelow = annotatedCode.annotationComments.filter(matchFn)
229+
if (matchingAnnotationsBelow.length === 0) return
230+
231+
// Go through the matching annotations in order of appearance, keep track of the nesting level,
232+
// and return the first end tag on the same level as the start tag
233+
matchingAnnotationsBelow.sort((a, b) => compareRanges(a.commentRange, b.commentRange, 'start'))
234+
let nestingLevel = 0
235+
for (const annotation of matchingAnnotationsBelow) {
236+
if (annotation.tag.relativeTargetRange === 'start') {
237+
nestingLevel++
238+
continue
239+
}
240+
if (nestingLevel === 0) return annotation
241+
nestingLevel--
242+
}
243+
}
244+
245+
function isMatchingRangeTag(a: AnnotationTag, b: AnnotationTag) {
246+
if (b.name !== a.name) return false
247+
if (!isRangeTag(a) || !isRangeTag(b)) return false
248+
const getRawQuery = (query: AnnotationTag['targetSearchQuery']) => (query instanceof RegExp ? query.source : query)
249+
return getRawQuery(b.targetSearchQuery) === getRawQuery(a.targetSearchQuery)
250+
}
251+
252+
function isRangeTag(tag: AnnotationTag) {
253+
return tag.relativeTargetRange === 'start' || tag.relativeTargetRange === 'end'
143254
}

packages/annotation-comments/src/core/parse.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ export function parseAnnotationComments(options: ParseAnnotationCommentsOptions)
8181
})
8282

8383
// Find the target ranges for all annotations
84-
findAnnotationTargets({ codeLines, annotationComments })
84+
const findTargetErrors = findAnnotationTargets({ codeLines, annotationComments })
85+
if (findTargetErrors.length) errorMessages.push(...findTargetErrors)
8586

8687
return {
8788
annotationComments,

packages/annotation-comments/src/core/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,11 @@ export type AnnotationTag = {
8787
* of the annotation. If the annotation shares a line with code, the range starts at this
8888
* line. Otherwise, it starts at the first non-annotation line in the direction of the range.
8989
* The special range `0` creates standalone annotations that do not target any code.
90+
* - A **range between two matching annotations** defined by the keywords `start` and `end`,
91+
* e.g. `// [!ins:start]`, followed by some code lines, and a matching `// [!ins:end]`
92+
* to mark the end of the inserted code.
9093
*/
91-
relativeTargetRange?: number | undefined
94+
relativeTargetRange?: number | 'start' | 'end' | undefined
9295
rawTag: string
9396
/**
9497
* The tag's range within the parsed source code.

packages/annotation-comments/src/parsers/annotation-tags.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,6 @@ const annotationTagRegex = new RegExp(
4646
delimiter,
4747
].join('')
4848
),
49-
// Last alternative: Non-quoted query string
50-
[
51-
// It must not start with a digit, optionally preceded by a dash:
52-
'(?!-?\\d)',
53-
// It must contain at least one of the following parts:
54-
// - any character that is not a backslash, colon, or closing bracket
55-
// - a backslash followed by any character
56-
`(?:[^\\\\:\\]]|\\\\.)+?`,
57-
].join(''),
5849
].join('|'),
5950
// End of capture group
6051
')',
@@ -68,7 +59,15 @@ const annotationTagRegex = new RegExp(
6859
// Colon separator
6960
':',
7061
// Relative target range (captured)
71-
'(-?\\d+)',
62+
'(',
63+
[
64+
// Numeric range, defined by a positive or negative number
65+
'-?\\d+',
66+
// Anything else that is not a closing bracket
67+
'[^\\]]+',
68+
].join('|'),
69+
// End of relative target range capture group
70+
')',
7271
// End of non-capturing optional group
7372
')?',
7473
],
@@ -104,6 +103,13 @@ function parseTargetSearchQuery(rawTargetSearchQuery: string | undefined): strin
104103
return unescapedQuery
105104
}
106105

106+
function parseRelativeTargetRange(rawInput: string | undefined): AnnotationTag['relativeTargetRange'] {
107+
if (rawInput === undefined) return undefined
108+
if (rawInput === 'start' || rawInput === 'begin') return 'start'
109+
if (rawInput === 'end') return 'end'
110+
return Number(rawInput)
111+
}
112+
107113
export function parseAnnotationTags(options: ParseAnnotationTagsOptions): ParseAnnotationTagsResult {
108114
const { codeLines } = options
109115
const annotationTags: AnnotationTag[] = []
@@ -113,16 +119,23 @@ export function parseAnnotationTags(options: ParseAnnotationTagsOptions): ParseA
113119
const matches = [...line.matchAll(annotationTagRegex)]
114120
matches.forEach((match) => {
115121
try {
116-
const [, name, rawTargetSearchQuery, relativeTargetRange] = match
122+
const [, name, rawTargetSearchQuery, rawRelativeTargetRange] = match
117123
const rawTag = match[0]
118124
const startColIndex = match.index
119125
const endColIndex = startColIndex + rawTag.length
120126

121127
const targetSearchQuery = parseTargetSearchQuery(rawTargetSearchQuery)
128+
const relativeTargetRange = parseRelativeTargetRange(rawRelativeTargetRange)
129+
if (Number.isNaN(relativeTargetRange)) {
130+
if (targetSearchQuery === undefined)
131+
throw new Error(`Unexpected text "${rawRelativeTargetRange}" in tag. If you intended to specify a search query, it must be enclosed in quotes.`)
132+
throw new Error(`Unexpected text "${rawRelativeTargetRange}" in relative target range. Expected a number or the keywords "start", "begin" or "end".`)
133+
}
134+
122135
annotationTags.push({
123136
name,
124137
targetSearchQuery,
125-
relativeTargetRange: relativeTargetRange !== undefined ? Number(relativeTargetRange) : undefined,
138+
relativeTargetRange,
126139
rawTag,
127140
range: {
128141
start: { line: lineIndex, column: startColIndex },

0 commit comments

Comments
 (0)