Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/little-coins-invent.md
Original file line number Diff line number Diff line change
@@ -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.')
```
5 changes: 5 additions & 0 deletions .changeset/spicy-guests-draw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'annotation-comments': major
---

First major release. Mainly to ensure that semver ranges work as expected, but hooray! 🎉
1 change: 1 addition & 0 deletions .config/typedoc.preamble.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
8 changes: 5 additions & 3 deletions packages/annotation-comments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -547,7 +548,7 @@ type AnnotationTag = {
name: string;
range: SourceRange;
rawTag: string;
relativeTargetRange: number;
relativeTargetRange: number | "start" | "end";
targetSearchQuery: string | RegExp;
};
```
Expand Down Expand Up @@ -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.
Expand All @@ -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?

Expand Down Expand Up @@ -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;
```
Expand Down
2 changes: 1 addition & 1 deletion packages/annotation-comments/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
127 changes: 119 additions & 8 deletions packages/annotation-comments/src/core/find-targets.ts
Original file line number Diff line number Diff line change
@@ -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<AnnotationTag>()
const errorMessages: string[] = []

annotationComments.forEach((comment) => {
const { tag, commentRange, targetRanges } = comment
Expand All @@ -16,30 +24,39 @@ 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: {
annotatedCode: AnnotatedCode
comment: AnnotationComment
commentLineContents: ReturnType<typeof getNonAnnotationCommentLineContents>
commentLineIndex: number
matchedEndTags: Set<AnnotationTag>
}) {
const {
annotatedCode,
comment: { tag, targetRanges },
commentLineContents,
commentLineIndex,
matchedEndTags,
} = options
const { relativeTargetRange } = tag

Expand Down Expand Up @@ -81,19 +98,34 @@ 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: {
annotatedCode: AnnotatedCode
comment: AnnotationComment
commentLineContents: ReturnType<typeof getNonAnnotationCommentLineContents>
commentLineIndex: number
matchedEndTags: Set<AnnotationTag>
}) {
const {
annotatedCode,
comment: { tag, targetRanges },
commentLineContents,
commentLineIndex,
matchedEndTags,
} = options
const { targetSearchQuery } = tag
let { relativeTargetRange } = tag
Expand Down Expand Up @@ -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<AnnotationTag>
}): { 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<AnnotationTag>
}) {
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'
}
3 changes: 2 additions & 1 deletion packages/annotation-comments/src/core/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion packages/annotation-comments/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 25 additions & 12 deletions packages/annotation-comments/src/parsers/annotation-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
')',
Expand All @@ -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
')?',
],
Expand Down Expand Up @@ -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[] = []
Expand All @@ -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 },
Expand Down
Loading
Loading