Skip to content

Commit 2069fae

Browse files
authored
Fix infinite loop in parser when streaming with unclosed formatting markers (#154)
1 parent fe8e46d commit 2069fae

File tree

6 files changed

+187
-13
lines changed

6 files changed

+187
-13
lines changed

.github/workflows/preview.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Surge PR Preview
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'example/**'
7+
- 'src/**'
8+
9+
jobs:
10+
preview:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
node-version: [22.x]
15+
steps:
16+
- name: ⤵️ Checkout
17+
uses: actions/checkout@v4
18+
19+
- name: 🎉 Setup Node.js environment
20+
uses: actions/setup-node@v4
21+
with:
22+
node-version: ${{ matrix.node-version }}
23+
24+
- name: 🚧 Install dependencies
25+
run: npm install
26+
27+
- name: 🚀 Build library
28+
run: npm run build
29+
30+
- name: 📦 Build example
31+
run: npx vite build -c vite.config.example.mjs
32+
33+
- name: 🌊 Deploy to Surge
34+
uses: afc163/surge-preview@v1
35+
with:
36+
surge_token: ${{ secrets.SURGE_TOKEN }}
37+
github_token: ${{ secrets.GITHUB_TOKEN }}
38+
dist: example/dist
39+
build: |
40+
echo "Already built"

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ dist/
146146
es/
147147
lib/
148148
/types/
149+
example/dist/
149150

150151
# Snapshot
151152
__tests__/snapshot/**/*-actual.*
153+
154+
# Test files
155+
test-streaming.html
156+
test-streaming-node.ts

__tests__/parser/syntax-parser.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,4 +382,84 @@ Total revenue reached [¥5M](metric_value, origin=5000000) with **strong growth*
382382
expect(phrase.metadata?.detail).toEqual([1, 2, 3]);
383383
expect((phrase.metadata as Record<string, unknown>).active).toBe(true);
384384
});
385+
386+
it('should handle unclosed bold formatting without infinite loop', () => {
387+
const syntax = `Text with **unclosed bold at the end`;
388+
const result = parseSyntax(syntax);
389+
390+
expect(result.sections).toHaveLength(1);
391+
const phrases = result.sections![0].paragraphs![0].phrases;
392+
393+
// Should parse without hanging
394+
expect(phrases.length).toBeGreaterThan(0);
395+
expect(phrases[0].value).toBe('Text with ');
396+
expect(phrases[1].value).toBe('**');
397+
expect(phrases[2].value).toBe('unclosed bold at the end');
398+
});
399+
400+
it('should handle unclosed italic formatting without infinite loop', () => {
401+
const syntax = `Text with *unclosed italic at the end`;
402+
const result = parseSyntax(syntax);
403+
404+
expect(result.sections).toHaveLength(1);
405+
const phrases = result.sections![0].paragraphs![0].phrases;
406+
407+
// Should parse without hanging
408+
expect(phrases.length).toBeGreaterThan(0);
409+
expect(phrases[0].value).toBe('Text with ');
410+
expect(phrases[1].value).toBe('*');
411+
expect(phrases[2].value).toBe('unclosed italic at the end');
412+
});
413+
414+
it('should handle unclosed underline formatting without infinite loop', () => {
415+
const syntax = `Text with __unclosed underline at the end`;
416+
const result = parseSyntax(syntax);
417+
418+
expect(result.sections).toHaveLength(1);
419+
const phrases = result.sections![0].paragraphs![0].phrases;
420+
421+
// Should parse without hanging
422+
expect(phrases.length).toBeGreaterThan(0);
423+
expect(phrases[0].value).toBe('Text with ');
424+
expect(phrases[1].value).toBe('__');
425+
expect(phrases[2].value).toBe('unclosed underline at the end');
426+
});
427+
428+
it('should handle multiple unclosed formatting markers', () => {
429+
const syntax = `Text **bold __underline *italic`;
430+
const result = parseSyntax(syntax);
431+
432+
expect(result.sections).toHaveLength(1);
433+
const phrases = result.sections![0].paragraphs![0].phrases;
434+
435+
// Should parse without hanging and treat all markers as plain text
436+
expect(phrases.length).toBeGreaterThan(0);
437+
});
438+
439+
it('should handle streaming-like partial syntax without infinite loop', () => {
440+
// This simulates what happens during streaming when text is incrementally added
441+
const partialChunks = [
442+
'The **premium segment**',
443+
'The **premium segment** (devices over $800) showed *remarkable',
444+
'The **premium segment** (devices over $800) showed *remarkable* [resilience](trend_desc',
445+
];
446+
447+
// Each chunk should parse without infinite loop
448+
partialChunks.forEach((chunk) => {
449+
const result = parseSyntax(chunk);
450+
expect(result.sections).toBeDefined();
451+
});
452+
});
453+
454+
it('should handle formatting markers at exact string boundaries', () => {
455+
// Test edge cases where markers appear at the very end
456+
const testCases = [{ text: 'x**' }, { text: 'x__' }, { text: 'ab**' }, { text: 'ab__' }];
457+
458+
testCases.forEach(({ text }) => {
459+
const result = parseSyntax(text);
460+
expect(result.sections).toBeDefined();
461+
// Should complete without hanging
462+
expect(result.sections!.length).toBeGreaterThanOrEqual(0);
463+
});
464+
});
385465
});

