Skip to content

Commit 51b402a

Browse files
committed
Add bullet parsing functionality and corresponding tests
## Summary - Introduced a new function `parseBullets` to handle bullet points with proper brace matching and escape sequences. - Added a function `removeBulletsBlock` to cleanly remove bullet blocks from slide content. - Created a new example file `braces_in_bullets.osf` demonstrating various bullet formats, including nested braces and escaped characters. - Expanded test suite to cover new bullet parsing scenarios, including handling of empty bullet blocks and unclosed bullet blocks. ## Testing - `pnpm test` to ensure all new and existing tests pass successfully.
1 parent fc23814 commit 51b402a

File tree

3 files changed

+312
-15
lines changed

3 files changed

+312
-15
lines changed

examples/braces_in_bullets.osf

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
@meta {
2+
title: "Braces in Bullet Strings Example";
3+
author: "OSF Parser";
4+
version: "0.5.0";
5+
}
6+
7+
@slide {
8+
title: "Code Examples";
9+
layout: TitleAndBullets;
10+
bullets {
11+
"JavaScript function: function() { return true; }";
12+
"Object literal: { key: \"value\", nested: { inner: 42 } }";
13+
"Array with objects: [{ id: 1, name: \"John\" }, { id: 2, name: \"Jane\" }]";
14+
"Complex nested: { outer: { middle: { inner: \"deep value\" } } }";
15+
"Mixed quotes and braces: { \"key\": \"value with { braces }\" }";
16+
}
17+
}
18+
19+
@slide {
20+
title: "Escaped Characters";
21+
layout: TitleAndBullets;
22+
bullets {
23+
"Quote inside: \"Hello World\"";
24+
"Mixed syntax: { \"property\": \"value\" }";
25+
"Escaped backslash: \\\"literal backslash quote\\\"";
26+
}
27+
}

parser/src/parser.ts

Lines changed: 149 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,19 @@ function parseString(str: string, i: number): { value: string; index: number } {
7171
let j = i + 1;
7272
let out = '';
7373
while (j < str.length && str[j] !== '"') {
74-
out += str[j++];
74+
if (str[j] === '\\' && j + 1 < str.length) {
75+
// Handle escape sequences
76+
const nextChar = str[j + 1];
77+
if (nextChar === '"' || nextChar === '\\') {
78+
out += nextChar;
79+
} else {
80+
out += str[j]! + nextChar!;
81+
}
82+
j += 2;
83+
} else {
84+
out += str[j];
85+
j++;
86+
}
7587
}
7688
return { value: out, index: j + 1 };
7789
}
@@ -138,6 +150,133 @@ function parseKV(content: string): Record<string, any> {
138150
return parseKVInternal(cleaned, 0).obj;
139151
}
140152

