Skip to content

Commit 1a2f81d

Browse files
johnsimonsbrc-dd
andauthored
feat: allow matching region end in snippets without tag (#4287)
Co-authored-by: Divyansh Singh <[email protected]>
1 parent e271695 commit 1a2f81d

File tree

2 files changed

+295
-41
lines changed

2 files changed

+295
-41
lines changed

__tests__/unit/node/markdown/plugins/snippet.test.ts

Lines changed: 227 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { dedent, rawPathToToken } from 'node/markdown/plugins/snippet'
1+
import {
2+
dedent,
3+
findRegion,
4+
rawPathToToken
5+
} from 'node/markdown/plugins/snippet'
6+
import { expect } from 'vitest'
27

38
const removeEmptyKeys = <T extends Record<string, unknown>>(obj: T) => {
49
return Object.fromEntries(
@@ -94,9 +99,228 @@ describe('node/markdown/plugins/snippet', () => {
9499
})
95100
})
96101

97-
test('rawPathToToken', () => {
98-
rawPathTokenMap.forEach(([rawPath, token]) => {
102+
describe('rawPathToToken', () => {
103+
test.each(rawPathTokenMap)('%s', (rawPath, token) => {
99104
expect(removeEmptyKeys(rawPathToToken(rawPath))).toEqual(token)
100105
})
101106
})
107+
108+
describe('findRegion', () => {
109+
it('returns null when no region markers are present', () => {
110+
const lines = ['function foo() {', ' console.log("hello");', '}']
111+
expect(findRegion(lines, 'foo')).toBeNull()
112+
})
113+
114+
it('ignores non-matching region names', () => {
115+
const lines = [
116+
'// #region regionA',
117+
'some code here',
118+
'// #endregion regionA'
119+
]
120+
expect(findRegion(lines, 'regionC')).toBeNull()
121+
})
122+
123+
it('returns null if a region start marker exists without a matching end marker', () => {
124+
const lines = [
125+
'// #region missingEnd',
126+
'console.log("inside region");',
127+
'console.log("still inside");'
128+
]
129+
expect(findRegion(lines, 'missingEnd')).toBeNull()
130+
})
131+
132+
it('returns null if an end marker exists without a preceding start marker', () => {
133+
const lines = [
134+
'// #endregion ghostRegion',
135+
'console.log("stray end marker");'
136+
]
137+
expect(findRegion(lines, 'ghostRegion')).toBeNull()
138+
})
139+
140+
it('detects C#/JavaScript style region markers with matching tags', () => {
141+
const lines = [
142+
'Console.WriteLine("Before region");',
143+
'#region hello',
144+
'Console.WriteLine("Hello, World!");',
145+
'#endregion hello',
146+
'Console.WriteLine("After region");'
147+
]
148+
const result = findRegion(lines, 'hello')
149+
expect(result).not.toBeNull()
150+
if (result) {
151+
expect(lines.slice(result.start, result.end).join('\n')).toBe(
152+
'Console.WriteLine("Hello, World!");'
153+
)
154+
}
155+
})
156+
157+
it('detects region markers even when the end marker omits the region name', () => {
158+
const lines = [
159+
'Console.WriteLine("Before region");',
160+
'#region hello',
161+
'Console.WriteLine("Hello, World!");',
162+
'#endregion',
163+
'Console.WriteLine("After region");'
164+
]
165+
const result = findRegion(lines, 'hello')
166+
expect(result).not.toBeNull()
167+
if (result) {
168+
expect(lines.slice(result.start, result.end).join('\n')).toBe(
169+
'Console.WriteLine("Hello, World!");'
170+
)
171+
}
172+
})
173+
174+
it('handles indented region markers correctly', () => {
175+
const lines = [
176+
' Console.WriteLine("Before region");',
177+
' #region hello',
178+
' Console.WriteLine("Hello, World!");',
179+
' #endregion hello',
180+
' Console.WriteLine("After region");'
181+
]
182+
const result = findRegion(lines, 'hello')
183+
expect(result).not.toBeNull()
184+
if (result) {
185+
expect(lines.slice(result.start, result.end).join('\n')).toBe(
186+
' Console.WriteLine("Hello, World!");'
187+
)
188+
}
189+
})
190+
191+
it('detects TypeScript style region markers', () => {
192+
const lines = [
193+
'let regexp: RegExp[] = [];',
194+
'// #region foo',
195+
'let start = -1;',
196+
'// #endregion foo'
197+
]
198+
const result = findRegion(lines, 'foo')
199+
expect(result).not.toBeNull()
200+
if (result) {
201+
expect(lines.slice(result.start, result.end).join('\n')).toBe(
202+
'let start = -1;'
203+
)
204+
}
205+
})
206+
207+
it('detects CSS style region markers', () => {
208+
const lines = [
209+
'.body-content {',
210+
'/* #region foo */',
211+
' padding-left: 15px;',
212+
'/* #endregion foo */',
213+
' padding-right: 15px;',
214+
'}'
215+
]
216+
const result = findRegion(lines, 'foo')
217+
expect(result).not.toBeNull()
218+
if (result) {
219+
expect(lines.slice(result.start, result.end).join('\n')).toBe(
220+
' padding-left: 15px;'
221+
)
222+
}
223+
})
224+
225+
it('detects HTML style region markers', () => {
226+
const lines = [
227+
'<div>Some content</div>',
228+
'<!-- #region foo -->',
229+
' <h1>Hello world</h1>',
230+
'<!-- #endregion foo -->',
231+
'<div>Other content</div>'
232+
]
233+
const result = findRegion(lines, 'foo')
234+
expect(result).not.toBeNull()
235+
if (result) {
236+
expect(lines.slice(result.start, result.end).join('\n')).toBe(
237+
' <h1>Hello world</h1>'
238+
)
239+
}
240+
})
241+
242+
it('detects Visual Basic style region markers (with case-insensitive "End")', () => {
243+
const lines = [
244+
'Console.WriteLine("VB")',
245+
'#Region VBRegion',
246+
' Console.WriteLine("Inside region")',
247+
'#End Region VBRegion',
248+
'Console.WriteLine("Done")'
249+
]
250+
const result = findRegion(lines, 'VBRegion')
251+
expect(result).not.toBeNull()
252+
if (result) {
253+
expect(lines.slice(result.start, result.end).join('\n')).toBe(
254+
' Console.WriteLine("Inside region")'
255+
)
256+
}
257+
})
258+
259+
it('detects Bat style region markers', () => {
260+
const lines = ['::#region foo', 'echo off', '::#endregion foo']
261+
const result = findRegion(lines, 'foo')
262+
expect(result).not.toBeNull()
263+
if (result) {
264+
expect(lines.slice(result.start, result.end).join('\n')).toBe(
265+
'echo off'
266+
)
267+
}
268+
})
269+
270+
it('detects C/C++ style region markers using #pragma', () => {
271+
const lines = [
272+
'#pragma region foo',
273+
'int a = 1;',
274+
'#pragma endregion foo'
275+
]
276+
const result = findRegion(lines, 'foo')
277+
expect(result).not.toBeNull()
278+
if (result) {
279+
expect(lines.slice(result.start, result.end).join('\n')).toBe(
280+
'int a = 1;'
281+
)
282+
}
283+
})
284+
285+
it('returns the first complete region when multiple regions exist', () => {
286+
const lines = [
287+
'// #region foo',
288+
'first region content',
289+
'// #endregion foo',
290+
'// #region foo',
291+
'second region content',
292+
'// #endregion foo'
293+
]
294+
const result = findRegion(lines, 'foo')
295+
expect(result).not.toBeNull()
296+
if (result) {
297+
expect(lines.slice(result.start, result.end).join('\n')).toBe(
298+
'first region content'
299+
)
300+
}
301+
})
302+
303+
it('handles nested regions with different names properly', () => {
304+
const lines = [
305+
'// #region foo',
306+
"console.log('line before nested');",
307+
'// #region bar',
308+
"console.log('nested content');",
309+
'// #endregion bar',
310+
'// #endregion foo'
311+
]
312+
const result = findRegion(lines, 'foo')
313+
expect(result).not.toBeNull()
314+
if (result) {
315+
const extracted = lines.slice(result.start, result.end).join('\n')
316+
const expected = [
317+
"console.log('line before nested');",
318+
'// #region bar',
319+
"console.log('nested content');",
320+
'// #endregion bar'
321+
].join('\n')
322+
expect(extracted).toBe(expected)
323+
}
324+
})
325+
})
102326
})

src/node/markdown/plugins/snippet.ts

Lines changed: 68 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -51,47 +51,71 @@ export function dedent(text: string): string {
5151
return text
5252
}
5353

54-
function testLine(
55-
line: string,
56-
regexp: RegExp,
57-
regionName: string,
58-
end: boolean = false
59-
) {
60-
const [full, tag, name] = regexp.exec(line.trim()) || []
61-
62-
return (
63-
full &&
64-
tag &&
65-
name === regionName &&
66-
tag.match(end ? /^[Ee]nd ?[rR]egion$/ : /^[rR]egion$/)
67-
)
68-
}
69-
7054
export function findRegion(lines: Array<string>, regionName: string) {
71-
const regionRegexps = [
72-
/^\/\/ ?#?((?:end)?region) ([\w*-]+)$/, // javascript, typescript, java
73-
/^\/\* ?#((?:end)?region) ([\w*-]+) ?\*\/$/, // css, less, scss
74-
/^#pragma ((?:end)?region) ([\w*-]+)$/, // C, C++
75-
/^<!-- #?((?:end)?region) ([\w*-]+) -->$/, // HTML, markdown
76-
/^#((?:End )Region) ([\w*-]+)$/, // Visual Basic
77-
/^::#((?:end)region) ([\w*-]+)$/, // Bat
78-
/^# ?((?:end)?region) ([\w*-]+)$/ // C#, PHP, Powershell, Python, perl & misc
55+
const regionRegexps: [RegExp, RegExp][] = [
56+
[
57+
/^[ \t]*\/\/ ?#?(region) ([\w*-]+)$/,
58+
/^[ \t]*\/\/ ?#?(endregion) ?([\w*-]*)$/
59+
], // javascript, typescript, java
60+
[
61+
/^\/\* ?#(region) ([\w*-]+) ?\*\/$/,
62+
/^\/\* ?#(endregion) ?([\w*-]*) ?\*\/$/
63+
], // css, less, scss
64+
[/^#pragma (region) ([\w*-]+)$/, /^#pragma (endregion) ?([\w*-]*)$/], // C, C++
65+
[/^<!-- #?(region) ([\w*-]+) -->$/, /^<!-- #?(endregion) ?([\w*-]*) -->$/], // HTML, markdown
66+
[/^[ \t]*#(Region) ([\w*-]+)$/, /^[ \t]*#(End Region) ?([\w*-]*)$/], // Visual Basic
67+
[/^::#(region) ([\w*-]+)$/, /^::#(endregion) ?([\w*-]*)$/], // Bat
68+
[/^[ \t]*# ?(region) ([\w*-]+)$/, /^[ \t]*# ?(endregion) ?([\w*-]*)$/] // C#, PHP, Powershell, Python, perl & misc
7969
]
8070

81-
let regexp = null
82-
let start = -1
83-
84-
for (const [lineId, line] of lines.entries()) {
85-
if (regexp === null) {
86-
for (const reg of regionRegexps) {
87-
if (testLine(line, reg, regionName)) {
88-
start = lineId + 1
89-
regexp = reg
90-
break
91-
}
71+
let chosenRegex: [RegExp, RegExp] | null = null
72+
let startLine = -1
73+
// find the regex pair for a start marker that matches the given region name
74+
for (let i = 0; i < lines.length; i++) {
75+
const line = lines[i].trim()
76+
for (const [startRegex, endRegex] of regionRegexps) {
77+
const startMatch = startRegex.exec(line)
78+
if (
79+
startMatch &&
80+
startMatch[2] === regionName &&
81+
/^[rR]egion$/.test(startMatch[1])
82+
) {
83+
chosenRegex = [startRegex, endRegex]
84+
startLine = i + 1
85+
break
86+
}
87+
}
88+
if (chosenRegex) break
89+
}
90+
if (!chosenRegex) return null
91+
92+
const [startRegex, endRegex] = chosenRegex
93+
let counter = 1
94+
// scan the rest of the lines to find the matching end marker, handling nested markers
95+
for (let i = startLine; i < lines.length; i++) {
96+
const trimmed = lines[i].trim()
97+
// check for an inner start marker for the same region
98+
const startMatch = startRegex.exec(trimmed)
99+
if (
100+
startMatch &&
101+
startMatch[2] === regionName &&
102+
/^[rR]egion$/.test(startMatch[1])
103+
) {
104+
counter++
105+
continue
106+
}
107+
// check for an end marker for the same region
108+
const endMatch = endRegex.exec(trimmed)
109+
if (
110+
endMatch &&
111+
// allow empty region name on the end marker as a fallback
112+
(endMatch[2] === regionName || endMatch[2] === '') &&
113+
/^[Ee]nd ?[rR]egion$/.test(endMatch[1])
114+
) {
115+
counter--
116+
if (counter === 0) {
117+
return { start: startLine, end: i, regexp: chosenRegex }
92118
}
93-
} else if (testLine(line, regexp, regionName, true)) {
94-
return { start, end: lineId, regexp }
95119
}
96120
}
97121

@@ -181,7 +205,13 @@ export const snippetPlugin = (md: MarkdownIt, srcDir: string) => {
181205
content = dedent(
182206
lines
183207
.slice(region.start, region.end)
184-
.filter((line) => !region.regexp.test(line.trim()))
208+
.filter((line) => {
209+
const trimmed = line.trim()
210+
return (
211+
!region.regexp[0].test(trimmed) &&
212+
!region.regexp[1].test(trimmed)
213+
)
214+
})
185215
.join('\n')
186216
)
187217
}

0 commit comments

Comments
 (0)