Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompt/node/streamingEdits.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type * as vscode from 'vscode';
7
import { AsyncIterableObject } from '../../../util/vs/base/common/async';
8
import { CharCode } from '../../../util/vs/base/common/charCode';
9
import { Constants } from '../../../util/vs/base/common/uint';
10
import { Range, TextEdit } from '../../../vscodeTypes';
11
import { looksLikeCode } from '../common/codeGuesser';
12
import { isImportStatement } from '../common/importStatement';
13
import { EditStrategy, Lines, trimLeadingWhitespace } from './editGeneration';
14
import { computeIndentLevel2, guessIndentation, normalizeIndentation } from './indentationGuesser';
15
16
export interface IStreamingEditsStrategy {
17
processStream(stream: AsyncIterable<LineOfText>): Promise<StreamingEditsResult>;
18
}
19
20
export interface IStreamingEditsStrategyFactory {
21
(lineFilter: ILineFilter, streamingWorkingCopyDocument: StreamingWorkingCopyDocument): IStreamingEditsStrategy;
22
}
23
24
export class InsertOrReplaceStreamingEdits implements IStreamingEditsStrategy {
25
26
private replyIndentationTracker: ReplyIndentationTracker | null = null;
27
28
constructor(
29
private readonly myDocument: StreamingWorkingCopyDocument,
30
private readonly initialSelection: vscode.Range,
31
private readonly adjustedSelection: vscode.Range,
32
private readonly editStrategy: EditStrategy,
33
private readonly collectImports: boolean = true,
34
private readonly lineFilter: ILineFilter = LineFilters.noop,
35
) {
36
}
37
38
public async processStream(_stream: AsyncIterable<LineOfText>): Promise<StreamingEditsResult> {
39
// console.log();
40
// console.log();
41
let stream = AsyncIterableObject.filter(_stream, this.lineFilter);
42
if (this.collectImports) {
43
stream = collectImportsIfNoneWereSentInRange(stream, this.myDocument, this.adjustedSelection);
44
}
45
46
let anchorLineIndex = this.myDocument.firstSentLineIndex;
47
48
for await (const el of this.findInitialAnchor(stream)) {
49
if (el instanceof LineWithAnchorInfo) {
50
anchorLineIndex = this.handleFirstReplyLine(el.anchor, el.line);
51
} else {
52
anchorLineIndex = this.handleSubsequentReplyLine(anchorLineIndex, el.value);
53
}
54
}
55
56
if (this.myDocument.didReplaceEdits && anchorLineIndex <= this.adjustedSelection.end.line) {
57
// anchorIndex hasn't reached the end of the ICodeContextInfo.range
58
// Emit a deletion of all remaining lines in the selection block
59
this.myDocument.deleteLines(anchorLineIndex, this.adjustedSelection.end.line);
60
}
61
62
return new StreamingEditsResult(
63
this.myDocument.didNoopEdits,
64
this.myDocument.didEdits,
65
this.myDocument.additionalImports
66
);
67
}
68
69
private handleFirstReplyLine(anchor: MatchedDocumentLine | null, line: string): number {
70
71
if (anchor) {
72
this.replyIndentationTracker = new ReplyIndentationTracker(this.myDocument, anchor.lineIndex, line);
73
const fixedLine = this.replyIndentationTracker.reindent(line, this.myDocument.indentStyle);
74
if (this.myDocument.getLine(anchor.lineIndex).sentInCodeBlock === SentInCodeBlock.Range) {
75
// Matched a line in the range => replace the entire sent range
76
return this.myDocument.replaceLines(this.adjustedSelection.start.line, anchor.lineIndex, fixedLine);
77
} else {
78
return this.myDocument.replaceLine(anchor.lineIndex, fixedLine);
79
}
80
}
81
82
// No anchor found
83
const firstRangeLine = this.adjustedSelection.start.line;
84
this.replyIndentationTracker = new ReplyIndentationTracker(this.myDocument, firstRangeLine, line);
85
const fixedLine = this.replyIndentationTracker.reindent(line, this.myDocument.indentStyle);
86
87
if (this.initialSelection.isEmpty) {
88
const cursorLineContent = this.myDocument.getLine(firstRangeLine).content;
89
if (
90
/^\s*$/.test(cursorLineContent)
91
|| fixedLine.adjustedContent.startsWith(cursorLineContent)
92
) {
93
// Cursor sitting on an empty or whitespace only line or the reply continues the line
94
return this.myDocument.replaceLine(firstRangeLine, fixedLine, /*isPreserving*/true);
95
}
96
}
97
98
if (this.editStrategy === EditStrategy.FallbackToInsertAboveRange) {
99
return this.myDocument.insertLineBefore(firstRangeLine, fixedLine);
100
}
101
if (this.editStrategy === EditStrategy.FallbackToInsertBelowRange || this.editStrategy === EditStrategy.ForceInsertion) {
102
return this.myDocument.insertLineAfter(firstRangeLine, fixedLine);
103
}
104
// DefaultEditStrategy.ReplaceRange
105
return this.myDocument.replaceLine(firstRangeLine, fixedLine);
106
}
107
108
private handleSubsequentReplyLine(anchorLineIndex: number, line: string): number {
109
const fixedLine = this.replyIndentationTracker!.reindent(line, this.myDocument.indentStyle);
110
111
if (fixedLine.trimmedContent !== '' || this.myDocument.didReplaceEdits) {
112
// search for a matching line only if the incoming line is not empty
113
// or if we have already made destructive edits
114
const matchedLine = this.matchReplyLine(fixedLine, anchorLineIndex);
115
if (matchedLine) {
116
return this.myDocument.replaceLines(anchorLineIndex, matchedLine.lineIndex, fixedLine);
117
}
118
}
119
120
121
if (anchorLineIndex >= this.myDocument.getLineCount()) {
122
// end of file => insert semantics!
123
return this.myDocument.appendLineAtEndOfDocument(fixedLine);
124
}
125
126
const existingLine = this.myDocument.getLine(anchorLineIndex);
127
if (!existingLine.isSent || existingLine.content === '' || fixedLine.trimmedContent === '') {
128
// line not sent or dealing empty lines => insert semantics!
129
return this.myDocument.insertLineBefore(anchorLineIndex, fixedLine);
130
}
131
132
if (existingLine.indentLevel < fixedLine.adjustedIndentLevel) {
133
// do not leave current scope with the incoming line
134
return this.myDocument.insertLineBefore(anchorLineIndex, fixedLine);
135
}
136
137
if (existingLine.indentLevel === fixedLine.adjustedIndentLevel && !this.myDocument.didReplaceEdits) {
138
// avoid overwriting sibling scope if no destructive edits have been made so far
139
return this.myDocument.insertLineBefore(anchorLineIndex, fixedLine);
140
}
141
142
return this.myDocument.replaceLine(anchorLineIndex, fixedLine);
143
}
144
145
private matchReplyLine(replyLine: ReplyLine, minimumLineIndex: number): MatchedDocumentLine | null {
146
const isVeryShortReplyLine = replyLine.trimmedContent.length <= 3;
147
148
for (let lineIndex = minimumLineIndex; lineIndex < this.myDocument.getLineCount(); lineIndex++) {
149
const documentLine = this.myDocument.getLine(lineIndex);
150
if (!documentLine.isSent) {
151
continue;
152
}
153
if (documentLine.normalizedContent === replyLine.adjustedContent) {
154
// bingo!
155
return new MatchedDocumentLine(lineIndex);
156
}
157
if (documentLine.trimmedContent.length > 0 && documentLine.indentLevel < replyLine.adjustedIndentLevel) {
158
// we shouldn't proceed with the search if we need to jump over original code that is more outdented
159
return null;
160
}
161
if (isVeryShortReplyLine && documentLine.trimmedContent.length > 0) {
162
// don't jump over original code with content if the reply is very short
163
return null;
164
}
165
}
166
return null;
167
}
168
169
/**
170
* Waits until at least 10 non-whitespace characters are seen in the stream
171
* Then tries to find a sequence of sent lines that match those first lines in the stream
172
*/
173
private findInitialAnchor(lineStream: AsyncIterable<LineOfText>): AsyncIterable<LineOfText | LineWithAnchorInfo> {
174
return new AsyncIterableObject<LineOfText | LineWithAnchorInfo>(async (emitter) => {
175
const accumulatedLines: LineOfText[] = [];
176
let accumulatedRealChars = 0; // non whitespace chars
177
let anchorFound = false;
178
for await (const line of lineStream) {
179
if (!anchorFound) {
180
accumulatedLines.push(line);
181
accumulatedRealChars += line.value.trim().length;
182
if (accumulatedRealChars > 10) {
183
const anchor = this.searchForEqualSentLines(accumulatedLines);
184
anchorFound = true;
185
emitter.emitOne(new LineWithAnchorInfo(accumulatedLines[0].value, anchor));
186
emitter.emitMany(accumulatedLines.slice(1));
187
}
188
} else {
189
emitter.emitOne(line);
190
}
191
}
192
});
193
}
194
195
/**
196
* Search for a contiguous set of lines in the document that match the lines.
197
* The equality is done with trimmed content.
198
*/
199
private searchForEqualSentLines(lines: LineOfText[]): MatchedDocumentLine | null {
200
const trimmedLines = lines.map(line => line.value.trim());
201
202
for (let i = this.myDocument.firstSentLineIndex, stopAt = this.myDocument.getLineCount() - lines.length; i <= stopAt; i++) {
203
if (!this.myDocument.getLine(i).isSent) {
204
continue;
205
}
206
let matchedAllLines = true;
207
for (let j = 0; j < trimmedLines.length; j++) {
208
const documentLine = this.myDocument.getLine(i + j);
209
if (!documentLine.isSent || documentLine.trimmedContent !== trimmedLines[j]) {
210
matchedAllLines = false;
211
break;
212
}
213
}
214
if (matchedAllLines) {
215
return new MatchedDocumentLine(i);
216
}
217
}
218
return null;
219
}
220
}
221
222
export class InsertionStreamingEdits implements IStreamingEditsStrategy {
223
224
private replyIndentationTracker: ReplyIndentationTracker | null = null;
225
226
constructor(
227
private readonly _myDocument: IStreamingWorkingCopyDocument,
228
private readonly _cursorPosition: vscode.Position,
229
private readonly _lineFilter: ILineFilter = LineFilters.noop
230
) { }
231
232
public async processStream(_stream: AsyncIterable<LineOfText>): Promise<StreamingEditsResult> {
233
let stream = AsyncIterableObject.filter(_stream, this._lineFilter);
234
stream = collectImportsIfNoneWereSentInRange(stream, this._myDocument, new Range(this._cursorPosition, this._cursorPosition));
235
236
let anchorLineIndex = 0;
237
238
for await (const line of stream) {
239
if (!this.replyIndentationTracker) {
240
// This is the first line
241
anchorLineIndex = this.handleFirstReplyLine(line.value);
242
} else {
243
anchorLineIndex = this.handleSubsequentReplyLine(anchorLineIndex, line.value);
244
}
245
}
246
247
return new StreamingEditsResult(
248
this._myDocument.didNoopEdits,
249
this._myDocument.didEdits,
250
this._myDocument.additionalImports,
251
);
252
}
253
254
private handleFirstReplyLine(replyLine: string): number {
255
256
const firstRangeLine = this._cursorPosition.line;
257
258
const cursorLineContent = this._myDocument.getLine(firstRangeLine).content;
259
260
// Cursor sitting on an empty or whitespace only line or the reply continues the line
261
const shouldLineBeReplaced = /^\s*$/.test(cursorLineContent) || replyLine.trimStart().startsWith(cursorLineContent.trimStart());
262
263
const lineNumForIndentGuessing = shouldLineBeReplaced // @ulugbekna: if we are insert line "after" (ie using `insertLineAfter`) we should guess indentation starting from where we insert the line
264
? firstRangeLine
265
: (this._myDocument.getLineCount() <= firstRangeLine + 1 ? firstRangeLine : firstRangeLine + 1);
266
267
this.replyIndentationTracker = new ReplyIndentationTracker(this._myDocument, lineNumForIndentGuessing, replyLine);
268
const fixedLine = this.replyIndentationTracker.reindent(replyLine, this._myDocument.indentStyle);
269
270
if (shouldLineBeReplaced) {
271
return this._myDocument.replaceLine(firstRangeLine, fixedLine, /*isPreserving*/true);
272
}
273
274
return this._myDocument.insertLineAfter(firstRangeLine, fixedLine);
275
}
276
277
private handleSubsequentReplyLine(anchorLineIndex: number, line: string): number {
278
const fixedLine = this.replyIndentationTracker!.reindent(line, this._myDocument.indentStyle);
279
280
return this._myDocument.insertLineBefore(anchorLineIndex, fixedLine);
281
}
282
}
283
284
export class ReplaceSelectionStreamingEdits implements IStreamingEditsStrategy {
285
286
private replyIndentationTracker: ReplyIndentationTracker | null = null;
287
288
constructor(
289
private readonly _myDocument: IStreamingWorkingCopyDocument,
290
private readonly _selection: vscode.Range,
291
private readonly _lineFilter: ILineFilter = LineFilters.noop
292
) { }
293
294
public async processStream(_stream: AsyncIterable<LineOfText>): Promise<StreamingEditsResult> {
295
let stream = AsyncIterableObject.filter(_stream, this._lineFilter);
296
stream = collectImportsIfNoneWereSentInRange(stream, this._myDocument, this._selection);
297
298
let anchorLineIndex = 0;
299
300
let replaceLineCount: number;
301
let initialTextOnLineAfterSelection: string = '';
302
if (this._selection.end.line > this._selection.start.line && this._selection.end.character === 0) {
303
replaceLineCount = this._selection.end.line - this._selection.start.line;
304
} else {
305
replaceLineCount = this._selection.end.line - this._selection.start.line + 1;
306
initialTextOnLineAfterSelection = this._myDocument.getLine(this._selection.end.line).content.substring(this._selection.end.character);
307
}
308
309
for await (const line of stream) {
310
if (!this.replyIndentationTracker) {
311
// This is the first line
312
// anchorLineIndex = this.handleFirstReplyLine(line);
313
const firstRangeLine = this._selection.start.line;
314
this.replyIndentationTracker = new ReplyIndentationTracker(this._myDocument, firstRangeLine, line.value);
315
const fixedLine = this.replyIndentationTracker.reindent(line.value, this._myDocument.indentStyle);
316
anchorLineIndex = this._myDocument.replaceLine(firstRangeLine, fixedLine);
317
replaceLineCount--;
318
} else {
319
// anchorLineIndex = this.handleSubsequentReplyLine(anchorLineIndex, line);
320
const fixedLine = this.replyIndentationTracker!.reindent(line.value, this._myDocument.indentStyle);
321
if (replaceLineCount > 0) {
322
anchorLineIndex = this._myDocument.replaceLine(anchorLineIndex, fixedLine);
323
replaceLineCount--;
324
} else {
325
anchorLineIndex = this._myDocument.insertLineAfter(anchorLineIndex - 1, fixedLine);
326
// anchorLineIndex = this._myDocument.insertLineBefore(anchorLineIndex, fixedLine);
327
}
328
}
329
}
330
331
if (this._myDocument.didEdits && replaceLineCount > 0) {
332
this._myDocument.deleteLines(anchorLineIndex, anchorLineIndex + replaceLineCount - 1);
333
}
334
if (this._myDocument.didEdits && initialTextOnLineAfterSelection.length > 0) {
335
this._myDocument.replaceLine(anchorLineIndex - 1, this._myDocument.getLine(anchorLineIndex - 1).content + initialTextOnLineAfterSelection);
336
}
337
338
return new StreamingEditsResult(
339
this._myDocument.didNoopEdits,
340
this._myDocument.didEdits,
341
this._myDocument.additionalImports
342
);
343
}
344
}
345
346
/**
347
* A filter which can be used to ignore lines from a stream.
348
* Returns true if the line should be kept.
349
*/
350
export interface ILineFilter {
351
(line: LineOfText): boolean;
352
}
353
354
export class StreamingEditsResult {
355
constructor(
356
public readonly didNoopEdits: boolean,
357
public readonly didEdits: boolean,
358
public readonly additionalImports: string[],
359
) { }
360
}
361
362
/**
363
* Keeps track of the indentation of the reply lines and is able to
364
* reindent reply lines to match the document, keeping their relative indentation.
365
*/
366
class ReplyIndentationTracker {
367
368
private _replyIndentStyle: vscode.FormattingOptions | undefined;
369
private indentDelta: number;
370
371
constructor(
372
document: IStreamingWorkingCopyDocument,
373
documentLineIdx: number,
374
replyLine: string
375
) {
376
let docIndentLevel = 0;
377
for (let i = documentLineIdx; i >= 0; i--) {
378
const documentLine = document.getLine(i);
379
// Use the indent of the first non-empty line
380
if (documentLine.content.length > 0) {
381
docIndentLevel = documentLine.indentLevel;
382
if (i !== documentLineIdx) {
383
// The first non-empty line is not the current line, indent if necessary
384
if (
385
documentLine.content.endsWith('{') ||
386
(document.languageId === 'python' && documentLine.content.endsWith(':'))
387
) {
388
// TODO: this is language specific
389
docIndentLevel += 1;
390
}
391
}
392
break;
393
}
394
}
395
396
this._replyIndentStyle = IndentUtils.guessIndentStyleFromLine(replyLine);
397
const replyIndentLevel = computeIndentLevel2(replyLine, this._replyIndentStyle?.tabSize ?? 4);
398
399
this.indentDelta = replyIndentLevel - docIndentLevel;
400
}
401
402
public reindent(replyLine: string, desiredStyle: vscode.FormattingOptions): ReplyLine {
403
if (replyLine === '') {
404
// Do not indent empty lines artificially
405
return new ReplyLine('', 0, '', 0);
406
}
407
408
if (!this._replyIndentStyle) {
409
this._replyIndentStyle = IndentUtils.guessIndentStyleFromLine(replyLine);
410
}
411
412
let originalIndentLevel = 0;
413
let adjustedIndentLevel = 0;
414
const determineAdjustedIndentLevel = (currentIndentLevel: number) => {
415
originalIndentLevel = currentIndentLevel;
416
adjustedIndentLevel = Math.max(originalIndentLevel - this.indentDelta, 0);
417
return adjustedIndentLevel;
418
};
419
const adjustedContent = IndentUtils.reindentLine(replyLine, this._replyIndentStyle ?? { insertSpaces: true, tabSize: 4 }, desiredStyle, determineAdjustedIndentLevel);
420
421
return new ReplyLine(replyLine, originalIndentLevel, adjustedContent, adjustedIndentLevel);
422
}
423
}
424
425
class LineWithAnchorInfo {
426
constructor(
427
readonly line: string,
428
readonly anchor: MatchedDocumentLine | null,
429
) { }
430
}
431
432
export class SentLine {
433
constructor(
434
readonly lineIndex: number,
435
readonly sentInCodeBlock: SentInCodeBlock.Above | SentInCodeBlock.Range | SentInCodeBlock.Below | SentInCodeBlock.Other
436
) { }
437
}
438
439
export class LineRange {
440
constructor(
441
readonly startLineIndex: number,
442
readonly endLineIndex: number
443
) { }
444
}
445
446
export interface IStreamingWorkingCopyDocument {
447
readonly languageId: string;
448
readonly indentStyle: vscode.FormattingOptions;
449
readonly didNoopEdits: boolean;
450
readonly didEdits: boolean;
451
readonly additionalImports: string[];
452
453
getLineCount(): number;
454
getLine(index: number): DocumentLine;
455
addAdditionalImport(importStatement: string): void;
456
replaceLine(index: number, line: ReplyLine | string, isPreserving?: boolean): number;
457
replaceLines(fromIndex: number, toIndex: number, line: ReplyLine): number;
458
appendLineAtEndOfDocument(line: ReplyLine): number;
459
insertLineAfter(index: number, line: ReplyLine): number;
460
insertLineBefore(index: number, line: ReplyLine): number;
461
deleteLines(fromIndex: number, toIndex: number): number;
462
}
463
464
/**
465
* Keeps track of the current document with edits applied immediately.
466
*/
467
export class StreamingWorkingCopyDocument implements IStreamingWorkingCopyDocument {
468
469
public readonly indentStyle: vscode.FormattingOptions;
470
private readonly _originalLines: string[] = [];
471
private lines: DocumentLine[] = [];
472
public readonly firstSentLineIndex: number;
473
private _didNoopEdits = false;
474
private _didEdits = false;
475
private _didReplaceEdits = false;
476
private readonly _additionalImports: string[] = [];
477
478
public get didNoopEdits(): boolean {
479
return this._didNoopEdits;
480
}
481
482
public get didEdits(): boolean {
483
return this._didEdits;
484
}
485
486
public get didReplaceEdits(): boolean {
487
return this._didReplaceEdits;
488
}
489
490
public get additionalImports(): string[] {
491
return this._additionalImports;
492
}
493
494
constructor(
495
private readonly outputStream: vscode.ChatResponseStream,
496
private readonly uri: vscode.Uri,
497
sourceCode: string,
498
sentLines: SentLine[],
499
selection: LineRange,
500
public readonly languageId: string,
501
fileIndentInfo: vscode.FormattingOptions | undefined
502
) {
503
// console.info(`---------\nNEW StreamingWorkingCopyDocument`);
504
this.indentStyle = IndentUtils.getDocumentIndentStyle(sourceCode, fileIndentInfo);
505
506
this._originalLines = sourceCode.split(/\r\n|\r|\n/g);
507
for (let i = 0; i < this._originalLines.length; i++) {
508
this.lines[i] = new DocumentLine(this._originalLines[i], this.indentStyle);
509
}
510
511
this.firstSentLineIndex = Number.MAX_SAFE_INTEGER;
512
for (const sentLine of sentLines) {
513
this.lines[sentLine.lineIndex].markSent(sentLine.sentInCodeBlock);
514
this.firstSentLineIndex = Math.min(this.firstSentLineIndex, sentLine.lineIndex);
515
}
516
517
this.firstSentLineIndex = Math.min(this.firstSentLineIndex, selection.startLineIndex);
518
}
519
520
public getText(): string {
521
return this.lines.map(line => line.content).join('\n');
522
}
523
524
public getLineCount(): number {
525
return this.lines.length;
526
}
527
528
public getLine(index: number): DocumentLine {
529
if (index < 0 || index >= this.lines.length) {
530
throw new Error(`Invalid index`);
531
}
532
return this.lines[index];
533
}
534
535
public addAdditionalImport(importStatement: string): void {
536
this._additionalImports.push(importStatement);
537
}
538
539
public replaceLine(index: number, line: ReplyLine | string, isPreserving: boolean = false): number {
540
const newLineContent = typeof line === 'string' ? line : line.adjustedContent;
541
// console.info(`replaceLine(${index}, ${this.lines[index].content}, ${newLineContent})`);
542
if (this.lines[index].content === newLineContent) {
543
this._didNoopEdits = true;
544
// no need to really replace the line
545
return index + 1;
546
}
547
this.lines[index] = new DocumentLine(newLineContent, this.indentStyle);
548
this.outputStream.textEdit(this.uri, [new TextEdit(new Range(index, 0, index, Constants.MAX_SAFE_SMALL_INTEGER), newLineContent)]);
549
this._didEdits = true;
550
this._didReplaceEdits = this._didReplaceEdits || (isPreserving ? false : true);
551
return index + 1;
552
}
553
554
public replaceLines(fromIndex: number, toIndex: number, line: ReplyLine): number {
555
if (fromIndex > toIndex) {
556
throw new Error(`Invalid range`);
557
}
558
if (fromIndex === toIndex) {
559
return this.replaceLine(fromIndex, line);
560
}
561
// console.info(`replaceLines(${fromIndex}, ${toIndex}, ${line.adjustedContent})`);
562
this.lines.splice(fromIndex, toIndex - fromIndex + 1, new DocumentLine(line.adjustedContent, this.indentStyle));
563
this.outputStream.textEdit(this.uri, [new TextEdit(new Range(fromIndex, 0, toIndex, Constants.MAX_SAFE_SMALL_INTEGER), line.adjustedContent)]);
564
this._didEdits = true;
565
this._didReplaceEdits = true;
566
return fromIndex + 1;
567
}
568
569
public appendLineAtEndOfDocument(line: ReplyLine): number {
570
// console.info(`appendLine(${line.adjustedContent})`);
571
this.lines.push(new DocumentLine(line.adjustedContent, this.indentStyle));
572
this.outputStream.textEdit(this.uri, [new TextEdit(new Range(this.lines.length - 1, Constants.MAX_SAFE_SMALL_INTEGER, this.lines.length - 1, Constants.MAX_SAFE_SMALL_INTEGER), '\n' + line.adjustedContent)]);
573
this._didEdits = true;
574
return this.lines.length;
575
}
576
577
public insertLineAfter(index: number, line: ReplyLine): number {
578
// console.info(`insertLineAfter(${index}, ${this.lines[index].content}, ${line.adjustedContent})`);
579
this.lines.splice(index + 1, 0, new DocumentLine(line.adjustedContent, this.indentStyle));
580
this.outputStream.textEdit(this.uri, [new TextEdit(new Range(index, Constants.MAX_SAFE_SMALL_INTEGER, index, Constants.MAX_SAFE_SMALL_INTEGER), '\n' + line.adjustedContent)]);
581
this._didEdits = true;
582
return index + 2;
583
}
584
585
public insertLineBefore(index: number, line: ReplyLine): number {
586
if (index === this.lines.length) {
587
// we must insert after the last line
588
return this.insertLineAfter(index - 1, line);
589
}
590
// console.info(`insertLineBefore(${index}, ${this.lines[index].content}, ${line.adjustedContent})`);
591
this.lines.splice(index, 0, new DocumentLine(line.adjustedContent, this.indentStyle));
592
this.outputStream.textEdit(this.uri, [new TextEdit(new Range(index, 0, index, 0), line.adjustedContent + '\n')]);
593
this._didEdits = true;
594
return index + 1;
595
}
596
597
public deleteLines(fromIndex: number, toIndex: number): number {
598
// console.info(`deleteLines(${fromIndex}, ${toIndex})`);
599
this.lines.splice(fromIndex, toIndex - fromIndex + 1);
600
this.outputStream.textEdit(this.uri, [new TextEdit(new Range(fromIndex, 0, toIndex + 1, 0), '')]); // TODO: what about end of document??
601
this._didEdits = true;
602
this._didReplaceEdits = true;
603
return fromIndex + 1;
604
}
605
}
606
607
class ReplyLine {
608
public readonly trimmedContent: string = this.originalContent.trim();
609
610
constructor(
611
public readonly originalContent: string, // as returned from the LLM
612
public readonly originalIndentLevel: number,
613
public readonly adjustedContent: string, // adjusted for insertion in the document
614
public readonly adjustedIndentLevel: number
615
) { }
616
}
617
618
class MatchedDocumentLine {
619
constructor(
620
public readonly lineIndex: number
621
) { }
622
}
623
624
export const enum SentInCodeBlock {
625
None,
626
Above,
627
Range,
628
Below,
629
Other,
630
}
631
632
class DocumentLine {
633
634
private _sentInCodeBlock: SentInCodeBlock = SentInCodeBlock.None;
635
public get isSent(): boolean {
636
return this._sentInCodeBlock !== SentInCodeBlock.None;
637
}
638
public get sentInCodeBlock(): SentInCodeBlock {
639
return this._sentInCodeBlock;
640
}
641
642
private _trimmedContent: string | null = null;
643
public get trimmedContent(): string {
644
if (this._trimmedContent === null) {
645
this._trimmedContent = this.content.trim();
646
}
647
return this._trimmedContent;
648
}
649
650
private _normalizedContent: string | null = null;
651
public get normalizedContent(): string {
652
if (this._normalizedContent === null) {
653
this._normalizedContent = normalizeIndentation(this.content, this._indentStyle.tabSize, this._indentStyle.insertSpaces);
654
}
655
return this._normalizedContent;
656
}
657
658
private _indentLevel: number = -1;
659
public get indentLevel(): number {
660
if (this._indentLevel === -1) {
661
this._indentLevel = computeIndentLevel2(this.content, this._indentStyle.tabSize);
662
}
663
return this._indentLevel;
664
}
665
666
constructor(
667
public readonly content: string,
668
private readonly _indentStyle: vscode.FormattingOptions
669
) { }
670
671
public markSent(sentInCodeBlock: SentInCodeBlock): void {
672
this._sentInCodeBlock = sentInCodeBlock;
673
}
674
}
675
676
class IndentUtils {
677
678
public static getDocumentIndentStyle(sourceCode: string, fileIndentInfo: vscode.FormattingOptions | undefined): vscode.FormattingOptions {
679
if (fileIndentInfo) {
680
// the indentation is known
681
return fileIndentInfo;
682
}
683
684
// we need to detect the indentation
685
return <vscode.FormattingOptions>guessIndentation(Lines.fromString(sourceCode), 4, false);
686
}
687
688
public static guessIndentStyleFromLine(line: string): vscode.FormattingOptions | undefined {
689
const leadingWhitespace = IndentUtils._getLeadingWhitespace(line);
690
if (leadingWhitespace === '' || leadingWhitespace === ' ') {
691
// insufficient information
692
return undefined;
693
}
694
return <vscode.FormattingOptions>guessIndentation([line], 4, false);
695
}
696
697
public static reindentLine(line: string, originalIndentStyle: vscode.FormattingOptions, desiredIndentStyle: vscode.FormattingOptions, getDesiredIndentLevel: (currentIndentLevel: number) => number = (n) => n): string {
698
let indentLevel = computeIndentLevel2(line, originalIndentStyle.tabSize);
699
const desiredIndentLevel = getDesiredIndentLevel(indentLevel);
700
701
// First we outdent to 0 and then we indent to the desired level
702
// This ensures that we normalize indentation in the process and that we
703
// maintain any trailing spaces at the end of the tab stop
704
while (indentLevel > 0) {
705
line = this._outdent(line, originalIndentStyle);
706
indentLevel--;
707
}
708
709
while (indentLevel < desiredIndentLevel) {
710
line = '\t' + line;
711
indentLevel++;
712
}
713
714
return normalizeIndentation(line, desiredIndentStyle.tabSize, desiredIndentStyle.insertSpaces);
715
}
716
717
private static _outdent(line: string, indentStyle: vscode.FormattingOptions): string {
718
let chrIndex = 0;
719
while (chrIndex < line.length) {
720
const chr = line.charCodeAt(chrIndex);
721
if (chr === CharCode.Tab) {
722
// consume the tab and stop
723
chrIndex++;
724
break;
725
}
726
if (chr !== CharCode.Space) {
727
// never remove non whitespace characters
728
break;
729
}
730
if (chrIndex === indentStyle.tabSize) {
731
// reached the maximum number of spaces
732
break;
733
}
734
chrIndex++;
735
}
736
return line.substring(chrIndex);
737
}
738
739
/**
740
* Gets all whitespace characters at the start of a string.
741
*/
742
private static _getLeadingWhitespace(line: string): string {
743
for (let i = 0; i < line.length; i++) {
744
const char = line.charCodeAt(i);
745
if (char !== 32 && char !== 9) { // 32 is ASCII for space and 9 is ASCII for tab
746
return line.substring(0, i);
747
}
748
}
749
return line;
750
}
751
}
752
753
export class LineFilters {
754
755
public static combine(...filters: (ILineFilter | undefined)[]): ILineFilter {
756
return (line: LineOfText) => filters.every(filter => filter ? filter(line) : true);
757
}
758
759
public static noop: ILineFilter = () => true;
760
761
/**
762
* Keeps only lines that are inside ``` code blocks.
763
*/
764
public static createCodeBlockFilter(): ILineFilter {
765
const enum State {
766
BeforeCodeBlock,
767
InCodeBlock,
768
AfterCodeBlock
769
}
770
let state = State.BeforeCodeBlock;
771
return (line: LineOfText) => {
772
if (state === State.BeforeCodeBlock) {
773
if (/^```/.test(line.value)) {
774
state = State.InCodeBlock;
775
}
776
return false;
777
}
778
if (state === State.InCodeBlock) {
779
if (/^```/.test(line.value)) {
780
state = State.AfterCodeBlock;
781
return false;
782
}
783
return true;
784
}
785
// text after code block
786
return false;
787
};
788
}
789
}
790
791
/**
792
* A line of text. Does not include the newline character.
793
*/
794
export class LineOfText {
795
readonly __lineOfTextBrand: void = undefined;
796
public readonly value: string;
797
constructor(
798
value: string
799
) {
800
this.value = value.replace(/\r$/, '');
801
}
802
}
803
804
export const enum TextPieceKind {
805
/**
806
* A text piece that appears outside a code block
807
*/
808
OutsideCodeBlock,
809
/**
810
* A text piece that appears inside a code block
811
*/
812
InsideCodeBlock,
813
/**
814
* A text piece that is a delimiter
815
*/
816
Delimiter,
817
}
818
819
export class ClassifiedTextPiece {
820
constructor(
821
public readonly value: string,
822
public readonly kind: TextPieceKind
823
) { }
824
}
825
826
/**
827
* Can classify pieces of text into different kinds.
828
*/
829
export interface IStreamingTextPieceClassifier {
830
(textSource: AsyncIterable<string>): AsyncIterableObject<ClassifiedTextPiece>;
831
}
832
833
export class TextPieceClassifiers {
834
/**
835
* Classifies lines using ``` code blocks.
836
*/
837
public static createCodeBlockClassifier(): IStreamingTextPieceClassifier {
838
return TextPieceClassifiers.attemptToRecoverFromMissingCodeBlock(
839
TextPieceClassifiers.createFencedBlockClassifier('```')
840
);
841
}
842
843
private static attemptToRecoverFromMissingCodeBlock(classifier: IStreamingTextPieceClassifier): IStreamingTextPieceClassifier {
844
return (source: AsyncIterable<string>) => {
845
return new AsyncIterableObject<ClassifiedTextPiece>(async (emitter) => {
846
// We buffer all pieces until the first code block, then
847
// we open the gate and start emitting all pieces immediately.
848
const bufferedPieces: ClassifiedTextPiece[] = [];
849
let sawOnlyLeadingText = true;
850
for await (const piece of classifier(source)) {
851
if (!sawOnlyLeadingText) {
852
emitter.emitOne(piece);
853
} else if (piece.kind === TextPieceKind.OutsideCodeBlock) {
854
bufferedPieces.push(piece);
855
} else {
856
sawOnlyLeadingText = false;
857
for (const p of bufferedPieces) {
858
emitter.emitOne(p);
859
}
860
bufferedPieces.length = 0;
861
emitter.emitOne(piece);
862
}
863
}
864
865
// if we never found a code block, we emit all pieces at the end
866
if (sawOnlyLeadingText) {
867
const allText = bufferedPieces.map(p => p.value).join('');
868
if (looksLikeCode(allText)) {
869
emitter.emitOne(new ClassifiedTextPiece(allText, TextPieceKind.InsideCodeBlock));
870
} else {
871
emitter.emitOne(new ClassifiedTextPiece(allText, TextPieceKind.OutsideCodeBlock));
872
}
873
}
874
});
875
};
876
}
877
878
/**
879
* Classifies lines using fenced blocks with the provided fence.
880
*/
881
public static createAlwaysInsideCodeBlockClassifier(): IStreamingTextPieceClassifier {
882
return (source: AsyncIterable<string>) => {
883
return AsyncIterableObject.map(source, line => new ClassifiedTextPiece(line, TextPieceKind.InsideCodeBlock));
884
};
885
}
886
887
/**
888
* Classifies lines using fenced blocks with the provided fence.
889
*/
890
public static createFencedBlockClassifier(fence: string): IStreamingTextPieceClassifier {
891
return (source: AsyncIterable<string>) => {
892
return new AsyncIterableObject<ClassifiedTextPiece>(async (emitter) => {
893
const reader = new PartialAsyncTextReader(source[Symbol.asyncIterator]());
894
895
let state = TextPieceKind.OutsideCodeBlock;
896
897
while (!reader.endOfStream) {
898
899
const text = await reader.peek(fence.length);
900
901
if (text !== fence) {
902
903
// consume and emit immediately all pieces until newline or end of stream
904
while (!reader.endOfStream) {
905
// we want to consume any piece that is available in order to emit it immediately
906
const piece = reader.readImmediateExcept('\n');
907
if (piece.length > 0) {
908
emitter.emitOne(new ClassifiedTextPiece(piece, state));
909
}
910
const nextChar = await reader.peek(1);
911
if (nextChar === '\n') {
912
reader.readImmediate(1);
913
emitter.emitOne(new ClassifiedTextPiece('\n', state));
914
break;
915
}
916
}
917
918
} else {
919
920
const lineWithFence = await reader.readLineIncludingLF();
921
state = state === TextPieceKind.InsideCodeBlock ? TextPieceKind.OutsideCodeBlock : TextPieceKind.InsideCodeBlock;
922
emitter.emitOne(new ClassifiedTextPiece(lineWithFence, TextPieceKind.Delimiter));
923
924
}
925
}
926
});
927
};
928
}
929
930
}
931
932
export class PartialAsyncTextReader {
933
934
private _buffer: string = '';
935
private _atEnd = false;
936
937
public get endOfStream(): boolean { return this._buffer.length === 0 && this._atEnd; }
938
939
constructor(
940
private readonly _source: AsyncIterator<string>
941
) {
942
}
943
944
private async extendBuffer(): Promise<void> {
945
if (this._atEnd) {
946
return;
947
}
948
const { value, done } = await this._source.next();
949
if (done) {
950
this._atEnd = true;
951
} else {
952
this._buffer += value;
953
}
954
}
955
956
/**
957
* Waits until n characters are available in the buffer or the end of the stream is reached.
958
*/
959
async waitForLength(n: number): Promise<void> {
960
while (this._buffer.length < n && !this._atEnd) {
961
await this.extendBuffer();
962
}
963
}
964
965
/**
966
* Peeks `n` characters or less if the stream ends.
967
*/
968
async peek(n: number): Promise<string> {
969
await this.waitForLength(n);
970
return this._buffer.substring(0, n);
971
}
972
973
/**
974
* Reads `n` characters or less if the stream ends.
975
*/
976
async read(n: number): Promise<string> {
977
await this.waitForLength(n);
978
const result = this._buffer.substring(0, n);
979
this._buffer = this._buffer.substring(n);
980
return result;
981
}
982
983
/**
984
* Read all available characters until `char`
985
*/
986
async readUntil(char: string): Promise<string> {
987
let result = '';
988
while (!this.endOfStream) {
989
const piece = this.readImmediateExcept(char);
990
result += piece;
991
const nextChar = await this.peek(1);
992
993
if (nextChar === char) {
994
break;
995
}
996
}
997
998
return result;
999
}
1000
1001
/**
1002
* Read an entire line including \n or until end of stream.
1003
*/
1004
async readLineIncludingLF(): Promise<string> {
1005
// consume all pieces until newline or end of stream
1006
let line = await this.readUntil('\n');
1007
// the next char should be \n or we're at end of stream
1008
line += await this.read(1);
1009
return line;
1010
}
1011
1012
/**
1013
* Read an entire line until \n (excluding \n) or until end of stream.
1014
* The \n is consumed from the stream
1015
*/
1016
async readLine(): Promise<string> {
1017
// consume all pieces until newline or end of stream
1018
const line = await this.readUntil('\n');
1019
// the next char should be \n or we're at end of stream
1020
await this.read(1);
1021
return line;
1022
}
1023
1024
/**
1025
* Returns immediately with all available characters until `char`.
1026
*/
1027
readImmediateExcept(char: string): string {
1028
const endIndex = this._buffer.indexOf(char);
1029
return this.readImmediate(endIndex === -1 ? this._buffer.length : endIndex);
1030
}
1031
1032
/**
1033
* Returns immediately with all available characters, but at most `n` characters.
1034
*/
1035
readImmediate(n: number): string {
1036
const result = this._buffer.substring(0, n);
1037
this._buffer = this._buffer.substring(n);
1038
return result;
1039
}
1040
}
1041
1042
export class AsyncReaderEndOfStream { }
1043
1044
export class AsyncReader<T> {
1045
1046
public static EOS = new AsyncReaderEndOfStream();
1047
1048
private _buffer: T[] = [];
1049
private _atEnd = false;
1050
1051
public get endOfStream(): boolean { return this._buffer.length === 0 && this._atEnd; }
1052
1053
constructor(
1054
private readonly _source: AsyncIterator<T>
1055
) {
1056
}
1057
1058
private async extendBuffer(): Promise<void> {
1059
if (this._atEnd) {
1060
return;
1061
}
1062
const { value, done } = await this._source.next();
1063
if (done) {
1064
this._atEnd = true;
1065
} else {
1066
this._buffer.push(value);
1067
}
1068
}
1069
1070
public async peek(): Promise<T | AsyncReaderEndOfStream> {
1071
if (this._buffer.length === 0 && !this._atEnd) {
1072
await this.extendBuffer();
1073
}
1074
if (this._buffer.length === 0) {
1075
return AsyncReader.EOS;
1076
}
1077
return this._buffer[0];
1078
}
1079
1080
public async read(): Promise<T | AsyncReaderEndOfStream> {
1081
if (this._buffer.length === 0 && !this._atEnd) {
1082
await this.extendBuffer();
1083
}
1084
if (this._buffer.length === 0) {
1085
return AsyncReader.EOS;
1086
}
1087
return this._buffer.shift()!;
1088
}
1089
1090
public async readWhile(predicate: (value: T) => boolean, callback: (element: T) => unknown): Promise<void> {
1091
do {
1092
const piece = await this.peek();
1093
if (piece instanceof AsyncReaderEndOfStream) {
1094
break;
1095
}
1096
if (!predicate(piece)) {
1097
break;
1098
}
1099
await this.read(); // consume
1100
await callback(piece);
1101
} while (true);
1102
}
1103
1104
public async consumeToEnd(): Promise<void> {
1105
while (!this.endOfStream) {
1106
await this.read();
1107
}
1108
}
1109
}
1110
1111
/**
1112
* Split an incoming stream of text to a stream of lines.
1113
*/
1114
export function streamLines(source: AsyncIterable<string>): AsyncIterableObject<LineOfText> {
1115
return new AsyncIterableObject<LineOfText>(async (emitter) => {
1116
let buffer = '';
1117
for await (const str of source) {
1118
buffer += str;
1119
do {
1120
const newlineIndex = buffer.indexOf('\n');
1121
if (newlineIndex === -1) {
1122
break;
1123
}
1124
1125
// take the first line
1126
const line = buffer.substring(0, newlineIndex);
1127
buffer = buffer.substring(newlineIndex + 1);
1128
1129
emitter.emitOne(new LineOfText(line));
1130
} while (true);
1131
}
1132
1133
if (buffer.length > 0) {
1134
// last line which doesn't end with \n
1135
emitter.emitOne(new LineOfText(buffer));
1136
}
1137
});
1138
}
1139
1140
function hasImportsInRange(doc: IStreamingWorkingCopyDocument, range: vscode.Range): boolean {
1141
const startLine = (range.start.character === 0 ? range.start.line : range.start.line + 1);
1142
const endLine = (doc.getLine(range.end.line).content.length === range.end.character ? range.end.line : range.end.line - 1);
1143
for (let i = startLine; i <= endLine; i++) {
1144
if (isImportStatement(doc.getLine(i).content, doc.languageId)) {
1145
return true;
1146
}
1147
}
1148
return false;
1149
}
1150
1151
function collectImportsIfNoneWereSentInRange(stream: AsyncIterableObject<LineOfText>, doc: IStreamingWorkingCopyDocument, rangeToCheckForImports: vscode.Range): AsyncIterableObject<LineOfText> {
1152
if (hasImportsInRange(doc, rangeToCheckForImports)) {
1153
// there are imports in the sent code block
1154
// no need to collect imports
1155
return stream;
1156
}
1157
// collect imports separately
1158
let extractedImports = false;
1159
let hasCode = false;
1160
return stream.filter(line => {
1161
if (isImportStatement(line.value, doc.languageId)) {
1162
doc.addAdditionalImport(trimLeadingWhitespace(line.value));
1163
extractedImports = true;
1164
return false;
1165
}
1166
const isOnlyWhitespace = (line.value.trim().length === 0);
1167
if (isOnlyWhitespace && extractedImports) {
1168
// there are imports in the reply which we have moved up
1169
// survive the empty line if it is inside code
1170
return hasCode;
1171
}
1172
hasCode = true;
1173
return true;
1174
});
1175
}
1176
1177