153+
function parseBullets(content: string): string[] {
154+
// Find the bullets block with proper brace matching
155+
const bulletMatch = /bullets\s*\{/.exec(content);
156+
if (!bulletMatch) return [];
157+
158+
let i = bulletMatch.index + bulletMatch[0].length;
159+
let depth = 1;
160+
let bulletContent = '';
161+
162+
// Find the matching closing brace, respecting quoted strings
163+
while (i < content.length && depth > 0) {
164+
const ch = content[i];
165+
if (ch === '"') {
166+
// Add the opening quote
167+
bulletContent += ch;
168+
i++;
169+
// Process the string content, preserving escape sequences
170+
while (i < content.length && content[i] !== '"') {
171+
if (content[i] === '\\' && i + 1 < content.length) {
172+
// Keep escape sequences as-is for now
173+
bulletContent += content[i]! + content[i + 1]!;
174+
i += 2;
175+
} else {
176+
bulletContent += content[i];
177+
i++;
178+
}
179+
}
180+
if (i < content.length) {
181+
bulletContent += content[i]; // closing quote
182+
i++;
183+
}
184+
} else if (ch === '{') {
185+
depth++;
186+
bulletContent += ch;
187+
i++;
188+
} else if (ch === '}') {
189+
depth--;
190+
if (depth > 0) {
191+
bulletContent += ch;
192+
}
193+
i++;
194+
} else {
195+
bulletContent += ch;
196+
i++;
197+
}
198+
}
199+
200+
if (depth > 0) {
201+
throw new Error('Unclosed bullets block');
202+
}
203+
204+
// Parse individual bullet items more carefully
205+
const bullets: string[] = [];
206+
let j = 0;
207+
208+
while (j < bulletContent.length) {
209+
j = skipWS(bulletContent, j);
210+
if (j >= bulletContent.length) break;
211+
212+
if (bulletContent[j] === '"') {
213+
// Parse quoted string using the existing parseString function
214+
const result = parseString(bulletContent, j);
215+
bullets.push(result.value);
216+
j = result.index;
217+
218+
// Skip whitespace and optional semicolon
219+
j = skipWS(bulletContent, j);
220+
if (j < bulletContent.length && bulletContent[j] === ';') {
221+
j++;
222+
}
223+
} else {
224+
// Handle unquoted content (skip to next semicolon or end)
225+
const start = j;
226+
while (j < bulletContent.length && bulletContent[j] !== ';') {
227+
j++;
228+
}
229+
const item = bulletContent.slice(start, j).trim();
230+
if (item) {
231+
bullets.push(item);
232+
}
233+
if (j < bulletContent.length && bulletContent[j] === ';') {
234+
j++;
235+
}
236+
}
237+
}
238+
239+
return bullets;
240+
}
241+
242+
function removeBulletsBlock(content: string): string {
243+
const bulletMatch = /bullets\s*\{/.exec(content);
244+
if (!bulletMatch) return content;
245+
246+
let i = bulletMatch.index + bulletMatch[0].length;
247+
let depth = 1;
248+
249+
// Find the matching closing brace, respecting quoted strings
250+
while (i < content.length && depth > 0) {
251+
const ch = content[i];
252+
if (ch === '"') {
253+
// Skip over quoted string
254+
i++;
255+
while (i < content.length && content[i] !== '"') {
256+
if (content[i] === '\\' && i + 1 < content.length) {
257+
i += 2;
258+
} else {
259+
i++;
260+
}
261+
}
262+
if (i < content.length) {
263+
i++; // closing quote
264+
}
265+
} else if (ch === '{') {
266+
depth++;
267+
i++;
268+
} else if (ch === '}') {
269+
depth--;
270+
i++;
271+
} else {
272+
i++;
273+
}
274+
}
275+
276+
// Remove the entire bullets block
277+
return content.slice(0, bulletMatch.index) + content.slice(i);
278+
}
279+
141280
export function parse(input: string): OSFDocument {
142281
const blocksRaw = findBlocks(input);
143282
const blocks: OSFBlock[] = blocksRaw.map(b => {
@@ -151,18 +290,13 @@ export function parse(input: string): OSFDocument {
151290
}
152291
case 'slide': {
153292
const slide: SlideBlock = { type: 'slide' };
154-
const bulletMatch = /bullets\s*\{([\s\S]*?)\}/.exec(b.content);
155-
if (bulletMatch) {
156-
const bulletContent = bulletMatch[1];
157-
if (bulletContent) {
158-
const items = bulletContent
159-
.split(/;\s*/)
160-
.map(s => s.trim())
161-
.filter(Boolean);
162-
slide.bullets = items.map(it => it.replace(/^"|"$/g, ''));
163-
}
164-
}
165-
const rest = b.content.replace(/bullets\s*\{[\s\S]*?\}/, '');
293+
294+
// Use the new bullet parser
295+
const bullets = parseBullets(b.content);
296+
slide.bullets = bullets;
297+
298+
// Remove bullets block from content before parsing other properties
299+
const rest = removeBulletsBlock(b.content);
166300
Object.assign(slide, parseKV(rest));
167301
return slide;
168302
}
@@ -260,8 +394,8 @@ export function serialize(doc: OSFDocument): string {
260394
}
261395
});
262396

263-
// Add bullets if they exist
264-
if (bullets && bullets.length > 0) {
397+
// Add bullets if they exist (including empty arrays for consistency)
398+
if (bullets !== undefined) {
265399
parts.push(' bullets {');
266400
bullets.forEach(bullet => {
267401
parts.push(` "${bullet}";`);

parser/tests/parser.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,141 @@ describe('OSF Parser', () => {
6060
expect(slideBlock.bullets).toEqual(['First bullet', 'Second bullet', 'Third bullet']);
6161
});
6262

63+
it('should parse slide bullets with braces inside strings', () => {
64+
const input = `@slide {
65+
title: "Test Slide";
66+
bullets {
67+
"Function call: func() { return true; }";
68+
"Object literal: { key: value }";
69+
"Array with objects: [{ id: 1 }, { id: 2 }]";
70+
"Nested braces: { outer: { inner: \\"value\\" } }";
71+
}
72+
}`;
73+
74+
const result = parse(input);
75+
76+
expect(result.blocks).toHaveLength(1);
77+
expect(result.blocks[0]?.type).toBe('slide');
78+
79+
const slideBlock = result.blocks[0] as SlideBlock;
80+
expect(slideBlock.title).toBe('Test Slide');
81+
expect(slideBlock.bullets).toEqual([
82+
'Function call: func() { return true; }',
83+
'Object literal: { key: value }',
84+
'Array with objects: [{ id: 1 }, { id: 2 }]',
85+
'Nested braces: { outer: { inner: "value" } }'
86+
]);
87+
});
88+
89+
it('should parse slide bullets with escaped quotes', () => {
90+
const input = `@slide {
91+
title: "Test Slide";
92+
bullets {
93+
"Quote inside: \\"Hello World\\"";
94+
"Mixed: { \\"key\\": \\"value\\" }";
95+
"Escaped backslash: \\\\\\"test\\\\\\"";
96+
}
97+
}`;
98+
99+
const result = parse(input);
100+
101+
expect(result.blocks).toHaveLength(1);
102+
const slideBlock = result.blocks[0] as SlideBlock;
103+
expect(slideBlock.bullets).toEqual([
104+
'Quote inside: "Hello World"',
105+
'Mixed: { "key": "value" }',
106+
'Escaped backslash: \\"test\\"'
107+
]);
108+
});
109+
110+
it('should handle empty bullets block', () => {
111+
const input = `@slide {
112+
title: "Test Slide";
113+
bullets {
114+
}
115+
}`;
116+
117+
const result = parse(input);
118+
119+
expect(result.blocks).toHaveLength(1);
120+
const slideBlock = result.blocks[0] as SlideBlock;
121+
expect(slideBlock.bullets).toEqual([]);
122+
});
123+
124+
it('should handle bullets with only whitespace', () => {
125+
const input = `@slide {
126+
title: "Test Slide";
127+
bullets {
128+
129+
130+
}
131+
}`;
132+
133+
const result = parse(input);
134+
135+
expect(result.blocks).toHaveLength(1);
136+
const slideBlock = result.blocks[0] as SlideBlock;
137+
expect(slideBlock.bullets).toEqual([]);
138+
});
139+
140+
it('should handle bullets with trailing semicolons', () => {
141+
const input = `@slide {
142+
title: "Test Slide";
143+
bullets {
144+
"First bullet";
145+
"Second bullet";
146+
"Third bullet";
147+
}
148+
}`;
149+
150+
const result = parse(input);
151+
152+
expect(result.blocks).toHaveLength(1);
153+
const slideBlock = result.blocks[0] as SlideBlock;
154+
expect(slideBlock.bullets).toEqual(['First bullet', 'Second bullet', 'Third bullet']);
155+
});
156+
157+
it('should handle bullets without trailing semicolons', () => {
158+
const input = `@slide {
159+
title: "Test Slide";
160+
bullets {
161+
"First bullet"
162+
"Second bullet"
163+
"Third bullet"
164+
}
165+
}`;
166+
167+
const result = parse(input);
168+
169+
expect(result.blocks).toHaveLength(1);
170+
const slideBlock = result.blocks[0] as SlideBlock;
171+
expect(slideBlock.bullets).toEqual(['First bullet', 'Second bullet', 'Third bullet']);
172+
});
173+
174+
it('should throw error for unclosed bullets block', () => {
175+
const input = `@slide {
176+
title: "Test Slide";
177+
bullets {
178+
"First bullet";
179+
"Second bullet";
180+
}
181+
}`;
182+
183+
// This should work fine
184+
const result = parse(input);
185+
expect(result.blocks).toHaveLength(1);
186+
187+
// Test a truly unclosed bullets block
188+
const badInput = `@slide {
189+
title: "Test Slide";
190+
bullets {
191+
"First bullet";
192+
"Second bullet"
193+
}`;
194+
195+
expect(() => parse(badInput)).toThrow('Missing closing } for block slide');
196+
});
197+
63198
it('should parse a sheet block with data and formulas', () => {
64199
const input = `@sheet {
65200
name: "TestSheet";
@@ -147,6 +282,7 @@ describe('OSF Parser', () => {
147282
const docBlock = result.blocks[0] as DocBlock;
148283
expect(docBlock.content).toBe('');
149284
});
285+
150286
});
151287

152288
describe('serialize', () => {

0 commit comments

Comments
 (0)