mirror of
https://github.com/microsoft/vscode.git
synced 2025-12-24 20:26:08 +00:00
Merge pull request #266263 from microsoft/aeschli/newPromptParser
new prompt file parser
This commit is contained in:
832
src/vs/base/common/yaml.ts
Normal file
832
src/vs/base/common/yaml.ts
Normal file
@@ -0,0 +1,832 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 a single string.
|
||||
* 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)
|
||||
*
|
||||
* 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: 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();
|
||||
}
|
||||
|
||||
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;
|
||||
// Track nesting level of flow (inline) collections '[' ']' '{' '}'
|
||||
private flowLevel: number = 0;
|
||||
|
||||
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 => {
|
||||
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();
|
||||
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 {
|
||||
while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') {
|
||||
const char = this.lexer.getCurrentChar();
|
||||
if (isTerminator(char)) {
|
||||
break;
|
||||
}
|
||||
value += this.lexer.advance();
|
||||
endPos = this.lexer.getCurrentPosition();
|
||||
}
|
||||
}
|
||||
const trimmed = value.trimEnd();
|
||||
const diff = value.length - trimmed.length;
|
||||
if (diff) {
|
||||
endPos = createPosition(start.line, endPos.character - diff);
|
||||
}
|
||||
const finalValue = (firstChar === '"' || firstChar === `'`) ? trimmed.substring(1) : trimmed;
|
||||
return this.createValueNode(finalValue, 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 '['
|
||||
this.flowLevel++;
|
||||
|
||||
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();
|
||||
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 }[] = [];
|
||||
|
||||
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();
|
||||
this.flowLevel--;
|
||||
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<string>();
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1084
src/vs/base/test/common/yaml.test.ts
Normal file
1084
src/vs/base/test/common/yaml.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
@@ -84,6 +83,8 @@ import { IViewsService } from '../../../services/views/common/viewsService.js';
|
||||
import product from '../../../../platform/product/common/product.js';
|
||||
import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js';
|
||||
import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
|
||||
import { ParsedPromptFile, PromptHeader } from '../common/promptSyntax/service/newPromptsParser.js';
|
||||
import { OffsetRange } from '../../../../editor/common/core/ranges/offsetRange.js';
|
||||
|
||||
const $ = dom.$;
|
||||
|
||||
@@ -1340,13 +1341,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, '');
|
||||
@@ -2145,21 +2146,22 @@ export class ChatWidget extends Disposable implements IChatWidget {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async _applyPromptFileIfSet(requestInput: IChatRequestInputOptions): Promise<IPromptParserResult | undefined> {
|
||||
private async _applyPromptFileIfSet(requestInput: IChatRequestInputOptions): Promise<void> {
|
||||
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);
|
||||
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(parseResult.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
|
||||
@@ -2170,7 +2172,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 +2182,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 +2189,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<IChatResponseModel | undefined> {
|
||||
@@ -2533,12 +2530,14 @@ export class ChatWidget extends Disposable implements IChatWidget {
|
||||
this.agentInInput.set(!!currentAgent);
|
||||
}
|
||||
|
||||
private async _applyPromptMetadata(metadata: TPromptMetadata, requestInput: IChatRequestInputOptions): Promise<void> {
|
||||
|
||||
const { mode, tools, model } = metadata;
|
||||
private async _applyPromptMetadata({ mode, tools, model }: PromptHeader, requestInput: IChatRequestInputOptions): Promise<void> {
|
||||
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<undefined | CodeLensList> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IPromptParserResult> = new ResourceMap();
|
||||
private _parseResults: ResourceMap<ParsedPromptFile> = new ResourceMap();
|
||||
|
||||
constructor(
|
||||
private readonly _readFileTool: IToolData | undefined,
|
||||
@@ -60,12 +61,12 @@ export class ComputeAutomaticInstructions {
|
||||
) {
|
||||
}
|
||||
|
||||
private async _parseInstructionsFile(uri: URI, token: CancellationToken): Promise<IPromptParserResult | undefined> {
|
||||
private async _parseInstructionsFile(uri: URI, token: CancellationToken): Promise<ParsedPromptFile | undefined> {
|
||||
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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<CompletionList | undefined> {
|
||||
|
||||
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<CompletionList | undefined> {
|
||||
|
||||
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.getAttribute('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));
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -4,67 +4,42 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
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<ILinksList | undefined> {
|
||||
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<ILinksList | undefined> {
|
||||
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) {
|
||||
if (!ref.isMarkdownLink) {
|
||||
const url = parser.body.resolveFilePath(ref.content);
|
||||
if (url) {
|
||||
links.push({ range: ref.range, url });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { links };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Iterable } from '../../../../../../base/common/iterator.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';
|
||||
import { Range } from '../../../../../../editor/common/core/range.js';
|
||||
import { chatVariableLeader } from '../../chatParserTypes.js';
|
||||
|
||||
export class NewPromptsParser {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
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: 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 {
|
||||
bodyStartLine = headerEndLine + 1;
|
||||
}
|
||||
// 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 (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 new ParsedPromptFile(uri, header, body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class ParsedPromptFile {
|
||||
constructor(public readonly uri: URI, public readonly header?: PromptHeader, public readonly body?: PromptBody) {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParseError {
|
||||
readonly message: string;
|
||||
readonly range: Range;
|
||||
readonly code: string;
|
||||
}
|
||||
|
||||
interface ParsedHeader {
|
||||
readonly node: YamlNode | undefined;
|
||||
readonly errors: ParseError[];
|
||||
readonly attributes: IHeaderAttribute[];
|
||||
}
|
||||
|
||||
export class PromptHeader {
|
||||
private _parsed: ParsedHeader | undefined;
|
||||
|
||||
constructor(public readonly range: Range, private readonly linesWithEOL: string[]) {
|
||||
}
|
||||
|
||||
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('');
|
||||
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({
|
||||
key: property.key.value,
|
||||
range: this.asRange({ start: property.key.start, end: property.value.end }),
|
||||
value: this.asValue(property.value)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
errors.push({ message: 'Invalid header, expecting <key: value> pairs', range: this.range, code: 'INVALID_YAML' });
|
||||
}
|
||||
this._parsed = { node, attributes, errors };
|
||||
}
|
||||
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._parsedHeader.attributes;
|
||||
}
|
||||
|
||||
public getAttribute(key: string): IHeaderAttribute | undefined {
|
||||
return this._parsedHeader.attributes.find(attr => attr.key === key);
|
||||
}
|
||||
|
||||
public get errors(): ParseError[] {
|
||||
return this._parsedHeader.errors;
|
||||
}
|
||||
|
||||
private getStringAttribute(key: string): string | undefined {
|
||||
const attribute = this._parsedHeader.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(): string[] | undefined {
|
||||
const toolsAttribute = this._parsedHeader.attributes.find(attr => attr.key === 'tools');
|
||||
if (!toolsAttribute) {
|
||||
return undefined;
|
||||
}
|
||||
if (toolsAttribute.value.type === 'array') {
|
||||
const tools: string[] = [];
|
||||
for (const item of toolsAttribute.value.items) {
|
||||
if (item.type === 'string' && item.value) {
|
||||
tools.push(item.value);
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
} else if (toolsAttribute.value.type === 'object') {
|
||||
const tools: string[] = [];
|
||||
const collectLeafs = ({ key, value }: { key: IStringValue; value: IValue }) => {
|
||||
if (value.type === 'boolean') {
|
||||
tools.push(key.value);
|
||||
} else if (value.type === 'object') {
|
||||
value.properties.forEach(collectLeafs);
|
||||
}
|
||||
};
|
||||
toolsAttribute.value.properties.forEach(collectLeafs);
|
||||
return tools;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export 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[];
|
||||
readonly variableReferences: readonly IBodyVariableReference[];
|
||||
readonly bodyOffset: number;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public get offset(): number {
|
||||
return this.getParsedBody().bodyOffset;
|
||||
}
|
||||
|
||||
private getParsedBody(): ParsedBody {
|
||||
if (this._parsed === undefined) {
|
||||
const markdownLinkRanges: Range[] = [];
|
||||
const fileReferences: IBodyFileReference[] = [];
|
||||
const variableReferences: IBodyVariableReference[] = [];
|
||||
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) {
|
||||
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, 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 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:') {
|
||||
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, isMarkdownLink: false });
|
||||
}
|
||||
} 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({ 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, 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('/')) {
|
||||
return this.uri.with({ path });
|
||||
} else if (path.match(/^[a-zA-Z]:\\/)) {
|
||||
return URI.parse(path);
|
||||
} else {
|
||||
const dirName = dirname(this.uri);
|
||||
return joinPath(dirName, path);
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface IBodyFileReference {
|
||||
readonly content: string;
|
||||
readonly range: Range;
|
||||
readonly isMarkdownLink: boolean;
|
||||
}
|
||||
|
||||
export interface IBodyVariableReference {
|
||||
readonly name: string;
|
||||
readonly range: Range;
|
||||
readonly offset: number;
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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, 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';
|
||||
|
||||
export class PromptValidator {
|
||||
constructor(
|
||||
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
|
||||
@ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService,
|
||||
@IChatModeService private readonly chatModeService: IChatModeService,
|
||||
@IFileService private readonly fileService: IFileService,
|
||||
) { }
|
||||
|
||||
public async validate(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise<void> {
|
||||
promptAST.header?.errors.forEach(error => report(toMarker(error.message, error.range, MarkerSeverity.Error)));
|
||||
this.validateHeader(promptAST, promptType, report);
|
||||
await this.validateBody(promptAST, report);
|
||||
}
|
||||
|
||||
private async validateBody(promptAST: ParsedPromptFile, report: (markers: IMarkerData) => void): Promise<void> {
|
||||
const body = promptAST.body;
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file references
|
||||
const fileReferenceChecks: Promise<void>[] = [];
|
||||
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.name)) {
|
||||
report(toMarker(localize('promptValidator.unknownVariableReference', "Unknown tool or toolset '{0}'.", variable.name), 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;
|
||||
}
|
||||
const validAttributeNames = getValidAttributeNames(promptType);
|
||||
const attributes = header.attributes;
|
||||
for (const attribute of attributes) {
|
||||
if (!validAttributeNames.includes(attribute.key)) {
|
||||
switch (promptType) {
|
||||
case PromptsType.prompt:
|
||||
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:
|
||||
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:
|
||||
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, report);
|
||||
switch (promptType) {
|
||||
case PromptsType.prompt: {
|
||||
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, report);
|
||||
break;
|
||||
|
||||
case PromptsType.mode:
|
||||
this.validateTools(attributes, ChatModeKind.Agent, report);
|
||||
this.validateModel(attributes, ChatModeKind.Agent, report);
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
report(toMarker(localize('promptValidator.descriptionMustBeString', "The 'description' attribute must be a string."), descriptionAttribute.range, MarkerSeverity.Error));
|
||||
return;
|
||||
}
|
||||
if (descriptionAttribute.value.value.trim().length === 0) {
|
||||
report(toMarker(localize('promptValidator.descriptionShouldNotBeEmpty', "The 'description' attribute should not be empty."), descriptionAttribute.value.range, MarkerSeverity.Error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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') {
|
||||
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) {
|
||||
report(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) {
|
||||
report(toMarker(localize('promptValidator.modelNotFound', "Unknown model '{0}'.", modelName), attribute.value.range, MarkerSeverity.Warning));
|
||||
|
||||
} else if (modeKind === ChatModeKind.Agent && !ILanguageModelChatMetadata.suitableForAgentMode(modelMetadata)) {
|
||||
report(toMarker(localize('promptValidator.modelNotSuited', "Model '{0}' is not suited for agent mode.", modelName), attribute.value.range, MarkerSeverity.Warning));
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
return 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') {
|
||||
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) {
|
||||
report(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(', '));
|
||||
report(toMarker(errorMessage, attribute.value.range, MarkerSeverity.Warning));
|
||||
return 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) {
|
||||
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') {
|
||||
report(toMarker(localize('promptValidator.toolsMustBeArray', "The 'tools' attribute must be an array."), attribute.value.range, MarkerSeverity.Error));
|
||||
return;
|
||||
}
|
||||
|
||||
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 (item.value && !available.has(item.value)) {
|
||||
report(toMarker(localize('promptValidator.toolNotFound', "Unknown tool '{0}'.", item.value), item.range, MarkerSeverity.Warning));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getAvailableToolAndToolSetNames(): Set<string> {
|
||||
const available = new Set<string>();
|
||||
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') {
|
||||
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) {
|
||||
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)) {
|
||||
report(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
report(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 localDisposables = this._register(new DisposableStore());
|
||||
|
||||
constructor(
|
||||
@IModelService private modelService: IModelService,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@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);
|
||||
|
||||
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<ModelTracker>();
|
||||
this.localDisposables.add(toDisposable(() => {
|
||||
trackers.forEach(tracker => tracker.dispose());
|
||||
}));
|
||||
|
||||
const validateAllDelayer = this._register(new Delayer<void>(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) {
|
||||
trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptsService, 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.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();
|
||||
}
|
||||
}
|
||||
|
||||
class ModelTracker extends Disposable {
|
||||
|
||||
private readonly delayer: Delayer<void>;
|
||||
|
||||
constructor(
|
||||
private readonly textModel: ITextModel,
|
||||
private readonly promptType: PromptsType,
|
||||
private readonly validator: PromptValidator,
|
||||
@IPromptsService private readonly promptsService: IPromptsService,
|
||||
@IMarkerService private readonly markerService: IMarkerService,
|
||||
) {
|
||||
super();
|
||||
this.delayer = this._register(new Delayer<void>(200));
|
||||
this._register(textModel.onDidChangeContent(() => this.validate()));
|
||||
this.validate();
|
||||
}
|
||||
|
||||
private validate(): void {
|
||||
this.delayer.trigger(async () => {
|
||||
const markers: IMarkerData[] = [];
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
public override dispose() {
|
||||
this.markerService.remove(MARKERS_OWNER_ID, [this.textModel.uri]);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,9 @@ 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';
|
||||
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.
|
||||
*/
|
||||
@@ -183,7 +190,7 @@ export interface IPromptsService extends IDisposable {
|
||||
/**
|
||||
* Gets the prompt file for a slash command.
|
||||
*/
|
||||
resolvePromptSlashCommand(data: IChatPromptSlashCommand, _token: CancellationToken): Promise<IPromptParserResult | undefined>;
|
||||
resolvePromptSlashCommand(data: IChatPromptSlashCommand, _token: CancellationToken): Promise<ParsedPromptFile | undefined>;
|
||||
|
||||
/**
|
||||
* Returns a prompt command if the command name is valid.
|
||||
@@ -206,6 +213,12 @@ export interface IPromptsService extends IDisposable {
|
||||
*/
|
||||
parse(uri: URI, type: PromptsType, token: CancellationToken): Promise<IPromptParserResult>;
|
||||
|
||||
/**
|
||||
* Parses the provided URI
|
||||
* @param uris
|
||||
*/
|
||||
parseNew(uri: URI, token: CancellationToken): Promise<ParsedPromptFile>;
|
||||
|
||||
/**
|
||||
* Returns the prompt file type for the given URI.
|
||||
* @param resource the URI of the resource
|
||||
@@ -223,7 +236,12 @@ export interface IChatPromptSlashCommand {
|
||||
export interface IPromptParserResult {
|
||||
readonly uri: URI;
|
||||
readonly metadata: TMetadata | null;
|
||||
readonly topError: ITopError | undefined;
|
||||
readonly fileReferences: readonly URI[];
|
||||
readonly variableReferences: readonly IVariableReference[];
|
||||
readonly header?: IPromptHeader;
|
||||
}
|
||||
|
||||
export interface IPromptHeader {
|
||||
readonly node: YamlNode | undefined;
|
||||
readonly errors: YamlParseError[];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -27,6 +27,12 @@ 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';
|
||||
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.
|
||||
@@ -49,6 +55,9 @@ export class PromptsService extends Disposable implements IPromptsService {
|
||||
*/
|
||||
private cachedCustomChatModes: Promise<readonly ICustomChatMode[]> | undefined;
|
||||
|
||||
|
||||
private parsedPromptFileCache = new ResourceMap<[number, ParsedPromptFile]>();
|
||||
|
||||
/**
|
||||
* Lazily created event that is fired when the custom chat modes change.
|
||||
*/
|
||||
@@ -62,6 +71,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();
|
||||
|
||||
@@ -96,6 +106,10 @@ export class PromptsService extends Disposable implements IPromptsService {
|
||||
return parser;
|
||||
})
|
||||
);
|
||||
|
||||
this._register(this.modelService.onModelRemoved((model) => {
|
||||
this.parsedPromptFileCache.delete(model.uri);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,6 +147,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<readonly IPromptPath[]> {
|
||||
if (!PromptsConfig.enabled(this.configurationService)) {
|
||||
return [];
|
||||
@@ -171,13 +197,13 @@ export class PromptsService extends Disposable implements IPromptsService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async resolvePromptSlashCommand(data: IChatPromptSlashCommand, token: CancellationToken): Promise<IPromptParserResult | undefined> {
|
||||
public async resolvePromptSlashCommand(data: IChatPromptSlashCommand, token: CancellationToken): Promise<ParsedPromptFile | undefined> {
|
||||
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;
|
||||
@@ -231,42 +257,28 @@ export class PromptsService extends Disposable implements IPromptsService {
|
||||
|
||||
const metadataList = await Promise.all(
|
||||
modeFiles.map(async ({ uri }): Promise<ICustomChatMode> => {
|
||||
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 };
|
||||
|
||||
})
|
||||
);
|
||||
|
||||
@@ -294,14 +306,25 @@ export class PromptsService extends Disposable implements IPromptsService {
|
||||
return {
|
||||
uri: parser.uri,
|
||||
metadata: parser.metadata,
|
||||
topError: parser.topError,
|
||||
variableReferences,
|
||||
fileReferences: parser.references.map(ref => ref.uri)
|
||||
fileReferences: parser.references.map(ref => ref.uri),
|
||||
};
|
||||
} finally {
|
||||
parser?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public async parseNew(uri: URI, token: CancellationToken): Promise<ParsedPromptFile> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
export function getPromptCommandName(path: string): string {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -33,6 +35,8 @@ export class MockPromptsService implements IPromptsService {
|
||||
resolvePromptSlashCommand(_data: any, _token: CancellationToken): Promise<any> { throw new Error('Not implemented'); }
|
||||
findPromptSlashCommands(): Promise<any[]> { throw new Error('Not implemented'); }
|
||||
parse(_uri: URI, _type: any, _token: CancellationToken): Promise<any> { throw new Error('Not implemented'); }
|
||||
parseNew(_uri: URI, _token: CancellationToken): Promise<any> { throw new Error('Not implemented'); }
|
||||
getPromptFileType(_resource: URI): any { return undefined; }
|
||||
getParsedPromptFile(textModel: ITextModel): ParsedPromptFile { throw new Error('Not implemented'); }
|
||||
dispose(): void { }
|
||||
}
|
||||
|
||||
@@ -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<FilePromptParser> {
|
||||
public async run(): Promise<any> {
|
||||
// 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.',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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('mode', async () => {
|
||||
const uri = URI.parse('file:///test/chatmode.md');
|
||||
const content = [
|
||||
/* 01 */"---",
|
||||
/* 02 */`description: "Agent mode test"`,
|
||||
/* 03 */"model: GPT 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 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: 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.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', offset: 116 }
|
||||
]);
|
||||
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, ['tool1', 'tool2']);
|
||||
});
|
||||
|
||||
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.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 },
|
||||
]);
|
||||
assert.deepEqual(result.body.variableReferences, []);
|
||||
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, 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: {
|
||||
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.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', offset: 152 }
|
||||
]);
|
||||
assert.deepEqual(result.header.description, 'General purpose coding assistant');
|
||||
assert.deepEqual(result.header.mode, 'agent');
|
||||
assert.deepEqual(result.header.model, 'GPT 4.1');
|
||||
assert.ok(result.header.tools);
|
||||
assert.deepEqual(result.header.tools, ['search', 'terminal']);
|
||||
});
|
||||
|
||||
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, ['built-in', 'browser-click', 'openPullRequest', 'copilotCodingAgent']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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';
|
||||
import { IFileService } from '../../../../../../../platform/files/common/files.js';
|
||||
import { ResourceSet } from '../../../../../../../base/common/map.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<ToolSet>().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.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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function validate(code: string, promptType: PromptsType): Promise<IMarkerData[]> {
|
||||
const uri = URI.parse('myFs://test/testFile' + getPromptFileExtension(promptType));
|
||||
const result = new NewPromptsParser().parse(uri, code);
|
||||
const validator = instaService.createInstance(PromptValidator);
|
||||
const markers: IMarkerData[] = [];
|
||||
await validator.validate(result, promptType, m => markers.push(m));
|
||||
return markers;
|
||||
}
|
||||
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 = await 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 = 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 })),
|
||||
[
|
||||
{ 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 = await 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 = 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.");
|
||||
});
|
||||
|
||||
test('unknown attribute in mode file', async () => {
|
||||
const content = [
|
||||
"---",
|
||||
"description: \"Test\"",
|
||||
"applyTo: '*.ts'", // not allowed in mode file
|
||||
"---",
|
||||
].join('\n');
|
||||
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."));
|
||||
});
|
||||
});
|
||||
|
||||
suite('instructions', () => {
|
||||
|
||||
test('instructions valid', async () => {
|
||||
const content = [
|
||||
"---",
|
||||
"description: \"Instr\"",
|
||||
"applyTo: *.ts,*.js",
|
||||
"---",
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.instructions);
|
||||
assert.deepEqual(markers, []);
|
||||
});
|
||||
|
||||
test('instructions invalid applyTo type', async () => {
|
||||
const content = [
|
||||
"---",
|
||||
"description: \"Instr\"",
|
||||
"applyTo: 5",
|
||||
"---",
|
||||
].join('\n');
|
||||
const markers = await 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 = 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);
|
||||
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 = await validate(content, PromptsType.instructions);
|
||||
assert.strictEqual(markers.length, 1);
|
||||
assert.strictEqual(markers[0].message, 'Invalid header, expecting <key: value> 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.1",
|
||||
"tools: ['tool1','tool2']",
|
||||
'---',
|
||||
'Body'
|
||||
].join('\n');
|
||||
const markers = await 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 = 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.");
|
||||
});
|
||||
|
||||
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 = await validate(content, PromptsType.prompt);
|
||||
assert.deepStrictEqual(markers, []);
|
||||
});
|
||||
|
||||
test('prompt with unknown mode Ask', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Prompt unknown mode Ask"',
|
||||
'mode: Ask',
|
||||
"tools: ['tool1','tool2']",
|
||||
'---',
|
||||
'Body'
|
||||
].join('\n');
|
||||
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.");
|
||||
});
|
||||
|
||||
test('prompt with mode edit', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Prompt edit mode with tool"',
|
||||
'mode: edit',
|
||||
"tools: ['tool1']",
|
||||
'---',
|
||||
'Body'
|
||||
].join('\n');
|
||||
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'.");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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)) {
|
||||
@@ -696,74 +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',
|
||||
},
|
||||
topError: undefined,
|
||||
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), offset: 239 },
|
||||
{ name: "my-other-tool", range: new Range(11, 5, 11, 18), offset: 251 },
|
||||
]
|
||||
);
|
||||
|
||||
const result2 = await service.parse(file3, PromptsType.prompt, CancellationToken.None);
|
||||
assert.deepEqual(result2, {
|
||||
uri: file3,
|
||||
metadata: {
|
||||
promptType: PromptsType.prompt,
|
||||
mode: 'edit',
|
||||
},
|
||||
topError: undefined,
|
||||
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',
|
||||
},
|
||||
topError: undefined,
|
||||
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.',
|
||||
},
|
||||
topError: undefined,
|
||||
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/'),
|
||||
],
|
||||
variableReferences: []
|
||||
} satisfies IPromptParserResult);
|
||||
);
|
||||
assert.deepEqual(result4.body.variableReferences, []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1291,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.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -47,11 +47,11 @@ export class MockFilesystem {
|
||||
/**
|
||||
* Starts the mock process.
|
||||
*/
|
||||
public async mock(): Promise<TWithURI<IMockFolder>[]> {
|
||||
public async mock(parentFolder?: URI): Promise<TWithURI<IMockFolder>[]> {
|
||||
const result = await Promise.all(
|
||||
this.folders
|
||||
.map((folder) => {
|
||||
return this.mockFolder(folder);
|
||||
return this.mockFolder(folder, parentFolder);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user