Path: blob/main/extensions/copilot/src/extension/prompts/node/codeMapper/patchEditGeneration.tsx
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { BasePromptElementProps, PromptElement } from '@vscode/prompt-tsx';6import { IResponsePart } from '../../../../platform/chat/common/chatMLFetcher';7import { IPromptPathRepresentationService } from '../../../../platform/prompts/common/promptPathRepresentationService';8import { equals } from '../../../../util/vs/base/common/arrays';9import { AsyncIterableObject } from '../../../../util/vs/base/common/async';10import { CharCode } from '../../../../util/vs/base/common/charCode';11import { URI } from '../../../../util/vs/base/common/uri';12import { TextEdit, Uri } from '../../../../vscodeTypes';13import { OutcomeAnnotation, OutcomeAnnotationLabel } from '../../../inlineChat/node/promptCraftingTypes';14import { Lines, LinesEdit } from '../../../prompt/node/editGeneration';15import { IGuessedIndentation, guessIndentation } from '../../../prompt/node/indentationGuesser';16import { PartialAsyncTextReader } from '../../../prompt/node/streamingEdits';17import { CodeBlock } from '../panel/safeElements';1819const MARKER_PREFIX = '---';2021type Marker = string;2223export namespace Marker {24export const FILEPATH = MARKER_PREFIX + 'FILEPATH';25export const FIND = MARKER_PREFIX + 'FIND';26export const REPLACE = MARKER_PREFIX + 'REPLACE';27export const COMPLETE = MARKER_PREFIX + 'COMPLETE';28}293031export class PatchEditRules extends PromptElement {32render() {33return (34<>35When proposing a code change, provide one or more modifications in the following format:<br />36Each modification consist of three sections headed by `{Marker.FILEPATH}`, `{Marker.FIND}` and `{Marker.REPLACE}`.<br />37After {Marker.FILEPATH} add the path to the file that needs to be changed.<br />38After {Marker.FIND} add a code block containing a section of the program that will be replaced.<br />39Add 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 />40Include 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 />41After {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 />42After all modifications, add {Marker.COMPLETE}.<br />43</>44);45}46}4748export interface PatchEditInputCodeBlockProps extends BasePromptElementProps {49readonly uri: Uri;50readonly languageId?: string;51readonly code: string[] | string;52readonly isSummarized?: boolean;53readonly shouldTrim?: boolean;54}5556export class PatchEditInputCodeBlock extends PromptElement<PatchEditInputCodeBlockProps> {57constructor(58props: PatchEditInputCodeBlockProps,59@IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService,60) {61super(props);62}6364render() {65const code = typeof this.props.code === 'string' ? this.props.code : this.props.code.join('\n');66return <>67{getFileMarker(this.promptPathRepresentationService.getFilePath(this.props.uri))}<br />68<CodeBlock code={code} uri={this.props.uri} languageId={this.props.languageId} includeFilepath={false} shouldTrim={this.props.shouldTrim} />69</>;70}71}727374export interface PatchEditExamplePatchProps extends BasePromptElementProps {75readonly changes: { uri: URI; find: Lines; replace: Lines }[];76}7778export class PatchEditExamplePatch extends PromptElement<PatchEditExamplePatchProps> {79constructor(80props: PatchEditExamplePatchProps,81@IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService,82) {83super(props);84}8586render() {87return <>88{this.props.changes.map(patch => (89<>90{getFileMarker(this.promptPathRepresentationService.getFilePath(patch.uri))}<br />91{Marker.FIND}<br />92```<br />93{patch.find.join('\n')}<br />94```<br />95{Marker.REPLACE}<br />96```<br />97{patch.replace.join('\n')}<br />98```<br />99</>100))}101{Marker.COMPLETE}102</>;103}104}105106107export function getFileMarker(filePath: string): string {108return `${Marker.FILEPATH} ${filePath}`;109}110111export function getCustomMarker(markerName: string): string {112return `${MARKER_PREFIX}${markerName}`;113}114115function isWhitespaceOrEmpty(line: string): boolean {116return !line.match(/\S/);117}118119120export function findEdit(code: Lines, findLines: Lines, replaceLines: Lines, fallbackInsertLine: number): LinesEdit | OutcomeAnnotation {121let firstFindLineIndex = 0;122// find first non empty line123while (firstFindLineIndex < findLines.length && isWhitespaceOrEmpty(findLines[firstFindLineIndex])) {124firstFindLineIndex++;125}126if (firstFindLineIndex === findLines.length) {127const codeIndentInfo = guessIndentation(code, 4, false);128const codeIndentLevel = code.length > 0 ? getMinimalIndentLevel(code, 0, code.length - 1, codeIndentInfo.tabSize) : 0;129const replaceString = getReplaceString(replaceLines, codeIndentLevel, codeIndentInfo);130return LinesEdit.insert(fallbackInsertLine, replaceString);131}132133const firstFindLine = findLines[firstFindLineIndex];134const firstFindIndentLength = getIndentLength(firstFindLine);135136let lastError: OutcomeAnnotation | undefined;137let i = 0, k = firstFindLineIndex;138139outer: while (i < code.length) {140141// find the first find line in the code142while (i < code.length && !endsWith(code[i], firstFindLine, firstFindIndentLength)) {143i++;144}145if (i === code.length) {146return lastError ?? { message: `First find line not found`, label: OutcomeAnnotationLabel.INVALID_PATCH, severity: 'error' };147}148149const firstLineIndex = i;150let endLineIndex = -1;151while (i < code.length && k < findLines.length) {152const codeLine = code[i];153const codeIndentLength = getIndentLength(codeLine);154if (codeIndentLength === codeLine.length) { // all whitespace155i++;156continue;157}158const findLine = findLines[k];159const findLineIndentLength = getIndentLength(findLine);160if (findLineIndentLength === findLine.length) { // all whitespace161k++;162continue;163}164if (endsWith(codeLine, findLine, findLineIndentLength)) {165endLineIndex = i;166i++;167k++;168} else {169// a line in the find lines does not match the line in the code170i = firstLineIndex + 1; // try to find the find line again starting on the next line171k = firstFindLineIndex;172if (findLine.indexOf('...') !== -1) {173lastError = { message: `Find contains ellipses`, label: OutcomeAnnotationLabel.INVALID_PATCH_LAZY, severity: 'error' };174} else if (isComment(codeLine)) {175lastError = { message: `Find not matching a comment`, label: OutcomeAnnotationLabel.INVALID_PATCH_COMMENT, severity: 'error' };176} else {177lastError = { message: `Find line ${k} does not match line ${i}`, label: OutcomeAnnotationLabel.INVALID_PATCH, severity: 'error' };178}179continue outer; // continue with the outer loop180}181}182while (k < findLines.length && isWhitespaceOrEmpty(findLines[k])) {183k++;184}185if (k === findLines.length && firstLineIndex !== -1 && endLineIndex !== -1) {186const codeIndentInfo = guessIndentation(code, 4, false);187const codeIndentLevel = getMinimalIndentLevel(code, firstLineIndex, endLineIndex, codeIndentInfo.tabSize);188const replaceString = getReplaceString(replaceLines, codeIndentLevel, codeIndentInfo);189return LinesEdit.replace(firstLineIndex, endLineIndex + 1, replaceString, endLineIndex === code.length - 1);190}191}192return lastError ?? { message: `Not all lines of find found`, label: OutcomeAnnotationLabel.INVALID_PATCH, severity: 'error' };193}194195function isWhiteSpace(charCode: number): boolean {196return charCode === CharCode.Space || charCode === CharCode.Tab;197}198199function isComment(line: string): boolean {200return line.match(/^\s*(\/\/|\/\*|#)/) !== null;201}202203204function getReplaceString(lines: Lines, newIndentLevel: number, indentInfo: IGuessedIndentation): Lines {205let start, end = 0;206for (start = 0; start < lines.length && isWhitespaceOrEmpty(lines[start]); start++) { }207if (start === lines.length) {208return [];209}210for (end = lines.length; end > start && isWhitespaceOrEmpty(lines[end - 1]); end--) { }211212if (start === end) {213// all replace lines are empty or whitespace only214return [];215}216217// find the line with the smallest indentation and remember the computed indentation level for each line218let minIndentLevel = Number.MAX_SAFE_INTEGER;219const indentations: Indentation[] = [];220for (let i = start; i < end; i++) {221const line = lines[i];222const indentation = computeIndentation(line, indentInfo.tabSize);223if (indentation.length !== line.length /* more than whitespace */ && indentation.level < minIndentLevel) {224minIndentLevel = indentation.level;225}226indentations.push(indentation);227}228229// there is at least one line with non-whitespace characters, so minIndentLevel is less than Number.MAX_SAFE_INTEGER230231// now adjust each line to the requested codeIndentLevel232const adjustedLines = [];233for (let i = start; i < end; i++) {234const line = lines[i];235const { level, length } = indentations[i - start];236const newLevel = Math.max(0, newIndentLevel + level - minIndentLevel);237const newIndentation = indentInfo.insertSpaces ? ' '.repeat(indentInfo.tabSize * newLevel) : '\t'.repeat(newLevel);238adjustedLines.push(newIndentation + line.substring(length));239}240return adjustedLines;241}242243function getIndentLength(line: string): number {244let i = 0;245while (i < line.length && isWhiteSpace(line.charCodeAt(i))) {246i++;247}248return i;249}250251function getMinimalIndentLevel(lines: Lines, startLineIndex: number, endLineIndex: number, tabSize: number): number {252let minIndentLevel = Number.MAX_SAFE_INTEGER;253for (let i = startLineIndex; i <= endLineIndex; i++) {254const line = lines[i];255const indentation = computeIndentation(line, tabSize);256if (indentation.length !== line.length /* more than whitespace */ && indentation.level < minIndentLevel) {257minIndentLevel = indentation.level;258}259}260return minIndentLevel !== Number.MAX_SAFE_INTEGER ? minIndentLevel : 0;261}262263type Indentation = { level: number; length: number };264265function computeIndentation(line: string, tabSize: number): Indentation {266let nSpaces = 0;267let level = 0;268let i = 0;269let length = 0;270const len = line.length;271while (i < len) {272const chCode = line.charCodeAt(i);273if (chCode === CharCode.Space) {274nSpaces++;275if (nSpaces === tabSize) {276level++;277nSpaces = 0;278length = i + 1;279}280} else if (chCode === CharCode.Tab) {281level++;282nSpaces = 0;283length = i + 1;284} else {285break;286}287i++;288}289return { level, length };290}291292293function endsWith(line: string, suffix: string, suffixIndentLength: number): boolean {294let i = line.length - 1, k = suffix.length - 1;295while (i >= 0 && k >= suffixIndentLength && line.charCodeAt(i) === suffix.charCodeAt(k)) {296i--;297k--;298}299if (k >= suffixIndentLength) {300// not the full suffix matched301return false;302}303304// make sure all is whitespace before the suffix305while (i >= 0 && isWhiteSpace(line.charCodeAt(i))) {306i--;307}308return i < 0;309}310311export type Patch = { filePath: string; find: Lines; replace: Lines };312export type Section = { marker: Marker | undefined; content: string[] };313314export interface PatchEditReplyProcessor {315getFirstParagraph(text: string): string;316process(replyText: string, documentText: string, documentUri?: URI, defaultInsertionLine?: number): PatchEditReplyProcessorResult;317}318319export type PatchEditReplyProcessorResult = {320readonly edits: TextEdit[];321readonly otherSections: Section[];322readonly appliedPatches: Patch[];323readonly otherPatches: Patch[];324readonly invalidPatches: Patch[];325readonly contentBefore: Lines;326readonly contentAfter: Lines;327readonly annotations: OutcomeAnnotation[];328};329330export function getReferencedFiles(replyText: string): string[] {331const result = new Set<string>();332for (const section of iterateSections(iterateLines(replyText))) {333if (section.marker === Marker.FILEPATH) {334result.add(section.content.join('\n').trim());335}336}337338return [...result];339}340341export function getPatchEditReplyProcessor(promptPathRepresentationService: IPromptPathRepresentationService): PatchEditReplyProcessor {342return {343getFirstParagraph(text: string): string {344const result = [];345for (const line of iterateLines(text)) {346if (line.length === 0 || line.startsWith(MARKER_PREFIX)) {347break;348}349result.push(line);350}351return result.join('\n');352},353process(replyText: string, documentText: string, documentUri?: URI, defaultInsertionLine: number = 0): PatchEditReplyProcessorResult {354let original, filePath;355const annotations: OutcomeAnnotation[] = [];356const otherSections: Section[] = [];357let patches: Patch[] = [];358const edits: TextEdit[] = [];359const invalidPatches: Patch[] = [];360const otherPatches: Patch[] = [];361const filePaths = new Set<string>();362let contentBefore: string[] = [];363let contentAfter: string[] = [];364loop: for (const section of iterateSections(iterateLines(replyText))) {365switch (section.marker) {366case undefined:367contentBefore = section.content;368break;369case Marker.FILEPATH:370filePath = section.content.join('\n').trim();371break;372case Marker.FIND:373original = section.content;374break;375case Marker.REPLACE: {376if (section.content && original && filePath) {377patches.push({ filePath, find: original, replace: section.content });378filePaths.add(filePath);379}380break;381}382case Marker.COMPLETE:383contentAfter = section.content;384break loop;385default:386otherSections.push(section);387break;388}389}390if (patches.length === 0) {391annotations.push({ message: 'No patch sections found', label: OutcomeAnnotationLabel.NO_PATCH, severity: 'error' });392return { edits, contentAfter, contentBefore, appliedPatches: [], otherSections, invalidPatches, otherPatches, annotations };393}394if (documentUri) {395const documentFilePath = promptPathRepresentationService.getFilePath(documentUri);396if (!filePaths.has(documentFilePath)) {397annotations.push({ message: `No patch for input document: ${documentFilePath}, patches for ${[...filePaths.keys()].join(', ')}`, label: OutcomeAnnotationLabel.OTHER_FILE, severity: 'warning' });398}399if (filePaths.size > 1) {400annotations.push({ message: `Multiple files modified: ${[...filePaths.keys()].join(', ')}`, label: OutcomeAnnotationLabel.MULTI_FILE, severity: 'warning' });401}402const patchesForDocument = [];403for (const patch of patches) {404if (patch.filePath !== documentFilePath) {405otherPatches.push(patch);406} else {407patchesForDocument.push(patch);408}409}410patches = patchesForDocument;411}412413if (patches.length !== 0) {414const documentLines = Lines.fromString(documentText);415for (const patch of patches) {416if (equals(patch.find, patch.replace)) {417annotations.push({ message: `Patch is a no-op`, label: OutcomeAnnotationLabel.INVALID_PATCH_NOOP, severity: 'error' });418invalidPatches.push(patch);419continue;420}421if (patch.find.length <= 1) {422annotations.push({ message: `Small patch: ${Math.min(patch.find.length)}`, label: OutcomeAnnotationLabel.INVALID_PATCH_SMALL, severity: 'warning' });423}424425const res = findEdit(documentLines, getCodeBlock(patch.find), getCodeBlock(patch.replace), defaultInsertionLine);426if (res instanceof LinesEdit) {427const success = addEditIfDisjoint(edits, res.toTextEdit());428if (!success) {429annotations.push({ message: `Overlapping edits`, label: OutcomeAnnotationLabel.INVALID_EDIT_OVERLAP, severity: 'error' });430invalidPatches.push(patch);431}432} else {433annotations.push(res);434invalidPatches.push(patch);435}436}437}438return { edits, appliedPatches: patches, otherSections, invalidPatches, otherPatches, annotations, contentBefore, contentAfter };439}440441};442}443444function addEditIfDisjoint(edits: TextEdit[], edit: TextEdit): boolean {445for (let i = 0; i < edits.length; i++) {446const existingEdit = edits[i];447if (edit.range.end.isBeforeOrEqual(existingEdit.range.start)) {448edits.splice(i, 0, edit);449return true;450}451if (edit.range.start.isBefore(existingEdit.range.end)) {452// intersecting453return false;454}455}456edits.push(edit);457return true;458}459460461export function getCodeBlock(content: Lines): Lines {462const result = [];463let inCodeBlock: string | undefined;464const codeBlockRegex = /^`{3,}/; // Regex to match 3 or more backticks at the beginning of the line465for (const line of content) {466const match = line.match(codeBlockRegex);467if (match) {468if (inCodeBlock) {469if (match[0] === inCodeBlock) {470return result;471} else {472result.push(line);473}474} else {475inCodeBlock = match[0];476}477} else if (inCodeBlock) {478result.push(line);479}480}481return content;482}483484export async function* iterateSectionsForResponse(lines: AsyncIterable<IResponsePart>): AsyncIterable<Section> {485486let currentMarker: Marker | undefined = undefined;487let currentContent: string[] = [];488489const textStream = AsyncIterableObject.map(lines, part => part.delta.text);490const reader = new PartialAsyncTextReader(textStream[Symbol.asyncIterator]());491492while (!reader.endOfStream) {493const line = await reader.readLineIncludingLF();494let marker: Marker | undefined;495if (line.startsWith(MARKER_PREFIX)) {496if (line.startsWith(Marker.FILEPATH)) {497marker = Marker.FILEPATH;498} else if (line.startsWith(Marker.FIND)) {499marker = Marker.FIND;500} else if (line.startsWith(Marker.REPLACE)) {501marker = Marker.REPLACE;502} else if (line.startsWith(Marker.COMPLETE)) {503marker = Marker.COMPLETE;504} else {505marker = removeTrailingLF(line);506}507yield { marker: currentMarker, content: currentContent };508currentContent = [removeTrailingLF(line.substring(marker.length))];509currentMarker = marker;510continue;511}512currentContent.push(removeTrailingLF(line));513}514515yield { marker: currentMarker, content: currentContent };516517function removeTrailingLF(str: string): string {518if (str.endsWith('\n')) {519return str.slice(0, -1);520}521return str;522}523}524525function* iterateSections(lines: Iterable<string>): Iterable<Section> {526let currentMarker: Marker | undefined = undefined;527let currentContent: string[] = [];528529for (const line of lines) {530let marker: Marker | undefined;531if (line.startsWith(MARKER_PREFIX)) {532if (line.startsWith(Marker.FILEPATH)) {533marker = Marker.FILEPATH;534} else if (line.startsWith(Marker.FIND)) {535marker = Marker.FIND;536} else if (line.startsWith(Marker.REPLACE)) {537marker = Marker.REPLACE;538} else if (line.startsWith(Marker.COMPLETE)) {539marker = Marker.COMPLETE;540} else {541marker = line;542}543yield { marker: currentMarker, content: currentContent };544currentContent = [line.substring(marker.length)];545currentMarker = marker;546continue;547}548currentContent.push(line);549}550551yield { marker: currentMarker, content: currentContent };552}553554function* iterateLines(input: string): Iterable<string> {555let start = 0, end = 0;556while (end < input.length) {557const ch = input.charCodeAt(end);558if (ch === CharCode.CarriageReturn || ch === CharCode.LineFeed) {559yield input.substring(start, end);560end++;561if (ch === CharCode.CarriageReturn && input.charCodeAt(end) === CharCode.LineFeed) {562end++;563}564start = end;565} else {566end++;567}568}569if (start < input.length) {570yield input.substring(start);571}572}573574575