Skip to content

Commit 58254e5

Browse files
committed
Add annotation target range tests, minor refactoring
1 parent 9f4a9a8 commit 58254e5

12 files changed

Lines changed: 1026 additions & 248 deletions

File tree

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import type { AnnotationComment } from './types'
1+
import type { AnnotatedCode, AnnotationComment } from './types'
22

3-
export type CleanCodeOptions = {
4-
codeLines: string[]
5-
annotationComments: AnnotationComment[]
3+
export type CleanCodeOptions = AnnotatedCode & {
64
removeAnnotationContents?: boolean | ((context: RemoveAnnotationContentsContext) => boolean)
75
updateTargetRanges?: boolean
86
handleRemoveLine?: (context: HandleRemoveLineContext) => boolean
Lines changed: 21 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
1-
import type { AnnotationComment, SourceRange } from './types'
2-
import { compareRanges, createRange, createSingleLineRange } from '../internal/ranges'
3-
import { getGroupIndicesFromRegExpMatch } from '../internal/regexps'
1+
import type { AnnotatedCode } from './types'
2+
import { findSearchQueryMatchesInLine, getFirstNonAnnotationCommentLineContents, getNonAnnotationCommentLineContents } from '../internal/text-content'
3+
import { compareRanges, createSingleLineRange } from '../internal/ranges'
44

5-
export type FindAnnotationTargetsOptions = {
6-
codeLines: string[]
7-
annotationComments: AnnotationComment[]
8-
}
9-
10-
export function findAnnotationTargets(options: FindAnnotationTargetsOptions) {
11-
const { codeLines, annotationComments } = options
5+
export function findAnnotationTargets(annotatedCode: AnnotatedCode) {
6+
const { codeLines, annotationComments } = annotatedCode
127

138
annotationComments.forEach((comment) => {
149
const { tag, commentRange, annotationRange, targetRanges } = comment
@@ -18,7 +13,7 @@ export function findAnnotationTargets(options: FindAnnotationTargetsOptions) {
1813
if (tag.name === 'ignore-tags') return
1914

2015
const commentLineIndex = commentRange.start.line
21-
const commentLineContents = getNonAnnotationCommentLineContents(commentLineIndex, options)
16+
const commentLineContents = getNonAnnotationCommentLineContents(commentLineIndex, annotatedCode)
2217
let { relativeTargetRange } = tag
2318
const { targetSearchQuery } = tag
2419

@@ -32,12 +27,12 @@ export function findAnnotationTargets(options: FindAnnotationTargetsOptions) {
3227
return targetRanges.push(createSingleLineRange(commentLineIndex))
3328
}
3429
// Otherwise, scan for a target line below
35-
const potentialTargetBelow = getFirstNonAnnotationCommentLineContents(commentLineIndex, 'below', options)
30+
const potentialTargetBelow = getFirstNonAnnotationCommentLineContents(commentLineIndex, 'below', annotatedCode)
3631
if (potentialTargetBelow?.hasNonWhitespaceContent) {
3732
return targetRanges.push(createSingleLineRange(potentialTargetBelow.lineIndex))
3833
}
3934
// Finally, scan for a target line above
40-
const potentialTargetAbove = getFirstNonAnnotationCommentLineContents(commentLineIndex, 'above', options)
35+
const potentialTargetAbove = getFirstNonAnnotationCommentLineContents(commentLineIndex, 'above', annotatedCode)
4136
if (potentialTargetAbove?.hasNonWhitespaceContent) {
4237
return targetRanges.push(createSingleLineRange(potentialTargetAbove.lineIndex))
4338
}
@@ -51,36 +46,29 @@ export function findAnnotationTargets(options: FindAnnotationTargetsOptions) {
5146
let lineIndex = commentLineIndex
5247
let remainingLines = Math.abs(relativeTargetRange)
5348
while (lineIndex >= 0 && lineIndex < codeLines.length && remainingLines > 0) {
54-
// Check if the line is a valid target
55-
const lineContents = getNonAnnotationCommentLineContents(lineIndex, options)
49+
// Check if the line is a valid target (annotation comment lines are skipped)
50+
const lineContents = getNonAnnotationCommentLineContents(lineIndex, annotatedCode)
5651
if (lineContents.contentRanges.length) {
5752
targetRanges.push(createSingleLineRange(lineIndex))
5853
remainingLines--
5954
}
6055
lineIndex += step
6156
}
62-
}
63-
64-
// If a target search query is present, perform the search to determine the target range(s)
65-
if (targetSearchQuery) {
66-
// Read the direction and number of matches to find from the tag,
67-
// or auto-detect this in case no relative target range was given
57+
} else {
58+
// A target search query is present, so we need to search for target ranges,
59+
// but first we need to ensure we know the direction and number of matches to find
6860
if (relativeTargetRange === undefined) {
6961
if (commentLineContents.hasNonWhitespaceContent) {
70-
// The annotation comment is on the same line as content, so the direction
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
75-
)
76-
relativeTargetRange = hasContentBeforeAnnotation ? -1 : 1
62+
// The annotation comment is on the same line as content,
63+
// so it starts searching at this line and goes downwards
64+
relativeTargetRange = 1
7765
} else {
7866
// Otherwise, the direction defaults to downwards, unless the annotation comment
7967
// is visually grouped with content above it (= there no content directly below
8068
// the annotation comment(s), but there is content directly above)
8169
const isGroupedWithContentAbove =
82-
!getFirstNonAnnotationCommentLineContents(commentLineIndex, 'below', options)?.hasNonWhitespaceContent &&
83-
getFirstNonAnnotationCommentLineContents(commentLineIndex, 'above', options)?.hasNonWhitespaceContent
70+
!getFirstNonAnnotationCommentLineContents(commentLineIndex, 'below', annotatedCode)?.hasNonWhitespaceContent &&
71+
getFirstNonAnnotationCommentLineContents(commentLineIndex, 'above', annotatedCode)?.hasNonWhitespaceContent
8472
relativeTargetRange = isGroupedWithContentAbove ? -1 : 1
8573
}
8674
}
@@ -92,8 +80,7 @@ export function findAnnotationTargets(options: FindAnnotationTargetsOptions) {
9280
while (lineIndex >= 0 && lineIndex < codeLines.length && remainingMatches > 0) {
9381
// Search all ranges of the line that are not part of an annotation comment
9482
// for matches of the target search query
95-
const lineContents = getNonAnnotationCommentLineContents(lineIndex, options)
96-
const matches = lineContents.contentRanges.flatMap((contentRange) => findSearchQueryMatchesInLine(targetSearchQuery, contentRange, options))
83+
const matches = findSearchQueryMatchesInLine(lineIndex, targetSearchQuery, annotatedCode)
9784
if (matches.length) {
9885
// Go through the matches in the direction of the relative target range
9986
// until we have found the required number of matches
@@ -107,135 +94,7 @@ export function findAnnotationTargets(options: FindAnnotationTargetsOptions) {
10794
lineIndex += step
10895
}
10996
}
97+
// In case of a negative direction, fix the potentially mixed up order of target ranges
98+
if (relativeTargetRange < 0) targetRanges.sort((a, b) => compareRanges(b, a, 'start'))
11099
})
111100
}
112-
113-
/**
114-
* Examines the given code line and annotation comments, and returns information about
115-
* the contents of this line that do NOT belong to annotation comments (if any).
116-
*/
117-
function getNonAnnotationCommentLineContents(lineIndex: number, options: FindAnnotationTargetsOptions) {
118-
const { codeLines, annotationComments } = options
119-
const lineLength = codeLines[lineIndex].length
120-
const contentRanges: SourceRange[] = [{ start: { line: lineIndex, column: 0 }, end: { line: lineIndex, column: lineLength } }]
121-
122-
annotationComments.forEach((comment) => {
123-
const { commentRange } = comment
124-
// Ignore the current comment if it's outside the current line
125-
if (commentRange.start.line > lineIndex || commentRange.end.line < lineIndex) return
126-
// Otherwise, go through all non-annotation ranges to remove, cut, or split them
127-
// if they intersect with with the current comment
128-
const commentStartColumn = commentRange.start.line === lineIndex ? (commentRange.start.column ?? 0) : 0
129-
const commentEndColumn = commentRange.end.line === lineIndex ? (commentRange.end.column ?? lineLength) : lineLength
130-
for (let i = contentRanges.length - 1; i >= 0; i--) {
131-
const nonAnnotationRange = contentRanges[i]
132-
const rangeStartColumn = nonAnnotationRange.start.column ?? 0
133-
const rangeEndColumn = nonAnnotationRange.end.column ?? lineLength
134-
if (commentStartColumn <= rangeStartColumn && commentEndColumn >= rangeEndColumn) {
135-
// The comment completely covers the range, so remove it
136-
contentRanges.splice(i, 1)
137-
} else if (commentStartColumn <= rangeStartColumn && commentEndColumn < rangeEndColumn) {
138-
// The comment overlaps with the start of the range, so adjust the range start
139-
nonAnnotationRange.start.column = commentEndColumn
140-
} else if (commentStartColumn > rangeStartColumn && commentEndColumn >= rangeEndColumn) {
141-
// The comment overlaps with the end of the range, so adjust the range end
142-
nonAnnotationRange.end.column = commentStartColumn
143-
} else if (commentStartColumn > rangeStartColumn && commentEndColumn < rangeEndColumn) {
144-
// The comment is inside the range, so split the range into two
145-
// ...by making the current range end before the comment
146-
nonAnnotationRange.end.column = commentStartColumn
147-
// ...and inserting a new range that starts after the comment
148-
contentRanges.splice(i + 1, 0, { start: { line: lineIndex, column: commentEndColumn }, end: { line: lineIndex, column: rangeEndColumn } })
149-
}
150-
}
151-
})
152-
153-
const nonWhitespaceContentRanges = contentRanges.filter((range) => codeLines[lineIndex].slice(range.start.column, range.end.column).search(/\S/) > -1)
154-
155-
return {
156-
lineIndex,
157-
contentRanges,
158-
nonWhitespaceContentRanges,
159-
hasNonWhitespaceContent: nonWhitespaceContentRanges.length > 0,
160-
}
161-
}
162-
163-
/**
164-
* Returns information about the contents of the first non-annotation comment line
165-
* above or below the given start line index.
166-
*
167-
* If no such line is found, returns `undefined`.
168-
*/
169-
function getFirstNonAnnotationCommentLineContents(startLineIndex: number, searchDirection: 'above' | 'below', options: FindAnnotationTargetsOptions) {
170-
const { codeLines } = options
171-
const lineCount = codeLines.length
172-
const step = searchDirection === 'above' ? -1 : 1
173-
let lineIndex = startLineIndex + step
174-
while (lineIndex >= 0 && lineIndex < lineCount) {
175-
const contents = getNonAnnotationCommentLineContents(lineIndex, options)
176-
if (contents.contentRanges.length) return contents
177-
lineIndex += step
178-
}
179-
}
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/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,8 @@ export type SourceRange = {
8585
/** The end (line & optional column) of the range. */
8686
end: SourceLocation
8787
}
88+
89+
export type AnnotatedCode = {
90+
codeLines: string[]
91+
annotationComments: AnnotationComment[]
92+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export type * from './core/types'
22
export * from './core/parse'
3-
export * from './core/remove'
3+
export * from './core/clean'
44

55
export * from './parsers/annotation-tags'
66
export * from './parsers/parent-comment'

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export function createSingleLineRange(lineIndex: number): SourceRange {
1818
return { start: { line: lineIndex }, end: { line: lineIndex } }
1919
}
2020

21+
export function createSingleLineRanges(...lineIndices: number[]): SourceRange[] {
22+
return lineIndices.map((lineIndex) => ({ start: { line: lineIndex }, end: { line: lineIndex } }))
23+
}
24+
2125
/**
2226
* Compares two source ranges by their start or end locations.
2327
*

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,40 @@ export function getGroupIndicesFromRegExpMatch(match: RegExpMatchArray) {
5454

5555
return groupIndices
5656
}
57+
58+
/**
59+
* Searches the given content for matches of the given regular expression,
60+
* and returns the column ranges of all matches.
61+
*
62+
* If the regular expression contains capture groups, it will use the column ranges
63+
* of the matched capture groups instead of the full match ranges.
64+
*/
65+
export function findRegExpMatchColumnRanges(content: string, regExp: RegExp) {
66+
const columnRanges: { start: number; end: number }[] = []
67+
const matches = content.matchAll(regExp)
68+
for (const match of matches) {
69+
const rawGroupIndices = getGroupIndicesFromRegExpMatch(match)
70+
// Remove null group indices
71+
let groupIndices = rawGroupIndices.flatMap((range) => (range ? [range] : []))
72+
// If there are no non-null indices, use the full match instead
73+
// (capture group feature fallback, impossible to cover in tests)
74+
/* c8 ignore start */
75+
if (!groupIndices.length) {
76+
groupIndices = [[match.index, match.index + match[0].length]]
77+
}
78+
/* c8 ignore end */
79+
// If there are multiple non-null indices, remove the first one
80+
// as it is the full match and we only want to mark capture groups
81+
if (groupIndices.length > 1) {
82+
groupIndices.shift()
83+
}
84+
// Create marked ranges from all remaining group indices
85+
groupIndices.forEach((range) => {
86+
columnRanges.push({
87+
start: range[0],
88+
end: range[1],
89+
})
90+
})
91+
}
92+
return columnRanges
93+
}

0 commit comments

Comments
 (0)