Skip to content

Commit 9067ae2

Browse files
pomberclaude
andcommitted
Support multi-line annotation ranges with start/end comments
Add `!name(start)` and `!name(end)` syntax for defining multi-line annotation ranges. This allows annotations like `!focus` to span multiple lines without specifying explicit line numbers. Closes #530 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 308b527 commit 9067ae2

2 files changed

Lines changed: 256 additions & 13 deletions

File tree

packages/codehike/src/code/extract-annotations.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,169 @@ test("extracts name with complex regex pattern", async () => {
5858
const annotation = annotations[0]
5959
expect(annotation.name).toEqual("tooltip")
6060
})
61+
62+
// start/end range marker tests
63+
64+
test("start/end creates block annotation spanning the range", async () => {
65+
const code = [
66+
"let a = 1",
67+
"// !focus(start)",
68+
"let b = 2",
69+
"let c = 3",
70+
"// !focus(end)",
71+
"let d = 4",
72+
].join("\n")
73+
const { code: resultCode, annotations } = await splitAnnotationsAndCode(
74+
code,
75+
"javascript",
76+
"!",
77+
)
78+
expect(resultCode).not.toContain("!focus")
79+
expect(annotations).toHaveLength(1)
80+
const a = annotations[0]
81+
expect(a.name).toEqual("focus")
82+
expect(a.ranges).toHaveLength(1)
83+
const range = a.ranges[0]
84+
// after removing 2 comment lines, the code is 4 lines
85+
// "let b = 2" is line 2, "let c = 3" is line 3
86+
expect(range.fromLineNumber).toEqual(2)
87+
expect(range.toLineNumber).toEqual(3)
88+
})
89+
90+
test("start/end preserves query string", async () => {
91+
const code = [
92+
"// !box(start) myquery",
93+
"let x = 1",
94+
"// !box(end)",
95+
].join("\n")
96+
const { annotations } = await splitAnnotationsAndCode(
97+
code,
98+
"javascript",
99+
"!",
100+
)
101+
expect(annotations).toHaveLength(1)
102+
expect(annotations[0].name).toEqual("box")
103+
expect(annotations[0].query).toEqual("myquery")
104+
})
105+
106+
test("start/end works with other annotations", async () => {
107+
const code = [
108+
"// !mark",
109+
"let a = 1",
110+
"// !focus(start)",
111+
"let b = 2",
112+
"let c = 3",
113+
"// !focus(end)",
114+
"let d = 4",
115+
].join("\n")
116+
const { annotations } = await splitAnnotationsAndCode(
117+
code,
118+
"javascript",
119+
"!",
120+
)
121+
expect(annotations).toHaveLength(2)
122+
const mark = annotations.find((a) => a.name === "mark")
123+
const focus = annotations.find((a) => a.name === "focus")
124+
expect(mark).toBeDefined()
125+
expect(focus).toBeDefined()
126+
expect(focus!.ranges[0].fromLineNumber).toBeDefined()
127+
expect(focus!.ranges[0].toLineNumber).toBeDefined()
128+
})
129+
130+
test("multiple start/end pairs of same name", async () => {
131+
const code = [
132+
"let a = 1",
133+
"// !focus(start)",
134+
"let b = 2",
135+
"// !focus(end)",
136+
"let c = 3",
137+
"// !focus(start)",
138+
"let d = 4",
139+
"// !focus(end)",
140+
"let e = 5",
141+
].join("\n")
142+
const { annotations } = await splitAnnotationsAndCode(
143+
code,
144+
"javascript",
145+
"!",
146+
)
147+
expect(annotations).toHaveLength(2)
148+
expect(annotations[0].name).toEqual("focus")
149+
expect(annotations[1].name).toEqual("focus")
150+
// The two ranges should not overlap
151+
const r0 = annotations[0].ranges[0]
152+
const r1 = annotations[1].ranges[0]
153+
expect(r0.toLineNumber).toBeLessThan(r1.fromLineNumber)
154+
})
155+
156+
test("different annotation names with start/end", async () => {
157+
const code = [
158+
"// !focus(start)",
159+
"let a = 1",
160+
"// !mark(start)",
161+
"let b = 2",
162+
"// !mark(end)",
163+
"let c = 3",
164+
"// !focus(end)",
165+
].join("\n")
166+
const { annotations } = await splitAnnotationsAndCode(
167+
code,
168+
"javascript",
169+
"!",
170+
)
171+
expect(annotations).toHaveLength(2)
172+
const focus = annotations.find((a) => a.name === "focus")
173+
const mark = annotations.find((a) => a.name === "mark")
174+
expect(focus).toBeDefined()
175+
expect(mark).toBeDefined()
176+
})
177+
178+
test("start/end removes comment lines from code", async () => {
179+
const code = [
180+
"let a = 1",
181+
"// !focus(start)",
182+
"let b = 2",
183+
"// !focus(end)",
184+
"let c = 3",
185+
].join("\n")
186+
const { code: resultCode } = await splitAnnotationsAndCode(
187+
code,
188+
"javascript",
189+
"!",
190+
)
191+
const lines = resultCode.split("\n")
192+
expect(lines).toHaveLength(3)
193+
expect(lines[0]).toContain("let a = 1")
194+
expect(lines[1]).toContain("let b = 2")
195+
expect(lines[2]).toContain("let c = 3")
196+
})
197+
198+
test("start/end works with Python comments", async () => {
199+
const code = [
200+
"x = 1",
201+
"# !focus(start)",
202+
"y = 2",
203+
"z = 3",
204+
"# !focus(end)",
205+
"w = 4",
206+
].join("\n")
207+
const { annotations } = await splitAnnotationsAndCode(code, "python", "!")
208+
expect(annotations).toHaveLength(1)
209+
expect(annotations[0].name).toEqual("focus")
210+
expect(annotations[0].ranges[0].fromLineNumber).toEqual(2)
211+
expect(annotations[0].ranges[0].toLineNumber).toEqual(3)
212+
})
213+
214+
test("start/end works with block comments", async () => {
215+
const code = [
216+
"int a = 1;",
217+
"/* !mark(start) */",
218+
"int b = 2;",
219+
"int c = 3;",
220+
"/* !mark(end) */",
221+
"int d = 4;",
222+
].join("\n")
223+
const { annotations } = await splitAnnotationsAndCode(code, "c", "!")
224+
expect(annotations).toHaveLength(1)
225+
expect(annotations[0].name).toEqual("mark")
226+
})

