Skip to content

Commit 0b1c66a

Browse files
committed
Handle multiple code blocks per fragment
1 parent c8f09f4 commit 0b1c66a

File tree

2 files changed

+98
-1
lines changed

2 files changed

+98
-1
lines changed

src/participant/streamParsing.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,20 +86,24 @@ class FragmentMatcher {
8686
private _endMatcher: StreamingKMP;
8787
private _matchedContent?: string;
8888
private _onContentMatched: (content: string) => void;
89+
private _onFragmentProcessed: (content: string) => void;
8990

9091
constructor({
9192
identifier,
9293
onContentMatched,
94+
onFragmentProcessed,
9395
}: {
9496
identifier: {
9597
start: string;
9698
end: string;
9799
};
98100
onContentMatched: (content: string) => void;
101+
onFragmentProcessed: (content: string) => void;
99102
}) {
100103
this._startMatcher = new StreamingKMP(identifier.start);
101104
this._endMatcher = new StreamingKMP(identifier.end);
102105
this._onContentMatched = onContentMatched;
106+
this._onFragmentProcessed = onFragmentProcessed;
103107
}
104108

105109
private _contentMatched(): void {
@@ -116,6 +120,18 @@ class FragmentMatcher {
116120
this._endMatcher.reset();
117121
}
118122

123+
// This needs to be invoked every time before we call `process` recursively or when `process`
124+
// completes processing the fragment. It will emit a notification to subscribers with the partial
125+
// fragment we've processed, regardless of whether there's a match or not.
126+
private _partialFragmentProcessed(
127+
fragment: string,
128+
index: number | undefined = undefined
129+
): void {
130+
this._onFragmentProcessed(
131+
index === undefined ? fragment : fragment.slice(0, index)
132+
);
133+
}
134+
119135
public process(fragment: string): void {
120136
if (this._matchedContent === undefined) {
121137
// We haven't matched the start identifier yet, so try and do that
@@ -124,19 +140,24 @@ class FragmentMatcher {
124140
// We found a match for the start identifier - update `_matchedContent` to an empty string
125141
// and recursively call `process` with the remainder of the fragment.
126142
this._matchedContent = '';
143+
this._partialFragmentProcessed(fragment, startIndex);
127144
this.process(fragment.slice(startIndex));
145+
} else {
146+
this._partialFragmentProcessed(fragment);
128147
}
129148
} else {
130149
const endIndex = this._endMatcher.match(fragment);
131150
if (endIndex !== -1) {
132151
// We've matched the end - emit the matched content and continue processing the partial fragment
133152
this._matchedContent += fragment.slice(0, endIndex);
153+
this._partialFragmentProcessed(fragment, endIndex);
134154
this._contentMatched();
135155
this.process(fragment.slice(endIndex));
136156
} else {
137157
// We haven't matched the end yet - append the fragment to the matched content and wait
138158
// for a future fragment to contain the end identifier.
139159
this._matchedContent += fragment;
160+
this._partialFragmentProcessed(fragment);
140161
}
141162
}
142163
}
@@ -165,10 +186,10 @@ export async function processStreamWithIdentifiers({
165186
const fragmentMatcher = new FragmentMatcher({
166187
identifier,
167188
onContentMatched: onStreamIdentifier,
189+
onFragmentProcessed: processStreamFragment,
168190
});
169191

170192
for await (const fragment of inputIterable) {
171-
processStreamFragment(fragment);
172193
fragmentMatcher.process(fragment);
173194
}
174195
}

src/test/suite/participant/streamParsing.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,4 +216,80 @@ suite('processStreamWithIdentifiers', () => {
216216
expect(fragmentsProcessed.join('')).to.deep.equal(inputFragments.join(''));
217217
expect(identifiersStreamed).to.deep.equal(['\ncode1\n', '\ncode2\n']);
218218
});
219+
220+
test('one fragment containing multiple code blocks emits event in correct order', async () => {
221+
// In case we have one fragment containing multiple code blocks, we want to make sure that
222+
// fragment notifications and identifier notifications arrive in the right order so that we're
223+
// adding code actions after the correct subfragment.
224+
// For example:
225+
// 'Text before code.\n```js\ncode1\n```\nText between code.\n```js\ncode2\n```\nText after code.'
226+
//
227+
// should emit:
228+
//
229+
// processStreamFragment: 'Text before code.\n```js\ncode1\n```'
230+
// onStreamIdentifier: '\ncode1\n'
231+
// processStreamFragment: '\nText between code.\n```js\ncode2\n```'
232+
// onStreamIdentifier: '\ncode2\n'
233+
// processStreamFragment: '\nText after code.'
234+
//
235+
// in that order to ensure we add each code action immediately after the code block
236+
// rather than add both at the end.
237+
238+
const inputFragments = [
239+
'Text before code.\n```js\ncode1\n```\nText between code.\n```js\ncode2\n```\nText after code.',
240+
];
241+
242+
const inputIterable = asyncIterableFromArray<string>(inputFragments);
243+
const identifier = { start: '```js', end: '```' };
244+
245+
const fragmentsEmitted: {
246+
source: 'processStreamFragment' | 'onStreamIdentifier';
247+
content: string;
248+
}[] = [];
249+
250+
const getFragmentHandler = (
251+
source: 'processStreamFragment' | 'onStreamIdentifier'
252+
): ((fragment: string) => void) => {
253+
return (fragment: string): void => {
254+
// It's an implementation detail, but the way the code is structured today, we're splitting the emitted fragments
255+
// whenever we find either a start or end identifier. This is irrelevant as long as we're emitting the entirety of
256+
// the text until the end of the code block in `processStreamFragment` and then the code block itself in `onStreamIdentifier`.
257+
// With the code below, we're combining all subfragments with the same source to make the test verify the desired
258+
// behavior rather than the actual implementation.
259+
const lastFragment = fragmentsEmitted[fragmentsEmitted.length - 1];
260+
if (lastFragment?.source === source) {
261+
lastFragment.content += fragment;
262+
} else {
263+
fragmentsEmitted.push({ source, content: fragment });
264+
}
265+
};
266+
};
267+
268+
await processStreamWithIdentifiers({
269+
processStreamFragment: getFragmentHandler('processStreamFragment'),
270+
onStreamIdentifier: getFragmentHandler('onStreamIdentifier'),
271+
inputIterable,
272+
identifier,
273+
});
274+
275+
expect(fragmentsEmitted).to.have.length(5);
276+
expect(fragmentsEmitted[0].source).to.equal('processStreamFragment');
277+
expect(fragmentsEmitted[0].content).to.equal(
278+
'Text before code.\n```js\ncode1\n```'
279+
);
280+
281+
expect(fragmentsEmitted[1].source).to.equal('onStreamIdentifier');
282+
expect(fragmentsEmitted[1].content).to.equal('\ncode1\n');
283+
284+
expect(fragmentsEmitted[2].source).to.equal('processStreamFragment');
285+
expect(fragmentsEmitted[2].content).to.equal(
286+
'\nText between code.\n```js\ncode2\n```'
287+
);
288+
289+
expect(fragmentsEmitted[3].source).to.equal('onStreamIdentifier');
290+
expect(fragmentsEmitted[3].content).to.equal('\ncode2\n');
291+
292+
expect(fragmentsEmitted[4].source).to.equal('processStreamFragment');
293+
expect(fragmentsEmitted[4].content).to.equal('\nText after code.');
294+
});
219295
});

0 commit comments

Comments
 (0)