Path: blob/main/extensions/copilot/src/platform/notebook/common/alternativeNotebookTextDocument.ts
13401 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 { NotebookCell, NotebookDocument, NotebookDocumentContentChange, TextDocument, TextDocumentContentChangeEvent } from 'vscode';6import { coalesce } from '../../../util/vs/base/common/arrays';7import { findLastIdxMonotonous } from '../../../util/vs/base/common/arraysFind';8import { StringEdit } from '../../../util/vs/editor/common/core/edits/stringEdit';9import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange';10import { NotebookCellKind, Position, Range } from '../../../vscodeTypes';11import { stringEditFromTextContentChange } from '../../editing/common/edit';12import { PositionOffsetTransformer } from '../../editing/common/positionOffsetTransformer';13import { generateCellTextMarker, getBlockComment, getLineCommentStart } from './alternativeContentProvider.text';14import { EOL, summarize } from './helpers';15import { CrLfOffsetTranslator } from './offsetTranslator';161718class AlternativeNotebookCellSnapshot {19private readonly positionTransformer: PositionOffsetTransformer;20private readonly crlfTranslator: CrLfOffsetTranslator;21public readonly lineCount: number;22/** Range of the alternative cell code */23public readonly altRange: Range;24/** Last line in the actual cell code */25private readonly lastLineLength: number;26public static fromNotebookCell(cell: NotebookCell, blockComment: [string, string], lineCommentStart: string): AlternativeNotebookCellSnapshot {27const summary = summarize(cell);28const cellMarker = generateCellTextMarker(summary, lineCommentStart);29const code = cell.document.getText().replace(/\r\n|\n/g, EOL);30const prefix = cell.kind === NotebookCellKind.Markup ? `${cellMarker}${EOL}${blockComment[0]}${EOL}` : `${cellMarker}${EOL}`;31const suffix = cell.kind === NotebookCellKind.Markup ? `${EOL}${blockComment[1]}` : '';32return new AlternativeNotebookCellSnapshot(cell, blockComment, lineCommentStart, code, prefix, suffix);33}34constructor(35public readonly cell: NotebookCell,36private readonly blockComment: [string, string],37private readonly lineCommentStart: string,38private readonly code: string,39private readonly prefix: string,40private readonly suffix: string41) {42this.crlfTranslator = new CrLfOffsetTranslator(cell.document.getText(), cell.document.eol);43this.positionTransformer = new PositionOffsetTransformer(`${prefix}${code}${suffix}`);44const lastPosition = this.positionTransformer.getPosition(this.positionTransformer.getText().length);45this.altRange = new Range(0, 0, lastPosition.line, lastPosition.character);46this.lineCount = this.altRange.end.line + 1;47this.lastLineLength = this.suffix.length === 0 ? this.altRange.end.character : this.positionTransformer.getPosition(this.positionTransformer.getText().length - this.suffix.length).character;48}4950public normalizeEdits(edits: readonly TextDocumentContentChangeEvent[]): TextDocumentContentChangeEvent[] {51return edits.map(e => {52const range = this.toAltRange(e.range);53const rangeOffset = this.crlfTranslator.translate(e.rangeOffset);54const endOffset = this.crlfTranslator.translate(e.rangeOffset + e.rangeLength);55return {56range,57rangeLength: endOffset - rangeOffset,58rangeOffset,59text: e.text.replace(/\r\n|\n/g, EOL), // Normalize line endings to EOL60};61});62}6364public withTextEdit(edit: StringEdit): AlternativeNotebookCellSnapshot {65const newCode = edit.apply(this.code);66return new AlternativeNotebookCellSnapshot(this.cell, this.blockComment, this.lineCommentStart, newCode, this.prefix, this.suffix);67}6869public get altText(): string {70return this.positionTransformer.getText();71}7273public toAltOffsetRange(range: Range): OffsetRange {74const startOffset = this.toAltOffset(range.start);75const endOffset = this.toAltOffset(range.end);76return new OffsetRange(startOffset, endOffset);77}7879public toAltOffset(position: Position): number {80// Remove the lines we've added for the cell marker and block comments81const extraLinesAdded = this.cell.kind === NotebookCellKind.Markup ? 2 : 1;82return this.positionTransformer.getOffset(new Position(position.line + extraLinesAdded, position.character));83}8485public toAltRange(range: Range): Range {86// Remove the lines we've added for the cell marker and block comments87const extraLinesAdded = this.cell.kind === NotebookCellKind.Markup ? 2 : 1;88return new Range(range.start.line + extraLinesAdded, range.start.character, range.end.line + extraLinesAdded, range.end.character);89}9091public fromAltOffsetRange(offsetRange: OffsetRange): Range {92const startOffset = offsetRange.start;93const endOffset = offsetRange.endExclusive;94const startPosition = this.positionTransformer.getPosition(startOffset);95const endPosition = this.positionTransformer.getPosition(endOffset);9697// Remove the lines we've added for the cell marker and block comments98const extraLinesAddedAtStart = this.cell.kind === NotebookCellKind.Markup ? 2 : 1;99const extraLinesAddedAtEnd = this.cell.kind === NotebookCellKind.Markup ? 1 : 0;100101const startLine = Math.max(startPosition.line - extraLinesAddedAtStart, 0);102const lastLineIndex = (this.lineCount - extraLinesAddedAtEnd) - 1;103let endLine = endPosition.line;104let endLineEndColumn = endPosition.character;105if (endLine > lastLineIndex) {106endLineEndColumn = endLineEndColumn === 0 ? endLineEndColumn : -1;107endLine = lastLineIndex - extraLinesAddedAtStart;108} else {109endLine = Math.max(endPosition.line - extraLinesAddedAtStart, 0);110}111if (endLine === (lastLineIndex - extraLinesAddedAtStart)) {112if (endLineEndColumn !== 0 && endLineEndColumn === -1 || this.lastLineLength < endLineEndColumn) {113endLineEndColumn = this.lastLineLength;114}115}116// If the original start was in a line that part of the prefix, then we need to start from line 0, character 0.117const startCharacter = startPosition.line - extraLinesAddedAtStart >= 0 ? startPosition.character : 0;118return new Range(startLine, startCharacter, endLine, endLineEndColumn);119}120public fromAltRange(range: Range): Range {121// Remove the lines we've added for the cell marker and block comments122const extraLinesAdded = this.cell.kind === NotebookCellKind.Markup ? 2 : 1;123const extraLinesAddedAtEnd = this.cell.kind === NotebookCellKind.Markup ? 1 : 0;124125const startLine = Math.max(range.start.line - extraLinesAdded, 0);126const isInvalidStartLine = extraLinesAdded ? (range.start.line + 1) <= extraLinesAdded : false;127const startCharacter = isInvalidStartLine ? 0 : range.start.character;128const isEndLineInvalid = extraLinesAddedAtEnd > 0 && (range.end.line === this.lineCount - 1);129const endLine = isEndLineInvalid ? (this.lineCount - extraLinesAdded - extraLinesAddedAtEnd - 1) : Math.max(range.end.line - extraLinesAdded, 0);130const lastLineIndex = (this.lineCount - extraLinesAdded - extraLinesAddedAtEnd) - 1;131const endLineCharacter = isEndLineInvalid ? this.lastLineLength : (endLine === lastLineIndex) ? Math.min(range.end.character, this.lastLineLength) : range.end.character;132return new Range(startLine, startCharacter, endLine, endLineCharacter);133}134}135136function buildAlternativeCells<T>(cellItems: readonly T[], altCelBuilder: (cellItem: T) => AlternativeNotebookCellSnapshot) {137let lineCount = 0;138let offset = 0;139return cellItems.map(item => {140const altCell = altCelBuilder(item);141const startLine = lineCount;142const startOffset = offset;143lineCount += altCell.lineCount;144offset += altCell.altText.length + EOL.length; // EOL is added between cells145return { altCell, startLine, startOffset };146});147}148149type AltCellInfo = {150altCell: AlternativeNotebookCellSnapshot;151/** Line number at which this cell starts within the Alternative Notebook */152startLine: number;153/** Character offset at which this cell starts within the Alternative Notebook */154startOffset: number;155};156157abstract class AbstractAlternativeNotebookDocument {158private readonly cellTextDocuments = new Map<TextDocument, NotebookCell>();159public constructor(public readonly notebook: NotebookDocument,160public readonly excludeMarkdownCells: boolean,161public readonly blockComment: [string, string],162public readonly lineCommentStart: string,163public readonly cells: readonly AltCellInfo[]) {164for (const { altCell } of this.cells) {165this.cellTextDocuments.set(altCell.cell.document, altCell.cell);166}167}168169/**170* Get the cell associated with a text document.171* @param textDocument The text document to find the cell for.172* @returns The notebook cell associated with the text document, or undefined if not found.173* If a cell was inserted into the notebook and this instance hasn't been updated yet, it will return undefined.174*/175public getCell(textDocument: TextDocument): NotebookCell | undefined {176return this.cellTextDocuments.get(textDocument);177}178179public getText(range?: OffsetRange): string {180const altText = this.cells.map(cell => cell.altCell.altText).join(EOL);181return range ? range.substring(altText) : altText;182}183184public fromAltRange(range: Range): [NotebookCell, Range][] {185const firstIdx = findLastIdxMonotonous(this.cells, c => c.startLine <= range.start.line);186if (firstIdx === -1) {187return [];188}189const cells: [NotebookCell, Range][] = [];190191for (let i = firstIdx; i < this.cells.length; i++) {192const { altCell, startLine } = this.cells[i];193if (i === firstIdx) {194const cellStartLine = range.start.line - startLine;195const cellEndLine = range.end.line - startLine;196const cellEnd = cellEndLine <= (altCell.lineCount - 1) ? cellEndLine : altCell.lineCount - 1;197let cellEndChar = range.end.character;198if (cellEnd !== cellEndLine) {199cellEndChar = altCell.altRange.end.character;200}201const cellRange = new Range(cellStartLine, range.start.character, cellEnd, cellEndChar);202cells.push([altCell.cell, altCell.fromAltRange(cellRange)]);203} else if (startLine + altCell.lineCount <= range.end.line) {204const cellRange = new Range(0, 0, altCell.altRange.end.line, altCell.altRange.end.character);205cells.push([altCell.cell, altCell.fromAltRange(cellRange)]);206} else if (startLine < range.end.line) {207const cellRange = new Range(0, 0, range.end.line - startLine, range.end.character);208cells.push([altCell.cell, altCell.fromAltRange(cellRange)]);209}210}211212return cells;213}214215public fromAltOffsetRange(offsetRange: OffsetRange): [NotebookCell, Range][] {216const firstIdx = findLastIdxMonotonous(this.cells, c => c.startOffset <= offsetRange.start);217if (firstIdx === -1) {218return [];219}220const cells: [NotebookCell, Range][] = [];221222for (let i = firstIdx; i < this.cells.length; i++) {223const { altCell, startOffset } = this.cells[i];224if (i === firstIdx) {225const endOffset = offsetRange.endExclusive > (startOffset + altCell.altText.length) ? (startOffset + altCell.altText.length) : offsetRange.endExclusive;226const offset = new OffsetRange(offsetRange.start - startOffset, endOffset - startOffset);227cells.push([altCell.cell, altCell.fromAltOffsetRange(offset)]);228} else if ((startOffset + altCell.altText.length) < offsetRange.endExclusive) {229const offset = new OffsetRange(0, altCell.altText.length);230cells.push([altCell.cell, altCell.fromAltOffsetRange(offset)]);231} else if (startOffset < offsetRange.endExclusive) {232const offset = new OffsetRange(0, offsetRange.endExclusive - startOffset);233cells.push([altCell.cell, altCell.fromAltOffsetRange(offset)]);234}235}236237return cells;238}239240public toAltOffset(cell: NotebookCell, position: Position): number | undefined {241const altCell = this.cells.find(c => c.altCell.cell === cell);242if (altCell) {243return altCell.altCell.toAltOffset(position);244} else {245return undefined;246}247}248249public toAltOffsetRange(cell: NotebookCell, ranges: readonly Range[]): OffsetRange[] {250let offset = 0;251for (const { altCell } of this.cells) {252if (altCell.cell === cell) {253return ranges.map(range => {254const offsetRange = altCell.toAltOffsetRange(range);255const adjustedRange = new OffsetRange(offset + offsetRange.start, offset + offsetRange.endExclusive);256return adjustedRange;257});258} else {259offset += altCell.altText.length + EOL.length; // EOL is added between cells260}261}262return [];263}264265public toAltRange(cell: NotebookCell, ranges: readonly Range[]): Range[] {266let offset = 0;267for (const { altCell, startLine } of this.cells) {268if (altCell.cell === cell) {269return ranges.map(range => {270const altCellRange = altCell.toAltRange(range);271const adjustedRange = new Range(altCellRange.start.line + startLine, altCellRange.start.character, altCellRange.end.line + startLine, altCellRange.end.character);272return adjustedRange;273});274} else {275offset += altCell.altText.length + EOL.length; // EOL is added between cells276}277}278return [];279}280}281282export interface IAlternativeNotebookDocumentSnapshot extends AbstractAlternativeNotebookDocument {283withNotebookChanges(events: readonly NotebookDocumentContentChange[]): AlternativeNotebookDocumentSnapshot;284withCellChanges(cellTextDoc: TextDocument, edit: readonly TextDocumentContentChangeEvent[]): AlternativeNotebookDocumentSnapshot;285}286287class AlternativeNotebookDocumentSnapshot extends AbstractAlternativeNotebookDocument implements IAlternativeNotebookDocumentSnapshot {288public static create(notebook: NotebookDocument, excludeMarkdownCells: boolean): AlternativeNotebookDocumentSnapshot {289const blockComment = getBlockComment(notebook);290const lineCommentStart = getLineCommentStart(notebook);291const notebookCells = notebook.getCells().filter(cell => !excludeMarkdownCells || cell.kind !== NotebookCellKind.Markup);292const altCells = buildAlternativeCells(notebookCells, cell => AlternativeNotebookCellSnapshot.fromNotebookCell(cell, blockComment, lineCommentStart));293294return new AlternativeNotebookDocumentSnapshot(notebook, excludeMarkdownCells, blockComment, lineCommentStart, altCells);295}296constructor(notebook: NotebookDocument,297excludeMarkdownCells: boolean,298blockComment: [string, string],299lineCommentStart: string,300altCells: readonly AltCellInfo[]) {301super(notebook, excludeMarkdownCells, blockComment, lineCommentStart, altCells);302}303304public withNotebookChanges(events: readonly NotebookDocumentContentChange[]): AlternativeNotebookDocumentSnapshot {305const cells = withNotebookChangesAndEdit(this.cells, this.blockComment, this.lineCommentStart, events, this.excludeMarkdownCells)[0];306return new AlternativeNotebookDocumentSnapshot(this.notebook, this.excludeMarkdownCells, this.blockComment, this.lineCommentStart, cells);307}308309public withCellChanges(cellTextDoc: TextDocument, edit: readonly TextDocumentContentChangeEvent[]): AlternativeNotebookDocumentSnapshot {310if (edit instanceof StringEdit ? edit.isEmpty() : edit.length === 0) {311return this;312}313const [altCells,] = withCellChangesAndEdit(this.cells, cellTextDoc, edit) || [undefined, undefined] as const;314if (!altCells) {315return this;316}317return new AlternativeNotebookDocumentSnapshot(this.notebook, this.excludeMarkdownCells, this.blockComment, this.lineCommentStart, altCells);318}319}320321export interface IAlternativeNotebookDocument extends AbstractAlternativeNotebookDocument {322applyNotebookChanges(events: readonly NotebookDocumentContentChange[]): void;323applyCellChanges(cellTextDoc: TextDocument, edit: readonly TextDocumentContentChangeEvent[]): void;324}325326327class AlternativeNotebookDocument extends AbstractAlternativeNotebookDocument implements IAlternativeNotebookDocument {328public static create(notebook: NotebookDocument, excludeMarkdownCells: boolean): AlternativeNotebookDocument {329const blockComment = getBlockComment(notebook);330const lineCommentStart = getLineCommentStart(notebook);331const notebookCells = notebook.getCells().filter(cell => !excludeMarkdownCells || cell.kind !== NotebookCellKind.Markup);332const altCells = buildAlternativeCells(notebookCells, cell => AlternativeNotebookCellSnapshot.fromNotebookCell(cell, blockComment, lineCommentStart));333334return new AlternativeNotebookDocument(notebook, excludeMarkdownCells, blockComment, lineCommentStart, altCells);335}336constructor(notebook: NotebookDocument,337excludeMarkdownCells: boolean,338blockComment: [string, string],339lineCommentStart: string,340public override cells: AltCellInfo[]) {341super(notebook, excludeMarkdownCells, blockComment, lineCommentStart, cells);342}343344private updateCells(cells: readonly AltCellInfo[]) {345this.cells.splice(0, this.cells.length, ...cells);346}347public applyNotebookChanges(events: readonly NotebookDocumentContentChange[]) {348const cells = withNotebookChangesAndEdit(this.cells, this.blockComment, this.lineCommentStart, events, this.excludeMarkdownCells)[0];349this.updateCells(cells);350}351352public applyCellChanges(cellTextDoc: TextDocument, edit: readonly TextDocumentContentChangeEvent[]) {353if (edit instanceof StringEdit ? edit.isEmpty() : edit.length === 0) {354return;355}356const [cells,] = withCellChangesAndEdit(this.cells, cellTextDoc, edit) || [undefined, undefined] as const;357if (!cells) {358return;359}360this.updateCells(cells);361}362}363364function withCellChangesAndEdit(cells: readonly AltCellInfo[], cellTextDoc: TextDocument, edit: readonly TextDocumentContentChangeEvent[]) {365if (edit instanceof StringEdit ? edit.isEmpty() : edit.length === 0) {366return undefined;367}368const cell = cells.find(c => c.altCell.cell.document === cellTextDoc);369if (!cell) {370return undefined;371}372const cellEdit = edit instanceof StringEdit ? edit : stringEditFromTextContentChange(cell.altCell.normalizeEdits(edit));373const altCells = buildAlternativeCells(cells, cell => cell.altCell.cell.document === cellTextDoc ? cell.altCell.withTextEdit(cellEdit) : cell.altCell);374return [altCells, edit] as const;375}376377function withNotebookChangesAndEdit(cells: readonly AltCellInfo[], blockComment: [string, string], lineCommentStart: string, events: readonly NotebookDocumentContentChange[], excludeMarkdownCells: boolean): [readonly AltCellInfo[], StringEdit | undefined] {378if (!events.length) {379return [cells, undefined];380}381// If we've only added md cells, then its a noop.382if (events.every(e => e.removedCells.length === 0 && e.addedCells.every(c => c.kind === NotebookCellKind.Markup))) {383return [cells, undefined];384}385let altCells = cells.slice();386let edit = StringEdit.empty;387for (const event of events) {388const newCells = event.addedCells.filter(c => excludeMarkdownCells ? c.kind === NotebookCellKind.Code : true).map(cell => ({ altCell: AlternativeNotebookCellSnapshot.fromNotebookCell(cell, blockComment, lineCommentStart), startLine: 0, startOffset: 0 }));389390const removedCells = altCells.slice(event.range.start, event.range.end);391let firstUnChangedCellIndex = -1;392if (event.range.isEmpty) {393firstUnChangedCellIndex = event.range.start === 0 ? -1 : event.range.start - 1;394} else {395firstUnChangedCellIndex = event.range.start === 0 ? -1 : event.range.start - 1;396}397const startOffset = firstUnChangedCellIndex === -1 ? 0 : altCells[firstUnChangedCellIndex].startOffset + altCells[firstUnChangedCellIndex].altCell.altText.length + EOL.length;398let offsetLength = removedCells.map((cell) => cell.altCell.altText).join(EOL).length;399let newCellsContent = newCells.map((cell) => cell.altCell.altText).join(EOL);400if (startOffset !== 0) {401if (!(event.range.end < altCells.length)) {402newCellsContent = `${EOL}${newCellsContent}`;403}404}405// if we have some cells after the insertion, then we need to insert an EOL at the end.406if (event.range.end < altCells.length) {407if (newCellsContent) {408newCellsContent += EOL;409}410if (offsetLength) {411offsetLength += EOL.length;412}413}414edit = edit.compose(StringEdit.replace(new OffsetRange(startOffset, startOffset + offsetLength), newCellsContent));415416altCells.splice(event.range.start, event.range.end - event.range.start, ...newCells);417altCells = buildAlternativeCells(altCells, cell => cell.altCell);418}419420return [altCells, edit];421}422423/**424* Represents the Notebook as a alternative text (Jupytext like) document that is mutable.425* Not to be used when dealing with agents for editing or reading notebooks.426* Use only with NES or other exceptional cases.427*/428export function createAlternativeNotebookDocument(notebook: NotebookDocument, excludeMarkdownCells: boolean = true): IAlternativeNotebookDocument {429return AlternativeNotebookDocument.create(notebook, excludeMarkdownCells);430}431432/**433* Represents the Notebook as an alternative text (Jupytext like) document that is immutable.434* Not to be used when dealing with agents for editing or reading notebooks.435* Use only with NES or other exceptional cases.436*/437export function createAlternativeNotebookDocumentSnapshot(notebook: NotebookDocument, excludeMarkdownCells: boolean = true): IAlternativeNotebookDocumentSnapshot {438return AlternativeNotebookDocumentSnapshot.create(notebook, excludeMarkdownCells);439}440441export function toAltNotebookCellChangeEdit(notebook: AbstractAlternativeNotebookDocument, cellTextDocument: TextDocument, events: readonly TextDocumentContentChangeEvent[]): StringEdit {442const replacementsInApplicationOrder = toAltCellTextDocumentContentChangeEvents(notebook, cellTextDocument, events);443return stringEditFromTextContentChange(replacementsInApplicationOrder);444}445446export function toAltNotebookChangeEdit(notebook: AbstractAlternativeNotebookDocument, events: readonly NotebookDocumentContentChange[]): StringEdit | undefined {447return withNotebookChangesAndEdit(notebook.cells, notebook.blockComment, notebook.lineCommentStart, events, notebook.excludeMarkdownCells)[1];448}449450function toAltCellTextDocumentContentChangeEvents(notebook: AbstractAlternativeNotebookDocument, cellTextDocument: TextDocument, events: readonly TextDocumentContentChangeEvent[]): TextDocumentContentChangeEvent[] {451return coalesce(events.map(e => {452const cell = notebook.getCell(cellTextDocument);453if (!cell) {454return undefined;455}456const ranges = notebook.toAltRange(cell, [e.range]);457const rangeOffsets = notebook.toAltOffsetRange(cell, [e.range]);458if (!ranges.length || !rangeOffsets.length) {459return undefined;460}461const range = ranges[0];462const rangeOffset = rangeOffsets[0];463return {464range,465rangeLength: rangeOffset.endExclusive - rangeOffset.start,466rangeOffset: rangeOffset.start,467text: e.text.replace(/\r\n|\n/g, EOL), // Normalize line endings to EOL468} as typeof e;469}));470}471472473