Skip to content

Commit 08fda18

Browse files
committed
Add parseAnnotationComments tests, handle chaining & nesting
1 parent 0ba54f0 commit 08fda18

4 files changed

Lines changed: 298 additions & 37 deletions

File tree

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,34 @@
1-
import type { AnnotationComment } from './types'
1+
import type { AnnotationComment, SourceRange } from './types'
22
import { parseAnnotationTags } from '../parsers/annotation-tags'
33
import { parseParentComment } from '../parsers/parent-comment'
4+
import { secondRangeIsInFirst } from '../internal/ranges'
45

56
export type ParseAnnotationCommentsOptions = {
67
codeLines: string[]
7-
validateAnnotationName?: (name: string) => boolean
88
}
99

1010
export function parseAnnotationComments(options: ParseAnnotationCommentsOptions): AnnotationComment[] {
11-
const { codeLines, validateAnnotationName } = options
11+
const { codeLines } = options
1212
const annotationComments: AnnotationComment[] = []
1313

14-
// Find annotation tags
14+
// Find annotation tags in the code
1515
const annotationTags = parseAnnotationTags({ codeLines })
16+
17+
let previousCommentRanges: SourceRange[] = []
1618
annotationTags.forEach((tag) => {
17-
// Ensure that the current annotation tag has not been ignored by an ´ignore-tags` directive. If it has, it will skip the tag and continue searching
18-
// If given, call the `validateAnnotationName` handler function to check if the annotation name is valid. If this function returns `false`, skip the tag and continue searching
19+
// Ignore the current tag if it is located inside the content ranges
20+
// of the previously processed annotation comment
21+
if (previousCommentRanges.some((range) => secondRangeIsInFirst(range, tag.range))) return
22+
23+
// TODO: Handle `[!ignore-tags]` logic
1924

2025
// Attempt to find a comment that the current annotation tag is located in
2126
const comment = parseParentComment({ codeLines, tag })
2227
if (!comment) return
2328

2429
// If a comment was found, add the tag and comment to the list of annotation comments
30+
annotationComments.push(comment)
31+
previousCommentRanges = comment.contentRanges
2532
})
2633

2734
return annotationComments
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { SourceRange } from '../core/types'
2+
3+
export function createRange(options: { line: string; lineIndex: number; startColumn: number; endColumn: number }) {
4+
const { line, lineIndex, startColumn, endColumn } = options
5+
const range: SourceRange = {
6+
start: { line: lineIndex },
7+
end: { line: lineIndex },
8+
}
9+
if (startColumn > 0) range.start.column = startColumn
10+
if (endColumn < line.length) range.end.column = endColumn
11+
return range
12+
}
13+
14+
/**
15+
* Compares two source ranges by their start or end locations.
16+
*
17+
* Returns:
18+
* - `> 0` if the second location is **greater than** (comes after) the first,
19+
* - `< 0` if the second location is **smaller than** (comes before) the first, or
20+
* - `0` if they are equal.
21+
*/
22+
export function compareRanges(a: SourceRange, b: SourceRange, prop: 'start' | 'end'): number {
23+
// Compare line numbers first
24+
const lineResult = b[prop].line - a[prop].line
25+
if (lineResult !== 0) return lineResult
26+
27+
// Line numbers are equal, so compare columns
28+
const aCol = a[prop].column
29+
const bCol = b[prop].column
30+
31+
// If both columns are undefined, the ranges are equal
32+
if (aCol === undefined && bCol === undefined) return 0
33+
34+
// If only one column is undefined (= covers the full line),
35+
// the other column starts after and ends before it
36+
if (aCol === undefined) return prop === 'start' ? 1 : -1
37+
if (bCol === undefined) return prop === 'start' ? -1 : 1
38+
39+
return bCol - aCol
40+
}
41+
42+
export function secondRangeIsInFirst(potentialOuterRange: SourceRange, rangeToTest: SourceRange): boolean {
43+
return (
44+
// To be in range, rangeToTest must start at or after potentialOuterRange...
45+
compareRanges(potentialOuterRange, rangeToTest, 'start') >= 0 &&
46+
// ...and end at or before potentialOuterRange
47+
compareRanges(potentialOuterRange, rangeToTest, 'end') <= 0
48+
)
49+
}

packages/annotation-comments/src/parsers/comment-types/multi-line.ts

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AnnotationComment, AnnotationTag, SourceRange } from '../../core/types'
22
import type { ParseParentCommentOptions } from '../parent-comment'
33
import { escapeRegExp } from '../../internal/escaping'
4+
import { compareRanges, createRange } from '../../internal/ranges'
45
import { getTextContentInLine } from '../text-content'
56

