diff --git a/extensions/git/src/decorators.ts b/extensions/git/src/decorators.ts index b1a25d4fd919a..f89ff2327e95f 100644 --- a/extensions/git/src/decorators.ts +++ b/extensions/git/src/decorators.ts @@ -98,4 +98,4 @@ export function debounce(delay: number): Function { this[timerKey] = setTimeout(() => fn.apply(this, args), delay); }; }); -} \ No newline at end of file +} diff --git a/src/vs/base/common/codecs/asyncDecoder.ts b/src/vs/base/common/codecs/asyncDecoder.ts new file mode 100644 index 0000000000000..0a024d2e7d094 --- /dev/null +++ b/src/vs/base/common/codecs/asyncDecoder.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../lifecycle.js'; +import { BaseDecoder } from './baseDecoder.js'; + +/** + * Asynchronous interator wrapper for a decoder. + */ +export class AsyncDecoder, K extends NonNullable = NonNullable> extends Disposable { + // Buffer of messages that have been decoded but not yet consumed. + private readonly messages: T[] = []; + + /** + * A transient promise that is resolved when a new event + * is received. Used in the situation when there is no new + * data avaialble and decoder stream did not finish yet, + * hence we need to wait until new event is received. + */ + private resolveOnNewEvent?: (value: void) => void; + + /** + * @param decoder The decoder instance to wrap. + */ + constructor( + private readonly decoder: BaseDecoder, + ) { + super(); + } + + /** + * Async iterator implementation. + */ + async *[Symbol.asyncIterator](): AsyncIterator { + // callback is called when `data` or `end` event is received + const callback = (data?: T) => { + if (data !== undefined) { + this.messages.push(data); + } else { + this.decoder.removeListener('data', callback); + this.decoder.removeListener('end', callback); + } + + // is the promise resolve callback is present, + // then call it and remove the reference + if (this.resolveOnNewEvent) { + this.resolveOnNewEvent(); + delete this.resolveOnNewEvent; + } + }; + this.decoder.on('data', callback); + this.decoder.on('end', callback); + + // start flowing the decoder stream + this.decoder.start(); + + while (true) { + const maybeMessage = this.messages.shift(); + if (maybeMessage !== undefined) { + yield maybeMessage; + continue; + } + + // if no data and stream ended, so we're done + if (this.decoder.isEnded) { + return null; + } + + // stream isn't ended so wait for the new + // `data` or `end` event to be received + await new Promise((resolve) => { + this.resolveOnNewEvent = resolve; + }); + } + } +} diff --git a/src/vs/base/common/codecs/baseDecoder.ts b/src/vs/base/common/codecs/baseDecoder.ts new file mode 100644 index 0000000000000..53612a47cc50a --- /dev/null +++ b/src/vs/base/common/codecs/baseDecoder.ts @@ -0,0 +1,318 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assert } from '../assert.js'; +import { Emitter } from '../event.js'; +import { ReadableStream } from '../stream.js'; +import { AsyncDecoder } from './asyncDecoder.js'; +import { Disposable, IDisposable } from '../lifecycle.js'; +import { TStreamListenerNames } from './types/TStreamListenerEventNames.js'; + +/** + * Base decoder class that can be used to convert stream messages data type + * from one type to another. For instance, a stream of binary data can be + * "decoded" into a stream of well defined objects. + * Intended to be a part of "codec" implementation rather than used directly. + */ +export abstract class BaseDecoder< + T extends NonNullable, + K extends NonNullable = NonNullable, +> extends Disposable implements ReadableStream { + /** + * Flag that indicates if the decoder stream has ended. + */ + protected ended = false; + + protected readonly _onData = this._register(new Emitter()); + protected readonly _onEnd = this._register(new Emitter()); + protected readonly _onError = this._register(new Emitter()); + + /** + * A store of currently registered event listeners. + */ + private readonly _listeners: Map> = new Map(); + + /** + * @param stream The input stream to decode. + */ + constructor( + protected readonly stream: ReadableStream, + ) { + super(); + + this.tryOnStreamData = this.tryOnStreamData.bind(this); + this.onStreamError = this.onStreamError.bind(this); + this.onStreamEnd = this.onStreamEnd.bind(this); + } + + /** + * This method is called when a new incomming data + * is received from the input stream. + */ + protected abstract onStreamData(data: K): void; + + /** + * Start receiveing data from the stream. + * @throws if the decoder stream has already ended. + */ + public start(): this { + assert( + !this.ended, + 'Cannot start stream that has already ended.', + ); + + this.stream.on('data', this.tryOnStreamData); + this.stream.on('error', this.onStreamError); + this.stream.on('end', this.onStreamEnd); + + // this allows to compose decoders together, - if a decoder + // instance is passed as a readble stream to this decoder, + // then we need to call `start` on it too + if (this.stream instanceof BaseDecoder) { + this.stream.start(); + } + + return this; + } + + /** + * Check if the decoder has been ended hence has + * no more data to produce. + */ + public get isEnded(): boolean { + return this.ended; + } + + /** + * Automatically catch and dispatch errors thrown inside `onStreamData`. + */ + private tryOnStreamData(data: K): void { + try { + this.onStreamData(data); + } catch (error) { + this.onStreamError(error); + } + } + + public on(event: 'data', callback: (data: T) => void): void; + public on(event: 'error', callback: (err: Error) => void): void; + public on(event: 'end', callback: () => void): void; + public on(event: TStreamListenerNames, callback: unknown): void { + if (event === 'data') { + return this.onData(callback as (data: T) => void); + } + + if (event === 'error') { + return this.onError(callback as (error: Error) => void); + } + + if (event === 'end') { + return this.onEnd(callback as () => void); + } + + throw new Error(`Invalid event name: ${event}`); + } + + /** + * Add listener for the `data` event. + * @throws if the decoder stream has already ended. + */ + public onData(callback: (data: T) => void): void { + assert( + !this.ended, + 'Cannot subscribe to the `data` event because the decoder stream has already ended.', + ); + + let currentListeners = this._listeners.get('data'); + + if (!currentListeners) { + currentListeners = new Map(); + this._listeners.set('data', currentListeners); + } + + currentListeners.set(callback, this._onData.event(callback)); + } + + /** + * Add listener for the `error` event. + * @throws if the decoder stream has already ended. + */ + public onError(callback: (error: Error) => void): void { + assert( + !this.ended, + 'Cannot subscribe to the `error` event because the decoder stream has already ended.', + ); + + let currentListeners = this._listeners.get('error'); + + if (!currentListeners) { + currentListeners = new Map(); + this._listeners.set('error', currentListeners); + } + + currentListeners.set(callback, this._onError.event(callback)); + } + + /** + * Add listener for the `end` event. + * @throws if the decoder stream has already ended. + */ + public onEnd(callback: () => void): void { + assert( + !this.ended, + 'Cannot subscribe to the `end` event because the decoder stream has already ended.', + ); + + let currentListeners = this._listeners.get('end'); + + if (!currentListeners) { + currentListeners = new Map(); + this._listeners.set('end', currentListeners); + } + + currentListeners.set(callback, this._onEnd.event(callback)); + } + + /** + * Remove all existing event listeners. + */ + public removeAllListeners(): void { + // remove listeners set up by this class + this.stream.removeListener('data', this.tryOnStreamData); + this.stream.removeListener('error', this.onStreamError); + this.stream.removeListener('end', this.onStreamEnd); + + // remove listeners set up by external consumers + for (const [name, listeners] of this._listeners.entries()) { + this._listeners.delete(name); + for (const [listener, disposable] of listeners) { + disposable.dispose(); + listeners.delete(listener); + } + } + } + + /** + * Pauses the stream. + */ + public pause(): void { + this.stream.pause(); + } + + /** + * Resumes the stream if it has been paused. + * @throws if the decoder stream has already ended. + */ + public resume(): void { + assert( + !this.ended, + 'Cannot resume the stream because it has already ended.', + ); + + this.stream.resume(); + } + + /** + * Destroys(disposes) the stream. + */ + public destroy(): void { + this.dispose(); + } + + /** + * Removes a priorly-registered event listener for a specified event. + * + * Note! + * - the callback function must be the same as the one that was used when + * registering the event listener as it is used as an identifier to + * remove the listener + * - this method is idempotent and results in no-op if the listener is + * not found, therefore passing incorrect `callback` function may + * result in silent unexpected behaviour + */ + public removeListener(event: string, callback: Function): void { + for (const [nameName, listeners] of this._listeners.entries()) { + if (nameName !== event) { + continue; + } + + for (const [listener, disposable] of listeners) { + if (listener !== callback) { + continue; + } + + disposable.dispose(); + listeners.delete(listener); + } + } + } + + /** + * This method is called when the input stream ends. + */ + protected onStreamEnd(): void { + if (this.ended) { + return; + } + + this.ended = true; + this._onEnd.fire(); + } + + /** + * This method is called when the input stream emits an error. + * We re-emit the error here by default, but subclasses can + * override this method to handle the error differently. + */ + protected onStreamError(error: Error): void { + this._onError.fire(error); + } + + /** + * Consume all messages from the stream, blocking until the stream finishes. + * @throws if the decoder stream has already ended. + */ + public async consumeAll(): Promise { + assert( + !this.ended, + 'Cannot consume all messages of the stream that has already ended.', + ); + + const messages = []; + + for await (const maybeMessage of this) { + if (maybeMessage === null) { + break; + } + + messages.push(maybeMessage); + } + + return messages; + } + + /** + * Async iterator interface for the decoder. + * @throws if the decoder stream has already ended. + */ + [Symbol.asyncIterator](): AsyncIterator { + assert( + !this.ended, + 'Cannot iterate on messages of the stream that has already ended.', + ); + + const asyncDecoder = this._register(new AsyncDecoder(this)); + + return asyncDecoder[Symbol.asyncIterator](); + } + + public override dispose(): void { + this.onStreamEnd(); + + this.stream.destroy(); + this.removeAllListeners(); + super.dispose(); + } +} diff --git a/src/vs/base/common/codecs/types/ICodec.d.ts b/src/vs/base/common/codecs/types/ICodec.d.ts new file mode 100644 index 0000000000000..c949b45089d0f --- /dev/null +++ b/src/vs/base/common/codecs/types/ICodec.d.ts @@ -0,0 +1,22 @@ +import { ReadableStream } from '../../stream.js'; + +/** + * A codec is an object capable of encoding/decoding a stream of data transforming its messages. + * Useful for abstracting a data transfer or protocol logic on top of a stream of bytes. + * + * For instance, if protocol messages need to be trasferred over `TCP` connection, a codec that + * encodes the messages into a sequence of bytes before sending it to a network socket. Likewise, + * on the other end of the connection, the same codec can decode the sequence of bytes back into + * a sequence of the protocol messages. + */ +export interface ICodec { + /** + * Encode a readable stream of `T`s into a readable stream of `K`s. + */ + encode: (value: ReadableStream) => ReadableStream; + + /** + * Encode a readable stream of `T`s into a readable stream of `K`s. + */ + decode: (value: ReadableStream) => ReadableStream; +} diff --git a/src/vs/base/common/codecs/types/TStreamListenerEventNames.d.ts b/src/vs/base/common/codecs/types/TStreamListenerEventNames.d.ts new file mode 100644 index 0000000000000..f0b56f309c6f9 --- /dev/null +++ b/src/vs/base/common/codecs/types/TStreamListenerEventNames.d.ts @@ -0,0 +1,4 @@ +/** + * Event names of {@link ReadableStream} stream. + */ +export type TStreamListenerNames = 'data' | 'error' | 'end'; diff --git a/src/vs/base/common/numbers.ts b/src/vs/base/common/numbers.ts index ab4c9f92e0629..594fcf63545db 100644 --- a/src/vs/base/common/numbers.ts +++ b/src/vs/base/common/numbers.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { assert } from './assert.js'; + export function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } @@ -96,3 +98,63 @@ export function isPointWithinTriangle( return u >= 0 && v >= 0 && u + v < 1; } + +/** + * Function to get a (pseudo)random integer from a provided `max`...[`min`] range. + * Both `min` and `max` values are inclusive. The `min` value is optional and defaults + * to `0` if not explicitely specified. + * + * @throws in the next cases: + * - if provided `min` or `max` is not a number + * - if provided `min` or `max` is not finite + * - if provided `min` is larger than `max` value + * + * ## Examples + * + * Specifying a `max` value only uses `0` as the `min` value by default: + * + * ```typescript + * // get a random integer between 0 and 10 + * const randomInt = randomInt(10); + * + * assert( + * randomInt >= 0, + * 'Should be greater than or equal to 0.', + * ); + * + * assert( + * randomInt <= 10, + * 'Should be less than or equal to 10.', + * ); + * ``` + * * Specifying both `max` and `min` values: + * + * ```typescript + * // get a random integer between 5 and 8 + * const randomInt = randomInt(8, 5); + * + * assert( + * randomInt >= 5, + * 'Should be greater than or equal to 5.', + * ); + * + * assert( + * randomInt <= 8, + * 'Should be less than or equal to 8.', + * ); + * ``` + */ +export const randomInt = (max: number, min: number = 0): number => { + assert(!isNaN(min), '"min" param is not a number.'); + assert(!isNaN(max), '"max" param is not a number.'); + + assert(isFinite(max), '"max" param is not finite.'); + assert(isFinite(min), '"min" param is not finite.'); + + assert(max > min, `"max"(${max}) param should be greater than "min"(${min}).`); + + const delta = max - min; + const randomFloat = delta * Math.random(); + + return Math.round(min + randomFloat); +}; diff --git a/src/vs/base/common/stream.ts b/src/vs/base/common/stream.ts index 7d990dfcab84f..3a9f913914e9b 100644 --- a/src/vs/base/common/stream.ts +++ b/src/vs/base/common/stream.ts @@ -186,7 +186,7 @@ export interface ITransformer { error?: IErrorTransformer; } -export function newWriteableStream(reducer: IReducer, options?: WriteableStreamOptions): WriteableStream { +export function newWriteableStream(reducer: IReducer | null, options?: WriteableStreamOptions): WriteableStream { return new WriteableStreamImpl(reducer, options); } @@ -221,7 +221,16 @@ class WriteableStreamImpl implements WriteableStream { private readonly pendingWritePromises: Function[] = []; - constructor(private reducer: IReducer, private options?: WriteableStreamOptions) { } + /** + * @param reducer a function that reduces the buffered data into a single object; + * because some objects can be complex and non-reducible, we also + * allow passing the explicit `null` value to skip the reduce step + * @param options stream options + */ + constructor( + private reducer: IReducer | null, + private options?: WriteableStreamOptions, + ) { } pause(): void { if (this.state.destroyed) { @@ -396,18 +405,30 @@ class WriteableStreamImpl implements WriteableStream { } private flowData(): void { - if (this.buffer.data.length > 0) { + // if buffer is empty, nothing to do + if (this.buffer.data.length === 0) { + return; + } + + // if buffer data can be reduced into a single object, + // emit the reduced data + if (typeof this.reducer === 'function') { const fullDataBuffer = this.reducer(this.buffer.data); this.emitData(fullDataBuffer); + } else { + // otherwise emit each buffered data instance individually + for (const data of this.buffer.data) { + this.emitData(data); + } + } - this.buffer.data.length = 0; + this.buffer.data.length = 0; - // When the buffer is empty, resolve all pending writers - const pendingWritePromises = [...this.pendingWritePromises]; - this.pendingWritePromises.length = 0; - pendingWritePromises.forEach(pendingWritePromise => pendingWritePromise()); - } + // when the buffer is empty, resolve all pending writers + const pendingWritePromises = [...this.pendingWritePromises]; + this.pendingWritePromises.length = 0; + pendingWritePromises.forEach(pendingWritePromise => pendingWritePromise()); } private flowErrors(): void { diff --git a/src/vs/base/common/types.ts b/src/vs/base/common/types.ts index 6ad28eaece005..54edfafe71fe5 100644 --- a/src/vs/base/common/types.ts +++ b/src/vs/base/common/types.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { assert } from './assert.js'; + /** * @returns whether the provided parameter is a JavaScript String or not. */ @@ -93,15 +95,52 @@ export function assertType(condition: unknown, type?: string): asserts condition /** * Asserts that the argument passed in is neither undefined nor null. + * + * @see {@link assertDefined} for a similar utility that leverages TS assertion functions to narrow down the type of `arg` to be non-nullable. */ -export function assertIsDefined(arg: T | null | undefined): T { - if (isUndefinedOrNull(arg)) { - throw new Error('Assertion Failed: argument is undefined or null'); - } +export function assertIsDefined(arg: T | null | undefined): NonNullable { + assert( + arg !== null && arg !== undefined, + 'Argument is `undefined` or `null`.', + ); return arg; } +/** + * Asserts that a provided `value` is `defined` - not `null` or `undefined`, + * throwing an error with the provided error or error message, while also + * narrowing down the type of the `value` to be `NonNullable` using TS + * assertion functions. + * + * @throws if the provided `value` is `null` or `undefined`. + * + * ## Examples + * + * ```typescript + * // an assert with an error message + * assertDefined('some value', 'String constant is not defined o_O.'); + * + * // `throws!` the provided error + * assertDefined(null, new Error('Should throw this error.')); + * + * // narrows down the type of `someValue` to be non-nullable + * const someValue: string | undefined | null = blackbox(); + * assertDefined(someValue, 'Some value must be defined.'); + * console.log(someValue.length); // now type of `someValue` is `string` + * ``` + * + * @see {@link assertIsDefined} for a similar utility but without assertion. + * @see {@link https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions typescript-3-7.html#assertion-functions} + */ +export function assertDefined(value: T, error: string | NonNullable): asserts value is NonNullable { + if (value === null || value === undefined) { + const errorToThrow = typeof error === 'string' ? new Error(error) : error; + + throw errorToThrow; + } +} + /** * Asserts that each argument passed in is neither undefined nor null. */ diff --git a/src/vs/base/test/common/numbers.test.ts b/src/vs/base/test/common/numbers.test.ts index ac544e76dfb79..7916bf6710d7e 100644 --- a/src/vs/base/test/common/numbers.test.ts +++ b/src/vs/base/test/common/numbers.test.ts @@ -3,9 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as assert from 'assert'; -import { isPointWithinTriangle } from '../../common/numbers.js'; +import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; +import { isPointWithinTriangle, randomInt } from '../../common/numbers.js'; suite('isPointWithinTriangle', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -25,3 +25,176 @@ suite('isPointWithinTriangle', () => { assert.ok(result); }); }); + +suite('randomInt', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + /** + * Test helper that allows to run a test on the `randomInt()` + * utility with specified `max` and `min` values. + */ + const testRandomIntUtil = (max: number, min: number | undefined, testName: string) => { + suite(testName, () => { + let i = 0; + while (++i < 5) { + test(`should generate random boolean attempt#${i}`, async () => { + let iterations = 100; + while (iterations-- > 0) { + const int = randomInt(max, min); + + assert( + int <= max, + `Expected ${int} to be less than or equal to ${max}.` + ); + assert( + int >= (min ?? 0), + `Expected ${int} to be greater than or equal to ${min ?? 0}.`, + ); + } + }); + } + + test(`should include min and max`, async () => { + let iterations = 100; + const results = []; + while (iterations-- > 0) { + results.push(randomInt(max, min)); + } + + assert( + results.includes(max), + `Expected ${results} to include ${max}.`, + ); + assert( + results.includes(min ?? 0), + `Expected ${results} to include ${min ?? 0}.`, + ); + }); + }); + }; + + suite('positive numbers', () => { + testRandomIntUtil(5, 2, 'max: 5, min: 2'); + testRandomIntUtil(5, 0, 'max: 5, min: 0'); + testRandomIntUtil(5, undefined, 'max: 5, min: undefined'); + testRandomIntUtil(1, 0, 'max: 0, min: 0'); + }); + + suite('negative numbers', () => { + testRandomIntUtil(-2, -5, 'max: -2, min: -5'); + testRandomIntUtil(0, -5, 'max: 0, min: -5'); + testRandomIntUtil(0, -1, 'max: 0, min: -1'); + }); + + suite('split numbers', () => { + testRandomIntUtil(3, -1, 'max: 3, min: -1'); + testRandomIntUtil(2, -2, 'max: 2, min: -2'); + testRandomIntUtil(1, -3, 'max: 2, min: -2'); + }); + + suite('errors', () => { + test('should throw if "min" is == "max" #1', () => { + assert.throws(() => { + randomInt(200, 200); + }, `"max"(200) param should be greater than "min"(200)."`); + }); + + test('should throw if "min" is == "max" #2', () => { + assert.throws(() => { + randomInt(2, 2); + }, `"max"(2) param should be greater than "min"(2)."`); + }); + + test('should throw if "min" is == "max" #3', () => { + assert.throws(() => { + randomInt(0); + }, `"max"(0) param should be greater than "min"(0)."`); + }); + + test('should throw if "min" is > "max" #1', () => { + assert.throws(() => { + randomInt(2, 3); + }, `"max"(2) param should be greater than "min"(3)."`); + }); + + test('should throw if "min" is > "max" #2', () => { + assert.throws(() => { + randomInt(999, 2000); + }, `"max"(999) param should be greater than "min"(2000)."`); + }); + + test('should throw if "min" is > "max" #3', () => { + assert.throws(() => { + randomInt(0, 1); + }, `"max"(0) param should be greater than "min"(1)."`); + }); + + test('should throw if "min" is > "max" #4', () => { + assert.throws(() => { + randomInt(-5, 2); + }, `"max"(-5) param should be greater than "min"(2)."`); + }); + + test('should throw if "min" is > "max" #5', () => { + assert.throws(() => { + randomInt(-5, 0); + }, `"max"(-5) param should be greater than "min"(0)."`); + }); + + test('should throw if "min" is > "max" #6', () => { + assert.throws(() => { + randomInt(-5); + }, `"max"(-5) param should be greater than "min"(0)."`); + }); + + test('should throw if "max" is `NaN`', () => { + assert.throws(() => { + randomInt(NaN); + }, `"max" param is not a number."`); + }); + + test('should throw if "min" is `NaN`', () => { + assert.throws(() => { + randomInt(5, NaN); + }, `"min" param is not a number."`); + }); + + suite('infinite arguments', () => { + test('should throw if "max" is infinite [Infinity]', () => { + assert.throws(() => { + randomInt(Infinity); + }, `"max" param is not finite."`); + }); + + test('should throw if "max" is infinite [-Infinity]', () => { + assert.throws(() => { + randomInt(-Infinity); + }, `"max" param is not finite."`); + }); + + test('should throw if "max" is infinite [+Infinity]', () => { + assert.throws(() => { + randomInt(+Infinity); + }, `"max" param is not finite."`); + }); + + test('should throw if "min" is infinite [Infinity]', () => { + assert.throws(() => { + randomInt(Infinity, Infinity); + }, `"max" param is not finite."`); + }); + + test('should throw if "min" is infinite [-Infinity]', () => { + assert.throws(() => { + randomInt(Infinity, -Infinity); + }, `"max" param is not finite."`); + }); + + test('should throw if "min" is infinite [+Infinity]', () => { + assert.throws(() => { + randomInt(Infinity, +Infinity); + }, `"max" param is not finite."`); + }); + }); + }); +}); diff --git a/src/vs/base/test/common/stream.test.ts b/src/vs/base/test/common/stream.test.ts index b25769c24153a..83e81e93aaece 100644 --- a/src/vs/base/test/common/stream.test.ts +++ b/src/vs/base/test/common/stream.test.ts @@ -5,10 +5,10 @@ import assert from 'assert'; import { timeout } from '../../common/async.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; import { bufferToReadable, VSBuffer } from '../../common/buffer.js'; import { CancellationTokenSource } from '../../common/cancellation.js'; import { consumeReadable, consumeStream, isReadable, isReadableBufferedStream, isReadableStream, listenStream, newWriteableStream, peekReadable, peekStream, prefixedReadable, prefixedStream, Readable, ReadableStream, toReadable, toStream, transform } from '../../common/stream.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; suite('Stream', () => { @@ -91,6 +91,104 @@ suite('Stream', () => { assert.strictEqual(chunks.length, 4); }); + test('stream with non-reducible messages', () => { + /** + * A complex object that cannot be reduced to a single object. + */ + class TestMessage { + constructor(public value: string) { } + } + + const stream = newWriteableStream(null); + + let error = false; + stream.on('error', e => { + error = true; + }); + + let end = false; + stream.on('end', () => { + end = true; + }); + + stream.write(new TestMessage('Hello')); + + const chunks: TestMessage[] = []; + stream.on('data', data => { + chunks.push(data); + }); + + assert( + chunks[0] instanceof TestMessage, + 'Message `0` must be an instance of `TestMessage`.', + ); + assert.strictEqual(chunks[0].value, 'Hello'); + + stream.write(new TestMessage('World')); + + assert( + chunks[1] instanceof TestMessage, + 'Message `1` must be an instance of `TestMessage`.', + ); + assert.strictEqual(chunks[1].value, 'World'); + + assert.strictEqual(error, false); + assert.strictEqual(end, false); + + stream.pause(); + stream.write(new TestMessage('1')); + stream.write(new TestMessage('2')); + stream.write(new TestMessage('3')); + + assert.strictEqual(chunks.length, 2); + + stream.resume(); + + assert.strictEqual(chunks.length, 5); + + assert( + chunks[2] instanceof TestMessage, + 'Message `2` must be an instance of `TestMessage`.', + ); + assert.strictEqual(chunks[2].value, '1'); + + assert( + chunks[3] instanceof TestMessage, + 'Message `3` must be an instance of `TestMessage`.', + ); + assert.strictEqual(chunks[3].value, '2'); + + assert( + chunks[4] instanceof TestMessage, + 'Message `4` must be an instance of `TestMessage`.', + ); + assert.strictEqual(chunks[4].value, '3'); + + stream.error(new Error()); + assert.strictEqual(error, true); + + error = false; + stream.error(new Error()); + assert.strictEqual(error, true); + + stream.end(new TestMessage('Final Bit')); + assert.strictEqual(chunks.length, 6); + + assert( + chunks[5] instanceof TestMessage, + 'Message `5` must be an instance of `TestMessage`.', + ); + assert.strictEqual(chunks[5].value, 'Final Bit'); + + + assert.strictEqual(end, true); + + stream.destroy(); + + stream.write(new TestMessage('Unexpected')); + assert.strictEqual(chunks.length, 6); + }); + test('WriteableStream - end with empty string works', async () => { const reducer = (strings: string[]) => strings.length > 0 ? strings.join() : 'error'; const stream = newWriteableStream(reducer); diff --git a/src/vs/base/test/common/types.test.ts b/src/vs/base/test/common/types.test.ts index 8f6eba0d0f32a..da00553920108 100644 --- a/src/vs/base/test/common/types.test.ts +++ b/src/vs/base/test/common/types.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import * as types from '../../common/types.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; +import { assertDefined } from '../../common/types.js'; suite('Types', () => { @@ -174,6 +175,135 @@ suite('Types', () => { assert.strictEqual(res[2], 'Hello'); }); + suite('assertDefined', () => { + test('should not throw if `value` is defined (bool)', async () => { + assert.doesNotThrow(function () { + assertDefined(true, 'Oops something happened.'); + }); + }); + + test('should not throw if `value` is defined (number)', async () => { + assert.doesNotThrow(function () { + assertDefined(5, 'Oops something happened.'); + }); + }); + + test('should not throw if `value` is defined (zero)', async () => { + assert.doesNotThrow(function () { + assertDefined(0, 'Oops something happened.'); + }); + }); + + test('should not throw if `value` is defined (string)', async () => { + assert.doesNotThrow(function () { + assertDefined('some string', 'Oops something happened.'); + }); + }); + + test('should not throw if `value` is defined (empty string)', async () => { + assert.doesNotThrow(function () { + assertDefined('', 'Oops something happened.'); + }); + }); + + /** + * Note! API of `assert.throws()` is different in the browser + * and in Node.js, and it is not possible to use the same code + * here. Therefore we had to resort to the manual try/catch. + */ + const assertThrows = ( + testFunction: () => void, + errorMessage: string, + ) => { + let thrownError: Error | undefined; + + try { + testFunction(); + } catch (e) { + thrownError = e as Error; + } + + assertDefined(thrownError, 'Must throw an error.'); + assert( + thrownError instanceof Error, + 'Error must be an instance of `Error`.', + ); + + assert.strictEqual( + thrownError.message, + errorMessage, + 'Error must have correct message.', + ); + }; + + test('should throw if `value` is `null`', async () => { + const errorMessage = 'Uggh ohh!'; + assertThrows(() => { + assertDefined(null, errorMessage); + }, errorMessage); + }); + + test('should throw if `value` is `undefined`', async () => { + const errorMessage = 'Oh no!'; + assertThrows(() => { + assertDefined(undefined, new Error(errorMessage)); + }, errorMessage); + }); + + test('should throw assertion error by default', async () => { + const errorMessage = 'Uggh ohh!'; + let thrownError: Error | undefined; + try { + assertDefined(null, errorMessage); + } catch (e) { + thrownError = e as Error; + } + + assertDefined(thrownError, 'Must throw an error.'); + + assert( + thrownError instanceof Error, + 'Error must be an instance of `Error`.', + ); + + assert.strictEqual( + thrownError.message, + errorMessage, + 'Error must have correct message.', + ); + }); + + test('should throw provided error instance', async () => { + class TestError extends Error { + constructor(...args: ConstructorParameters) { + super(...args); + + this.name = 'TestError'; + } + } + + const errorMessage = 'Oops something hapenned.'; + const error = new TestError(errorMessage); + + let thrownError; + try { + assertDefined(null, error); + } catch (e) { + thrownError = e; + } + + assert( + thrownError instanceof TestError, + 'Error must be an instance of `TestError`.', + ); + assert.strictEqual( + thrownError.message, + errorMessage, + 'Error must have correct message.', + ); + }); + }); + test('validateConstraints', () => { types.validateConstraints([1, 'test', true], [Number, String, Boolean]); types.validateConstraints([1, 'test', true], ['number', 'string', 'boolean']); diff --git a/src/vs/editor/common/codecs/baseToken.ts b/src/vs/editor/common/codecs/baseToken.ts new file mode 100644 index 0000000000000..b25fa2184899f --- /dev/null +++ b/src/vs/editor/common/codecs/baseToken.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from '../../../editor/common/core/range.js'; + +/** + * Base class for all tokens with a `range` that + * reflects token position in the original data. + */ +export abstract class BaseToken { + constructor( + public readonly range: Range, + ) { } + + /** + * Check if this token has the same range as another one. + */ + public sameRange(other: Range): boolean { + return this.range.equalsRange(other); + } + + /** + * Returns a string representation of the token. + */ + public abstract toString(): string; + + /** + * Check if this token is equal to another one. + */ + public equals(other: T): boolean { + if (!(other instanceof this.constructor)) { + return false; + } + + return this.sameRange(other.range); + } +} diff --git a/src/vs/editor/common/codecs/linesCodec/linesDecoder.ts b/src/vs/editor/common/codecs/linesCodec/linesDecoder.ts new file mode 100644 index 0000000000000..ab22fd7b8ed93 --- /dev/null +++ b/src/vs/editor/common/codecs/linesCodec/linesDecoder.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Line, NewLine } from './tokens/index.js'; +import { assert } from '../../../../base/common/assert.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { assertDefined } from '../../../../base/common/types.js'; +import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; + +/** + * Tokens produced by the `LinesDecoder`. + */ +export type TLineToken = Line | NewLine; + +/** + * The `decoder` part of the `LinesCodec` and is able to transform + * data from a binary stream into a stream of text lines(`Line`). + */ +export class LinesDecoder extends BaseDecoder { + /** + * Buffered received data yet to be processed. + */ + private buffer: VSBuffer = VSBuffer.alloc(0); + + /** + * The last emitted `Line` token, if any. The value is used + * to correctly emit remaining line range in the `onStreamEnd` + * method when underlying input stream ends and `buffer` still + * contains some data that must be emitted as the last line. + */ + private lastEmittedLine?: Line; + + /** + * Process data received from the input stream. + */ + protected override onStreamData(chunk: VSBuffer): void { + this.buffer = VSBuffer.concat([this.buffer, chunk]); + + this.processData(false); + } + + /** + * Process buffered data. + * + * @param streamEnded Flag that indicates if the input stream has ended, + * which means that is the last call of this method. + * @throws If internal logic implementation error is detected. + */ + private processData( + streamEnded: boolean, + ) { + // iterate over each line of the data buffer, emitting each line + // as a `Line` token followed by a `NewLine` token, if applies + while (this.buffer.byteLength > 0) { + // get line number based on a previously emitted line, if any + const lineNumber = this.lastEmittedLine + ? this.lastEmittedLine.range.startLineNumber + 1 + : 1; + + // find the newline symbol in the data, if any + const newLineIndex = this.buffer.indexOf(NewLine.byte); + + // no newline symbol found in the data, stop processing because we + // either (1)need more data to arraive or (2)the stream has ended + // in the case (2) remaining data must be emitted as the last line + if (newLineIndex < 0) { + // if `streamEnded`, we need to emit the whole remaining + // data as the last line immediately + if (streamEnded) { + this.emitLine(lineNumber, this.buffer.slice(0)); + } + + break; + } + + // emit the line found in the data as the `Line` token + this.emitLine(lineNumber, this.buffer.slice(0, newLineIndex)); + + // must always hold true as the `emitLine` above sets this + assertDefined( + this.lastEmittedLine, + 'No last emitted line found.', + ); + + // emit `NewLine` token for the newline symbol found in the data + this._onData.fire( + NewLine.newOnLine( + this.lastEmittedLine, + this.lastEmittedLine.range.endColumn, + ), + ); + // shorten the data buffer by the length of the newline symbol + this.buffer = this.buffer.slice(NewLine.byte.byteLength); + } + + // if the stream has ended, assert that the input data buffer is now empty + // otherwise we have a logic error and leaving some buffered data behind + if (streamEnded) { + assert( + this.buffer.byteLength === 0, + 'Expected the input data buffer to be empty when the stream ends.', + ); + } + } + + /** + * Emit a provided line as the `Line` token to the output stream. + */ + private emitLine( + lineNumber: number, // Note! 1-based indexing + lineBytes: VSBuffer, + ): void { + + const line = new Line(lineNumber, lineBytes.toString()); + this._onData.fire(line); + + // store the last emitted line so we can use it when we need + // to send the remaining line in the `onStreamEnd` method + this.lastEmittedLine = line; + + // shorten the data buffer by the length of the line emitted + this.buffer = this.buffer.slice(lineBytes.byteLength); + } + + /** + * Handle the end of the input stream - if the buffer still has some data, + * emit it as the last available line token before firing the `onEnd` event. + */ + protected override onStreamEnd(): void { + // if the input data buffer is not empty when the input stream ends, emit + // the remaining data as the last line before firing the `onEnd` event + if (this.buffer.byteLength > 0) { + this.processData(true); + } + + super.onStreamEnd(); + } +} diff --git a/src/vs/editor/common/codecs/linesCodec/tokens/index.ts b/src/vs/editor/common/codecs/linesCodec/tokens/index.ts new file mode 100644 index 0000000000000..e456dec60310a --- /dev/null +++ b/src/vs/editor/common/codecs/linesCodec/tokens/index.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Set of tokens that are handled by the `LinesCodec`. + */ + +import { Line } from './line.js'; +import { NewLine } from './newLine.js'; + +export { Line, NewLine }; diff --git a/src/vs/editor/common/codecs/linesCodec/tokens/line.ts b/src/vs/editor/common/codecs/linesCodec/tokens/line.ts new file mode 100644 index 0000000000000..6669169967f57 --- /dev/null +++ b/src/vs/editor/common/codecs/linesCodec/tokens/line.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { assert } from '../../../../../base/common/assert.js'; +import { Range } from '../../../../../editor/common/core/range.js'; + +/** + * Token representing a line of text with a `range` which + * reflects the line's position in the original data. + */ +export class Line extends BaseToken { + constructor( + // the line index + // Note! 1-based indexing + lineNumber: number, + // the line contents + public readonly text: string, + ) { + assert( + !isNaN(lineNumber), + `The line number must not be a NaN.`, + ); + + assert( + lineNumber > 0, + `The line number must be >= 1, got "${lineNumber}".`, + ); + + super( + new Range( + lineNumber, + 1, + lineNumber, + text.length + 1, + ), + ); + } + + /** + * Check if this token is equal to another one. + */ + public override equals(other: T): boolean { + if (!super.equals(other)) { + return false; + } + + if (!(other instanceof Line)) { + return false; + } + + return this.text === other.text; + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `line("${this.text}")${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/linesCodec/tokens/newLine.ts b/src/vs/editor/common/codecs/linesCodec/tokens/newLine.ts new file mode 100644 index 0000000000000..e131225bf7f79 --- /dev/null +++ b/src/vs/editor/common/codecs/linesCodec/tokens/newLine.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Line } from './line.js'; +import { BaseToken } from '../../baseToken.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { Position } from '../../../../../editor/common/core/position.js'; + +/** + * A token that represent a `new line` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class NewLine extends BaseToken { + /** + * The underlying symbol of the `NewLine` token. + */ + public static readonly symbol: string = '\n'; + + /** + * The byte representation of the {@link symbol}. + */ + public static readonly byte = VSBuffer.fromString(NewLine.symbol); + + /** + * Create new `NewLine` token with range inside + * the given `Line` at the given `column number`. + */ + public static newOnLine( + line: Line, + atColumnNumber: number, + ): NewLine { + const { range } = line; + + const startPosition = new Position(range.startLineNumber, atColumnNumber); + const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); + + return new NewLine( + Range.fromPositions(startPosition, endPosition), + ); + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `newline${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts b/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts new file mode 100644 index 0000000000000..d44f9d01b3ab3 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { NewLine } from '../linesCodec/tokens/index.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { ReadableStream } from '../../../../base/common/stream.js'; +import { Word, Space, Tab, } from '../simpleCodec/tokens/index.js'; +import { LinesDecoder, TLineToken } from '../linesCodec/linesDecoder.js'; +import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; + +/** + * A token type that this decoder can handle. + */ +export type TSimpleToken = Word | Space | Tab | NewLine; + +/** + * Characters that stop a "word" sequence. + * Note! the `\n` is excluded because this decoder based on `LinesDecoder` that already + * handles the `newline` case and emits lines that don't contain the `\n` anymore. + */ +const STOP_CHARACTERS = [Space.symbol, Tab.symbol]; + +/** + * A decoder that can decode a stream of `Line`s into a stream + * of simple token, - `Word`, `Space`, `Tab`, `NewLine`, etc. + */ +export class SimpleDecoder extends BaseDecoder { + constructor( + stream: ReadableStream, + ) { + super(new LinesDecoder(stream)); + } + + protected override onStreamData(token: TLineToken): void { + // re-emit new lines + if (token instanceof NewLine) { + this._onData.fire(token); + + return; + } + + // loop through the text separating it into `Word` and `Space` tokens + let i = 0; + while (i < token.text.length) { + // index is 0-based, but column numbers are 1-based + const columnNumber = i + 1; + + // if a space character, emit a `Space` token and continue + if (token.text[i] === Space.symbol) { + this._onData.fire(Space.newOnLine(token, columnNumber)); + + i++; + continue; + } + + // if a tab character, emit a `Tab` token and continue + if (token.text[i] === Tab.symbol) { + this._onData.fire(Tab.newOnLine(token, columnNumber)); + + i++; + continue; + } + + // if a non-space character, parse out the whole word and + // emit it, then continue from the last word character position + let word = ''; + while (i < token.text.length && !(STOP_CHARACTERS.includes(token.text[i]))) { + word += token.text[i]; + i++; + } + + this._onData.fire( + Word.newOnLine(word, token, columnNumber), + ); + } + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/index.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/index.ts new file mode 100644 index 0000000000000..3f3369a7ea81a --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/index.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Set of tokens that are handled by the `SimpleCodec`. + */ + +import { Tab } from './tab.js'; +import { Word } from './word.js'; +import { Space } from './space.js'; + +export { Word, Space, Tab }; diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/space.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/space.ts new file mode 100644 index 0000000000000..9961c38ece9ce --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/space.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { Line } from '../../linesCodec/tokens/line.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { Position } from '../../../../../editor/common/core/position.js'; + +/** + * A token that represent a `space` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class Space extends BaseToken { + /** + * The underlying symbol of the `Space` token. + */ + public static readonly symbol: string = ' '; + + /** + * Create new `Space` token with range inside + * the given `Line` at the given `column number`. + */ + public static newOnLine( + line: Line, + atColumnNumber: number, + ): Space { + const { range } = line; + + const startPosition = new Position(range.startLineNumber, atColumnNumber); + const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); + + return new Space(Range.fromPositions( + startPosition, + endPosition, + )); + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `space${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/tab.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/tab.ts new file mode 100644 index 0000000000000..aab11327bc156 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/tab.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { Line } from '../../linesCodec/tokens/line.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { Position } from '../../../../../editor/common/core/position.js'; + +/** + * A token that represent a `tab` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class Tab extends BaseToken { + /** + * The underlying symbol of the `Tab` token. + */ + public static readonly symbol: string = '\t'; + + /** + * Create new `Tab` token with range inside + * the given `Line` at the given `column number`. + */ + public static newOnLine( + line: Line, + atColumnNumber: number, + ): Tab { + const { range } = line; + + const startPosition = new Position(range.startLineNumber, atColumnNumber); + // the tab token length is 1, hence `+ 1` + const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); + + return new Tab(Range.fromPositions( + startPosition, + endPosition, + )); + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `tab${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/word.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/word.ts new file mode 100644 index 0000000000000..fc3cefa79be68 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/word.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { Line } from '../../linesCodec/tokens/line.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { Position } from '../../../../../editor/common/core/position.js'; + +/** + * A token that represent a word - a set of continuous + * characters without stop characters, like a `space`, + * a `tab`, or a `new line`. + */ +export class Word extends BaseToken { + constructor( + /** + * The word range. + */ + range: Range, + + /** + * The string value of the word. + */ + public readonly text: string, + ) { + super(range); + } + + /** + * Create new `Word` token with the given `text` and the range + * inside the given `Line` at the specified `column number`. + */ + public static newOnLine( + text: string, + line: Line, + atColumnNumber: number, + ): Word { + const { range } = line; + + const startPosition = new Position(range.startLineNumber, atColumnNumber); + const endPosition = new Position(range.startLineNumber, atColumnNumber + text.length); + + return new Word( + Range.fromPositions(startPosition, endPosition), + text, + ); + } + + /** + * Check if this token is equal to another one. + */ + public override equals(other: T): boolean { + if (!super.equals(other)) { + return false; + } + + if (!(other instanceof Word)) { + return false; + } + + return this.text === other.text; + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `word("${this.text.slice(0, 8)}")${this.range}`; + } +} diff --git a/src/vs/editor/test/common/codecs/linesDecoder.test.ts b/src/vs/editor/test/common/codecs/linesDecoder.test.ts new file mode 100644 index 0000000000000..38a6c21def67c --- /dev/null +++ b/src/vs/editor/test/common/codecs/linesDecoder.test.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TestDecoder } from '../utils/testDecoder.js'; +import { Range } from '../../../common/core/range.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { newWriteableStream } from '../../../../base/common/stream.js'; +import { Line, NewLine } from '../../../common/codecs/linesCodec/tokens/index.js'; +import { LinesDecoder, TLineToken } from '../../../common/codecs/linesCodec/linesDecoder.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; + +/** + * A reusable test utility that asserts that a `LinesDecoder` instance + * correctly decodes `inputData` into a stream of `TLineToken` tokens. + * + * ## Examples + * + * ```typescript + * // create a new test utility instance + * const test = testDisposables.add(new TestLinesDecoder()); + * + * // run the test + * await test.run( + * ' hello world\n', + * [ + * new Line(1, ' hello world'), + * new NewLine(new Range(1, 13, 1, 14)), + * ], + * ); + */ +export class TestLinesDecoder extends TestDecoder { + constructor() { + const stream = newWriteableStream(null); + const decoder = new LinesDecoder(stream); + + super(stream, decoder); + } +} + +suite('LinesDecoder', () => { + suite('produces expected tokens', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('input starts with line data', async () => { + const test = testDisposables.add(new TestLinesDecoder()); + + await test.run( + ' hello world\nhow are you doing?\n\n 😊 \n ', + [ + new Line(1, ' hello world'), + new NewLine(new Range(1, 13, 1, 14)), + new Line(2, 'how are you doing?'), + new NewLine(new Range(2, 19, 2, 20)), + new Line(3, ''), + new NewLine(new Range(3, 1, 3, 2)), + new Line(4, ' 😊 '), + new NewLine(new Range(4, 5, 4, 6)), + new Line(5, ' '), + ], + ); + }); + + test('input starts with a new line', async () => { + const test = testDisposables.add(new TestLinesDecoder()); + + await test.run( + '\nsome text on this line\n\n\nanother 💬 on this line\n🤫\n', + [ + new Line(1, ''), + new NewLine(new Range(1, 1, 1, 2)), + new Line(2, 'some text on this line'), + new NewLine(new Range(2, 23, 2, 24)), + new Line(3, ''), + new NewLine(new Range(3, 1, 3, 2)), + new Line(4, ''), + new NewLine(new Range(4, 1, 4, 2)), + new Line(5, 'another 💬 on this line'), + new NewLine(new Range(5, 24, 5, 25)), + new Line(6, '🤫'), + new NewLine(new Range(6, 3, 6, 4)), + ], + ); + }); + + test('input starts and ends with multiple new lines', async () => { + const test = testDisposables.add(new TestLinesDecoder()); + + await test.run( + '\n\n\nciao! 🗯️\t💭 💥 come\tva?\n\n\n\n\n', + [ + new Line(1, ''), + new NewLine(new Range(1, 1, 1, 2)), + new Line(2, ''), + new NewLine(new Range(2, 1, 2, 2)), + new Line(3, ''), + new NewLine(new Range(3, 1, 3, 2)), + new Line(4, 'ciao! 🗯️\t💭 💥 come\tva?'), + new NewLine(new Range(4, 25, 4, 26)), + new Line(5, ''), + new NewLine(new Range(5, 1, 5, 2)), + new Line(6, ''), + new NewLine(new Range(6, 1, 6, 2)), + new Line(7, ''), + new NewLine(new Range(7, 1, 7, 2)), + new Line(8, ''), + new NewLine(new Range(8, 1, 8, 2)), + ], + ); + }); + }); +}); diff --git a/src/vs/editor/test/common/codecs/simpleDecoder.test.ts b/src/vs/editor/test/common/codecs/simpleDecoder.test.ts new file mode 100644 index 0000000000000..5f62d40c8c1e9 --- /dev/null +++ b/src/vs/editor/test/common/codecs/simpleDecoder.test.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TestDecoder } from '../utils/testDecoder.js'; +import { Range } from '../../../common/core/range.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { newWriteableStream } from '../../../../base/common/stream.js'; +import { NewLine } from '../../../common/codecs/linesCodec/tokens/newLine.js'; +import { Word, Space, Tab } from '../../../common/codecs/simpleCodec/tokens/index.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { SimpleDecoder, TSimpleToken } from '../../../common/codecs/simpleCodec/simpleDecoder.js'; + +/** + * A reusable test utility that asserts that a `SimpleDecoder` instance + * correctly decodes `inputData` into a stream of `TSimpleToken` tokens. + * + * ## Examples + * + * ```typescript + * // create a new test utility instance + * const test = testDisposables.add(new TestSimpleDecoder()); + * + * // run the test + * await test.run( + * ' hello world\n', + * [ + * new Space(new Range(1, 1, 1, 2)), + * new Word(new Range(1, 2, 1, 7), 'hello'), + * new Space(new Range(1, 7, 1, 8)), + * new Word(new Range(1, 8, 1, 13), 'world'), + * new NewLine(new Range(1, 13, 1, 14)), + * ], + * ); + */ +export class TestSimpleDecoder extends TestDecoder { + constructor() { + const stream = newWriteableStream(null); + const decoder = new SimpleDecoder(stream); + + super(stream, decoder); + } +} + +suite('SimpleDecoder', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('produces expected tokens', async () => { + const test = testDisposables.add( + new TestSimpleDecoder(), + ); + + await test.run( + ' hello world\nhow are\t you?\n\n (test) [!@#$%^&*_+=] \n\t\t🤗❤ \t\n ', + [ + // first line + new Space(new Range(1, 1, 1, 2)), + new Word(new Range(1, 2, 1, 7), 'hello'), + new Space(new Range(1, 7, 1, 8)), + new Word(new Range(1, 8, 1, 13), 'world'), + new NewLine(new Range(1, 13, 1, 14)), + // second line + new Word(new Range(2, 1, 2, 4), 'how'), + new Space(new Range(2, 4, 2, 5)), + new Word(new Range(2, 5, 2, 8), 'are'), + new Tab(new Range(2, 8, 2, 9)), + new Space(new Range(2, 9, 2, 10)), + new Word(new Range(2, 10, 2, 14), 'you?'), + new NewLine(new Range(2, 14, 2, 15)), + // third line + new NewLine(new Range(3, 1, 3, 2)), + // fourth line + new Space(new Range(4, 1, 4, 2)), + new Space(new Range(4, 2, 4, 3)), + new Space(new Range(4, 3, 4, 4)), + new Word(new Range(4, 4, 4, 10), '(test)'), + new Space(new Range(4, 10, 4, 11)), + new Space(new Range(4, 11, 4, 12)), + new Word(new Range(4, 12, 4, 25), '[!@#$%^&*_+=]'), + new Space(new Range(4, 25, 4, 26)), + new Space(new Range(4, 26, 4, 27)), + new NewLine(new Range(4, 27, 4, 28)), + // fifth line + new Tab(new Range(5, 1, 5, 2)), + new Tab(new Range(5, 2, 5, 3)), + new Word(new Range(5, 3, 5, 6), '🤗❤'), + new Space(new Range(5, 6, 5, 7)), + new Tab(new Range(5, 7, 5, 8)), + new NewLine(new Range(5, 8, 5, 9)), + // sixth line + new Space(new Range(6, 1, 6, 2)), + ], + ); + }); +}); diff --git a/src/vs/editor/test/common/utils/testDecoder.ts b/src/vs/editor/test/common/utils/testDecoder.ts new file mode 100644 index 0000000000000..e9ee9ce1067ea --- /dev/null +++ b/src/vs/editor/test/common/utils/testDecoder.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { randomInt } from '../../../../base/common/numbers.js'; +import { BaseToken } from '../../../common/codecs/baseToken.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { WriteableStream } from '../../../../base/common/stream.js'; +import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; + +/** + * (pseudo)Random boolean generator. + * + * ## Examples + * + * ```typsecript + * randomBoolean(); // generates either `true` or `false` + * ``` + */ +const randomBoolean = (): boolean => { + return Math.random() > 0.5; +}; + +/** + * A reusable test utility that asserts that the given decoder + * produces the expected `expectedTokens` sequence of tokens. + * + * ## Examples + * + * ```typescript + * const stream = newWriteableStream(null); + * const decoder = testDisposables.add(new LinesDecoder(stream)); + * + * // create a new test utility instance + * const test = testDisposables.add(new TestDecoder(stream, decoder)); + * + * // run the test + * await test.run( + * ' hello world\n', + * [ + * new Line(1, ' hello world'), + * new NewLine(new Range(1, 13, 1, 14)), + * ], + * ); + */ +export class TestDecoder> extends Disposable { + constructor( + private readonly stream: WriteableStream, + private readonly decoder: D, + ) { + super(); + + this._register(this.decoder); + } + + /** + * Run the test sending the `inputData` data to the stream and asserting + * that the decoder produces the `expectedTokens` sequence of tokens. + */ + public async run( + inputData: string, + expectedTokens: readonly T[], + ): Promise { + // write the data to the stream after a short delay to ensure + // that the the data is sent after the reading loop below + setTimeout(() => { + let inputDataBytes = VSBuffer.fromString(inputData); + + // write the input data to the stream in multiple random-length chunks + while (inputDataBytes.byteLength > 0) { + const dataToSend = inputDataBytes.slice(0, randomInt(inputDataBytes.byteLength)); + this.stream.write(dataToSend); + inputDataBytes = inputDataBytes.slice(dataToSend.byteLength); + } + + this.stream.end(); + }, 25); + + // randomly use either the `async iterator` or the `.consume()` + // variants of getting tokens, they both must yield equal results + const receivedTokens: T[] = []; + if (randomBoolean()) { + // test the `async iterator` code path + for await (const token of this.decoder) { + if (token === null) { + break; + } + + receivedTokens.push(token); + } + } else { + // test the `.consume()` code path + receivedTokens.push(...(await this.decoder.consumeAll())); + } + + for (let i = 0; i < expectedTokens.length; i++) { + const expectedToken = expectedTokens[i]; + const receivedtoken = receivedTokens[i]; + + assert( + receivedtoken.equals(expectedToken), + `Expected token '${i}' to be '${expectedToken}', got '${receivedtoken}'.`, + ); + } + + assert.strictEqual( + receivedTokens.length, + expectedTokens.length, + 'Must produce correct number of tokens.', + ); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts b/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts index 73ab2960823fa..06c7f71b444fd 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts @@ -8,6 +8,7 @@ import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { basename, dirname } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -23,7 +24,7 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ResourceLabels } from '../../../../browser/labels.js'; import { ResourceContextKey } from '../../../../common/contextkeys.js'; -import { IChatRequestImplicitVariableEntry } from '../../common/chatModel.js'; +import { ChatImplicitContext } from '../contrib/chatImplicitContext.js'; export class ImplicitContextAttachmentWidget extends Disposable { public readonly domNode: HTMLElement; @@ -31,7 +32,7 @@ export class ImplicitContextAttachmentWidget extends Disposable { private readonly renderDisposables = this._register(new DisposableStore()); constructor( - private readonly attachment: IChatRequestImplicitVariableEntry, + private readonly attachment: ChatImplicitContext, private readonly resourceLabels: ResourceLabels, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @@ -62,20 +63,34 @@ export class ImplicitContextAttachmentWidget extends Disposable { const friendlyName = `${fileBasename} ${fileDirname}`; const ariaLabel = range ? localize('chat.fileAttachmentWithRange', "Attached file, {0}, line {1} to line {2}", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.fileAttachment', "Attached file, {0}", friendlyName); - const uriLabel = this.labelService.getUriLabel(file, { relative: true }); const currentFile = localize('openEditor', "Current file context"); const inactive = localize('enableHint', "disabled"); - const currentFileHint = currentFile + (this.attachment.enabled ? '' : ` (${inactive})`); - const title = `${currentFileHint}\n${uriLabel}`; + const currentFileHint = new MarkdownString(currentFile + (this.attachment.enabled ? '' : ` (${inactive})`)); + + const title = [currentFileHint, ...this.getUriLabel(file)] + .map((markdown) => { + return markdown.value; + }) + .join('\n'); + label.setFile(file, { fileKind: FileKind.FILE, hidePath: true, range, - title + title, }); this.domNode.ariaLabel = ariaLabel; this.domNode.tabIndex = 0; - const hintElement = dom.append(this.domNode, dom.$('span.chat-implicit-hint', undefined, 'Current file')); + + const hintTitle = localize('current file', 'Current file'); + const hintElement = dom.append( + this.domNode, + dom.$( + 'span.chat-implicit-hint', + undefined, + `${hintTitle}${this.getReferencesSuffix()}`, + ), + ); this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), hintElement, title)); const buttonMsg = this.attachment.enabled ? localize('disable', "Disable current file context") : localize('enable', "Enable current file context"); @@ -106,4 +121,36 @@ export class ImplicitContextAttachmentWidget extends Disposable { }); })); } + + /** + * If file is a prompt that references other files, include the number of + * child references in the label as a `(+N more)` suffix. + */ + private getReferencesSuffix(): string { + const referencesCount = this.attachment.validFileReferenceUris.length; + + return referencesCount + ? ` (+${referencesCount} ${localize('more', 'more')})` + : ''; + } + + /** + * Get file URIs label, including its possible nested file references. + */ + private getUriLabel( + file: URI, + ): IMarkdownString[] { + const result = [new MarkdownString( + `• ${this.labelService.getUriLabel(file, { relative: true })}`, + )]; + + // if file is a prompt that references other files, add them to the label + for (const child of this.attachment.validFileReferenceUris) { + result.push(new MarkdownString( + ` • ${this.labelService.getUriLabel(child, { relative: true })}`, + )); + } + + return result; + } } diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index 135718089b2bf..031c7181ffbba 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -78,7 +78,7 @@ import { revealInSideBarCommand } from '../../files/browser/fileActions.contribu import { ChatAgentLocation, IChatAgentService } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, WorkingSetEntryState } from '../common/chatEditingService.js'; -import { IChatRequestVariableEntry } from '../common/chatModel.js'; +import { IBaseChatRequestVariableEntry, IChatRequestVariableEntry } from '../common/chatModel.js'; import { ChatRequestDynamicVariablePart } from '../common/chatParserTypes.js'; import { IChatFollowup } from '../common/chatService.js'; import { IChatResponseViewModel } from '../common/chatViewModel.js'; @@ -146,10 +146,53 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this._attachmentModel; } - public getAttachedAndImplicitContext(): IChatRequestVariableEntry[] { + public getAttachedAndImplicitContext( + chatWidget: IChatWidget, + ): IChatRequestVariableEntry[] { const contextArr = [...this.attachmentModel.attachments]; - if (this.implicitContext?.enabled && this.implicitContext.value) { - contextArr.push(this.implicitContext.toBaseEntry()); + + if (this._implicitContext?.enabled && this._implicitContext.value) { + const mainEntry = this._implicitContext.toBaseEntry(); + + contextArr.push(mainEntry); + + // if the implicit context is a file, it can have nested + // file references that should be included in the context + if (this._implicitContext.validFileReferenceUris) { + const childReferences = this._implicitContext.validFileReferenceUris + .map((uri): IBaseChatRequestVariableEntry => { + return { + ...mainEntry, + name: `file:${basename(uri.path)}`, + value: uri, + }; + }); + contextArr.push(...childReferences); + } + } + + // factor in nested references of dynamic variables into the implicit attached context + for (const part of chatWidget.parsedInput.parts) { + if (!(part instanceof ChatRequestDynamicVariablePart)) { + continue; + } + + if (!(part.isFile && URI.isUri(part.data))) { + continue; + } + + for (const childUri of part.childReferences ?? []) { + contextArr.push({ + id: part.id, + name: basename(childUri.path), + value: childUri, + kind: 'implicit', + isSelection: false, + enabled: true, + isFile: true, + isDynamic: true, + }); + } } return contextArr; @@ -538,6 +581,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _handleAttachedContextChange() { this._hasFileAttachmentContextKey.set(Boolean(this._attachmentModel.attachments.find(a => a.isFile))); this.renderAttachedContext(); + + return this; } render(container: HTMLElement, initialValue: string, widget: IChatWidget) { @@ -580,8 +625,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const toolbarsContainer = elements.inputToolbars; this.chatEditingSessionWidgetContainer = elements.chatEditingSessionWidgetContainer; this.renderAttachedContext(); - if (this.options.enableImplicitContext) { - this._implicitContext = this._register(new ChatImplicitContext()); + + if (this.options.enableImplicitContext && !this._implicitContext) { + this._implicitContext = this._register(this.instantiationService.createInstance(ChatImplicitContext)); this._register(this._implicitContext.onDidChangeValue(() => this._handleAttachedContextChange())); } @@ -764,6 +810,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge })); } + private async renderAttachedContext() { const container = this.attachedContextContainer; const oldHeight = container.offsetHeight; @@ -776,13 +823,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Render as attachments anything that isn't a file, but still render specific ranges in a file ? [...this.attachmentModel.attachments.entries()].filter(([_, attachment]) => !attachment.isFile || attachment.isFile && typeof attachment.value === 'object' && !!attachment.value && 'range' in attachment.value) : [...this.attachmentModel.attachments.entries()]; - dom.setVisibility(Boolean(attachments.length) || Boolean(this.implicitContext?.value), this.attachedContextContainer); + dom.setVisibility(Boolean(attachments.length) || Boolean(this._implicitContext?.value), this.attachedContextContainer); if (!attachments.length) { this._indexOfLastAttachedContextDeletedWithKeyboard = -1; } - if (this.implicitContext?.value) { - const implicitPart = store.add(this.instantiationService.createInstance(ImplicitContextAttachmentWidget, this.implicitContext, this._contextResourceLabels)); + if (this._implicitContext?.value) { + const implicitPart = store.add(this.instantiationService.createInstance( + ImplicitContextAttachmentWidget, + this._implicitContext, + this._contextResourceLabels), + ); container.appendChild(implicitPart.domNode); } @@ -1058,12 +1109,31 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } // Factor file variables that are part of the user query into the working set for (const part of chatWidget?.parsedInput.parts ?? []) { - if (part instanceof ChatRequestDynamicVariablePart && part.isFile && URI.isUri(part.data) && !seenEntries.has(part.data)) { + if (!(part instanceof ChatRequestDynamicVariablePart)) { + continue; + } + + if (!(part.isFile && URI.isUri(part.data) && !seenEntries.has(part.data))) { + continue; + } + + entries.unshift({ + reference: part.data, + state: WorkingSetEntryState.Attached, + kind: 'reference', + }); + + // if nested child references are found in the file represented + // by the dynamic variable, add them to the attached entries + const childReferences = part.childReferences ?? []; + for (const child of childReferences) { entries.unshift({ - reference: part.data, + reference: child, state: WorkingSetEntryState.Attached, kind: 'reference', }); + + seenEntries.add(child); } } const excludedEntries: IChatCollapsibleListItem[] = []; diff --git a/src/vs/workbench/contrib/chat/browser/chatVariables.ts b/src/vs/workbench/contrib/chat/browser/chatVariables.ts index 4ab91fa76d76b..c4833b9854f6c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/chatVariables.ts @@ -3,12 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { coalesce } from '../../../../base/common/arrays.js'; +import { assert } from '../../../../base/common/assert.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { onUnexpectedExternalError } from '../../../../base/common/errors.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { assertDefined } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { Location } from '../../../../editor/common/languages.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; @@ -25,23 +26,53 @@ interface IChatData { resolver: IChatVariableResolver; } +/** + * Helper to run provided `jobs` in parallel and return only + * the `successful` results in the same order as the original jobs. + */ +const getJobResults = async ( + jobs: Promise[], +): Promise => { + return (await Promise.allSettled(jobs)) + // filter out `failed` and `empty` results + .filter((result) => { + return result.status !== 'rejected' && result.value !== null; + }) + // extract the result value + .map((result) => { + // assertions below must always be true because of the filter logic above + assert( + result.status === 'fulfilled', + `Failed to resolve variables: unexpected promise result status "${result.status}".`, + ); + assert( + result.value !== null, + `Failed to resolve variables: promise result must not be null.`, + ); + + return result.value; + }); +}; + export class ChatVariablesService implements IChatVariablesService { declare _serviceBrand: undefined; - private _resolver = new Map(); + private readonly _resolver = new Map(); constructor( @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IViewsService private readonly viewsService: IViewsService, - ) { - } + ) { } - async resolveVariables(prompt: IParsedChatRequest, attachedContextVariables: IChatRequestVariableEntry[] | undefined, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise { - let resolvedVariables: IChatRequestVariableEntry[] = []; - const jobs: Promise[] = []; - - prompt.parts - .forEach((part, i) => { + public async resolveVariables( + prompt: IParsedChatRequest, + attachedContextVariables: IChatRequestVariableEntry[] | undefined, + model: IChatModel, + progress: (part: IChatVariableResolverProgress) => void, + token: CancellationToken, + ): Promise { + const resolvedVariableJobs: Promise[] = prompt.parts + .map(async (part) => { if (part instanceof ChatRequestVariablePart) { const data = this._resolver.get(part.variableName.toLowerCase()); if (data) { @@ -53,22 +84,61 @@ export class ChatVariablesService implements IChatVariablesService { } progress(item); }; - jobs.push(data.resolver(prompt.text, part.variableArg, model, variableProgressCallback, token).then(value => { - if (value) { - resolvedVariables[i] = { id: data.data.id, modelDescription: data.data.modelDescription, name: part.variableName, range: part.range, value, references, fullName: data.data.fullName, icon: data.data.icon }; + + try { + const value = await data.resolver(prompt.text, part.variableArg, model, variableProgressCallback, token); + + if (!value) { + return null; } - }).catch(onUnexpectedExternalError)); + + return { + id: data.data.id, + modelDescription: data.data.modelDescription, + name: part.variableName, + range: part.range, + value, + references, + fullName: data.data.fullName, + icon: data.data.icon, + }; + } catch (error) { + onUnexpectedExternalError(error); + + throw error; + } } - } else if (part instanceof ChatRequestDynamicVariablePart) { - resolvedVariables[i] = { id: part.id, name: part.referenceText, range: part.range, value: part.data, fullName: part.fullName, icon: part.icon, isFile: part.isFile }; - } else if (part instanceof ChatRequestToolPart) { - resolvedVariables[i] = { id: part.toolId, name: part.toolName, range: part.range, value: undefined, isTool: true, icon: ThemeIcon.isThemeIcon(part.icon) ? part.icon : undefined, fullName: part.displayName }; } + + if (part instanceof ChatRequestDynamicVariablePart) { + return { + id: part.id, + name: part.referenceText, + range: part.range, + value: part.data, + fullName: part.fullName, + icon: part.icon, + isFile: part.isFile, + }; + } + + if (part instanceof ChatRequestToolPart) { + return { + id: part.toolId, + name: part.toolName, + range: part.range, + value: undefined, + isTool: true, + icon: ThemeIcon.isThemeIcon(part.icon) ? part.icon : undefined, + fullName: part.displayName, + }; + } + + return null; }); - const resolvedAttachedContext: IChatRequestVariableEntry[] = []; - attachedContextVariables - ?.forEach((attachment, i) => { + const resolvedAttachedContextJobs: Promise[] = (attachedContextVariables || []) + .map(async (attachment) => { const data = this._resolver.get(attachment.name?.toLowerCase()); if (data) { const references: IChatContentReference[] = []; @@ -79,30 +149,69 @@ export class ChatVariablesService implements IChatVariablesService { } progress(item); }; - jobs.push(data.resolver(prompt.text, '', model, variableProgressCallback, token).then(value => { - if (value) { - resolvedAttachedContext[i] = { id: data.data.id, modelDescription: data.data.modelDescription, name: attachment.name, fullName: attachment.fullName, range: attachment.range, value, references, icon: attachment.icon }; + + try { + const value = await data.resolver(prompt.text, '', model, variableProgressCallback, token); + if (!value) { + return null; } - }).catch(onUnexpectedExternalError)); - } else if (attachment.isDynamic || attachment.isTool) { - resolvedAttachedContext[i] = attachment; + + return { + id: data.data.id, + modelDescription: data.data.modelDescription, + name: attachment.name, + fullName: attachment.fullName, + range: attachment.range, + value, + references, + icon: attachment.icon, + }; + } catch (error) { + onUnexpectedExternalError(error); + + throw error; + } + } + + if (attachment.isDynamic || attachment.isTool) { + return attachment; } - }); - await Promise.allSettled(jobs); + return null; + }); - // Make array not sparse - resolvedVariables = coalesce(resolvedVariables); + // run all jobs in paralle and get results in the original order + // Note! the `getJobResults` call supposed to never fail, hence it's ok to do + // `Promise.all()`, otherwise we have a logic error that would be caught here + const [resolvedVariables, resolvedAttachedContext] = await Promise.all( + [ + getJobResults(resolvedVariableJobs), + getJobResults(resolvedAttachedContextJobs), + ], + ); - // "reverse", high index first so that replacement is simple - resolvedVariables.sort((a, b) => b.range!.start - a.range!.start); + // "reverse" resolved variables making the high index + // to go first so that an replacement logic is simple + resolvedVariables + .sort((left, right) => { + assertDefined( + left.range, + `Failed to sort resolved variables: "left" variable does not have a range.`, + ); - // resolvedAttachedContext is a sparse array - resolvedVariables.push(...coalesce(resolvedAttachedContext)); + assertDefined( + right.range, + `Failed to sort resolved variables: "right" variable does not have a range.`, + ); + return right.range.start - left.range.start; + }); return { - variables: resolvedVariables, + variables: [ + ...resolvedVariables, + ...resolvedAttachedContext, + ], }; } diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index 316f2a724d6bd..8b84e2d5c43e9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -50,10 +50,11 @@ import { ChatAttachmentModel } from './chatAttachmentModel.js'; import { ChatInputPart, IChatInputStyles } from './chatInputPart.js'; import { ChatListDelegate, ChatListItemRenderer, IChatRendererDelegate } from './chatListRenderer.js'; import { ChatEditorOptions } from './chatOptions.js'; +import { ChatViewWelcomePart } from './viewsWelcome/chatViewWelcomeController.js'; + import './media/chat.css'; import './media/chatAgentHover.css'; import './media/chatViewWelcome.css'; -import { ChatViewWelcomePart } from './viewsWelcome/chatViewWelcomeController.js'; const $ = dom.$; @@ -192,7 +193,13 @@ export class ChatWidget extends Disposable implements IChatWidget { return { text: '', parts: [] }; } - this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel!.sessionId, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent }); + this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser) + .parseChatRequest( + this.viewModel.sessionId, + this.getInput(), + this.location, + { selectedAgent: this._lastSelectedAgent }, + ); } return this.parsedChatRequest; @@ -472,7 +479,13 @@ export class ChatWidget extends Disposable implements IChatWidget { if (!this.viewModel) { return; } - this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel.sessionId, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent }); + this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser) + .parseChatRequest( + this.viewModel.sessionId, + this.getInput(), + this.location, + { selectedAgent: this._lastSelectedAgent }, + ); this._onDidChangeParsedInput.fire(); } @@ -983,7 +996,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } } - let attachedContext = this.inputPart.getAttachedAndImplicitContext(); + let attachedContext = this.inputPart.getAttachedAndImplicitContext(this); let workingSet: URI[] | undefined; if (this.location === ChatAgentLocation.EditingSession) { const currentEditingSession = this.chatEditingService.currentEditingSessionObs.get(); diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariable.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariable.ts new file mode 100644 index 0000000000000..f90cb8269011d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariable.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IDynamicVariable } from '../../common/chatVariables.js'; +import { basename } from '../../../../../base/common/resources.js'; +import { assertDefined } from '../../../../../base/common/types.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; +import { Location } from '../../../../../editor/common/languages.js'; +import { PromptFileReference } from '../../common/promptFileReference.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; + +/** + * Parse the `data` property of a reference as an `URI`. + * @throws if the `data` reference is `not defined` or an invalid `URI`. + */ +const parseUri = (data: IDynamicVariable['data']): URI | Location => { + assertDefined( + data, + `The reference must have a \`data\` property, got ${data}.`, + ); + + if (typeof data === 'string') { + return URI.parse(data); + } + + if (data instanceof URI) { + return data; + } + + if ('uri' in data && data.uri instanceof URI) { + return data.uri; + } + + throw new Error( + `The reference must have a \`data\` property parseable as an 'URI', got ${data}.`, + ); +}; + +/** + * A wrapper class for an `IDynamicVariable` object that that adds functionality + * to parse nested file references of this variable. + * See {@link PromptFileReference} for details. + */ +export class ChatDynamicVariable extends PromptFileReference implements IDynamicVariable { + constructor( + private readonly reference: IDynamicVariable, + @IFileService fileService: IFileService, + @IConfigurationService configService: IConfigurationService, + ) { + super( + parseUri(reference.data), + fileService, + configService, + ); + } + + /** + * Get the filename of the reference with the suffix for how many nested child references + * the current reference has. E.g. `(+3 more)` if there are 3 child references found. + */ + public get filenameWithReferences(): string { + const fileName = basename(this.uri); + + const suffix = this.validChildReferences.length + ? ` (+${this.validChildReferences.length} ${localize('more', 'more')})` + : ''; + + return `${fileName}${suffix}`; + } + + /** + * Note! below are the getters that simply forward to the underlying `IDynamicVariable` object; + * while we could implement the logic generically using the `Proxy` class here, it's hard + * to make Typescript to recognize this generic implementation correctly + */ + + public get id() { + return this.reference.id; + } + + public get range() { + return this.reference.range; + } + + public set range(range: IRange) { + this.reference.range = range; + } + + public get data(): URI { + return this.uri; + } + + public get prefix() { + return this.reference.prefix; + } + + public get isFile() { + return this.reference.isFile; + } + + public get fullName() { + return this.reference.fullName; + } + + public get modelDescription() { + return this.reference.modelDescription; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts index 3c3c64d522232..456e4dad8dce1 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts @@ -15,7 +15,7 @@ import { ITextModelService } from '../../../../../editor/common/services/resolve import { localize } from '../../../../../nls.js'; import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { AnythingQuickAccessProviderRunOptions, IQuickAccessOptions } from '../../../../../platform/quickinput/common/quickAccess.js'; @@ -23,14 +23,16 @@ import { IQuickInputService } from '../../../../../platform/quickinput/common/qu import { IChatWidget } from '../chat.js'; import { ChatWidget, IChatWidgetContrib } from '../chatWidget.js'; import { IChatRequestVariableValue, IChatVariablesService, IDynamicVariable } from '../../common/chatVariables.js'; +import { ChatDynamicVariable } from './chatDynamicVariable.js'; +import { EditOperation } from '../../../../../editor/common/core/editOperation.js'; export const dynamicVariableDecorationType = 'chat-dynamic-variable'; export class ChatDynamicVariableModel extends Disposable implements IChatWidgetContrib { public static readonly ID = 'chatDynamicVariableModel'; - private _variables: IDynamicVariable[] = []; - get variables(): ReadonlyArray { + private _variables: ChatDynamicVariable[] = []; + get variables(): ReadonlyArray { return [...this._variables]; } @@ -41,12 +43,17 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC constructor( private readonly widget: IChatWidget, @ILabelService private readonly labelService: ILabelService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); this._register(widget.inputEditor.onDidChangeModelContent(e => { e.changes.forEach(c => { // Don't mutate entries in _variables, since they will be returned from the getter - this._variables = coalesce(this._variables.map(ref => { + this._variables = coalesce(this._variables.map((ref) => { + if (c.text === `#file:${ref.filenameWithReferences}`) { + return ref; + } + const intersection = Range.intersectRanges(ref.range, c.range); if (intersection && !intersection.isEmpty()) { // The reference text was changed, it's broken. @@ -60,60 +67,120 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC this.widget.refreshParsedInput(); } return null; - } else if (Range.compareRangesUsingStarts(ref.range, c.range) > 0) { + } + + if (Range.compareRangesUsingStarts(ref.range, c.range) > 0) { const delta = c.text.length - c.rangeLength; - return { - ...ref, - range: { - startLineNumber: ref.range.startLineNumber, - startColumn: ref.range.startColumn + delta, - endLineNumber: ref.range.endLineNumber, - endColumn: ref.range.endColumn + delta - } + ref.range = { + startLineNumber: ref.range.startLineNumber, + startColumn: ref.range.startColumn + delta, + endLineNumber: ref.range.endLineNumber, + endColumn: ref.range.endColumn + delta, }; + + return ref; } return ref; })); }); - this.updateDecorations(); + this.updateVariableDecorations(); })); } - getInputState(): any { - return this.variables; - } - setInputState(s: any): void { if (!Array.isArray(s)) { s = []; } this._variables = s; - this.updateDecorations(); + this.updateVariableDecorations(); } - addReference(ref: IDynamicVariable): void { - this._variables.push(ref); - this.updateDecorations(); + public addReference(ref: IDynamicVariable): void { + const variable = this._register( + this.instantiationService.createInstance(ChatDynamicVariable, ref), + ); + + this._variables.push(variable); + this.updateVariableDecorations(); this.widget.refreshParsedInput(); + + // if the `prompt snippets` feature is enabled, start resolving + // nested file references immediatelly and subscribe to updates + if (variable.isPromptSnippetFile) { + // subscribe to variable changes + this._register(variable.onUpdate(() => { + this.updateVariableTexts(); + this.updateVariableDecorations(); + this.widget.refreshParsedInput(); + })); + // start resolving the file references + variable.resolve(); + } } - private updateDecorations(): void { - this.widget.inputEditor.setDecorationsByType('chat', dynamicVariableDecorationType, this._variables.map((r): IDecorationOptions => ({ - range: r.range, - hoverMessage: this.getHoverForReference(r) - }))); + /** + * Update variables text inside input editor to add the `(+N more)` + * suffix if the variable has nested child file references. + */ + private updateVariableTexts(): void { + for (const variable of this._variables) { + const text = `#file:${variable.filenameWithReferences}`; + const range = variable.range; + + const success = this.widget.inputEditor.executeEdits( + 'chatUpdateFileReference', + [EditOperation.replaceMove(new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn), text)], + ); + + if (!success) { + continue; + } + + variable.range = new Range( + range.startLineNumber, + range.startColumn, + range.endLineNumber, + range.startColumn + text.length, + ); + } + } + + private updateVariableDecorations(): void { + this.widget.inputEditor.setDecorationsByType( + 'chat', + dynamicVariableDecorationType, + this._variables.map((variable): IDecorationOptions => { + return { + range: variable.range, + hoverMessage: this.getHoverForReference(variable), + }; + }), + ); } - private getHoverForReference(ref: IDynamicVariable): IMarkdownString | undefined { - const value = ref.data; - if (URI.isUri(value)) { - return new MarkdownString(this.labelService.getUriLabel(value, { relative: true })); - } else { - return undefined; + private getHoverForReference(variable: IDynamicVariable): IMarkdownString | IMarkdownString[] { + const result: IMarkdownString[] = []; + const { data } = variable; + + if (!URI.isUri(data)) { + return result; } + + result.push(new MarkdownString( + `${this.labelService.getUriLabel(data, { relative: true })}`, + )); + + // if reference has nested child file references, include them in the label + for (const childUri of variable.validFileReferenceUris ?? []) { + result.push(new MarkdownString( + ` • ${this.labelService.getUriLabel(childUri, { relative: true })}`, + )); + } + + return result; } } @@ -212,8 +279,13 @@ export class SelectAndInsertFileAction extends Action2 { id: 'vscode.file', isFile: true, prefix: 'file', - range: { startLineNumber: range.startLineNumber, startColumn: range.startColumn, endLineNumber: range.endLineNumber, endColumn: range.startColumn + text.length }, - data: resource + range: { + startLineNumber: range.startLineNumber, + startColumn: range.startColumn, + endLineNumber: range.endLineNumber, + endColumn: range.startColumn + text.length, + }, + data: resource, }); } } @@ -284,7 +356,7 @@ export class AddDynamicVariableAction extends Action2 { range: range, isFile: true, prefix: 'file', - data: variableData + data: variableData, }); } } diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts index ed33aec41d9c9..b80c5acbf6853 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts @@ -11,6 +11,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ICodeEditor, isCodeEditor, isDiffEditor } from '../../../../../editor/browser/editorBrowser.js'; import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; import { Location } from '../../../../../editor/common/languages.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { EditorsOrder } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -18,6 +19,8 @@ import { ChatAgentLocation } from '../../common/chatAgents.js'; import { IBaseChatRequestVariableEntry, IChatRequestImplicitVariableEntry } from '../../common/chatModel.js'; import { ILanguageModelIgnoredFilesService } from '../../common/ignoredFiles.js'; import { IChatWidget, IChatWidgetService } from '../chat.js'; +import { PromptFileReference } from '../../common/promptFileReference.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; export class ChatImplicitContextContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'chat.implicitContext'; @@ -125,6 +128,22 @@ export class ChatImplicitContextContribution extends Disposable implements IWork } export class ChatImplicitContext extends Disposable implements IChatRequestImplicitVariableEntry { + private _onDidChangeValue = this._register(new Emitter()); + readonly onDidChangeValue = this._onDidChangeValue.event; + + /** + * Chat reference object for the current implicit context `URI` + * allows to resolve nested file references(aka `prompt snippets`). + */ + private promptFileReference?: PromptFileReference; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService private readonly configService: IConfigurationService, + ) { + super(); + } + get id() { if (URI.isUri(this.value)) { return 'vscode.implicit.file'; @@ -170,8 +189,9 @@ export class ChatImplicitContext extends Disposable implements IChatRequestImpli return this._isSelection; } - private _onDidChangeValue = new Emitter(); - readonly onDidChangeValue = this._onDidChangeValue.event; + public set isSelection(value: boolean) { + this._isSelection = value; + } private _value: Location | URI | undefined; get value() { @@ -188,17 +208,79 @@ export class ChatImplicitContext extends Disposable implements IChatRequestImpli this._onDidChangeValue.fire(); } - constructor(value?: Location | URI) { - super(); - this._value = value; + /** + * Get nested file references list, if exists. + */ + public get validFileReferenceUris(): readonly URI[] { + if (!this.promptFileReference) { + return []; + } + + return this.promptFileReference.validFileReferenceUris; } - setValue(value: Location | URI | undefined, isSelection: boolean) { + /** + * Set value of the implicit context or remove it if `undefined` is provided. + */ + public setValue( + value: Location | URI | undefined, + isSelection: boolean, + ) { + // if the `prompt-snippets` feature is enabled, add a chat reference object + if (PromptFileReference.promptSnippetsEnabled(this.configService)) { + this.addPromprFileReferenceFor(value); + } + this._value = value; this._isSelection = isSelection; this._onDidChangeValue.fire(); } + /** + * Add a prompt file reference object for the provided `URI` value. + */ + private addPromprFileReferenceFor( + value: Location | URI | undefined, + ) { + // new value is `undefined` so remove the existing file reference + if (!value) { + return this.removePromptFileReference(); + } + + // if the `URI` value didn't change and prompt file reference exists, nothing to do + if (this.promptFileReference && this.promptFileReference.sameUri(value)) { + return; + } + + // got a new `URI` value, so remove the existing prompt file + // reference object(if present) and create a new one + this.removePromptFileReference(); + this.promptFileReference = this._register( + this.instantiationService.createInstance(PromptFileReference, value), + ); + + // subscribe to updates of the prompt file reference + this._register( + this.promptFileReference.onUpdate( + this._onDidChangeValue.fire.bind(this._onDidChangeValue), + ), + ); + // start resolving the nested prompt file references immediately + this.promptFileReference.resolve(); + } + + /** + * Remove current prompt file reference, if present. + */ + private removePromptFileReference() { + if (!this.promptFileReference) { + return; + } + + this.promptFileReference.dispose(); + delete this.promptFileReference; + } + toBaseEntry(): IBaseChatRequestVariableEntry { return { id: this.id, diff --git a/src/vs/workbench/contrib/chat/common/chatParserTypes.ts b/src/vs/workbench/contrib/chat/common/chatParserTypes.ts index 04099d3e1a551..52f3148b15f55 100644 --- a/src/vs/workbench/contrib/chat/common/chatParserTypes.ts +++ b/src/vs/workbench/contrib/chat/common/chatParserTypes.ts @@ -5,11 +5,12 @@ import { revive } from '../../../../base/common/marshalling.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; import { IOffsetRange, OffsetRange } from '../../../../editor/common/core/offsetRange.js'; import { IRange } from '../../../../editor/common/core/range.js'; import { ChatAgentLocation, IChatAgentCommand, IChatAgentData, IChatAgentService, reviveSerializedAgent } from './chatAgents.js'; import { IChatSlashData } from './chatSlashCommands.js'; -import { IChatRequestVariableValue } from './chatVariables.js'; +import { IChatRequestVariableValue, IDynamicVariable } from './chatVariables.js'; import { IToolData } from './languageModelToolsService.js'; // These are in a separate file to avoid circular dependencies with the dependencies of the parser @@ -137,12 +138,58 @@ export class ChatRequestSlashCommandPart implements IParsedChatRequestPart { } /** - * An invocation of a dynamic reference like '#file:' + * An invocation of a dynamic reference like '#file:'. */ export class ChatRequestDynamicVariablePart implements IParsedChatRequestPart { static readonly Kind = 'dynamic'; readonly kind = ChatRequestDynamicVariablePart.Kind; - constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly text: string, readonly id: string, readonly modelDescription: string | undefined, readonly data: IChatRequestVariableValue, readonly fullName?: string, readonly icon?: ThemeIcon, readonly isFile?: boolean) { } + constructor( + public readonly range: OffsetRange, + public readonly text: string, + private readonly variable: IDynamicVariable, + ) { } + + /** + * The nested child file references of this variable, if any. + */ + public get childReferences(): ReadonlyArray | undefined { + return this.variable.validFileReferenceUris; + } + + /** + * Convert current object to an `IDynamicVariable` object. + */ + public toDynamicVariable(): IDynamicVariable { + return this.variable; + } + + public get id(): string { + return this.variable.id; + } + + public get editorRange(): IRange { + return this.variable.range; + } + + public get modelDescription(): string | undefined { + return this.variable.modelDescription; + } + + public get data(): IChatRequestVariableValue { + return this.variable.data; + } + + public get fullName(): string | undefined { + return this.variable.fullName; + } + + public get icon(): ThemeIcon | undefined { + return this.variable.icon; + } + + public get isFile(): boolean | undefined { + return this.variable.isFile; + } get referenceText(): string { return this.text.replace(chatVariableLeader, ''); @@ -201,21 +248,20 @@ export function reviveParsedChatRequest(serialized: IParsedChatRequest): IParsed part.editorRange, (part as ChatRequestSlashCommandPart).slashCommand ); - } else if (part.kind === ChatRequestDynamicVariablePart.Kind) { + } + + if (part.kind === ChatRequestDynamicVariablePart.Kind) { + const variable = (part as ChatRequestDynamicVariablePart).toDynamicVariable(); + variable.data = revive(variable.data); + return new ChatRequestDynamicVariablePart( new OffsetRange(part.range.start, part.range.endExclusive), - part.editorRange, (part as ChatRequestDynamicVariablePart).text, - (part as ChatRequestDynamicVariablePart).id, - (part as ChatRequestDynamicVariablePart).modelDescription, - revive((part as ChatRequestDynamicVariablePart).data), - (part as ChatRequestDynamicVariablePart).fullName, - (part as ChatRequestDynamicVariablePart).icon, - (part as ChatRequestDynamicVariablePart).isFile + variable, ); - } else { - throw new Error(`Unknown chat request part: ${part.kind}`); } + + throw new Error(`Unknown chat request part '${part.kind}'.`); }) }; } diff --git a/src/vs/workbench/contrib/chat/common/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/chatRequestParser.ts index d660bbd0df240..1bd282db88143 100644 --- a/src/vs/workbench/contrib/chat/common/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/chatRequestParser.ts @@ -29,7 +29,12 @@ export class ChatRequestParser { @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, ) { } - parseChatRequest(sessionId: string, message: string, location: ChatAgentLocation = ChatAgentLocation.Panel, context?: IChatParserContext): IParsedChatRequest { + public parseChatRequest( + sessionId: string, + message: string, + location: ChatAgentLocation = ChatAgentLocation.Panel, + context?: IChatParserContext, + ): IParsedChatRequest { const parts: IParsedChatRequestPart[] = []; const references = this.variableService.getDynamicVariables(sessionId); // must access this list before any async calls @@ -220,7 +225,12 @@ export class ChatRequestParser { return; } - private tryToParseDynamicVariable(message: string, offset: number, position: IPosition, references: ReadonlyArray): ChatRequestDynamicVariablePart | undefined { + private tryToParseDynamicVariable( + message: string, + offset: number, + position: IPosition, + references: ReadonlyArray, + ): ChatRequestDynamicVariablePart | undefined { const refAtThisPosition = references.find(r => r.range.startLineNumber === position.lineNumber && r.range.startColumn === position.column); @@ -228,7 +238,11 @@ export class ChatRequestParser { const length = refAtThisPosition.range.endColumn - refAtThisPosition.range.startColumn; const text = message.substring(0, length); const range = new OffsetRange(offset, offset + length); - return new ChatRequestDynamicVariablePart(range, refAtThisPosition.range, text, refAtThisPosition.id, refAtThisPosition.modelDescription, refAtThisPosition.data, refAtThisPosition.fullName, refAtThisPosition.icon, refAtThisPosition.isFile); + return new ChatRequestDynamicVariablePart( + range, + text, + refAtThisPosition, + ); } return; diff --git a/src/vs/workbench/contrib/chat/common/chatVariables.ts b/src/vs/workbench/contrib/chat/common/chatVariables.ts index 1bd1db9c39641..7040fdf0f1bac 100644 --- a/src/vs/workbench/contrib/chat/common/chatVariables.ts +++ b/src/vs/workbench/contrib/chat/common/chatVariables.ts @@ -63,4 +63,9 @@ export interface IDynamicVariable { modelDescription?: string; isFile?: boolean; data: IChatRequestVariableValue; + + /** + * The nested child file references of this variable, if any. + */ + validFileReferenceUris?: readonly URI[]; } diff --git a/src/vs/workbench/contrib/chat/common/codecs/chatPromptCodec/chatPromptCodec.ts b/src/vs/workbench/contrib/chat/common/codecs/chatPromptCodec/chatPromptCodec.ts new file mode 100644 index 0000000000000..618e06a75b0ac --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/codecs/chatPromptCodec/chatPromptCodec.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { ReadableStream } from '../../../../../../base/common/stream.js'; +import { ICodec } from '../../../../../../base/common/codecs/types/ICodec.js'; +import { ChatbotPromptDecoder, TChatbotPromptToken } from './chatPromptDecoder.js'; + +/** + * Codec that is capable to encode and decode syntax tokens of a AI chat bot prompt message. + */ +export class ChatbotPromptCodec extends Disposable implements ICodec { + public encode(_: ReadableStream): ReadableStream { + throw new Error('The `encode` method is not implemented.'); + } + + public decode(stream: ReadableStream): ChatbotPromptDecoder { + return this._register(new ChatbotPromptDecoder(stream)); + } +} diff --git a/src/vs/workbench/contrib/chat/common/codecs/chatPromptCodec/chatPromptDecoder.ts b/src/vs/workbench/contrib/chat/common/codecs/chatPromptCodec/chatPromptDecoder.ts new file mode 100644 index 0000000000000..eb2e5c3b840ea --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/codecs/chatPromptCodec/chatPromptDecoder.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { FileReference } from './tokens/fileReference.js'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { ReadableStream } from '../../../../../../base/common/stream.js'; +import { BaseDecoder } from '../../../../../../base/common/codecs/baseDecoder.js'; +import { Word } from '../../../../../../editor/common/codecs/simpleCodec/tokens/index.js'; +import { SimpleDecoder, TSimpleToken } from '../../../../../../editor/common/codecs/simpleCodec/simpleDecoder.js'; + +/** + * Tokens handled by the `ChatbotPromptDecoder` decoder. + */ +export type TChatbotPromptToken = FileReference; + +/** + * Decoder for the common chatbot prompt message syntax. + * For instance, the file references `#file:./path/file.md` are handled by this decoder. + */ +export class ChatbotPromptDecoder extends BaseDecoder { + constructor( + stream: ReadableStream, + ) { + super(new SimpleDecoder(stream)); + } + + protected override onStreamData(simpleToken: TSimpleToken): void { + // handle the word tokens only + if (!(simpleToken instanceof Word)) { + return; + } + + // handle file references only for now + const { text } = simpleToken; + if (!text.startsWith(FileReference.TOKEN_START)) { + return; + } + + this._onData.fire( + FileReference.fromWord(simpleToken), + ); + } +} diff --git a/src/vs/workbench/contrib/chat/common/codecs/chatPromptCodec/tokens/fileReference.ts b/src/vs/workbench/contrib/chat/common/codecs/chatPromptCodec/tokens/fileReference.ts new file mode 100644 index 0000000000000..a843d8d3517b3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/codecs/chatPromptCodec/tokens/fileReference.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assert } from '../../../../../../../base/common/assert.js'; +import { Range } from '../../../../../../../editor/common/core/range.js'; +import { BaseToken } from '../../../../../../../editor/common/codecs/baseToken.js'; +import { Word } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/index.js'; + + +// Start sequence for a file reference token in a prompt. +const TOKEN_START: string = '#file:'; + +/** + * A file reference token inside a prompt. + */ +export class FileReference extends BaseToken { + // Start sequence for a file reference token in a prompt. + public static readonly TOKEN_START = TOKEN_START; + + constructor( + range: Range, + public readonly path: string, + ) { + super(range); + } + + /** + * Get full text of the file reference token. + */ + get text(): string { + return `${TOKEN_START}${this.path}`; + } + + /** + * Create a file reference token out of a generic `Word`. + * @throws if the word does not conform to the expected format or if + * the reference is an invalid `URI`. + */ + public static fromWord(word: Word): FileReference { + const { text } = word; + + assert( + text.startsWith(TOKEN_START), + `The reference must start with "${TOKEN_START}", got ${text}.`, + ); + + const maybeReference = text.split(TOKEN_START); + + assert( + maybeReference.length === 2, + `The expected reference format is "${TOKEN_START}:filesystem-path", got ${text}.`, + ); + + const [first, second] = maybeReference; + + assert( + first === '', + `The reference must start with "${TOKEN_START}", got ${first}.`, + ); + + assert( + // Note! this accounts for both cases when second is `undefined` or `empty` + // and we don't care about rest of the "falsy" cases here + !!second, + `The reference path must be defined, got ${second}.`, + ); + + const reference = new FileReference( + word.range, + second, + ); + + return reference; + } + + /** + * Check if this token is equal to another one. + */ + public override equals(other: T): boolean { + if (!super.sameRange(other.range)) { + return false; + } + + if (!(other instanceof FileReference)) { + return false; + } + + return this.text === other.text; + } + + /** + * Return a string representation of the token. + */ + public override toString(): string { + return `file-ref("${this.text}")${this.range}`; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptFileReference.ts b/src/vs/workbench/contrib/chat/common/promptFileReference.ts new file mode 100644 index 0000000000000..214e3a628ddf6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptFileReference.ts @@ -0,0 +1,405 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { extUri } from '../../../../base/common/resources.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Location } from '../../../../editor/common/languages.js'; +import { ChatbotPromptCodec } from './codecs/chatPromptCodec/chatPromptCodec.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { FileOpenFailed, NonPromptSnippetFile, RecursiveReference } from './promptFileReferenceErrors.js'; +import { FileChangesEvent, FileChangeType, IFileService, IFileStreamContent } from '../../../../platform/files/common/files.js'; + +/** + * Error conditions that may happen during the file reference resolution. + */ +export type TErrorCondition = FileOpenFailed | RecursiveReference | NonPromptSnippetFile; + +/** + * File extension for the prompt snippet files. + */ +const PROMP_SNIPPET_FILE_EXTENSION: string = '.prompt.md'; + +/** + * Configuration key for the prompt snippets feature. + */ +const PROMPT_SNIPPETS_CONFIG_KEY: string = 'chat.experimental.prompt-snippets'; + +/** + * Represents a file reference in the chatbot prompt, e.g. `#file:./path/to/file.md`. + * Contains logic to resolve all nested file references in the target file and all + * referenced child files recursively, if any. + * + * ## Examples + * + * ```typescript + * const fileReference = new PromptFileReference( + * URI.file('/path/to/file.md'), + * fileService, + * ); + * + * // subscribe to updates to the file reference tree + * fileReference.onUpdate(() => { + * // .. do something with the file reference tree .. + * // e.g. get URIs of all resolved file references in the tree + * const resolved = fileReference + * // get all file references as a flat array + * .flatten() + * // remove self from the list if only child references are needed + * .slice(1) + * // filter out unresolved references + * .filter(reference => reference.resolveFailed === flase) + * // convert to URIs only + * .map(reference => reference.uri); + * + * console.log(resolved); + * }); + * + * // *optional* if need to re-resolve file references when target files change + * // note that this does not sets up filesystem listeners for nested file references + * fileReference.addFilesystemListeners(); + * + * // start resolving the file reference tree; this can also be `await`ed if needed + * // to wait for the resolution on the main file reference to complete (the nested + * // references can still be resolving in the background) + * fileReference.resolve(); + * + * // don't forget to dispose when no longer needed! + * fileReference.dispose(); + * ``` + */ +export class PromptFileReference extends Disposable { + /** + * Chatbot prompt message codec helps to parse out prompt syntax. + */ + private readonly codec = this._register(new ChatbotPromptCodec()); + + /** + * Child references of the current one. + */ + protected readonly children: PromptFileReference[] = []; + + private readonly _onUpdate = this._register(new Emitter()); + /** + * The event is fired when nested prompt snippet references are updated, if any. + */ + public readonly onUpdate = this._onUpdate.event; + + private _errorCondition?: TErrorCondition; + /** + * If file reference resolution fails, this attribute will be set + * to an error instance that describes the error condition. + */ + public get errorCondition(): TErrorCondition | undefined { + return this._errorCondition; + } + + /** + * Whether file reference resolution was attempted at least once. + */ + private _resolveAttempted: boolean = false; + /** + * Whether file references resolution failed. + * Set to `undefined` if the `resolve` method hasn't been ever called yet. + */ + public get resolveFailed(): boolean | undefined { + if (!this._resolveAttempted) { + return undefined; + } + + return !!this._errorCondition; + } + + constructor( + private readonly _uri: URI | Location, + @IFileService private readonly fileService: IFileService, + @IConfigurationService private readonly configService: IConfigurationService, + ) { + super(); + this.onFilesChanged = this.onFilesChanged.bind(this); + + // make sure the variable is updated on file changes + // but only for the prompt snippet files + if (this.isPromptSnippetFile) { + this.addFilesystemListeners(); + } + } + + /** + * Check if the prompt snippets feature is enabled. + * @see {@link PROMPT_SNIPPETS_CONFIG_KEY} + */ + public static promptSnippetsEnabled( + configService: IConfigurationService, + ): boolean { + const value = configService.getValue(PROMPT_SNIPPETS_CONFIG_KEY); + + if (!value) { + return false; + } + + if (typeof value === 'string') { + return value.trim().toLowerCase() === 'true'; + } + + return !!value; + } + + /** + * Check if the current reference points to a prompt snippet file. + */ + public get isPromptSnippetFile(): boolean { + return this.uri.path.endsWith(PROMP_SNIPPET_FILE_EXTENSION); + } + + /** + * Associated URI of the reference. + */ + public get uri(): URI { + return this._uri instanceof URI + ? this._uri + : this._uri.uri; + } + + /** + * Get the parent folder of the file reference. + */ + public get parentFolder() { + return URI.joinPath(this.uri, '..'); + } + + /** + * Check if the current reference points to a given resource. + */ + public sameUri(other: URI | Location): boolean { + const otherUri = other instanceof URI ? other : other.uri; + + return this.uri.toString() === otherUri.toString(); + } + + /** + * Add file system event listeners for the current file reference. + */ + private addFilesystemListeners(): this { + this._register( + this.fileService.onDidFilesChange(this.onFilesChanged), + ); + + return this; + } + + /** + * Event handler for the `onDidFilesChange` event. + */ + private onFilesChanged(event: FileChangesEvent) { + const fileChanged = event.contains(this.uri, FileChangeType.UPDATED); + const fileDeleted = event.contains(this.uri, FileChangeType.DELETED); + if (!fileChanged && !fileDeleted) { + return; + } + + // if file is changed or deleted, re-resolve the file reference + // in the case when the file is deleted, this should result in + // failure to open the file, so the `errorCondition` field will + // be updated to an appropriate error instance and the `children` + // field will be cleared up + this.resolve(); + } + + /** + * Get file stream, if the file exsists. + */ + private async getFileStream(): Promise { + // if URI doesn't point to a prompt snippet file, don't try to resolve it + if (this.uri.path.endsWith(PROMP_SNIPPET_FILE_EXTENSION) === false) { + this._errorCondition = new NonPromptSnippetFile(this.uri); + + return null; + } + + try { + return await this.fileService.readFileStream(this.uri); + } catch (error) { + this._errorCondition = new FileOpenFailed(this.uri, error); + + return null; + } + } + + /** + * Resolve the current file reference on the disk and + * all nested file references that may exist in the file. + * + * @param waitForChildren Whether need to block until all child references are resolved. + */ + public async resolve( + waitForChildren: boolean = false, + ): Promise { + return await this.resolveReference(waitForChildren); + } + + /** + * Private implementation of the {@link resolve} method, that allows + * to pass `seenReferences` list to the recursive calls to prevent + * infinite file reference recursion. + */ + private async resolveReference( + waitForChildren: boolean = false, + seenReferences: string[] = [], + ): Promise { + // remove current error condition from the previous resolve attempt, if any + delete this._errorCondition; + + // dispose current child references, if any exist from a previous resolve + this.disposeChildren(); + + // to prevent infinite file recursion, we keep track of all references in + // the current branch of the file reference tree and check if the current + // file reference has been already seen before + if (seenReferences.includes(this.uri.path)) { + seenReferences.push(this.uri.path); + + this._errorCondition = new RecursiveReference(this.uri, seenReferences); + this._resolveAttempted = true; + this._onUpdate.fire(); + + return this; + } + + // we don't care if reading the file fails below, hence can add the path + // of the current reference to the `seenReferences` set immediately, - + // even if the file doesn't exist, we would never end up in the recursion + seenReferences.push(this.uri.path); + + // try to get stream for the contents of the file, it may + // fail to multiple reasons, e.g. file doesn't exist, etc. + const fileStream = await this.getFileStream(); + this._resolveAttempted = true; + + // failed to open the file, nothing to resolve + if (fileStream === null) { + this._onUpdate.fire(); + + return this; + } + + // get all file references in the file contents + const references = await this.codec.decode(fileStream.value).consumeAll(); + + // recursively resolve all references and add to the `children` array + // + // Note! we don't register the children references as disposables here, because we dispose them + // explicitly in the `dispose` override method of this class. This is done to prevent + // the disposables store to be littered with already-disposed child instances due to + // the fact that the `resolve` method can be called multiple times on target file changes + const childPromises = []; + for (const reference of references) { + const childUri = extUri.resolvePath(this.parentFolder, reference.path); + + const child = new PromptFileReference( + childUri, + this.fileService, + this.configService, + ); + + // subscribe to child updates + this._register(child.onUpdate( + this._onUpdate.fire.bind(this._onUpdate), + )); + this.children.push(child); + + // start resolving the child in the background, including its children + // Note! we have to clone the `seenReferences` list here to ensure that + // different tree branches don't interfere with each other as we + // care about the parent references when checking for recursion + childPromises.push( + child.resolveReference(waitForChildren, [...seenReferences]), + ); + } + + // if should wait for all children to resolve, block here + if (waitForChildren) { + await Promise.all(childPromises); + } + + this._onUpdate.fire(); + + return this; + } + + /** + * Dispose current child file references. + */ + private disposeChildren(): this { + for (const child of this.children) { + child.dispose(); + } + + this.children.length = 0; + this._onUpdate.fire(); + + return this; + } + + /** + * Flatten the current file reference tree into a single array. + */ + public flatten(): readonly PromptFileReference[] { + const result = []; + + // then add self to the result + result.push(this); + + // get flattened children references first + for (const child of this.children) { + result.push(...child.flatten()); + } + + return result; + } + + /** + * Get list of all valid child references. + */ + public get validChildReferences(): readonly PromptFileReference[] { + return this.flatten() + // skip the root reference itself (this variable) + .slice(1) + // filter out unresolved references + .filter(reference => reference.resolveFailed === false); + } + + /** + * Get list of all valid child references as URIs. + */ + public get validFileReferenceUris(): readonly URI[] { + return this.validChildReferences + .map(child => child.uri); + } + + /** + * Check if the current reference is equal to a given one. + */ + public equals(other: PromptFileReference): boolean { + if (!this.sameUri(other.uri)) { + return false; + } + + return true; + } + + /** + * Returns a string representation of this reference. + */ + public override toString() { + return `#file:${this.uri.path}`; + } + + public override dispose() { + this.disposeChildren(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptFileReferenceErrors.ts b/src/vs/workbench/contrib/chat/common/promptFileReferenceErrors.ts new file mode 100644 index 0000000000000..60da24e52a7fd --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptFileReferenceErrors.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; + +/** + * Base resolve error class used when file reference resolution fails. + */ +abstract class ResolveError extends Error { + constructor( + public readonly uri: URI, + message?: string, + options?: ErrorOptions, + ) { + super(message, options); + } + + /** + * Check if provided object is of the same type as this error. + */ + public sameTypeAs(other: unknown): other is typeof this { + if (other === null || other === undefined) { + return false; + } + + return other instanceof this.constructor; + } + + /** + * Check if provided object is equal to this error. + */ + public equal(other: unknown): boolean { + return this.sameTypeAs(other); + } +} + +/** + * Error that reflects the case when attempt to open target file fails. + */ +export class FileOpenFailed extends ResolveError { + constructor( + uri: URI, + public readonly originalError: unknown, + ) { + super( + uri, + `Failed to open file '${uri.toString()}': ${originalError}.`, + ); + } +} + +/** + * Error that reflects the case when attempt resolve nested file + * references failes due to a recursive reference, e.g., + * + * ```markdown + * // a.md + * #file:b.md + * ``` + * + * ```markdown + * // b.md + * #file:a.md + * ``` + */ +export class RecursiveReference extends ResolveError { + constructor( + uri: URI, + public readonly recursivePath: string[], + ) { + const references = recursivePath.join(' -> '); + + super( + uri, + `Recursive references found: ${references}.`, + ); + } + + /** + * Returns a string representation of the recursive path. + */ + public get recursivePathString(): string { + return this.recursivePath.join(' -> '); + } + + /** + * Check if provided object is of the same type as this + * error, contains the same recursive path and URI. + */ + public override equal(other: unknown): other is this { + if (!this.sameTypeAs(other)) { + return false; + } + + if (this.uri.toString() !== other.uri.toString()) { + return false; + } + + return this.recursivePathString === other.recursivePathString; + } + + /** + * Returns a string representation of the error object. + */ + public override toString(): string { + return `"${this.message}"(${this.uri})`; + } +} + +/** + * Error that reflects the case when resource URI does not point to + * a prompt snippet file, hence was not attempted to be resolved. + */ +export class NonPromptSnippetFile extends ResolveError { + constructor( + uri: URI, + message: string = '', + ) { + + const suffix = message ? `: ${message}` : ''; + + super( + uri, + `Resource at ${uri.path} is not a prompt snippet file${suffix}`, + ); + } +} diff --git a/src/vs/workbench/contrib/chat/test/common/codecs/chatPromptCodec.test.ts b/src/vs/workbench/contrib/chat/test/common/codecs/chatPromptCodec.test.ts new file mode 100644 index 0000000000000..64f9194687001 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/codecs/chatPromptCodec.test.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { newWriteableStream } from '../../../../../../base/common/stream.js'; +import { TestDecoder } from '../../../../../../editor/test/common/utils/testDecoder.js'; +import { FileReference } from '../../../common/codecs/chatPromptCodec/tokens/fileReference.js'; +import { ChatbotPromptCodec } from '../../../common/codecs/chatPromptCodec/chatPromptCodec.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ChatbotPromptDecoder, TChatbotPromptToken } from '../../../common/codecs/chatPromptCodec/chatPromptDecoder.js'; + +/** + * A reusable test utility that asserts that a `ChatbotPromptDecoder` instance + * correctly decodes `inputData` into a stream of `TChatbotPromptToken` tokens. + * + * ## Examples + * + * ```typescript + * // create a new test utility instance + * const test = testDisposables.add(new TestChatbotPromptCodec()); + * + * // run the test + * await test.run( + * ' hello #file:./some-file.md world\n', + * [ + * new FileReference( + * new Range(1, 8, 1, 28), + * './some-file.md', + * ), + * ] + * ); + */ +export class TestChatbotPromptCodec extends TestDecoder { + constructor() { + const stream = newWriteableStream(null); + const codec = new ChatbotPromptCodec(); + const decoder = codec.decode(stream); + + super(stream, decoder); + + this._register(codec); + } +} + +suite('ChatbotPromptCodec', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('produces expected tokens', async () => { + const test = testDisposables.add(new TestChatbotPromptCodec()); + + await test.run( + '#file:/etc/hosts some text\t\n for #file:./README.md\t testing\n ✔ purposes\n#file:LICENSE.md ✌ \t#file:.gitignore\n\n\n\t #file:/Users/legomushroom/repos/vscode ', + [ + new FileReference( + new Range(1, 1, 1, 1 + 16), + '/etc/hosts', + ), + new FileReference( + new Range(2, 7, 2, 7 + 17), + './README.md', + ), + new FileReference( + new Range(4, 1, 4, 1 + 16), + 'LICENSE.md', + ), + new FileReference( + new Range(4, 21, 4, 21 + 16), + '.gitignore', + ), + new FileReference( + new Range(7, 5, 7, 5 + 38), + '/Users/legomushroom/repos/vscode', + ), + ], + ); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/codecs/chatPromptDecoder.test.ts b/src/vs/workbench/contrib/chat/test/common/codecs/chatPromptDecoder.test.ts new file mode 100644 index 0000000000000..f87834274acd1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/codecs/chatPromptDecoder.test.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { newWriteableStream } from '../../../../../../base/common/stream.js'; +import { TestDecoder } from '../../../../../../editor/test/common/utils/testDecoder.js'; +import { FileReference } from '../../../common/codecs/chatPromptCodec/tokens/fileReference.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ChatbotPromptDecoder, TChatbotPromptToken } from '../../../common/codecs/chatPromptCodec/chatPromptDecoder.js'; + +/** + * A reusable test utility that asserts that a `ChatbotPromptDecoder` instance + * correctly decodes `inputData` into a stream of `TChatbotPromptToken` tokens. + * + * ## Examples + * + * ```typescript + * // create a new test utility instance + * const test = testDisposables.add(new TestChatbotPromptDecoder()); + * + * // run the test + * await test.run( + * ' hello #file:./some-file.md world\n', + * [ + * new FileReference( + * new Range(1, 8, 1, 28), + * './some-file.md', + * ), + * ] + * ); + */ +export class TestChatbotPromptDecoder extends TestDecoder { + constructor( + ) { + const stream = newWriteableStream(null); + const decoder = new ChatbotPromptDecoder(stream); + + super(stream, decoder); + } +} + +suite('ChatbotPromptDecoder', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('produces expected tokens', async () => { + const test = testDisposables.add( + new TestChatbotPromptDecoder(), + ); + + await test.run( + '\nhaalo!\n message 👾 message #file:./path/to/file1.md \n\n \t#file:a/b/c/filename2.md\t🖖\t#file:other-file.md\nsome text #file:/some/file/with/absolute/path.md\t', + [ + new FileReference( + new Range(3, 21, 3, 21 + 24), + './path/to/file1.md', + ), + new FileReference( + new Range(5, 3, 5, 3 + 24), + 'a/b/c/filename2.md', + ), + new FileReference( + new Range(5, 31, 5, 31 + 19), + 'other-file.md', + ), + new FileReference( + new Range(6, 11, 6, 11 + 38), + '/some/file/with/absolute/path.md', + ), + ], + ); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptFileReference.test.ts b/src/vs/workbench/contrib/chat/test/common/promptFileReference.test.ts new file mode 100644 index 0000000000000..d9e15783e8b37 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptFileReference.test.ts @@ -0,0 +1,473 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { isWindows } from '../../../../../base/common/platform.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { FileService } from '../../../../../platform/files/common/fileService.js'; +import { NullPolicyService } from '../../../../../platform/policy/common/policy.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { PromptFileReference, TErrorCondition } from '../../common/promptFileReference.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationService } from '../../../../../platform/configuration/common/configurationService.js'; +import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { FileOpenFailed, RecursiveReference, NonPromptSnippetFile } from '../../common/promptFileReferenceErrors.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; + +/** + * Represents a file system node. + */ +interface IFilesystemNode { + name: string; +} + +/** + * Represents a file node. + */ +interface IFile extends IFilesystemNode { + contents: string; +} + +/** + * Represents a folder node. + */ +interface IFolder extends IFilesystemNode { + children: (IFolder | IFile)[]; +} + +/** + * Represents a file reference with an expected + * error condition value for testing purposes. + */ +class ExpectedReference extends PromptFileReference { + constructor( + uri: URI, + public readonly error: TErrorCondition | undefined, + ) { + const nullLogService = new NullLogService(); + const nullPolicyService = new NullPolicyService(); + const nullFileService = new FileService(nullLogService); + const nullConfigService = new ConfigurationService( + URI.file('/config.json'), + nullFileService, + nullPolicyService, + nullLogService, + ); + super(uri, nullFileService, nullConfigService); + + this._register(nullFileService); + this._register(nullConfigService); + } + + /** + * Override the error condition getter to + * return the provided expected error value. + */ + public override get errorCondition() { + return this.error; + } +} + +/** + * A reusable test utility to test the `PromptFileReference` class. + */ +class TestPromptFileReference extends Disposable { + constructor( + private readonly fileStructure: IFolder, + private readonly rootFileUri: URI, + private readonly expectedReferences: ExpectedReference[], + @IFileService private readonly fileService: IFileService, + @IConfigurationService private readonly configService: IConfigurationService, + ) { + super(); + + // ensure all the expected references are disposed + for (const expectedReference of this.expectedReferences) { + this._register(expectedReference); + } + + // create in-memory file system + const fileSystemProvider = this._register(new InMemoryFileSystemProvider()); + this._register(this.fileService.registerProvider(Schemas.file, fileSystemProvider)); + } + + /** + * Run the test. + */ + public async run() { + // create the files structure on the disk + await this.createFolder( + this.fileService, + this.fileStructure, + ); + + // start resolving references for the specified root file + const rootReference = this._register(new PromptFileReference( + this.rootFileUri, + this.fileService, + this.configService, + )); + + // resolve the root file reference including all nested references + const resolvedReferences = (await rootReference.resolve(true)) + .flatten(); + + assert.strictEqual( + resolvedReferences.length, + this.expectedReferences.length, + [ + `\nExpected(${this.expectedReferences.length}): [\n ${this.expectedReferences.join('\n ')}\n]`, + `Received(${resolvedReferences.length}): [\n ${resolvedReferences.join('\n ')}\n]`, + ].join('\n') + ); + + for (let i = 0; i < this.expectedReferences.length; i++) { + const expectedReference = this.expectedReferences[i]; + const resolvedReference = resolvedReferences[i]; + + assert( + resolvedReference.equals(expectedReference), + [ + `Expected ${i}th resolved reference to be ${expectedReference}`, + `got ${resolvedReference}.`, + ].join(', '), + ); + + if (expectedReference.errorCondition === undefined) { + assert( + resolvedReference.errorCondition === undefined, + [ + `Expected ${i}th error condition to be 'undefined'`, + `got '${resolvedReference.errorCondition}'.`, + ].join(', '), + ); + continue; + } + + assert( + expectedReference.errorCondition.equal(resolvedReference.errorCondition), + [ + `Expected ${i}th error condition to be '${expectedReference.errorCondition}'`, + `got '${resolvedReference.errorCondition}'.`, + ].join(', '), + ); + } + } + + /** + * Create the provided filesystem folder structure. + */ + async createFolder( + fileService: IFileService, + folder: IFolder, + parentFolder?: URI, + ): Promise { + const folderUri = parentFolder + ? URI.joinPath(parentFolder, folder.name) + : URI.file(folder.name); + + if (await fileService.exists(folderUri)) { + await fileService.del(folderUri); + } + await fileService.createFolder(folderUri); + + for (const child of folder.children) { + const childUri = URI.joinPath(folderUri, child.name); + // create child file + if ('contents' in child) { + await fileService.writeFile(childUri, VSBuffer.fromString(child.contents)); + continue; + } + + // recursively create child filesystem structure + await this.createFolder(fileService, child, folderUri); + } + } +} + +suite('ChatbotPromptReference (Unix)', function () { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + // let parser: ChatRequestParser; + + // let varService: MockObject; + let instantiationService: TestInstantiationService; + setup(async () => { + const nullPolicyService = new NullPolicyService(); + const nullLogService = testDisposables.add(new NullLogService()); + const nullFileService = testDisposables.add(new FileService(nullLogService)); + const nullConfigService = testDisposables.add(new ConfigurationService( + URI.file('/config.json'), + nullFileService, + nullPolicyService, + nullLogService, + )); + instantiationService = testDisposables.add(new TestInstantiationService()); + + instantiationService.stub(IFileService, nullFileService); + instantiationService.stub(ILogService, nullLogService); + instantiationService.stub(IConfigurationService, nullConfigService); + }); + + test('resolves nested file references', async function () { + if (isWindows) { + this.skip(); + } + + const rootFolderName = 'resolves-nested-file-references'; + const rootFolder = `/${rootFolderName}`; + const rootUri = URI.file(rootFolder); + + const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, + /** + * The file structure to be created on the disk for the test. + */ + { + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: '## Some Header\nsome contents\n ', + }, + { + name: 'file2.prompt.md', + contents: '## Files\n\t- this file #file:folder1/file3.prompt.md \n\t- also this #file:./folder1/some-other-folder/file4.prompt.md please!\n ', + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: `\n\n\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents\n some more\t content`, + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference\n\nand some non-prompt #file:./some-non-prompt-file.md', + }, + { + name: 'file.txt', + contents: 'contents of a non-prompt-snippet file', + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + contents: 'another-file.prompt.md contents\t #file:../file.txt', + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }, + /** + * The root file path to start the resolve process from. + */ + URI.file(`/${rootFolderName}/file2.prompt.md`), + /** + * The expected references to be resolved. + */ + [ + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './file2.prompt.md'), + undefined, + )), + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './folder1/file3.prompt.md'), + undefined, + )), + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md'), + undefined, + )), + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder/file.txt'), + new NonPromptSnippetFile( + URI.joinPath(rootUri, './folder1/some-other-folder/file.txt'), + 'Ughh oh!', + ), + )), + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder/file4.prompt.md'), + undefined, + )), + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder/some-non-existing/file.prompt.md'), + new FileOpenFailed( + URI.joinPath(rootUri, './folder1/some-other-folder/some-non-existing/file.prompt.md'), + 'Some error message.', + ), + )), + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder/some-non-prompt-file.md'), + new NonPromptSnippetFile( + URI.joinPath(rootUri, './folder1/some-other-folder/some-non-prompt-file.md'), + 'Oh no!', + ), + )), + ] + )); + + await test.run(); + }); + + test('does not fall into infinite reference recursion', async function () { + if (isWindows) { + this.skip(); + } + + const rootFolderName = 'infinite-recursion'; + const rootFolder = `/${rootFolderName}`; + const rootUri = URI.file(rootFolder); + + const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, + /** + * The file structure to be created on the disk for the test. + */ + { + name: rootFolderName, + children: [ + { + name: 'file1.md', + contents: '## Some Header\nsome contents\n ', + }, + { + name: 'file2.prompt.md', + contents: `## Files\n\t- this file #file:folder1/file3.prompt.md \n\t- also this #file:./folder1/some-other-folder/file4.prompt.md\n\n#file:${rootFolder}/folder1/some-other-folder/file5.prompt.md\t please!\n\t#file:./file1.md `, + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: `\n\n\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents\n some more\t content`, + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: 'this file has a non-existing #file:../some-non-existing/file.prompt.md\t\treference', + }, + { + name: 'file5.prompt.md', + contents: 'this file has a relative recursive #file:../../file2.prompt.md\nreference\n ', + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + // absolute path with recursion + contents: `some test goes\t\nhere #file:${rootFolder}/file2.prompt.md`, + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }, + /** + * The root file path to start the resolve process from. + */ + URI.file(`/${rootFolderName}/file2.prompt.md`), + /** + * The expected references to be resolved. + */ + [ + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './file2.prompt.md'), + undefined, + )), + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './folder1/file3.prompt.md'), + undefined, + )), + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md'), + undefined, + )), + /** + * This reference should be resolved as + * a recursive reference error condition. + * (the absolute reference case) + */ + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './file2.prompt.md'), + new RecursiveReference( + URI.joinPath(rootUri, './file2.prompt.md'), + [ + '/infinite-recursion/file2.prompt.md', + '/infinite-recursion/folder1/file3.prompt.md', + '/infinite-recursion/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md', + '/infinite-recursion/file2.prompt.md', + ], + ), + )), + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder/file4.prompt.md'), + undefined, + )), + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-non-existing/file.prompt.md'), + new FileOpenFailed( + URI.joinPath(rootUri, './folder1/some-non-existing/file.prompt.md'), + 'Some error message.', + ), + )), + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder/file5.prompt.md'), + undefined, + )), + /** + * This reference should be resolved as + * a recursive reference error condition. + * (the relative reference case) + */ + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './file2.prompt.md'), + new RecursiveReference( + URI.joinPath(rootUri, './file2.prompt.md'), + [ + '/infinite-recursion/file2.prompt.md', + '/infinite-recursion/folder1/some-other-folder/file5.prompt.md', + '/infinite-recursion/file2.prompt.md', + ], + ), + )), + testDisposables.add(new ExpectedReference( + URI.joinPath(rootUri, './file1.md'), + new NonPromptSnippetFile( + URI.joinPath(rootUri, './file1.md'), + 'Uggh oh!', + ), + )), + ] + )); + + await test.run(); + }); +});