opencode-snippets 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -71
- package/index.ts +47 -17
- package/package.json +10 -5
- package/src/constants.ts +8 -8
- package/src/expander.test.ts +456 -0
- package/src/expander.ts +47 -37
- package/src/loader.test.ts +261 -0
- package/src/loader.ts +99 -45
- package/src/logger.test.ts +136 -0
- package/src/logger.ts +95 -95
- package/src/shell.ts +30 -24
- package/src/types.ts +6 -6
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { expandHashtags } from "../src/expander.js";
|
|
2
|
+
import type { SnippetRegistry } from "../src/types.js";
|
|
3
|
+
|
|
4
|
+
describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
5
|
+
describe("Basic expansion", () => {
|
|
6
|
+
it("should expand a single hashtag", () => {
|
|
7
|
+
const registry: SnippetRegistry = new Map([["greeting", "Hello, World!"]]);
|
|
8
|
+
|
|
9
|
+
const result = expandHashtags("Say #greeting", registry);
|
|
10
|
+
|
|
11
|
+
expect(result).toBe("Say Hello, World!");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("should expand multiple hashtags in one text", () => {
|
|
15
|
+
const registry: SnippetRegistry = new Map([
|
|
16
|
+
["greeting", "Hello"],
|
|
17
|
+
["name", "Alice"],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const result = expandHashtags("#greeting, #name!", registry);
|
|
21
|
+
|
|
22
|
+
expect(result).toBe("Hello, Alice!");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should leave unknown hashtags unchanged", () => {
|
|
26
|
+
const registry: SnippetRegistry = new Map([["known", "content"]]);
|
|
27
|
+
|
|
28
|
+
const result = expandHashtags("This is #known and #unknown", registry);
|
|
29
|
+
|
|
30
|
+
expect(result).toBe("This is content and #unknown");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should handle empty text", () => {
|
|
34
|
+
const registry: SnippetRegistry = new Map([["test", "content"]]);
|
|
35
|
+
|
|
36
|
+
const result = expandHashtags("", registry);
|
|
37
|
+
|
|
38
|
+
expect(result).toBe("");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should handle text with no hashtags", () => {
|
|
42
|
+
const registry: SnippetRegistry = new Map([["test", "content"]]);
|
|
43
|
+
|
|
44
|
+
const result = expandHashtags("No hashtags here", registry);
|
|
45
|
+
|
|
46
|
+
expect(result).toBe("No hashtags here");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should handle case-insensitive hashtags", () => {
|
|
50
|
+
const registry: SnippetRegistry = new Map([["greeting", "Hello"]]);
|
|
51
|
+
|
|
52
|
+
const result = expandHashtags("#Greeting #GREETING #greeting", registry);
|
|
53
|
+
|
|
54
|
+
expect(result).toBe("Hello Hello Hello");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("Recursive expansion", () => {
|
|
59
|
+
it("should expand nested hashtags one level deep", () => {
|
|
60
|
+
const registry: SnippetRegistry = new Map([
|
|
61
|
+
["outer", "Start #inner End"],
|
|
62
|
+
["inner", "Middle"],
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const result = expandHashtags("#outer", registry);
|
|
66
|
+
|
|
67
|
+
expect(result).toBe("Start Middle End");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should expand nested hashtags multiple levels deep", () => {
|
|
71
|
+
const registry: SnippetRegistry = new Map([
|
|
72
|
+
["level1", "L1 #level2"],
|
|
73
|
+
["level2", "L2 #level3"],
|
|
74
|
+
["level3", "L3 #level4"],
|
|
75
|
+
["level4", "L4"],
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const result = expandHashtags("#level1", registry);
|
|
79
|
+
|
|
80
|
+
expect(result).toBe("L1 L2 L3 L4");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should expand multiple nested hashtags in one snippet", () => {
|
|
84
|
+
const registry: SnippetRegistry = new Map([
|
|
85
|
+
["main", "Start #a and #b End"],
|
|
86
|
+
["a", "Content A"],
|
|
87
|
+
["b", "Content B"],
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
const result = expandHashtags("#main", registry);
|
|
91
|
+
|
|
92
|
+
expect(result).toBe("Start Content A and Content B End");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should expand complex nested structure", () => {
|
|
96
|
+
const registry: SnippetRegistry = new Map([
|
|
97
|
+
["greeting", "#hello #name"],
|
|
98
|
+
["hello", "Hello"],
|
|
99
|
+
["name", "#firstname #lastname"],
|
|
100
|
+
["firstname", "John"],
|
|
101
|
+
["lastname", "Doe"],
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const result = expandHashtags("#greeting", registry);
|
|
105
|
+
|
|
106
|
+
expect(result).toBe("Hello John Doe");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("Loop detection - Direct cycles", () => {
|
|
111
|
+
it("should detect and prevent simple self-reference", { timeout: 100 }, () => {
|
|
112
|
+
const registry: SnippetRegistry = new Map([["self", "I reference #self"]]);
|
|
113
|
+
|
|
114
|
+
const result = expandHashtags("#self", registry);
|
|
115
|
+
|
|
116
|
+
// Loop detected after 15 expansions, #self left as-is
|
|
117
|
+
const expected = `${"I reference ".repeat(15)}#self`;
|
|
118
|
+
expect(result).toBe(expected);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should detect and prevent two-way circular reference", () => {
|
|
122
|
+
const registry: SnippetRegistry = new Map([
|
|
123
|
+
["a", "A references #b"],
|
|
124
|
+
["b", "B references #a"],
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
const result = expandHashtags("#a", registry);
|
|
128
|
+
|
|
129
|
+
// Should expand alternating A and B 15 times then stop
|
|
130
|
+
const expected = `${"A references B references ".repeat(15)}#a`;
|
|
131
|
+
expect(result).toBe(expected);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should detect and prevent three-way circular reference", () => {
|
|
135
|
+
const registry: SnippetRegistry = new Map([
|
|
136
|
+
["a", "A -> #b"],
|
|
137
|
+
["b", "B -> #c"],
|
|
138
|
+
["c", "C -> #a"],
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
const result = expandHashtags("#a", registry);
|
|
142
|
+
|
|
143
|
+
// Should expand cycling through A, B, C 15 times then stop
|
|
144
|
+
const expected = `${"A -> B -> C -> ".repeat(15)}#a`;
|
|
145
|
+
expect(result).toBe(expected);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("should detect loops in longer chains", () => {
|
|
149
|
+
const registry: SnippetRegistry = new Map([
|
|
150
|
+
["a", "#b"],
|
|
151
|
+
["b", "#c"],
|
|
152
|
+
["c", "#d"],
|
|
153
|
+
["d", "#e"],
|
|
154
|
+
["e", "#b"], // Loop back to b
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
const result = expandHashtags("#a", registry);
|
|
158
|
+
|
|
159
|
+
// Should expand until loop detected
|
|
160
|
+
expect(result).toBe("#b");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("Loop detection - Complex scenarios", () => {
|
|
165
|
+
it("should allow same snippet in different branches", () => {
|
|
166
|
+
const registry: SnippetRegistry = new Map([
|
|
167
|
+
["main", "#branch1 and #branch2"],
|
|
168
|
+
["branch1", "B1 uses #shared"],
|
|
169
|
+
["branch2", "B2 uses #shared"],
|
|
170
|
+
["shared", "Shared content"],
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
const result = expandHashtags("#main", registry);
|
|
174
|
+
|
|
175
|
+
// #shared should be expanded in both branches
|
|
176
|
+
expect(result).toBe("B1 uses Shared content and B2 uses Shared content");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("should handle partial loops with valid branches", () => {
|
|
180
|
+
const registry: SnippetRegistry = new Map([
|
|
181
|
+
["main", "#valid and #loop"],
|
|
182
|
+
["valid", "Valid content"],
|
|
183
|
+
["loop", "Loop #loop"],
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
const result = expandHashtags("#main", registry);
|
|
187
|
+
|
|
188
|
+
// Valid expands once, loop expands 15 times
|
|
189
|
+
const expected = `Valid content and ${"Loop ".repeat(15)}#loop`;
|
|
190
|
+
expect(result).toBe(expected);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should handle multiple independent loops", () => {
|
|
194
|
+
const registry: SnippetRegistry = new Map([
|
|
195
|
+
["main", "#loop1 and #loop2"],
|
|
196
|
+
["loop1", "L1 #loop1"],
|
|
197
|
+
["loop2", "L2 #loop2"],
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
const result = expandHashtags("#main", registry);
|
|
201
|
+
|
|
202
|
+
// Each loop expands 15 times independently
|
|
203
|
+
const expected = `${"L1 ".repeat(15)}#loop1 and ${"L2 ".repeat(15)}#loop2`;
|
|
204
|
+
expect(result).toBe(expected);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should handle nested loops", () => {
|
|
208
|
+
const registry: SnippetRegistry = new Map([
|
|
209
|
+
["outer", "Outer #inner"],
|
|
210
|
+
["inner", "Inner #outer and #self"],
|
|
211
|
+
["self", "Self #self"],
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
const result = expandHashtags("#outer", registry);
|
|
215
|
+
|
|
216
|
+
// Complex nested loop - outer/inner cycle 15 times, plus self cycles
|
|
217
|
+
// This is complex expansion behavior, just verify it doesn't hang
|
|
218
|
+
expect(result).toContain("Outer");
|
|
219
|
+
expect(result).toContain("Inner");
|
|
220
|
+
expect(result).toContain("#outer");
|
|
221
|
+
expect(result).toContain("#self");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("should handle diamond pattern (same snippet reached via multiple paths)", () => {
|
|
225
|
+
const registry: SnippetRegistry = new Map([
|
|
226
|
+
["top", "#left #right"],
|
|
227
|
+
["left", "Left #bottom"],
|
|
228
|
+
["right", "Right #bottom"],
|
|
229
|
+
["bottom", "Bottom"],
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
const result = expandHashtags("#top", registry);
|
|
233
|
+
|
|
234
|
+
// Diamond: top -> left -> bottom, top -> right -> bottom
|
|
235
|
+
expect(result).toBe("Left Bottom Right Bottom");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("should handle loop after valid expansion", () => {
|
|
239
|
+
const registry: SnippetRegistry = new Map([
|
|
240
|
+
["a", "#b #c"],
|
|
241
|
+
["b", "Valid B"],
|
|
242
|
+
["c", "#d"],
|
|
243
|
+
["d", "#c"], // Loop back
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
const result = expandHashtags("#a", registry);
|
|
247
|
+
|
|
248
|
+
expect(result).toBe("Valid B #c");
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("Edge cases", () => {
|
|
253
|
+
it("should handle empty registry", () => {
|
|
254
|
+
const registry: SnippetRegistry = new Map();
|
|
255
|
+
|
|
256
|
+
const result = expandHashtags("#anything", registry);
|
|
257
|
+
|
|
258
|
+
expect(result).toBe("#anything");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should handle snippet with empty content", () => {
|
|
262
|
+
const registry: SnippetRegistry = new Map([["empty", ""]]);
|
|
263
|
+
|
|
264
|
+
const result = expandHashtags("Before #empty After", registry);
|
|
265
|
+
|
|
266
|
+
expect(result).toBe("Before After");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("should handle snippet containing only hashtags", () => {
|
|
270
|
+
const registry: SnippetRegistry = new Map([
|
|
271
|
+
["only-refs", "#a #b"],
|
|
272
|
+
["a", "A"],
|
|
273
|
+
["b", "B"],
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
const result = expandHashtags("#only-refs", registry);
|
|
277
|
+
|
|
278
|
+
expect(result).toBe("A B");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("should handle hashtags at start, middle, and end", () => {
|
|
282
|
+
const registry: SnippetRegistry = new Map([
|
|
283
|
+
["start", "Start"],
|
|
284
|
+
["middle", "Middle"],
|
|
285
|
+
["end", "End"],
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
const result = expandHashtags("#start text #middle text #end", registry);
|
|
289
|
+
|
|
290
|
+
expect(result).toBe("Start text Middle text End");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("should handle consecutive hashtags", () => {
|
|
294
|
+
const registry: SnippetRegistry = new Map([
|
|
295
|
+
["a", "A"],
|
|
296
|
+
["b", "B"],
|
|
297
|
+
["c", "C"],
|
|
298
|
+
]);
|
|
299
|
+
|
|
300
|
+
const result = expandHashtags("#a#b#c", registry);
|
|
301
|
+
|
|
302
|
+
expect(result).toBe("ABC");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("should handle hashtags with hyphens and underscores", () => {
|
|
306
|
+
const registry: SnippetRegistry = new Map([
|
|
307
|
+
["my-snippet", "Hyphenated"],
|
|
308
|
+
["my_snippet", "Underscored"],
|
|
309
|
+
["my-complex_name", "Mixed"],
|
|
310
|
+
]);
|
|
311
|
+
|
|
312
|
+
const result = expandHashtags("#my-snippet #my_snippet #my-complex_name", registry);
|
|
313
|
+
|
|
314
|
+
expect(result).toBe("Hyphenated Underscored Mixed");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("should handle hashtags with numbers", () => {
|
|
318
|
+
const registry: SnippetRegistry = new Map([
|
|
319
|
+
["test123", "Test with numbers"],
|
|
320
|
+
["123test", "Numbers first"],
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
const result = expandHashtags("#test123 #123test", registry);
|
|
324
|
+
|
|
325
|
+
expect(result).toBe("Test with numbers Numbers first");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("should not expand hashtags in URLs", () => {
|
|
329
|
+
const registry: SnippetRegistry = new Map([["issue", "ISSUE"]]);
|
|
330
|
+
|
|
331
|
+
// Note: The current implementation WILL expand #issue in URLs
|
|
332
|
+
// This test documents current behavior
|
|
333
|
+
const result = expandHashtags("See https://github.com/user/repo/issues/#issue", registry);
|
|
334
|
+
|
|
335
|
+
expect(result).toBe("See https://github.com/user/repo/issues/ISSUE");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("should handle multiline content", () => {
|
|
339
|
+
const registry: SnippetRegistry = new Map([["multiline", "Line 1\nLine 2\nLine 3"]]);
|
|
340
|
+
|
|
341
|
+
const result = expandHashtags("Start\n#multiline\nEnd", registry);
|
|
342
|
+
|
|
343
|
+
expect(result).toBe("Start\nLine 1\nLine 2\nLine 3\nEnd");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("should handle nested multiline content", () => {
|
|
347
|
+
const registry: SnippetRegistry = new Map([
|
|
348
|
+
["outer", "Outer start\n#inner\nOuter end"],
|
|
349
|
+
["inner", "Inner line 1\nInner line 2"],
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
const result = expandHashtags("#outer", registry);
|
|
353
|
+
|
|
354
|
+
expect(result).toBe("Outer start\nInner line 1\nInner line 2\nOuter end");
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe("Real-world scenarios", () => {
|
|
359
|
+
it("should expand code review template with nested snippets", () => {
|
|
360
|
+
const registry: SnippetRegistry = new Map([
|
|
361
|
+
["review", "Code Review Checklist:\n#security\n#performance\n#tests"],
|
|
362
|
+
["security", "- Check for SQL injection\n- Validate input"],
|
|
363
|
+
["performance", "- Check for N+1 queries\n- Review algorithm complexity"],
|
|
364
|
+
["tests", "- Unit tests present\n- Edge cases covered"],
|
|
365
|
+
]);
|
|
366
|
+
|
|
367
|
+
const result = expandHashtags("#review", registry);
|
|
368
|
+
|
|
369
|
+
expect(result).toContain("Code Review Checklist:");
|
|
370
|
+
expect(result).toContain("Check for SQL injection");
|
|
371
|
+
expect(result).toContain("Check for N+1 queries");
|
|
372
|
+
expect(result).toContain("Unit tests present");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("should expand documentation template with shared components", () => {
|
|
376
|
+
const registry: SnippetRegistry = new Map([
|
|
377
|
+
["doc", "# Documentation\n#header\n#body\n#footer"],
|
|
378
|
+
["header", "Author: #author\nDate: 2024-01-01"],
|
|
379
|
+
["author", "John Doe"],
|
|
380
|
+
["body", "Main content here"],
|
|
381
|
+
["footer", "Contact: #author"],
|
|
382
|
+
]);
|
|
383
|
+
|
|
384
|
+
const result = expandHashtags("#doc", registry);
|
|
385
|
+
|
|
386
|
+
// #author should be expanded in both header and footer
|
|
387
|
+
expect(result).toContain("Author: John Doe");
|
|
388
|
+
expect(result).toContain("Contact: John Doe");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("should handle instruction composition", () => {
|
|
392
|
+
const registry: SnippetRegistry = new Map([
|
|
393
|
+
["careful", "Think step by step. #verify"],
|
|
394
|
+
["verify", "Double-check your work."],
|
|
395
|
+
["complete", "Be thorough. #careful"],
|
|
396
|
+
]);
|
|
397
|
+
|
|
398
|
+
const result = expandHashtags("Instructions: #complete", registry);
|
|
399
|
+
|
|
400
|
+
expect(result).toBe("Instructions: Be thorough. Think step by step. Double-check your work.");
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe("Performance and stress tests", () => {
|
|
405
|
+
it("should handle deep nesting without stack overflow", () => {
|
|
406
|
+
const registry: SnippetRegistry = new Map();
|
|
407
|
+
const depth = 50;
|
|
408
|
+
|
|
409
|
+
// Create a chain: level0 -> level1 -> level2 -> ... -> level49 -> "End"
|
|
410
|
+
for (let i = 0; i < depth - 1; i++) {
|
|
411
|
+
registry.set(`level${i}`, `L${i} #level${i + 1}`);
|
|
412
|
+
}
|
|
413
|
+
registry.set(`level${depth - 1}`, "End");
|
|
414
|
+
|
|
415
|
+
const result = expandHashtags("#level0", registry);
|
|
416
|
+
|
|
417
|
+
expect(result).toContain("L0");
|
|
418
|
+
expect(result).toContain("End");
|
|
419
|
+
expect(result.split(" ").length).toBe(depth);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("should handle many snippets in one text", () => {
|
|
423
|
+
const registry: SnippetRegistry = new Map();
|
|
424
|
+
const count = 100;
|
|
425
|
+
|
|
426
|
+
for (let i = 0; i < count; i++) {
|
|
427
|
+
registry.set(`snippet${i}`, `Content${i}`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const hashtags = Array.from({ length: count }, (_, i) => `#snippet${i}`).join(" ");
|
|
431
|
+
const result = expandHashtags(hashtags, registry);
|
|
432
|
+
|
|
433
|
+
expect(result.split(" ").length).toBe(count);
|
|
434
|
+
expect(result).toContain("Content0");
|
|
435
|
+
expect(result).toContain(`Content${count - 1}`);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("should handle wide branching (many children)", () => {
|
|
439
|
+
const registry: SnippetRegistry = new Map();
|
|
440
|
+
const branches = 20;
|
|
441
|
+
|
|
442
|
+
const children = Array.from({ length: branches }, (_, i) => `#child${i}`).join(" ");
|
|
443
|
+
registry.set("parent", children);
|
|
444
|
+
|
|
445
|
+
for (let i = 0; i < branches; i++) {
|
|
446
|
+
registry.set(`child${i}`, `Child${i}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const result = expandHashtags("#parent", registry);
|
|
450
|
+
|
|
451
|
+
for (let i = 0; i < branches; i++) {
|
|
452
|
+
expect(result).toContain(`Child${i}`);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
});
|
package/src/expander.ts
CHANGED
|
@@ -1,57 +1,67 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { PATTERNS } from "./constants.js";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
|
+
import type { SnippetRegistry } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Maximum number of times a snippet can be expanded to prevent infinite loops
|
|
7
|
+
*/
|
|
8
|
+
const MAX_EXPANSION_COUNT = 15;
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* Expands hashtags in text recursively with loop detection
|
|
6
|
-
*
|
|
12
|
+
*
|
|
7
13
|
* @param text - The text containing hashtags to expand
|
|
8
14
|
* @param registry - The snippet registry to look up hashtags
|
|
9
|
-
* @param
|
|
15
|
+
* @param expansionCounts - Map tracking how many times each snippet has been expanded
|
|
10
16
|
* @returns The text with all hashtags expanded
|
|
11
17
|
*/
|
|
12
18
|
export function expandHashtags(
|
|
13
19
|
text: string,
|
|
14
20
|
registry: SnippetRegistry,
|
|
15
|
-
|
|
21
|
+
expansionCounts = new Map<string, number>(),
|
|
16
22
|
): string {
|
|
17
|
-
let expanded = text
|
|
18
|
-
let hasChanges = true
|
|
19
|
-
|
|
23
|
+
let expanded = text;
|
|
24
|
+
let hasChanges = true;
|
|
25
|
+
|
|
20
26
|
// Keep expanding until no more hashtags are found
|
|
21
27
|
while (hasChanges) {
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
const previous = expanded;
|
|
29
|
+
let loopDetected = false;
|
|
30
|
+
|
|
24
31
|
// Reset regex state (global flag requires this)
|
|
25
|
-
PATTERNS.HASHTAG.lastIndex = 0
|
|
26
|
-
|
|
32
|
+
PATTERNS.HASHTAG.lastIndex = 0;
|
|
33
|
+
|
|
27
34
|
expanded = expanded.replace(PATTERNS.HASHTAG, (match, name) => {
|
|
28
|
-
const key = name.toLowerCase()
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
// Loop detected! Leave the hashtag unchanged to prevent infinite recursion
|
|
33
|
-
return match
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const content = registry.get(key)
|
|
37
|
-
if (!content) {
|
|
35
|
+
const key = name.toLowerCase();
|
|
36
|
+
|
|
37
|
+
const content = registry.get(key);
|
|
38
|
+
if (content === undefined) {
|
|
38
39
|
// Unknown snippet - leave as-is
|
|
39
|
-
return match
|
|
40
|
+
return match;
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
|
|
43
|
+
// Track expansion count to prevent infinite loops
|
|
44
|
+
const count = (expansionCounts.get(key) || 0) + 1;
|
|
45
|
+
if (count > MAX_EXPANSION_COUNT) {
|
|
46
|
+
// Loop detected! Leave the hashtag as-is and stop expanding
|
|
47
|
+
logger.warn(
|
|
48
|
+
`Loop detected: snippet '#${key}' expanded ${count} times (max: ${MAX_EXPANSION_COUNT})`,
|
|
49
|
+
);
|
|
50
|
+
loopDetected = true;
|
|
51
|
+
return match; // Leave as-is instead of error message
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
expansionCounts.set(key, count);
|
|
55
|
+
|
|
46
56
|
// Recursively expand any hashtags in the snippet content
|
|
47
|
-
const result = expandHashtags(content, registry,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
const result = expandHashtags(content, registry, expansionCounts);
|
|
58
|
+
|
|
59
|
+
return result;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Only continue if the text actually changed AND no loop was detected
|
|
63
|
+
hasChanges = expanded !== previous && !loopDetected;
|
|
54
64
|
}
|
|
55
|
-
|
|
56
|
-
return expanded
|
|
65
|
+
|
|
66
|
+
return expanded;
|
|
57
67
|
}
|