Skip to content

Commit c02d19c

Browse files
committed
Add RegExp support to tag parser
1 parent 7189707 commit c02d19c

6 files changed

Lines changed: 127 additions & 14 deletions

File tree

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,12 @@ export type AnnotationTag = {
1818
* located inside the annotation tag.
1919
*
2020
* This query can be used to search for the target of the annotation.
21-
* It can be a plaintext term, optionally within single or double quotes,
22-
* or a regular expression within forward slashes `/`.
21+
* It can be a string or a regular expression.
2322
*
2423
* Example: The tag `[!ins:Astro.props]` targets the next occurrence
2524
* of the plaintext search term `Astro.props`.
2625
*/
27-
targetSearchQuery?: string | undefined
26+
targetSearchQuery?: string | RegExp | undefined
2827
/**
2928
* The optional relative target range of the annotation,
3029
* located inside the annotation tag.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Converts possible try-catch error value types to a standard object format.
3+
*
4+
* @param error Error object, which could be string, Error, or ResolveMessage.
5+
* @returns object containing message and, if present, error code.
6+
*/
7+
export function coerceError(error: unknown): { message: string; code?: string | undefined } {
8+
if (typeof error === 'object' && error !== null && 'message' in error) {
9+
return error as { message: string; code?: string | undefined }
10+
}
11+
return { message: error as string }
12+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@ export function escapeRegExp(input: string) {
1212
* to be escaped in the first place.
1313
*/
1414
export function getEscapeSequenceRegExp(...valueEndDelimiters: string[]): RegExp {
15-
return new RegExp(`\\\\(${['\\', ...valueEndDelimiters].map(escapeRegExp).join('|')})`, 'g')
15+
return new RegExp(`\\\\(${valueEndDelimiters.map(escapeRegExp).join('|')})`, 'g')
1616
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { coerceError } from './errors'
2+
3+
/**
4+
* Takes a string or existing regular expression as input and returns a regular expression
5+
* that has the global flag set.
6+
*
7+
* If supported by the platform this code is running on, it will also set the `d` flag that
8+
* enables capture group indices.
9+
*/
10+
export function parseAsGlobalRegExp(pattern: string | RegExp, extraFlags?: string): RegExp {
11+
let regExp: RegExp | undefined
12+
try {
13+
// Try to use regular expressions with capture group indices
14+
regExp = new RegExp(pattern, 'gd' + (extraFlags || ''))
15+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
16+
} catch (_error) {
17+
try {
18+
// Use fallback if unsupported
19+
regExp = new RegExp(pattern, 'g' + (extraFlags || ''))
20+
} catch (error) {
21+
throw new Error(`Failed to parse \`${pattern}\` as regular expression: ${coerceError(error).message}`)
22+
}
23+
}
24+
return regExp
25+
}

packages/annotation-comments/src/internal/tag-parser.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AnnotationTag } from '../core/types'
22
import { getEscapeSequenceRegExp } from './escaping'
3+
import { parseAsGlobalRegExp } from './regexps'
34

45
type ParseAnnotationTagsOptions = {
56
codeLines: string[]
@@ -73,20 +74,28 @@ const annotationTagRegex = new RegExp(
7374
'g'
7475
)
7576

76-
const unquotedValueEscapeSequence = getEscapeSequenceRegExp(':', ']')
77-
const quotedValueEscapeSequences = new Map<string, RegExp>([
78-
['"', getEscapeSequenceRegExp('"')],
79-
["'", getEscapeSequenceRegExp("'")],
77+
const plainValueEscapeSequence = getEscapeSequenceRegExp('\\', ':', ']')
78+
const delimitedValueEscapeSequences = new Map<string, RegExp>([
79+
['"', getEscapeSequenceRegExp('\\', '"')],
80+
["'", getEscapeSequenceRegExp('\\', "'")],
8081
['/', getEscapeSequenceRegExp('/')],
8182
])
8283

83-
function parseTargetSearchQuery(rawTargetSearchQuery: string | undefined): string | undefined {
84+
function parseTargetSearchQuery(rawTargetSearchQuery: string | undefined): string | RegExp | undefined {
8485
if (rawTargetSearchQuery === undefined || rawTargetSearchQuery === '') return
85-
const quotedValueEscapeSequence = quotedValueEscapeSequences.get(rawTargetSearchQuery[0])
86-
const escapeSequenceRegExp = quotedValueEscapeSequence || unquotedValueEscapeSequence
87-
const unescapedQuery = rawTargetSearchQuery.replace(escapeSequenceRegExp, '$1')
88-
const queryWithoutQuotes = quotedValueEscapeSequence === undefined ? unescapedQuery : unescapedQuery.slice(1, -1)
89-
return queryWithoutQuotes
86+
const delimiter = rawTargetSearchQuery[0]
87+
const delimitedValueEscapeSequence = delimitedValueEscapeSequences.get(delimiter)
88+
const escapeSequenceRegExp = delimitedValueEscapeSequence || plainValueEscapeSequence
89+
const undelimitedQuery = delimitedValueEscapeSequence === undefined ? rawTargetSearchQuery : rawTargetSearchQuery.slice(1, -1)
90+
const unescapedQuery = undelimitedQuery.replace(escapeSequenceRegExp, '$1')
91+
92+
// If the delimiter was a slash, try to parse the value as a regular expression and return it
93+
if (delimiter === '/') {
94+
return parseAsGlobalRegExp(unescapedQuery)
95+
}
96+
97+
// Otherwise, return the unescaped query as a string
98+
return unescapedQuery
9099
}
91100

92101
export function parseAnnotationTags(options: ParseAnnotationTagsOptions): AnnotationTag[] {

packages/annotation-comments/test/tag-parser.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AnnotationTag } from 'annotation-comments'
22
import { describe, expect, test } from 'vitest'
33
import { parseAnnotationTags } from '../src/internal/tag-parser'
4+
import { parseAsGlobalRegExp } from '../src/internal/regexps'
45

56
describe('parseAnnotationTags', () => {
67
test('Returns an empty array when no annotation tags are found', () => {
@@ -150,6 +151,15 @@ console.log('Some code');
150151
relativeTargetRange: undefined,
151152
})
152153
})
154+
155+
test(`[!tag:"C:\\Users\\Test Path"]`, ({ task }) => {
156+
performTagTest({
157+
rawTag: task.name,
158+
name: 'tag',
159+
targetSearchQuery: `C:\\Users\\Test Path`,
160+
relativeTargetRange: undefined,
161+
})
162+
})
153163
})
154164

155165
describe('Tags with quoted target search query and target range', () => {
@@ -171,6 +181,64 @@ console.log('Some code');
171181
})
172182
})
173183
})
184+
185+
describe('Tags with RegExp target search query', () => {
186+
test(`[!tag:/reg(exp)|regular (expression)/]`, ({ task }) => {
187+
performTagTest({
188+
rawTag: task.name,
189+
name: 'tag',
190+
targetSearchQuery: parseAsGlobalRegExp(/reg(exp)|regular (expression)/),
191+
relativeTargetRange: undefined,
192+
})
193+
})
194+
195+
test(`[!tag:/with escaped \\\\ backslash and \\/ slash/]`, ({ task }) => {
196+
performTagTest({
197+
rawTag: task.name,
198+
name: 'tag',
199+
targetSearchQuery: parseAsGlobalRegExp(/with escaped \\ backslash and \/ slash/),
200+
relativeTargetRange: undefined,
201+
})
202+
})
203+
204+
test(`[!tag:/regexp[s]?|"regular\\s+\\w{2}pressions?"/]`, ({ task }) => {
205+
performTagTest({
206+
rawTag: task.name,
207+
name: 'tag',
208+
targetSearchQuery: parseAsGlobalRegExp(/regexp[s]?|"regular\s+\w{2}pressions?"/),
209+
relativeTargetRange: undefined,
210+
})
211+
})
212+
})
213+
214+
describe('Tags with RegExp target search query and target range', () => {
215+
test(`[!tag:/reg(exp)|regular (expression)/:5]`, ({ task }) => {
216+
performTagTest({
217+
rawTag: task.name,
218+
name: 'tag',
219+
targetSearchQuery: parseAsGlobalRegExp(/reg(exp)|regular (expression)/),
220+
relativeTargetRange: 5,
221+
})
222+
})
223+
224+
test(`[!tag:/with escaped \\\\ backslash and \\/ slash/:-3]`, ({ task }) => {
225+
performTagTest({
226+
rawTag: task.name,
227+
name: 'tag',
228+
targetSearchQuery: parseAsGlobalRegExp(/with escaped \\ backslash and \/ slash/),
229+
relativeTargetRange: -3,
230+
})
231+
})
232+
233+
test(`[!tag:/regexp[s]?|"regular\\s+\\w{2}pressions?"/:1]`, ({ task }) => {
234+
performTagTest({
235+
rawTag: task.name,
236+
name: 'tag',
237+
targetSearchQuery: parseAsGlobalRegExp(/regexp[s]?|"regular\s+\w{2}pressions?"/),
238+
relativeTargetRange: 1,
239+
})
240+
})
241+
})
174242
})
175243

176244
function performTagTest(test: Required<Omit<AnnotationTag, 'location'>>) {

0 commit comments

Comments
 (0)