packages/codehike/src/code/extract-annotations.tsx

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,91 @@
11
import { Annotation, extractAnnotations } from "@code-hike/lighter"
22

3+
const START_MARKER = "\0start\0"
4+
const END_MARKER = "\0end\0"
5+
36
export async function splitAnnotationsAndCode(
47
code: string,
58
lang: string,
69
annotationPrefix: string,
710
) {
8-
let annotations: Annotation[] = []
9-
let codeWithoutAnnotations = code
10-
11-
const { code: newCode, annotations: newAnnotations } =
12-
await extractCommentAnnotations(
13-
codeWithoutAnnotations,
14-
lang,
15-
annotationPrefix,
11+
const { code: newCode, annotations: rawAnnotations } =
12+
await extractCommentAnnotations(code, lang, annotationPrefix)
13+
14+
const annotations = processStartEndMarkers(rawAnnotations)
15+
16+
return { code: newCode, annotations }
17+
}
18+
19+
function getLineFromRange(range: any): number {
20+
if (range.lineNumber) return range.lineNumber
21+
return range.fromLineNumber
22+
}
23+
24+
function processStartEndMarkers(annotations: Annotation[]): Annotation[] {
25+
const regular: Annotation[] = []
26+
const starts: { name: string; query: string; line: number }[] = []
27+
const ends: { name: string; query: string; line: number }[] = []
28+
29+
for (const a of annotations) {
30+
const q = a.query ?? ""
31+
if (q.startsWith(START_MARKER)) {
32+
starts.push({
33+
name: a.name,
34+
query: q.slice(START_MARKER.length),
35+
line: getLineFromRange(a.ranges[0]),
36+
})
37+
} else if (q.startsWith(END_MARKER)) {
38+
ends.push({
39+
name: a.name,
40+
query: q.slice(END_MARKER.length),
41+
line: getLineFromRange(a.ranges[0]),
42+
})
43+
} else {
44+
regular.push(a)
45+
}
46+
}
47+
48+
if (starts.length === 0 && ends.length === 0) {
49+
return annotations
50+
}
51+
52+
const paired: Annotation[] = []
53+
const usedEnds = new Set<number>()
54+
55+
for (const start of starts) {
56+
// find the first unused end with the same name that comes after the start
57+
const endIndex = ends.findIndex(
58+
(e, i) =>
59+
!usedEnds.has(i) &&
60+
e.name === start.name &&
61+
e.line >= start.line,
1662
)
17-
annotations = [...annotations, ...newAnnotations]
18-
codeWithoutAnnotations = newCode
63+
if (endIndex === -1) {
64+
console.warn(
65+
`Code Hike warning: Unmatched !${start.name}(start) annotation`,
66+
)
67+
continue
68+
}
69+
usedEnds.add(endIndex)
70+
const end = ends[endIndex]
71+
paired.push({
72+
name: start.name,
73+
query: start.query,
74+
ranges: [
75+
{ fromLineNumber: start.line, toLineNumber: end.line - 1 },
76+
],
77+
})
78+
}
1979

20-
return { code: codeWithoutAnnotations, annotations }
80+
for (let i = 0; i < ends.length; i++) {
81+
if (!usedEnds.has(i)) {
82+
console.warn(
83+
`Code Hike warning: Unmatched !${ends[i].name}(end) annotation`,
84+
)
85+
}
86+
}
87+
88+
return [...regular, ...paired]
2189
}
2290

2391
async function extractCommentAnnotations(
@@ -45,12 +113,21 @@ async function extractCommentAnnotations(
45113
return null
46114
}
47115
const name = match[1]
48-
const rangeString = match[2]
49-
const query = match[3]?.trim()
116+
let rangeString = match[2]
117+
let query = match[3]?.trim() ?? ""
50118
if (!name || !name.startsWith(annotationPrefix)) {
51119
return null
52120
}
53121

122+
// Handle start/end range markers: !name(start) and !name(end)
123+
if (rangeString === "(start)") {
124+
query = START_MARKER + query
125+
rangeString = undefined
126+
} else if (rangeString === "(end)") {
127+
query = END_MARKER + query
128+
rangeString = undefined
129+
}
130+
54131
return {
55132
name: name.slice(annotationPrefix.length),
56133
rangeString,

0 commit comments

Comments
 (0)