Path: blob/main/src/vs/workbench/contrib/debug/common/replModel.ts
3296 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 { Emitter, Event } from '../../../../base/common/event.js';6import severity from '../../../../base/common/severity.js';7import { isObject, isString } from '../../../../base/common/types.js';8import { generateUuid } from '../../../../base/common/uuid.js';9import * as nls from '../../../../nls.js';10import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';11import { IDebugConfiguration, IDebugSession, IExpression, INestingReplElement, IReplElement, IReplElementSource, IStackFrame } from './debug.js';12import { ExpressionContainer } from './debugModel.js';1314let topReplElementCounter = 0;15const getUniqueId = () => `topReplElement:${topReplElementCounter++}`;1617/**18* General case of data from DAP the `output` event. {@link ReplVariableElement}19* is used instead only if there is a `variablesReference` with no `output` text.20*/21export class ReplOutputElement implements INestingReplElement {2223private _count = 1;24private _onDidChangeCount = new Emitter<void>();2526constructor(27public session: IDebugSession,28private id: string,29public value: string,30public severity: severity,31public sourceData?: IReplElementSource,32public readonly expression?: IExpression,33) {34}3536toString(includeSource = false): string {37let valueRespectCount = this.value;38for (let i = 1; i < this.count; i++) {39valueRespectCount += (valueRespectCount.endsWith('\n') ? '' : '\n') + this.value;40}41const sourceStr = (this.sourceData && includeSource) ? ` ${this.sourceData.source.name}` : '';42return valueRespectCount + sourceStr;43}4445getId(): string {46return this.id;47}4849getChildren(): Promise<IReplElement[]> {50return this.expression?.getChildren() || Promise.resolve([]);51}5253set count(value: number) {54this._count = value;55this._onDidChangeCount.fire();56}5758get count(): number {59return this._count;60}6162get onDidChangeCount(): Event<void> {63return this._onDidChangeCount.event;64}6566get hasChildren() {67return !!this.expression?.hasChildren;68}69}7071/** Top-level variable logged via DAP output when there's no `output` string */72export class ReplVariableElement implements INestingReplElement {73public readonly hasChildren: boolean;74private readonly id = generateUuid();7576constructor(77private readonly session: IDebugSession,78public readonly expression: IExpression,79public readonly severity: severity,80public readonly sourceData?: IReplElementSource,81) {82this.hasChildren = expression.hasChildren;83}8485getSession() {86return this.session;87}8889getChildren(): IReplElement[] | Promise<IReplElement[]> {90return this.expression.getChildren();91}9293toString(): string {94return this.expression.toString();95}9697getId(): string {98return this.id;99}100}101102export class RawObjectReplElement implements IExpression, INestingReplElement {103104private static readonly MAX_CHILDREN = 1000; // upper bound of children per value105106constructor(private id: string, public name: string, public valueObj: any, public sourceData?: IReplElementSource, public annotation?: string) { }107108getId(): string {109return this.id;110}111112getSession(): IDebugSession | undefined {113return undefined;114}115116get value(): string {117if (this.valueObj === null) {118return 'null';119} else if (Array.isArray(this.valueObj)) {120return `Array[${this.valueObj.length}]`;121} else if (isObject(this.valueObj)) {122return 'Object';123} else if (isString(this.valueObj)) {124return `"${this.valueObj}"`;125}126127return String(this.valueObj) || '';128}129130get hasChildren(): boolean {131return (Array.isArray(this.valueObj) && this.valueObj.length > 0) || (isObject(this.valueObj) && Object.getOwnPropertyNames(this.valueObj).length > 0);132}133134evaluateLazy(): Promise<void> {135throw new Error('Method not implemented.');136}137138getChildren(): Promise<IExpression[]> {139let result: IExpression[] = [];140if (Array.isArray(this.valueObj)) {141result = (<any[]>this.valueObj).slice(0, RawObjectReplElement.MAX_CHILDREN)142.map((v, index) => new RawObjectReplElement(`${this.id}:${index}`, String(index), v));143} else if (isObject(this.valueObj)) {144result = Object.getOwnPropertyNames(this.valueObj).slice(0, RawObjectReplElement.MAX_CHILDREN)145.map((key, index) => new RawObjectReplElement(`${this.id}:${index}`, key, this.valueObj[key]));146}147148return Promise.resolve(result);149}150151toString(): string {152return `${this.name}\n${this.value}`;153}154}155156export class ReplEvaluationInput implements IReplElement {157private id: string;158159constructor(public value: string) {160this.id = generateUuid();161}162163toString(): string {164return this.value;165}166167getId(): string {168return this.id;169}170}171172export class ReplEvaluationResult extends ExpressionContainer implements IReplElement {173private _available = true;174175get available(): boolean {176return this._available;177}178179constructor(public readonly originalExpression: string) {180super(undefined, undefined, 0, generateUuid());181}182183override async evaluateExpression(expression: string, session: IDebugSession | undefined, stackFrame: IStackFrame | undefined, context: string): Promise<boolean> {184const result = await super.evaluateExpression(expression, session, stackFrame, context);185this._available = result;186187return result;188}189190override toString(): string {191return `${this.value}`;192}193}194195export class ReplGroup implements INestingReplElement {196197private children: IReplElement[] = [];198private id: string;199private ended = false;200static COUNTER = 0;201202constructor(203public readonly session: IDebugSession,204public name: string,205public autoExpand: boolean,206public sourceData?: IReplElementSource207) {208this.id = `replGroup:${ReplGroup.COUNTER++}`;209}210211get hasChildren() {212return true;213}214215getId(): string {216return this.id;217}218219toString(includeSource = false): string {220const sourceStr = (includeSource && this.sourceData) ? ` ${this.sourceData.source.name}` : '';221return this.name + sourceStr;222}223224addChild(child: IReplElement): void {225const lastElement = this.children.length ? this.children[this.children.length - 1] : undefined;226if (lastElement instanceof ReplGroup && !lastElement.hasEnded) {227lastElement.addChild(child);228} else {229this.children.push(child);230}231}232233getChildren(): IReplElement[] {234return this.children;235}236237end(): void {238const lastElement = this.children.length ? this.children[this.children.length - 1] : undefined;239if (lastElement instanceof ReplGroup && !lastElement.hasEnded) {240lastElement.end();241} else {242this.ended = true;243}244}245246get hasEnded(): boolean {247return this.ended;248}249}250251function areSourcesEqual(first: IReplElementSource | undefined, second: IReplElementSource | undefined): boolean {252if (!first && !second) {253return true;254}255if (first && second) {256return first.column === second.column && first.lineNumber === second.lineNumber && first.source.uri.toString() === second.source.uri.toString();257}258259return false;260}261262export interface INewReplElementData {263output: string;264expression?: IExpression;265sev: severity;266source?: IReplElementSource;267}268269export class ReplModel {270private replElements: IReplElement[] = [];271private readonly _onDidChangeElements = new Emitter<IReplElement | undefined>();272readonly onDidChangeElements = this._onDidChangeElements.event;273274constructor(private readonly configurationService: IConfigurationService) { }275276getReplElements(): IReplElement[] {277return this.replElements;278}279280async addReplExpression(session: IDebugSession, stackFrame: IStackFrame | undefined, expression: string): Promise<void> {281this.addReplElement(new ReplEvaluationInput(expression));282const result = new ReplEvaluationResult(expression);283await result.evaluateExpression(expression, session, stackFrame, 'repl');284this.addReplElement(result);285}286287appendToRepl(session: IDebugSession, { output, expression, sev, source }: INewReplElementData): void {288const clearAnsiSequence = '\u001b[2J';289const clearAnsiIndex = output.lastIndexOf(clearAnsiSequence);290if (clearAnsiIndex !== -1) {291// [2J is the ansi escape sequence for clearing the display http://ascii-table.com/ansi-escape-sequences.php292this.removeReplExpressions();293this.appendToRepl(session, { output: nls.localize('consoleCleared', "Console was cleared"), sev: severity.Ignore });294output = output.substring(clearAnsiIndex + clearAnsiSequence.length);295}296297if (expression) {298// if there is an output string, prefer to show that, since the DA could299// have formatted it nicely e.g. with ANSI color codes.300this.addReplElement(output301? new ReplOutputElement(session, getUniqueId(), output, sev, source, expression)302: new ReplVariableElement(session, expression, sev, source));303return;304}305306this.appendOutputToRepl(session, output, sev, source);307}308309private appendOutputToRepl(session: IDebugSession, output: string, sev: severity, source?: IReplElementSource): void {310const config = this.configurationService.getValue<IDebugConfiguration>('debug');311const previousElement = this.replElements.length ? this.replElements[this.replElements.length - 1] : undefined;312313// Handle concatenation of incomplete lines first314if (previousElement instanceof ReplOutputElement && previousElement.severity === sev && areSourcesEqual(previousElement.sourceData, source)) {315if (!previousElement.value.endsWith('\n') && !previousElement.value.endsWith('\r\n') && previousElement.count === 1) {316// Concatenate with previous incomplete line317const combinedOutput = previousElement.value + output;318this.replElements[this.replElements.length - 1] = new ReplOutputElement(319session, getUniqueId(), combinedOutput, sev, source);320this._onDidChangeElements.fire(undefined);321322// If the combined output now forms a complete line and collapsing is enabled,323// check if it can be collapsed with previous elements324if (config.console.collapseIdenticalLines && combinedOutput.endsWith('\n')) {325this.tryCollapseCompleteLine(sev, source);326}327328// If the combined output contains multiple lines, apply line-level collapsing329if (config.console.collapseIdenticalLines && combinedOutput.includes('\n')) {330const lines = this.splitIntoLines(combinedOutput);331if (lines.length > 1) {332this.applyLineLevelCollapsing(session, sev, source);333}334}335return;336}337}338339// If collapsing is enabled and the output contains line breaks, parse and collapse at line level340if (config.console.collapseIdenticalLines && output.includes('\n')) {341this.processMultiLineOutput(session, output, sev, source);342} else {343// For simple output without line breaks, use the original logic344if (previousElement instanceof ReplOutputElement && previousElement.severity === sev && areSourcesEqual(previousElement.sourceData, source)) {345if (previousElement.value === output && config.console.collapseIdenticalLines) {346previousElement.count++;347// No need to fire an event, just the count updates and badge will adjust automatically348return;349}350}351352const element = new ReplOutputElement(session, getUniqueId(), output, sev, source);353this.addReplElement(element);354}355}356357private tryCollapseCompleteLine(sev: severity, source?: IReplElementSource): void {358// Try to collapse the last element with the second-to-last if they are identical complete lines359if (this.replElements.length < 2) {360return;361}362363const lastElement = this.replElements[this.replElements.length - 1];364const secondToLastElement = this.replElements[this.replElements.length - 2];365366if (lastElement instanceof ReplOutputElement &&367secondToLastElement instanceof ReplOutputElement &&368lastElement.severity === sev &&369secondToLastElement.severity === sev &&370areSourcesEqual(lastElement.sourceData, source) &&371areSourcesEqual(secondToLastElement.sourceData, source) &&372lastElement.value === secondToLastElement.value &&373lastElement.count === 1 &&374lastElement.value.endsWith('\n')) {375376// Collapse the last element into the second-to-last377secondToLastElement.count += lastElement.count;378this.replElements.pop();379this._onDidChangeElements.fire(undefined);380}381}382383private processMultiLineOutput(session: IDebugSession, output: string, sev: severity, source?: IReplElementSource): void {384// Split output into lines, preserving line endings385const lines = this.splitIntoLines(output);386387for (const line of lines) {388if (line.length === 0) { continue; }389390const previousElement = this.replElements.length ? this.replElements[this.replElements.length - 1] : undefined;391392// Check if this line can be collapsed with the previous one393if (previousElement instanceof ReplOutputElement &&394previousElement.severity === sev &&395areSourcesEqual(previousElement.sourceData, source) &&396previousElement.value === line) {397previousElement.count++;398// No need to fire an event, just the count updates and badge will adjust automatically399} else {400const element = new ReplOutputElement(session, getUniqueId(), line, sev, source);401this.addReplElement(element);402}403}404}405406private splitIntoLines(text: string): string[] {407// Split text into lines while preserving line endings, using indexOf for efficiency408const lines: string[] = [];409let start = 0;410411while (start < text.length) {412const nextLF = text.indexOf('\n', start);413if (nextLF === -1) {414lines.push(text.substring(start));415break;416}417lines.push(text.substring(start, nextLF + 1));418start = nextLF + 1;419}420421return lines;422}423424private applyLineLevelCollapsing(session: IDebugSession, sev: severity, source?: IReplElementSource): void {425// Apply line-level collapsing to the last element if it contains multiple lines426const lastElement = this.replElements[this.replElements.length - 1];427if (!(lastElement instanceof ReplOutputElement) || lastElement.severity !== sev || !areSourcesEqual(lastElement.sourceData, source)) {428return;429}430431const lines = this.splitIntoLines(lastElement.value);432if (lines.length <= 1) {433return; // No multiple lines to collapse434}435436// Remove the last element and reprocess it as multiple lines437this.replElements.pop();438439// Process each line and try to collapse with existing elements440for (const line of lines) {441if (line.length === 0) { continue; }442443const previousElement = this.replElements.length ? this.replElements[this.replElements.length - 1] : undefined;444445// Check if this line can be collapsed with the previous one446if (previousElement instanceof ReplOutputElement &&447previousElement.severity === sev &&448areSourcesEqual(previousElement.sourceData, source) &&449previousElement.value === line) {450previousElement.count++;451} else {452const element = new ReplOutputElement(session, getUniqueId(), line, sev, source);453this.addReplElement(element);454}455}456457this._onDidChangeElements.fire(undefined);458}459460startGroup(session: IDebugSession, name: string, autoExpand: boolean, sourceData?: IReplElementSource): void {461const group = new ReplGroup(session, name, autoExpand, sourceData);462this.addReplElement(group);463}464465endGroup(): void {466const lastElement = this.replElements[this.replElements.length - 1];467if (lastElement instanceof ReplGroup) {468lastElement.end();469}470}471472private addReplElement(newElement: IReplElement): void {473const lastElement = this.replElements.length ? this.replElements[this.replElements.length - 1] : undefined;474if (lastElement instanceof ReplGroup && !lastElement.hasEnded) {475lastElement.addChild(newElement);476} else {477this.replElements.push(newElement);478const config = this.configurationService.getValue<IDebugConfiguration>('debug');479if (this.replElements.length > config.console.maximumLines) {480this.replElements.splice(0, this.replElements.length - config.console.maximumLines);481}482}483this._onDidChangeElements.fire(newElement);484}485486removeReplExpressions(): void {487if (this.replElements.length > 0) {488this.replElements = [];489this._onDidChangeElements.fire(undefined);490}491}492493/** Returns a new REPL model that's a copy of this one. */494clone() {495const newRepl = new ReplModel(this.configurationService);496newRepl.replElements = this.replElements.slice();497return newRepl;498}499}500501502