67
type MultiLineCommentSyntax = {
@@ -391,28 +392,3 @@ function isValidFullMatch(match: Partial<MultiLineCommentSyntaxMatch>): match is
391392

392393
return true
393394
}
394-
395-
function createRange(options: { line: string; lineIndex: number; startColumn: number; endColumn: number }) {
396-
const { line, lineIndex, startColumn, endColumn } = options
397-
const range: SourceRange = {
398-
start: { line: lineIndex },
399-
end: { line: lineIndex },
400-
}
401-
if (startColumn > 0) range.start.column = startColumn
402-
if (endColumn < line.length) range.end.column = endColumn
403-
return range
404-
}
405-
406-
/**
407-
* Compares two source ranges by their start or end locations.
408-
*
409-
* Returns:
410-
* - `> 0` if the second location is **greater than** (comes after) the first,
411-
* - `< 0` if the second location is **smaller than** (comes before) the first, or
412-
* - `0` if they are equal.
413-
*/
414-
function compareRanges(a: SourceRange, b: SourceRange, prop: 'start' | 'end'): number {
415-
const aCol = a[prop].column ?? 0
416-
const bCol = b[prop].column ?? 0
417-
return a[prop].line - b[prop].line || aCol - bCol
418-
}
Lines changed: 235 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,241 @@
11
import { describe, expect, test } from 'vitest'
2+
import type { AnnotationComment, AnnotationTag } from '../src/core/types'
3+
import { parseAnnotationComments } from '../src/core/parse'
4+
import { splitCodeLines } from './utils'
5+
6+
type PartialAnnotationComment = Partial<Omit<AnnotationComment, 'tag'>> & { tag: Partial<AnnotationTag> }
27

