Path: blob/main/extensions/copilot/src/extension/prompts/node/inline/adjustSelection.ts
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 { AbstractDocument } from '../../../../platform/editing/common/abstractText';6import { OverlayNode } from '../../../../platform/parser/node/nodes';7import { binarySearch2, equals } from '../../../../util/vs/base/common/arrays';8import { CharCode } from '../../../../util/vs/base/common/charCode';9import { BugIndicatingError } from '../../../../util/vs/base/common/errors';10import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange';11import { Range } from '../../../../vscodeTypes';1213export function getAdjustedSelection<TDocument extends AbstractDocument>(14ast: OverlayNode,15document: TDocument,16userSelection: Range,17): { adjusted: OffsetRange; original: OffsetRange } {18const documentText = document.getText();19const astWithoutWs = alignOverlayNodesToNonWsText(ast, documentText);20const root = LinkedOverlayNode.convertToLinkedTree(documentText, astWithoutWs ?? ast);21const start = document.getOffsetAtPosition(userSelection.start);22const end = document.getOffsetAtPosition(userSelection.end);23const adjustedSelection = markSelectedNodes(root, start, end);24return { adjusted: adjustedSelection, original: new OffsetRange(start, end) };25}2627function alignOverlayNodesToNonWsText(ast: OverlayNode, text: string): OverlayNode | undefined {28const newStartIndex = alignToNonWsTextRight(ast.startIndex, text);29const newEndIndex = Math.max(newStartIndex, alignToNonWsTextLeft(ast.endIndex, text));30if (newStartIndex === newEndIndex) { // indentation-based structure can include nodes which can just contain newlines31return undefined;32}33const arr = ast.children.map(child => alignOverlayNodesToNonWsText(child, text)).filter(s => s !== undefined);34if (newStartIndex === ast.startIndex && newEndIndex === ast.endIndex && equals(arr, ast.children)) {35return ast;36}37return new OverlayNode(newStartIndex, newEndIndex, ast.kind, arr);38}3940function alignToNonWsTextRight(idx: number, str: string): number {41while (idx < str.length) {42const ch = str.charCodeAt(idx);43if (ch !== CharCode.Space && ch !== CharCode.Tab && ch !== CharCode.LineFeed && ch !== CharCode.CarriageReturn) {44return idx;45}46idx++;47}48return idx;49}5051function alignToNonWsTextLeft(idx: number, str: string): number {52while (idx > 0) {53const ch = str.charCodeAt(idx - 1);54if (ch !== CharCode.Space && ch !== CharCode.Tab && ch !== CharCode.LineFeed && ch !== CharCode.CarriageReturn) {55return idx;56}57idx--;58}59return idx;60}6162function markSelectedNodes(root: LinkedOverlayNode, start: number, end: number) {63[start, end] = moveTowardsContent(root, start, end);64return adjustSelection(root, start, end);65}6667/**68* If the selection sits on whitespace, move it towards the closest content69*/70function moveTowardsContent(root: LinkedOverlayNode, initialStart: number, initialEnd: number): [number, number] {71const selectedText = root.text.substring(initialStart, initialEnd);72const selectedTextIsEmptyOrWhitespace = /^\s*$/.test(selectedText);73if (!selectedTextIsEmptyOrWhitespace) {74return [initialStart, initialEnd];75}76let start = initialStart;77let end = initialEnd;78let goLeft = true;79let goRight = true;80do {81if (goRight && end >= root.text.length) {82// can't go right anymore83goRight = false;84}85if (goRight) {86const nextCharCode = root.text.charCodeAt(end);87if (nextCharCode === CharCode.CarriageReturn || nextCharCode === CharCode.LineFeed) {88// Hit the EOL89goRight = false;90} else if (nextCharCode !== CharCode.Space && nextCharCode !== CharCode.Tab) {91// Hit real content92return [end, end + 1];93} else {94end++;95}96}97if (goLeft && start === 0) {98// can't go left anymore99goLeft = false;100}101if (goLeft) {102const prevCharCode = root.text.charCodeAt(start - 1);103if (prevCharCode === CharCode.CarriageReturn || prevCharCode === CharCode.LineFeed) {104// Hit the EOL105goLeft = false;106} else if (prevCharCode !== CharCode.Space && prevCharCode !== CharCode.Tab) {107// Hit real content108return [start - 1, start];109} else {110start--;111}112}113} while (goLeft || goRight);114115// Couldn't find real content116return [initialStart, initialEnd];117}118119function adjustSelection(root: LinkedOverlayNode, start: number, end: number): OffsetRange {120// If the selection starts at the end of a line with content, move over the line feed121if (start > 0 && start < end && root.text.charCodeAt(start - 1) !== CharCode.LineFeed && root.text.charCodeAt(start) === CharCode.LineFeed) {122start++;123}124125start = moveToStartOfLineOverWhitespace(root, start);126end = moveToEndOfLineOverWhitespace(root, end);127128let startNode = root.findLeaf2(start);129let endNode = root.findLeaf2(end);130131let hasChanged = false;132133const extendStart = (newStart: number) => {134if (newStart < start) {135start = moveToStartOfLineOverWhitespace(root, newStart);136hasChanged = true;137startNode = root.findLeaf2(start);138}139};140141const extendEnd = (newEnd: number) => {142if (newEnd > end) {143end = moveToEndOfLineOverWhitespace(root, newEnd);144hasChanged = true;145endNode = root.findLeaf2(end);146}147};148149do {150hasChanged = false;151152if (startNode instanceof LinkedOverlayNodeGap) {153const matchingGap = (startNode.isFirstGapInParent ? startNode.parent.lastGap : null);154const hasSelectedContentInGap = startNode.hasSelectedContent(start, end);155const hasSelectedContentInMatchingGap = (matchingGap && matchingGap.hasSelectedContent(start, end));156const extendSelection = (hasSelectedContentInGap || hasSelectedContentInMatchingGap);157if (extendSelection) {158extendStart(startNode.firstNonWhitespaceIndex);159}160if (extendSelection && matchingGap) {161extendEnd(matchingGap.lastNonWhitespaceIndex + 1);162}163}164165if (endNode instanceof LinkedOverlayNodeGap) {166const matchingFirstGap = (endNode.isLastGapInParent ? endNode.parent.firstGap : null);167const hasSelectedContentInGap = endNode.hasSelectedContent(start, end);168const hasSelectedContentInFirstGap = (matchingFirstGap && matchingFirstGap.hasSelectedContent(start, end));169const extendSelection = (hasSelectedContentInGap || hasSelectedContentInFirstGap);170if (extendSelection && endNode.lastNonWhitespaceIndex + 1 > end) {171extendEnd(endNode.lastNonWhitespaceIndex + 1);172}173if (extendSelection && matchingFirstGap) {174extendStart(matchingFirstGap.firstNonWhitespaceIndex);175}176}177178if (startNode instanceof LinkedOverlayNode && root.hasContentInRange(new OffsetRange(start, end))) {179// Hit a leaf!180if (startNode.startIndex < start) {181extendStart(startNode.startIndex);182}183}184185if (endNode instanceof LinkedOverlayNode && root.hasContentInRange(new OffsetRange(start, end))) {186// Hit a leaf!187if (endNode.endIndex > end) {188extendEnd(endNode.endIndex);189}190}191192} while (hasChanged);193194return new OffsetRange(start, end);195}196197function moveToStartOfLineOverWhitespace(root: LinkedOverlayNode, start: number): number {198// Move start to the start of the line if it only goes over whitespace199while (start > 0) {200const charCodeBeforeSelection = root.text.charCodeAt(start - 1);201if (charCodeBeforeSelection !== CharCode.Space && charCodeBeforeSelection !== CharCode.Tab) {202break;203}204start--;205}206return start;207}208209function moveToEndOfLineOverWhitespace(root: LinkedOverlayNode, end: number): number {210const charCodeBefore = end > 0 ? root.text.charCodeAt(end - 1) : CharCode.Null;211if (charCodeBefore === CharCode.LineFeed) {212// Do not leave first character of the line213return end;214}215216// Move end to the end of the line if it only goes over whitespace217while (end < root.text.length) {218const charCodeAfterSelection = root.text.charCodeAt(end);219if (charCodeAfterSelection !== CharCode.Space && charCodeAfterSelection !== CharCode.Tab) {220break;221}222end++;223}224return end;225}226227function debugstr(str: string) {228return str.replace(/\r/g, '\\r').replace(/\n/g, '\\n').replace(/\t/g, '\\t');229}230231/**232* A tree datastructure which has parent pointers and markers if a node will survive or not.233*/234export class LinkedOverlayNode {235236public static convertToLinkedTree(text: string, root: OverlayNode): LinkedOverlayNode {237const linkedRoot = new LinkedOverlayNode(text, null, root.startIndex, root.endIndex, root.kind, [], 0); // parentChildIndex238LinkedOverlayNode._convertChildrenToLinkedTree(text, root, linkedRoot); // Start with depth 1239return linkedRoot;240}241242private static _convertChildrenToLinkedTree(text: string, overlayNode: OverlayNode, linkedNode: LinkedOverlayNode) {243for (let i = 0; i < overlayNode.children.length; i++) {244const child = overlayNode.children[i];245const linkedChild = new LinkedOverlayNode(text, linkedNode, child.startIndex, child.endIndex, child.kind, [], i);246linkedNode.children.push(linkedChild);247LinkedOverlayNode._convertChildrenToLinkedTree(text, child, linkedChild);248}249}250251constructor(252private readonly _originalText: string,253public readonly parent: LinkedOverlayNode | null,254public readonly startIndex: number,255public readonly endIndex: number,256public readonly kind: string, // TODO@ulugbekna: come up with more generic kinds so that these aren't per-language, then use enum?257public readonly children: LinkedOverlayNode[],258private readonly myIndex: number, // Added parentChildIndex field259) { }260261public get text(): string {262return this._originalText.substring(this.startIndex, this.endIndex);263}264265public textAt(range: OffsetRange): string {266return range.substring(this._originalText);267}268269/**270* Intersects the selection with this node's range to check if there is any non-whitespace text selected from this gap.271*/272public hasContentInRange(range: OffsetRange): boolean {273const content = this.textAt(range);274return !/^\s*$/s.test(content);275}276277public toString(): string {278return `Node (${this.startIndex}-${this.endIndex}) ${debugstr(this._originalText.substring(this.startIndex, this.endIndex))}`;279}280281gapBeforeChild(childIndex: number): LinkedOverlayNodeGap {282const startIndex = (childIndex === 0 ? this.startIndex : this.children[childIndex - 1].endIndex);283const endIndex = (childIndex === this.children.length ? this.endIndex : this.children[childIndex].startIndex);284return new LinkedOverlayNodeGap(this._originalText, this, startIndex, endIndex, childIndex);285}286287childAt(childIndex: number): LinkedOverlayNode | null {288return this.children[childIndex] ?? null;289}290291public get firstGap(): LinkedOverlayNodeGap | null {292if (this.children.length === 0) {293// there are no gaps294return null;295}296return this.gapBeforeChild(0);297}298299public get lastGap(): LinkedOverlayNodeGap | null {300if (this.children.length === 0) {301// there are no gaps302return null;303}304return this.gapBeforeChild(this.children.length);305}306307/**308* @return The deepest node which contains the offset. If the node is a leaf, the second pair result is 0.309* If the node is not a leaf, the second pair result will be -(n+1) (or ~n, using bitwise notation),310* where n is the index of the child before which the offset lies. (i.e. offset lies between children n-1 and n)311*/312public findLeaf(offset: number): [LinkedOverlayNode, number] {313if (this.children.length === 0) {314return [this, 0];315}316317const index = binarySearch2(this.children.length, (index) => {318const child = this.children[index];319if (offset >= child.startIndex && offset <= child.endIndex) {320return 0;321} else if (offset < child.startIndex) {322return 1;323} else {324return -1;325}326});327328if (index < 0) {329return [this, index];330}331332return this.children[index].findLeaf(offset);333}334335public findLeaf2(offset: number): LinkedOverlayNodeOrGap {336const [leaf, leafChildGapIndex] = this.findLeaf(offset);337if (leafChildGapIndex < 0) {338return leaf.gapBeforeChild(~leafChildGapIndex);339}340return leaf;341}342343public get nextLeaf(): LinkedOverlayNode | null {344let currentNode: LinkedOverlayNode | null = this;345do {346const nextSibling = currentNode.nextSibling;347if (nextSibling) {348return nextSibling.leftMostLeafChild;349}350// go up until finding a next sibling351currentNode = currentNode.parent;352} while (currentNode);353return null;354}355356public get leftMostLeafChild(): LinkedOverlayNode {357let currentNode: LinkedOverlayNode = this;358while (currentNode.children.length > 0) {359currentNode = currentNode.children[0];360}361return currentNode;362}363364public get prevSibling(): LinkedOverlayNode | null {365const parent = this.parent;366const prevIndex = this.myIndex - 1;367return (parent && prevIndex >= 0) ? parent.children[prevIndex] : null;368}369370public get nextSibling(): LinkedOverlayNode | null {371const parent = this.parent;372const nextIndex = this.myIndex + 1;373return (parent && nextIndex < parent.children.length) ? parent.children[nextIndex] : null;374}375}376377/**378* Represents a gap (before the first child, between two children, or after the last child) in a `LinkedOverlayNode`.379*/380class LinkedOverlayNodeGap {381382constructor(383private readonly _originalText: string,384public readonly parent: LinkedOverlayNode,385public readonly startIndex: number,386public readonly endIndex: number,387public readonly gapIndex: number,388) {389if (this.startIndex > this.endIndex) {390throw new BugIndicatingError('Invalid gap');391}392}393394public get range(): OffsetRange {395return new OffsetRange(this.startIndex, this.endIndex);396}397398public get isFirstGapInParent(): boolean {399return this.gapIndex === 0;400}401402public get isLastGapInParent(): boolean {403return this.gapIndex === this.parent.children.length;404}405406public toString(): string {407return `NodeGap (${this.startIndex}-${this.endIndex}) ${debugstr(this._originalText.substring(this.startIndex, this.endIndex))}`;408}409410public get text(): string {411return this._originalText.substring(this.startIndex, this.endIndex);412}413414/**415* Intersects the selection with this node's range to check if there is any non-whitespace text selected from this gap.416*/417public hasSelectedContent(start: number, end: number): boolean {418const selectedGapRange = this.range.intersect(new OffsetRange(start, end));419const selectedGapText = selectedGapRange ? this._originalText.substring(selectedGapRange.start, selectedGapRange.endExclusive) : '';420return !/^\s*$/s.test(selectedGapText);421}422423public get firstNonWhitespaceIndex(): number {424let index = this.startIndex;425while (index < this.endIndex) {426const charCode = this._originalText.charCodeAt(index);427if (charCode !== CharCode.Tab && charCode !== CharCode.Space && charCode !== CharCode.LineFeed) {428return index;429}430index++;431}432return this.endIndex;433}434435public get lastNonWhitespaceIndex(): number {436let index = this.endIndex - 1;437while (index >= this.startIndex) {438const charCode = this._originalText.charCodeAt(index);439if (charCode !== CharCode.Tab && charCode !== CharCode.Space && charCode !== CharCode.LineFeed) {440return index;441}442index--;443}444return this.startIndex - 1;445}446447public get nextLeaf(): LinkedOverlayNode | null {448const nextSibling = this.parent.childAt(this.gapIndex);449return nextSibling ? nextSibling.leftMostLeafChild : this.parent.nextLeaf;450}451452}453454type LinkedOverlayNodeOrGap = LinkedOverlayNode | LinkedOverlayNodeGap;455456457