Path: blob/main/extensions/copilot/src/extension/prompt/node/streamingEdits.ts
13399 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 type * as vscode from 'vscode';6import { AsyncIterableObject } from '../../../util/vs/base/common/async';7import { CharCode } from '../../../util/vs/base/common/charCode';8import { Constants } from '../../../util/vs/base/common/uint';9import { Range, TextEdit } from '../../../vscodeTypes';10import { looksLikeCode } from '../common/codeGuesser';11import { isImportStatement } from '../common/importStatement';12import { EditStrategy, Lines, trimLeadingWhitespace } from './editGeneration';13import { computeIndentLevel2, guessIndentation, normalizeIndentation } from './indentationGuesser';1415export interface IStreamingEditsStrategy {16processStream(stream: AsyncIterable<LineOfText>): Promise<StreamingEditsResult>;17}1819export interface IStreamingEditsStrategyFactory {20(lineFilter: ILineFilter, streamingWorkingCopyDocument: StreamingWorkingCopyDocument): IStreamingEditsStrategy;21}2223export class InsertOrReplaceStreamingEdits implements IStreamingEditsStrategy {2425private replyIndentationTracker: ReplyIndentationTracker | null = null;2627constructor(28private readonly myDocument: StreamingWorkingCopyDocument,29private readonly initialSelection: vscode.Range,30private readonly adjustedSelection: vscode.Range,31private readonly editStrategy: EditStrategy,32private readonly collectImports: boolean = true,33private readonly lineFilter: ILineFilter = LineFilters.noop,34) {35}3637public async processStream(_stream: AsyncIterable<LineOfText>): Promise<StreamingEditsResult> {38// console.log();39// console.log();40let stream = AsyncIterableObject.filter(_stream, this.lineFilter);41if (this.collectImports) {42stream = collectImportsIfNoneWereSentInRange(stream, this.myDocument, this.adjustedSelection);43}4445let anchorLineIndex = this.myDocument.firstSentLineIndex;4647for await (const el of this.findInitialAnchor(stream)) {48if (el instanceof LineWithAnchorInfo) {49anchorLineIndex = this.handleFirstReplyLine(el.anchor, el.line);50} else {51anchorLineIndex = this.handleSubsequentReplyLine(anchorLineIndex, el.value);52}53}5455if (this.myDocument.didReplaceEdits && anchorLineIndex <= this.adjustedSelection.end.line) {56// anchorIndex hasn't reached the end of the ICodeContextInfo.range57// Emit a deletion of all remaining lines in the selection block58this.myDocument.deleteLines(anchorLineIndex, this.adjustedSelection.end.line);59}6061return new StreamingEditsResult(62this.myDocument.didNoopEdits,63this.myDocument.didEdits,64this.myDocument.additionalImports65);66}6768private handleFirstReplyLine(anchor: MatchedDocumentLine | null, line: string): number {6970if (anchor) {71this.replyIndentationTracker = new ReplyIndentationTracker(this.myDocument, anchor.lineIndex, line);72const fixedLine = this.replyIndentationTracker.reindent(line, this.myDocument.indentStyle);73if (this.myDocument.getLine(anchor.lineIndex).sentInCodeBlock === SentInCodeBlock.Range) {74// Matched a line in the range => replace the entire sent range75return this.myDocument.replaceLines(this.adjustedSelection.start.line, anchor.lineIndex, fixedLine);76} else {77return this.myDocument.replaceLine(anchor.lineIndex, fixedLine);78}79}8081// No anchor found82const firstRangeLine = this.adjustedSelection.start.line;83this.replyIndentationTracker = new ReplyIndentationTracker(this.myDocument, firstRangeLine, line);84const fixedLine = this.replyIndentationTracker.reindent(line, this.myDocument.indentStyle);8586if (this.initialSelection.isEmpty) {87const cursorLineContent = this.myDocument.getLine(firstRangeLine).content;88if (89/^\s*$/.test(cursorLineContent)90|| fixedLine.adjustedContent.startsWith(cursorLineContent)91) {92// Cursor sitting on an empty or whitespace only line or the reply continues the line93return this.myDocument.replaceLine(firstRangeLine, fixedLine, /*isPreserving*/true);94}95}9697if (this.editStrategy === EditStrategy.FallbackToInsertAboveRange) {98return this.myDocument.insertLineBefore(firstRangeLine, fixedLine);99}100if (this.editStrategy === EditStrategy.FallbackToInsertBelowRange || this.editStrategy === EditStrategy.ForceInsertion) {101return this.myDocument.insertLineAfter(firstRangeLine, fixedLine);102}103// DefaultEditStrategy.ReplaceRange104return this.myDocument.replaceLine(firstRangeLine, fixedLine);105}106107private handleSubsequentReplyLine(anchorLineIndex: number, line: string): number {108const fixedLine = this.replyIndentationTracker!.reindent(line, this.myDocument.indentStyle);109110if (fixedLine.trimmedContent !== '' || this.myDocument.didReplaceEdits) {111// search for a matching line only if the incoming line is not empty112// or if we have already made destructive edits113const matchedLine = this.matchReplyLine(fixedLine, anchorLineIndex);114if (matchedLine) {115return this.myDocument.replaceLines(anchorLineIndex, matchedLine.lineIndex, fixedLine);116}117}118119120if (anchorLineIndex >= this.myDocument.getLineCount()) {121// end of file => insert semantics!122return this.myDocument.appendLineAtEndOfDocument(fixedLine);123}124125const existingLine = this.myDocument.getLine(anchorLineIndex);126if (!existingLine.isSent || existingLine.content === '' || fixedLine.trimmedContent === '') {127// line not sent or dealing empty lines => insert semantics!128return this.myDocument.insertLineBefore(anchorLineIndex, fixedLine);129}130131if (existingLine.indentLevel < fixedLine.adjustedIndentLevel) {132// do not leave current scope with the incoming line133return this.myDocument.insertLineBefore(anchorLineIndex, fixedLine);134}135136if (existingLine.indentLevel === fixedLine.adjustedIndentLevel && !this.myDocument.didReplaceEdits) {137// avoid overwriting sibling scope if no destructive edits have been made so far138return this.myDocument.insertLineBefore(anchorLineIndex, fixedLine);139}140141return this.myDocument.replaceLine(anchorLineIndex, fixedLine);142}143144private matchReplyLine(replyLine: ReplyLine, minimumLineIndex: number): MatchedDocumentLine | null {145const isVeryShortReplyLine = replyLine.trimmedContent.length <= 3;146147for (let lineIndex = minimumLineIndex; lineIndex < this.myDocument.getLineCount(); lineIndex++) {148const documentLine = this.myDocument.getLine(lineIndex);149if (!documentLine.isSent) {150continue;151}152if (documentLine.normalizedContent === replyLine.adjustedContent) {153// bingo!154return new MatchedDocumentLine(lineIndex);155}156if (documentLine.trimmedContent.length > 0 && documentLine.indentLevel < replyLine.adjustedIndentLevel) {157// we shouldn't proceed with the search if we need to jump over original code that is more outdented158return null;159}160if (isVeryShortReplyLine && documentLine.trimmedContent.length > 0) {161// don't jump over original code with content if the reply is very short162return null;163}164}165return null;166}167168/**169* Waits until at least 10 non-whitespace characters are seen in the stream170* Then tries to find a sequence of sent lines that match those first lines in the stream171*/172private findInitialAnchor(lineStream: AsyncIterable<LineOfText>): AsyncIterable<LineOfText | LineWithAnchorInfo> {173return new AsyncIterableObject<LineOfText | LineWithAnchorInfo>(async (emitter) => {174const accumulatedLines: LineOfText[] = [];175let accumulatedRealChars = 0; // non whitespace chars176let anchorFound = false;177for await (const line of lineStream) {178if (!anchorFound) {179accumulatedLines.push(line);180accumulatedRealChars += line.value.trim().length;181if (accumulatedRealChars > 10) {182const anchor = this.searchForEqualSentLines(accumulatedLines);183anchorFound = true;184emitter.emitOne(new LineWithAnchorInfo(accumulatedLines[0].value, anchor));185emitter.emitMany(accumulatedLines.slice(1));186}187} else {188emitter.emitOne(line);189}190}191});192}193194/**195* Search for a contiguous set of lines in the document that match the lines.196* The equality is done with trimmed content.197*/198private searchForEqualSentLines(lines: LineOfText[]): MatchedDocumentLine | null {199const trimmedLines = lines.map(line => line.value.trim());200201for (let i = this.myDocument.firstSentLineIndex, stopAt = this.myDocument.getLineCount() - lines.length; i <= stopAt; i++) {202if (!this.myDocument.getLine(i).isSent) {203continue;204}205let matchedAllLines = true;206for (let j = 0; j < trimmedLines.length; j++) {207const documentLine = this.myDocument.getLine(i + j);208if (!documentLine.isSent || documentLine.trimmedContent !== trimmedLines[j]) {209matchedAllLines = false;210break;211}212}213if (matchedAllLines) {214return new MatchedDocumentLine(i);215}216}217return null;218}219}220221export class InsertionStreamingEdits implements IStreamingEditsStrategy {222223private replyIndentationTracker: ReplyIndentationTracker | null = null;224225constructor(226private readonly _myDocument: IStreamingWorkingCopyDocument,227private readonly _cursorPosition: vscode.Position,228private readonly _lineFilter: ILineFilter = LineFilters.noop229) { }230231public async processStream(_stream: AsyncIterable<LineOfText>): Promise<StreamingEditsResult> {232let stream = AsyncIterableObject.filter(_stream, this._lineFilter);233stream = collectImportsIfNoneWereSentInRange(stream, this._myDocument, new Range(this._cursorPosition, this._cursorPosition));234235let anchorLineIndex = 0;236237for await (const line of stream) {238if (!this.replyIndentationTracker) {239// This is the first line240anchorLineIndex = this.handleFirstReplyLine(line.value);241} else {242anchorLineIndex = this.handleSubsequentReplyLine(anchorLineIndex, line.value);243}244}245246return new StreamingEditsResult(247this._myDocument.didNoopEdits,248this._myDocument.didEdits,249this._myDocument.additionalImports,250);251}252253private handleFirstReplyLine(replyLine: string): number {254255const firstRangeLine = this._cursorPosition.line;256257const cursorLineContent = this._myDocument.getLine(firstRangeLine).content;258259// Cursor sitting on an empty or whitespace only line or the reply continues the line260const shouldLineBeReplaced = /^\s*$/.test(cursorLineContent) || replyLine.trimStart().startsWith(cursorLineContent.trimStart());261262const lineNumForIndentGuessing = shouldLineBeReplaced // @ulugbekna: if we are insert line "after" (ie using `insertLineAfter`) we should guess indentation starting from where we insert the line263? firstRangeLine264: (this._myDocument.getLineCount() <= firstRangeLine + 1 ? firstRangeLine : firstRangeLine + 1);265266this.replyIndentationTracker = new ReplyIndentationTracker(this._myDocument, lineNumForIndentGuessing, replyLine);267const fixedLine = this.replyIndentationTracker.reindent(replyLine, this._myDocument.indentStyle);268269if (shouldLineBeReplaced) {270return this._myDocument.replaceLine(firstRangeLine, fixedLine, /*isPreserving*/true);271}272273return this._myDocument.insertLineAfter(firstRangeLine, fixedLine);274}275276private handleSubsequentReplyLine(anchorLineIndex: number, line: string): number {277const fixedLine = this.replyIndentationTracker!.reindent(line, this._myDocument.indentStyle);278279return this._myDocument.insertLineBefore(anchorLineIndex, fixedLine);280}281}282283export class ReplaceSelectionStreamingEdits implements IStreamingEditsStrategy {284285private replyIndentationTracker: ReplyIndentationTracker | null = null;286287constructor(288private readonly _myDocument: IStreamingWorkingCopyDocument,289private readonly _selection: vscode.Range,290private readonly _lineFilter: ILineFilter = LineFilters.noop291) { }292293public async processStream(_stream: AsyncIterable<LineOfText>): Promise<StreamingEditsResult> {294let stream = AsyncIterableObject.filter(_stream, this._lineFilter);295stream = collectImportsIfNoneWereSentInRange(stream, this._myDocument, this._selection);296297let anchorLineIndex = 0;298299let replaceLineCount: number;300let initialTextOnLineAfterSelection: string = '';301if (this._selection.end.line > this._selection.start.line && this._selection.end.character === 0) {302replaceLineCount = this._selection.end.line - this._selection.start.line;303} else {304replaceLineCount = this._selection.end.line - this._selection.start.line + 1;305initialTextOnLineAfterSelection = this._myDocument.getLine(this._selection.end.line).content.substring(this._selection.end.character);306}307308for await (const line of stream) {309if (!this.replyIndentationTracker) {310// This is the first line311// anchorLineIndex = this.handleFirstReplyLine(line);312const firstRangeLine = this._selection.start.line;313this.replyIndentationTracker = new ReplyIndentationTracker(this._myDocument, firstRangeLine, line.value);314const fixedLine = this.replyIndentationTracker.reindent(line.value, this._myDocument.indentStyle);315anchorLineIndex = this._myDocument.replaceLine(firstRangeLine, fixedLine);316replaceLineCount--;317} else {318// anchorLineIndex = this.handleSubsequentReplyLine(anchorLineIndex, line);319const fixedLine = this.replyIndentationTracker!.reindent(line.value, this._myDocument.indentStyle);320if (replaceLineCount > 0) {321anchorLineIndex = this._myDocument.replaceLine(anchorLineIndex, fixedLine);322replaceLineCount--;323} else {324anchorLineIndex = this._myDocument.insertLineAfter(anchorLineIndex - 1, fixedLine);325// anchorLineIndex = this._myDocument.insertLineBefore(anchorLineIndex, fixedLine);326}327}328}329330if (this._myDocument.didEdits && replaceLineCount > 0) {331this._myDocument.deleteLines(anchorLineIndex, anchorLineIndex + replaceLineCount - 1);332}333if (this._myDocument.didEdits && initialTextOnLineAfterSelection.length > 0) {334this._myDocument.replaceLine(anchorLineIndex - 1, this._myDocument.getLine(anchorLineIndex - 1).content + initialTextOnLineAfterSelection);335}336337return new StreamingEditsResult(338this._myDocument.didNoopEdits,339this._myDocument.didEdits,340this._myDocument.additionalImports341);342}343}344345/**346* A filter which can be used to ignore lines from a stream.347* Returns true if the line should be kept.348*/349export interface ILineFilter {350(line: LineOfText): boolean;351}352353export class StreamingEditsResult {354constructor(355public readonly didNoopEdits: boolean,356public readonly didEdits: boolean,357public readonly additionalImports: string[],358) { }359}360361/**362* Keeps track of the indentation of the reply lines and is able to363* reindent reply lines to match the document, keeping their relative indentation.364*/365class ReplyIndentationTracker {366367private _replyIndentStyle: vscode.FormattingOptions | undefined;368private indentDelta: number;369370constructor(371document: IStreamingWorkingCopyDocument,372documentLineIdx: number,373replyLine: string374) {375let docIndentLevel = 0;376for (let i = documentLineIdx; i >= 0; i--) {377const documentLine = document.getLine(i);378// Use the indent of the first non-empty line379if (documentLine.content.length > 0) {380docIndentLevel = documentLine.indentLevel;381if (i !== documentLineIdx) {382// The first non-empty line is not the current line, indent if necessary383if (384documentLine.content.endsWith('{') ||385(document.languageId === 'python' && documentLine.content.endsWith(':'))386) {387// TODO: this is language specific388docIndentLevel += 1;389}390}391break;392}393}394395this._replyIndentStyle = IndentUtils.guessIndentStyleFromLine(replyLine);396const replyIndentLevel = computeIndentLevel2(replyLine, this._replyIndentStyle?.tabSize ?? 4);397398this.indentDelta = replyIndentLevel - docIndentLevel;399}400401public reindent(replyLine: string, desiredStyle: vscode.FormattingOptions): ReplyLine {402if (replyLine === '') {403// Do not indent empty lines artificially404return new ReplyLine('', 0, '', 0);405}406407if (!this._replyIndentStyle) {408this._replyIndentStyle = IndentUtils.guessIndentStyleFromLine(replyLine);409}410411let originalIndentLevel = 0;412let adjustedIndentLevel = 0;413const determineAdjustedIndentLevel = (currentIndentLevel: number) => {414originalIndentLevel = currentIndentLevel;415adjustedIndentLevel = Math.max(originalIndentLevel - this.indentDelta, 0);416return adjustedIndentLevel;417};418const adjustedContent = IndentUtils.reindentLine(replyLine, this._replyIndentStyle ?? { insertSpaces: true, tabSize: 4 }, desiredStyle, determineAdjustedIndentLevel);419420return new ReplyLine(replyLine, originalIndentLevel, adjustedContent, adjustedIndentLevel);421}422}423424class LineWithAnchorInfo {425constructor(426readonly line: string,427readonly anchor: MatchedDocumentLine | null,428) { }429}430431export class SentLine {432constructor(433readonly lineIndex: number,434readonly sentInCodeBlock: SentInCodeBlock.Above | SentInCodeBlock.Range | SentInCodeBlock.Below | SentInCodeBlock.Other435) { }436}437438export class LineRange {439constructor(440readonly startLineIndex: number,441readonly endLineIndex: number442) { }443}444445export interface IStreamingWorkingCopyDocument {446readonly languageId: string;447readonly indentStyle: vscode.FormattingOptions;448readonly didNoopEdits: boolean;449readonly didEdits: boolean;450readonly additionalImports: string[];451452getLineCount(): number;453getLine(index: number): DocumentLine;454addAdditionalImport(importStatement: string): void;455replaceLine(index: number, line: ReplyLine | string, isPreserving?: boolean): number;456replaceLines(fromIndex: number, toIndex: number, line: ReplyLine): number;457appendLineAtEndOfDocument(line: ReplyLine): number;458insertLineAfter(index: number, line: ReplyLine): number;459insertLineBefore(index: number, line: ReplyLine): number;460deleteLines(fromIndex: number, toIndex: number): number;461}462463/**464* Keeps track of the current document with edits applied immediately.465*/466export class StreamingWorkingCopyDocument implements IStreamingWorkingCopyDocument {467468public readonly indentStyle: vscode.FormattingOptions;469private readonly _originalLines: string[] = [];470private lines: DocumentLine[] = [];471public readonly firstSentLineIndex: number;472private _didNoopEdits = false;473private _didEdits = false;474private _didReplaceEdits = false;475private readonly _additionalImports: string[] = [];476477public get didNoopEdits(): boolean {478return this._didNoopEdits;479}480481public get didEdits(): boolean {482return this._didEdits;483}484485public get didReplaceEdits(): boolean {486return this._didReplaceEdits;487}488489public get additionalImports(): string[] {490return this._additionalImports;491}492493constructor(494private readonly outputStream: vscode.ChatResponseStream,495private readonly uri: vscode.Uri,496sourceCode: string,497sentLines: SentLine[],498selection: LineRange,499public readonly languageId: string,500fileIndentInfo: vscode.FormattingOptions | undefined501) {502// console.info(`---------\nNEW StreamingWorkingCopyDocument`);503this.indentStyle = IndentUtils.getDocumentIndentStyle(sourceCode, fileIndentInfo);504505this._originalLines = sourceCode.split(/\r\n|\r|\n/g);506for (let i = 0; i < this._originalLines.length; i++) {507this.lines[i] = new DocumentLine(this._originalLines[i], this.indentStyle);508}509510this.firstSentLineIndex = Number.MAX_SAFE_INTEGER;511for (const sentLine of sentLines) {512this.lines[sentLine.lineIndex].markSent(sentLine.sentInCodeBlock);513this.firstSentLineIndex = Math.min(this.firstSentLineIndex, sentLine.lineIndex);514}515516this.firstSentLineIndex = Math.min(this.firstSentLineIndex, selection.startLineIndex);517}518519public getText(): string {520return this.lines.map(line => line.content).join('\n');521}522523public getLineCount(): number {524return this.lines.length;525}526527public getLine(index: number): DocumentLine {528if (index < 0 || index >= this.lines.length) {529throw new Error(`Invalid index`);530}531return this.lines[index];532}533534public addAdditionalImport(importStatement: string): void {535this._additionalImports.push(importStatement);536}537538public replaceLine(index: number, line: ReplyLine | string, isPreserving: boolean = false): number {539const newLineContent = typeof line === 'string' ? line : line.adjustedContent;540// console.info(`replaceLine(${index}, ${this.lines[index].content}, ${newLineContent})`);541if (this.lines[index].content === newLineContent) {542this._didNoopEdits = true;543// no need to really replace the line544return index + 1;545}546this.lines[index] = new DocumentLine(newLineContent, this.indentStyle);547this.outputStream.textEdit(this.uri, [new TextEdit(new Range(index, 0, index, Constants.MAX_SAFE_SMALL_INTEGER), newLineContent)]);548this._didEdits = true;549this._didReplaceEdits = this._didReplaceEdits || (isPreserving ? false : true);550return index + 1;551}552553public replaceLines(fromIndex: number, toIndex: number, line: ReplyLine): number {554if (fromIndex > toIndex) {555throw new Error(`Invalid range`);556}557if (fromIndex === toIndex) {558return this.replaceLine(fromIndex, line);559}560// console.info(`replaceLines(${fromIndex}, ${toIndex}, ${line.adjustedContent})`);561this.lines.splice(fromIndex, toIndex - fromIndex + 1, new DocumentLine(line.adjustedContent, this.indentStyle));562this.outputStream.textEdit(this.uri, [new TextEdit(new Range(fromIndex, 0, toIndex, Constants.MAX_SAFE_SMALL_INTEGER), line.adjustedContent)]);563this._didEdits = true;564this._didReplaceEdits = true;565return fromIndex + 1;566}567568public appendLineAtEndOfDocument(line: ReplyLine): number {569// console.info(`appendLine(${line.adjustedContent})`);570this.lines.push(new DocumentLine(line.adjustedContent, this.indentStyle));571this.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)]);572this._didEdits = true;573return this.lines.length;574}575576public insertLineAfter(index: number, line: ReplyLine): number {577// console.info(`insertLineAfter(${index}, ${this.lines[index].content}, ${line.adjustedContent})`);578this.lines.splice(index + 1, 0, new DocumentLine(line.adjustedContent, this.indentStyle));579this.outputStream.textEdit(this.uri, [new TextEdit(new Range(index, Constants.MAX_SAFE_SMALL_INTEGER, index, Constants.MAX_SAFE_SMALL_INTEGER), '\n' + line.adjustedContent)]);580this._didEdits = true;581return index + 2;582}583584public insertLineBefore(index: number, line: ReplyLine): number {585if (index === this.lines.length) {586// we must insert after the last line587return this.insertLineAfter(index - 1, line);588}589// console.info(`insertLineBefore(${index}, ${this.lines[index].content}, ${line.adjustedContent})`);590this.lines.splice(index, 0, new DocumentLine(line.adjustedContent, this.indentStyle));591this.outputStream.textEdit(this.uri, [new TextEdit(new Range(index, 0, index, 0), line.adjustedContent + '\n')]);592this._didEdits = true;593return index + 1;594}595596public deleteLines(fromIndex: number, toIndex: number): number {597// console.info(`deleteLines(${fromIndex}, ${toIndex})`);598this.lines.splice(fromIndex, toIndex - fromIndex + 1);599this.outputStream.textEdit(this.uri, [new TextEdit(new Range(fromIndex, 0, toIndex + 1, 0), '')]); // TODO: what about end of document??600this._didEdits = true;601this._didReplaceEdits = true;602return fromIndex + 1;603}604}605606class ReplyLine {607public readonly trimmedContent: string = this.originalContent.trim();608609constructor(610public readonly originalContent: string, // as returned from the LLM611public readonly originalIndentLevel: number,612public readonly adjustedContent: string, // adjusted for insertion in the document613public readonly adjustedIndentLevel: number614) { }615}616617class MatchedDocumentLine {618constructor(619public readonly lineIndex: number620) { }621}622623export const enum SentInCodeBlock {624None,625Above,626Range,627Below,628Other,629}630631class DocumentLine {632633private _sentInCodeBlock: SentInCodeBlock = SentInCodeBlock.None;634public get isSent(): boolean {635return this._sentInCodeBlock !== SentInCodeBlock.None;636}637public get sentInCodeBlock(): SentInCodeBlock {638return this._sentInCodeBlock;639}640641private _trimmedContent: string | null = null;642public get trimmedContent(): string {643if (this._trimmedContent === null) {644this._trimmedContent = this.content.trim();645}646return this._trimmedContent;647}648649private _normalizedContent: string | null = null;650public get normalizedContent(): string {651if (this._normalizedContent === null) {652this._normalizedContent = normalizeIndentation(this.content, this._indentStyle.tabSize, this._indentStyle.insertSpaces);653}654return this._normalizedContent;655}656657private _indentLevel: number = -1;658public get indentLevel(): number {659if (this._indentLevel === -1) {660this._indentLevel = computeIndentLevel2(this.content, this._indentStyle.tabSize);661}662return this._indentLevel;663}664665constructor(666public readonly content: string,667private readonly _indentStyle: vscode.FormattingOptions668) { }669670public markSent(sentInCodeBlock: SentInCodeBlock): void {671this._sentInCodeBlock = sentInCodeBlock;672}673}674675class IndentUtils {676677public static getDocumentIndentStyle(sourceCode: string, fileIndentInfo: vscode.FormattingOptions | undefined): vscode.FormattingOptions {678if (fileIndentInfo) {679// the indentation is known680return fileIndentInfo;681}682683// we need to detect the indentation684return <vscode.FormattingOptions>guessIndentation(Lines.fromString(sourceCode), 4, false);685}686687public static guessIndentStyleFromLine(line: string): vscode.FormattingOptions | undefined {688const leadingWhitespace = IndentUtils._getLeadingWhitespace(line);689if (leadingWhitespace === '' || leadingWhitespace === ' ') {690// insufficient information691return undefined;692}693return <vscode.FormattingOptions>guessIndentation([line], 4, false);694}695696public static reindentLine(line: string, originalIndentStyle: vscode.FormattingOptions, desiredIndentStyle: vscode.FormattingOptions, getDesiredIndentLevel: (currentIndentLevel: number) => number = (n) => n): string {697let indentLevel = computeIndentLevel2(line, originalIndentStyle.tabSize);698const desiredIndentLevel = getDesiredIndentLevel(indentLevel);699700// First we outdent to 0 and then we indent to the desired level701// This ensures that we normalize indentation in the process and that we702// maintain any trailing spaces at the end of the tab stop703while (indentLevel > 0) {704line = this._outdent(line, originalIndentStyle);705indentLevel--;706}707708while (indentLevel < desiredIndentLevel) {709line = '\t' + line;710indentLevel++;711}712713return normalizeIndentation(line, desiredIndentStyle.tabSize, desiredIndentStyle.insertSpaces);714}715716private static _outdent(line: string, indentStyle: vscode.FormattingOptions): string {717let chrIndex = 0;718while (chrIndex < line.length) {719const chr = line.charCodeAt(chrIndex);720if (chr === CharCode.Tab) {721// consume the tab and stop722chrIndex++;723break;724}725if (chr !== CharCode.Space) {726// never remove non whitespace characters727break;728}729if (chrIndex === indentStyle.tabSize) {730// reached the maximum number of spaces731break;732}733chrIndex++;734}735return line.substring(chrIndex);736}737738/**739* Gets all whitespace characters at the start of a string.740*/741private static _getLeadingWhitespace(line: string): string {742for (let i = 0; i < line.length; i++) {743const char = line.charCodeAt(i);744if (char !== 32 && char !== 9) { // 32 is ASCII for space and 9 is ASCII for tab745return line.substring(0, i);746}747}748return line;749}750}751752export class LineFilters {753754public static combine(...filters: (ILineFilter | undefined)[]): ILineFilter {755return (line: LineOfText) => filters.every(filter => filter ? filter(line) : true);756}757758public static noop: ILineFilter = () => true;759760/**761* Keeps only lines that are inside ``` code blocks.762*/763public static createCodeBlockFilter(): ILineFilter {764const enum State {765BeforeCodeBlock,766InCodeBlock,767AfterCodeBlock768}769let state = State.BeforeCodeBlock;770return (line: LineOfText) => {771if (state === State.BeforeCodeBlock) {772if (/^```/.test(line.value)) {773state = State.InCodeBlock;774}775return false;776}777if (state === State.InCodeBlock) {778if (/^```/.test(line.value)) {779state = State.AfterCodeBlock;780return false;781}782return true;783}784// text after code block785return false;786};787}788}789790/**791* A line of text. Does not include the newline character.792*/793export class LineOfText {794readonly __lineOfTextBrand: void = undefined;795public readonly value: string;796constructor(797value: string798) {799this.value = value.replace(/\r$/, '');800}801}802803export const enum TextPieceKind {804/**805* A text piece that appears outside a code block806*/807OutsideCodeBlock,808/**809* A text piece that appears inside a code block810*/811InsideCodeBlock,812/**813* A text piece that is a delimiter814*/815Delimiter,816}817818export class ClassifiedTextPiece {819constructor(820public readonly value: string,821public readonly kind: TextPieceKind822) { }823}824825/**826* Can classify pieces of text into different kinds.827*/828export interface IStreamingTextPieceClassifier {829(textSource: AsyncIterable<string>): AsyncIterableObject<ClassifiedTextPiece>;830}831832export class TextPieceClassifiers {833/**834* Classifies lines using ``` code blocks.835*/836public static createCodeBlockClassifier(): IStreamingTextPieceClassifier {837return TextPieceClassifiers.attemptToRecoverFromMissingCodeBlock(838TextPieceClassifiers.createFencedBlockClassifier('```')839);840}841842private static attemptToRecoverFromMissingCodeBlock(classifier: IStreamingTextPieceClassifier): IStreamingTextPieceClassifier {843return (source: AsyncIterable<string>) => {844return new AsyncIterableObject<ClassifiedTextPiece>(async (emitter) => {845// We buffer all pieces until the first code block, then846// we open the gate and start emitting all pieces immediately.847const bufferedPieces: ClassifiedTextPiece[] = [];848let sawOnlyLeadingText = true;849for await (const piece of classifier(source)) {850if (!sawOnlyLeadingText) {851emitter.emitOne(piece);852} else if (piece.kind === TextPieceKind.OutsideCodeBlock) {853bufferedPieces.push(piece);854} else {855sawOnlyLeadingText = false;856for (const p of bufferedPieces) {857emitter.emitOne(p);858}859bufferedPieces.length = 0;860emitter.emitOne(piece);861}862}863864// if we never found a code block, we emit all pieces at the end865if (sawOnlyLeadingText) {866const allText = bufferedPieces.map(p => p.value).join('');867if (looksLikeCode(allText)) {868emitter.emitOne(new ClassifiedTextPiece(allText, TextPieceKind.InsideCodeBlock));869} else {870emitter.emitOne(new ClassifiedTextPiece(allText, TextPieceKind.OutsideCodeBlock));871}872}873});874};875}876877/**878* Classifies lines using fenced blocks with the provided fence.879*/880public static createAlwaysInsideCodeBlockClassifier(): IStreamingTextPieceClassifier {881return (source: AsyncIterable<string>) => {882return AsyncIterableObject.map(source, line => new ClassifiedTextPiece(line, TextPieceKind.InsideCodeBlock));883};884}885886/**887* Classifies lines using fenced blocks with the provided fence.888*/889public static createFencedBlockClassifier(fence: string): IStreamingTextPieceClassifier {890return (source: AsyncIterable<string>) => {891return new AsyncIterableObject<ClassifiedTextPiece>(async (emitter) => {892const reader = new PartialAsyncTextReader(source[Symbol.asyncIterator]());893894let state = TextPieceKind.OutsideCodeBlock;895896while (!reader.endOfStream) {897898const text = await reader.peek(fence.length);899900if (text !== fence) {901902// consume and emit immediately all pieces until newline or end of stream903while (!reader.endOfStream) {904// we want to consume any piece that is available in order to emit it immediately905const piece = reader.readImmediateExcept('\n');906if (piece.length > 0) {907emitter.emitOne(new ClassifiedTextPiece(piece, state));908}909const nextChar = await reader.peek(1);910if (nextChar === '\n') {911reader.readImmediate(1);912emitter.emitOne(new ClassifiedTextPiece('\n', state));913break;914}915}916917} else {918919const lineWithFence = await reader.readLineIncludingLF();920state = state === TextPieceKind.InsideCodeBlock ? TextPieceKind.OutsideCodeBlock : TextPieceKind.InsideCodeBlock;921emitter.emitOne(new ClassifiedTextPiece(lineWithFence, TextPieceKind.Delimiter));922923}924}925});926};927}928929}930931export class PartialAsyncTextReader {932933private _buffer: string = '';934private _atEnd = false;935936public get endOfStream(): boolean { return this._buffer.length === 0 && this._atEnd; }937938constructor(939private readonly _source: AsyncIterator<string>940) {941}942943private async extendBuffer(): Promise<void> {944if (this._atEnd) {945return;946}947const { value, done } = await this._source.next();948if (done) {949this._atEnd = true;950} else {951this._buffer += value;952}953}954955/**956* Waits until n characters are available in the buffer or the end of the stream is reached.957*/958async waitForLength(n: number): Promise<void> {959while (this._buffer.length < n && !this._atEnd) {960await this.extendBuffer();961}962}963964/**965* Peeks `n` characters or less if the stream ends.966*/967async peek(n: number): Promise<string> {968await this.waitForLength(n);969return this._buffer.substring(0, n);970}971972/**973* Reads `n` characters or less if the stream ends.974*/975async read(n: number): Promise<string> {976await this.waitForLength(n);977const result = this._buffer.substring(0, n);978this._buffer = this._buffer.substring(n);979return result;980}981982/**983* Read all available characters until `char`984*/985async readUntil(char: string): Promise<string> {986let result = '';987while (!this.endOfStream) {988const piece = this.readImmediateExcept(char);989result += piece;990const nextChar = await this.peek(1);991992if (nextChar === char) {993break;994}995}996997return result;998}9991000/**1001* Read an entire line including \n or until end of stream.1002*/1003async readLineIncludingLF(): Promise<string> {1004// consume all pieces until newline or end of stream1005let line = await this.readUntil('\n');1006// the next char should be \n or we're at end of stream1007line += await this.read(1);1008return line;1009}10101011/**1012* Read an entire line until \n (excluding \n) or until end of stream.1013* The \n is consumed from the stream1014*/1015async readLine(): Promise<string> {1016// consume all pieces until newline or end of stream1017const line = await this.readUntil('\n');1018// the next char should be \n or we're at end of stream1019await this.read(1);1020return line;1021}10221023/**1024* Returns immediately with all available characters until `char`.1025*/1026readImmediateExcept(char: string): string {1027const endIndex = this._buffer.indexOf(char);1028return this.readImmediate(endIndex === -1 ? this._buffer.length : endIndex);1029}10301031/**1032* Returns immediately with all available characters, but at most `n` characters.1033*/1034readImmediate(n: number): string {1035const result = this._buffer.substring(0, n);1036this._buffer = this._buffer.substring(n);1037return result;1038}1039}10401041export class AsyncReaderEndOfStream { }10421043export class AsyncReader<T> {10441045public static EOS = new AsyncReaderEndOfStream();10461047private _buffer: T[] = [];1048private _atEnd = false;10491050public get endOfStream(): boolean { return this._buffer.length === 0 && this._atEnd; }10511052constructor(1053private readonly _source: AsyncIterator<T>1054) {1055}10561057private async extendBuffer(): Promise<void> {1058if (this._atEnd) {1059return;1060}1061const { value, done } = await this._source.next();1062if (done) {1063this._atEnd = true;1064} else {1065this._buffer.push(value);1066}1067}10681069public async peek(): Promise<T | AsyncReaderEndOfStream> {1070if (this._buffer.length === 0 && !this._atEnd) {1071await this.extendBuffer();1072}1073if (this._buffer.length === 0) {1074return AsyncReader.EOS;1075}1076return this._buffer[0];1077}10781079public async read(): Promise<T | AsyncReaderEndOfStream> {1080if (this._buffer.length === 0 && !this._atEnd) {1081await this.extendBuffer();1082}1083if (this._buffer.length === 0) {1084return AsyncReader.EOS;1085}1086return this._buffer.shift()!;1087}10881089public async readWhile(predicate: (value: T) => boolean, callback: (element: T) => unknown): Promise<void> {1090do {1091const piece = await this.peek();1092if (piece instanceof AsyncReaderEndOfStream) {1093break;1094}1095if (!predicate(piece)) {1096break;1097}1098await this.read(); // consume1099await callback(piece);1100} while (true);1101}11021103public async consumeToEnd(): Promise<void> {1104while (!this.endOfStream) {1105await this.read();1106}1107}1108}11091110/**1111* Split an incoming stream of text to a stream of lines.1112*/1113export function streamLines(source: AsyncIterable<string>): AsyncIterableObject<LineOfText> {1114return new AsyncIterableObject<LineOfText>(async (emitter) => {1115let buffer = '';1116for await (const str of source) {1117buffer += str;1118do {1119const newlineIndex = buffer.indexOf('\n');1120if (newlineIndex === -1) {1121break;1122}11231124// take the first line1125const line = buffer.substring(0, newlineIndex);1126buffer = buffer.substring(newlineIndex + 1);11271128emitter.emitOne(new LineOfText(line));1129} while (true);1130}11311132if (buffer.length > 0) {1133// last line which doesn't end with \n1134emitter.emitOne(new LineOfText(buffer));1135}1136});1137}11381139function hasImportsInRange(doc: IStreamingWorkingCopyDocument, range: vscode.Range): boolean {1140const startLine = (range.start.character === 0 ? range.start.line : range.start.line + 1);1141const endLine = (doc.getLine(range.end.line).content.length === range.end.character ? range.end.line : range.end.line - 1);1142for (let i = startLine; i <= endLine; i++) {1143if (isImportStatement(doc.getLine(i).content, doc.languageId)) {1144return true;1145}1146}1147return false;1148}11491150function collectImportsIfNoneWereSentInRange(stream: AsyncIterableObject<LineOfText>, doc: IStreamingWorkingCopyDocument, rangeToCheckForImports: vscode.Range): AsyncIterableObject<LineOfText> {1151if (hasImportsInRange(doc, rangeToCheckForImports)) {1152// there are imports in the sent code block1153// no need to collect imports1154return stream;1155}1156// collect imports separately1157let extractedImports = false;1158let hasCode = false;1159return stream.filter(line => {1160if (isImportStatement(line.value, doc.languageId)) {1161doc.addAdditionalImport(trimLeadingWhitespace(line.value));1162extractedImports = true;1163return false;1164}1165const isOnlyWhitespace = (line.value.trim().length === 0);1166if (isOnlyWhitespace && extractedImports) {1167// there are imports in the reply which we have moved up1168// survive the empty line if it is inside code1169return hasCode;1170}1171hasCode = true;1172return true;1173});1174}117511761177