Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompts/node/codeMapper/patchEditGeneration.tsx
13405 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 { BasePromptElementProps, PromptElement } from '@vscode/prompt-tsx';
7
import { IResponsePart } from '../../../../platform/chat/common/chatMLFetcher';
8
import { IPromptPathRepresentationService } from '../../../../platform/prompts/common/promptPathRepresentationService';
9
import { equals } from '../../../../util/vs/base/common/arrays';
10
import { AsyncIterableObject } from '../../../../util/vs/base/common/async';
11
import { CharCode } from '../../../../util/vs/base/common/charCode';
12
import { URI } from '../../../../util/vs/base/common/uri';
13
import { TextEdit, Uri } from '../../../../vscodeTypes';
14
import { OutcomeAnnotation, OutcomeAnnotationLabel } from '../../../inlineChat/node/promptCraftingTypes';
15
import { Lines, LinesEdit } from '../../../prompt/node/editGeneration';
16
import { IGuessedIndentation, guessIndentation } from '../../../prompt/node/indentationGuesser';
17
import { PartialAsyncTextReader } from '../../../prompt/node/streamingEdits';
18
import { CodeBlock } from '../panel/safeElements';
19
20
const MARKER_PREFIX = '---';
21
22
type Marker = string;
23
24
export namespace Marker {
25
export const FILEPATH = MARKER_PREFIX + 'FILEPATH';
26
export const FIND = MARKER_PREFIX + 'FIND';
27
export const REPLACE = MARKER_PREFIX + 'REPLACE';
28
export const COMPLETE = MARKER_PREFIX + 'COMPLETE';
29
}
30
31
32
export class PatchEditRules extends PromptElement {
33
render() {
34
return (
35
<>
36
When proposing a code change, provide one or more modifications in the following format:<br />
37
Each modification consist of three sections headed by `{Marker.FILEPATH}`, `{Marker.FIND}` and `{Marker.REPLACE}`.<br />
38
After {Marker.FILEPATH} add the path to the file that needs to be changed.<br />
39
After {Marker.FIND} add a code block containing a section of the program that will be replaced.<br />
40
Add multiple lines so that a find tool can find and identify a section of the programm. Start and end with a line that will not be modified. <br />
41
Include all comments and empty lines exactly as they appear in the original source code. Do not abreviate any line or summarize the code with `...`. <br />
42
After {Marker.REPLACE} add a code block with the updated version of the original code in the find section. Maintain the same indentation and code style as in the original code.<br />
43
After all modifications, add {Marker.COMPLETE}.<br />
44
</>
45
);
46
}
47
}
48
49
export interface PatchEditInputCodeBlockProps extends BasePromptElementProps {
50
readonly uri: Uri;
51
readonly languageId?: string;
52
readonly code: string[] | string;
53
readonly isSummarized?: boolean;
54
readonly shouldTrim?: boolean;
55
}
56
57
export class PatchEditInputCodeBlock extends PromptElement<PatchEditInputCodeBlockProps> {
58
constructor(
59
props: PatchEditInputCodeBlockProps,
60
@IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService,
61
) {
62
super(props);
63
}
64
65
render() {
66
const code = typeof this.props.code === 'string' ? this.props.code : this.props.code.join('\n');
67
return <>
68
{getFileMarker(this.promptPathRepresentationService.getFilePath(this.props.uri))}<br />
69
<CodeBlock code={code} uri={this.props.uri} languageId={this.props.languageId} includeFilepath={false} shouldTrim={this.props.shouldTrim} />
70
</>;
71
}
72
}
73
74
75
export interface PatchEditExamplePatchProps extends BasePromptElementProps {
76
readonly changes: { uri: URI; find: Lines; replace: Lines }[];
77
}
78
79
export class PatchEditExamplePatch extends PromptElement<PatchEditExamplePatchProps> {
80
constructor(
81
props: PatchEditExamplePatchProps,
82
@IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService,
83
) {
84
super(props);
85
}
86
87
render() {
88
return <>
89
{this.props.changes.map(patch => (
90
<>
91
{getFileMarker(this.promptPathRepresentationService.getFilePath(patch.uri))}<br />
92
{Marker.FIND}<br />
93
```<br />
94
{patch.find.join('\n')}<br />
95
```<br />
96
{Marker.REPLACE}<br />
97
```<br />
98
{patch.replace.join('\n')}<br />
99
```<br />
100
</>
101
))}
102
{Marker.COMPLETE}
103
</>;
104
}
105
}
106
107
108
export function getFileMarker(filePath: string): string {
109
return `${Marker.FILEPATH} ${filePath}`;
110
}
111
112
export function getCustomMarker(markerName: string): string {
113
return `${MARKER_PREFIX}${markerName}`;
114
}
115
116
function isWhitespaceOrEmpty(line: string): boolean {
117
return !line.match(/\S/);
118
}
119
120
121
export function findEdit(code: Lines, findLines: Lines, replaceLines: Lines, fallbackInsertLine: number): LinesEdit | OutcomeAnnotation {
122
let firstFindLineIndex = 0;
123
// find first non empty line
124
while (firstFindLineIndex < findLines.length && isWhitespaceOrEmpty(findLines[firstFindLineIndex])) {
125
firstFindLineIndex++;
126
}
127
if (firstFindLineIndex === findLines.length) {
128
const codeIndentInfo = guessIndentation(code, 4, false);
129
const codeIndentLevel = code.length > 0 ? getMinimalIndentLevel(code, 0, code.length - 1, codeIndentInfo.tabSize) : 0;
130
const replaceString = getReplaceString(replaceLines, codeIndentLevel, codeIndentInfo);
131
return LinesEdit.insert(fallbackInsertLine, replaceString);
132
}
133
134
const firstFindLine = findLines[firstFindLineIndex];
135
const firstFindIndentLength = getIndentLength(firstFindLine);
136
137
let lastError: OutcomeAnnotation | undefined;
138
let i = 0, k = firstFindLineIndex;
139
140
outer: while (i < code.length) {
141
142
// find the first find line in the code
143
while (i < code.length && !endsWith(code[i], firstFindLine, firstFindIndentLength)) {
144
i++;
145
}
146
if (i === code.length) {
147
return lastError ?? { message: `First find line not found`, label: OutcomeAnnotationLabel.INVALID_PATCH, severity: 'error' };
148
}
149
150
const firstLineIndex = i;
151
let endLineIndex = -1;
152
while (i < code.length && k < findLines.length) {
153
const codeLine = code[i];
154
const codeIndentLength = getIndentLength(codeLine);
155
if (codeIndentLength === codeLine.length) { // all whitespace
156
i++;
157
continue;
158
}
159
const findLine = findLines[k];
160
const findLineIndentLength = getIndentLength(findLine);
161
if (findLineIndentLength === findLine.length) { // all whitespace
162
k++;
163
continue;
164
}
165
if (endsWith(codeLine, findLine, findLineIndentLength)) {
166
endLineIndex = i;
167
i++;
168
k++;
169
} else {
170
// a line in the find lines does not match the line in the code
171
i = firstLineIndex + 1; // try to find the find line again starting on the next line
172
k = firstFindLineIndex;
173
if (findLine.indexOf('...') !== -1) {
174
lastError = { message: `Find contains ellipses`, label: OutcomeAnnotationLabel.INVALID_PATCH_LAZY, severity: 'error' };
175
} else if (isComment(codeLine)) {
176
lastError = { message: `Find not matching a comment`, label: OutcomeAnnotationLabel.INVALID_PATCH_COMMENT, severity: 'error' };
177
} else {
178
lastError = { message: `Find line ${k} does not match line ${i}`, label: OutcomeAnnotationLabel.INVALID_PATCH, severity: 'error' };
179
}
180
continue outer; // continue with the outer loop
181
}
182
}
183
while (k < findLines.length && isWhitespaceOrEmpty(findLines[k])) {
184
k++;
185
}
186
if (k === findLines.length && firstLineIndex !== -1 && endLineIndex !== -1) {
187
const codeIndentInfo = guessIndentation(code, 4, false);
188
const codeIndentLevel = getMinimalIndentLevel(code, firstLineIndex, endLineIndex, codeIndentInfo.tabSize);
189
const replaceString = getReplaceString(replaceLines, codeIndentLevel, codeIndentInfo);
190
return LinesEdit.replace(firstLineIndex, endLineIndex + 1, replaceString, endLineIndex === code.length - 1);
191
}
192
}
193
return lastError ?? { message: `Not all lines of find found`, label: OutcomeAnnotationLabel.INVALID_PATCH, severity: 'error' };
194
}
195
196
function isWhiteSpace(charCode: number): boolean {
197
return charCode === CharCode.Space || charCode === CharCode.Tab;
198
}
199
200
function isComment(line: string): boolean {
201
return line.match(/^\s*(\/\/|\/\*|#)/) !== null;
202
}
203
204
205
function getReplaceString(lines: Lines, newIndentLevel: number, indentInfo: IGuessedIndentation): Lines {
206
let start, end = 0;
207
for (start = 0; start < lines.length && isWhitespaceOrEmpty(lines[start]); start++) { }
208
if (start === lines.length) {
209
return [];
210
}
211
for (end = lines.length; end > start && isWhitespaceOrEmpty(lines[end - 1]); end--) { }
212
213
if (start === end) {
214
// all replace lines are empty or whitespace only
215
return [];
216
}
217
218
// find the line with the smallest indentation and remember the computed indentation level for each line
219
let minIndentLevel = Number.MAX_SAFE_INTEGER;
220
const indentations: Indentation[] = [];
221
for (let i = start; i < end; i++) {
222
const line = lines[i];
223
const indentation = computeIndentation(line, indentInfo.tabSize);
224
if (indentation.length !== line.length /* more than whitespace */ && indentation.level < minIndentLevel) {
225
minIndentLevel = indentation.level;
226
}
227
indentations.push(indentation);
228
}
229
230
// there is at least one line with non-whitespace characters, so minIndentLevel is less than Number.MAX_SAFE_INTEGER
231
232
// now adjust each line to the requested codeIndentLevel
233
const adjustedLines = [];
234
for (let i = start; i < end; i++) {
235
const line = lines[i];
236
const { level, length } = indentations[i - start];
237
const newLevel = Math.max(0, newIndentLevel + level - minIndentLevel);
238
const newIndentation = indentInfo.insertSpaces ? ' '.repeat(indentInfo.tabSize * newLevel) : '\t'.repeat(newLevel);
239
adjustedLines.push(newIndentation + line.substring(length));
240
}
241
return adjustedLines;
242
}
243
244
function getIndentLength(line: string): number {
245
let i = 0;
246
while (i < line.length && isWhiteSpace(line.charCodeAt(i))) {
247
i++;
248
}
249
return i;
250
}
251
252
function getMinimalIndentLevel(lines: Lines, startLineIndex: number, endLineIndex: number, tabSize: number): number {
253
let minIndentLevel = Number.MAX_SAFE_INTEGER;
254
for (let i = startLineIndex; i <= endLineIndex; i++) {
255
const line = lines[i];
256
const indentation = computeIndentation(line, tabSize);
257
if (indentation.length !== line.length /* more than whitespace */ && indentation.level < minIndentLevel) {
258
minIndentLevel = indentation.level;
259
}
260
}
261
return minIndentLevel !== Number.MAX_SAFE_INTEGER ? minIndentLevel : 0;
262
}
263
264
type Indentation = { level: number; length: number };
265
266
function computeIndentation(line: string, tabSize: number): Indentation {
267
let nSpaces = 0;
268
let level = 0;
269
let i = 0;
270
let length = 0;
271
const len = line.length;
272
while (i < len) {
273
const chCode = line.charCodeAt(i);
274
if (chCode === CharCode.Space) {
275
nSpaces++;
276
if (nSpaces === tabSize) {
277
level++;
278
nSpaces = 0;
279
length = i + 1;
280
}
281
} else if (chCode === CharCode.Tab) {
282
level++;
283
nSpaces = 0;
284
length = i + 1;
285
} else {
286
break;
287
}
288
i++;
289
}
290
return { level, length };
291
}
292
293
294
function endsWith(line: string, suffix: string, suffixIndentLength: number): boolean {
295
let i = line.length - 1, k = suffix.length - 1;
296
while (i >= 0 && k >= suffixIndentLength && line.charCodeAt(i) === suffix.charCodeAt(k)) {
297
i--;
298
k--;
299
}
300
if (k >= suffixIndentLength) {
301
// not the full suffix matched
302
return false;
303
}
304
305
// make sure all is whitespace before the suffix
306
while (i >= 0 && isWhiteSpace(line.charCodeAt(i))) {
307
i--;
308
}
309
return i < 0;
310
}
311
312
export type Patch = { filePath: string; find: Lines; replace: Lines };
313
export type Section = { marker: Marker | undefined; content: string[] };
314
315
export interface PatchEditReplyProcessor {
316
getFirstParagraph(text: string): string;
317
process(replyText: string, documentText: string, documentUri?: URI, defaultInsertionLine?: number): PatchEditReplyProcessorResult;
318
}
319
320
export type PatchEditReplyProcessorResult = {
321
readonly edits: TextEdit[];
322
readonly otherSections: Section[];
323
readonly appliedPatches: Patch[];
324
readonly otherPatches: Patch[];
325
readonly invalidPatches: Patch[];
326
readonly contentBefore: Lines;
327
readonly contentAfter: Lines;
328
readonly annotations: OutcomeAnnotation[];
329
};
330
331
export function getReferencedFiles(replyText: string): string[] {
332
const result = new Set<string>();
333
for (const section of iterateSections(iterateLines(replyText))) {
334
if (section.marker === Marker.FILEPATH) {
335
result.add(section.content.join('\n').trim());
336
}
337
}
338
339
return [...result];
340
}
341
342
export function getPatchEditReplyProcessor(promptPathRepresentationService: IPromptPathRepresentationService): PatchEditReplyProcessor {
343
return {
344
getFirstParagraph(text: string): string {
345
const result = [];
346
for (const line of iterateLines(text)) {
347
if (line.length === 0 || line.startsWith(MARKER_PREFIX)) {
348
break;
349
}
350
result.push(line);
351
}
352
return result.join('\n');
353
},
354
process(replyText: string, documentText: string, documentUri?: URI, defaultInsertionLine: number = 0): PatchEditReplyProcessorResult {
355
let original, filePath;
356
const annotations: OutcomeAnnotation[] = [];
357
const otherSections: Section[] = [];
358
let patches: Patch[] = [];
359
const edits: TextEdit[] = [];
360
const invalidPatches: Patch[] = [];
361
const otherPatches: Patch[] = [];
362
const filePaths = new Set<string>();
363
let contentBefore: string[] = [];
364
let contentAfter: string[] = [];
365
loop: for (const section of iterateSections(iterateLines(replyText))) {
366
switch (section.marker) {
367
case undefined:
368
contentBefore = section.content;
369
break;
370
case Marker.FILEPATH:
371
filePath = section.content.join('\n').trim();
372
break;
373
case Marker.FIND:
374
original = section.content;
375
break;
376
case Marker.REPLACE: {
377
if (section.content && original && filePath) {
378
patches.push({ filePath, find: original, replace: section.content });
379
filePaths.add(filePath);
380
}
381
break;
382
}
383
case Marker.COMPLETE:
384
contentAfter = section.content;
385
break loop;
386
default:
387
otherSections.push(section);
388
break;
389
}
390
}
391
if (patches.length === 0) {
392
annotations.push({ message: 'No patch sections found', label: OutcomeAnnotationLabel.NO_PATCH, severity: 'error' });
393
return { edits, contentAfter, contentBefore, appliedPatches: [], otherSections, invalidPatches, otherPatches, annotations };
394
}
395
if (documentUri) {
396
const documentFilePath = promptPathRepresentationService.getFilePath(documentUri);
397
if (!filePaths.has(documentFilePath)) {
398
annotations.push({ message: `No patch for input document: ${documentFilePath}, patches for ${[...filePaths.keys()].join(', ')}`, label: OutcomeAnnotationLabel.OTHER_FILE, severity: 'warning' });
399
}
400
if (filePaths.size > 1) {
401
annotations.push({ message: `Multiple files modified: ${[...filePaths.keys()].join(', ')}`, label: OutcomeAnnotationLabel.MULTI_FILE, severity: 'warning' });
402
}
403
const patchesForDocument = [];
404
for (const patch of patches) {
405
if (patch.filePath !== documentFilePath) {
406
otherPatches.push(patch);
407
} else {
408
patchesForDocument.push(patch);
409
}
410
}
411
patches = patchesForDocument;
412
}
413
414
if (patches.length !== 0) {
415
const documentLines = Lines.fromString(documentText);
416
for (const patch of patches) {
417
if (equals(patch.find, patch.replace)) {
418
annotations.push({ message: `Patch is a no-op`, label: OutcomeAnnotationLabel.INVALID_PATCH_NOOP, severity: 'error' });
419
invalidPatches.push(patch);
420
continue;
421
}
422
if (patch.find.length <= 1) {
423
annotations.push({ message: `Small patch: ${Math.min(patch.find.length)}`, label: OutcomeAnnotationLabel.INVALID_PATCH_SMALL, severity: 'warning' });
424
}
425
426
const res = findEdit(documentLines, getCodeBlock(patch.find), getCodeBlock(patch.replace), defaultInsertionLine);
427
if (res instanceof LinesEdit) {
428
const success = addEditIfDisjoint(edits, res.toTextEdit());
429
if (!success) {
430
annotations.push({ message: `Overlapping edits`, label: OutcomeAnnotationLabel.INVALID_EDIT_OVERLAP, severity: 'error' });
431
invalidPatches.push(patch);
432
}
433
} else {
434
annotations.push(res);
435
invalidPatches.push(patch);
436
}
437
}
438
}
439
return { edits, appliedPatches: patches, otherSections, invalidPatches, otherPatches, annotations, contentBefore, contentAfter };
440
}
441
442
};
443
}
444
445
function addEditIfDisjoint(edits: TextEdit[], edit: TextEdit): boolean {
446
for (let i = 0; i < edits.length; i++) {
447
const existingEdit = edits[i];
448
if (edit.range.end.isBeforeOrEqual(existingEdit.range.start)) {
449
edits.splice(i, 0, edit);
450
return true;
451
}
452
if (edit.range.start.isBefore(existingEdit.range.end)) {
453
// intersecting
454
return false;
455
}
456
}
457
edits.push(edit);
458
return true;
459
}
460
461
462
export function getCodeBlock(content: Lines): Lines {
463
const result = [];
464
let inCodeBlock: string | undefined;
465
const codeBlockRegex = /^`{3,}/; // Regex to match 3 or more backticks at the beginning of the line
466
for (const line of content) {
467
const match = line.match(codeBlockRegex);
468
if (match) {
469
if (inCodeBlock) {
470
if (match[0] === inCodeBlock) {
471
return result;
472
} else {
473
result.push(line);
474
}
475
} else {
476
inCodeBlock = match[0];
477
}
478
} else if (inCodeBlock) {
479
result.push(line);
480
}
481
}
482
return content;
483
}
484
485
export async function* iterateSectionsForResponse(lines: AsyncIterable<IResponsePart>): AsyncIterable<Section> {
486
487
let currentMarker: Marker | undefined = undefined;
488
let currentContent: string[] = [];
489
490
const textStream = AsyncIterableObject.map(lines, part => part.delta.text);
491
const reader = new PartialAsyncTextReader(textStream[Symbol.asyncIterator]());
492
493
while (!reader.endOfStream) {
494
const line = await reader.readLineIncludingLF();
495
let marker: Marker | undefined;
496
if (line.startsWith(MARKER_PREFIX)) {
497
if (line.startsWith(Marker.FILEPATH)) {
498
marker = Marker.FILEPATH;
499
} else if (line.startsWith(Marker.FIND)) {
500
marker = Marker.FIND;
501
} else if (line.startsWith(Marker.REPLACE)) {
502
marker = Marker.REPLACE;
503
} else if (line.startsWith(Marker.COMPLETE)) {
504
marker = Marker.COMPLETE;
505
} else {
506
marker = removeTrailingLF(line);
507
}
508
yield { marker: currentMarker, content: currentContent };
509
currentContent = [removeTrailingLF(line.substring(marker.length))];
510
currentMarker = marker;
511
continue;
512
}
513
currentContent.push(removeTrailingLF(line));
514
}
515
516
yield { marker: currentMarker, content: currentContent };
517
518
function removeTrailingLF(str: string): string {
519
if (str.endsWith('\n')) {
520
return str.slice(0, -1);
521
}
522
return str;
523
}
524
}
525
526
function* iterateSections(lines: Iterable<string>): Iterable<Section> {
527
let currentMarker: Marker | undefined = undefined;
528
let currentContent: string[] = [];
529
530
for (const line of lines) {
531
let marker: Marker | undefined;
532
if (line.startsWith(MARKER_PREFIX)) {
533
if (line.startsWith(Marker.FILEPATH)) {
534
marker = Marker.FILEPATH;
535
} else if (line.startsWith(Marker.FIND)) {
536
marker = Marker.FIND;
537
} else if (line.startsWith(Marker.REPLACE)) {
538
marker = Marker.REPLACE;
539
} else if (line.startsWith(Marker.COMPLETE)) {
540
marker = Marker.COMPLETE;
541
} else {
542
marker = line;
543
}
544
yield { marker: currentMarker, content: currentContent };
545
currentContent = [line.substring(marker.length)];
546
currentMarker = marker;
547
continue;
548
}
549
currentContent.push(line);
550
}
551
552
yield { marker: currentMarker, content: currentContent };
553
}
554
555
function* iterateLines(input: string): Iterable<string> {
556
let start = 0, end = 0;
557
while (end < input.length) {
558
const ch = input.charCodeAt(end);
559
if (ch === CharCode.CarriageReturn || ch === CharCode.LineFeed) {
560
yield input.substring(start, end);
561
end++;
562
if (ch === CharCode.CarriageReturn && input.charCodeAt(end) === CharCode.LineFeed) {
563
end++;
564
}
565
start = end;
566
} else {
567
end++;
568
}
569
}
570
if (start < input.length) {
571
yield input.substring(start);
572
}
573
}
574
575