38
describe('parseAnnotationComments()', () => {
4-
// TODO: We need to test if the parser properly skips processing the second annotation tag
5-
// in the following code, which requires checking the comment range of the previously processed
6-
// annotation comment and skipping all tags located before the end of the comment range:
7-
// "someCode() // [!note] Mismatching comment # [!syntax] also prevents chaining"
8-
test('Todo', () => {
9-
expect(true).toBe(true)
9+
describe('Successfully parses code examples in various languages', () => {
10+
test('JavaScript (including JSDoc)', () => {
11+
const jsTestCode = `
12+
import { defineConfig } from 'astro/config';
13+
14+
/**
15+
* Some JSDoc test.
16+
*
17+
* [!note:test] The \`test\` function here is just an example
18+
* and doesn't do anything meaningful.
19+
*
20+
* Also note that we just added a [!note] tag to an existing
21+
* JSDoc comment to create this note.
22+
*/
23+
export async function test(a, b, c) {
24+
let x = true
25+
let y = a.toString()
26+
const z = \`hello\${a === true ? 'x' : 'y'}\`
27+
const fn = () => "test\\nanother line"
28+
}
29+
30+
// Single-line comment
31+
var v = 300 // [!ins]
32+
test(1, Math.min(6, 2), defineConfig.someProp || v)
33+
34+
export default defineConfig({
35+
markdown: {
36+
// [!note:"'some.example'"] This setting does not actually exist.
37+
'some.example': 2048,
38+
smartypants: false, // [!ins]
39+
/* [!ins] */ gfm: false,
40+
}
41+
});
42+
`.trim()
43+
const comments = getComments(jsTestCode)
44+
expect(comments).toHaveLength(5)
45+
expect(comments).toMatchObject([
46+
{
47+
tag: { name: 'note', targetSearchQuery: 'test' },
48+
contents: [
49+
`The \`test\` function here is just an example`,
50+
`and doesn't do anything meaningful.`,
51+
``,
52+
`Also note that we just added a [!note] tag to an existing`,
53+
`JSDoc comment to create this note.`,
54+
],
55+
},
56+
{
57+
tag: { name: 'ins', targetSearchQuery: undefined },
58+
contents: [],
59+
},
60+
{
61+
tag: { name: 'note', targetSearchQuery: "'some.example'" },
62+
contents: [`This setting does not actually exist.`],
63+
},
64+
{
65+
tag: { name: 'ins', targetSearchQuery: undefined },
66+
contents: [],
67+
},
68+
{
69+
tag: { name: 'ins', targetSearchQuery: undefined },
70+
contents: [],
71+
},
72+
] as PartialAnnotationComment[])
73+
})
74+
75+
test('CSS', () => {
76+
const cssTestCode = `
77+
@media (min-width: 50em) {
78+
:root {
79+
--min-spacing-inline: calc(0.5vw - 1.5rem); /* [!ins] */
80+
/* [!del] */ color: blue;
81+
}
82+
body, html, .test[data-size="large"], #id {
83+
/* [!note:linear-gradient]
84+
As this [!note] points out, we let the browser
85+
create a gradient for us here. */
86+
background: linear-gradient(to top, #80f 1px, rgb(30, 90, 130) 50%);
87+
}
88+
.frame:focus-within :focus-visible ~ .copy button:not(:hover) {
89+
content: 'Hello \\000026 welcome!';
90+
opacity: 0.75;
91+
}
92+
}
93+
`.trim()
94+
const comments = getComments(cssTestCode)
95+
expect(comments).toHaveLength(3)
96+
expect(comments).toMatchObject([
97+
{
98+
tag: { name: 'ins', targetSearchQuery: undefined },
99+
contents: [],
100+
},
101+
{
102+
tag: { name: 'del', targetSearchQuery: undefined },
103+
contents: [],
104+
},
105+
{
106+
tag: { name: 'note', targetSearchQuery: 'linear-gradient' },
107+
contents: [
108+
// Content lines
109+
`As this [!note] points out, we let the browser`,
110+
`create a gradient for us here.`,
111+
],
112+
},
113+
] as PartialAnnotationComment[])
114+
})
115+
116+
test('Astro', () => {
117+
const astroTestCode = `
118+
---
119+
import Header from './Header.astro';
120+
import Logo from './Logo.astro';
121+
import Footer from './Footer.astro';
122+
123+
// [!note:title] By destructuring the \`Astro.props\` object,
124+
// we can access the \`title\` prop passed to this component.
125+
const { title } = Astro.props
126+
---
127+
<div id="content-wrapper" class="test">
128+
<Header />
129+
<Logo size="large"/>
130+
<!-- [!note:{title}] By wrapping any variable name in curly braces,
131+
we can output its value in the HTML template,
132+
as explained by this [!note] annotation. -->
133+
<h1>{title} &amp; some text</h1>
134+
<slot /> <!-- [!note] Children passed to the component will be inserted here -->
135+
<Footer />
136+
</div>
137+
`.trim()
138+
const comments = getComments(astroTestCode)
139+
expect(comments).toHaveLength(3)
140+
expect(comments).toMatchObject([
141+
{
142+
tag: { name: 'note', targetSearchQuery: 'title' },
143+
contents: [
144+
// Content lines
145+
`By destructuring the \`Astro.props\` object,`,
146+
`we can access the \`title\` prop passed to this component.`,
147+
],
148+
},
149+
{
150+
tag: { name: 'note', targetSearchQuery: '{title}' },
151+
contents: [
152+
// Content lines
153+
`By wrapping any variable name in curly braces,`,
154+
`we can output its value in the HTML template,`,
155+
`as explained by this [!note] annotation.`,
156+
],
157+
},
158+
{
159+
tag: { name: 'note', targetSearchQuery: undefined },
160+
contents: [`Children passed to the component will be inserted here`],
161+
},
162+
] as PartialAnnotationComment[])
163+
})
164+
165+
test('Python', () => {
166+
const pythonTestCode = `
167+
import time
168+
169+
# [!note] This function will print a countdown from the given time,
170+
# as explained by this # [!note] annotation.
171+
def countdown(time_sec):
172+
while time_sec:
173+
mins, secs = divmod(time_sec, 60)
174+
timeformat = '{:02d}:{:02d}'.format(mins, secs)
175+
print(timeformat, end='\\r')
176+
time.sleep(1)
177+
time_sec -= 1 # [!note] This is important to actually count down
178+
print("stop")
179+
180+
countdown(5)
181+
`.trim()
182+
const comments = getComments(pythonTestCode)
183+
expect(comments).toMatchObject([
184+
{
185+
tag: { name: 'note', targetSearchQuery: undefined },
186+
contents: [
187+
// Content lines
188+
`This function will print a countdown from the given time,`,
189+
`as explained by this # [!note] annotation.`,
190+
],
191+
},
192+
{
193+
tag: { name: 'note', targetSearchQuery: undefined },
194+
contents: [`This is important to actually count down`],
195+
},
196+
] as PartialAnnotationComment[])
197+
})
10198
})
199+
200+
test('Supports chaining multiple matching single-line annotations on the same line', () => {
201+
const lines = [
202+
`// [!note] This is the note content. // [!ins]`,
203+
`console.log('Inserted line with an attached note')`,
204+
`testCode() // [!mark] // [!note] It also works at the end of a line.`,
205+
]
206+
const comments = getComments(lines.join('\n'))
207+
expect(comments).toHaveLength(4)
208+
expect(comments).toMatchObject([
209+
{
210+
tag: { name: 'note', targetSearchQuery: undefined },
211+
commentRange: { start: { line: 0 }, end: { line: 0, column: lines[0].indexOf(' // [!ins]') } },
212+
contents: [`This is the note content.`],
213+
},
214+
{
215+
tag: { name: 'ins', targetSearchQuery: undefined },
216+
commentRange: { start: { line: 0, column: lines[0].indexOf(' // [!ins]') }, end: { line: 0 } },
217+
contents: [],
218+
},
219+
{
220+
tag: { name: 'mark', targetSearchQuery: undefined },
221+
commentRange: {
222+
start: { line: 2, column: lines[2].indexOf(' // [!mark]') },
223+
end: { line: 2, column: lines[2].indexOf(' // [!note]') },
224+
},
225+
contents: [],
226+
},
227+
{
228+
tag: { name: 'note', targetSearchQuery: undefined },
229+
commentRange: { start: { line: 2, column: lines[2].indexOf(' // [!note]') }, end: { line: 2 } },
230+
contents: [`It also works at the end of a line.`],
231+
},
232+
] as PartialAnnotationComment[])
233+
})
234+
11235
// TODO: We also need to implement and test the `[!ignore-tags]` logic
236+
237+
function getComments(code: string) {
238+
const codeLines = splitCodeLines(code)
239+
return parseAnnotationComments({ codeLines })
240+
}
12241
})

0 commit comments

Comments
 (0)