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