Path: blob/main/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts
5243 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Iterable } from '../../../../../base/common/iterator.js';6import { dirname, joinPath } from '../../../../../base/common/resources.js';7import { splitLinesIncludeSeparators } from '../../../../../base/common/strings.js';8import { URI } from '../../../../../base/common/uri.js';9import { parse, YamlNode, YamlParseError, Position as YamlPosition } from '../../../../../base/common/yaml.js';10import { Range } from '../../../../../editor/common/core/range.js';11import { Target } from './service/promptsService.js';1213export class PromptFileParser {14constructor() {15}1617public parse(uri: URI, content: string): ParsedPromptFile {18const linesWithEOL = splitLinesIncludeSeparators(content);19if (linesWithEOL.length === 0) {20return new ParsedPromptFile(uri, undefined, undefined);21}22let header: PromptHeader | undefined = undefined;23let body: PromptBody | undefined = undefined;24let bodyStartLine = 0;25if (linesWithEOL[0].match(/^---[\s\r\n]*$/)) {26let headerEndLine = linesWithEOL.findIndex((line, index) => index > 0 && line.match(/^---[\s\r\n]*$/));27if (headerEndLine === -1) {28headerEndLine = linesWithEOL.length;29bodyStartLine = linesWithEOL.length;30} else {31bodyStartLine = headerEndLine + 1;32}33// range starts on the line after the ---, and ends at the beginning of the line that has the closing ---34const range = new Range(2, 1, headerEndLine + 1, 1);35header = new PromptHeader(range, uri, linesWithEOL);36}37if (bodyStartLine < linesWithEOL.length) {38// range starts on the line after the ---, and ends at the beginning of line after the last line39const range = new Range(bodyStartLine + 1, 1, linesWithEOL.length + 1, 1);40body = new PromptBody(range, linesWithEOL, uri);41}42return new ParsedPromptFile(uri, header, body);43}44}454647export class ParsedPromptFile {48constructor(public readonly uri: URI, public readonly header?: PromptHeader, public readonly body?: PromptBody) {49}50}5152export interface ParseError {53readonly message: string;54readonly range: Range;55readonly code: string;56}5758interface ParsedHeader {59readonly node: YamlNode | undefined;60readonly errors: ParseError[];61readonly attributes: IHeaderAttribute[];62}6364export namespace PromptHeaderAttributes {65export const name = 'name';66export const description = 'description';67export const agent = 'agent';68export const mode = 'mode';69export const model = 'model';70export const applyTo = 'applyTo';71export const tools = 'tools';72export const handOffs = 'handoffs';73export const advancedOptions = 'advancedOptions';74export const argumentHint = 'argument-hint';75export const excludeAgent = 'excludeAgent';76export const target = 'target';77export const infer = 'infer';78export const license = 'license';79export const compatibility = 'compatibility';80export const metadata = 'metadata';81export const agents = 'agents';82export const userInvokable = 'user-invokable';83export const disableModelInvocation = 'disable-model-invocation';84}8586export namespace GithubPromptHeaderAttributes {87export const mcpServers = 'mcp-servers';88}8990export namespace ClaudeHeaderAttributes {91export const disallowedTools = 'disallowedTools';92}9394export function isTarget(value: unknown): value is Target {95return value === Target.VSCode || value === Target.GitHubCopilot || value === Target.Claude || value === Target.Undefined;96}9798export class PromptHeader {99private _parsed: ParsedHeader | undefined;100101constructor(public readonly range: Range, public readonly uri: URI, private readonly linesWithEOL: string[]) {102}103104private get _parsedHeader(): ParsedHeader {105if (this._parsed === undefined) {106const yamlErrors: YamlParseError[] = [];107const lines = this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join('');108const node = parse(lines, yamlErrors);109const attributes = [];110const errors: ParseError[] = yamlErrors.map(err => ({ message: err.message, range: this.asRange(err), code: err.code }));111if (node) {112if (node.type !== 'object') {113errors.push({ message: 'Invalid header, expecting <key: value> pairs', range: this.range, code: 'INVALID_YAML' });114} else {115for (const property of node.properties) {116attributes.push({117key: property.key.value,118range: this.asRange({ start: property.key.start, end: property.value.end }),119value: this.asValue(property.value)120});121}122}123}124this._parsed = { node, attributes, errors };125}126return this._parsed;127}128129private asRange({ start, end }: { start: YamlPosition; end: YamlPosition }): Range {130return new Range(this.range.startLineNumber + start.line, start.character + 1, this.range.startLineNumber + end.line, end.character + 1);131}132133private asValue(node: YamlNode): IValue {134switch (node.type) {135case 'string':136return { type: 'string', value: node.value, range: this.asRange(node) };137case 'number':138return { type: 'number', value: node.value, range: this.asRange(node) };139case 'boolean':140return { type: 'boolean', value: node.value, range: this.asRange(node) };141case 'null':142return { type: 'null', value: node.value, range: this.asRange(node) };143case 'array':144return { type: 'array', items: node.items.map(item => this.asValue(item)), range: this.asRange(node) };145case 'object': {146const properties = node.properties.map(property => ({ key: this.asValue(property.key) as IStringValue, value: this.asValue(property.value) }));147return { type: 'object', properties, range: this.asRange(node) };148}149}150}151152public get attributes(): IHeaderAttribute[] {153return this._parsedHeader.attributes;154}155156public getAttribute(key: string): IHeaderAttribute | undefined {157return this._parsedHeader.attributes.find(attr => attr.key === key);158}159160public get errors(): ParseError[] {161return this._parsedHeader.errors;162}163164private getStringAttribute(key: string): string | undefined {165const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);166if (attribute?.value.type === 'string') {167return attribute.value.value;168}169return undefined;170}171172public get name(): string | undefined {173return this.getStringAttribute(PromptHeaderAttributes.name);174}175176public get description(): string | undefined {177return this.getStringAttribute(PromptHeaderAttributes.description);178}179180public get agent(): string | undefined {181return this.getStringAttribute(PromptHeaderAttributes.agent) ?? this.getStringAttribute(PromptHeaderAttributes.mode);182}183184public get model(): readonly string[] | undefined {185return this.getStringOrStringArrayAttribute(PromptHeaderAttributes.model);186}187188public get applyTo(): string | undefined {189return this.getStringAttribute(PromptHeaderAttributes.applyTo);190}191192public get argumentHint(): string | undefined {193return this.getStringAttribute(PromptHeaderAttributes.argumentHint);194}195196public get target(): string | undefined {197return this.getStringAttribute(PromptHeaderAttributes.target);198}199200public get infer(): boolean | undefined {201const attribute = this._parsedHeader.attributes.find(attr => attr.key === PromptHeaderAttributes.infer);202if (attribute?.value.type === 'boolean') {203return attribute.value.value;204}205return undefined;206}207208public get tools(): string[] | undefined {209const toolsAttribute = this._parsedHeader.attributes.find(attr => attr.key === PromptHeaderAttributes.tools);210if (!toolsAttribute) {211return undefined;212}213let value = toolsAttribute.value;214if (value.type === 'string') {215value = parseCommaSeparatedList(value);216}217if (value.type === 'array') {218const tools: string[] = [];219for (const item of value.items) {220if (item.type === 'string' && item.value) {221tools.push(item.value);222}223}224return tools;225}226return undefined;227}228229public get handOffs(): IHandOff[] | undefined {230const handoffsAttribute = this._parsedHeader.attributes.find(attr => attr.key === PromptHeaderAttributes.handOffs);231if (!handoffsAttribute) {232return undefined;233}234if (handoffsAttribute.value.type === 'array') {235// Array format: list of objects: { agent, label, prompt, send?, showContinueOn?, model? }236const handoffs: IHandOff[] = [];237for (const item of handoffsAttribute.value.items) {238if (item.type === 'object') {239let agent: string | undefined;240let label: string | undefined;241let prompt: string | undefined;242let send: boolean | undefined;243let showContinueOn: boolean | undefined;244let model: string | undefined;245for (const prop of item.properties) {246if (prop.key.value === 'agent' && prop.value.type === 'string') {247agent = prop.value.value;248} else if (prop.key.value === 'label' && prop.value.type === 'string') {249label = prop.value.value;250} else if (prop.key.value === 'prompt' && prop.value.type === 'string') {251prompt = prop.value.value;252} else if (prop.key.value === 'send' && prop.value.type === 'boolean') {253send = prop.value.value;254} else if (prop.key.value === 'showContinueOn' && prop.value.type === 'boolean') {255showContinueOn = prop.value.value;256} else if (prop.key.value === 'model' && prop.value.type === 'string') {257model = prop.value.value;258}259}260if (agent && label && prompt !== undefined) {261const handoff: IHandOff = {262agent,263label,264prompt,265...(send !== undefined ? { send } : {}),266...(showContinueOn !== undefined ? { showContinueOn } : {}),267...(model !== undefined ? { model } : {})268};269handoffs.push(handoff);270}271}272}273return handoffs;274}275return undefined;276}277278private getStringArrayAttribute(key: string): string[] | undefined {279const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);280if (!attribute) {281return undefined;282}283if (attribute.value.type === 'array') {284const result: string[] = [];285for (const item of attribute.value.items) {286if (item.type === 'string' && item.value) {287result.push(item.value);288}289}290return result;291}292return undefined;293}294295private getStringOrStringArrayAttribute(key: string): readonly string[] | undefined {296const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);297if (!attribute) {298return undefined;299}300if (attribute.value.type === 'string') {301return [attribute.value.value];302}303if (attribute.value.type === 'array') {304const result: string[] = [];305for (const item of attribute.value.items) {306if (item.type === 'string') {307result.push(item.value);308}309}310return result;311}312return undefined;313}314315public get agents(): string[] | undefined {316return this.getStringArrayAttribute(PromptHeaderAttributes.agents);317}318319public get userInvokable(): boolean | undefined {320return this.getBooleanAttribute(PromptHeaderAttributes.userInvokable);321}322323public get disableModelInvocation(): boolean | undefined {324return this.getBooleanAttribute(PromptHeaderAttributes.disableModelInvocation);325}326327private getBooleanAttribute(key: string): boolean | undefined {328const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);329if (attribute?.value.type === 'boolean') {330return attribute.value.value;331}332return undefined;333}334}335336export interface IHandOff {337readonly agent: string;338readonly label: string;339readonly prompt: string;340readonly send?: boolean;341readonly showContinueOn?: boolean; // treated exactly like send (optional boolean)342readonly model?: string; // qualified model name to switch to (e.g., "GPT-5 (copilot)")343}344345export interface IHeaderAttribute {346readonly range: Range;347readonly key: string;348readonly value: IValue;349}350351export interface IStringValue { readonly type: 'string'; readonly value: string; readonly range: Range }352export interface INumberValue { readonly type: 'number'; readonly value: number; readonly range: Range }353export interface INullValue { readonly type: 'null'; readonly value: null; readonly range: Range }354export interface IBooleanValue { readonly type: 'boolean'; readonly value: boolean; readonly range: Range }355356export interface IArrayValue {357readonly type: 'array';358readonly items: readonly IValue[];359readonly range: Range;360}361362export interface IObjectValue {363readonly type: 'object';364readonly properties: { key: IStringValue; value: IValue }[];365readonly range: Range;366}367368export type IValue = IStringValue | INumberValue | IBooleanValue | IArrayValue | IObjectValue | INullValue;369370371interface ParsedBody {372readonly fileReferences: readonly IBodyFileReference[];373readonly variableReferences: readonly IBodyVariableReference[];374readonly bodyOffset: number;375}376377export class PromptBody {378private _parsed: ParsedBody | undefined;379380constructor(public readonly range: Range, private readonly linesWithEOL: string[], public readonly uri: URI) {381}382383public get fileReferences(): readonly IBodyFileReference[] {384return this.getParsedBody().fileReferences;385}386387public get variableReferences(): readonly IBodyVariableReference[] {388return this.getParsedBody().variableReferences;389}390391public get offset(): number {392return this.getParsedBody().bodyOffset;393}394395private getParsedBody(): ParsedBody {396if (this._parsed === undefined) {397const markdownLinkRanges: Range[] = [];398const fileReferences: IBodyFileReference[] = [];399const variableReferences: IBodyVariableReference[] = [];400const bodyOffset = Iterable.reduce(Iterable.slice(this.linesWithEOL, 0, this.range.startLineNumber - 1), (len, line) => line.length + len, 0);401for (let i = this.range.startLineNumber - 1, lineStartOffset = bodyOffset; i < this.range.endLineNumber - 1; i++) {402const line = this.linesWithEOL[i];403// Match markdown links: [text](link)404const linkMatch = line.matchAll(/\[(.*?)\]\((.+?)\)/g);405for (const match of linkMatch) {406if (match.index > 0 && line[match.index - 1] === '!') {407continue; // skip image links408}409const linkEndOffset = match.index + match[0].length - 1; // before the parenthesis410const linkStartOffset = match.index + match[0].length - match[2].length - 1;411const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1);412fileReferences.push({ content: match[2], range, isMarkdownLink: true });413markdownLinkRanges.push(new Range(i + 1, match.index + 1, i + 1, match.index + match[0].length + 1));414}415// Match #file:<filePath> and #tool:<toolName>416// Regarding the <toolName> pattern below, see also the variableReg regex in chatRequestParser.ts.417const reg = /#file:(?<filePath>[^\s#]+)|#tool:(?<toolName>[\w_\-\.\/]+)/gi;418const matches = line.matchAll(reg);419for (const match of matches) {420const fullMatch = match[0];421const fullRange = new Range(i + 1, match.index + 1, i + 1, match.index + fullMatch.length + 1);422if (markdownLinkRanges.some(mdRange => Range.areIntersectingOrTouching(mdRange, fullRange))) {423continue;424}425const contentMatch = match.groups?.['filePath'] || match.groups?.['toolName'];426if (!contentMatch) {427continue;428}429const startOffset = match.index + fullMatch.length - contentMatch.length;430const endOffset = match.index + fullMatch.length;431const range = new Range(i + 1, startOffset + 1, i + 1, endOffset + 1);432if (match.groups?.['filePath']) {433fileReferences.push({ content: match.groups?.['filePath'], range, isMarkdownLink: false });434} else if (match.groups?.['toolName']) {435variableReferences.push({ name: match.groups?.['toolName'], range, offset: lineStartOffset + match.index });436}437}438lineStartOffset += line.length;439}440this._parsed = { fileReferences: fileReferences.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)), variableReferences, bodyOffset };441}442return this._parsed;443}444445public getContent(): string {446return this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join('');447}448449public resolveFilePath(path: string): URI | undefined {450try {451if (path.startsWith('/')) {452return this.uri.with({ path });453} else if (path.match(/^[a-zA-Z]+:\//)) {454return URI.parse(path);455} else {456const dirName = dirname(this.uri);457return joinPath(dirName, path);458}459} catch {460return undefined;461}462}463}464465export interface IBodyFileReference {466readonly content: string;467readonly range: Range;468readonly isMarkdownLink: boolean;469}470471export interface IBodyVariableReference {472readonly name: string;473readonly range: Range;474readonly offset: number;475}476477/**478* Parses a comma-separated list of values into an array of strings.479* Values can be unquoted or quoted (single or double quotes).480*481* @param input A string containing comma-separated values482* @returns An IArrayValue containing the parsed values and their ranges483*/484export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue {485const result: IStringValue[] = [];486const input = stringValue.value;487const positionOffset = stringValue.range.getStartPosition();488let pos = 0;489const isWhitespace = (char: string): boolean => char === ' ' || char === '\t';490491while (pos < input.length) {492// Skip leading whitespace493while (pos < input.length && isWhitespace(input[pos])) {494pos++;495}496497if (pos >= input.length) {498break;499}500501const startPos = pos;502let value = '';503let endPos: number;504505const char = input[pos];506if (char === '"' || char === `'`) {507// Quoted string508const quote = char;509pos++; // Skip opening quote510511while (pos < input.length && input[pos] !== quote) {512value += input[pos];513pos++;514}515endPos = pos + 1; // Include closing quote in the range516517if (pos < input.length) {518pos++;519}520521} else {522// Unquoted string - read until comma or end523const startPos = pos;524while (pos < input.length && input[pos] !== ',') {525value += input[pos];526pos++;527}528value = value.trimEnd();529endPos = startPos + value.length;530}531532result.push({ type: 'string', value: value, range: new Range(positionOffset.lineNumber, positionOffset.column + startPos, positionOffset.lineNumber, positionOffset.column + endPos) });533534// Skip whitespace after value535while (pos < input.length && isWhitespace(input[pos])) {536pos++;537}538539// Skip comma if present540if (pos < input.length && input[pos] === ',') {541pos++;542}543}544545return { type: 'array', items: result, range: stringValue.range };546}547548549550551