example/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ const app1 = document.getElementById('app1');
5353
const text1 = new Text(app1!);
5454
(async () => {
5555
let chunk = '';
56-
for (let i = 0; i < syntax.length; i += 50) {
57-
chunk += syntax.slice(i, i + 50);
56+
for (let i = 0; i < syntax.length; i += 10) {
57+
chunk += syntax.slice(i, i + 10);
5858
text1.render(chunk);
5959
await new Promise((resolve) => setTimeout(resolve, 160));
6060
}

src/parser/syntax-parser.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ function parseTextWithFormatting(text: string): PhraseSpec[] {
280280
let foundFormatting = false;
281281

282282
// Check for bold (**text**)
283-
if (currentIndex + 2 < textLength && text.substring(currentIndex, currentIndex + 2) === '**') {
283+
if (currentIndex + 1 < textLength && text.substring(currentIndex, currentIndex + 2) === '**') {
284284
const endIndex = text.indexOf('**', currentIndex + 2);
285285
if (endIndex !== -1) {
286286
const content = text.substring(currentIndex + 2, endIndex);
@@ -291,11 +291,19 @@ function parseTextWithFormatting(text: string): PhraseSpec[] {
291291
});
292292
currentIndex = endIndex + 2;
293293
foundFormatting = true;
294+
} else {
295+
// No closing marker found, treat the opening marker as plain text
296+
phrases.push({
297+
type: PhraseType.TEXT,
298+
value: '**',
299+
});
300+
currentIndex += 2;
301+
foundFormatting = true;
294302
}
295303
}
296304

297305
// Check for underline (__text__)
298-
if (!foundFormatting && currentIndex + 2 < textLength && text.substring(currentIndex, currentIndex + 2) === '__') {
306+
if (!foundFormatting && currentIndex + 1 < textLength && text.substring(currentIndex, currentIndex + 2) === '__') {
299307
const endIndex = text.indexOf('__', currentIndex + 2);
300308
if (endIndex !== -1) {
301309
const content = text.substring(currentIndex + 2, endIndex);
@@ -306,6 +314,14 @@ function parseTextWithFormatting(text: string): PhraseSpec[] {
306314
});
307315
currentIndex = endIndex + 2;
308316
foundFormatting = true;
317+
} else {
318+
// No closing marker found, treat the opening marker as plain text
319+
phrases.push({
320+
type: PhraseType.TEXT,
321+
value: '__',
322+
});
323+
currentIndex += 2;
324+
foundFormatting = true;
309325
}
310326
}
311327

@@ -321,6 +337,14 @@ function parseTextWithFormatting(text: string): PhraseSpec[] {
321337
});
322338
currentIndex = endIndex + 1;
323339
foundFormatting = true;
340+
} else {
341+
// No closing marker found, treat the opening marker as plain text
342+
phrases.push({
343+
type: PhraseType.TEXT,
344+
value: '*',
345+
});
346+
currentIndex += 1;
347+
foundFormatting = true;
324348
}
325349
}
326350

@@ -343,19 +367,32 @@ function parseTextWithFormatting(text: string): PhraseSpec[] {
343367
value: plainText,
344368
});
345369
}
346-
currentIndex = nextMarkerIndex;
347370

348-
// If we're at the end and haven't moved, break to avoid infinite loop
349-
if (currentIndex === textLength) {
350-
break;
371+
// If we found a marker at currentIndex but couldn't process it (due to boundary conditions),
372+
// we need to treat it as plain text and advance past it
373+
if (nextMarkerIndex === currentIndex && currentIndex < textLength) {
374+
// Determine marker length at current position
375+
let markerLength = 1;
376+
if (currentIndex + 1 < textLength) {
377+
const twoChar = text.substring(currentIndex, currentIndex + 2);
378+
if (twoChar === '**' || twoChar === '__') {
379+
markerLength = 2;
380+
}
381+
}
382+
383+
phrases.push({
384+
type: PhraseType.TEXT,
385+
value: text.substring(currentIndex, currentIndex + markerLength),
386+
});
387+
currentIndex += markerLength;
388+
} else {
389+
currentIndex = nextMarkerIndex;
351390
}
352-
if (currentIndex === nextMarkerIndex && nextMarkerIndex === textLength) {
391+
392+
// If we're at the end, break to avoid infinite loop
393+
if (currentIndex === textLength) {
353394
break;
354395
}
355-
// If we found no markers and are not at end, skip one character to avoid infinite loop
356-
if (nextMarkerIndex === textLength && currentIndex < textLength) {
357-
currentIndex++;
358-
}
359396
}
360397
}
361398

vite.config.example.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { defineConfig } from 'vite';
2+
import preact from '@preact/preset-vite';
3+
4+
export default defineConfig({
5+
root: './example',
6+
base: './',
7+
build: {
8+
outDir: 'dist',
9+
emptyOutDir: true,
10+
},
11+
plugins: [preact()],
12+
});

0 commit comments

Comments
 (0)