From 6a45a1ad8354fc9efd024094a1a66d2c2d49a2b2 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 28 Aug 2025 16:54:08 +0200 Subject: [PATCH 01/23] new prompt parser --- src/vs/base/common/yaml.ts | 829 +++++++++++++ src/vs/base/test/common/yaml.test.ts | 1083 +++++++++++++++++ .../promptSyntax/service/newPromptsParser.ts | 94 ++ .../promptSyntax/service/promptsService.ts | 10 +- .../service/promptsServiceImpl.ts | 4 +- 5 files changed, 2016 insertions(+), 4 deletions(-) create mode 100644 src/vs/base/common/yaml.ts create mode 100644 src/vs/base/test/common/yaml.test.ts create mode 100644 src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts diff --git a/src/vs/base/common/yaml.ts b/src/vs/base/common/yaml.ts new file mode 100644 index 00000000000..b6b977e5897 --- /dev/null +++ b/src/vs/base/common/yaml.ts @@ -0,0 +1,829 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Parses a simplified YAML-like input from an iterable of strings (lines). + * Supports objects, arrays, primitive types (string, number, boolean, null). + * Tracks positions for error reporting and node locations. + * + * Limitations: + * - No multi-line strings or block literals + * - No anchors or references + * - No complex types (dates, binary) + * - No special handling for escape sequences in strings + * - Indentation must be consistent (spaces only, no tabs) + * + * @param input Iterable of strings representing lines of the YAML-like input + * @param errors Array to collect parsing errors + * @param options Parsing options + * @returns The parsed representation (ObjectNode, ArrayNode, or primitive node) + */ +export function parse(input: Iterable, errors: YamlParseError[] = [], options: ParseOptions = {}): YamlNode | undefined { + const lines = Array.from(input); + const parser = new YamlParser(lines, errors, options); + return parser.parse(); +} + +export interface YamlParseError { + readonly message: string; + readonly start: Position; + readonly end: Position; + readonly code: string; +} + +export interface ParseOptions { + readonly allowDuplicateKeys?: boolean; +} + +export interface Position { + readonly line: number; + readonly character: number; +} + +export interface YamlStringNode { + readonly type: 'string'; + readonly value: string; + readonly start: Position; + readonly end: Position; +} + +export interface YamlNumberNode { + readonly type: 'number'; + readonly value: number; + readonly start: Position; + readonly end: Position; +} + +export interface YamlBooleanNode { + readonly type: 'boolean'; + readonly value: boolean; + readonly start: Position; + readonly end: Position; +} + +export interface YamlNullNode { + readonly type: 'null'; + readonly value: null; + readonly start: Position; + readonly end: Position; +} + +export interface YamlObjectNode { + readonly type: 'object'; + readonly properties: { key: YamlStringNode; value: YamlNode }[]; + readonly start: Position; + readonly end: Position; +} + +export interface YamlArrayNode { + readonly type: 'array'; + readonly items: YamlNode[]; + readonly start: Position; + readonly end: Position; +} + +export type YamlNode = YamlStringNode | YamlNumberNode | YamlBooleanNode | YamlNullNode | YamlObjectNode | YamlArrayNode; + +// Helper functions for position and node creation +function createPosition(line: number, character: number): Position { + return { line, character }; +} + +// Specialized node creation functions using a more concise approach +function createStringNode(value: string, start: Position, end: Position): YamlStringNode { + return { type: 'string', value, start, end }; +} + +function createNumberNode(value: number, start: Position, end: Position): YamlNumberNode { + return { type: 'number', value, start, end }; +} + +function createBooleanNode(value: boolean, start: Position, end: Position): YamlBooleanNode { + return { type: 'boolean', value, start, end }; +} + +function createNullNode(start: Position, end: Position): YamlNullNode { + return { type: 'null', value: null, start, end }; +} + +function createObjectNode(properties: { key: YamlStringNode; value: YamlNode }[], start: Position, end: Position): YamlObjectNode { + return { type: 'object', start, end, properties }; +} + +function createArrayNode(items: YamlNode[], start: Position, end: Position): YamlArrayNode { + return { type: 'array', start, end, items }; +} + +// Utility functions for parsing +function isWhitespace(char: string): boolean { + return char === ' ' || char === '\t'; +} + +// Simplified number validation using regex +function isValidNumber(value: string): boolean { + return /^-?\d*\.?\d+$/.test(value); +} + +// Lexer/Tokenizer for YAML content +class YamlLexer { + private lines: string[]; + private currentLine: number = 0; + private currentChar: number = 0; + + constructor(lines: string[]) { + this.lines = lines; + } + + getCurrentPosition(): Position { + return createPosition(this.currentLine, this.currentChar); + } + + getCurrentLineNumber(): number { + return this.currentLine; + } + + getCurrentCharNumber(): number { + return this.currentChar; + } + + getCurrentLineText(): string { + return this.currentLine < this.lines.length ? this.lines[this.currentLine] : ''; + } + + savePosition(): { line: number; char: number } { + return { line: this.currentLine, char: this.currentChar }; + } + + restorePosition(pos: { line: number; char: number }): void { + this.currentLine = pos.line; + this.currentChar = pos.char; + } + + isAtEnd(): boolean { + return this.currentLine >= this.lines.length; + } + + getCurrentChar(): string { + if (this.isAtEnd() || this.currentChar >= this.lines[this.currentLine].length) { + return ''; + } + return this.lines[this.currentLine][this.currentChar]; + } + + peek(offset: number = 1): string { + const newChar = this.currentChar + offset; + if (this.currentLine >= this.lines.length || newChar >= this.lines[this.currentLine].length) { + return ''; + } + return this.lines[this.currentLine][newChar]; + } + + advance(): string { + const char = this.getCurrentChar(); + if (this.currentChar >= this.lines[this.currentLine].length && this.currentLine < this.lines.length - 1) { + this.currentLine++; + this.currentChar = 0; + } else { + this.currentChar++; + } + return char; + } + + advanceLine(): void { + this.currentLine++; + this.currentChar = 0; + } + + skipWhitespace(): void { + while (!this.isAtEnd() && this.currentChar < this.lines[this.currentLine].length && isWhitespace(this.getCurrentChar())) { + this.advance(); + } + } + + skipToEndOfLine(): void { + this.currentChar = this.lines[this.currentLine].length; + } + + getIndentation(): number { + if (this.isAtEnd()) { + return 0; + } + let indent = 0; + for (let i = 0; i < this.lines[this.currentLine].length; i++) { + if (this.lines[this.currentLine][i] === ' ') { + indent++; + } else if (this.lines[this.currentLine][i] === '\t') { + indent += 4; // Treat tab as 4 spaces + } else { + break; + } + } + return indent; + } + + moveToNextNonEmptyLine(): void { + while (this.currentLine < this.lines.length) { + // First check current line from current position + if (this.currentChar < this.lines[this.currentLine].length) { + const remainingLine = this.lines[this.currentLine].substring(this.currentChar).trim(); + if (remainingLine.length > 0 && !remainingLine.startsWith('#')) { + this.skipWhitespace(); + return; + } + } + + // Move to next line and check from beginning + this.currentLine++; + this.currentChar = 0; + + if (this.currentLine < this.lines.length) { + const line = this.lines[this.currentLine].trim(); + if (line.length > 0 && !line.startsWith('#')) { + this.skipWhitespace(); + return; + } + } + } + } +} + +// Parser class for handling YAML parsing +class YamlParser { + private lexer: YamlLexer; + private errors: YamlParseError[]; + private options: ParseOptions; + + constructor(lines: string[], errors: YamlParseError[], options: ParseOptions) { + this.lexer = new YamlLexer(lines); + this.errors = errors; + this.options = options; + } + + addError(message: string, code: string, start: Position, end: Position): void { + this.errors.push({ message, code, start, end }); + } + + parseValue(expectedIndent?: number): YamlNode { + this.lexer.skipWhitespace(); + + if (this.lexer.isAtEnd()) { + const pos = this.lexer.getCurrentPosition(); + return createStringNode('', pos, pos); + } + + const char = this.lexer.getCurrentChar(); + + // Handle quoted strings + if (char === '"' || char === `'`) { + return this.parseQuotedString(char); + } + + // Handle inline arrays + if (char === '[') { + return this.parseInlineArray(); + } + + // Handle inline objects + if (char === '{') { + return this.parseInlineObject(); + } + + // Handle unquoted values + return this.parseUnquotedValue(); + } + + parseQuotedString(quote: string): YamlNode { + const start = this.lexer.getCurrentPosition(); + this.lexer.advance(); // Skip opening quote + + let value = ''; + while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== quote) { + value += this.lexer.advance(); + } + + if (this.lexer.getCurrentChar() === quote) { + this.lexer.advance(); // Skip closing quote + } + + const end = this.lexer.getCurrentPosition(); + return createStringNode(value, start, end); + } + + parseUnquotedValue(): YamlNode { + const start = this.lexer.getCurrentPosition(); + let value = ''; + let endPos = start; + + // Helper function to check for value terminators + const isTerminator = (char: string): boolean => + char === '#' || char === ',' || char === ']' || char === '}'; + + // Handle opening quote that might not be closed + const firstChar = this.lexer.getCurrentChar(); + if (firstChar === '"' || firstChar === `'`) { + value += this.lexer.advance(); + endPos = this.lexer.getCurrentPosition(); + + // Continue until we find closing quote or terminator + while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') { + const char = this.lexer.getCurrentChar(); + + if (char === firstChar || isTerminator(char)) { + break; + } + + value += this.lexer.advance(); + endPos = this.lexer.getCurrentPosition(); + } + } else { + // Regular unquoted value + while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') { + const char = this.lexer.getCurrentChar(); + + if (isTerminator(char)) { + break; + } + + value += this.lexer.advance(); + endPos = this.lexer.getCurrentPosition(); + } + } + + value = value.trim(); + + // Adjust end position for trimmed value + if (value.length === 0) { + endPos = start; + } else { + endPos = createPosition(start.line, start.character + value.length); + } + + // Return appropriate node type based on value + return this.createValueNode(value, start, endPos); + } + + private createValueNode(value: string, start: Position, end: Position): YamlNode { + if (value === '') { + return createStringNode('', start, start); + } + + // Boolean values + if (value === 'true') { + return createBooleanNode(true, start, end); + } + if (value === 'false') { + return createBooleanNode(false, start, end); + } + + // Null values + if (value === 'null' || value === '~') { + return createNullNode(start, end); + } + + // Number values + const numberValue = Number(value); + if (!isNaN(numberValue) && isFinite(numberValue) && isValidNumber(value)) { + return createNumberNode(numberValue, start, end); + } + + // Default to string + return createStringNode(value, start, end); + } + + parseInlineArray(): YamlArrayNode { + const start = this.lexer.getCurrentPosition(); + this.lexer.advance(); // Skip '[' + + const items: YamlNode[] = []; + + while (!this.lexer.isAtEnd()) { + this.lexer.skipWhitespace(); + + // Handle end of array + if (this.lexer.getCurrentChar() === ']') { + this.lexer.advance(); + break; + } + + // Handle end of line - continue to next line for multi-line arrays + if (this.lexer.getCurrentChar() === '') { + this.lexer.advanceLine(); + continue; + } + + // Parse array item + const item = this.parseValue(); + items.push(item); + + this.lexer.skipWhitespace(); + + // Handle comma separator + if (this.lexer.getCurrentChar() === ',') { + this.lexer.advance(); + } + } + + const end = this.lexer.getCurrentPosition(); + return createArrayNode(items, start, end); + } + + parseInlineObject(): YamlObjectNode { + const start = this.lexer.getCurrentPosition(); + this.lexer.advance(); // Skip '{' + + const properties: { key: YamlStringNode; value: YamlNode }[] = []; + + while (!this.lexer.isAtEnd()) { + this.lexer.skipWhitespace(); + + // Handle end of object + if (this.lexer.getCurrentChar() === '}') { + this.lexer.advance(); + break; + } + + // Parse key - read until colon + const keyStart = this.lexer.getCurrentPosition(); + let keyValue = ''; + + // Handle quoted keys + if (this.lexer.getCurrentChar() === '"' || this.lexer.getCurrentChar() === `'`) { + const quote = this.lexer.getCurrentChar(); + this.lexer.advance(); // Skip opening quote + + while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== quote) { + keyValue += this.lexer.advance(); + } + + if (this.lexer.getCurrentChar() === quote) { + this.lexer.advance(); // Skip closing quote + } + } else { + // Handle unquoted keys - read until colon + while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== ':') { + keyValue += this.lexer.advance(); + } + } + + keyValue = keyValue.trim(); + const keyEnd = this.lexer.getCurrentPosition(); + const key = createStringNode(keyValue, keyStart, keyEnd); + + this.lexer.skipWhitespace(); + + // Expect colon + if (this.lexer.getCurrentChar() === ':') { + this.lexer.advance(); + } + + this.lexer.skipWhitespace(); + + // Parse value + const value = this.parseValue(); + + properties.push({ key, value }); + + this.lexer.skipWhitespace(); + + // Handle comma separator + if (this.lexer.getCurrentChar() === ',') { + this.lexer.advance(); + } + } + + const end = this.lexer.getCurrentPosition(); + return createObjectNode(properties, start, end); + } + + parseBlockArray(baseIndent: number): YamlArrayNode { + const start = this.lexer.getCurrentPosition(); + const items: YamlNode[] = []; + + while (!this.lexer.isAtEnd()) { + this.lexer.moveToNextNonEmptyLine(); + + if (this.lexer.isAtEnd()) { + break; + } + + const currentIndent = this.lexer.getIndentation(); + + // If indentation is less than expected, we're done with this array + if (currentIndent < baseIndent) { + break; + } + + this.lexer.skipWhitespace(); + + // Check for array item marker + if (this.lexer.getCurrentChar() === '-') { + this.lexer.advance(); // Skip '-' + this.lexer.skipWhitespace(); + + const itemStart = this.lexer.getCurrentPosition(); + + // Check if this is a nested structure + if (this.lexer.getCurrentChar() === '' || this.lexer.getCurrentChar() === '#') { + // Empty item - check if next lines form a nested structure + this.lexer.advanceLine(); + + if (!this.lexer.isAtEnd()) { + const nextIndent = this.lexer.getIndentation(); + + if (nextIndent > currentIndent) { + // Check if the next line starts with a dash (nested array) or has properties (nested object) + this.lexer.skipWhitespace(); + if (this.lexer.getCurrentChar() === '-') { + // It's a nested array + const nestedArray = this.parseBlockArray(nextIndent); + items.push(nestedArray); + } else { + // Check if it looks like an object property (has a colon) + const currentLine = this.lexer.getCurrentLineText(); + const currentPos = this.lexer.getCurrentCharNumber(); + const remainingLine = currentLine.substring(currentPos); + + if (remainingLine.includes(':') && !remainingLine.trim().startsWith('#')) { + // It's a nested object + const nestedObject = this.parseBlockObject(nextIndent, this.lexer.getCurrentCharNumber()); + items.push(nestedObject); + } else { + // Not a nested structure, create empty string + items.push(createStringNode('', itemStart, itemStart)); + } + } + } else { + // No nested content, empty item + items.push(createStringNode('', itemStart, itemStart)); + } + } else { + // End of input, empty item + items.push(createStringNode('', itemStart, itemStart)); + } + } else { + // Parse the item value + // Check if this is a multi-line object by looking for a colon and checking next lines + const currentLine = this.lexer.getCurrentLineText(); + const currentPos = this.lexer.getCurrentCharNumber(); + const remainingLine = currentLine.substring(currentPos); + + // Check if there's a colon on this line (indicating object properties) + const hasColon = remainingLine.includes(':'); + + if (hasColon) { + // Any line with a colon should be treated as an object + // Parse as an object with the current item's indentation as the base + const item = this.parseBlockObject(itemStart.character, itemStart.character); + items.push(item); + } else { + // No colon, parse as regular value + const item = this.parseValue(); + items.push(item); + + // Skip to end of line + while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== '#') { + this.lexer.advance(); + } + this.lexer.advanceLine(); + } + } + } else { + // No dash found at expected indent level, break + break; + } + } + + // Calculate end position based on the last item + let end = start; + if (items.length > 0) { + const lastItem = items[items.length - 1]; + end = lastItem.end; + } else { + // If no items, end is right after the start + end = createPosition(start.line, start.character + 1); + } + + return createArrayNode(items, start, end); + } + + parseBlockObject(baseIndent: number, baseCharPosition?: number): YamlObjectNode { + const start = this.lexer.getCurrentPosition(); + const properties: { key: YamlStringNode; value: YamlNode }[] = []; + const localKeysSeen = new Set(); + + // For parsing from current position (inline object parsing) + const fromCurrentPosition = baseCharPosition !== undefined; + let firstIteration = true; + + while (!this.lexer.isAtEnd()) { + if (!firstIteration || !fromCurrentPosition) { + this.lexer.moveToNextNonEmptyLine(); + } + firstIteration = false; + + if (this.lexer.isAtEnd()) { + break; + } + + const currentIndent = this.lexer.getIndentation(); + + if (fromCurrentPosition) { + // For current position parsing, check character position alignment + this.lexer.skipWhitespace(); + const currentCharPosition = this.lexer.getCurrentCharNumber(); + + if (currentCharPosition < baseCharPosition) { + break; + } + } else { + // For normal block parsing, check indentation level + if (currentIndent < baseIndent) { + break; + } + + // Check for incorrect indentation + if (currentIndent > baseIndent) { + const lineStart = createPosition(this.lexer.getCurrentLineNumber(), 0); + const lineEnd = createPosition(this.lexer.getCurrentLineNumber(), this.lexer.getCurrentLineText().length); + this.addError('Unexpected indentation', 'indentation', lineStart, lineEnd); + + // Try to recover by treating it as a property anyway + this.lexer.skipWhitespace(); + } else { + this.lexer.skipWhitespace(); + } + } + + // Parse key + const keyStart = this.lexer.getCurrentPosition(); + let keyValue = ''; + + while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== ':') { + keyValue += this.lexer.advance(); + } + + keyValue = keyValue.trim(); + const keyEnd = this.lexer.getCurrentPosition(); + const key = createStringNode(keyValue, keyStart, keyEnd); + + // Check for duplicate keys + if (!this.options.allowDuplicateKeys && localKeysSeen.has(keyValue)) { + this.addError(`Duplicate key '${keyValue}'`, 'duplicateKey', keyStart, keyEnd); + } + localKeysSeen.add(keyValue); + + // Expect colon + if (this.lexer.getCurrentChar() === ':') { + this.lexer.advance(); + } + + this.lexer.skipWhitespace(); + + // Determine if value is on same line or next line(s) + let value: YamlNode; + const valueStart = this.lexer.getCurrentPosition(); + + if (this.lexer.getCurrentChar() === '' || this.lexer.getCurrentChar() === '#') { + // Value is on next line(s) or empty + this.lexer.advanceLine(); + + // Check next line for nested content + if (!this.lexer.isAtEnd()) { + const nextIndent = this.lexer.getIndentation(); + + if (nextIndent > currentIndent) { + // Nested content - determine if it's an object or array + this.lexer.skipWhitespace(); + + if (this.lexer.getCurrentChar() === '-') { + value = this.parseBlockArray(nextIndent); + } else { + value = this.parseBlockObject(nextIndent); + } + } else if (!fromCurrentPosition && nextIndent === currentIndent) { + // Same indentation level - check if it's an array item + this.lexer.skipWhitespace(); + + if (this.lexer.getCurrentChar() === '-') { + value = this.parseBlockArray(currentIndent); + } else { + value = createStringNode('', valueStart, valueStart); + } + } else { + value = createStringNode('', valueStart, valueStart); + } + } else { + value = createStringNode('', valueStart, valueStart); + } + } else { + // Value is on the same line + value = this.parseValue(); + + // Skip any remaining content on this line (comments, etc.) + while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== '#') { + if (isWhitespace(this.lexer.getCurrentChar())) { + this.lexer.advance(); + } else { + break; + } + } + + // Skip to end of line if we hit a comment + if (this.lexer.getCurrentChar() === '#') { + this.lexer.skipToEndOfLine(); + } + + // Move to next line for next iteration + if (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() === '') { + this.lexer.advanceLine(); + } + } + + properties.push({ key, value }); + } + + // Calculate the end position based on the last property + let end = start; + if (properties.length > 0) { + const lastProperty = properties[properties.length - 1]; + end = lastProperty.value.end; + } + + return createObjectNode(properties, start, end); + } + + parse(): YamlNode | undefined { + if (this.lexer.isAtEnd()) { + return undefined; + } + + this.lexer.moveToNextNonEmptyLine(); + + if (this.lexer.isAtEnd()) { + return undefined; + } + + // Determine the root structure type + this.lexer.skipWhitespace(); + + if (this.lexer.getCurrentChar() === '-') { + // Check if this is an array item or a negative number + // Look at the character after the dash + const nextChar = this.lexer.peek(); + if (nextChar === ' ' || nextChar === '\t' || nextChar === '' || nextChar === '#') { + // It's an array item (dash followed by whitespace/end/comment) + return this.parseBlockArray(0); + } else { + // It's likely a negative number or other value, treat as single value + return this.parseValue(); + } + } else if (this.lexer.getCurrentChar() === '[') { + // Root is an inline array + return this.parseInlineArray(); + } else if (this.lexer.getCurrentChar() === '{') { + // Root is an inline object + return this.parseInlineObject(); + } else { + // Check if this looks like a key-value pair by looking for a colon + // For single values, there shouldn't be a colon + const currentLine = this.lexer.getCurrentLineText(); + const currentPos = this.lexer.getCurrentCharNumber(); + const remainingLine = currentLine.substring(currentPos); + + // Check if there's a colon that's not inside quotes + let hasColon = false; + let inQuotes = false; + let quoteChar = ''; + + for (let i = 0; i < remainingLine.length; i++) { + const char = remainingLine[i]; + + if (!inQuotes && (char === '"' || char === `'`)) { + inQuotes = true; + quoteChar = char; + } else if (inQuotes && char === quoteChar) { + inQuotes = false; + quoteChar = ''; + } else if (!inQuotes && char === ':') { + hasColon = true; + break; + } else if (!inQuotes && char === '#') { + // Comment starts, stop looking + break; + } + } + + if (hasColon) { + // Root is an object + return this.parseBlockObject(0); + } else { + // Root is a single value + return this.parseValue(); + } + } + } +} + + diff --git a/src/vs/base/test/common/yaml.test.ts b/src/vs/base/test/common/yaml.test.ts new file mode 100644 index 00000000000..49ddbd2596f --- /dev/null +++ b/src/vs/base/test/common/yaml.test.ts @@ -0,0 +1,1083 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { deepStrictEqual, strictEqual, ok } from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; +import { parse, ParseOptions, YamlParseError, Position, YamlNode } from '../../common/yaml.js'; + + +function assertValidParse(input: string[], expected: YamlNode, expectedErrors: YamlParseError[], options?: ParseOptions): void { + const errors: YamlParseError[] = []; + const actual1 = parse(input, errors, options); + deepStrictEqual(actual1, expected); + deepStrictEqual(errors, expectedErrors); +} + +function pos(line: number, character: number): Position { + return { line, character }; +} + +suite('YAML Parser', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('scalars', () => { + + test('numbers', () => { + assertValidParse(['1'], { type: 'number', start: pos(0, 0), end: pos(0, 1), value: 1 }, []); + assertValidParse(['1.234'], { type: 'number', start: pos(0, 0), end: pos(0, 5), value: 1.234 }, []); + assertValidParse(['-42'], { type: 'number', start: pos(0, 0), end: pos(0, 3), value: -42 }, []); + }); + + test('boolean', () => { + assertValidParse(['true'], { type: 'boolean', start: pos(0, 0), end: pos(0, 4), value: true }, []); + assertValidParse(['false'], { type: 'boolean', start: pos(0, 0), end: pos(0, 5), value: false }, []); + }); + + test('null', () => { + assertValidParse(['null'], { type: 'null', start: pos(0, 0), end: pos(0, 4), value: null }, []); + assertValidParse(['~'], { type: 'null', start: pos(0, 0), end: pos(0, 1), value: null }, []); + }); + + test('string', () => { + assertValidParse(['A Developer'], { type: 'string', start: pos(0, 0), end: pos(0, 11), value: 'A Developer' }, []); + assertValidParse(['\'A Developer\''], { type: 'string', start: pos(0, 0), end: pos(0, 13), value: 'A Developer' }, []); + assertValidParse(['"A Developer"'], { type: 'string', start: pos(0, 0), end: pos(0, 13), value: 'A Developer' }, []); + }); + }); + + suite('objects', () => { + + test('simple properties', () => { + assertValidParse(['name: John Doe'], { + type: 'object', start: pos(0, 0), end: pos(0, 14), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, + value: { type: 'string', start: pos(0, 6), end: pos(0, 14), value: 'John Doe' } + } + ] + }, []); + assertValidParse(['age: 30'], { + type: 'object', start: pos(0, 0), end: pos(0, 7), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'age' }, + value: { type: 'number', start: pos(0, 5), end: pos(0, 7), value: 30 } + } + ] + }, []); + assertValidParse(['active: true'], { + type: 'object', start: pos(0, 0), end: pos(0, 12), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'active' }, + value: { type: 'boolean', start: pos(0, 8), end: pos(0, 12), value: true } + } + ] + }, []); + assertValidParse(['value: null'], { + type: 'object', start: pos(0, 0), end: pos(0, 11), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 5), value: 'value' }, + value: { type: 'null', start: pos(0, 7), end: pos(0, 11), value: null } + } + ] + }, []); + }); + + test('multiple properties', () => { + assertValidParse( + [ + 'name: John Doe', + 'age: 30' + ], + { + type: 'object', start: pos(0, 0), end: pos(1, 7), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, + value: { type: 'string', start: pos(0, 6), end: pos(0, 14), value: 'John Doe' } + }, + { + key: { type: 'string', start: pos(1, 0), end: pos(1, 3), value: 'age' }, + value: { type: 'number', start: pos(1, 5), end: pos(1, 7), value: 30 } + } + ] + }, + [] + ); + }); + + test('nested object', () => { + assertValidParse( + [ + 'person:', + ' name: John Doe', + ' age: 30' + ], + { + type: 'object', start: pos(0, 0), end: pos(2, 9), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'person' }, + value: { + type: 'object', start: pos(1, 2), end: pos(2, 9), properties: [ + { + key: { type: 'string', start: pos(1, 2), end: pos(1, 6), value: 'name' }, + value: { type: 'string', start: pos(1, 8), end: pos(1, 16), value: 'John Doe' } + }, + { + key: { type: 'string', start: pos(2, 2), end: pos(2, 5), value: 'age' }, + value: { type: 'number', start: pos(2, 7), end: pos(2, 9), value: 30 } + } + ] + } + } + ] + + }, + [] + ); + }); + + + test('nested objects with address', () => { + assertValidParse( + [ + 'person:', + ' name: John Doe', + ' age: 30', + ' address:', + ' street: 123 Main St', + ' city: Example City' + ], + { + type: 'object', start: pos(0, 0), end: pos(5, 22), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'person' }, + value: { + type: 'object', start: pos(1, 2), end: pos(5, 22), + properties: [ + { + key: { type: 'string', start: pos(1, 2), end: pos(1, 6), value: 'name' }, + value: { type: 'string', start: pos(1, 8), end: pos(1, 16), value: 'John Doe' } + }, + { + key: { type: 'string', start: pos(2, 2), end: pos(2, 5), value: 'age' }, + value: { type: 'number', start: pos(2, 7), end: pos(2, 9), value: 30 } + }, + { + key: { type: 'string', start: pos(3, 2), end: pos(3, 9), value: 'address' }, + value: { + type: 'object', start: pos(4, 4), end: pos(5, 22), properties: [ + { + key: { type: 'string', start: pos(4, 4), end: pos(4, 10), value: 'street' }, + value: { type: 'string', start: pos(4, 12), end: pos(4, 23), value: '123 Main St' } + }, + { + key: { type: 'string', start: pos(5, 4), end: pos(5, 8), value: 'city' }, + value: { type: 'string', start: pos(5, 10), end: pos(5, 22), value: 'Example City' } + } + ] + } + } + ] + } + } + ] + }, + [] + ); + }); + + test('properties without space after colon', () => { + assertValidParse( + ['name:John'], + { + type: 'object', start: pos(0, 0), end: pos(0, 9), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, + value: { type: 'string', start: pos(0, 5), end: pos(0, 9), value: 'John' } + } + ] + }, + [] + ); + + // Test mixed: some properties with space, some without + assertValidParse( + [ + 'config:', + ' database:', + ' host:localhost', + ' port: 5432', + ' credentials:', + ' username:admin', + ' password: secret123' + ], + { + type: 'object', start: pos(0, 0), end: pos(6, 25), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'config' }, + value: { + type: 'object', start: pos(1, 2), end: pos(6, 25), properties: [ + { + key: { type: 'string', start: pos(1, 2), end: pos(1, 10), value: 'database' }, + value: { + type: 'object', start: pos(2, 4), end: pos(6, 25), properties: [ + { + key: { type: 'string', start: pos(2, 4), end: pos(2, 8), value: 'host' }, + value: { type: 'string', start: pos(2, 9), end: pos(2, 18), value: 'localhost' } + }, + { + key: { type: 'string', start: pos(3, 4), end: pos(3, 8), value: 'port' }, + value: { type: 'number', start: pos(3, 10), end: pos(3, 14), value: 5432 } + }, + { + key: { type: 'string', start: pos(4, 4), end: pos(4, 15), value: 'credentials' }, + value: { + type: 'object', start: pos(5, 6), end: pos(6, 25), properties: [ + { + key: { type: 'string', start: pos(5, 6), end: pos(5, 14), value: 'username' }, + value: { type: 'string', start: pos(5, 15), end: pos(5, 20), value: 'admin' } + }, + { + key: { type: 'string', start: pos(6, 6), end: pos(6, 14), value: 'password' }, + value: { type: 'string', start: pos(6, 16), end: pos(6, 25), value: 'secret123' } + } + ] + } + } + ] + } + } + ] + } + } + ] + }, + [] + ); + }); + + test('inline objects', () => { + assertValidParse( + ['{name: John, age: 30}'], + { + type: 'object', start: pos(0, 0), end: pos(0, 21), properties: [ + { + key: { type: 'string', start: pos(0, 1), end: pos(0, 5), value: 'name' }, + value: { type: 'string', start: pos(0, 7), end: pos(0, 11), value: 'John' } + }, + { + key: { type: 'string', start: pos(0, 13), end: pos(0, 16), value: 'age' }, + value: { type: 'number', start: pos(0, 18), end: pos(0, 20), value: 30 } + } + ] + }, + [] + ); + + // Test with different data types + assertValidParse( + ['{active: true, score: 85.5, role: null}'], + { + type: 'object', start: pos(0, 0), end: pos(0, 39), properties: [ + { + key: { type: 'string', start: pos(0, 1), end: pos(0, 7), value: 'active' }, + value: { type: 'boolean', start: pos(0, 9), end: pos(0, 13), value: true } + }, + { + key: { type: 'string', start: pos(0, 15), end: pos(0, 20), value: 'score' }, + value: { type: 'number', start: pos(0, 22), end: pos(0, 26), value: 85.5 } + }, + { + key: { type: 'string', start: pos(0, 28), end: pos(0, 32), value: 'role' }, + value: { type: 'null', start: pos(0, 34), end: pos(0, 38), value: null } + } + ] + }, + [] + ); + + // Test empty inline object + assertValidParse( + ['{}'], + { + type: 'object', start: pos(0, 0), end: pos(0, 2), properties: [] + }, + [] + ); + + // Test inline object with quoted keys and values + assertValidParse( + ['{"name": "John Doe", "age": 30}'], + { + type: 'object', start: pos(0, 0), end: pos(0, 31), properties: [ + { + key: { type: 'string', start: pos(0, 1), end: pos(0, 7), value: 'name' }, + value: { type: 'string', start: pos(0, 9), end: pos(0, 19), value: 'John Doe' } + }, + { + key: { type: 'string', start: pos(0, 21), end: pos(0, 26), value: 'age' }, + value: { type: 'number', start: pos(0, 28), end: pos(0, 30), value: 30 } + } + ] + }, + [] + ); + + // Test inline object without spaces + assertValidParse( + ['{name:John,age:30}'], + { + type: 'object', start: pos(0, 0), end: pos(0, 18), properties: [ + { + key: { type: 'string', start: pos(0, 1), end: pos(0, 5), value: 'name' }, + value: { type: 'string', start: pos(0, 6), end: pos(0, 10), value: 'John' } + }, + { + key: { type: 'string', start: pos(0, 11), end: pos(0, 14), value: 'age' }, + value: { type: 'number', start: pos(0, 15), end: pos(0, 17), value: 30 } + } + ] + }, + [] + ); + }); + + test('special characters in values', () => { + // Test values with special characters + assertValidParse( + [`key: value with \t special chars`], + { + type: 'object', start: pos(0, 0), end: pos(0, 31), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, + value: { type: 'string', start: pos(0, 5), end: pos(0, 31), value: `value with \t special chars` } + } + ] + }, + [] + ); + }); + + test('various whitespace types', () => { + // Test different types of whitespace + assertValidParse( + [`key:\t \t \t value`], + { + type: 'object', start: pos(0, 0), end: pos(0, 15), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, + value: { type: 'string', start: pos(0, 10), end: pos(0, 15), value: 'value' } + } + ] + }, + [] + ); + }); + }); + + suite('arrays', () => { + + + test('arrays', () => { + assertValidParse( + [ + '- Boston Red Sox', + '- Detroit Tigers', + '- New York Yankees' + ], + { + type: 'array', start: pos(0, 0), end: pos(2, 18), items: [ + { type: 'string', start: pos(0, 2), end: pos(0, 16), value: 'Boston Red Sox' }, + { type: 'string', start: pos(1, 2), end: pos(1, 16), value: 'Detroit Tigers' }, + { type: 'string', start: pos(2, 2), end: pos(2, 18), value: 'New York Yankees' } + ] + + }, + [] + ); + }); + + + test('inline arrays', () => { + assertValidParse( + ['[Apple, Banana, Cherry]'], + { + type: 'array', start: pos(0, 0), end: pos(0, 23), items: [ + { type: 'string', start: pos(0, 1), end: pos(0, 6), value: 'Apple' }, + { type: 'string', start: pos(0, 8), end: pos(0, 14), value: 'Banana' }, + { type: 'string', start: pos(0, 16), end: pos(0, 22), value: 'Cherry' } + ] + + }, + [] + ); + }); + + test('multi-line inline arrays', () => { + assertValidParse( + [ + '[', + ' geen, ', + ' yello, red]' + ], + { + type: 'array', start: pos(0, 0), end: pos(2, 15), items: [ + { type: 'string', start: pos(1, 4), end: pos(1, 8), value: 'geen' }, + { type: 'string', start: pos(2, 4), end: pos(2, 9), value: 'yello' }, + { type: 'string', start: pos(2, 11), end: pos(2, 14), value: 'red' } + ] + }, + [] + ); + }); + + test('arrays of arrays', () => { + assertValidParse( + [ + '-', + ' - Apple', + ' - Banana', + ' - Cherry' + ], + { + type: 'array', start: pos(0, 0), end: pos(3, 10), items: [ + { + type: 'array', start: pos(1, 2), end: pos(3, 10), items: [ + { type: 'string', start: pos(1, 4), end: pos(1, 9), value: 'Apple' }, + { type: 'string', start: pos(2, 4), end: pos(2, 10), value: 'Banana' }, + { type: 'string', start: pos(3, 4), end: pos(3, 10), value: 'Cherry' } + ] + } + ] + }, + [] + ); + }); + + test('inline arrays of inline arrays', () => { + assertValidParse( + [ + '[', + ' [ee], [ff, gg]', + ']', + ], + { + type: 'array', start: pos(0, 0), end: pos(2, 1), items: [ + { + type: 'array', start: pos(1, 2), end: pos(1, 6), items: [ + { type: 'string', start: pos(1, 3), end: pos(1, 5), value: 'ee' }, + ], + }, + { + type: 'array', start: pos(1, 8), end: pos(1, 16), items: [ + { type: 'string', start: pos(1, 9), end: pos(1, 11), value: 'ff' }, + { type: 'string', start: pos(1, 13), end: pos(1, 15), value: 'gg' }, + ], + } + ] + }, + [] + ); + }); + + test('object with array containing single object', () => { + assertValidParse( + [ + 'items:', + '- name: John', + ' age: 30' + ], + { + type: 'object', start: pos(0, 0), end: pos(2, 9), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 5), value: 'items' }, + value: { + type: 'array', start: pos(1, 0), end: pos(2, 9), items: [ + { + type: 'object', start: pos(1, 2), end: pos(2, 9), properties: [ + { + key: { type: 'string', start: pos(1, 2), end: pos(1, 6), value: 'name' }, + value: { type: 'string', start: pos(1, 8), end: pos(1, 12), value: 'John' } + }, + { + key: { type: 'string', start: pos(2, 2), end: pos(2, 5), value: 'age' }, + value: { type: 'number', start: pos(2, 7), end: pos(2, 9), value: 30 } + } + ] + } + ] + } + } + ] + }, + [] + ); + }); + + test('arrays of objects', () => { + assertValidParse( + [ + '-', + ' name: one', + '- name: two', + '-', + ' name: three' + ], + { + type: 'array', start: pos(0, 0), end: pos(4, 13), items: [ + { + type: 'object', start: pos(1, 2), end: pos(1, 11), properties: [ + { + key: { type: 'string', start: pos(1, 2), end: pos(1, 6), value: 'name' }, + value: { type: 'string', start: pos(1, 8), end: pos(1, 11), value: 'one' } + } + ] + }, + { + type: 'object', start: pos(2, 2), end: pos(2, 11), properties: [ + { + key: { type: 'string', start: pos(2, 2), end: pos(2, 6), value: 'name' }, + value: { type: 'string', start: pos(2, 8), end: pos(2, 11), value: 'two' } + } + ] + }, + { + type: 'object', start: pos(4, 2), end: pos(4, 13), properties: [ + { + key: { type: 'string', start: pos(4, 2), end: pos(4, 6), value: 'name' }, + value: { type: 'string', start: pos(4, 8), end: pos(4, 13), value: 'three' } + } + ] + } + ] + }, + [] + ); + }); + }); + + suite('complex structures', () => { + + test('array of objects', () => { + assertValidParse( + [ + 'products:', + ' - name: Laptop', + ' price: 999.99', + ' in_stock: true', + ' - name: Mouse', + ' price: 25.50', + ' in_stock: false' + ], + { + type: 'object', start: pos(0, 0), end: pos(6, 19), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 8), value: 'products' }, + value: { + type: 'array', start: pos(1, 2), end: pos(6, 19), items: [ + { + type: 'object', start: pos(1, 4), end: pos(3, 18), properties: [ + { + key: { type: 'string', start: pos(1, 4), end: pos(1, 8), value: 'name' }, + value: { type: 'string', start: pos(1, 10), end: pos(1, 16), value: 'Laptop' } + }, + { + key: { type: 'string', start: pos(2, 4), end: pos(2, 9), value: 'price' }, + value: { type: 'number', start: pos(2, 11), end: pos(2, 17), value: 999.99 } + }, + { + key: { type: 'string', start: pos(3, 4), end: pos(3, 12), value: 'in_stock' }, + value: { type: 'boolean', start: pos(3, 14), end: pos(3, 18), value: true } + } + ] + }, + { + type: 'object', start: pos(4, 4), end: pos(6, 19), properties: [ + { + key: { type: 'string', start: pos(4, 4), end: pos(4, 8), value: 'name' }, + value: { type: 'string', start: pos(4, 10), end: pos(4, 15), value: 'Mouse' } + }, + { + key: { type: 'string', start: pos(5, 4), end: pos(5, 9), value: 'price' }, + value: { type: 'number', start: pos(5, 11), end: pos(5, 16), value: 25.50 } + }, + { + key: { type: 'string', start: pos(6, 4), end: pos(6, 12), value: 'in_stock' }, + value: { type: 'boolean', start: pos(6, 14), end: pos(6, 19), value: false } + } + ] + } + ] + } + } + ] + }, + [] + ); + }); + + test('inline array mixed primitives', () => { + assertValidParse( + ['vals: [1, true, null, "str"]'], + { + type: 'object', start: pos(0, 0), end: pos(0, 28), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'vals' }, + value: { + type: 'array', start: pos(0, 6), end: pos(0, 28), items: [ + { type: 'number', start: pos(0, 7), end: pos(0, 8), value: 1 }, + { type: 'boolean', start: pos(0, 10), end: pos(0, 14), value: true }, + { type: 'null', start: pos(0, 16), end: pos(0, 20), value: null }, + { type: 'string', start: pos(0, 22), end: pos(0, 27), value: 'str' } + ] + } + } + ] + }, + [] + ); + }); + + test('mixed inline structures', () => { + assertValidParse( + ['config: {env: "prod", settings: [true, 42], debug: false}'], + { + type: 'object', start: pos(0, 0), end: pos(0, 57), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'config' }, + value: { + type: 'object', start: pos(0, 8), end: pos(0, 57), properties: [ + { + key: { type: 'string', start: pos(0, 9), end: pos(0, 12), value: 'env' }, + value: { type: 'string', start: pos(0, 14), end: pos(0, 20), value: 'prod' } + }, + { + key: { type: 'string', start: pos(0, 22), end: pos(0, 30), value: 'settings' }, + value: { + type: 'array', start: pos(0, 32), end: pos(0, 42), items: [ + { type: 'boolean', start: pos(0, 33), end: pos(0, 37), value: true }, + { type: 'number', start: pos(0, 39), end: pos(0, 41), value: 42 } + ] + } + }, + { + key: { type: 'string', start: pos(0, 44), end: pos(0, 49), value: 'debug' }, + value: { type: 'boolean', start: pos(0, 51), end: pos(0, 56), value: false } + } + ] + } + } + ] + }, + [] + ); + }); + + test('with comments', () => { + assertValidParse( + [ + `# This is a comment`, + 'name: John Doe # inline comment', + 'age: 30' + ], + { + type: 'object', start: pos(1, 0), end: pos(2, 7), properties: [ + { + key: { type: 'string', start: pos(1, 0), end: pos(1, 4), value: 'name' }, + value: { type: 'string', start: pos(1, 6), end: pos(1, 14), value: 'John Doe' } + }, + { + key: { type: 'string', start: pos(2, 0), end: pos(2, 3), value: 'age' }, + value: { type: 'number', start: pos(2, 5), end: pos(2, 7), value: 30 } + } + ] + }, + [] + ); + }); + }); + + suite('edge cases and error handling', () => { + + + // Edge cases + test('duplicate keys error', () => { + assertValidParse( + [ + 'key: 1', + 'key: 2' + ], + { + type: 'object', start: pos(0, 0), end: pos(1, 6), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, + value: { type: 'number', start: pos(0, 5), end: pos(0, 6), value: 1 } + }, + { + key: { type: 'string', start: pos(1, 0), end: pos(1, 3), value: 'key' }, + value: { type: 'number', start: pos(1, 5), end: pos(1, 6), value: 2 } + } + ] + }, + [ + { + message: "Duplicate key 'key'", + code: 'duplicateKey', + start: pos(1, 0), + end: pos(1, 3) + } + ] + ); + }); + + test('duplicate keys allowed with option', () => { + assertValidParse( + [ + 'key: 1', + 'key: 2' + ], + { + type: 'object', start: pos(0, 0), end: pos(1, 6), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, + value: { type: 'number', start: pos(0, 5), end: pos(0, 6), value: 1 } + }, + { + key: { type: 'string', start: pos(1, 0), end: pos(1, 3), value: 'key' }, + value: { type: 'number', start: pos(1, 5), end: pos(1, 6), value: 2 } + } + ] + }, + [], + { allowDuplicateKeys: true } + ); + }); + + test('unexpected indentation error with recovery', () => { + // Parser reports error but still captures the over-indented property. + assertValidParse( + [ + 'key: 1', + ' stray: value' + ], + { + type: 'object', start: pos(0, 0), end: pos(1, 16), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, + value: { type: 'number', start: pos(0, 5), end: pos(0, 6), value: 1 } + }, + { + key: { type: 'string', start: pos(1, 4), end: pos(1, 9), value: 'stray' }, + value: { type: 'string', start: pos(1, 11), end: pos(1, 16), value: 'value' } + } + ] + }, + [ + { + message: 'Unexpected indentation', + code: 'indentation', + start: pos(1, 0), + end: pos(1, 16) + } + ] + ); + }); + + test('empty values and inline empty array', () => { + assertValidParse( + [ + 'empty:', + 'array: []' + ], + { + type: 'object', start: pos(0, 0), end: pos(1, 9), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 5), value: 'empty' }, + value: { type: 'string', start: pos(0, 6), end: pos(0, 6), value: '' } + }, + { + key: { type: 'string', start: pos(1, 0), end: pos(1, 5), value: 'array' }, + value: { type: 'array', start: pos(1, 7), end: pos(1, 9), items: [] } + } + ] + }, + [] + ); + }); + + + + test('nested empty objects', () => { + // Parser should create nodes for both parent and child, with child having empty string value. + assertValidParse( + [ + 'parent:', + ' child:' + ], + { + type: 'object', start: pos(0, 0), end: pos(1, 8), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'parent' }, + value: { + type: 'object', start: pos(1, 2), end: pos(1, 8), properties: [ + { + key: { type: 'string', start: pos(1, 2), end: pos(1, 7), value: 'child' }, + value: { type: 'string', start: pos(1, 8), end: pos(1, 8), value: '' } + } + ] + } + } + ] + }, + [] + ); + }); + + test('empty object with only colons', () => { + // Test object with empty values + assertValidParse( + ["key1:", "key2:", "key3:"], + { + type: 'object', start: pos(0, 0), end: pos(2, 5), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'key1' }, + value: { type: 'string', start: pos(0, 5), end: pos(0, 5), value: '' } + }, + { + key: { type: 'string', start: pos(1, 0), end: pos(1, 4), value: 'key2' }, + value: { type: 'string', start: pos(1, 5), end: pos(1, 5), value: '' } + }, + { + key: { type: 'string', start: pos(2, 0), end: pos(2, 4), value: 'key3' }, + value: { type: 'string', start: pos(2, 5), end: pos(2, 5), value: '' } + } + ] + }, + [] + ); + }); + + test('large input performance', () => { + // Test that large inputs are handled efficiently + const input = Array.from({ length: 1000 }, (_, i) => `key${i}: value${i}`); + const expectedProperties = Array.from({ length: 1000 }, (_, i) => ({ + key: { type: 'string' as const, start: pos(i, 0), end: pos(i, `key${i}`.length), value: `key${i}` }, + value: { type: 'string' as const, start: pos(i, `key${i}: `.length), end: pos(i, `key${i}: value${i}`.length), value: `value${i}` } + })); + + const start = Date.now(); + assertValidParse( + input, + { + type: 'object', + start: pos(0, 0), + end: pos(999, 'key999: value999'.length), + properties: expectedProperties + }, + [] + ); + const duration = Date.now() - start; + + ok(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); + }); + + test('deeply nested structure performance', () => { + // Test that deeply nested structures are handled efficiently + const lines = []; + for (let i = 0; i < 50; i++) { + const indent = ' '.repeat(i); + lines.push(`${indent}level${i}:`); + } + lines.push(' '.repeat(50) + 'deepValue: reached'); + + const start = Date.now(); + const errors: YamlParseError[] = []; + const result = parse(lines, errors); + const duration = Date.now() - start; + + ok(result); + strictEqual(result.type, 'object'); + strictEqual(errors.length, 0); + ok(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); + }); + + test('malformed array with position issues', () => { + // Test malformed arrays that might cause position advancement issues + assertValidParse( + [ + "key: [", + "", + "", + "", + "" + ], + { + type: 'object', start: pos(0, 0), end: pos(5, 0), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, + value: { type: 'array', start: pos(0, 5), end: pos(5, 0), items: [] } + } + ] + }, + [] + ); + }); + + test('self-referential like structure', () => { + // Test structures that might appear self-referential + assertValidParse( + [ + "a:", + " b:", + " a:", + " b:", + " value: test" + ], + { + type: 'object', start: pos(0, 0), end: pos(4, 19), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 1), value: 'a' }, + value: { + type: 'object', start: pos(1, 2), end: pos(4, 19), properties: [ + { + key: { type: 'string', start: pos(1, 2), end: pos(1, 3), value: 'b' }, + value: { + type: 'object', start: pos(2, 4), end: pos(4, 19), properties: [ + { + key: { type: 'string', start: pos(2, 4), end: pos(2, 5), value: 'a' }, + value: { + type: 'object', start: pos(3, 6), end: pos(4, 19), properties: [ + { + key: { type: 'string', start: pos(3, 6), end: pos(3, 7), value: 'b' }, + value: { + type: 'object', start: pos(4, 8), end: pos(4, 19), properties: [ + { + key: { type: 'string', start: pos(4, 8), end: pos(4, 13), value: 'value' }, + value: { type: 'string', start: pos(4, 15), end: pos(4, 19), value: 'test' } + } + ] + } + } + ] + } + } + ] + } + } + ] + } + } + ] + }, + [] + ); + }); + + test('array with empty lines', () => { + // Test arrays spanning multiple lines with empty lines + assertValidParse( + ["arr: [", "", "item1,", "", "item2", "", "]"], + { + type: 'object', start: pos(0, 0), end: pos(6, 1), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'arr' }, + value: { + type: 'array', start: pos(0, 5), end: pos(6, 1), items: [ + { type: 'string', start: pos(2, 0), end: pos(2, 5), value: 'item1' }, + { type: 'string', start: pos(4, 0), end: pos(4, 5), value: 'item2' } + ] + } + } + ] + }, + [] + ); + }); + + test('whitespace advancement robustness', () => { + // Test that whitespace advancement works correctly + assertValidParse( + [`key: value`], + { + type: 'object', start: pos(0, 0), end: pos(0, 15), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, + value: { type: 'string', start: pos(0, 10), end: pos(0, 15), value: 'value' } + } + ] + }, + [] + ); + }); + + + test('missing end quote in string values', () => { + // Test unclosed double quote - parser treats it as bare string with quote included + assertValidParse( + ['name: "John'], + { + type: 'object', start: pos(0, 0), end: pos(0, 11), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, + value: { type: 'string', start: pos(0, 6), end: pos(0, 11), value: 'John' } + } + ] + }, + [] + ); + + // Test unclosed single quote - parser treats it as bare string with quote included + assertValidParse( + ['description: \'Hello world'], + { + type: 'object', start: pos(0, 0), end: pos(0, 25), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 11), value: 'description' }, + value: { type: 'string', start: pos(0, 13), end: pos(0, 25), value: 'Hello world' } + } + ] + }, + [] + ); + + // Test unclosed quote in multi-line context + assertValidParse( + [ + 'data: "incomplete', + 'next: value' + ], + { + type: 'object', start: pos(0, 0), end: pos(1, 11), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'data' }, + value: { type: 'string', start: pos(0, 6), end: pos(0, 17), value: 'incomplete' } + }, + { + key: { type: 'string', start: pos(1, 0), end: pos(1, 4), value: 'next' }, + value: { type: 'string', start: pos(1, 6), end: pos(1, 11), value: 'value' } + } + ] + }, + [] + ); + + // Test properly quoted strings for comparison + assertValidParse( + ['name: "John"'], + { + type: 'object', start: pos(0, 0), end: pos(0, 12), properties: [ + { + key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, + value: { type: 'string', start: pos(0, 6), end: pos(0, 12), value: 'John' } + } + ] + }, + [] + ); + }); + + + }); + +}); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts new file mode 100644 index 00000000000..15c2ec6a24b --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { splitLines } from '../../../../../../base/common/strings.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { parse, YamlNode, YamlParseError } from '../../../../../../base/common/yaml.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { IPromptParserResult } from './promptsService.js'; + +export class NewPromptsParser { + constructor( + private readonly modelService: IModelService, + private readonly fileService: IFileService, + ) { + // TODO + } + + public async parse(uri: URI): Promise { + const content = await this.getContents(uri); + if (!content) { + return; + } + const lines = splitLines(content); + if (lines.length === 0) { + return createResult(uri, undefined, []); + } + let header: PromptHeader | undefined = undefined; + let body: { references: URI[] } | undefined = undefined; + let bodyStart = 0; + if (lines[0] === '---') { + let headerEnd = lines.indexOf('---', 1); + if (headerEnd === -1) { + headerEnd = lines.length; + bodyStart = lines.length; + } else { + bodyStart = headerEnd + 1; + } + header = this.parseHeader(lines.slice(1, headerEnd !== -1 ? headerEnd : lines.length)); + } + if (bodyStart < lines.length) { + body = this.parseBody(lines.slice(bodyStart)); + } + return createResult(uri, header, body?.references ?? []); + } + + private parseBody(lines: string[]): { references: URI[] } { + const references: URI[] = []; + for (const line of lines) { + const match = line.match(/\[(.+?)\]\((.+?)\)/); + if (match) { + const [, _text, uri] = match; + references.push(URI.file(uri)); + } + } + return { references }; + } + + private parseHeader(lines: string[]): PromptHeader { + const errors: YamlParseError[] = []; + const node = parse(lines, errors); + return new PromptHeader(node, errors); + } + + private async getContents(uri: URI): Promise { + const model = this.modelService.getModel(uri); + if (model) { + return model.getValue(); + } + const content = await this.fileService.readFile(uri); + if (content) { + return content.value.toString(); + } + return undefined; + } +} + +function createResult(uri: URI, header: PromptHeader | undefined, references: URI[]): IPromptParserResult { + return { + uri, + header, + references, + metadata: null + }; +} + +export class PromptHeader { + constructor(public readonly node: YamlNode | undefined, public readonly errors: YamlParseError[]) { + + } +} + diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 577a9e4d1df..21709075896 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -13,7 +13,7 @@ import { TextModelPromptParser } from '../parsers/textModelPromptParser.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { PromptsType } from '../promptTypes.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { ITopError } from '../parsers/types.js'; +import { YamlNode, YamlParseError } from '../../../../../../base/common/yaml.js'; /** * Provides prompt services. @@ -217,6 +217,12 @@ export interface IChatPromptSlashCommand { export interface IPromptParserResult { readonly uri: URI; readonly metadata: TMetadata | null; - readonly topError: ITopError | undefined; readonly references: readonly URI[]; + readonly header?: IPromptHeader; } + +export interface IPromptHeader { + readonly node: YamlNode | undefined; + readonly errors: YamlParseError[]; +} + diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 662aa6673b6..746e9d52b75 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -270,8 +270,8 @@ export class PromptsService extends Disposable implements IPromptsService { return { uri: parser.uri, metadata: parser.metadata, - topError: parser.topError, - references: parser.references.map(ref => ref.uri) + references: parser.references.map(ref => ref.uri), + header: undefined }; } finally { parser?.dispose(); From f999ab90e129f11afae946b258b79c8173252a0a Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 28 Aug 2025 17:05:21 +0200 Subject: [PATCH 02/23] fix tests --- .../common/promptSyntax/service/promptsService.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index f8db1ba18f0..7bba2c0cfbc 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -679,7 +679,7 @@ suite('PromptsService', () => { tools: ['my-tool1'], mode: 'agent', }, - topError: undefined, + header: undefined, references: [file3, file4] }); @@ -690,7 +690,7 @@ suite('PromptsService', () => { promptType: PromptsType.prompt, mode: 'edit', }, - topError: undefined, + header: undefined, references: [nonExistingFolder, yetAnotherFile] }); @@ -702,7 +702,7 @@ suite('PromptsService', () => { description: 'Another file description.', applyTo: '**/*.tsx', }, - topError: undefined, + header: undefined, references: [someOtherFolder, someOtherFolderFile] }); @@ -713,7 +713,7 @@ suite('PromptsService', () => { promptType: PromptsType.instructions, description: 'File 4 splendid description.', }, - topError: undefined, + header: undefined, references: [ URI.joinPath(rootFolderUri, '/folder1/some-other-folder/some-non-existing/file.prompt.md'), URI.joinPath(rootFolderUri, '/folder1/some-other-folder/some-non-prompt-file.md'), From a86f0c916b2f434fbe252bff4680f90ebacd24b9 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 5 Sep 2025 22:31:54 +0200 Subject: [PATCH 03/23] update --- .../promptSyntax/service/newPromptsParser.ts | 221 ++++++++++++------ .../service/promptsServiceImpl.ts | 15 ++ .../service/newPromptsParser.test.ts | 48 ++++ 3 files changed, 211 insertions(+), 73 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts index c6d368c54dd..3f21c87cb43 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts @@ -3,98 +3,173 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { splitLines } from '../../../../../../base/common/strings.js'; +import { Iterable } from '../../../../../../base/common/iterator.js'; +import { dirname, resolvePath } from '../../../../../../base/common/resources.js'; +import { splitLinesIncludeSeparators } from '../../../../../../base/common/strings.js'; import { URI } from '../../../../../../base/common/uri.js'; import { parse, YamlNode, YamlParseError } from '../../../../../../base/common/yaml.js'; -import { IModelService } from '../../../../../../editor/common/services/model.js'; -import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { IVariableReference } from '../../chatModes.js'; -import { IPromptParserResult } from './promptsService.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { chatVariableLeader } from '../../chatParserTypes.js'; export class NewPromptsParser { - constructor( - private readonly modelService: IModelService, - private readonly fileService: IFileService, - ) { - // TODO + constructor() { } - public async parse(uri: URI): Promise { - const content = await this.getContents(uri); - if (!content) { - return; - } - const lines = splitLines(content); - if (lines.length === 0) { - return createResult(uri, undefined, [], []); + public parse(uri: URI, content: string): ParsedPromptFile { + const linesWithEOL = splitLinesIncludeSeparators(content); + if (linesWithEOL.length === 0) { + return new ParsedPromptFile(uri, undefined, undefined); } let header: PromptHeader | undefined = undefined; - - let body: { fileReferences: URI[]; variableReferences: IVariableReference[] } | undefined = undefined; - let bodyStart = 0; - if (lines[0] === '---') { - let headerEnd = lines.indexOf('---', 1); - if (headerEnd === -1) { - headerEnd = lines.length; - bodyStart = lines.length; + let body: PromptBody | undefined = undefined; + let bodyStartLine = 0; + if (linesWithEOL[0].match(/^---[\s\r\n]*$/)) { + let headerEndLine = linesWithEOL.findIndex((line, index) => index > 0 && line.match(/^---[\s\r\n]*$/)); + if (headerEndLine === -1) { + headerEndLine = linesWithEOL.length; + bodyStartLine = linesWithEOL.length; } else { - bodyStart = headerEnd + 1; + bodyStartLine = headerEndLine + 1; } - header = this.parseHeader(lines.slice(1, headerEnd !== -1 ? headerEnd : lines.length)); + // range starts on the line after the ---, and ends at the beginning of the line that has the closing --- + const range = new Range(2, 1, headerEndLine + 1, 1); + header = new PromptHeader(range, linesWithEOL); } - if (bodyStart < lines.length) { - body = this.parseBody(lines.slice(bodyStart)); - } else { - body = { fileReferences: [], variableReferences: [] }; + if (bodyStartLine < linesWithEOL.length) { + // range starts on the line after the ---, and ends at the beginning of line after the last line + const range = new Range(bodyStartLine + 1, 1, linesWithEOL.length + 1, 1); + body = new PromptBody(range, linesWithEOL, uri); } - return createResult(uri, header, body.fileReferences, body.variableReferences); - } - - private parseBody(lines: string[]): { fileReferences: URI[]; variableReferences: IVariableReference[] } { - const fileReferences: URI[] = []; - const variableReferences: IVariableReference[] = []; - for (const line of lines) { - const match = line.match(/\[(.+?)\]\((.+?)\)/); - if (match) { - const [, _text, uri] = match; - fileReferences.push(URI.file(uri)); - } - } - return { fileReferences, variableReferences }; - } - - private parseHeader(lines: string[]): PromptHeader { - const errors: YamlParseError[] = []; - const node = parse(lines, errors); - return new PromptHeader(node, errors); - } - - private async getContents(uri: URI): Promise { - const model = this.modelService.getModel(uri); - if (model) { - return model.getValue(); - } - const content = await this.fileService.readFile(uri); - if (content) { - return content.value.toString(); - } - return undefined; + return new ParsedPromptFile(uri, header, body); } } -function createResult(uri: URI, header: PromptHeader | undefined, fileReferences: URI[], variableReferences: IVariableReference[]): IPromptParserResult { - return { - uri, - header, - fileReferences, - variableReferences, - metadata: null - }; + +export class ParsedPromptFile { + constructor(public readonly uri: URI, public readonly header?: PromptHeader, public readonly body?: PromptBody) { + } +} + +interface ParsedHeader { + readonly node: YamlNode | undefined; + readonly errors: YamlParseError[]; + readonly attributes: IHeaderAttribute[]; } export class PromptHeader { - constructor(public readonly node: YamlNode | undefined, public readonly errors: YamlParseError[]) { + private _parsed: ParsedHeader | undefined; + constructor(public readonly range: Range, private readonly linesWithEOL: string[]) { + } + + public getParsedHeader(): ParsedHeader { + if (this._parsed === undefined) { + const errors: YamlParseError[] = []; + const lines = Iterable.map(Iterable.slice(this.linesWithEOL, this.range.startLineNumber - 1, this.range.endLineNumber - 1), line => line.replace(/[\r\n]+$/, '')); + const node = parse(lines, errors); + const attributes = []; + if (node?.type === 'object') { + for (const property of node.properties) { + attributes.push({ + key: property.key.value, + range: new Range(this.range.startLineNumber + property.key.start.line, property.key.start.character + 1, this.range.startLineNumber + property.value.end.line, property.value.end.character + 1) + }); + } + } + this._parsed = { node, attributes, errors }; + } + return this._parsed; + } + + public get attributes(): IHeaderAttribute[] { + return this.getParsedHeader().attributes; + } +} +interface IHeaderAttribute { + readonly range: Range; + readonly key: string; +} + + +interface ParsedBody { + readonly fileReferences: readonly IBodyFileReference[]; + readonly variableReferences: readonly IBodyVariableReference[]; +} + +export class PromptBody { + private _parsed: ParsedBody | undefined; + + constructor(public readonly range: Range, private readonly linesWithEOL: string[], public readonly uri: URI) { + } + + public get fileReferences(): readonly IBodyFileReference[] { + return this.getParsedBody().fileReferences; + } + + public get variableReferences(): readonly IBodyVariableReference[] { + return this.getParsedBody().variableReferences; + } + + private getParsedBody(): ParsedBody { + if (this._parsed === undefined) { + const fileReferences: IBodyFileReference[] = []; + const variableReferences: IBodyVariableReference[] = []; + for (let i = this.range.startLineNumber - 1; i < this.range.endLineNumber - 1; i++) { + const line = this.linesWithEOL[i]; + const linkMatch = line.matchAll(/\[(.+?)\]\((.+?)\)/g); + for (const match of linkMatch) { + const linkEndOffset = match.index + match[0].length - 1; // before the parenthesis + const linkStartOffset = match.index + match[0].length - match[2].length - 1; + const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1); + fileReferences.push({ content: match[2], range }); + } + const reg = new RegExp(`${chatVariableLeader}([\\w]+:)?([^\\s#]*)`, 'g'); + const matches = line.matchAll(reg); + for (const match of matches) { + const varType = match[1]; + if (varType) { + if (varType === 'file:') { + const linkStartOffset = match.index + match[0].length - match[2].length; + const linkEndOffset = match.index + match[0].length; + const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1); + fileReferences.push({ content: match[2], range }); + } + } else { + const contentStartOffset = match.index + 1; // after the # + const contentEndOffset = match.index + match[0].length; + const range = new Range(i + 1, contentStartOffset + 1, i + 1, contentEndOffset + 1); + variableReferences.push({ content: match[2], range }); + } + } + } + this._parsed = { fileReferences: fileReferences.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)), variableReferences }; + } + return this._parsed; + } + + public resolveFilePath(path: string): URI | undefined { + try { + if (path.startsWith('/')) { + return URI.file(path); + } else if (path.match(/^[a-zA-Z]:\\/)) { + return URI.parse(path); + } else { + return resolvePath(dirname(this.uri), path); + } + } catch { + return undefined; + } } } +interface IBodyFileReference { + content: string; + range: Range; +} + +interface IBodyVariableReference { + content: string; + range: Range; +} + + diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 14eb712360e..45b976408d9 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -27,6 +27,8 @@ import { ILanguageService } from '../../../../../../editor/common/languages/lang import { PromptsConfig } from '../config/config.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { PositionOffsetTransformer } from '../../../../../../editor/common/core/text/positionToOffset.js'; +import { NewPromptsParser, ParsedPromptFile } from './newPromptsParser.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; /** * Provides prompt services. @@ -62,6 +64,7 @@ export class PromptsService extends Disposable implements IPromptsService { @IUserDataProfileService private readonly userDataService: IUserDataProfileService, @ILanguageService private readonly languageService: ILanguageService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IFileService private readonly fileService: IFileService, ) { super(); @@ -302,6 +305,18 @@ export class PromptsService extends Disposable implements IPromptsService { parser?.dispose(); } } + + public async parseNew(uri: URI): Promise { + let content: string | undefined; + const model = this.modelService.getModel(uri); + if (model) { + content = model.getValue(); + } else { + const fileContent = await this.fileService.readFile(uri); + content = fileContent.value.toString(); + } + return new NewPromptsParser().parse(uri, content); + } } export function getPromptCommandName(path: string): string { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts new file mode 100644 index 00000000000..1281fcfeef9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { Range } from '../../../../../../../editor/common/core/range.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { NewPromptsParser } from '../../../../common/promptSyntax/service/newPromptsParser.js'; + +suite('NewPromptsParser', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + + test('provides cached parser instance', async () => { + const uri = URI.parse('file:///test/prompt1.md'); + const content = [ + /* 01 */"---", + /* 02 */`description: "Agent mode test"`, + /* 03 */"mode: agent", + /* 04 */"tools: ['tool1', 'tool2']", + /* 05 */"---", + /* 06 */"This is a builtin agent mode test.", + /* 07 */"Here is a #tool1 variable and a #file:./reference1.md as well as a [reference](./reference2.md).", + ].join('\n'); + const result = new NewPromptsParser().parse(uri, content); + assert.deepEqual(result.uri, uri); + assert.deepEqual(result.header?.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 5, endColumn: 1 }); + assert.deepEqual(result.header?.attributes, [ + { key: 'description', range: new Range(2, 1, 2, 31) }, + { key: 'mode', range: new Range(3, 1, 3, 12) }, + { key: 'tools', range: new Range(4, 1, 4, 26) }, + ]); + + + assert.deepEqual(result.body?.range, { startLineNumber: 6, startColumn: 1, endLineNumber: 8, endColumn: 1 }); + assert.deepEqual(result.body?.fileReferences, [ + { range: new Range(7, 39, 7, 54), content: './reference1.md' }, + { range: new Range(7, 80, 7, 95), content: './reference2.md' } + ]); + assert.deepEqual(result.body?.variableReferences, [ + { range: new Range(7, 12, 7, 17), content: 'tool1' } + ]); + }); + +}); From 6439bdd441671f4457676d1386e5ff35d12409f7 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Mon, 8 Sep 2025 08:15:12 +0200 Subject: [PATCH 04/23] update --- .../promptSyntax/service/newPromptsParser.ts | 108 +++++++++++++++++- .../service/newPromptsParser.test.ts | 65 ++++++++--- 2 files changed, 156 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts index 3f21c87cb43..7a5396f47af 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts @@ -7,7 +7,7 @@ import { Iterable } from '../../../../../../base/common/iterator.js'; import { dirname, resolvePath } from '../../../../../../base/common/resources.js'; import { splitLinesIncludeSeparators } from '../../../../../../base/common/strings.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { parse, YamlNode, YamlParseError } from '../../../../../../base/common/yaml.js'; +import { parse, YamlNode, YamlParseError, Position as YamlPosition } from '../../../../../../base/common/yaml.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { chatVariableLeader } from '../../chatParserTypes.js'; @@ -72,7 +72,8 @@ export class PromptHeader { for (const property of node.properties) { attributes.push({ key: property.key.value, - range: new Range(this.range.startLineNumber + property.key.start.line, property.key.start.character + 1, this.range.startLineNumber + property.value.end.line, property.value.end.character + 1) + range: this.asRange({ start: property.key.start, end: property.value.end }), + value: this.asValue(property.value) }); } } @@ -81,15 +82,112 @@ export class PromptHeader { return this._parsed; } + private asRange({ start, end }: { start: YamlPosition; end: YamlPosition }): Range { + return new Range(this.range.startLineNumber + start.line, start.character + 1, this.range.startLineNumber + end.line, end.character + 1); + } + + private asValue(node: YamlNode): IValue { + switch (node.type) { + case 'string': + return { type: 'string', value: node.value, range: this.asRange(node) }; + case 'number': + return { type: 'number', value: node.value, range: this.asRange(node) }; + case 'boolean': + return { type: 'boolean', value: node.value, range: this.asRange(node) }; + case 'null': + return { type: 'null', value: node.value, range: this.asRange(node) }; + case 'array': + return { type: 'array', items: node.items.map(item => this.asValue(item)), range: this.asRange(node) }; + case 'object': { + const properties = node.properties.map(property => ({ key: this.asValue(property.key) as IStringValue, value: this.asValue(property.value) })); + return { type: 'object', properties, range: this.asRange(node) }; + } + } + } + public get attributes(): IHeaderAttribute[] { return this.getParsedHeader().attributes; } + + private getStringAttribute(key: string): string | undefined { + const attribute = this.getParsedHeader().attributes.find(attr => attr.key === key); + if (attribute?.value.type === 'string') { + return attribute.value.value; + } + return undefined; + } + + public get description(): string | undefined { + return this.getStringAttribute('description'); + } + + public get mode(): string | undefined { + return this.getStringAttribute('mode'); + } + + public get model(): string | undefined { + return this.getStringAttribute('model'); + } + + public get applyTo(): string | undefined { + return this.getStringAttribute('applyTo'); + } + + public get tools(): Map | undefined { + const toolsAttribute = this.getParsedHeader().attributes.find(attr => attr.key === 'tools'); + if (!toolsAttribute) { + return undefined; + } + if (toolsAttribute.value.type === 'array') { + const tools = new Map; + for (const item of toolsAttribute.value.items) { + if (item.type === 'string') { + tools.set(item.value, true); + } + } + return tools; + } else if (toolsAttribute.value.type === 'object') { + const tools = new Map; + const collectLeafs = ({ key, value }: { key: IStringValue; value: IValue }) => { + if (value.type === 'boolean') { + tools.set(key.value, value.value); + } else if (value.type === 'object') { + value.properties.forEach(collectLeafs); + } + }; + toolsAttribute.value.properties.forEach(collectLeafs); + return tools; + } + return undefined; + } + } + interface IHeaderAttribute { readonly range: Range; readonly key: string; + readonly value: IValue; } +export interface IStringValue { readonly type: 'string'; readonly value: string; readonly range: Range } +export interface INumberValue { readonly type: 'number'; readonly value: number; readonly range: Range } +export interface INullValue { readonly type: 'null'; readonly value: null; readonly range: Range } +export interface IBooleanValue { readonly type: 'boolean'; readonly value: boolean; readonly range: Range } + +export interface IArrayValue { + readonly type: 'array'; + readonly items: readonly IValue[]; + readonly range: Range; +} + +export interface IObjectValue { + readonly type: 'object'; + readonly properties: { key: IStringValue; value: IValue }[]; + readonly range: Range; +} + +export type IValue = IStringValue | INumberValue | IBooleanValue | IArrayValue | IObjectValue | INullValue; + interface ParsedBody { readonly fileReferences: readonly IBodyFileReference[]; @@ -112,6 +210,7 @@ export class PromptBody { private getParsedBody(): ParsedBody { if (this._parsed === undefined) { + const markdownLinkRanges: Range[] = []; const fileReferences: IBodyFileReference[] = []; const variableReferences: IBodyVariableReference[] = []; for (let i = this.range.startLineNumber - 1; i < this.range.endLineNumber - 1; i++) { @@ -122,10 +221,15 @@ export class PromptBody { const linkStartOffset = match.index + match[0].length - match[2].length - 1; const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1); fileReferences.push({ content: match[2], range }); + markdownLinkRanges.push(new Range(i + 1, match.index + 1, i + 1, match.index + match[0].length + 1)); } const reg = new RegExp(`${chatVariableLeader}([\\w]+:)?([^\\s#]*)`, 'g'); const matches = line.matchAll(reg); for (const match of matches) { + const fullRange = new Range(i + 1, match.index + 1, i + 1, match.index + match[0].length + 1); + if (markdownLinkRanges.some(mdRange => Range.areIntersectingOrTouching(mdRange, fullRange))) { + continue; + } const varType = match[1]; if (varType) { if (varType === 'file:') { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts index 1281fcfeef9..97fc5649a94 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts @@ -13,36 +13,71 @@ import { NewPromptsParser } from '../../../../common/promptSyntax/service/newPro suite('NewPromptsParser', () => { ensureNoDisposablesAreLeakedInTestSuite(); - - test('provides cached parser instance', async () => { - const uri = URI.parse('file:///test/prompt1.md'); + test('mode', async () => { + const uri = URI.parse('file:///test/chatmode.md'); const content = [ /* 01 */"---", /* 02 */`description: "Agent mode test"`, - /* 03 */"mode: agent", + /* 03 */"model: GPT 4.1", /* 04 */"tools: ['tool1', 'tool2']", /* 05 */"---", - /* 06 */"This is a builtin agent mode test.", + /* 06 */"This is a chat mode test.", /* 07 */"Here is a #tool1 variable and a #file:./reference1.md as well as a [reference](./reference2.md).", ].join('\n'); const result = new NewPromptsParser().parse(uri, content); assert.deepEqual(result.uri, uri); - assert.deepEqual(result.header?.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 5, endColumn: 1 }); - assert.deepEqual(result.header?.attributes, [ - { key: 'description', range: new Range(2, 1, 2, 31) }, - { key: 'mode', range: new Range(3, 1, 3, 12) }, - { key: 'tools', range: new Range(4, 1, 4, 26) }, + assert.ok(result.header); + assert.ok(result.body); + assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 5, endColumn: 1 }); + assert.deepEqual(result.header.attributes, [ + { key: 'description', range: new Range(2, 1, 2, 31), value: { type: 'string', value: 'Agent mode test', range: new Range(2, 14, 2, 31) } }, + { key: 'model', range: new Range(3, 1, 3, 15), value: { type: 'string', value: 'GPT 4.1', range: new Range(3, 8, 3, 15) } }, + { + key: 'tools', range: new Range(4, 1, 4, 26), value: { + type: 'array', + items: [{ type: 'string', value: 'tool1', range: new Range(4, 9, 4, 16) }, { type: 'string', value: 'tool2', range: new Range(4, 18, 4, 25) }], + range: new Range(4, 8, 4, 26) + } + }, ]); - - - assert.deepEqual(result.body?.range, { startLineNumber: 6, startColumn: 1, endLineNumber: 8, endColumn: 1 }); - assert.deepEqual(result.body?.fileReferences, [ + assert.deepEqual(result.body.range, { startLineNumber: 6, startColumn: 1, endLineNumber: 8, endColumn: 1 }); + assert.deepEqual(result.body.fileReferences, [ { range: new Range(7, 39, 7, 54), content: './reference1.md' }, { range: new Range(7, 80, 7, 95), content: './reference2.md' } ]); - assert.deepEqual(result.body?.variableReferences, [ + assert.deepEqual(result.body.variableReferences, [ { range: new Range(7, 12, 7, 17), content: 'tool1' } ]); + assert.deepEqual(result.header.description, 'Agent mode test'); + assert.deepEqual(result.header.model, 'GPT 4.1'); + assert.ok(result.header.tools); + assert.deepEqual([...result.header.tools.entries()], [['tool1', true], ['tool2', true]]); }); + test('instructions', async () => { + const uri = URI.parse('file:///test/prompt1.md'); + const content = [ + /* 01 */"---", + /* 02 */`description: "Code style instructions for TypeScript"`, + /* 03 */"applyTo: *.ts", + /* 04 */"---", + /* 05 */"Follow my companies coding guidlines at [mycomp-ts-guidelines](https://mycomp/guidelines#typescript.md)", + ].join('\n'); + const result = new NewPromptsParser().parse(uri, content); + assert.deepEqual(result.uri, uri); + assert.ok(result.header); + assert.ok(result.body); + assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 4, endColumn: 1 }); + assert.deepEqual(result.header.attributes, [ + { key: 'description', range: new Range(2, 1, 2, 54), value: { type: 'string', value: 'Code style instructions for TypeScript', range: new Range(2, 14, 2, 54) } }, + { key: 'applyTo', range: new Range(3, 1, 3, 14), value: { type: 'string', value: '*.ts', range: new Range(3, 10, 3, 14) } }, + ]); + assert.deepEqual(result.body.range, { startLineNumber: 5, startColumn: 1, endLineNumber: 6, endColumn: 1 }); + assert.deepEqual(result.body.fileReferences, [ + { range: new Range(5, 64, 5, 103), content: 'https://mycomp/guidelines#typescript.md' }, + ]); + assert.deepEqual(result.body.variableReferences, []); + assert.deepEqual(result.header.description, 'Code style instructions for TypeScript'); + assert.deepEqual(result.header.applyTo, '*.ts'); + }); }); From 67e8752460ab5162d6e3bc81bbfd7cec6a18fc4a Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Tue, 9 Sep 2025 16:25:46 +0200 Subject: [PATCH 05/23] update --- src/vs/base/common/yaml.ts | 51 +++++++++++++++------------- src/vs/base/test/common/yaml.test.ts | 9 ++--- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/vs/base/common/yaml.ts b/src/vs/base/common/yaml.ts index b6b977e5897..490d068e0f0 100644 --- a/src/vs/base/common/yaml.ts +++ b/src/vs/base/common/yaml.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /** - * Parses a simplified YAML-like input from an iterable of strings (lines). + * Parses a simplified YAML-like input from a single string. * Supports objects, arrays, primitive types (string, number, boolean, null). * Tracks positions for error reporting and node locations. * @@ -15,13 +15,18 @@ * - No special handling for escape sequences in strings * - Indentation must be consistent (spaces only, no tabs) * - * @param input Iterable of strings representing lines of the YAML-like input + * Notes: + * - New line separators can be either "\n" or "\r\n". The input string is split into lines internally. + * + * @param input A string containing the YAML-like input * @param errors Array to collect parsing errors * @param options Parsing options * @returns The parsed representation (ObjectNode, ArrayNode, or primitive node) */ -export function parse(input: Iterable, errors: YamlParseError[] = [], options: ParseOptions = {}): YamlNode | undefined { - const lines = Array.from(input); +export function parse(input: string, errors: YamlParseError[] = [], options: ParseOptions = {}): YamlNode | undefined { + // Normalize both LF and CRLF by splitting on either; CR characters are not retained as part of line text. + // This keeps the existing line/character based lexer logic intact. + const lines = input.length === 0 ? [] : input.split(/\r\n|\n/); const parser = new YamlParser(lines, errors, options); return parser.parse(); } @@ -254,6 +259,8 @@ class YamlParser { private lexer: YamlLexer; private errors: YamlParseError[]; private options: ParseOptions; + // Track nesting level of flow (inline) collections '[' ']' '{' '}' + private flowLevel: number = 0; constructor(lines: string[], errors: YamlParseError[], options: ParseOptions) { this.lexer = new YamlLexer(lines); @@ -317,51 +324,43 @@ class YamlParser { let endPos = start; // Helper function to check for value terminators - const isTerminator = (char: string): boolean => - char === '#' || char === ',' || char === ']' || char === '}'; + const isTerminator = (char: string): boolean => { + if (char === '#') { return true; } + // Comma, ']' and '}' only terminate inside flow collections + if (this.flowLevel > 0 && (char === ',' || char === ']' || char === '}')) { return true; } + return false; + }; // Handle opening quote that might not be closed const firstChar = this.lexer.getCurrentChar(); if (firstChar === '"' || firstChar === `'`) { value += this.lexer.advance(); endPos = this.lexer.getCurrentPosition(); - - // Continue until we find closing quote or terminator while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') { const char = this.lexer.getCurrentChar(); - if (char === firstChar || isTerminator(char)) { break; } - value += this.lexer.advance(); endPos = this.lexer.getCurrentPosition(); } } else { - // Regular unquoted value while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') { const char = this.lexer.getCurrentChar(); - if (isTerminator(char)) { break; } - value += this.lexer.advance(); endPos = this.lexer.getCurrentPosition(); } } - - value = value.trim(); - - // Adjust end position for trimmed value - if (value.length === 0) { - endPos = start; - } else { - endPos = createPosition(start.line, start.character + value.length); + const trimmed = value.trimEnd(); + const diff = value.length - trimmed.length; + if (diff) { + endPos = createPosition(start.line, endPos.character - diff); } - - // Return appropriate node type based on value - return this.createValueNode(value, start, endPos); + const finalValue = (firstChar === '"' || firstChar === `'`) ? trimmed.substring(1) : trimmed; + return this.createValueNode(finalValue, start, endPos); } private createValueNode(value: string, start: Position, end: Position): YamlNode { @@ -395,6 +394,7 @@ class YamlParser { parseInlineArray(): YamlArrayNode { const start = this.lexer.getCurrentPosition(); this.lexer.advance(); // Skip '[' + this.flowLevel++; const items: YamlNode[] = []; @@ -426,12 +426,14 @@ class YamlParser { } const end = this.lexer.getCurrentPosition(); + this.flowLevel--; return createArrayNode(items, start, end); } parseInlineObject(): YamlObjectNode { const start = this.lexer.getCurrentPosition(); this.lexer.advance(); // Skip '{' + this.flowLevel++; const properties: { key: YamlStringNode; value: YamlNode }[] = []; @@ -494,6 +496,7 @@ class YamlParser { } const end = this.lexer.getCurrentPosition(); + this.flowLevel--; return createObjectNode(properties, start, end); } diff --git a/src/vs/base/test/common/yaml.test.ts b/src/vs/base/test/common/yaml.test.ts index 49ddbd2596f..cba621bf023 100644 --- a/src/vs/base/test/common/yaml.test.ts +++ b/src/vs/base/test/common/yaml.test.ts @@ -3,13 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { deepStrictEqual, strictEqual, ok } from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; import { parse, ParseOptions, YamlParseError, Position, YamlNode } from '../../common/yaml.js'; - +import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; function assertValidParse(input: string[], expected: YamlNode, expectedErrors: YamlParseError[], options?: ParseOptions): void { const errors: YamlParseError[] = []; - const actual1 = parse(input, errors, options); + const text = input.join('\n'); + const actual1 = parse(text, errors, options); deepStrictEqual(actual1, expected); deepStrictEqual(errors, expectedErrors); } @@ -44,6 +44,7 @@ suite('YAML Parser', () => { assertValidParse(['A Developer'], { type: 'string', start: pos(0, 0), end: pos(0, 11), value: 'A Developer' }, []); assertValidParse(['\'A Developer\''], { type: 'string', start: pos(0, 0), end: pos(0, 13), value: 'A Developer' }, []); assertValidParse(['"A Developer"'], { type: 'string', start: pos(0, 0), end: pos(0, 13), value: 'A Developer' }, []); + assertValidParse(['*.js,*.ts'], { type: 'string', start: pos(0, 0), end: pos(0, 9), value: '*.js,*.ts' }, []); }); }); @@ -893,7 +894,7 @@ suite('YAML Parser', () => { const start = Date.now(); const errors: YamlParseError[] = []; - const result = parse(lines, errors); + const result = parse(lines.join('\n'), errors); const duration = Date.now() - start; ok(result); From 9a87c8e34e7d5b8c1224ca4cbe25293c2d4dd9e6 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Tue, 9 Sep 2025 16:29:32 +0200 Subject: [PATCH 06/23] update --- .../chat/common/promptSyntax/service/newPromptsParser.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts index 7a5396f47af..716494a36c3 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Iterable } from '../../../../../../base/common/iterator.js'; import { dirname, resolvePath } from '../../../../../../base/common/resources.js'; import { splitLinesIncludeSeparators } from '../../../../../../base/common/strings.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -65,7 +64,7 @@ export class PromptHeader { public getParsedHeader(): ParsedHeader { if (this._parsed === undefined) { const errors: YamlParseError[] = []; - const lines = Iterable.map(Iterable.slice(this.linesWithEOL, this.range.startLineNumber - 1, this.range.endLineNumber - 1), line => line.replace(/[\r\n]+$/, '')); + const lines = this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join(''); const node = parse(lines, errors); const attributes = []; if (node?.type === 'object') { From ff5c6f710106e6b81e05dbaabfe9ea0b30a43c62 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 10 Sep 2025 16:58:40 +0200 Subject: [PATCH 07/23] update --- .../promptSyntax/promptFileContributions.ts | 13 +- .../promptSyntax/service/newPromptsParser.ts | 21 +- .../promptSyntax/service/promptValidator.ts | 352 ++++++++++++++++++ .../service/newPromptsParser.test.ts | 112 ++++++ 4 files changed, 483 insertions(+), 15 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileContributions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileContributions.ts index 63c908abeeb..71d350b20fa 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileContributions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileContributions.ts @@ -8,12 +8,11 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js'; import { IWorkbenchContributionsRegistry, Extensions, IWorkbenchContribution } from '../../../../common/contributions.js'; import { PromptLinkProvider } from './languageProviders/promptLinkProvider.js'; -import { PromptLinkDiagnosticsInstanceManager } from './languageProviders/promptLinkDiagnosticsProvider.js'; -import { PromptHeaderDiagnosticsInstanceManager } from './languageProviders/promptHeaderDiagnosticsProvider.js'; import { PromptBodyAutocompletion } from './languageProviders/promptBodyAutocompletion.js'; import { PromptHeaderAutocompletion } from './languageProviders/promptHeaderAutocompletion.js'; import { PromptHeaderHoverProvider } from './languageProviders/promptHeaderHovers.js'; import { PromptHeaderDefinitionProvider } from './languageProviders/PromptHeaderDefinitionProvider.js'; +import { PromptValidatorContribution } from './service/promptValidator.js'; /** @@ -24,15 +23,7 @@ export function registerPromptFileContributions(): void { // all language constributions registerContribution(PromptLinkProvider); - registerContribution(PromptLinkDiagnosticsInstanceManager); - registerContribution(PromptHeaderDiagnosticsInstanceManager); - /** - * PromptDecorationsProviderInstanceManager is currently disabled because the only currently - * available decoration is the Front Matter header, which we decided to disable for now. - * Add it back when more decorations are needed. - */ - // registerContribution(PromptDecorationsProviderInstanceManager); , - + registerContribution(PromptValidatorContribution); registerContribution(PromptBodyAutocompletion); registerContribution(PromptHeaderAutocompletion); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts index 716494a36c3..f0c88fcb01d 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts @@ -49,9 +49,15 @@ export class ParsedPromptFile { } } +export interface ParseError { + readonly message: string; + readonly range: Range; + readonly code: string; +} + interface ParsedHeader { readonly node: YamlNode | undefined; - readonly errors: YamlParseError[]; + readonly errors: ParseError[]; readonly attributes: IHeaderAttribute[]; } @@ -63,10 +69,11 @@ export class PromptHeader { public getParsedHeader(): ParsedHeader { if (this._parsed === undefined) { - const errors: YamlParseError[] = []; + const yamlErrors: YamlParseError[] = []; const lines = this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join(''); - const node = parse(lines, errors); + const node = parse(lines, yamlErrors); const attributes = []; + const errors: ParseError[] = yamlErrors.map(err => ({ message: err.message, range: this.asRange(err), code: err.code })); if (node?.type === 'object') { for (const property of node.properties) { attributes.push({ @@ -75,6 +82,8 @@ export class PromptHeader { value: this.asValue(property.value) }); } + } else { + errors.push({ message: 'Invalid header, expecting pairs', range: this.range, code: 'INVALID_YAML' }); } this._parsed = { node, attributes, errors }; } @@ -108,6 +117,10 @@ export class PromptHeader { return this.getParsedHeader().attributes; } + public get errors(): ParseError[] { + return this.getParsedHeader().errors; + } + private getStringAttribute(key: string): string | undefined { const attribute = this.getParsedHeader().attributes.find(attr => attr.key === key); if (attribute?.value.type === 'string') { @@ -162,7 +175,7 @@ export class PromptHeader { } -interface IHeaderAttribute { +export interface IHeaderAttribute { readonly range: Range; readonly key: string; readonly value: IValue; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts new file mode 100644 index 00000000000..488f956733b --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts @@ -0,0 +1,352 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEmptyPattern, parse, splitGlobAware } from '../../../../../../base/common/glob.js'; +import { Iterable } from '../../../../../../base/common/iterator.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { ITextModel } from '../../../../../../editor/common/model.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; +import { localize } from '../../../../../../nls.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../../../platform/markers/common/markers.js'; +import { IChatMode, IChatModeService } from '../../chatModes.js'; +import { ChatModeKind } from '../../constants.js'; +import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; +import { ILanguageModelToolsService } from '../../languageModelToolsService.js'; +import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; +import { IHeaderAttribute, NewPromptsParser, ParsedPromptFile } from './newPromptsParser.js'; +import { PromptsConfig } from '../config/config.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Delayer } from '../../../../../../base/common/async.js'; +import { ResourceMap } from '../../../../../../base/common/map.js'; + +const MARKERS_OWNER_ID = 'prompts-diagnostics-provider'; + +export class PromptValidator { + constructor( + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, + @IChatModeService private readonly chatModeService: IChatModeService, + ) { } + + public validate(promptAST: ParsedPromptFile, model: ITextModel, promptType: PromptsType): IMarkerData[] { + const markers: IMarkerData[] = []; + promptAST.header?.errors.forEach(error => { + markers.push(toMarker(error.message, error.range, MarkerSeverity.Error)); + }); + this.validateHeader(promptAST, model, promptType, markers); + return markers; + } + + private validateHeader(promptAST: ParsedPromptFile, model: ITextModel, promptType: PromptsType, result: IMarkerData[]): void { + const header = promptAST.header; + if (!header) { + return; + } + const validAttributeNames = getValidAttributeNames(promptType); + const attributes = header.attributes; + for (const attribute of attributes) { + if (!validAttributeNames.includes(attribute.key)) { + switch (promptType) { + case PromptsType.prompt: + result.push(toMarker(localize('promptValidator.unknownAttribute.prompt', "Attribute '{0}' is not supported in prompt files. Supported: {1}", attribute.key, validAttributeNames.join(', ')), attribute.range, MarkerSeverity.Warning)); + break; + case PromptsType.mode: + result.push(toMarker(localize('promptValidator.unknownAttribute.mode', "Attribute '{0}' is not supported in mode files. Supported: {1}", attribute.key, validAttributeNames.join(', ')), attribute.range, MarkerSeverity.Warning)); + break; + case PromptsType.instructions: + result.push(toMarker(localize('promptValidator.unknownAttribute.instructions', "Attribute '{0}' is not supported in instructions files. Supported: {1}", attribute.key, validAttributeNames.join(', ')), attribute.range, MarkerSeverity.Warning)); + break; + } + } + } + this.validateDescription(attributes, result); + switch (promptType) { + case PromptsType.prompt: { + const mode = this.validateMode(attributes, result); + this.validateTools(attributes, mode?.kind ?? ChatModeKind.Agent, result); + this.validateModel(attributes, mode?.kind ?? ChatModeKind.Agent, result); + break; + } + case PromptsType.instructions: + this.validateApplyTo(attributes, result); + break; + + case PromptsType.mode: + this.validateTools(attributes, ChatModeKind.Agent, result); + this.validateModel(attributes, ChatModeKind.Agent, result); + break; + + } + } + + private validateDescription(attributes: IHeaderAttribute[], markers: IMarkerData[]): void { + const descriptionAttribute = attributes.find(attr => attr.key === 'description'); + if (!descriptionAttribute) { + return; + } + if (descriptionAttribute.value.type !== 'string') { + markers.push(toMarker(localize('promptValidator.descriptionMustBeString', "The 'description' attribute must be a string."), descriptionAttribute.range, MarkerSeverity.Error)); + return; + } + if (descriptionAttribute.value.value.trim().length === 0) { + markers.push(toMarker(localize('promptValidator.descriptionShouldNotBeEmpty', "The 'description' attribute should not be empty."), descriptionAttribute.value.range, MarkerSeverity.Error)); + return; + } + } + + + private validateModel(attributes: IHeaderAttribute[], modeKind: ChatModeKind, markers: IMarkerData[]): void { + const attribute = attributes.find(attr => attr.key === 'model'); + if (!attribute) { + return; + } + if (attribute.value.type !== 'string') { + markers.push(toMarker(localize('promptValidator.modelMustBeString', "The 'model' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); + return; + } + const modelName = attribute.value.value.trim(); + if (modelName.length === 0) { + markers.push(toMarker(localize('promptValidator.modelMustBeNonEmpty', "The 'model' attribute must be a non-empty string."), attribute.value.range, MarkerSeverity.Error)); + return; + } + + const languageModes = this.languageModelsService.getLanguageModelIds(); + if (languageModes.length === 0) { + // likely the service is not initialized yet + return; + } + const modelMetadata = this.findModelByName(languageModes, modelName); + if (!modelMetadata) { + markers.push(toMarker(localize('promptValidator.modelNotFound', "Unknown model '{0}'", modelName), attribute.value.range, MarkerSeverity.Warning)); + + } else if (modeKind === ChatModeKind.Agent && !ILanguageModelChatMetadata.suitableForAgentMode(modelMetadata)) { + markers.push(toMarker(localize('promptValidator.modelNotSuited', "Model '{0}' is not suited for agent mode", modelName), attribute.value.range, MarkerSeverity.Warning)); + } + } + + findModelByName(languageModes: string[], modelName: string): ILanguageModelChatMetadata | undefined { + for (const model of languageModes) { + const metadata = this.languageModelsService.lookupLanguageModel(model); + if (metadata && metadata.isUserSelectable !== false && ILanguageModelChatMetadata.matchesQualifiedName(modelName, metadata)) { + return metadata; + } + } + return undefined; + } + + private validateMode(attributes: IHeaderAttribute[], markers: IMarkerData[]): IChatMode | undefined { + const attribute = attributes.find(attr => attr.key === 'mode'); + if (!attribute) { + return undefined; // default mode for prompts is Agent + } + if (attribute.value.type !== 'string') { + markers.push(toMarker(localize('promptValidator.modeMustBeString', "The 'mode' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); + return undefined; + } + const modeValue = attribute.value.value; + if (modeValue.trim().length === 0) { + markers.push(toMarker(localize('promptValidator.modeMustBeNonEmpty', "The 'mode' attribute must be a non-empty string."), attribute.value.range, MarkerSeverity.Error)); + return undefined; + } + + const modes = this.chatModeService.getModes(); + const availableModes = []; + + // Check if mode exists in builtin or custom modes + for (const mode of Iterable.concat(modes.builtin, modes.custom)) { + if (mode.name === modeValue) { + return mode; + } + availableModes.push(mode.name); // collect all available mode names + } + + const errorMessage = localize('promptValidator.modeNotFound', "Unknown mode '{0}'. Available modes: {1}", modeValue, availableModes.join(', ')); + markers.push(toMarker(errorMessage, attribute.value.range, MarkerSeverity.Warning)); + return undefined; + } + + private validateTools(attributes: IHeaderAttribute[], modeKind: ChatModeKind, markers: IMarkerData[]): undefined { + const attribute = attributes.find(attr => attr.key === 'tools'); + if (!attribute) { + return; + } + if (modeKind !== ChatModeKind.Agent) { + markers.push(toMarker(localize('promptValidator.toolsOnlyInAgent', "The 'tools' attribute is only supported in agent mode. Attribute will be ignored."), attribute.range, MarkerSeverity.Warning)); + + } + if (attribute.value.type !== 'array') { + markers.push(toMarker(localize('promptValidator.toolsMustBeArray', "The 'tools' attribute must be an array."), attribute.value.range, MarkerSeverity.Error)); + return; + } + + const toolNames = new Map(); + for (const item of attribute.value.items) { + if (item.type !== 'string') { + markers.push(toMarker(localize('promptValidator.eachToolMustBeString', "Each tool name in the 'tools' attribute must be a string."), item.range, MarkerSeverity.Error)); + } else { + toolNames.set(item.value, item.range); + } + } + if (toolNames.size === 0) { + return; + } + for (const tool of this.languageModelToolsService.getTools()) { + toolNames.delete(tool.toolReferenceName ?? tool.displayName); + } + for (const toolSet of this.languageModelToolsService.toolSets.get()) { + toolNames.delete(toolSet.referenceName); + } + + for (const [toolName, range] of toolNames) { + markers.push(toMarker(localize('promptValidator.toolNotFound', "Unknown tool '{0}'", toolName), range, MarkerSeverity.Warning)); + } + } + + private validateApplyTo(attributes: IHeaderAttribute[], markers: IMarkerData[]): undefined { + const attribute = attributes.find(attr => attr.key === 'applyTo'); + if (!attribute) { + return; + } + if (attribute.value.type !== 'string') { + markers.push(toMarker(localize('promptValidator.applyToMustBeString', "The 'applyTo' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); + return; + } + const pattern = attribute.value.value; + try { + const patterns = splitGlobAware(pattern, ','); + if (patterns.length === 0) { + markers.push(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error)); + return; + } + for (const pattern of patterns) { + const globPattern = parse(pattern); + if (isEmptyPattern(globPattern)) { + markers.push(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error)); + return; + } + } + } catch (_error) { + markers.push(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error)); + } + } +} + +function getValidAttributeNames(promptType: PromptsType): string[] { + switch (promptType) { + case PromptsType.prompt: + return ['description', 'model', 'tools', 'mode']; + case PromptsType.instructions: + return ['description', 'applyTo']; + case PromptsType.mode: + return ['description', 'model', 'tools']; + } +} + +function toMarker(message: string, range: Range, severity = MarkerSeverity.Error): IMarkerData { + return { severity, message, ...range }; +} + +export class PromptValidatorContribution extends Disposable { + + private readonly validator: PromptValidator; + private readonly promptParser: NewPromptsParser; + private readonly localDisposables = this._register(new DisposableStore()); + + constructor( + @IModelService private modelService: IModelService, + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService private configService: IConfigurationService, + @IMarkerService private readonly markerService: IMarkerService, + ) { + super(); + this.validator = instantiationService.createInstance(PromptValidator); + this.promptParser = instantiationService.createInstance(NewPromptsParser); + + this.updateRegistration(); + this._register(this.configService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(PromptsConfig.KEY)) { + this.updateRegistration(); + } + })); + } + + updateRegistration(): void { + this.localDisposables.clear(); + if (!PromptsConfig.enabled(this.configService)) { + return; + } + const trackers = new ResourceMap(); + this.localDisposables.add(toDisposable(() => { + trackers.forEach(tracker => tracker.dispose()); + })); + this.modelService.getModels().forEach(model => { + const promptType = getPromptsTypeForLanguageId(model.getLanguageId()); + if (promptType) { + trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptParser, this.markerService)); + } + }); + this.localDisposables.add(this.modelService.onModelAdded((model) => { + const promptType = getPromptsTypeForLanguageId(model.getLanguageId()); + if (promptType) { + trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptParser, this.markerService)); + } + })); + this.localDisposables.add(this.modelService.onModelRemoved((model) => { + const promptType = getPromptsTypeForLanguageId(model.getLanguageId()); + if (promptType) { + const tracker = trackers.get(model.uri); + if (tracker) { + tracker.dispose(); + trackers.delete(model.uri); + } + } + })); + this.localDisposables.add(this.modelService.onModelLanguageChanged((event) => { + const { model } = event; + const tracker = trackers.get(model.uri); + if (tracker) { + tracker.dispose(); + trackers.delete(model.uri); + } + const promptType = getPromptsTypeForLanguageId(model.getLanguageId()); + if (promptType) { + trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptParser, this.markerService)); + } + })); + } +} + +class ModelTracker extends Disposable { + + private readonly delayer: Delayer; + + constructor( + private readonly textModel: ITextModel, + private readonly promptType: PromptsType, + private readonly validator: PromptValidator, + private readonly promptParser: NewPromptsParser, + @IMarkerService private readonly markerService: IMarkerService, + ) { + super(); + this.delayer = this._register(new Delayer(200)); + this._register(textModel.onDidChangeContent(() => this.validate())); + this.validate(); + } + + private validate(): void { + this.delayer.trigger(() => { + const ast = this.promptParser.parse(this.textModel.uri, this.textModel.getValue()); + const markers = this.validator.validate(ast, this.textModel, this.promptType); + this.markerService.changeOne(MARKERS_OWNER_ID, this.textModel.uri, markers); + }); + } + + public override dispose() { + this.markerService.remove(MARKERS_OWNER_ID, [this.textModel.uri]); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts index 97fc5649a94..57352939405 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts @@ -80,4 +80,116 @@ suite('NewPromptsParser', () => { assert.deepEqual(result.header.description, 'Code style instructions for TypeScript'); assert.deepEqual(result.header.applyTo, '*.ts'); }); + + test('prompt file', async () => { + const uri = URI.parse('file:///test/prompt2.md'); + const content = [ + /* 01 */"---", + /* 02 */`description: "General purpose coding assistant"`, + /* 03 */"mode: agent", + /* 04 */"model: GPT 4.1", + /* 05 */"tools: ['search', 'terminal']", + /* 06 */"---", + /* 07 */"This is a prompt file body referencing #search and [docs](https://example.com/docs).", + ].join('\n'); + const result = new NewPromptsParser().parse(uri, content); + assert.deepEqual(result.uri, uri); + assert.ok(result.header); + assert.ok(result.body); + assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 6, endColumn: 1 }); + assert.deepEqual(result.header.attributes, [ + { key: 'description', range: new Range(2, 1, 2, 48), value: { type: 'string', value: 'General purpose coding assistant', range: new Range(2, 14, 2, 48) } }, + { key: 'mode', range: new Range(3, 1, 3, 13), value: { type: 'string', value: 'agent', range: new Range(3, 7, 3, 12) } }, + { key: 'model', range: new Range(4, 1, 4, 15), value: { type: 'string', value: 'GPT 4.1', range: new Range(4, 8, 4, 15) } }, + { + key: 'tools', range: new Range(5, 1, 5, 30), value: { + type: 'array', + items: [{ type: 'string', value: 'search', range: new Range(5, 9, 5, 17) }, { type: 'string', value: 'terminal', range: new Range(5, 19, 5, 29) }], + range: new Range(5, 8, 5, 30) + } + }, + ]); + assert.deepEqual(result.body.range, { startLineNumber: 7, startColumn: 1, endLineNumber: 8, endColumn: 1 }); + assert.deepEqual(result.body.fileReferences, [ + { range: new Range(7, 59, 7, 83), content: 'https://example.com/docs' }, + ]); + assert.deepEqual(result.body.variableReferences, [ + { range: new Range(7, 41, 7, 47), content: 'search' } + ]); + assert.deepEqual(result.header.description, 'General purpose coding assistant'); + assert.deepEqual(result.header.mode, 'prompt'); + assert.deepEqual(result.header.model, 'GPT 4.1'); + assert.ok(result.header.tools); + assert.deepEqual([...result.header.tools.entries()], [['search', true], ['terminal', true]]); + }); + + test('prompt file tools as map', async () => { + const uri = URI.parse('file:///test/prompt2.md'); + const content = [ + /* 01 */"---", + /* 02 */"tools:", + /* 03 */" built-in: true", + /* 04 */" mcp:", + /* 05 */" vscode-playright-mcp:", + /* 06 */" browser-click: true", + /* 07 */" extensions:", + /* 08 */" github.vscode-pull-request-github:", + /* 09 */" openPullRequest: true", + /* 10 */" copilotCodingAgent: false", + /* 11 */"---", + ].join('\n'); + const result = new NewPromptsParser().parse(uri, content); + assert.deepEqual(result.uri, uri); + assert.ok(result.header); + assert.ok(!result.body); + assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 11, endColumn: 1 }); + assert.deepEqual(result.header.attributes, [ + { + key: 'tools', range: new Range(2, 1, 10, 32), value: { + type: 'object', + properties: [ + { + "key": { type: 'string', value: 'built-in', range: new Range(3, 3, 3, 11) }, + "value": { type: 'boolean', value: true, range: new Range(3, 13, 3, 17) } + }, + { + "key": { type: 'string', value: 'mcp', range: new Range(4, 3, 4, 6) }, + "value": { + type: 'object', range: new Range(5, 5, 6, 26), properties: [ + { + "key": { type: 'string', value: 'vscode-playright-mcp', range: new Range(5, 5, 5, 25) }, "value": { + type: 'object', range: new Range(6, 7, 6, 26), properties: [ + { "key": { type: 'string', value: 'browser-click', range: new Range(6, 7, 6, 20) }, "value": { type: 'boolean', value: true, range: new Range(6, 22, 6, 26) } } + ] + } + } + ] + } + }, + { + "key": { type: 'string', value: 'extensions', range: new Range(7, 3, 7, 13) }, + "value": { + type: 'object', range: new Range(8, 5, 10, 32), properties: [ + { + "key": { type: 'string', value: 'github.vscode-pull-request-github', range: new Range(8, 5, 8, 38) }, "value": { + type: 'object', range: new Range(9, 7, 10, 32), properties: [ + { "key": { type: 'string', value: 'openPullRequest', range: new Range(9, 7, 9, 22) }, "value": { type: 'boolean', value: true, range: new Range(9, 24, 9, 28) } }, + { "key": { type: 'string', value: 'copilotCodingAgent', range: new Range(10, 7, 10, 25) }, "value": { type: 'boolean', value: false, range: new Range(10, 27, 10, 32) } } + ] + } + } + ] + } + }, + ], + range: new Range(3, 3, 10, 32) + }, + } + ]); + assert.deepEqual(result.header.description, undefined); + assert.deepEqual(result.header.mode, undefined); + assert.deepEqual(result.header.model, undefined); + assert.ok(result.header.tools); + assert.deepEqual([...result.header.tools.entries()], [['built-in', true], ['browser-click', true], ['openPullRequest', true], ['copilotCodingAgent', false]]); + }); }); From a2b3aee71293c653a9fc868e05e4a1bbaebc7cad Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 10 Sep 2025 18:43:31 +0200 Subject: [PATCH 08/23] update --- .../promptSyntax/service/promptValidator.ts | 18 +- .../chat/test/common/mockChatModeService.ts | 4 +- .../service/newPromptsParser.test.ts | 4 +- .../service/promptValidator.test.ts | 275 ++++++++++++++++++ .../service/promptsService.test.ts | 1 + 5 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptValidator.test.ts diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts index 488f956733b..288c26c6c53 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts @@ -32,16 +32,16 @@ export class PromptValidator { @IChatModeService private readonly chatModeService: IChatModeService, ) { } - public validate(promptAST: ParsedPromptFile, model: ITextModel, promptType: PromptsType): IMarkerData[] { + public validate(promptAST: ParsedPromptFile, promptType: PromptsType): IMarkerData[] { const markers: IMarkerData[] = []; promptAST.header?.errors.forEach(error => { markers.push(toMarker(error.message, error.range, MarkerSeverity.Error)); }); - this.validateHeader(promptAST, model, promptType, markers); + this.validateHeader(promptAST, promptType, markers); return markers; } - private validateHeader(promptAST: ParsedPromptFile, model: ITextModel, promptType: PromptsType, result: IMarkerData[]): void { + private validateHeader(promptAST: ParsedPromptFile, promptType: PromptsType, result: IMarkerData[]): void { const header = promptAST.header; if (!header) { return; @@ -121,14 +121,14 @@ export class PromptValidator { } const modelMetadata = this.findModelByName(languageModes, modelName); if (!modelMetadata) { - markers.push(toMarker(localize('promptValidator.modelNotFound', "Unknown model '{0}'", modelName), attribute.value.range, MarkerSeverity.Warning)); + markers.push(toMarker(localize('promptValidator.modelNotFound', "Unknown model '{0}'.", modelName), attribute.value.range, MarkerSeverity.Warning)); } else if (modeKind === ChatModeKind.Agent && !ILanguageModelChatMetadata.suitableForAgentMode(modelMetadata)) { - markers.push(toMarker(localize('promptValidator.modelNotSuited', "Model '{0}' is not suited for agent mode", modelName), attribute.value.range, MarkerSeverity.Warning)); + markers.push(toMarker(localize('promptValidator.modelNotSuited', "Model '{0}' is not suited for agent mode.", modelName), attribute.value.range, MarkerSeverity.Warning)); } } - findModelByName(languageModes: string[], modelName: string): ILanguageModelChatMetadata | undefined { + private findModelByName(languageModes: string[], modelName: string): ILanguageModelChatMetadata | undefined { for (const model of languageModes) { const metadata = this.languageModelsService.lookupLanguageModel(model); if (metadata && metadata.isUserSelectable !== false && ILanguageModelChatMetadata.matchesQualifiedName(modelName, metadata)) { @@ -164,7 +164,7 @@ export class PromptValidator { availableModes.push(mode.name); // collect all available mode names } - const errorMessage = localize('promptValidator.modeNotFound', "Unknown mode '{0}'. Available modes: {1}", modeValue, availableModes.join(', ')); + const errorMessage = localize('promptValidator.modeNotFound', "Unknown mode '{0}'. Available modes: {1}.", modeValue, availableModes.join(', ')); markers.push(toMarker(errorMessage, attribute.value.range, MarkerSeverity.Warning)); return undefined; } @@ -202,7 +202,7 @@ export class PromptValidator { } for (const [toolName, range] of toolNames) { - markers.push(toMarker(localize('promptValidator.toolNotFound', "Unknown tool '{0}'", toolName), range, MarkerSeverity.Warning)); + markers.push(toMarker(localize('promptValidator.toolNotFound', "Unknown tool '{0}'.", toolName), range, MarkerSeverity.Warning)); } } @@ -340,7 +340,7 @@ class ModelTracker extends Disposable { private validate(): void { this.delayer.trigger(() => { const ast = this.promptParser.parse(this.textModel.uri, this.textModel.getValue()); - const markers = this.validator.validate(ast, this.textModel, this.promptType); + const markers = this.validator.validate(ast, this.promptType); this.markerService.changeOne(MARKERS_OWNER_ID, this.textModel.uri, markers); }); } diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatModeService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatModeService.ts index 300b1f202fc..fc3cdb78daf 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatModeService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatModeService.ts @@ -10,10 +10,10 @@ import { ChatMode, IChatMode, IChatModeService } from '../../common/chatModes.js export class MockChatModeService implements IChatModeService { readonly _serviceBrand: undefined; - private _modes: { builtin: readonly IChatMode[]; custom: readonly IChatMode[] } = { builtin: [ChatMode.Ask], custom: [] }; - public readonly onDidChangeChatModes = Event.None; + constructor(private readonly _modes: { builtin: readonly IChatMode[]; custom: readonly IChatMode[] } = { builtin: [ChatMode.Ask], custom: [] }) { } + getModes(): { builtin: readonly IChatMode[]; custom: readonly IChatMode[] } { return this._modes; } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts index 57352939405..62ee58be278 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts @@ -99,7 +99,7 @@ suite('NewPromptsParser', () => { assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 6, endColumn: 1 }); assert.deepEqual(result.header.attributes, [ { key: 'description', range: new Range(2, 1, 2, 48), value: { type: 'string', value: 'General purpose coding assistant', range: new Range(2, 14, 2, 48) } }, - { key: 'mode', range: new Range(3, 1, 3, 13), value: { type: 'string', value: 'agent', range: new Range(3, 7, 3, 12) } }, + { key: 'mode', range: new Range(3, 1, 3, 12), value: { type: 'string', value: 'agent', range: new Range(3, 7, 3, 12) } }, { key: 'model', range: new Range(4, 1, 4, 15), value: { type: 'string', value: 'GPT 4.1', range: new Range(4, 8, 4, 15) } }, { key: 'tools', range: new Range(5, 1, 5, 30), value: { @@ -117,7 +117,7 @@ suite('NewPromptsParser', () => { { range: new Range(7, 41, 7, 47), content: 'search' } ]); assert.deepEqual(result.header.description, 'General purpose coding assistant'); - assert.deepEqual(result.header.mode, 'prompt'); + assert.deepEqual(result.header.mode, 'agent'); assert.deepEqual(result.header.model, 'GPT 4.1'); assert.ok(result.header.tools); assert.deepEqual([...result.header.tools.entries()], [['search', true], ['terminal', true]]); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptValidator.test.ts new file mode 100644 index 00000000000..58231ccc889 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptValidator.test.ts @@ -0,0 +1,275 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { NewPromptsParser } from '../../../../common/promptSyntax/service/newPromptsParser.js'; +import { PromptValidator } from '../../../../common/promptSyntax/service/promptValidator.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { ILanguageModelToolsService, IToolData, ToolDataSource, ToolSet } from '../../../../common/languageModelToolsService.js'; +import { ObservableSet } from '../../../../../../../base/common/observable.js'; +import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js'; +import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; +import { ChatMode, CustomChatMode, IChatModeService } from '../../../../common/chatModes.js'; +import { MockChatModeService } from '../../mockChatModeService.js'; +import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; +import { IMarkerData, MarkerSeverity } from '../../../../../../../platform/markers/common/markers.js'; +import { getPromptFileExtension } from '../../../../common/promptSyntax/config/promptFileLocations.js'; + +suite('PromptValidator', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let instaService: TestInstantiationService; + + setup(async () => { + instaService = disposables.add(new TestInstantiationService()); + + const testConfigService = new TestConfigurationService(); + testConfigService.setUserConfiguration(PromptsConfig.KEY, true); + + instaService.stub(IConfigurationService, testConfigService); + + const testTool1 = { id: 'testTool1', displayName: 'tool1', canBeReferencedInPrompt: true, modelDescription: 'Test Tool 1', source: ToolDataSource.External, inputSchema: {} } satisfies IToolData; + const testTool2 = { id: 'testTool2', displayName: 'tool2', canBeReferencedInPrompt: true, toolReferenceName: 'tool2', modelDescription: 'Test Tool 2', source: ToolDataSource.External, inputSchema: {} } satisfies IToolData; + + instaService.stub(ILanguageModelToolsService, { + getTools() { return [testTool1, testTool2]; }, + toolSets: new ObservableSet().observable + }); + + const testModels: ILanguageModelChatMetadata[] = [ + { id: 'mae-4', name: 'MAE 4', vendor: 'olama', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true } } satisfies ILanguageModelChatMetadata, + { id: 'mae-4.1', name: 'MAE 4.1', vendor: 'copilot', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true } } satisfies ILanguageModelChatMetadata, + { id: 'mae-3.5-turbo', name: 'MAE 3.5 Turbo', vendor: 'copilot', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024 } satisfies ILanguageModelChatMetadata + ]; + + instaService.stub(ILanguageModelsService, { + getLanguageModelIds() { return testModels.map(m => m.id); }, + lookupLanguageModel(name: string) { + return testModels.find(m => m.id === name); + } + }); + + const customChatMode = new CustomChatMode({ uri: URI.file('/test/chatmode.md'), name: 'BeastMode', body: '', variableReferences: [] }); + instaService.stub(IChatModeService, new MockChatModeService({ builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], custom: [customChatMode] })); + }); + + function validate(code: string, promptType: PromptsType): IMarkerData[] { + const uri = URI.parse('file:///test/chatmode' + getPromptFileExtension(promptType)); + const result = new NewPromptsParser().parse(uri, code); + const validator = instaService.createInstance(PromptValidator); + return validator.validate(result, promptType); + } + suite('modes', () => { + + test('correct mode', async () => { + const content = [ + /* 01 */"---", + /* 02 */`description: "Agent mode test"`, + /* 03 */"model: MAE 4.1", + /* 04 */"tools: ['tool1', 'tool2']", + /* 05 */"---", + /* 06 */"This is a chat mode test.", + /* 07 */"Here is a #tool1 variable and a #file:./reference1.md as well as a [reference](./reference2.md).", + ].join('\n'); + const markers = validate(content, PromptsType.mode); + assert.deepStrictEqual(markers, []); + }); + + test('mode with errors (empty description, unknown tool & model)', async () => { + const content = [ + /* 01 */"---", + /* 02 */`description: ""`, // empty description -> error + /* 03 */"model: MAE 4.2", // unknown model -> warning + /* 04 */"tools: ['tool1', 'tool2', 'tool3']", // tool3 unknown -> warning + /* 05 */"---", + /* 06 */"Body", + ].join('\n'); + const markers = validate(content, PromptsType.mode); + assert.strictEqual(markers.length, 3, 'Expected 3 validation issues'); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: "The 'description' attribute should not be empty." }, + { severity: MarkerSeverity.Warning, message: "Unknown tool 'tool3'." }, + { severity: MarkerSeverity.Warning, message: "Unknown model 'MAE 4.2'." }, + ] + ); + }); + + test('tools must be array', async () => { + const content = [ + "---", + "description: \"Test\"", + "tools: 'tool1'", + "---", + ].join('\n'); + const markers = validate(content, PromptsType.mode); + assert.strictEqual(markers.length, 1); + assert.deepStrictEqual(markers.map(m => m.message), ["The 'tools' attribute must be an array."]); + }); + + test('each tool must be string', async () => { + const content = [ + "---", + "description: \"Test\"", + "tools: ['tool1', 2]", + "---", + ].join('\n'); + const markers = validate(content, PromptsType.mode); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].message, "Each tool name in the 'tools' attribute must be a string."); + }); + + test('unknown attribute in mode file', async () => { + const content = [ + "---", + "description: \"Test\"", + "applyTo: '*.ts'", // not allowed in mode file + "---", + ].join('\n'); + const markers = validate(content, PromptsType.mode); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.ok(markers[0].message.startsWith("Attribute 'applyTo' is not supported in mode files.")); + }); + }); + + suite('instructions', () => { + + test('instructions valid', async () => { + const content = [ + "---", + "description: \"Instr\"", + "applyTo: *.ts,*.js", + "---", + ].join('\n'); + const markers = validate(content, PromptsType.instructions); + assert.deepEqual(markers, []); + }); + + test('instructions invalid applyTo type', async () => { + const content = [ + "---", + "description: \"Instr\"", + "applyTo: 5", + "---", + ].join('\n'); + const markers = validate(content, PromptsType.instructions); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].message, "The 'applyTo' attribute must be a string."); + }); + + test('instructions invalid applyTo glob & unknown attribute', async () => { + const content = [ + "---", + "description: \"Instr\"", + "applyTo: ''", // empty -> invalid glob + "model: mae-4", // model not allowed in instructions + "---", + ].join('\n'); + const markers = validate(content, PromptsType.instructions); + assert.strictEqual(markers.length, 2); + // Order: unknown attribute warnings first (attribute iteration) then applyTo validation + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.ok(markers[0].message.startsWith("Attribute 'model' is not supported in instructions files.")); + assert.strictEqual(markers[1].message, "The 'applyTo' attribute must be a valid glob pattern."); + }); + + test('invalid header structure (YAML array)', async () => { + const content = [ + "---", + "- item1", + "---", + "Body", + ].join('\n'); + const markers = validate(content, PromptsType.instructions); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].message, 'Invalid header, expecting pairs'); + }); + }); + + suite('prompts', () => { + + test('prompt valid with agent mode (default) and tools and a BYO model', async () => { + // mode omitted -> defaults to Agent; tools+model should validate; model MAE 4 is agent capable + const content = [ + '---', + 'description: "Prompt with tools"', + "model: MAE 4 (olama)", + "tools: ['tool1','tool2']", + '---', + 'Body' + ].join('\n'); + const markers = validate(content, PromptsType.prompt); + assert.deepStrictEqual(markers, []); + }); + + test('prompt model not suited for agent mode', async () => { + // MAE 3.5 Turbo lacks agentMode capability -> warning when used in agent (default) mode + const content = [ + '---', + 'description: "Prompt with unsuitable model"', + "model: MAE 3.5 Turbo", + '---', + 'Body' + ].join('\n'); + const markers = validate(content, PromptsType.prompt); + assert.strictEqual(markers.length, 1, 'Expected one warning about unsuitable model'); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, "Model 'MAE 3.5 Turbo' is not suited for agent mode."); + }); + + test('prompt with custom mode BeastMode and tools', async () => { + // Explicit custom mode should be recognized; BeastMode kind comes from setup; ensure tools accepted + const content = [ + '---', + 'description: "Prompt custom mode"', + 'mode: BeastMode', + "tools: ['tool1']", + '---', + 'Body' + ].join('\n'); + const markers = validate(content, PromptsType.prompt); + assert.deepStrictEqual(markers, []); + }); + + test('prompt with mode Ask and tools warns', async () => { + const content = [ + '---', + 'description: "Prompt ask mode with tools"', + 'mode: Ask', + "tools: ['tool1','tool2']", + '---', + 'Body' + ].join('\n'); + const markers = validate(content, PromptsType.prompt); + assert.strictEqual(markers.length, 1, 'Expected one warning about unknown mode'); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, "Unknown mode 'Ask'. Available modes: agent, ask, edit, BeastMode."); + }); + + test('prompt with mode edit', async () => { + const content = [ + '---', + 'description: "Prompt edit mode with tool"', + 'mode: edit', + "tools: ['tool1']", + '---', + 'Body' + ].join('\n'); + const markers = validate(content, PromptsType.prompt); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, "The 'tools' attribute is only supported in agent mode. Attribute will be ignored."); + }); + }); + +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index ffb62a68d0d..69ea302d960 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -699,6 +699,7 @@ suite('PromptsService', () => { const result1 = await service.parse(rootFileUri, PromptsType.prompt, CancellationToken.None); assert.deepEqual(result1, { uri: rootFileUri, + header: undefined, metadata: { promptType: PromptsType.prompt, description: 'Root prompt description.', From 7c1912cde1b6f9519197f9238353db1d7074055b Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 10 Sep 2025 20:29:52 +0200 Subject: [PATCH 09/23] update --- .../promptSyntax/service/promptValidator.ts | 162 +++++++++++------- .../service/promptValidator.test.ts | 98 ++++++++--- 2 files changed, 179 insertions(+), 81 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts index 288c26c6c53..fe9cfa3f8c7 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts @@ -22,6 +22,7 @@ import { PromptsConfig } from '../config/config.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; const MARKERS_OWNER_ID = 'prompts-diagnostics-provider'; @@ -30,18 +31,55 @@ export class PromptValidator { @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @IChatModeService private readonly chatModeService: IChatModeService, + @IFileService private readonly fileService: IFileService, ) { } - public validate(promptAST: ParsedPromptFile, promptType: PromptsType): IMarkerData[] { - const markers: IMarkerData[] = []; - promptAST.header?.errors.forEach(error => { - markers.push(toMarker(error.message, error.range, MarkerSeverity.Error)); - }); - this.validateHeader(promptAST, promptType, markers); - return markers; + public async validate(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { + promptAST.header?.errors.forEach(error => report(toMarker(error.message, error.range, MarkerSeverity.Error))); + this.validateHeader(promptAST, promptType, report); + await this.validateBody(promptAST, report); } - private validateHeader(promptAST: ParsedPromptFile, promptType: PromptsType, result: IMarkerData[]): void { + private async validateBody(promptAST: ParsedPromptFile, report: (markers: IMarkerData) => void): Promise { + const body = promptAST.body; + if (!body) { + return; + } + + // Validate file references + const fileReferenceChecks: Promise[] = []; + for (const ref of body.fileReferences) { + const resolved = body.resolveFilePath(ref.content); + if (!resolved) { + report(toMarker(localize('promptValidator.invalidFileReference', "Invalid file reference '{0}'.", ref.content), ref.range, MarkerSeverity.Warning)); + continue; + } + fileReferenceChecks.push((async () => { + try { + const exists = await this.fileService.exists(resolved); + if (!exists) { + report(toMarker(localize('promptValidator.fileNotFound', "File '{0}' not found.", ref.content), ref.range, MarkerSeverity.Warning)); + } + } catch { + report(toMarker(localize('promptValidator.fileNotFound', "File '{0}' not found.", ref.content), ref.range, MarkerSeverity.Warning)); + } + })()); + } + + // Validate variable references (tool or toolset names) + if (body.variableReferences.length) { + const available = this.getAvailableToolAndToolSetNames(); + for (const variable of body.variableReferences) { + if (!available.has(variable.content)) { + report(toMarker(localize('promptValidator.unknownVariableReference', "Unknown tool or toolset '{0}'.", variable.content), variable.range, MarkerSeverity.Warning)); + } + } + } + + await Promise.all(fileReferenceChecks); + } + + private validateHeader(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): void { const header = promptAST.header; if (!header) { return; @@ -52,65 +90,65 @@ export class PromptValidator { if (!validAttributeNames.includes(attribute.key)) { switch (promptType) { case PromptsType.prompt: - result.push(toMarker(localize('promptValidator.unknownAttribute.prompt', "Attribute '{0}' is not supported in prompt files. Supported: {1}", attribute.key, validAttributeNames.join(', ')), attribute.range, MarkerSeverity.Warning)); + report(toMarker(localize('promptValidator.unknownAttribute.prompt', "Attribute '{0}' is not supported in prompt files. Supported: {1}", attribute.key, validAttributeNames.join(', ')), attribute.range, MarkerSeverity.Warning)); break; case PromptsType.mode: - result.push(toMarker(localize('promptValidator.unknownAttribute.mode', "Attribute '{0}' is not supported in mode files. Supported: {1}", attribute.key, validAttributeNames.join(', ')), attribute.range, MarkerSeverity.Warning)); + report(toMarker(localize('promptValidator.unknownAttribute.mode', "Attribute '{0}' is not supported in mode files. Supported: {1}", attribute.key, validAttributeNames.join(', ')), attribute.range, MarkerSeverity.Warning)); break; case PromptsType.instructions: - result.push(toMarker(localize('promptValidator.unknownAttribute.instructions', "Attribute '{0}' is not supported in instructions files. Supported: {1}", attribute.key, validAttributeNames.join(', ')), attribute.range, MarkerSeverity.Warning)); + report(toMarker(localize('promptValidator.unknownAttribute.instructions', "Attribute '{0}' is not supported in instructions files. Supported: {1}", attribute.key, validAttributeNames.join(', ')), attribute.range, MarkerSeverity.Warning)); break; } } } - this.validateDescription(attributes, result); + this.validateDescription(attributes, report); switch (promptType) { case PromptsType.prompt: { - const mode = this.validateMode(attributes, result); - this.validateTools(attributes, mode?.kind ?? ChatModeKind.Agent, result); - this.validateModel(attributes, mode?.kind ?? ChatModeKind.Agent, result); + const mode = this.validateMode(attributes, report); + this.validateTools(attributes, mode?.kind ?? ChatModeKind.Agent, report); + this.validateModel(attributes, mode?.kind ?? ChatModeKind.Agent, report); break; } case PromptsType.instructions: - this.validateApplyTo(attributes, result); + this.validateApplyTo(attributes, report); break; case PromptsType.mode: - this.validateTools(attributes, ChatModeKind.Agent, result); - this.validateModel(attributes, ChatModeKind.Agent, result); + this.validateTools(attributes, ChatModeKind.Agent, report); + this.validateModel(attributes, ChatModeKind.Agent, report); break; } } - private validateDescription(attributes: IHeaderAttribute[], markers: IMarkerData[]): void { + private validateDescription(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): void { const descriptionAttribute = attributes.find(attr => attr.key === 'description'); if (!descriptionAttribute) { return; } if (descriptionAttribute.value.type !== 'string') { - markers.push(toMarker(localize('promptValidator.descriptionMustBeString', "The 'description' attribute must be a string."), descriptionAttribute.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.descriptionMustBeString', "The 'description' attribute must be a string."), descriptionAttribute.range, MarkerSeverity.Error)); return; } if (descriptionAttribute.value.value.trim().length === 0) { - markers.push(toMarker(localize('promptValidator.descriptionShouldNotBeEmpty', "The 'description' attribute should not be empty."), descriptionAttribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.descriptionShouldNotBeEmpty', "The 'description' attribute should not be empty."), descriptionAttribute.value.range, MarkerSeverity.Error)); return; } } - private validateModel(attributes: IHeaderAttribute[], modeKind: ChatModeKind, markers: IMarkerData[]): void { + private validateModel(attributes: IHeaderAttribute[], modeKind: ChatModeKind, report: (markers: IMarkerData) => void): void { const attribute = attributes.find(attr => attr.key === 'model'); if (!attribute) { return; } if (attribute.value.type !== 'string') { - markers.push(toMarker(localize('promptValidator.modelMustBeString', "The 'model' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.modelMustBeString', "The 'model' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); return; } const modelName = attribute.value.value.trim(); if (modelName.length === 0) { - markers.push(toMarker(localize('promptValidator.modelMustBeNonEmpty', "The 'model' attribute must be a non-empty string."), attribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.modelMustBeNonEmpty', "The 'model' attribute must be a non-empty string."), attribute.value.range, MarkerSeverity.Error)); return; } @@ -121,10 +159,10 @@ export class PromptValidator { } const modelMetadata = this.findModelByName(languageModes, modelName); if (!modelMetadata) { - markers.push(toMarker(localize('promptValidator.modelNotFound', "Unknown model '{0}'.", modelName), attribute.value.range, MarkerSeverity.Warning)); + report(toMarker(localize('promptValidator.modelNotFound', "Unknown model '{0}'.", modelName), attribute.value.range, MarkerSeverity.Warning)); } else if (modeKind === ChatModeKind.Agent && !ILanguageModelChatMetadata.suitableForAgentMode(modelMetadata)) { - markers.push(toMarker(localize('promptValidator.modelNotSuited', "Model '{0}' is not suited for agent mode.", modelName), attribute.value.range, MarkerSeverity.Warning)); + report(toMarker(localize('promptValidator.modelNotSuited', "Model '{0}' is not suited for agent mode.", modelName), attribute.value.range, MarkerSeverity.Warning)); } } @@ -138,18 +176,18 @@ export class PromptValidator { return undefined; } - private validateMode(attributes: IHeaderAttribute[], markers: IMarkerData[]): IChatMode | undefined { + private validateMode(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): IChatMode | undefined { const attribute = attributes.find(attr => attr.key === 'mode'); if (!attribute) { return undefined; // default mode for prompts is Agent } if (attribute.value.type !== 'string') { - markers.push(toMarker(localize('promptValidator.modeMustBeString', "The 'mode' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.modeMustBeString', "The 'mode' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); return undefined; } const modeValue = attribute.value.value; if (modeValue.trim().length === 0) { - markers.push(toMarker(localize('promptValidator.modeMustBeNonEmpty', "The 'mode' attribute must be a non-empty string."), attribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.modeMustBeNonEmpty', "The 'mode' attribute must be a non-empty string."), attribute.value.range, MarkerSeverity.Error)); return undefined; } @@ -165,72 +203,77 @@ export class PromptValidator { } const errorMessage = localize('promptValidator.modeNotFound', "Unknown mode '{0}'. Available modes: {1}.", modeValue, availableModes.join(', ')); - markers.push(toMarker(errorMessage, attribute.value.range, MarkerSeverity.Warning)); + report(toMarker(errorMessage, attribute.value.range, MarkerSeverity.Warning)); return undefined; } - private validateTools(attributes: IHeaderAttribute[], modeKind: ChatModeKind, markers: IMarkerData[]): undefined { + private validateTools(attributes: IHeaderAttribute[], modeKind: ChatModeKind, report: (markers: IMarkerData) => void): undefined { const attribute = attributes.find(attr => attr.key === 'tools'); if (!attribute) { return; } if (modeKind !== ChatModeKind.Agent) { - markers.push(toMarker(localize('promptValidator.toolsOnlyInAgent', "The 'tools' attribute is only supported in agent mode. Attribute will be ignored."), attribute.range, MarkerSeverity.Warning)); + report(toMarker(localize('promptValidator.toolsOnlyInAgent', "The 'tools' attribute is only supported in agent mode. Attribute will be ignored."), attribute.range, MarkerSeverity.Warning)); } if (attribute.value.type !== 'array') { - markers.push(toMarker(localize('promptValidator.toolsMustBeArray', "The 'tools' attribute must be an array."), attribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.toolsMustBeArray', "The 'tools' attribute must be an array."), attribute.value.range, MarkerSeverity.Error)); return; } - const toolNames = new Map(); - for (const item of attribute.value.items) { - if (item.type !== 'string') { - markers.push(toMarker(localize('promptValidator.eachToolMustBeString', "Each tool name in the 'tools' attribute must be a string."), item.range, MarkerSeverity.Error)); - } else { - toolNames.set(item.value, item.range); + if (attribute.value.items.length > 0) { + const available = this.getAvailableToolAndToolSetNames(); + for (const item of attribute.value.items) { + if (item.type !== 'string') { + report(toMarker(localize('promptValidator.eachToolMustBeString', "Each tool name in the 'tools' attribute must be a string."), item.range, MarkerSeverity.Error)); + } else if (!available.has(item.value)) { + report(toMarker(localize('promptValidator.toolNotFound', "Unknown tool '{0}'.", item.value), item.range, MarkerSeverity.Warning)); + } } } - if (toolNames.size === 0) { - return; - } - for (const tool of this.languageModelToolsService.getTools()) { - toolNames.delete(tool.toolReferenceName ?? tool.displayName); - } - for (const toolSet of this.languageModelToolsService.toolSets.get()) { - toolNames.delete(toolSet.referenceName); - } - - for (const [toolName, range] of toolNames) { - markers.push(toMarker(localize('promptValidator.toolNotFound', "Unknown tool '{0}'.", toolName), range, MarkerSeverity.Warning)); - } } - private validateApplyTo(attributes: IHeaderAttribute[], markers: IMarkerData[]): undefined { + private getAvailableToolAndToolSetNames(): Set { + const available = new Set(); + for (const tool of this.languageModelToolsService.getTools()) { + if (tool.canBeReferencedInPrompt) { + available.add(tool.toolReferenceName ?? tool.displayName); + } + } + for (const toolSet of this.languageModelToolsService.toolSets.get()) { + available.add(toolSet.referenceName); + for (const tool of toolSet.getTools()) { + available.add(tool.toolReferenceName ?? tool.displayName); + } + } + return available; + } + + private validateApplyTo(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { const attribute = attributes.find(attr => attr.key === 'applyTo'); if (!attribute) { return; } if (attribute.value.type !== 'string') { - markers.push(toMarker(localize('promptValidator.applyToMustBeString', "The 'applyTo' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.applyToMustBeString', "The 'applyTo' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); return; } const pattern = attribute.value.value; try { const patterns = splitGlobAware(pattern, ','); if (patterns.length === 0) { - markers.push(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error)); return; } for (const pattern of patterns) { const globPattern = parse(pattern); if (isEmptyPattern(globPattern)) { - markers.push(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error)); return; } } } catch (_error) { - markers.push(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error)); } } } @@ -338,9 +381,10 @@ class ModelTracker extends Disposable { } private validate(): void { - this.delayer.trigger(() => { + this.delayer.trigger(async () => { + const markers: IMarkerData[] = []; const ast = this.promptParser.parse(this.textModel.uri, this.textModel.getValue()); - const markers = this.validator.validate(ast, this.promptType); + await this.validator.validate(ast, this.promptType, m => markers.push(m)); this.markerService.changeOne(MARKERS_OWNER_ID, this.textModel.uri, markers); }); } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptValidator.test.ts index 58231ccc889..397ec504af4 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptValidator.test.ts @@ -22,6 +22,8 @@ import { MockChatModeService } from '../../mockChatModeService.js'; import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { IMarkerData, MarkerSeverity } from '../../../../../../../platform/markers/common/markers.js'; import { getPromptFileExtension } from '../../../../common/promptSyntax/config/promptFileLocations.js'; +import { IFileService } from '../../../../../../../platform/files/common/files.js'; +import { ResourceSet } from '../../../../../../../base/common/map.js'; suite('PromptValidator', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -57,15 +59,25 @@ suite('PromptValidator', () => { } }); - const customChatMode = new CustomChatMode({ uri: URI.file('/test/chatmode.md'), name: 'BeastMode', body: '', variableReferences: [] }); + const customChatMode = new CustomChatMode({ uri: URI.parse('myFs://test/test/chatmode.md'), name: 'BeastMode', body: '', variableReferences: [] }); instaService.stub(IChatModeService, new MockChatModeService({ builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], custom: [customChatMode] })); + + + const existingFiles = new ResourceSet([URI.parse('myFs://test/reference1.md'), URI.parse('myFs://test/reference2.md')]); + instaService.stub(IFileService, { + exists(uri: URI) { + return Promise.resolve(existingFiles.has(uri)); + } + }); }); - function validate(code: string, promptType: PromptsType): IMarkerData[] { - const uri = URI.parse('file:///test/chatmode' + getPromptFileExtension(promptType)); + async function validate(code: string, promptType: PromptsType): Promise { + const uri = URI.parse('myFs://test/testFile' + getPromptFileExtension(promptType)); const result = new NewPromptsParser().parse(uri, code); const validator = instaService.createInstance(PromptValidator); - return validator.validate(result, promptType); + const markers: IMarkerData[] = []; + await validator.validate(result, promptType, m => markers.push(m)); + return markers; } suite('modes', () => { @@ -79,7 +91,7 @@ suite('PromptValidator', () => { /* 06 */"This is a chat mode test.", /* 07 */"Here is a #tool1 variable and a #file:./reference1.md as well as a [reference](./reference2.md).", ].join('\n'); - const markers = validate(content, PromptsType.mode); + const markers = await validate(content, PromptsType.mode); assert.deepStrictEqual(markers, []); }); @@ -92,7 +104,7 @@ suite('PromptValidator', () => { /* 05 */"---", /* 06 */"Body", ].join('\n'); - const markers = validate(content, PromptsType.mode); + const markers = await validate(content, PromptsType.mode); assert.strictEqual(markers.length, 3, 'Expected 3 validation issues'); assert.deepStrictEqual( markers.map(m => ({ severity: m.severity, message: m.message })), @@ -111,7 +123,7 @@ suite('PromptValidator', () => { "tools: 'tool1'", "---", ].join('\n'); - const markers = validate(content, PromptsType.mode); + const markers = await validate(content, PromptsType.mode); assert.strictEqual(markers.length, 1); assert.deepStrictEqual(markers.map(m => m.message), ["The 'tools' attribute must be an array."]); }); @@ -123,7 +135,7 @@ suite('PromptValidator', () => { "tools: ['tool1', 2]", "---", ].join('\n'); - const markers = validate(content, PromptsType.mode); + const markers = await validate(content, PromptsType.mode); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].message, "Each tool name in the 'tools' attribute must be a string."); }); @@ -135,7 +147,7 @@ suite('PromptValidator', () => { "applyTo: '*.ts'", // not allowed in mode file "---", ].join('\n'); - const markers = validate(content, PromptsType.mode); + const markers = await validate(content, PromptsType.mode); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); assert.ok(markers[0].message.startsWith("Attribute 'applyTo' is not supported in mode files.")); @@ -151,7 +163,7 @@ suite('PromptValidator', () => { "applyTo: *.ts,*.js", "---", ].join('\n'); - const markers = validate(content, PromptsType.instructions); + const markers = await validate(content, PromptsType.instructions); assert.deepEqual(markers, []); }); @@ -162,7 +174,7 @@ suite('PromptValidator', () => { "applyTo: 5", "---", ].join('\n'); - const markers = validate(content, PromptsType.instructions); + const markers = await validate(content, PromptsType.instructions); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].message, "The 'applyTo' attribute must be a string."); }); @@ -175,7 +187,7 @@ suite('PromptValidator', () => { "model: mae-4", // model not allowed in instructions "---", ].join('\n'); - const markers = validate(content, PromptsType.instructions); + const markers = await validate(content, PromptsType.instructions); assert.strictEqual(markers.length, 2); // Order: unknown attribute warnings first (attribute iteration) then applyTo validation assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); @@ -190,7 +202,7 @@ suite('PromptValidator', () => { "---", "Body", ].join('\n'); - const markers = validate(content, PromptsType.instructions); + const markers = await validate(content, PromptsType.instructions); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].message, 'Invalid header, expecting pairs'); }); @@ -203,12 +215,12 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Prompt with tools"', - "model: MAE 4 (olama)", + "model: MAE 4.1", "tools: ['tool1','tool2']", '---', 'Body' ].join('\n'); - const markers = validate(content, PromptsType.prompt); + const markers = await validate(content, PromptsType.prompt); assert.deepStrictEqual(markers, []); }); @@ -221,7 +233,7 @@ suite('PromptValidator', () => { '---', 'Body' ].join('\n'); - const markers = validate(content, PromptsType.prompt); + const markers = await validate(content, PromptsType.prompt); assert.strictEqual(markers.length, 1, 'Expected one warning about unsuitable model'); assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); assert.strictEqual(markers[0].message, "Model 'MAE 3.5 Turbo' is not suited for agent mode."); @@ -237,21 +249,21 @@ suite('PromptValidator', () => { '---', 'Body' ].join('\n'); - const markers = validate(content, PromptsType.prompt); + const markers = await validate(content, PromptsType.prompt); assert.deepStrictEqual(markers, []); }); - test('prompt with mode Ask and tools warns', async () => { + test('prompt with unknown mode Ask', async () => { const content = [ '---', - 'description: "Prompt ask mode with tools"', + 'description: "Prompt unknown mode Ask"', 'mode: Ask', "tools: ['tool1','tool2']", '---', 'Body' ].join('\n'); - const markers = validate(content, PromptsType.prompt); - assert.strictEqual(markers.length, 1, 'Expected one warning about unknown mode'); + const markers = await validate(content, PromptsType.prompt); + assert.strictEqual(markers.length, 1, 'Expected one warning about tools in non-agent mode'); assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); assert.strictEqual(markers[0].message, "Unknown mode 'Ask'. Available modes: agent, ask, edit, BeastMode."); }); @@ -265,11 +277,53 @@ suite('PromptValidator', () => { '---', 'Body' ].join('\n'); - const markers = validate(content, PromptsType.prompt); + const markers = await validate(content, PromptsType.prompt); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); assert.strictEqual(markers[0].message, "The 'tools' attribute is only supported in agent mode. Attribute will be ignored."); }); }); + suite('body', () => { + test('body with existing file references and known tools has no markers', async () => { + const content = [ + '---', + 'description: "Refs"', + '---', + 'Here is a #file:./reference1.md and a markdown [reference](./reference2.md) plus variables #tool1 and #tool2' + ].join('\n'); + const markers = await validate(content, PromptsType.prompt); + assert.deepStrictEqual(markers, [], 'Expected no validation issues'); + }); + + test('body with missing file references reports warnings', async () => { + const content = [ + '---', + 'description: "Missing Refs"', + '---', + 'Here is a #file:./missing1.md and a markdown [missing link](./missing2.md).' + ].join('\n'); + const markers = await validate(content, PromptsType.prompt); + const messages = markers.map(m => m.message).sort(); + assert.deepStrictEqual(messages, [ + "File './missing1.md' not found.", + "File './missing2.md' not found." + ]); + }); + + test('body with unknown tool variable reference warns', async () => { + const content = [ + '---', + 'description: "Unknown tool var"', + '---', + 'This line references known #tool1 and unknown #toolX' + ].join('\n'); + const markers = await validate(content, PromptsType.prompt); + assert.strictEqual(markers.length, 1, 'Expected one warning for unknown tool variable'); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, "Unknown tool or toolset 'toolX'."); + }); + + }); + }); From 99646f23fe0051a1bc1f53f003166826e1dca6cb Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 10 Sep 2025 22:21:59 +0200 Subject: [PATCH 10/23] IPromptsService.getParsedPromptFile --- .../promptSyntax/service/promptValidator.ts | 16 +++++----- .../promptSyntax/service/promptsService.ts | 7 +++++ .../service/promptsServiceImpl.ts | 29 +++++++++++++++---- .../chat/test/common/mockPromptsService.ts | 3 ++ 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts index fe9cfa3f8c7..8f6604b33fd 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts @@ -17,12 +17,13 @@ import { ChatModeKind } from '../../constants.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService } from '../../languageModelToolsService.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { IHeaderAttribute, NewPromptsParser, ParsedPromptFile } from './newPromptsParser.js'; +import { IHeaderAttribute, ParsedPromptFile } from './newPromptsParser.js'; import { PromptsConfig } from '../config/config.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { IPromptsService } from './promptsService.js'; const MARKERS_OWNER_ID = 'prompts-diagnostics-provider'; @@ -296,7 +297,6 @@ function toMarker(message: string, range: Range, severity = MarkerSeverity.Error export class PromptValidatorContribution extends Disposable { private readonly validator: PromptValidator; - private readonly promptParser: NewPromptsParser; private readonly localDisposables = this._register(new DisposableStore()); constructor( @@ -304,10 +304,10 @@ export class PromptValidatorContribution extends Disposable { @IInstantiationService instantiationService: IInstantiationService, @IConfigurationService private configService: IConfigurationService, @IMarkerService private readonly markerService: IMarkerService, + @IPromptsService private readonly promptsService: IPromptsService, ) { super(); this.validator = instantiationService.createInstance(PromptValidator); - this.promptParser = instantiationService.createInstance(NewPromptsParser); this.updateRegistration(); this._register(this.configService.onDidChangeConfiguration(e => { @@ -329,13 +329,13 @@ export class PromptValidatorContribution extends Disposable { this.modelService.getModels().forEach(model => { const promptType = getPromptsTypeForLanguageId(model.getLanguageId()); if (promptType) { - trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptParser, this.markerService)); + trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptsService, this.markerService)); } }); this.localDisposables.add(this.modelService.onModelAdded((model) => { const promptType = getPromptsTypeForLanguageId(model.getLanguageId()); if (promptType) { - trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptParser, this.markerService)); + trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptsService, this.markerService)); } })); this.localDisposables.add(this.modelService.onModelRemoved((model) => { @@ -357,7 +357,7 @@ export class PromptValidatorContribution extends Disposable { } const promptType = getPromptsTypeForLanguageId(model.getLanguageId()); if (promptType) { - trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptParser, this.markerService)); + trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptsService, this.markerService)); } })); } @@ -371,7 +371,7 @@ class ModelTracker extends Disposable { private readonly textModel: ITextModel, private readonly promptType: PromptsType, private readonly validator: PromptValidator, - private readonly promptParser: NewPromptsParser, + @IPromptsService private readonly promptsService: IPromptsService, @IMarkerService private readonly markerService: IMarkerService, ) { super(); @@ -383,7 +383,7 @@ class ModelTracker extends Disposable { private validate(): void { this.delayer.trigger(async () => { const markers: IMarkerData[] = []; - const ast = this.promptParser.parse(this.textModel.uri, this.textModel.getValue()); + const ast = this.promptsService.getParsedPromptFile(this.textModel); await this.validator.validate(ast, this.promptType, m => markers.push(m)); this.markerService.changeOne(MARKERS_OWNER_ID, this.textModel.uri, markers); }); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index e287f55e851..ca85e45152f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -15,6 +15,7 @@ import { PromptsType } from '../promptTypes.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; import { YamlNode, YamlParseError } from '../../../../../../base/common/yaml.js'; import { IVariableReference } from '../../chatModes.js'; +import { ParsedPromptFile } from './newPromptsParser.js'; /** * Provides prompt services. @@ -164,6 +165,12 @@ export interface IPromptsService extends IDisposable { */ getSyntaxParserFor(model: ITextModel): TSharedPrompt & { isDisposed: false }; + /** + * The parsed prompt file for the provided text model. + * @param textModel Returns the parsed prompt file. + */ + getParsedPromptFile(textModel: ITextModel): ParsedPromptFile; + /** * List all available prompt files. */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 45b976408d9..68217698a22 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -29,6 +29,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { PositionOffsetTransformer } from '../../../../../../editor/common/core/text/positionToOffset.js'; import { NewPromptsParser, ParsedPromptFile } from './newPromptsParser.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { ResourceMap } from '../../../../../../base/common/map.js'; /** * Provides prompt services. @@ -51,6 +52,9 @@ export class PromptsService extends Disposable implements IPromptsService { */ private cachedCustomChatModes: Promise | undefined; + + private parsedPromptFileCache = new ResourceMap<[number, ParsedPromptFile]>(); + /** * Lazily created event that is fired when the custom chat modes change. */ @@ -99,6 +103,10 @@ export class PromptsService extends Disposable implements IPromptsService { return parser; }) ); + + this._register(this.modelService.onModelRemoved((model) => { + this.parsedPromptFileCache.delete(model.uri); + })); } /** @@ -136,6 +144,18 @@ export class PromptsService extends Disposable implements IPromptsService { return this.cache.get(model); } + public getParsedPromptFile(textModel: ITextModel): ParsedPromptFile { + const cached = this.parsedPromptFileCache.get(textModel.uri); + if (cached && cached[0] === textModel.getVersionId()) { + return cached[1]; + } + const ast = new NewPromptsParser().parse(textModel.uri, textModel.getValue()); + if (!cached || cached[0] < textModel.getVersionId()) { + this.parsedPromptFileCache.set(textModel.uri, [textModel.getVersionId(), ast]); + } + return ast; + } + public async listPromptFiles(type: PromptsType, token: CancellationToken): Promise { if (!PromptsConfig.enabled(this.configurationService)) { return []; @@ -307,15 +327,12 @@ export class PromptsService extends Disposable implements IPromptsService { } public async parseNew(uri: URI): Promise { - let content: string | undefined; const model = this.modelService.getModel(uri); if (model) { - content = model.getValue(); - } else { - const fileContent = await this.fileService.readFile(uri); - content = fileContent.value.toString(); + return this.getParsedPromptFile(model); } - return new NewPromptsParser().parse(uri, content); + const fileContent = await this.fileService.readFile(uri); + return new NewPromptsParser().parse(uri, fileContent.value.toString()); } } diff --git a/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts index 97394b57d90..6728e53f6a6 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts @@ -6,6 +6,8 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { ParsedPromptFile } from '../../common/promptSyntax/service/newPromptsParser.js'; import { ICustomChatMode, IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; export class MockPromptsService implements IPromptsService { @@ -34,5 +36,6 @@ export class MockPromptsService implements IPromptsService { findPromptSlashCommands(): Promise { throw new Error('Not implemented'); } parse(_uri: URI, _type: any, _token: CancellationToken): Promise { throw new Error('Not implemented'); } getPromptFileType(_resource: URI): any { return undefined; } + getParsedPromptFile(textModel: ITextModel): ParsedPromptFile { throw new Error('Not implemented'); } dispose(): void { } } From 4b3b3c9507c3a9e13a21859ea70684bd5fd3df33 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 11 Sep 2025 15:42:40 +0200 Subject: [PATCH 11/23] adopt code completion --- .../promptHeaderAutocompletion.ts | 56 ++++++------------- 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index 92c425f90bb..12644cb61eb 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -14,13 +14,10 @@ import { ILanguageFeaturesService } from '../../../../../../editor/common/servic import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService } from '../../languageModelToolsService.js'; import { IChatModeService } from '../../chatModes.js'; -import { InstructionsHeader } from '../parsers/promptHeader/instructionsHeader.js'; -import { PromptToolsMetadata } from '../parsers/promptHeader/metadata/tools.js'; -import { ModeHeader } from '../parsers/promptHeader/modeHeader.js'; -import { PromptHeader } from '../parsers/promptHeader/promptHeader.js'; import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; +import { PromptHeader } from '../service/newPromptsParser.js'; export class PromptHeaderAutocompletion extends Disposable implements CompletionItemProvider { /** @@ -62,27 +59,14 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion return undefined; } - const parser = this.promptsService.getSyntaxParserFor(model); - await parser.start(token).settled(); - - if (token.isCancellationRequested) { - return undefined; - } - + const parser = this.promptsService.getParsedPromptFile(model); const header = parser.header; if (!header) { return undefined; } - const completed = await header.settled; - if (!completed || token.isCancellationRequested) { - return undefined; - } - - const fullHeaderRange = parser.header.range; - const headerRange = new Range(fullHeaderRange.startLineNumber + 1, 0, fullHeaderRange.endLineNumber - 1, model.getLineMaxColumn(fullHeaderRange.endLineNumber - 1),); - - if (!headerRange.containsPosition(position)) { + const headerRange = parser.header.range; + if (position.lineNumber < headerRange.startLineNumber || position.lineNumber >= headerRange.endLineNumber) { // if the position is not inside the header, we don't provide any completions return undefined; } @@ -103,7 +87,7 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion position: Position, headerRange: Range, colonPosition: Position | undefined, - promptType: string, + promptType: PromptsType, ): Promise { const suggestions: CompletionItem[] = []; @@ -140,9 +124,9 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion private async provideValueCompletions( model: ITextModel, position: Position, - header: PromptHeader | ModeHeader | InstructionsHeader, + header: PromptHeader, colonPosition: Position, - promptType: string, + promptType: PromptsType, ): Promise { const suggestions: CompletionItem[] = []; @@ -153,14 +137,11 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion return undefined; } - if (header instanceof PromptHeader || header instanceof ModeHeader) { - const tools = header.metadataUtility.tools; - if (tools) { - // if the position is inside the tools metadata, we provide tool name completions - const result = this.provideToolCompletions(model, position, tools); - if (result) { - return result; - } + if (promptType === PromptsType.prompt || promptType === PromptsType.mode) { + // if the position is inside the tools metadata, we provide tool name completions + const result = this.provideToolCompletions(model, position, header); + if (result) { + return result; } } @@ -244,9 +225,9 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion return result; } - private provideToolCompletions(model: ITextModel, position: Position, node: PromptToolsMetadata): CompletionList | undefined { - const tools = node.value; - if (!tools || !node.range.containsPosition(position)) { + private provideToolCompletions(model: ITextModel, position: Position, header: PromptHeader): CompletionList | undefined { + const toolsAttr = header.attributes.find(attr => attr.key === 'tools'); + if (!toolsAttr || toolsAttr.value.type !== 'array' || !toolsAttr.range.containsPosition(position)) { return undefined; } const getSuggestions = (toolRange: Range) => { @@ -278,11 +259,10 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion return { suggestions }; }; - for (const tool of tools) { - const toolRange = node.getToolRange(tool); - if (toolRange?.containsPosition(position)) { + for (const toolNameNode of toolsAttr.value.items) { + if (toolNameNode.range.containsPosition(position)) { // if the position is inside a tool range, we provide tool name completions - return getSuggestions(toolRange); + return getSuggestions(toolNameNode.range); } } const prefix = model.getValueInRange(new Range(position.lineNumber, 1, position.lineNumber, position.column)); From f9145afad26457527dd50d8101f1b7f3f91612f3 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 11 Sep 2025 17:07:46 +0200 Subject: [PATCH 12/23] adopt getParsedPromptFile --- .../promptSyntax/promptFileRewriter.ts | 24 ++---- .../promptToolsCodeLensProvider.ts | 39 ++++------ .../PromptHeaderDefinitionProvider.ts | 32 +------- .../promptHeaderAutocompletion.ts | 2 +- .../languageProviders/promptHeaderHovers.ts | 76 +++++++------------ .../languageProviders/promptLinkProvider.ts | 65 +++++----------- .../promptSyntax/service/newPromptsParser.ts | 9 ++- .../promptSyntax/service/promptsService.ts | 6 ++ .../service/promptsServiceImpl.ts | 6 +- .../service/newPromptsParser.test.ts | 8 +- 10 files changed, 96 insertions(+), 171 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts index c87e8d9c7e3..21072444d07 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts @@ -26,30 +26,20 @@ export class PromptFileRewriter { } const model = editor.getModel(); - const parser = this._promptsService.getSyntaxParserFor(model); - await parser.start(token).settled(); - const { header } = parser; - if (header === undefined) { + const parser = this._promptsService.getParsedPromptFile(model); + if (!parser.header) { return undefined; } - const completed = await header.settled; - if (!completed || token.isCancellationRequested) { - return; + const toolsAttr = parser.header.getAttribute('tools'); + if (!toolsAttr) { + return undefined; } - if (('tools' in header.metadataUtility) === false) { - return undefined; - } - const { tools } = header.metadataUtility; - if (tools === undefined) { - return undefined; - } - editor.setSelection(tools.range); - this.rewriteTools(model, newTools, tools.range); + editor.setSelection(toolsAttr.range); + this.rewriteTools(model, newTools, toolsAttr.range); } - public rewriteTools(model: ITextModel, newTools: IToolAndToolSetEnablementMap | undefined, range: Range): void { const newString = newTools === undefined ? '' : `tools: ${this.getNewValueString(newTools)}`; model.pushStackElement(); diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts index 904ff2dff6f..f06236bb2a6 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts @@ -15,10 +15,10 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { showToolsPicker } from '../actions/chatToolPicker.js'; import { ILanguageModelToolsService } from '../../common/languageModelToolsService.js'; import { ALL_PROMPTS_LANGUAGE_SELECTOR } from '../../common/promptSyntax/promptTypes.js'; -import { PromptToolsMetadata } from '../../common/promptSyntax/parsers/promptHeader/metadata/tools.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { registerEditorFeature } from '../../../../../editor/common/editorFeatures.js'; import { PromptFileRewriter } from './promptFileRewriter.js'; +import { Range } from '../../../../../editor/common/core/range.js'; class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider { @@ -37,56 +37,47 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider this._register(this.languageService.codeLensProvider.register(ALL_PROMPTS_LANGUAGE_SELECTOR, this)); this._register(CommandsRegistry.registerCommand(this.cmdId, (_accessor, ...args) => { - const [first, second] = args; - if (isITextModel(first) && second instanceof PromptToolsMetadata) { - this.updateTools(first, second); + const [first, second, third] = args; + if (isITextModel(first) && Range.isIRange(second) && Array.isArray(third)) { + this.updateTools(first, Range.lift(second), third); } })); } async provideCodeLenses(model: ITextModel, token: CancellationToken): Promise { - const parser = this.promptsService.getSyntaxParserFor(model); - - await parser.start(token).settled(); - const { header } = parser; - if (!header) { + const parser = this.promptsService.getParsedPromptFile(model); + if (!parser.header) { return undefined; } - const completed = await header.settled; - if (!completed || token.isCancellationRequested) { - return undefined; - } - if (('tools' in header.metadataUtility) === false) { - return undefined; - } - - const { tools } = header.metadataUtility; - if (tools === undefined) { + const toolsAttr = parser.header.getAttribute('tools'); + if (!toolsAttr || toolsAttr.value.type !== 'array') { return undefined; } + const items = toolsAttr.value.items; + const selectedTools = items.filter(item => item.type === 'string').map(item => item.value); const codeLens: CodeLens = { - range: tools.range.collapseToStart(), + range: toolsAttr.range.collapseToStart(), command: { title: localize('configure-tools.capitalized.ellipsis', "Configure Tools..."), id: this.cmdId, - arguments: [model, tools] + arguments: [model, toolsAttr.range, selectedTools] } }; return { lenses: [codeLens] }; } - private async updateTools(model: ITextModel, tools: PromptToolsMetadata) { + private async updateTools(model: ITextModel, range: Range, selectedTools: readonly string[]) { - const selectedToolsNow = tools.value ? this.languageModelToolsService.toToolAndToolSetEnablementMap(tools.value) : new Map(); + const selectedToolsNow = this.languageModelToolsService.toToolAndToolSetEnablementMap(selectedTools); const newSelectedAfter = await this.instantiationService.invokeFunction(showToolsPicker, localize('placeholder', "Select tools"), undefined, selectedToolsNow); if (!newSelectedAfter) { return; } - await this.instantiationService.createInstance(PromptFileRewriter).rewriteTools(model, newSelectedAfter, tools.range); + await this.instantiationService.createInstance(PromptFileRewriter).rewriteTools(model, newSelectedAfter, range); } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts index 785b407da9c..f9afca76373 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts @@ -13,8 +13,6 @@ import { ILanguageFeaturesService } from '../../../../../../editor/common/servic import { IChatModeService } from '../../chatModes.js'; import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; -import { PromptModeMetadata } from '../parsers/promptHeader/metadata/mode.js'; -import { PromptHeader } from '../parsers/promptHeader/promptHeader.js'; export class PromptHeaderDefinitionProvider extends Disposable implements DefinitionProvider { /** @@ -38,37 +36,15 @@ export class PromptHeaderDefinitionProvider extends Disposable implements Defini return undefined; } - const parser = this.promptsService.getSyntaxParserFor(model); - await parser.start(token).settled(); - - if (token.isCancellationRequested) { - return undefined; - } - + const parser = this.promptsService.getParsedPromptFile(model); const header = parser.header; if (!header) { return undefined; } - const completed = await header.settled; - if (!completed || token.isCancellationRequested) { - return undefined; - } - - if (header instanceof PromptHeader) { - const mode = header.metadataUtility.mode; - if (mode?.range.containsPosition(position)) { - return this.getModeDefinition(mode, position); - } - } - return undefined; - } - - - private getModeDefinition(mode: PromptModeMetadata, position: Position): Definition | undefined { - const value = mode.value; - if (value && mode.valueRange?.containsPosition(position)) { - const mode = this.chatModeService.findModeByName(value); + const modeAttr = header.getAttribute('mode'); + if (modeAttr && modeAttr.value.type === 'string' && modeAttr.range.containsPosition(position)) { + const mode = this.chatModeService.findModeByName(modeAttr.value.value); if (mode && mode.uri) { return { uri: mode.uri.get(), diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index 12644cb61eb..61bbbcc7754 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -226,7 +226,7 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion } private provideToolCompletions(model: ITextModel, position: Position, header: PromptHeader): CompletionList | undefined { - const toolsAttr = header.attributes.find(attr => attr.key === 'tools'); + const toolsAttr = header.getAttribute('tools'); if (!toolsAttr || toolsAttr.value.type !== 'array' || !toolsAttr.range.containsPosition(position)) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderHovers.ts index 9072b2bf21f..050ccbebe23 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderHovers.ts @@ -15,13 +15,9 @@ import { localize } from '../../../../../../nls.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, ToolSet } from '../../languageModelToolsService.js'; import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js'; -import { InstructionsHeader } from '../parsers/promptHeader/instructionsHeader.js'; -import { PromptModelMetadata } from '../parsers/promptHeader/metadata/model.js'; -import { PromptToolsMetadata } from '../parsers/promptHeader/metadata/tools.js'; -import { ModeHeader } from '../parsers/promptHeader/modeHeader.js'; -import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId } from '../promptTypes.js'; +import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; -import { PromptModeMetadata } from '../parsers/promptHeader/metadata/mode.js'; +import { IHeaderAttribute } from '../service/newPromptsParser.js'; export class PromptHeaderHoverProvider extends Disposable implements HoverProvider { /** @@ -61,60 +57,49 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid return undefined; } - const parser = this.promptsService.getSyntaxParserFor(model); - await parser.start(token).settled(); - - if (token.isCancellationRequested) { - return undefined; - } - + const parser = this.promptsService.getParsedPromptFile(model); const header = parser.header; if (!header) { return undefined; } - const completed = await header.settled; - if (!completed || token.isCancellationRequested) { - return undefined; - } - - if (header instanceof InstructionsHeader) { - const descriptionRange = header.metadataUtility.description?.range; + if (promptType === PromptsType.instructions) { + const descriptionRange = header.getAttribute('description')?.range; if (descriptionRange?.containsPosition(position)) { return this.createHover(localize('promptHeader.instructions.description', 'The description of the instruction file. It can be used to provide additional context or information about the instructions and is passed to the language model as part of the prompt.'), descriptionRange); } - const applyToRange = header.metadataUtility.applyTo?.range; + const applyToRange = header.getAttribute('applyTo')?.range; if (applyToRange?.containsPosition(position)) { return this.createHover(localize('promptHeader.instructions.applyToRange', 'One or more glob pattern (separated by comma) that describe for which files the instructions apply to. Based on these patterns, the file is automatically included in the prompt, when the context contains a file that matches one or more of these patterns. Use `**` when you want this file to always be added.\nExample: **/*.ts, **/*.js, client/**'), applyToRange); } - } else if (header instanceof ModeHeader) { - const descriptionRange = header.metadataUtility.description?.range; + } else if (promptType === PromptsType.mode) { + const descriptionRange = header.getAttribute('description')?.range; if (descriptionRange?.containsPosition(position)) { return this.createHover(localize('promptHeader.mode.description', 'The description of the mode file. It can be used to provide additional context or information about the mode to the mode author.'), descriptionRange); } - const model = header.metadataUtility.model; + const model = header.getAttribute('model'); if (model?.range.containsPosition(position)) { return this.getModelHover(model, model.range, localize('promptHeader.mode.model', 'The model to use in this mode.')); } - const tools = header.metadataUtility.tools; + const tools = header.getAttribute('tools'); if (tools?.range.containsPosition(position)) { return this.getToolHover(tools, position, localize('promptHeader.mode.tools', 'The tools to use in this mode.')); } } else { - const descriptionRange = header.metadataUtility.description?.range; + const descriptionRange = header.getAttribute('description')?.range; if (descriptionRange?.containsPosition(position)) { return this.createHover(localize('promptHeader.prompt.description', 'The description of the prompt file. It can be used to provide additional context or information about the prompt to the prompt author.'), descriptionRange); } - const model = header.metadataUtility.model; + const model = header.getAttribute('model'); if (model?.range.containsPosition(position)) { return this.getModelHover(model, model.range, localize('promptHeader.prompt.model', 'The model to use in this prompt.')); } - const tools = header.metadataUtility.tools; + const tools = header.getAttribute('tools'); if (tools?.range.containsPosition(position)) { return this.getToolHover(tools, position, localize('promptHeader.prompt.tools', 'The tools to use in this prompt.')); } - const mode = header.metadataUtility.mode; + const mode = header.getAttribute('mode'); if (mode?.range.containsPosition(position)) { return this.getModeHover(mode, position, localize('promptHeader.prompt.mode', 'The mode to use in this prompt.')); } @@ -122,19 +107,17 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid return undefined; } - private getToolHover(node: PromptToolsMetadata, position: Position, baseMessage: string): Hover | undefined { - if (node.value) { - - for (const toolName of node.value) { - const toolRange = node.getToolRange(toolName); - if (toolRange?.containsPosition(position)) { - const tool = this.languageModelToolsService.getToolByName(toolName); + private getToolHover(node: IHeaderAttribute, position: Position, baseMessage: string): Hover | undefined { + if (node.value.type === 'array') { + for (const toolName of node.value.items) { + if (toolName.type === 'string' && toolName.range.containsPosition(position)) { + const tool = this.languageModelToolsService.getToolByName(toolName.value); if (tool) { - return this.createHover(tool.modelDescription, toolRange); + return this.createHover(tool.modelDescription, toolName.range); } - const toolSet = this.languageModelToolsService.getToolSetByName(toolName); + const toolSet = this.languageModelToolsService.getToolSetByName(toolName.value); if (toolSet) { - return this.getToolsetHover(toolSet, toolRange); + return this.getToolsetHover(toolSet, toolName.range); } } } @@ -154,12 +137,11 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid return this.createHover(lines.join('\n'), range); } - private getModelHover(node: PromptModelMetadata, range: Range, baseMessage: string): Hover | undefined { - const modelName = node.value; - if (modelName) { + private getModelHover(node: IHeaderAttribute, range: Range, baseMessage: string): Hover | undefined { + if (node.value.type === 'string') { for (const id of this.languageModelsService.getLanguageModelIds()) { const meta = this.languageModelsService.lookupLanguageModel(id); - if (meta && ILanguageModelChatMetadata.matchesQualifiedName(modelName, meta)) { + if (meta && ILanguageModelChatMetadata.matchesQualifiedName(node.value.value, meta)) { const lines: string[] = []; lines.push(baseMessage + '\n'); lines.push(localize('modelName', '- Name: {0}', meta.name)); @@ -175,13 +157,11 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid return this.createHover(baseMessage, range); } - private getModeHover(mode: PromptModeMetadata, position: Position, baseMessage: string): Hover | undefined { + private getModeHover(mode: IHeaderAttribute, position: Position, baseMessage: string): Hover | undefined { const lines: string[] = []; - - const value = mode.value; - if (value && mode.valueRange?.containsPosition(position)) { - const mode = this.chatModeService.findModeByName(value); + if (value.type === 'string' && value.range.containsPosition(position)) { + const mode = this.chatModeService.findModeByName(value.value); if (mode) { const description = mode.description.get() || (isBuiltinChatMode(mode) ? localize('promptHeader.prompt.mode.builtInDesc', 'Built-in chat mode') : localize('promptHeader.prompt.mode.customDesc', 'Custom chat mode')); lines.push(`\`${mode.name}\`: ${description}`); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptLinkProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptLinkProvider.ts index 22b498d8b3a..53e408c38dc 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptLinkProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptLinkProvider.ts @@ -4,67 +4,40 @@ *--------------------------------------------------------------------------------------------*/ import { IPromptsService } from '../service/promptsService.js'; -import { assert } from '../../../../../../base/common/assert.js'; import { ITextModel } from '../../../../../../editor/common/model.js'; -import { assertDefined } from '../../../../../../base/common/types.js'; -import { CancellationError } from '../../../../../../base/common/errors.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { ILink, ILinksList, LinkProvider } from '../../../../../../editor/common/languages.js'; +import { ALL_PROMPTS_LANGUAGE_SELECTOR } from '../promptTypes.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { ILanguageFeaturesService } from '../../../../../../editor/common/services/languageFeatures.js'; /** * Provides link references for prompt files. */ -export class PromptLinkProvider implements LinkProvider { +export class PromptLinkProvider extends Disposable implements LinkProvider { constructor( + @ILanguageFeaturesService languageService: ILanguageFeaturesService, @IPromptsService private readonly promptsService: IPromptsService, ) { + super(); + this._register(languageService.linkProvider.register(ALL_PROMPTS_LANGUAGE_SELECTOR, this)); } /** * Provide list of links for the provided text model. */ - public async provideLinks( - model: ITextModel, - token: CancellationToken, - ): Promise { - assert( - !token.isCancellationRequested, - new CancellationError(), - ); - - const parser = this.promptsService.getSyntaxParserFor(model); - assert( - parser.isDisposed === false, - 'Prompt parser must not be disposed.', - ); - - // start the parser in case it was not started yet, - // and wait for it to settle to a final result - const completed = await parser.start(token).settled(); - if (!completed || token.isCancellationRequested) { - return undefined; + public async provideLinks(model: ITextModel, token: CancellationToken): Promise { + const parser = this.promptsService.getParsedPromptFile(model); + if (!parser.body) { + return; } - const { references } = parser; - - // filter out references that are not valid links - const links: ILink[] = references - .map((reference) => { - const { uri, linkRange } = reference; - - // must always be true because of the filter above - assertDefined( - linkRange, - 'Link range must be defined.', - ); - - return { - range: linkRange, - url: uri, - }; - }); - - return { - links, - }; + const links: ILink[] = []; + for (const ref of parser.body.fileReferences) { + const url = parser.body.resolveFilePath(ref.content); + if (url) { + links.push({ range: ref.range, url }); + } + } + return { links }; } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts index f0c88fcb01d..bf35d5da09e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts @@ -117,6 +117,10 @@ export class PromptHeader { return this.getParsedHeader().attributes; } + public getAttribute(key: string): IHeaderAttribute | undefined { + return this.getParsedHeader().attributes.find(attr => attr.key === key); + } + public get errors(): ParseError[] { return this.getParsedHeader().errors; } @@ -232,7 +236,7 @@ export class PromptBody { const linkEndOffset = match.index + match[0].length - 1; // before the parenthesis const linkStartOffset = match.index + match[0].length - match[2].length - 1; const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1); - fileReferences.push({ content: match[2], range }); + fileReferences.push({ content: match[2], range, isMarkdownLink: true }); markdownLinkRanges.push(new Range(i + 1, match.index + 1, i + 1, match.index + match[0].length + 1)); } const reg = new RegExp(`${chatVariableLeader}([\\w]+:)?([^\\s#]*)`, 'g'); @@ -248,7 +252,7 @@ export class PromptBody { const linkStartOffset = match.index + match[0].length - match[2].length; const linkEndOffset = match.index + match[0].length; const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1); - fileReferences.push({ content: match[2], range }); + fileReferences.push({ content: match[2], range, isMarkdownLink: false }); } } else { const contentStartOffset = match.index + 1; // after the # @@ -281,6 +285,7 @@ export class PromptBody { interface IBodyFileReference { content: string; range: Range; + isMarkdownLink: boolean; } interface IBodyVariableReference { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index ca85e45152f..5363d277802 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -213,6 +213,12 @@ export interface IPromptsService extends IDisposable { */ parse(uri: URI, type: PromptsType, token: CancellationToken): Promise; + /** + * Parses the provided URI + * @param uris + */ + parseNew(uri: URI, token: CancellationToken): Promise; + /** * Returns the prompt file type for the given URI. * @param resource the URI of the resource diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 68217698a22..36a75f027a2 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -30,6 +30,7 @@ import { PositionOffsetTransformer } from '../../../../../../editor/common/core/ import { NewPromptsParser, ParsedPromptFile } from './newPromptsParser.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; +import { CancellationError } from '../../../../../../base/common/errors.js'; /** * Provides prompt services. @@ -326,12 +327,15 @@ export class PromptsService extends Disposable implements IPromptsService { } } - public async parseNew(uri: URI): Promise { + public async parseNew(uri: URI, token: CancellationToken): Promise { const model = this.modelService.getModel(uri); if (model) { return this.getParsedPromptFile(model); } const fileContent = await this.fileService.readFile(uri); + if (token.isCancellationRequested) { + throw new CancellationError(); + } return new NewPromptsParser().parse(uri, fileContent.value.toString()); } } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts index 62ee58be278..2353b60db80 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts @@ -42,8 +42,8 @@ suite('NewPromptsParser', () => { ]); assert.deepEqual(result.body.range, { startLineNumber: 6, startColumn: 1, endLineNumber: 8, endColumn: 1 }); assert.deepEqual(result.body.fileReferences, [ - { range: new Range(7, 39, 7, 54), content: './reference1.md' }, - { range: new Range(7, 80, 7, 95), content: './reference2.md' } + { range: new Range(7, 39, 7, 54), content: './reference1.md', isMarkdownLink: false }, + { range: new Range(7, 80, 7, 95), content: './reference2.md', isMarkdownLink: true } ]); assert.deepEqual(result.body.variableReferences, [ { range: new Range(7, 12, 7, 17), content: 'tool1' } @@ -74,7 +74,7 @@ suite('NewPromptsParser', () => { ]); assert.deepEqual(result.body.range, { startLineNumber: 5, startColumn: 1, endLineNumber: 6, endColumn: 1 }); assert.deepEqual(result.body.fileReferences, [ - { range: new Range(5, 64, 5, 103), content: 'https://mycomp/guidelines#typescript.md' }, + { range: new Range(5, 64, 5, 103), content: 'https://mycomp/guidelines#typescript.md', isMarkdownLink: true }, ]); assert.deepEqual(result.body.variableReferences, []); assert.deepEqual(result.header.description, 'Code style instructions for TypeScript'); @@ -111,7 +111,7 @@ suite('NewPromptsParser', () => { ]); assert.deepEqual(result.body.range, { startLineNumber: 7, startColumn: 1, endLineNumber: 8, endColumn: 1 }); assert.deepEqual(result.body.fileReferences, [ - { range: new Range(7, 59, 7, 83), content: 'https://example.com/docs' }, + { range: new Range(7, 59, 7, 83), content: 'https://example.com/docs', isMarkdownLink: true }, ]); assert.deepEqual(result.body.variableReferences, [ { range: new Range(7, 41, 7, 47), content: 'search' } From 58f906bc23d12b4b8df371c2a2e4fbc3c5f15e33 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 11 Sep 2025 17:09:01 +0200 Subject: [PATCH 13/23] update --- src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts index 6728e53f6a6..93709eefffc 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts @@ -35,6 +35,7 @@ export class MockPromptsService implements IPromptsService { resolvePromptSlashCommand(_data: any, _token: CancellationToken): Promise { throw new Error('Not implemented'); } findPromptSlashCommands(): Promise { throw new Error('Not implemented'); } parse(_uri: URI, _type: any, _token: CancellationToken): Promise { throw new Error('Not implemented'); } + parseNew(_uri: URI, _token: CancellationToken): Promise { throw new Error('Not implemented'); } getPromptFileType(_resource: URI): any { return undefined; } getParsedPromptFile(textModel: ITextModel): ParsedPromptFile { throw new Error('Not implemented'); } dispose(): void { } From 2a101129a39587b597187a5ae1dc6d6aadd5b2d9 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 11 Sep 2025 17:26:07 +0200 Subject: [PATCH 14/23] fix tests --- .../chat/common/promptSyntax/service/promptsServiceImpl.ts | 1 - .../test/common/promptSyntax/service/promptsService.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 36a75f027a2..779960cf9f9 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -320,7 +320,6 @@ export class PromptsService extends Disposable implements IPromptsService { metadata: parser.metadata, variableReferences, fileReferences: parser.references.map(ref => ref.uri), - header: undefined }; } finally { parser?.dispose(); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 69ea302d960..e2a7e64aa27 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -42,6 +42,7 @@ import { testWorkspace } from '../../../../../../../platform/workspace/test/comm import { IUserDataProfileService } from '../../../../../../services/userDataProfile/common/userDataProfile.js'; import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { Event } from '../../../../../../../base/common/event.js'; /** * Helper class to assert the properties of a link. @@ -139,7 +140,7 @@ suite('PromptsService', () => { const fileService = disposables.add(instaService.createInstance(FileService)); instaService.stub(IFileService, fileService); - instaService.stub(IModelService, { getModel() { return null; } }); + instaService.stub(IModelService, { getModel() { return null; }, onModelRemoved: Event.None }); instaService.stub(ILanguageService, { guessLanguageIdByFilepathOrFirstLine(uri: URI) { if (uri.path.endsWith(PROMPT_FILE_EXTENSION)) { @@ -699,7 +700,6 @@ suite('PromptsService', () => { const result1 = await service.parse(rootFileUri, PromptsType.prompt, CancellationToken.None); assert.deepEqual(result1, { uri: rootFileUri, - header: undefined, metadata: { promptType: PromptsType.prompt, description: 'Root prompt description.', From 795cd6297edf1a16ed9875408e0dd53f491a4a9f Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 11 Sep 2025 17:38:28 +0200 Subject: [PATCH 15/23] revalidate on tool/mode or model change --- .../promptSyntax/service/promptValidator.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts index 8f6604b33fd..3158901db24 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts @@ -305,6 +305,9 @@ export class PromptValidatorContribution extends Disposable { @IConfigurationService private configService: IConfigurationService, @IMarkerService private readonly markerService: IMarkerService, @IPromptsService private readonly promptsService: IPromptsService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, + @IChatModeService private readonly chatModeService: IChatModeService, ) { super(); this.validator = instantiationService.createInstance(PromptValidator); @@ -326,12 +329,18 @@ export class PromptValidatorContribution extends Disposable { this.localDisposables.add(toDisposable(() => { trackers.forEach(tracker => tracker.dispose()); })); - this.modelService.getModels().forEach(model => { - const promptType = getPromptsTypeForLanguageId(model.getLanguageId()); - if (promptType) { - trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptsService, this.markerService)); - } - }); + + const validateAllDelayer = this._register(new Delayer(200)); + const validateAll = (): void => { + validateAllDelayer.trigger(async () => { + this.modelService.getModels().forEach(model => { + const promptType = getPromptsTypeForLanguageId(model.getLanguageId()); + if (promptType) { + trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptsService, this.markerService)); + } + }); + }); + }; this.localDisposables.add(this.modelService.onModelAdded((model) => { const promptType = getPromptsTypeForLanguageId(model.getLanguageId()); if (promptType) { @@ -360,6 +369,10 @@ export class PromptValidatorContribution extends Disposable { trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptsService, this.markerService)); } })); + this.localDisposables.add(this.languageModelToolsService.onDidChangeTools(() => validateAll())); + this.localDisposables.add(this.chatModeService.onDidChangeChatModes(() => validateAll())); + this.localDisposables.add(this.languageModelsService.onDidChangeLanguageModels(() => validateAll())); + validateAll(); } } From 9cfa06cf36cdb2c21b12ff7f621a2b686a6e88f0 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 11 Sep 2025 17:59:27 +0200 Subject: [PATCH 16/23] adopt parseNew --- .../contrib/chat/browser/chatWidget.ts | 27 ++++++-------- .../computeAutomaticInstructions.ts | 35 +++++++++---------- .../promptSyntax/service/newPromptsParser.ts | 18 +++++----- .../promptSyntax/service/promptValidator.ts | 4 +-- .../promptSyntax/service/promptsService.ts | 2 +- .../service/promptsServiceImpl.ts | 4 +-- .../service/newPromptsParser.test.ts | 6 ++-- 7 files changed, 43 insertions(+), 53 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index 652a7929c43..d83b1b8ce7b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -64,9 +64,8 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind, TodoListWidgetPosit import { ILanguageModelToolsService, IToolData, ToolSet } from '../common/languageModelToolsService.js'; import { ComputeAutomaticInstructions } from '../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; -import { type TPromptMetadata } from '../common/promptSyntax/parsers/promptHeader/promptHeader.js'; import { PromptsType } from '../common/promptSyntax/promptTypes.js'; -import { IPromptParserResult, IPromptsService } from '../common/promptSyntax/service/promptsService.js'; +import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; import { handleModeSwitch } from './actions/chatActions.js'; import { ChatTreeItem, IChatAcceptInputOptions, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewOptions, ChatViewId } from './chat.js'; import { ChatAccessibilityProvider } from './chatAccessibilityProvider.js'; @@ -83,6 +82,7 @@ import { ChatViewPane } from './chatViewPane.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { ParsedPromptFile, PromptHeader } from '../common/promptSyntax/service/newPromptsParser.js'; const $ = dom.$; @@ -2145,13 +2145,13 @@ export class ChatWidget extends Disposable implements IChatWidget { return undefined; } - private async _applyPromptFileIfSet(requestInput: IChatRequestInputOptions): Promise { + private async _applyPromptFileIfSet(requestInput: IChatRequestInputOptions): Promise { if (!PromptsConfig.enabled(this.configurationService)) { // if prompts are not enabled, we don't need to do anything return undefined; } - let parseResult: IPromptParserResult | undefined; + let parseResult: ParsedPromptFile | undefined; // first check if the input has a prompt slash command const agentSlashPromptPart = this.parsedInput.parts.find((r): r is ChatRequestSlashPromptPart => r instanceof ChatRequestSlashPromptPart); @@ -2159,7 +2159,7 @@ export class ChatWidget extends Disposable implements IChatWidget { parseResult = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.slashPromptCommand, CancellationToken.None); if (parseResult) { // add the prompt file to the context, but not sticky - const toolReferences = this.toolsService.toToolReferences(parseResult.variableReferences); + const toolReferences = this.toolsService.toToolReferences([]); // TODO: this.toolsService.toToolReferences(parseResult.body?.variableReferences ?? []); requestInput.attachedContext.insertFirst(toPromptFileVariableEntry(parseResult.uri, PromptFileVariableKind.PromptFile, undefined, true, toolReferences)); // remove the slash command from the input @@ -2170,7 +2170,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const uri = this._findPromptFileInContext(requestInput.attachedContext); if (uri) { try { - parseResult = await this.promptsService.parse(uri, PromptsType.prompt, CancellationToken.None); + parseResult = await this.promptsService.parseNew(uri, CancellationToken.None); } catch (error) { this.logService.error(`[_applyPromptFileIfSet] Failed to parse prompt file: ${uri}`, error); } @@ -2180,10 +2180,6 @@ export class ChatWidget extends Disposable implements IChatWidget { if (!parseResult) { return undefined; } - const meta = parseResult.metadata; - if (meta?.promptType !== PromptsType.prompt) { - return undefined; - } const input = requestInput.input.trim(); requestInput.input = `Follow instructions in [${basename(parseResult.uri)}](${parseResult.uri.toString()}).`; @@ -2191,10 +2187,9 @@ export class ChatWidget extends Disposable implements IChatWidget { // if the input is not empty, append it to the prompt requestInput.input += `\n${input}`; } - - await this._applyPromptMetadata(meta, requestInput); - - return parseResult; + if (parseResult.header) { + await this._applyPromptMetadata(parseResult.header, requestInput); + } } private async _acceptInput(query: { query: string } | undefined, options?: IChatAcceptInputOptions): Promise { @@ -2533,9 +2528,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.agentInInput.set(!!currentAgent); } - private async _applyPromptMetadata(metadata: TPromptMetadata, requestInput: IChatRequestInputOptions): Promise { - - const { mode, tools, model } = metadata; + private async _applyPromptMetadata({ mode, tools, model }: PromptHeader, requestInput: IChatRequestInputOptions): Promise { const currentMode = this.input.currentModeObs.get(); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 51129a130f1..9aa457af4be 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -21,7 +21,8 @@ import { IToolData } from '../languageModelToolsService.js'; import { PromptsConfig } from './config/config.js'; import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, isPromptOrInstructionsFile } from './config/promptFileLocations.js'; import { PromptsType } from './promptTypes.js'; -import { IPromptParserResult, IPromptPath, IPromptsService } from './service/promptsService.js'; +import { ParsedPromptFile } from './service/newPromptsParser.js'; +import { IPromptPath, IPromptsService } from './service/promptsService.js'; export type InstructionsCollectionEvent = { applyingInstructionsCount: number; @@ -46,7 +47,7 @@ type InstructionsCollectionClassification = { export class ComputeAutomaticInstructions { - private _parseResults: ResourceMap = new ResourceMap(); + private _parseResults: ResourceMap = new ResourceMap(); constructor( private readonly _readFileTool: IToolData | undefined, @@ -60,12 +61,12 @@ export class ComputeAutomaticInstructions { ) { } - private async _parseInstructionsFile(uri: URI, token: CancellationToken): Promise { + private async _parseInstructionsFile(uri: URI, token: CancellationToken): Promise { if (this._parseResults.has(uri)) { return this._parseResults.get(uri)!; } try { - const result = await this._promptsService.parse(uri, PromptsType.instructions, token); + const result = await this._promptsService.parseNew(uri, token); this._parseResults.set(uri, result); return result; } catch (error) { @@ -125,11 +126,7 @@ export class ComputeAutomaticInstructions { continue; } - if (parsedFile.metadata?.promptType !== PromptsType.instructions) { - this._logService.trace(`[InstructionsContextComputer] Not an instruction file: ${uri}`); - continue; - } - const applyTo = parsedFile.metadata.applyTo; + const applyTo = parsedFile.header?.applyTo; if (!applyTo) { this._logService.trace(`[InstructionsContextComputer] No 'applyTo' found: ${uri}`); @@ -262,12 +259,11 @@ export class ComputeAutomaticInstructions { const entries: string[] = []; for (const { uri } of instructionFiles) { const parsedFile = await this._parseInstructionsFile(uri, token); - if (parsedFile?.metadata?.promptType !== PromptsType.instructions) { - continue; + if (parsedFile) { + const applyTo = parsedFile.header?.applyTo ?? '**/*'; + const description = parsedFile.header?.description ?? ''; + entries.push(`| '${getFilePath(uri)}' | ${applyTo} | ${description} |`); } - const applyTo = parsedFile.metadata.applyTo ?? '**/*'; - const description = parsedFile.metadata.description ?? ''; - entries.push(`| '${getFilePath(uri)}' | ${applyTo} | ${description} |`); } if (entries.length === 0) { return entries; @@ -299,13 +295,14 @@ export class ComputeAutomaticInstructions { let next = todo.pop(); while (next) { const result = await this._parseInstructionsFile(next, token); - if (result) { + if (result && result.body) { const refsToCheck: { resource: URI }[] = []; - for (const ref of result.fileReferences) { - if (!seen.has(ref) && (isPromptOrInstructionsFile(ref) || this._workspaceService.getWorkspaceFolder(ref) !== undefined)) { + for (const ref of result.body.fileReferences) { + const url = result.body.resolveFilePath(ref.content); + if (url && !seen.has(url) && (isPromptOrInstructionsFile(url) || this._workspaceService.getWorkspaceFolder(url) !== undefined)) { // only add references that are either prompt or instruction files or are part of the workspace - refsToCheck.push({ resource: ref }); - seen.add(ref); + refsToCheck.push({ resource: url }); + seen.add(url); } } if (refsToCheck.length > 0) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts index bf35d5da09e..51d6dfdfcf4 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts @@ -149,24 +149,24 @@ export class PromptHeader { return this.getStringAttribute('applyTo'); } - public get tools(): Map | undefined { + public get tools(): string[] | undefined { const toolsAttribute = this.getParsedHeader().attributes.find(attr => attr.key === 'tools'); if (!toolsAttribute) { return undefined; } if (toolsAttribute.value.type === 'array') { - const tools = new Map; + const tools: string[] = []; for (const item of toolsAttribute.value.items) { if (item.type === 'string') { - tools.set(item.value, true); + tools.push(item.value); } } return tools; } else if (toolsAttribute.value.type === 'object') { - const tools = new Map; + const tools: string[] = []; const collectLeafs = ({ key, value }: { key: IStringValue; value: IValue }) => { if (value.type === 'boolean') { - tools.set(key.value, value.value); + tools.push(key.value); } else if (value.type === 'object') { value.properties.forEach(collectLeafs); } @@ -258,7 +258,7 @@ export class PromptBody { const contentStartOffset = match.index + 1; // after the # const contentEndOffset = match.index + match[0].length; const range = new Range(i + 1, contentStartOffset + 1, i + 1, contentEndOffset + 1); - variableReferences.push({ content: match[2], range }); + variableReferences.push({ name: match[2], range }); } } } @@ -282,14 +282,14 @@ export class PromptBody { } } -interface IBodyFileReference { +export interface IBodyFileReference { content: string; range: Range; isMarkdownLink: boolean; } -interface IBodyVariableReference { - content: string; +export interface IBodyVariableReference { + name: string; range: Range; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts index 3158901db24..511e1a1e28a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts @@ -71,8 +71,8 @@ export class PromptValidator { if (body.variableReferences.length) { const available = this.getAvailableToolAndToolSetNames(); for (const variable of body.variableReferences) { - if (!available.has(variable.content)) { - report(toMarker(localize('promptValidator.unknownVariableReference', "Unknown tool or toolset '{0}'.", variable.content), variable.range, MarkerSeverity.Warning)); + if (!available.has(variable.name)) { + report(toMarker(localize('promptValidator.unknownVariableReference', "Unknown tool or toolset '{0}'.", variable.name), variable.range, MarkerSeverity.Warning)); } } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 5363d277802..ab3b4887aa4 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -190,7 +190,7 @@ export interface IPromptsService extends IDisposable { /** * Gets the prompt file for a slash command. */ - resolvePromptSlashCommand(data: IChatPromptSlashCommand, _token: CancellationToken): Promise; + resolvePromptSlashCommand(data: IChatPromptSlashCommand, _token: CancellationToken): Promise; /** * Returns a prompt command if the command name is valid. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 779960cf9f9..df8191d491b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -195,13 +195,13 @@ export class PromptsService extends Disposable implements IPromptsService { return undefined; } - public async resolvePromptSlashCommand(data: IChatPromptSlashCommand, token: CancellationToken): Promise { + public async resolvePromptSlashCommand(data: IChatPromptSlashCommand, token: CancellationToken): Promise { const promptUri = await this.getPromptPath(data); if (!promptUri) { return undefined; } try { - return await this.parse(promptUri, PromptsType.prompt, token); + return await this.parseNew(promptUri, token); } catch (error) { this.logger.error(`[resolvePromptSlashCommand] Failed to parse prompt file: ${promptUri}`, error); return undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts index 2353b60db80..93b4d30b495 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts @@ -51,7 +51,7 @@ suite('NewPromptsParser', () => { assert.deepEqual(result.header.description, 'Agent mode test'); assert.deepEqual(result.header.model, 'GPT 4.1'); assert.ok(result.header.tools); - assert.deepEqual([...result.header.tools.entries()], [['tool1', true], ['tool2', true]]); + assert.deepEqual(result.header.tools, ['tool1', 'tool2']); }); test('instructions', async () => { @@ -120,7 +120,7 @@ suite('NewPromptsParser', () => { assert.deepEqual(result.header.mode, 'agent'); assert.deepEqual(result.header.model, 'GPT 4.1'); assert.ok(result.header.tools); - assert.deepEqual([...result.header.tools.entries()], [['search', true], ['terminal', true]]); + assert.deepEqual(result.header.tools, ['search', 'terminal']); }); test('prompt file tools as map', async () => { @@ -190,6 +190,6 @@ suite('NewPromptsParser', () => { assert.deepEqual(result.header.mode, undefined); assert.deepEqual(result.header.model, undefined); assert.ok(result.header.tools); - assert.deepEqual([...result.header.tools.entries()], [['built-in', true], ['browser-click', true], ['openPullRequest', true], ['copilotCodingAgent', false]]); + assert.deepEqual(result.header.tools, ['built-in', 'browser-click', 'openPullRequest', 'copilotCodingAgent']); }); }); From e11146a36965ff44ff3e37f20686af8dd041fb05 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 11 Sep 2025 18:02:58 +0200 Subject: [PATCH 17/23] fix tests --- .../test/common/promptSyntax/service/newPromptsParser.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts index 93b4d30b495..22409da076b 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts @@ -46,7 +46,7 @@ suite('NewPromptsParser', () => { { range: new Range(7, 80, 7, 95), content: './reference2.md', isMarkdownLink: true } ]); assert.deepEqual(result.body.variableReferences, [ - { range: new Range(7, 12, 7, 17), content: 'tool1' } + { range: new Range(7, 12, 7, 17), name: 'tool1' } ]); assert.deepEqual(result.header.description, 'Agent mode test'); assert.deepEqual(result.header.model, 'GPT 4.1'); @@ -114,7 +114,7 @@ suite('NewPromptsParser', () => { { range: new Range(7, 59, 7, 83), content: 'https://example.com/docs', isMarkdownLink: true }, ]); assert.deepEqual(result.body.variableReferences, [ - { range: new Range(7, 41, 7, 47), content: 'search' } + { range: new Range(7, 41, 7, 47), name: 'search' } ]); assert.deepEqual(result.header.description, 'General purpose coding assistant'); assert.deepEqual(result.header.mode, 'agent'); From a7bf33c840f6cfd9afcc736b78a4e58f7480287d Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 11 Sep 2025 18:07:53 +0200 Subject: [PATCH 18/23] update --- src/vs/workbench/contrib/chat/browser/chatWidget.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index d83b1b8ce7b..9070071c397 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -1339,13 +1339,13 @@ export class ChatWidget extends Disposable implements IChatWidget { if (promptNames.includes(promptCommand.command)) { try { if (promptCommand.promptPath) { - const parseResult = await this.promptsService.parse( + const parseResult = await this.promptsService.parseNew( promptCommand.promptPath.uri, - promptCommand.promptPath.type, CancellationToken.None ); - if (parseResult.metadata?.description) { - this.promptDescriptionsCache.set(promptCommand.command, parseResult.metadata.description); + const description = parseResult.header?.description; + if (description) { + this.promptDescriptionsCache.set(promptCommand.command, description); } else { // Set empty string to indicate we've checked this prompt this.promptDescriptionsCache.set(promptCommand.command, ''); From 8fcd9147be876d78c3ec5c747eba2a48d52a3502 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 11 Sep 2025 22:40:47 +0200 Subject: [PATCH 19/23] adopt promptFileReference.test --- .../contrib/chat/browser/chatWidget.ts | 4 + .../languageProviders/promptLinkProvider.ts | 8 +- .../promptSyntax/promptFileReference.test.ts | 238 ++++++++---------- .../promptSyntax/testUtils/mockFilesystem.ts | 4 +- 4 files changed, 109 insertions(+), 145 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index 9070071c397..eba8f37399a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -2532,6 +2532,10 @@ export class ChatWidget extends Disposable implements IChatWidget { const currentMode = this.input.currentModeObs.get(); + if (tools !== undefined && !mode && currentMode.kind !== ChatModeKind.Agent) { + mode = ChatModeKind.Agent; + } + // switch to appropriate chat mode if needed if (mode && mode !== currentMode.name) { // Find the mode object to get its kind diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptLinkProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptLinkProvider.ts index 53e408c38dc..094e4619f21 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptLinkProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptLinkProvider.ts @@ -33,9 +33,11 @@ export class PromptLinkProvider extends Disposable implements LinkProvider { } const links: ILink[] = []; for (const ref of parser.body.fileReferences) { - const url = parser.body.resolveFilePath(ref.content); - if (url) { - links.push({ range: ref.range, url }); + if (!ref.isMarkdownLink) { + const url = parser.body.resolveFilePath(ref.content); + if (url) { + links.push({ range: ref.range, url }); + } } } return { links }; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts index c1cfdd00f0b..928bcfc6f81 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts @@ -4,11 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { timeout } from '../../../../../../base/common/async.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../../../base/common/network.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { randomBoolean } from '../../../../../../base/test/common/testUtils.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; @@ -23,14 +20,10 @@ import { TestInstantiationService } from '../../../../../../platform/instantiati import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { NullPolicyService } from '../../../../../../platform/policy/common/policy.js'; import { ChatModeKind } from '../../../common/constants.js'; -import { MarkdownLink } from '../../../common/promptSyntax/codecs/base/markdownCodec/tokens/markdownLink.js'; -import { FileReference } from '../../../common/promptSyntax/codecs/tokens/fileReference.js'; import { getPromptFileType } from '../../../common/promptSyntax/config/promptFileLocations.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; -import { type TErrorCondition } from '../../../common/promptSyntax/parsers/basePromptParser.js'; -import { FilePromptParser } from '../../../common/promptSyntax/parsers/filePromptParser.js'; -import { type TPromptReference } from '../../../common/promptSyntax/parsers/types.js'; import { IMockFolder, MockFilesystem } from './testUtils/mockFilesystem.js'; +import { IBodyFileReference, NewPromptsParser } from '../../../common/promptSyntax/service/newPromptsParser.js'; /** * Represents a file reference with an expected @@ -44,19 +37,18 @@ class ExpectedReference { constructor( dirname: URI, - public readonly linkToken: FileReference | MarkdownLink, - public readonly errorCondition?: TErrorCondition, + public readonly ref: IBodyFileReference, ) { - this.uri = (linkToken.path.startsWith('/')) - ? URI.file(linkToken.path) - : URI.joinPath(dirname, linkToken.path); + this.uri = (ref.content.startsWith('/')) + ? URI.file(ref.content) + : URI.joinPath(dirname, ref.content); } /** * Range of the underlying file reference token. */ public get range(): Range { - return this.linkToken.range; + return this.ref.range; } /** @@ -67,6 +59,10 @@ class ExpectedReference { } } +function toUri(filePath: string): URI { + return URI.parse('testFs://' + filePath); +} + /** * A reusable test utility to test the `PromptFileReference` class. */ @@ -82,80 +78,32 @@ class TestPromptFileReference extends Disposable { // create in-memory file system const fileSystemProvider = this._register(new InMemoryFileSystemProvider()); - this._register(this.fileService.registerProvider(Schemas.file, fileSystemProvider)); + this._register(this.fileService.registerProvider('testFs', fileSystemProvider)); } /** * Run the test. */ - public async run( - ): Promise { + public async run(): Promise { // create the files structure on the disk - await (this.instantiationService.createInstance(MockFilesystem, this.fileStructure)).mock(); + await (this.instantiationService.createInstance(MockFilesystem, this.fileStructure)).mock(toUri('/')); - // randomly test with and without delay to ensure that the file - // reference resolution is not susceptible to race conditions - if (randomBoolean()) { - await timeout(5); - } + const content = await this.fileService.readFile(this.rootFileUri); - // start resolving references for the specified root file - const rootReference = this._register( - this.instantiationService.createInstance( - FilePromptParser, - this.rootFileUri, - { allowNonPromptFiles: true, languageId: undefined, updateOnChange: true }, - ), - ).start(); - - // wait until entire prompts tree is resolved - await rootReference.settled(); + const ast = new NewPromptsParser().parse(this.rootFileUri, content.value.toString()); + assert(ast.body, 'Prompt file must have a body'); // resolve the root file reference including all nested references - const resolvedReferences: readonly (TPromptReference | undefined)[] = rootReference.references; + const resolvedReferences = ast.body.fileReferences ?? []; for (let i = 0; i < this.expectedReferences.length; i++) { const expectedReference = this.expectedReferences[i]; const resolvedReference = resolvedReferences[i]; - if (expectedReference.linkToken instanceof MarkdownLink) { - assert( - resolvedReference?.subtype === 'markdown', - [ - `Expected ${i}th resolved reference to be a markdown link`, - `got '${resolvedReference}'.`, - ].join(', '), - ); - } - - if (expectedReference.linkToken instanceof FileReference) { - assert( - resolvedReference?.subtype === 'prompt', - [ - `Expected ${i}th resolved reference to be a #file: link`, - `got '${resolvedReference}'.`, - ].join(', '), - ); - } - - assert( - (resolvedReference) && - (resolvedReference.uri.toString() === expectedReference.uri.toString()), - [ - `Expected ${i}th resolved reference URI to be '${expectedReference.uri}'`, - `got '${resolvedReference?.uri}'.`, - ].join(', '), - ); - - assert( - (resolvedReference) && - (resolvedReference.range.equalsRange(expectedReference.range)), - [ - `Expected ${i}th resolved reference range to be '${expectedReference.range}'`, - `got '${resolvedReference?.range}'.`, - ].join(', '), - ); + const resolvedUri = ast.body.resolveFilePath(resolvedReference.content); + assert.equal(resolvedUri?.fsPath, expectedReference.uri.fsPath); + assert.deepStrictEqual(resolvedReference.range, expectedReference.range); } assert.strictEqual( @@ -167,7 +115,16 @@ class TestPromptFileReference extends Disposable { ].join('\n'), ); - return rootReference; + const result: any = {}; + result.promptType = getPromptFileType(this.rootFileUri); + if (ast.header) { + for (const key of ['tools', 'model', 'mode', 'applyTo', 'description'] as const) { + if (ast.header[key]) { + result[key] = ast.header[key]; + } + } + } + return result; } } @@ -180,19 +137,34 @@ class TestPromptFileReference extends Disposable { * @param lineNumber The expected line number of the file reference. * @param startColumnNumber The expected start column number of the file reference. */ -function createTestFileReference( - filePath: string, - lineNumber: number, - startColumnNumber: number, -): FileReference { +function createFileReference(filePath: string, lineNumber: number, startColumnNumber: number): IBodyFileReference { const range = new Range( lineNumber, - startColumnNumber, + startColumnNumber + '#file:'.length, lineNumber, - startColumnNumber + `#file:${filePath}`.length, + startColumnNumber + '#file:'.length + filePath.length, ); - return new FileReference(range, filePath); + return { + range, + content: filePath, + isMarkdownLink: false, + }; +} + +function createMarkdownReference(lineNumber: number, startColumnNumber: number, firstSeg: string, secondSeg: string): IBodyFileReference { + const range = new Range( + lineNumber, + startColumnNumber + firstSeg.length + 1, + lineNumber, + startColumnNumber + firstSeg.length + secondSeg.length - 1, + ); + + return { + range, + content: secondSeg.substring(1, secondSeg.length - 1), + isMarkdownLink: true, + }; } suite('PromptFileReference', function () { @@ -225,7 +197,7 @@ suite('PromptFileReference', function () { test('resolves nested file references', async function () { const rootFolderName = 'resolves-nested-file-references'; const rootFolder = `/${rootFolderName}`; - const rootUri = URI.file(rootFolder); + const rootUri = toUri(rootFolder); const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, /** @@ -282,18 +254,18 @@ suite('PromptFileReference', function () { /** * The root file path to start the resolve process from. */ - URI.file(`/${rootFolderName}/file2.prompt.md`), + toUri(`/${rootFolderName}/file2.prompt.md`), /** * The expected references to be resolved. */ [ new ExpectedReference( rootUri, - createTestFileReference('folder1/file3.prompt.md', 2, 14), + createFileReference('folder1/file3.prompt.md', 2, 14), ), new ExpectedReference( rootUri, - new MarkdownLink( + createMarkdownReference( 3, 14, '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', ), @@ -309,7 +281,7 @@ suite('PromptFileReference', function () { test('tools', async function () { const rootFolderName = 'resolves-nested-file-references'; const rootFolder = `/${rootFolderName}`; - const rootUri = URI.file(rootFolder); + const rootUri = toUri(rootFolder); const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, /** @@ -405,18 +377,18 @@ suite('PromptFileReference', function () { /** * The root file path to start the resolve process from. */ - URI.file(`/${rootFolderName}/file2.prompt.md`), + toUri(`/${rootFolderName}/file2.prompt.md`), /** * The expected references to be resolved. */ [ new ExpectedReference( rootUri, - createTestFileReference('folder1/file3.prompt.md', 7, 14), + createFileReference('folder1/file3.prompt.md', 7, 14), ), new ExpectedReference( rootUri, - new MarkdownLink( + createMarkdownReference( 8, 14, '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', ), @@ -424,9 +396,7 @@ suite('PromptFileReference', function () { ] )); - const rootReference = await test.run(); - - const { metadata } = rootReference; + const metadata = await test.run(); assert.deepStrictEqual( metadata, @@ -445,7 +415,7 @@ suite('PromptFileReference', function () { test('prompt language', async function () { const rootFolderName = 'resolves-nested-file-references'; const rootFolder = `/${rootFolderName}`; - const rootUri = URI.file(rootFolder); + const rootUri = toUri(rootFolder); const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, /** @@ -513,18 +483,18 @@ suite('PromptFileReference', function () { /** * The root file path to start the resolve process from. */ - URI.file(`/${rootFolderName}/file2.prompt.md`), + toUri(`/${rootFolderName}/file2.prompt.md`), /** * The expected references to be resolved. */ [ new ExpectedReference( rootUri, - createTestFileReference('folder1/file3.prompt.md', 7, 14), + createFileReference('folder1/file3.prompt.md', 7, 14), ), new ExpectedReference( rootUri, - new MarkdownLink( + createMarkdownReference( 8, 14, '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', ), @@ -532,17 +502,15 @@ suite('PromptFileReference', function () { ] )); - const rootReference = await test.run(); - - const { metadata } = rootReference; + const metadata = await test.run(); assert.deepStrictEqual( metadata, { promptType: PromptsType.prompt, - mode: ChatModeKind.Agent, description: 'Description of my prompt.', tools: ['my-tool12'], + applyTo: '**/*', }, 'Must have correct metadata.', ); @@ -553,7 +521,7 @@ suite('PromptFileReference', function () { test('instructions language', async function () { const rootFolderName = 'resolves-nested-file-references'; const rootFolder = `/${rootFolderName}`; - const rootUri = URI.file(rootFolder); + const rootUri = toUri(rootFolder); const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, /** @@ -621,18 +589,18 @@ suite('PromptFileReference', function () { /** * The root file path to start the resolve process from. */ - URI.file(`/${rootFolderName}/file2.instructions.md`), + toUri(`/${rootFolderName}/file2.instructions.md`), /** * The expected references to be resolved. */ [ new ExpectedReference( rootUri, - createTestFileReference('folder1/file3.prompt.md', 7, 14), + createFileReference('folder1/file3.prompt.md', 7, 14), ), new ExpectedReference( rootUri, - new MarkdownLink( + createMarkdownReference( 8, 14, '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', ), @@ -640,9 +608,7 @@ suite('PromptFileReference', function () { ] )); - const rootReference = await test.run(); - - const { metadata } = rootReference; + const metadata = await test.run(); assert.deepStrictEqual( metadata, @@ -650,6 +616,7 @@ suite('PromptFileReference', function () { promptType: PromptsType.instructions, applyTo: '**/*', description: 'Description of my instructions file.', + tools: ['my-tool12'], }, 'Must have correct metadata.', ); @@ -657,10 +624,10 @@ suite('PromptFileReference', function () { }); suite('tools and mode compatibility', () => { - test('tools are ignored if root prompt is in the ask mode', async function () { + test('ask mode', async function () { const rootFolderName = 'resolves-nested-file-references'; const rootFolder = `/${rootFolderName}`; - const rootUri = URI.file(rootFolder); + const rootUri = toUri(rootFolder); const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, /** @@ -728,18 +695,18 @@ suite('PromptFileReference', function () { /** * The root file path to start the resolve process from. */ - URI.file(`/${rootFolderName}/file2.prompt.md`), + toUri(`/${rootFolderName}/file2.prompt.md`), /** * The expected references to be resolved. */ [ new ExpectedReference( rootUri, - createTestFileReference('folder1/file3.prompt.md', 6, 14), + createFileReference('folder1/file3.prompt.md', 6, 14), ), new ExpectedReference( rootUri, - new MarkdownLink( + createMarkdownReference( 7, 14, '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', ), @@ -747,9 +714,7 @@ suite('PromptFileReference', function () { ] )); - const rootReference = await test.run(); - - const { metadata } = rootReference; + const metadata = await test.run(); assert.deepStrictEqual( metadata, @@ -762,10 +727,10 @@ suite('PromptFileReference', function () { ); }); - test('tools are ignored if root prompt is in the edit mode', async function () { + test('edit mode', async function () { const rootFolderName = 'resolves-nested-file-references'; const rootFolder = `/${rootFolderName}`; - const rootUri = URI.file(rootFolder); + const rootUri = toUri(rootFolder); const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, /** @@ -832,18 +797,18 @@ suite('PromptFileReference', function () { /** * The root file path to start the resolve process from. */ - URI.file(`/${rootFolderName}/file2.prompt.md`), + toUri(`/${rootFolderName}/file2.prompt.md`), /** * The expected references to be resolved. */ [ new ExpectedReference( rootUri, - createTestFileReference('folder1/file3.prompt.md', 6, 14), + createFileReference('folder1/file3.prompt.md', 6, 14), ), new ExpectedReference( rootUri, - new MarkdownLink( + createMarkdownReference( 7, 14, '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', ), @@ -851,9 +816,7 @@ suite('PromptFileReference', function () { ] )); - const rootReference = await test.run(); - - const { metadata } = rootReference; + const metadata = await test.run(); assert.deepStrictEqual( metadata, @@ -867,10 +830,10 @@ suite('PromptFileReference', function () { }); - test('tools are not ignored if root prompt is in the agent mode', async function () { + test('agent mode', async function () { const rootFolderName = 'resolves-nested-file-references'; const rootFolder = `/${rootFolderName}`; - const rootUri = URI.file(rootFolder); + const rootUri = toUri(rootFolder); const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, /** @@ -937,18 +900,18 @@ suite('PromptFileReference', function () { /** * The root file path to start the resolve process from. */ - URI.file(`/${rootFolderName}/file2.prompt.md`), + toUri(`/${rootFolderName}/file2.prompt.md`), /** * The expected references to be resolved. */ [ new ExpectedReference( rootUri, - createTestFileReference('folder1/file3.prompt.md', 6, 14), + createFileReference('folder1/file3.prompt.md', 6, 14), ), new ExpectedReference( rootUri, - new MarkdownLink( + createMarkdownReference( 7, 14, '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', ), @@ -956,9 +919,7 @@ suite('PromptFileReference', function () { ] )); - const rootReference = await test.run(); - - const { metadata } = rootReference; + const metadata = await test.run(); assert.deepStrictEqual( metadata, @@ -972,10 +933,10 @@ suite('PromptFileReference', function () { }); - test('tools are not ignored if root prompt implicitly in the agent mode', async function () { + test('no mode', async function () { const rootFolderName = 'resolves-nested-file-references'; const rootFolder = `/${rootFolderName}`; - const rootUri = URI.file(rootFolder); + const rootUri = toUri(rootFolder); const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, /** @@ -1042,18 +1003,18 @@ suite('PromptFileReference', function () { /** * The root file path to start the resolve process from. */ - URI.file(`/${rootFolderName}/file2.prompt.md`), + toUri(`/${rootFolderName}/file2.prompt.md`), /** * The expected references to be resolved. */ [ new ExpectedReference( rootUri, - createTestFileReference('folder1/file3.prompt.md', 6, 14), + createFileReference('folder1/file3.prompt.md', 6, 14), ), new ExpectedReference( rootUri, - new MarkdownLink( + createMarkdownReference( 7, 14, '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', ), @@ -1061,15 +1022,12 @@ suite('PromptFileReference', function () { ] )); - const rootReference = await test.run(); - - const { metadata, } = rootReference; + const metadata = await test.run(); assert.deepStrictEqual( metadata, { promptType: PromptsType.prompt, - mode: ChatModeKind.Agent, tools: ['my-tool12'], description: 'Description of the prompt file.', }, diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts index dc0a44da5c7..d08b9685ae8 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts @@ -47,11 +47,11 @@ export class MockFilesystem { /** * Starts the mock process. */ - public async mock(): Promise[]> { + public async mock(parentFolder?: URI): Promise[]> { const result = await Promise.all( this.folders .map((folder) => { - return this.mockFolder(folder); + return this.mockFolder(folder, parentFolder); }), ); From bf29996207b43bc12aa91fe0102c9dd9a2a53749 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 12 Sep 2025 12:37:11 +0200 Subject: [PATCH 20/23] adopt promptService.test --- .../promptSyntax/service/newPromptsParser.ts | 6 +- .../promptSyntax/service/promptValidator.ts | 2 +- .../service/promptsService.test.ts | 105 ++++++++---------- 3 files changed, 50 insertions(+), 63 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts index 51d6dfdfcf4..fb0112acae5 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts @@ -157,7 +157,7 @@ export class PromptHeader { if (toolsAttribute.value.type === 'array') { const tools: string[] = []; for (const item of toolsAttribute.value.items) { - if (item.type === 'string') { + if (item.type === 'string' && item.value) { tools.push(item.value); } } @@ -231,7 +231,7 @@ export class PromptBody { const variableReferences: IBodyVariableReference[] = []; for (let i = this.range.startLineNumber - 1; i < this.range.endLineNumber - 1; i++) { const line = this.linesWithEOL[i]; - const linkMatch = line.matchAll(/\[(.+?)\]\((.+?)\)/g); + const linkMatch = line.matchAll(/\[(.*?)\]\((.+?)\)/g); for (const match of linkMatch) { const linkEndOffset = match.index + match[0].length - 1; // before the parenthesis const linkStartOffset = match.index + match[0].length - match[2].length - 1; @@ -239,7 +239,7 @@ export class PromptBody { fileReferences.push({ content: match[2], range, isMarkdownLink: true }); markdownLinkRanges.push(new Range(i + 1, match.index + 1, i + 1, match.index + match[0].length + 1)); } - const reg = new RegExp(`${chatVariableLeader}([\\w]+:)?([^\\s#]*)`, 'g'); + const reg = new RegExp(`${chatVariableLeader}([\\w]+:)?([^\\s#]+)`, 'g'); const matches = line.matchAll(reg); for (const match of matches) { const fullRange = new Range(i + 1, match.index + 1, i + 1, match.index + match[0].length + 1); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts index 511e1a1e28a..ead5fc60b43 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptValidator.ts @@ -227,7 +227,7 @@ export class PromptValidator { for (const item of attribute.value.items) { if (item.type !== 'string') { report(toMarker(localize('promptValidator.eachToolMustBeString', "Each tool name in the 'tools' attribute must be a string."), item.range, MarkerSeverity.Error)); - } else if (!available.has(item.value)) { + } else if (item.value && !available.has(item.value)) { report(toMarker(localize('promptValidator.toolNotFound', "Unknown tool '{0}'.", item.value), item.range, MarkerSeverity.Warning)); } } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index e2a7e64aa27..0c453e00b46 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -27,7 +27,7 @@ import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../.. import { TextModelPromptParser } from '../../../../common/promptSyntax/parsers/textModelPromptParser.js'; import { IPromptFileReference } from '../../../../common/promptSyntax/parsers/types.js'; import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js'; -import { ICustomChatMode, IPromptParserResult, IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomChatMode, IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockFilesystem } from '../testUtils/mockFilesystem.js'; import { ILabelService } from '../../../../../../../platform/label/common/label.js'; import { ComputeAutomaticInstructions, newInstructionsCollectionEvent } from '../../../../common/promptSyntax/computeAutomaticInstructions.js'; @@ -697,70 +697,57 @@ suite('PromptsService', () => { const yetAnotherFile = URI.joinPath(rootFolderUri, 'folder1/some-other-folder/yetAnotherFolder🤭/another-file.instructions.md'); - const result1 = await service.parse(rootFileUri, PromptsType.prompt, CancellationToken.None); - assert.deepEqual(result1, { - uri: rootFileUri, - metadata: { - promptType: PromptsType.prompt, - description: 'Root prompt description.', - tools: ['my-tool1'], - mode: 'agent', - }, - fileReferences: [file3, file4], - variableReferences: [ - { - name: "my-other-tool", - range: { - endExclusive: 265, - start: 251 - } - }, - { - name: "my-tool", - range: { - start: 239, - endExclusive: 247, - } - }], - } satisfies IPromptParserResult); + const result1 = await service.parseNew(rootFileUri, CancellationToken.None); + assert.deepEqual(result1.uri, rootFileUri); + assert.deepEqual(result1.header?.description, 'Root prompt description.'); + assert.deepEqual(result1.header?.tools, ['my-tool1']); + assert.deepEqual(result1.header?.mode, 'agent'); + assert.ok(result1.body); + assert.deepEqual( + result1.body.fileReferences.map(r => result1.body?.resolveFilePath(r.content)), + [file3, file4], + ); + assert.deepEqual( + result1.body.variableReferences, + [ + { name: "my-tool", range: new Range(10, 5, 10, 12) }, + { name: "my-other-tool", range: new Range(11, 5, 11, 18) }, + ] + ); - const result2 = await service.parse(file3, PromptsType.prompt, CancellationToken.None); - assert.deepEqual(result2, { - uri: file3, - metadata: { - promptType: PromptsType.prompt, - mode: 'edit', - }, - fileReferences: [nonExistingFolder, yetAnotherFile], - variableReferences: [] - } satisfies IPromptParserResult); + const result2 = await service.parseNew(file3, CancellationToken.None); + assert.deepEqual(result2.uri, file3); + assert.deepEqual(result2.header?.mode, 'edit'); + assert.ok(result2.body); + assert.deepEqual( + result2.body.fileReferences.map(r => result2.body?.resolveFilePath(r.content)), + [nonExistingFolder, yetAnotherFile], + ); - const result3 = await service.parse(yetAnotherFile, PromptsType.instructions, CancellationToken.None); - assert.deepEqual(result3, { - uri: yetAnotherFile, - metadata: { - promptType: PromptsType.instructions, - description: 'Another file description.', - applyTo: '**/*.tsx', - }, - fileReferences: [someOtherFolder, someOtherFolderFile], - variableReferences: [] - } satisfies IPromptParserResult); + const result3 = await service.parseNew(yetAnotherFile, CancellationToken.None); + assert.deepEqual(result3.uri, yetAnotherFile); + assert.deepEqual(result3.header?.description, 'Another file description.'); + assert.deepEqual(result3.header?.applyTo, '**/*.tsx'); + assert.ok(result3.body); + assert.deepEqual( + result3.body.fileReferences.map(r => result3.body?.resolveFilePath(r.content)), + [someOtherFolder, someOtherFolderFile], + ); + assert.deepEqual(result3.body.variableReferences, []); - const result4 = await service.parse(file4, PromptsType.instructions, CancellationToken.None); - assert.deepEqual(result4, { - uri: file4, - metadata: { - promptType: PromptsType.instructions, - description: 'File 4 splendid description.', - }, - fileReferences: [ + const result4 = await service.parseNew(file4, CancellationToken.None); + assert.deepEqual(result4.uri, file4); + assert.deepEqual(result4.header?.description, 'File 4 splendid description.'); + assert.ok(result4.body); + assert.deepEqual( + result4.body.fileReferences.map(r => result4.body?.resolveFilePath(r.content)), + [ URI.joinPath(rootFolderUri, '/folder1/some-other-folder/some-non-existing/file.prompt.md'), URI.joinPath(rootFolderUri, '/folder1/some-other-folder/some-non-prompt-file.md'), - URI.joinPath(rootFolderUri, '/folder1/'), + URI.joinPath(rootFolderUri, '/folder1'), ], - variableReferences: [] - } satisfies IPromptParserResult); + ); + assert.deepEqual(result4.body.variableReferences, []); }); }); From a650fa7a1468782714372c0f0f2477f0dfc0442b Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 12 Sep 2025 14:26:19 +0200 Subject: [PATCH 21/23] adopt computeCustomChatModes, IVariableReferences --- .../contrib/chat/browser/chatWidget.ts | 6 +- .../promptSyntax/service/newPromptsParser.ts | 31 ++++++---- .../service/promptsServiceImpl.ts | 56 ++++++++----------- .../service/newPromptsParser.test.ts | 12 +++- .../service/promptsService.test.ts | 9 +-- 5 files changed, 60 insertions(+), 54 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index e864ff2dc3a..66279d3061a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -83,6 +83,7 @@ import { IViewsService } from '../../../services/views/common/viewsService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { ParsedPromptFile, PromptHeader } from '../common/promptSyntax/service/newPromptsParser.js'; +import { OffsetRange } from '../../../../editor/common/core/ranges/offsetRange.js'; const $ = dom.$; @@ -2157,8 +2158,9 @@ export class ChatWidget extends Disposable implements IChatWidget { if (agentSlashPromptPart) { parseResult = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.slashPromptCommand, CancellationToken.None); if (parseResult) { - // add the prompt file to the context, but not sticky - const toolReferences = this.toolsService.toToolReferences([]); // TODO: this.toolsService.toToolReferences(parseResult.body?.variableReferences ?? []); + // add the prompt file to the context + const refs = parseResult.body?.variableReferences.map(({ name, offset }) => ({ name, range: new OffsetRange(offset, offset + name.length + 1) })) ?? []; + const toolReferences = this.toolsService.toToolReferences(refs); requestInput.attachedContext.insertFirst(toPromptFileVariableEntry(parseResult.uri, PromptFileVariableKind.PromptFile, undefined, true, toolReferences)); // remove the slash command from the input diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts index fb0112acae5..fd08120c4ce 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Iterable } from '../../../../../../base/common/iterator.js'; import { dirname, resolvePath } from '../../../../../../base/common/resources.js'; import { splitLinesIncludeSeparators } from '../../../../../../base/common/strings.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -208,6 +209,7 @@ export type IValue = IStringValue | INumberValue | IBooleanValue | IArrayValue | interface ParsedBody { readonly fileReferences: readonly IBodyFileReference[]; readonly variableReferences: readonly IBodyVariableReference[]; + readonly bodyOffset: number; } export class PromptBody { @@ -224,12 +226,17 @@ export class PromptBody { return this.getParsedBody().variableReferences; } + public get offset(): number { + return this.getParsedBody().bodyOffset; + } + private getParsedBody(): ParsedBody { if (this._parsed === undefined) { const markdownLinkRanges: Range[] = []; const fileReferences: IBodyFileReference[] = []; const variableReferences: IBodyVariableReference[] = []; - for (let i = this.range.startLineNumber - 1; i < this.range.endLineNumber - 1; i++) { + const bodyOffset = Iterable.reduce(Iterable.slice(this.linesWithEOL, 0, this.range.startLineNumber - 1), (len, line) => line.length + len, 0); + for (let i = this.range.startLineNumber - 1, lineStartOffset = bodyOffset; i < this.range.endLineNumber - 1; i++) { const line = this.linesWithEOL[i]; const linkMatch = line.matchAll(/\[(.*?)\]\((.+?)\)/g); for (const match of linkMatch) { @@ -258,15 +265,20 @@ export class PromptBody { const contentStartOffset = match.index + 1; // after the # const contentEndOffset = match.index + match[0].length; const range = new Range(i + 1, contentStartOffset + 1, i + 1, contentEndOffset + 1); - variableReferences.push({ name: match[2], range }); + variableReferences.push({ name: match[2], range, offset: lineStartOffset + match.index }); } } + lineStartOffset += line.length; } - this._parsed = { fileReferences: fileReferences.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)), variableReferences }; + this._parsed = { fileReferences: fileReferences.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)), variableReferences, bodyOffset }; } return this._parsed; } + public getContent(): string { + return this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join(''); + } + public resolveFilePath(path: string): URI | undefined { try { if (path.startsWith('/')) { @@ -283,14 +295,13 @@ export class PromptBody { } export interface IBodyFileReference { - content: string; - range: Range; - isMarkdownLink: boolean; + readonly content: string; + readonly range: Range; + readonly isMarkdownLink: boolean; } export interface IBodyVariableReference { - name: string; - range: Range; + readonly name: string; + readonly range: Range; + readonly offset: number; } - - diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index df8191d491b..07579a4d003 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../../../nls.js'; -import { getLanguageIdForPromptsType, getPromptsTypeForLanguageId, MODE_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../promptTypes.js'; +import { getLanguageIdForPromptsType, getPromptsTypeForLanguageId, PROMPT_LANGUAGE_ID, PromptsType } from '../promptTypes.js'; import { PromptParser } from '../parsers/promptParser.js'; import { type URI } from '../../../../../../base/common/uri.js'; import { assert } from '../../../../../../base/common/assert.js'; @@ -31,6 +31,8 @@ import { NewPromptsParser, ParsedPromptFile } from './newPromptsParser.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; +import { OffsetRange } from '../../../../../../editor/common/core/ranges/offsetRange.js'; +import { IVariableReference } from '../../chatModes.js'; /** * Provides prompt services. @@ -255,42 +257,28 @@ export class PromptsService extends Disposable implements IPromptsService { const metadataList = await Promise.all( modeFiles.map(async ({ uri }): Promise => { - let parser: PromptParser | undefined; - try { - // Note! this can be (and should be) improved by using shared parser instances - // that the `getSyntaxParserFor` method provides for opened documents. - parser = this.instantiationService.createInstance( - PromptParser, - uri, - { allowNonPromptFiles: true, languageId: MODE_LANGUAGE_ID, updateOnChange: false }, - ).start(token); + const ast = await this.parseNew(uri, token); - const completed = await parser.settled(); - if (!completed) { - throw new Error(localize('promptParser.notCompleted', "Prompt parser for {0} did not complete.", uri.toString())); + const variableReferences: IVariableReference[] = []; + let body = ''; + if (ast.body) { + const bodyOffset = ast.body.offset; + const bodyVarRefs = ast.body.variableReferences; + for (let i = bodyVarRefs.length - 1; i >= 0; i--) { // in reverse order + const { name, offset } = bodyVarRefs[i]; + const range = new OffsetRange(offset - bodyOffset, offset - bodyOffset + name.length + 1); + variableReferences.push({ name, range }); } - - const body = await parser.getBody(); - const nHeaderLines = parser.header?.range.endLineNumber ?? 0; - const transformer = new PositionOffsetTransformer(body); - const variableReferences = parser.variableReferences.map(ref => { - return { - name: ref.name, - range: transformer.getOffsetRange(ref.range.delta(-nHeaderLines)) - }; - }).sort((a, b) => b.range.start - a.range.start); // in reverse order - - const name = getCleanPromptName(uri); - - const metadata = parser.metadata; - if (metadata?.promptType !== PromptsType.mode) { - return { uri, name, body, variableReferences }; - } - const { description, model, tools } = metadata; - return { uri, name, description, model, tools, body, variableReferences }; - } finally { - parser?.dispose(); + body = ast.body.getContent(); } + + const name = getCleanPromptName(uri); + if (!ast.header) { + return { uri, name, body, variableReferences }; + } + const { description, model, tools } = ast.header; + return { uri, name, description, model, tools, body, variableReferences }; + }) ); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts index 22409da076b..89dac4ac4db 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts @@ -41,12 +41,15 @@ suite('NewPromptsParser', () => { }, ]); assert.deepEqual(result.body.range, { startLineNumber: 6, startColumn: 1, endLineNumber: 8, endColumn: 1 }); + assert.equal(result.body.offset, 80); + assert.equal(result.body.getContent(), 'This is a chat mode test.\nHere is a #tool1 variable and a #file:./reference1.md as well as a [reference](./reference2.md).'); + assert.deepEqual(result.body.fileReferences, [ { range: new Range(7, 39, 7, 54), content: './reference1.md', isMarkdownLink: false }, { range: new Range(7, 80, 7, 95), content: './reference2.md', isMarkdownLink: true } ]); assert.deepEqual(result.body.variableReferences, [ - { range: new Range(7, 12, 7, 17), name: 'tool1' } + { range: new Range(7, 12, 7, 17), name: 'tool1', offset: 116 } ]); assert.deepEqual(result.header.description, 'Agent mode test'); assert.deepEqual(result.header.model, 'GPT 4.1'); @@ -73,6 +76,9 @@ suite('NewPromptsParser', () => { { key: 'applyTo', range: new Range(3, 1, 3, 14), value: { type: 'string', value: '*.ts', range: new Range(3, 10, 3, 14) } }, ]); assert.deepEqual(result.body.range, { startLineNumber: 5, startColumn: 1, endLineNumber: 6, endColumn: 1 }); + assert.equal(result.body.offset, 76); + assert.equal(result.body.getContent(), 'Follow my companies coding guidlines at [mycomp-ts-guidelines](https://mycomp/guidelines#typescript.md)'); + assert.deepEqual(result.body.fileReferences, [ { range: new Range(5, 64, 5, 103), content: 'https://mycomp/guidelines#typescript.md', isMarkdownLink: true }, ]); @@ -110,11 +116,13 @@ suite('NewPromptsParser', () => { }, ]); assert.deepEqual(result.body.range, { startLineNumber: 7, startColumn: 1, endLineNumber: 8, endColumn: 1 }); + assert.equal(result.body.offset, 113); + assert.equal(result.body.getContent(), 'This is a prompt file body referencing #search and [docs](https://example.com/docs).'); assert.deepEqual(result.body.fileReferences, [ { range: new Range(7, 59, 7, 83), content: 'https://example.com/docs', isMarkdownLink: true }, ]); assert.deepEqual(result.body.variableReferences, [ - { range: new Range(7, 41, 7, 47), name: 'search' } + { range: new Range(7, 41, 7, 47), name: 'search', offset: 152 } ]); assert.deepEqual(result.header.description, 'General purpose coding assistant'); assert.deepEqual(result.header.mode, 'agent'); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 0c453e00b46..21f60a3aae0 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -710,8 +710,8 @@ suite('PromptsService', () => { assert.deepEqual( result1.body.variableReferences, [ - { name: "my-tool", range: new Range(10, 5, 10, 12) }, - { name: "my-other-tool", range: new Range(11, 5, 11, 18) }, + { name: "my-tool", range: new Range(10, 5, 10, 12), offset: 239 }, + { name: "my-other-tool", range: new Range(11, 5, 11, 18), offset: 251 }, ] ); @@ -1275,18 +1275,15 @@ suite('PromptsService', () => { }, { name: 'mode2', - description: undefined, - tools: undefined, body: 'First use #tool2\nThen use #tool1', variableReferences: [{ name: 'tool1', range: { start: 26, endExclusive: 32 } }, { name: 'tool2', range: { start: 10, endExclusive: 16 } }], - model: undefined, uri: URI.joinPath(rootFolderUri, '.github/chatmodes/mode2.instructions.md'), } ]; assert.deepEqual( - expected, result, + expected, 'Must get custom chat modes.', ); }); From 422ad3ccef7d26b0484e44fb4300413af7c2654d Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 12 Sep 2025 15:55:31 +0200 Subject: [PATCH 22/23] polish --- .../common/promptSyntax/service/newPromptsParser.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts index fd08120c4ce..6aade05f2c5 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts @@ -68,7 +68,7 @@ export class PromptHeader { constructor(public readonly range: Range, private readonly linesWithEOL: string[]) { } - public getParsedHeader(): ParsedHeader { + private get _parsedHeader(): ParsedHeader { if (this._parsed === undefined) { const yamlErrors: YamlParseError[] = []; const lines = this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join(''); @@ -115,19 +115,19 @@ export class PromptHeader { } public get attributes(): IHeaderAttribute[] { - return this.getParsedHeader().attributes; + return this._parsedHeader.attributes; } public getAttribute(key: string): IHeaderAttribute | undefined { - return this.getParsedHeader().attributes.find(attr => attr.key === key); + return this._parsedHeader.attributes.find(attr => attr.key === key); } public get errors(): ParseError[] { - return this.getParsedHeader().errors; + return this._parsedHeader.errors; } private getStringAttribute(key: string): string | undefined { - const attribute = this.getParsedHeader().attributes.find(attr => attr.key === key); + const attribute = this._parsedHeader.attributes.find(attr => attr.key === key); if (attribute?.value.type === 'string') { return attribute.value.value; } @@ -151,7 +151,7 @@ export class PromptHeader { } public get tools(): string[] | undefined { - const toolsAttribute = this.getParsedHeader().attributes.find(attr => attr.key === 'tools'); + const toolsAttribute = this._parsedHeader.attributes.find(attr => attr.key === 'tools'); if (!toolsAttribute) { return undefined; } From 3d935a87052c7ae522e899fd536cd0d674db208a Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Sun, 14 Sep 2025 14:03:44 -0700 Subject: [PATCH 23/23] fix tests on windows --- .../chat/common/promptSyntax/service/newPromptsParser.ts | 7 ++++--- .../common/promptSyntax/service/promptsService.test.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts index 6aade05f2c5..801e9338873 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Iterable } from '../../../../../../base/common/iterator.js'; -import { dirname, resolvePath } from '../../../../../../base/common/resources.js'; +import { dirname, joinPath } from '../../../../../../base/common/resources.js'; import { splitLinesIncludeSeparators } from '../../../../../../base/common/strings.js'; import { URI } from '../../../../../../base/common/uri.js'; import { parse, YamlNode, YamlParseError, Position as YamlPosition } from '../../../../../../base/common/yaml.js'; @@ -282,11 +282,12 @@ export class PromptBody { public resolveFilePath(path: string): URI | undefined { try { if (path.startsWith('/')) { - return URI.file(path); + return this.uri.with({ path }); } else if (path.match(/^[a-zA-Z]:\\/)) { return URI.parse(path); } else { - return resolvePath(dirname(this.uri), path); + const dirName = dirname(this.uri); + return joinPath(dirName, path); } } catch { return undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 21f60a3aae0..4e45d2bfdfa 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -744,7 +744,7 @@ suite('PromptsService', () => { [ URI.joinPath(rootFolderUri, '/folder1/some-other-folder/some-non-existing/file.prompt.md'), URI.joinPath(rootFolderUri, '/folder1/some-other-folder/some-non-prompt-file.md'), - URI.joinPath(rootFolderUri, '/folder1'), + URI.joinPath(rootFolderUri, '/folder1/'), ], ); assert.deepEqual(result4.body.variableReferences, []);