Path: blob/main/extensions/copilot/src/extension/completions/common/parseBlock.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 { StringText } from '../../../util/vs/editor/common/core/text/abstractText';67// TODO: This should probably be language specific8const continuations = [9// Brace control10'\\{',11'\\}',12'\\[',13'\\]',14'\\(',15'\\)',16].concat(17[18// Separators in a multi-line list19// ",", ";", "\\|",20// Multi-line comments21// None22// Keywords for same-level control flow23'then',24'else',25'elseif',26'elif',27'catch',28'finally',29// End keywords30'fi',31'done',32'end',33'loop',34'until',35'where',36'when',37].map(s => s + '\\b')38);39const continuationRegex = new RegExp(`^(${continuations.join('|')})`);4041/**42* Returns true if the given line is a line where we continue completion where43* the indentation level equals the current indentation level.44*45* TODO: Should probably be language specific46*/47function isContinuationLine(line: string) {48return continuationRegex.test(line.trimLeft().toLowerCase());49}5051/**52* Return the indentation level of a given single line.53*54* If the line is blank, return undefined.55*56* TODO: Possibly support tabs specially?57*/58function indentationOfLine(line: string): number | undefined {59// [^] is used to match any character include '`r', otherwise this regex never matches on60// a file containing Windows newlines.61// TODO this is a bit of hack and ideally we would be using the "right" newline character at the62// point where we split/join lines.63const match = /^(\s*)([^]*)$/.exec(line);64if (match && match[2] && match[2].length > 0) {65return match[1].length;66} else {67return undefined;68}69}7071/**72* Represents the indentation around the context of a cursor position in the code.73*74* The indentation level of the current line is the number of leading whitespace75* characters. If the current line is blank, we define its indentation level to76* be that of the preceding line (recursive if that is also blank).77*78* The indentation level of the next line is defined analogously, but recurses79* forwards until a non-blank line is encountered. It is `undefined` if there80* are no non-blank lines after the current.81*/82export interface ContextIndentation {83/**84* Next smaller indentation above the current line (guaranteed to be85* smaller than `current`, or else undefined).86*/87prev: number | undefined;88/** Indentation at the current line */89current: number;90/** Indentation at the following line */91next: number | undefined;92}9394/**95* Return the context indentation corresponding to a given position.96*/97export function contextIndentation(doc: StringText, offset: number, languageId: string): ContextIndentation {98return contextIndentationFromText(doc.value, offset, languageId);99}100101/**102* Return the context indentation corresponding to a given offset in text.103*/104export function contextIndentationFromText(source: string, offset: number, languageId: string): ContextIndentation {105const prevLines = source.slice(0, offset).split('\n');106const nextLines = source.slice(offset).split('\n');107function seekNonBlank(lines: string[], start: number, direction: -1 | 1): [number | undefined, number | undefined] {108let i = start;109let ind,110indIdx: number | undefined = undefined;111while (ind === undefined && i >= 0 && i < lines.length) {112ind = indentationOfLine(lines[i]);113indIdx = i;114i += direction;115}116if (languageId === 'python' && direction === -1) {117// HACK: special case to support multi-statement completions after Python doc comments.118// The logic looks for comments formatted as described in PEP 257.119120// The final iteration of the indentation loop will have got us to one before the "current line".121i++;122const trimmedLine = lines[i].trim();123124if (trimmedLine.endsWith(`"""`)) {125const isSingleLineDocString = trimmedLine.startsWith(`"""`) && trimmedLine !== `"""`;126if (!isSingleLineDocString) {127// Look backwards for the opening """"128i--;129while (i >= 0 && !lines[i].trim().startsWith(`"""`)) {130i--;131}132}133// i should point to the line with the opening """, if found.134// If i is negative then we never found the opening """". Give up and use the indentation135// we originally calculated.136if (i >= 0) {137ind = undefined;138i--;139// This is the same loop as above but specialised for direction = -1140while (ind === undefined && i >= 0) {141ind = indentationOfLine(lines[i]);142indIdx = i;143i--;144}145}146}147}148return [ind, indIdx];149}150const [current, currentIdx] = seekNonBlank(prevLines, prevLines.length - 1, -1);151const prev = (() => {152if (current === undefined || currentIdx === undefined) {153return undefined;154}155for (let i = currentIdx - 1; i >= 0; i--) {156const ind = indentationOfLine(prevLines[i]);157if (ind !== undefined && ind < current) {158return ind;159}160}161})();162const [next] = seekNonBlank(nextLines, 1, 1); // Skip the current line.163return {164prev,165current: current ?? 0,166next,167};168}169170// If the model thinks we are at the end of a line, do we want to offer a completion171// for the next line? For now (05 Oct 2021) we leave it as false to minimise behaviour172// changes between parsing and indentation mode.173const OfferNextLineCompletion = false;174175/**176* Return an offset where the completion ends its current context, or177* "continue" if it has not yet ended.178*179* A completion should be continued if it is:180* - A very long line that did not yet end; or181* - A multi-line context that is not yet ended.182*183* We use indentation with continuation patterns to determine whether a context184* is ended.185*/186export function completionCutOrContinue(187completion: string,188contextIndentation: ContextIndentation,189previewText: string | undefined190): number | 'continue' {191const completionLines = completion.split('\n');192const isContinuation = previewText !== undefined;193const lastLineOfPreview = previewText?.split('\n').pop();194let startLine = 0;195if (isContinuation) {196if (lastLineOfPreview?.trim() !== '' && completionLines[0].trim() !== '') {197// If we're in the middle of a line after the preview, we should at least finish it.198startLine++;199}200}201if (!isContinuation && OfferNextLineCompletion && completionLines[0].trim() === '') {202// See the comment on `OfferNextLineCompletion` for why we might do this.203startLine++;204}205if (!isContinuation) {206// We want to offer at least one line.207startLine++;208}209if (completionLines.length === startLine) {210// A single line that did not yet end.211return 'continue';212}213const breakIndentation = Math.max(contextIndentation.current, contextIndentation.next ?? 0);214for (let i = startLine; i < completionLines.length; i++) {215let line = completionLines[i];216if (i === 0 && lastLineOfPreview !== undefined) {217line = lastLineOfPreview + line;218}219const ind = indentationOfLine(line);220if (ind !== undefined && (ind < breakIndentation || (ind === breakIndentation && !isContinuationLine(line)))) {221return completionLines.slice(0, i).join('\n').length;222}223}224return 'continue';225}226227/**228* Returns a callback appropriate as `finishedCb` for229* `CompletionStream.streamChoices` that terminates a block according to230* indentation-logic.231*/232export function indentationBlockFinished(233contextIndentation: ContextIndentation,234previewText: string | undefined235): (completion: string) => Promise<number | undefined> {236// NOTE: The returned callback is only async because streamChoices needs an237// async callback238return async (completion: string) => {239const res = completionCutOrContinue(completion, contextIndentation, previewText);240// streamChoices needs a callback with bad type signature where241// undefined really means "continue".242return res === 'continue' ? undefined : res;243};244}245246247