Skip to content

Commit 9f4a9a8

Browse files
committed
Implement line targeting & search logic, tests
1 parent 851b3bc commit 9f4a9a8

7 files changed

Lines changed: 327 additions & 157 deletions

File tree

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

Lines changed: 139 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AnnotationComment, SourceRange } from './types'
2-
import { compareRanges, createSingleLineRange } from '../internal/ranges'
2+
import { compareRanges, createRange, createSingleLineRange } from '../internal/ranges'
3+
import { getGroupIndicesFromRegExpMatch } from '../internal/regexps'
34

45
export type FindAnnotationTargetsOptions = {
56
codeLines: string[]
@@ -9,71 +10,102 @@ export type FindAnnotationTargetsOptions = {
910
export function findAnnotationTargets(options: FindAnnotationTargetsOptions) {
1011
const { codeLines, annotationComments } = options
1112

12-
// TODO: Finish implementation
1313
annotationComments.forEach((comment) => {
14-
const annotationLineIndex = comment.annotationRange.start.line
15-
const annotationLineContents = getNonAnnotationCommentLineContents(annotationLineIndex, options)
16-
17-
// If the annotation has no relative target range and no target search query,
18-
// try to find a nearby target line that is not empty and not an annotation comment
19-
if (comment.tag.relativeTargetRange === undefined && comment.tag.targetSearchQuery === undefined) {
20-
// Check if the annotation line itself is a valid target
21-
if (annotationLineContents.hasNonWhitespaceContent) {
22-
return comment.targetRanges.push(createSingleLineRange(annotationLineIndex))
23-
}
24-
// Otherwise, scan for a target line below
25-
const potentialTargetBelow = getFirstNonAnnotationCommentLineContents(annotationLineIndex, 'below', options)
26-
if (potentialTargetBelow?.hasNonWhitespaceContent) {
27-
return comment.targetRanges.push(createSingleLineRange(potentialTargetBelow.lineIndex))
14+
const { tag, commentRange, annotationRange, targetRanges } = comment
15+
16+
// We don't need to search for `ignore-tags` annotation targets
17+
// as ignores are handled by the annotation comment parser
18+
if (tag.name === 'ignore-tags') return
19+
20+
const commentLineIndex = commentRange.start.line
21+
const commentLineContents = getNonAnnotationCommentLineContents(commentLineIndex, options)
22+
let { relativeTargetRange } = tag
23+
const { targetSearchQuery } = tag
24+
25+
// Handle annotations without a target search query (they target full lines)
26+
if (targetSearchQuery === undefined) {
27+
// If the annotation has no relative target range, try to find a nearby target line
28+
// that is not empty and not an annotation comment
29+
if (relativeTargetRange === undefined) {
30+
// Check if the annotation comment line itself is a valid target
31+
if (commentLineContents.hasNonWhitespaceContent) {
32+
return targetRanges.push(createSingleLineRange(commentLineIndex))
33+
}
34+
// Otherwise, scan for a target line below
35+
const potentialTargetBelow = getFirstNonAnnotationCommentLineContents(commentLineIndex, 'below', options)
36+
if (potentialTargetBelow?.hasNonWhitespaceContent) {
37+
return targetRanges.push(createSingleLineRange(potentialTargetBelow.lineIndex))
38+
}
39+
// Finally, scan for a target line above
40+
const potentialTargetAbove = getFirstNonAnnotationCommentLineContents(commentLineIndex, 'above', options)
41+
if (potentialTargetAbove?.hasNonWhitespaceContent) {
42+
return targetRanges.push(createSingleLineRange(potentialTargetAbove.lineIndex))
43+
}
44+
// If we arrive here, there is no target range (same as the relative range `:0`),
45+
// so don't do anything
46+
return
2847
}
29-
// Finally, scan for a target line above
30-
const potentialTargetAbove = getFirstNonAnnotationCommentLineContents(annotationLineIndex, 'above', options)
31-
if (potentialTargetAbove?.hasNonWhitespaceContent) {
32-
return comment.targetRanges.push(createSingleLineRange(potentialTargetAbove.lineIndex))
48+
// It has a relative target range, so select the number of non-annotation lines
49+
// in the given direction, starting with the comment line
50+
const step = relativeTargetRange > 0 ? 1 : -1
51+
let lineIndex = commentLineIndex
52+
let remainingLines = Math.abs(relativeTargetRange)
53+
while (lineIndex >= 0 && lineIndex < codeLines.length && remainingLines > 0) {
54+
// Check if the line is a valid target
55+
const lineContents = getNonAnnotationCommentLineContents(lineIndex, options)
56+
if (lineContents.contentRanges.length) {
57+
targetRanges.push(createSingleLineRange(lineIndex))
58+
remainingLines--
59+
}
60+
lineIndex += step
3361
}
34-
// If we arrive here, there is no target range (same as the relative range `:0`),
35-
// so don't do anything
36-
return
3762
}
3863

3964
// If a target search query is present, perform the search to determine the target range(s)
40-
if (comment.tag.targetSearchQuery) {
65+
if (targetSearchQuery) {
4166
// Read the direction and number of matches to find from the tag,
4267
// or auto-detect this in case no relative target range was given
43-
let relativeTargetRange = comment.tag.relativeTargetRange
4468
if (relativeTargetRange === undefined) {
45-
if (annotationLineContents.hasNonWhitespaceContent) {
69+
if (commentLineContents.hasNonWhitespaceContent) {
4670
// The annotation comment is on the same line as content, so the direction
47-
// depends on where the content is in relation to the comment
48-
const hasContentBeforeAnnotation = annotationLineContents.nonWhitespaceContentRanges.some(
49-
(contentRange) => compareRanges(comment.annotationRange, contentRange, 'start') < 0
71+
// depends on where the content is in relation to the annotation
72+
const hasContentBeforeAnnotation = commentLineContents.nonWhitespaceContentRanges.some(
73+
// Check for non-whitespace content that starts before the annotation
74+
(contentRange) => compareRanges(annotationRange, contentRange, 'start') < 0
5075
)
5176
relativeTargetRange = hasContentBeforeAnnotation ? -1 : 1
5277
} else {
5378
// Otherwise, the direction defaults to downwards, unless the annotation comment
5479
// is visually grouped with content above it (= there no content directly below
5580
// the annotation comment(s), but there is content directly above)
5681
const isGroupedWithContentAbove =
57-
!getFirstNonAnnotationCommentLineContents(annotationLineIndex, 'below', options)?.hasNonWhitespaceContent &&
58-
getFirstNonAnnotationCommentLineContents(annotationLineIndex, 'above', options)?.hasNonWhitespaceContent
82+
!getFirstNonAnnotationCommentLineContents(commentLineIndex, 'below', options)?.hasNonWhitespaceContent &&
83+
getFirstNonAnnotationCommentLineContents(commentLineIndex, 'above', options)?.hasNonWhitespaceContent
5984
relativeTargetRange = isGroupedWithContentAbove ? -1 : 1
6085
}
6186
}
6287

63-
// - Perform the search:
64-
// - The target search query can be a simple string, a single-quoted string, a double-quoted
65-
// string, or a regular expression. Regular expressions can optionally contain capture groups,
66-
// which will then be used to determine the target range(s) instead of the full match.
67-
// - The search is performed line by line, starting at the start or end of the annotation comment
68-
// and going in the direction determined by the relative target range that was either given or
69-
// automatically determined as described above.
70-
// - Before searching a line for matches, all characters that lie within the `outerRange` of any
71-
// annotation comment are removed from the line. If matches are found, the matched ranges are
72-
// adjusted to include the removed characters.
73-
// - Each match is added to the `targetRanges` until the number of matches equals the absolute
74-
// value of the relative target range, or the end of the code is reached.
75-
// - In the case of regular expressions with capture groups, a single match can result in multiple
76-
// target ranges, one for each capture group.
88+
// Perform the search
89+
const step = relativeTargetRange > 0 ? 1 : -1
90+
let lineIndex = commentLineIndex
91+
let remainingMatches = Math.abs(relativeTargetRange)
92+
while (lineIndex >= 0 && lineIndex < codeLines.length && remainingMatches > 0) {
93+
// Search all ranges of the line that are not part of an annotation comment
94+
// for matches of the target search query
95+
const lineContents = getNonAnnotationCommentLineContents(lineIndex, options)
96+
const matches = lineContents.contentRanges.flatMap((contentRange) => findSearchQueryMatchesInLine(targetSearchQuery, contentRange, options))
97+
if (matches.length) {
98+
// Go through the matches in the direction of the relative target range
99+
// until we have found the required number of matches
100+
let matchIndex = relativeTargetRange > 0 ? 0 : matches.length - 1
101+
while (matchIndex >= 0 && matchIndex < matches.length && remainingMatches > 0) {
102+
targetRanges.push(matches[matchIndex])
103+
remainingMatches--
104+
matchIndex += step
105+
}
106+
}
107+
lineIndex += step
108+
}
77109
}
78110
})
79111
}
@@ -145,3 +177,65 @@ function getFirstNonAnnotationCommentLineContents(startLineIndex: number, search
145177
lineIndex += step
146178
}
147179
}
180+
181+
/**
182+
* Searches the given column range on a single line for matches of the given search query,
183+
* and returns an array of source ranges that represent the matches.
184+
*/
185+
function findSearchQueryMatchesInLine(searchQuery: string | RegExp, rangeToSearch: SourceRange, options: FindAnnotationTargetsOptions) {
186+
const { codeLines } = options
187+
const lineIndex = rangeToSearch.start.line
188+
const startColumn = rangeToSearch.start.column ?? 0
189+
const content = codeLines[lineIndex].slice(startColumn, rangeToSearch.end.column)
190+
191+
const ranges: SourceRange[] = []
192+
193+
// Handle plaintext string search terms
194+
if (typeof searchQuery === 'string') {
195+
let idx = content.indexOf(searchQuery, 0)
196+
while (idx > -1) {
197+
ranges.push(
198+
createRange({
199+
codeLines,
200+
start: { line: lineIndex, column: startColumn + idx },
201+
end: { line: lineIndex, column: startColumn + idx + searchQuery.length },
202+
})
203+
)
204+
idx = content.indexOf(searchQuery, idx + searchQuery.length)
205+
}
206+
}
207+
208+
// Handle regular expression search terms
209+
if (searchQuery instanceof RegExp) {
210+
const matches = content.matchAll(searchQuery)
211+
for (const match of matches) {
212+
const rawGroupIndices = getGroupIndicesFromRegExpMatch(match)
213+
// Remove null group indices
214+
let groupIndices = rawGroupIndices.flatMap((range) => (range ? [range] : []))
215+
// If there are no non-null indices, use the full match instead
216+
// (capture group feature fallback, impossible to cover in tests)
217+
/* c8 ignore start */
218+
if (!groupIndices.length) {
219+
groupIndices = [[match.index, match.index + match[0].length]]
220+
}
221+
/* c8 ignore end */
222+
// If there are multiple non-null indices, remove the first one
223+
// as it is the full match and we only want to mark capture groups
224+
if (groupIndices.length > 1) {
225+
groupIndices.shift()
226+
}
227+
// Create marked ranges from all remaining group indices
228+
groupIndices.forEach((range) => {
229+
ranges.push(
230+
createRange({
231+
codeLines,
232+
start: { line: lineIndex, column: startColumn + range[0] },
233+
end: { line: lineIndex, column: startColumn + range[1] },
234+
})
235+
)
236+
})
237+
}
238+
}
239+
240+
return ranges
241+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { AnnotationComment, SourceRange } from './types'
22
import { parseAnnotationTags } from '../parsers/annotation-tags'
33
import { parseParentComment } from '../parsers/parent-comment'
44
import { secondRangeIsInFirst } from '../internal/ranges'
5+
import { findAnnotationTargets } from './find-targets'
56

67
export type ParseAnnotationCommentsOptions = {
78
codeLines: string[]
@@ -57,7 +58,8 @@ export function parseAnnotationComments(options: ParseAnnotationCommentsOptions)
5758
previousContentRanges = comment.contentRanges
5859
})
5960

60-
// TODO: Call findAnnotationTargets() here
61+
// Find the target ranges for all annotations
62+
findAnnotationTargets({ codeLines, annotationComments })
6163

6264
return annotationComments
6365
}

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ export type AnnotationComment = {
66
* including the comment's opening and closing syntax.
77
*
88
* Note that multi-line comments can contain multiple annotations and non-annotation content.
9-
* In such cases, {@link AnnotationComment.commentRange} is larger than
10-
* {@link AnnotationComment.annotationRange}.
9+
* In such cases, the comment range is larger than {@link AnnotationComment.annotationRange}.
1110
*/
1211
commentRange: SourceRange
1312
/**

packages/annotation-comments/src/internal/regexps.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,34 @@ export function createGlobalRegExp(pattern: string | RegExp, extraFlags?: string
2323
}
2424
return regExp
2525
}
26+
27+
/**
28+
* Retrieves all group indices from the given RegExp match. Group indices are ranges
29+
* defined by start & end positions. The first group index refers to the full match,
30+
* and the following indices to RegExp capture groups (if any).
31+
*
32+
* If the RegExp flag `d` was enabled (and supported), it returns the native group indices.
33+
*
34+
* Otherwise, it uses fallback logic to manually search for the group contents inside the
35+
* full match. Note that this can be wrong if a group's contents can be found multiple times
36+
* inside the full match, but that's probably a rare case and still better than failing.
37+
*/
38+
export function getGroupIndicesFromRegExpMatch(match: RegExpMatchArray) {
39+
// Read the start and end ranges from the `indices` property,
40+
// which is made available through the RegExp flag `d`
41+
let groupIndices = match.indices as ([start: number, end: number] | null)[]
42+
if (groupIndices?.length) return groupIndices
43+
44+
// We could not access native group indices, so we need to use fallback logic
45+
// to find the position of each capture group match inside the full match
46+
const fullMatchIndex = match.index as number
47+
groupIndices = match.map((groupValue) => {
48+
const groupIndex = groupValue ? match[0].indexOf(groupValue) : -1
49+
if (groupIndex === -1) return null
50+
const groupStart = fullMatchIndex + groupIndex
51+
const groupEnd = groupStart + groupValue.length
52+
return [groupStart, groupEnd]
53+
})
54+
55+
return groupIndices
56+
}

packages/annotation-comments/test/parent-comment.test.ts

Lines changed: 19 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,41 @@ import { describe, expect, test } from 'vitest'
22
import type { AnnotationComment, SourceRange } from '../src/core/types'
33
import { parseAnnotationTags } from '../src/parsers/annotation-tags'
44
import { parseParentComment } from '../src/parsers/parent-comment'
5-
import { splitCodeLines } from './utils'
5+
import { splitCodeLines, validateAnnotationComment } from './utils'
66

77
describe('parseParentComment()', () => {
88
describe('Returns undefined when no valid parent comment is found', () => {
99
describe('Single-line comment syntax', () => {
1010
test('No comment syntax in the same line', () => {
11-
expect(getParentComment(`console.log('This is [!ins] in a string')`)).toEqual(undefined)
11+
expect(getParentComment([`console.log('This is [!ins] in a string'])`])).toEqual(undefined)
1212
})
1313
test('Comment syntax located after the annotation tag', () => {
14-
expect(getParentComment(`console.log('More [!test] text') // Hi!`)).toEqual(undefined)
14+
expect(getParentComment([`console.log('More [!test] text') // Hi!]`])).toEqual(undefined)
1515
})
1616
test('Missing whitespace before comment opening syntax', () => {
17-
expect(getParentComment(`someCode()// [!note] Invalid syntax`)).toEqual(undefined)
17+
expect(getParentComment([`someCode()// [!note] Invalid syntax`])).toEqual(undefined)
1818
})
1919
test('Missing whitespace before annotation tag', () => {
20-
expect(getParentComment(`someCode() //[!note] Invalid syntax`)).toEqual(undefined)
20+
expect(getParentComment([`someCode() //[!note] Invalid syntax`])).toEqual(undefined)
2121
})
2222
test('Content between comment opening and annotation tag', () => {
23-
expect(getParentComment(`someCode() // Hi [!note] This will not work`)).toEqual(undefined)
23+
expect(getParentComment([`someCode() // Hi [!note] This will not work`])).toEqual(undefined)
2424
})
2525
})
2626
describe('Multi-line comment syntax', () => {
2727
test('Content between the opening syntax and the annotation tag', () => {
28-
expect(getParentComment(`/* Hi [!note] This will not work */`)).toEqual(undefined)
29-
expect(getParentComment(`someCode() /* Hi [!note] This will not work */`)).toEqual(undefined)
28+
expect(getParentComment([`/* Hi [!note] This will not work */`])).toEqual(undefined)
29+
expect(getParentComment([`someCode() /* Hi [!note] This will not work */`])).toEqual(undefined)
3030
})
3131
test('Content between the beginning of the line and the annotation tag', () => {
3232
expect(
33-
getParentComment(
34-
[
35-
'someCode()',
36-
'/*',
37-
// Content before the annotation tag
38-
'Hi [!note] This will not work',
39-
'*/',
40-
].join('\n')
41-
)
33+
getParentComment([
34+
'someCode()',
35+
'/*',
36+
// Content before the annotation tag
37+
'Hi [!note] This will not work',
38+
'*/',
39+
])
4240
).toEqual(undefined)
4341
})
4442
})
@@ -616,7 +614,6 @@ describe('parseParentComment()', () => {
616614
validateParentComment({
617615
lines,
618616
contents: ['Look, a note!'],
619-
// Expect the space between the code and the comment to be included
620617
commentRange: { start: { line: 4 }, end: { line: 4 } },
621618
})
622619
})
@@ -636,7 +633,6 @@ describe('parseParentComment()', () => {
636633
validateParentComment({
637634
lines,
638635
contents: ['Look, a note!'],
639-
// Expect the space between the code and the comment to be included
640636
commentRange: { start: { line: 4 }, end: { line: 6 } },
641637
})
642638
})
@@ -716,30 +712,17 @@ console.log('Done!')
716712
`
717713
}
718714

719-
function getParentComment(code: string) {
720-
const codeLines = splitCodeLines(code)
715+
function getParentComment(codeLines: string[]) {
721716
const annotationTags = parseAnnotationTags({ codeLines })
722717
expect(annotationTags.length, 'No annotation tag was found in test code').toBeGreaterThanOrEqual(1)
723718
const tag = annotationTags[0]
724719
return parseParentComment({ codeLines, tag })
725720
}
726721

727722
function validateParentComment(options: { lines: string[]; contents: string[]; commentRange: SourceRange; annotationRange?: SourceRange | undefined }) {
728-
const comment = getParentComment(getTestCode(options.lines.join('\n'))) as AnnotationComment
729-
expect(comment.contents).toEqual(options.contents)
730-
expect(comment.commentRange).toEqual(options.commentRange)
731-
expect(comment.annotationRange).toEqual(options.annotationRange ?? options.commentRange)
723+
const codeLines = splitCodeLines(getTestCode(options.lines.join('\n')))
724+
const comment = getParentComment(codeLines) as AnnotationComment
732725

733-
const contentToSourceRange = (content: string) => {
734-
for (let lineIndex = 0; lineIndex < options.lines.length; lineIndex++) {
735-
const column = options.lines[lineIndex].indexOf(content)
736-
if (column === -1) continue
737-
const range: SourceRange = { start: { line: lineIndex + 2 }, end: { line: lineIndex + 2 } }
738-
if (column > 0) range.start.column = column
739-
if (column + content.length < options.lines[lineIndex].length) range.end.column = column + content.length
740-
return range
741-
}
742-
}
743-
expect(comment.contentRanges).toEqual(options.contents.map(contentToSourceRange))
726+
validateAnnotationComment(comment, codeLines, options)
744727
}
745728
})

0 commit comments

Comments
 (0)