Path: blob/main/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts
4780 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';1112export class PromptFileParser {13constructor() {14}1516public parse(uri: URI, content: string): ParsedPromptFile {17const linesWithEOL = splitLinesIncludeSeparators(content);18if (linesWithEOL.length === 0) {19return new ParsedPromptFile(uri, undefined, undefined);20}21let header: PromptHeader | undefined = undefined;22let body: PromptBody | undefined = undefined;23let bodyStartLine = 0;24if (linesWithEOL[0].match(/^---[\s\r\n]*$/)) {25let headerEndLine = linesWithEOL.findIndex((line, index) => index > 0 && line.match(/^---[\s\r\n]*$/));26if (headerEndLine === -1) {27headerEndLine = linesWithEOL.length;28bodyStartLine = linesWithEOL.length;29} else {30bodyStartLine = headerEndLine + 1;31}32// range starts on the line after the ---, and ends at the beginning of the line that has the closing ---33const range = new Range(2, 1, headerEndLine + 1, 1);34header = new PromptHeader(range, linesWithEOL);35}36if (bodyStartLine < linesWithEOL.length) {37// range starts on the line after the ---, and ends at the beginning of line after the last line38const range = new Range(bodyStartLine + 1, 1, linesWithEOL.length + 1, 1);39body = new PromptBody(range, linesWithEOL, uri);40}41return new ParsedPromptFile(uri, header, body);42}43}444546export class ParsedPromptFile {47constructor(public readonly uri: URI, public readonly header?: PromptHeader, public readonly body?: PromptBody) {48}49}5051export interface ParseError {52readonly message: string;53readonly range: Range;54readonly code: string;55}5657interface ParsedHeader {58readonly node: YamlNode | undefined;59readonly errors: ParseError[];60readonly attributes: IHeaderAttribute[];61}6263export namespace PromptHeaderAttributes {64export const name = 'name';65export const description = 'description';66export const agent = 'agent';67export const mode = 'mode';68export const model = 'model';69export const applyTo = 'applyTo';70export const tools = 'tools';71export const handOffs = 'handoffs';72export const advancedOptions = 'advancedOptions';73export const argumentHint = 'argument-hint';74export const excludeAgent = 'excludeAgent';75export const target = 'target';76export const infer = 'infer';77}7879export namespace GithubPromptHeaderAttributes {80export const mcpServers = 'mcp-servers';81}8283export enum Target {84VSCode = 'vscode',85GitHubCopilot = 'github-copilot'86}8788export class PromptHeader {89private _parsed: ParsedHeader | undefined;9091constructor(public readonly range: Range, private readonly linesWithEOL: string[]) {92}9394private get _parsedHeader(): ParsedHeader {95if (this._parsed === undefined) {96const yamlErrors: YamlParseError[] = [];97const lines = this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join('');98const node = parse(lines, yamlErrors);99const attributes = [];100const errors: ParseError[] = yamlErrors.map(err => ({ message: err.message, range: this.asRange(err), code: err.code }));101if (node) {102if (node.type !== 'object') {103errors.push({ message: 'Invalid header, expecting <key: value> pairs', range: this.range, code: 'INVALID_YAML' });104} else {105for (const property of node.properties) {106attributes.push({107key: property.key.value,108range: this.asRange({ start: property.key.start, end: property.value.end }),109value: this.asValue(property.value)110});111}112}113}114this._parsed = { node, attributes, errors };115}116return this._parsed;117}118119private asRange({ start, end }: { start: YamlPosition; end: YamlPosition }): Range {120return new Range(this.range.startLineNumber + start.line, start.character + 1, this.range.startLineNumber + end.line, end.character + 1);121}122123private asValue(node: YamlNode): IValue {124switch (node.type) {125case 'string':126return { type: 'string', value: node.value, range: this.asRange(node) };127case 'number':128return { type: 'number', value: node.value, range: this.asRange(node) };129case 'boolean':130return { type: 'boolean', value: node.value, range: this.asRange(node) };131case 'null':132return { type: 'null', value: node.value, range: this.asRange(node) };133case 'array':134return { type: 'array', items: node.items.map(item => this.asValue(item)), range: this.asRange(node) };135case 'object': {136const properties = node.properties.map(property => ({ key: this.asValue(property.key) as IStringValue, value: this.asValue(property.value) }));137return { type: 'object', properties, range: this.asRange(node) };138}139}140}141142public get attributes(): IHeaderAttribute[] {143return this._parsedHeader.attributes;144}145146public getAttribute(key: string): IHeaderAttribute | undefined {147return this._parsedHeader.attributes.find(attr => attr.key === key);148}149150public get errors(): ParseError[] {151return this._parsedHeader.errors;152}153154private getStringAttribute(key: string): string | undefined {155const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);156if (attribute?.value.type === 'string') {157return attribute.value.value;158}159return undefined;160}161162private getBooleanAttribute(key: string): boolean | undefined {163const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);164if (attribute?.value.type === 'boolean') {165return attribute.value.value;166}167return undefined;168}169170public get name(): string | undefined {171return this.getStringAttribute(PromptHeaderAttributes.name);172}173174public get description(): string | undefined {175return this.getStringAttribute(PromptHeaderAttributes.description);176}177178public get agent(): string | undefined {179return this.getStringAttribute(PromptHeaderAttributes.agent) ?? this.getStringAttribute(PromptHeaderAttributes.mode);180}181182public get model(): string | undefined {183return this.getStringAttribute(PromptHeaderAttributes.model);184}185186public get applyTo(): string | undefined {187return this.getStringAttribute(PromptHeaderAttributes.applyTo);188}189190public get argumentHint(): string | undefined {191return this.getStringAttribute(PromptHeaderAttributes.argumentHint);192}193194public get target(): string | undefined {195return this.getStringAttribute(PromptHeaderAttributes.target);196}197198public get infer(): boolean | undefined {199return this.getBooleanAttribute(PromptHeaderAttributes.infer);200}201202public get tools(): string[] | undefined {203const toolsAttribute = this._parsedHeader.attributes.find(attr => attr.key === PromptHeaderAttributes.tools);204if (!toolsAttribute) {205return undefined;206}207if (toolsAttribute.value.type === 'array') {208const tools: string[] = [];209for (const item of toolsAttribute.value.items) {210if (item.type === 'string' && item.value) {211tools.push(item.value);212}213}214return tools;215} else if (toolsAttribute.value.type === 'object') {216const tools: string[] = [];217const collectLeafs = ({ key, value }: { key: IStringValue; value: IValue }) => {218if (value.type === 'boolean') {219tools.push(key.value);220} else if (value.type === 'object') {221value.properties.forEach(collectLeafs);222}223};224toolsAttribute.value.properties.forEach(collectLeafs);225return tools;226}227return undefined;228}229230public get handOffs(): IHandOff[] | undefined {231const handoffsAttribute = this._parsedHeader.attributes.find(attr => attr.key === PromptHeaderAttributes.handOffs);232if (!handoffsAttribute) {233return undefined;234}235if (handoffsAttribute.value.type === 'array') {236// Array format: list of objects: { agent, label, prompt, send?, showContinueOn? }237const handoffs: IHandOff[] = [];238for (const item of handoffsAttribute.value.items) {239if (item.type === 'object') {240let agent: string | undefined;241let label: string | undefined;242let prompt: string | undefined;243let send: boolean | undefined;244let showContinueOn: boolean | 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}257}258if (agent && label && prompt !== undefined) {259const handoff: IHandOff = {260agent,261label,262prompt,263...(send !== undefined ? { send } : {}),264...(showContinueOn !== undefined ? { showContinueOn } : {})265};266handoffs.push(handoff);267}268}269}270return handoffs;271}272return undefined;273}274}275276export interface IHandOff {277readonly agent: string;278readonly label: string;279readonly prompt: string;280readonly send?: boolean;281readonly showContinueOn?: boolean; // treated exactly like send (optional boolean)282}283284export interface IHeaderAttribute {285readonly range: Range;286readonly key: string;287readonly value: IValue;288}289290export interface IStringValue { readonly type: 'string'; readonly value: string; readonly range: Range }291export interface INumberValue { readonly type: 'number'; readonly value: number; readonly range: Range }292export interface INullValue { readonly type: 'null'; readonly value: null; readonly range: Range }293export interface IBooleanValue { readonly type: 'boolean'; readonly value: boolean; readonly range: Range }294295export interface IArrayValue {296readonly type: 'array';297readonly items: readonly IValue[];298readonly range: Range;299}300301export interface IObjectValue {302readonly type: 'object';303readonly properties: { key: IStringValue; value: IValue }[];304readonly range: Range;305}306307export type IValue = IStringValue | INumberValue | IBooleanValue | IArrayValue | IObjectValue | INullValue;308309310interface ParsedBody {311readonly fileReferences: readonly IBodyFileReference[];312readonly variableReferences: readonly IBodyVariableReference[];313readonly bodyOffset: number;314}315316export class PromptBody {317private _parsed: ParsedBody | undefined;318319constructor(public readonly range: Range, private readonly linesWithEOL: string[], public readonly uri: URI) {320}321322public get fileReferences(): readonly IBodyFileReference[] {323return this.getParsedBody().fileReferences;324}325326public get variableReferences(): readonly IBodyVariableReference[] {327return this.getParsedBody().variableReferences;328}329330public get offset(): number {331return this.getParsedBody().bodyOffset;332}333334private getParsedBody(): ParsedBody {335if (this._parsed === undefined) {336const markdownLinkRanges: Range[] = [];337const fileReferences: IBodyFileReference[] = [];338const variableReferences: IBodyVariableReference[] = [];339const bodyOffset = Iterable.reduce(Iterable.slice(this.linesWithEOL, 0, this.range.startLineNumber - 1), (len, line) => line.length + len, 0);340for (let i = this.range.startLineNumber - 1, lineStartOffset = bodyOffset; i < this.range.endLineNumber - 1; i++) {341const line = this.linesWithEOL[i];342// Match markdown links: [text](link)343const linkMatch = line.matchAll(/\[(.*?)\]\((.+?)\)/g);344for (const match of linkMatch) {345const linkEndOffset = match.index + match[0].length - 1; // before the parenthesis346const linkStartOffset = match.index + match[0].length - match[2].length - 1;347const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1);348fileReferences.push({ content: match[2], range, isMarkdownLink: true });349markdownLinkRanges.push(new Range(i + 1, match.index + 1, i + 1, match.index + match[0].length + 1));350}351// Match #file:<filePath> and #tool:<toolName>352// Regarding the <toolName> pattern below, see also the variableReg regex in chatRequestParser.ts.353const reg = /#file:(?<filePath>[^\s#]+)|#tool:(?<toolName>[\w_\-\.\/]+)/gi;354const matches = line.matchAll(reg);355for (const match of matches) {356const fullMatch = match[0];357const fullRange = new Range(i + 1, match.index + 1, i + 1, match.index + fullMatch.length + 1);358if (markdownLinkRanges.some(mdRange => Range.areIntersectingOrTouching(mdRange, fullRange))) {359continue;360}361const contentMatch = match.groups?.['filePath'] || match.groups?.['toolName'];362if (!contentMatch) {363continue;364}365const startOffset = match.index + fullMatch.length - contentMatch.length;366const endOffset = match.index + fullMatch.length;367const range = new Range(i + 1, startOffset + 1, i + 1, endOffset + 1);368if (match.groups?.['filePath']) {369fileReferences.push({ content: match.groups?.['filePath'], range, isMarkdownLink: false });370} else if (match.groups?.['toolName']) {371variableReferences.push({ name: match.groups?.['toolName'], range, offset: lineStartOffset + match.index });372}373}374lineStartOffset += line.length;375}376this._parsed = { fileReferences: fileReferences.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)), variableReferences, bodyOffset };377}378return this._parsed;379}380381public getContent(): string {382return this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join('');383}384385public resolveFilePath(path: string): URI | undefined {386try {387if (path.startsWith('/')) {388return this.uri.with({ path });389} else if (path.match(/^[a-zA-Z]+:\//)) {390return URI.parse(path);391} else {392const dirName = dirname(this.uri);393return joinPath(dirName, path);394}395} catch {396return undefined;397}398}399}400401export interface IBodyFileReference {402readonly content: string;403readonly range: Range;404readonly isMarkdownLink: boolean;405}406407export interface IBodyVariableReference {408readonly name: string;409readonly range: Range;410readonly offset: number;411}412413414