11import 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
45export type FindAnnotationTargetsOptions = {
56 codeLines : string [ ]
@@ -9,71 +10,102 @@ export type FindAnnotationTargetsOptions = {
910export 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+ }
0 commit comments