Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts
4780 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Iterable } from '../../../../../base/common/iterator.js';
7
import { dirname, joinPath } from '../../../../../base/common/resources.js';
8
import { splitLinesIncludeSeparators } from '../../../../../base/common/strings.js';
9
import { URI } from '../../../../../base/common/uri.js';
10
import { parse, YamlNode, YamlParseError, Position as YamlPosition } from '../../../../../base/common/yaml.js';
11
import { Range } from '../../../../../editor/common/core/range.js';
12
13
export class PromptFileParser {
14
constructor() {
15
}
16
17
public parse(uri: URI, content: string): ParsedPromptFile {
18
const linesWithEOL = splitLinesIncludeSeparators(content);
19
if (linesWithEOL.length === 0) {
20
return new ParsedPromptFile(uri, undefined, undefined);
21
}
22
let header: PromptHeader | undefined = undefined;
23
let body: PromptBody | undefined = undefined;
24
let bodyStartLine = 0;
25
if (linesWithEOL[0].match(/^---[\s\r\n]*$/)) {
26
let headerEndLine = linesWithEOL.findIndex((line, index) => index > 0 && line.match(/^---[\s\r\n]*$/));
27
if (headerEndLine === -1) {
28
headerEndLine = linesWithEOL.length;
29
bodyStartLine = linesWithEOL.length;
30
} else {
31
bodyStartLine = headerEndLine + 1;
32
}
33
// range starts on the line after the ---, and ends at the beginning of the line that has the closing ---
34
const range = new Range(2, 1, headerEndLine + 1, 1);
35
header = new PromptHeader(range, linesWithEOL);
36
}
37
if (bodyStartLine < linesWithEOL.length) {
38
// range starts on the line after the ---, and ends at the beginning of line after the last line
39
const range = new Range(bodyStartLine + 1, 1, linesWithEOL.length + 1, 1);
40
body = new PromptBody(range, linesWithEOL, uri);
41
}
42
return new ParsedPromptFile(uri, header, body);
43
}
44
}
45
46
47
export class ParsedPromptFile {
48
constructor(public readonly uri: URI, public readonly header?: PromptHeader, public readonly body?: PromptBody) {
49
}
50
}
51
52
export interface ParseError {
53
readonly message: string;
54
readonly range: Range;
55
readonly code: string;
56
}
57
58
interface ParsedHeader {
59
readonly node: YamlNode | undefined;
60
readonly errors: ParseError[];
61
readonly attributes: IHeaderAttribute[];
62
}
63
64
export namespace PromptHeaderAttributes {
65
export const name = 'name';
66
export const description = 'description';
67
export const agent = 'agent';
68
export const mode = 'mode';
69
export const model = 'model';
70
export const applyTo = 'applyTo';
71
export const tools = 'tools';
72
export const handOffs = 'handoffs';
73
export const advancedOptions = 'advancedOptions';
74
export const argumentHint = 'argument-hint';
75
export const excludeAgent = 'excludeAgent';
76
export const target = 'target';
77
export const infer = 'infer';
78
}
79
80
export namespace GithubPromptHeaderAttributes {
81
export const mcpServers = 'mcp-servers';
82
}
83
84
export enum Target {
85
VSCode = 'vscode',
86
GitHubCopilot = 'github-copilot'
87
}
88
89
export class PromptHeader {
90
private _parsed: ParsedHeader | undefined;
91
92
constructor(public readonly range: Range, private readonly linesWithEOL: string[]) {
93
}
94
95
private get _parsedHeader(): ParsedHeader {
96
if (this._parsed === undefined) {
97
const yamlErrors: YamlParseError[] = [];
98
const lines = this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join('');
99
const node = parse(lines, yamlErrors);
100
const attributes = [];
101
const errors: ParseError[] = yamlErrors.map(err => ({ message: err.message, range: this.asRange(err), code: err.code }));
102
if (node) {
103
if (node.type !== 'object') {
104
errors.push({ message: 'Invalid header, expecting <key: value> pairs', range: this.range, code: 'INVALID_YAML' });
105
} else {
106
for (const property of node.properties) {
107
attributes.push({
108
key: property.key.value,
109
range: this.asRange({ start: property.key.start, end: property.value.end }),
110
value: this.asValue(property.value)
111
});
112
}
113
}
114
}
115
this._parsed = { node, attributes, errors };
116
}
117
return this._parsed;
118
}
119
120
private asRange({ start, end }: { start: YamlPosition; end: YamlPosition }): Range {
121
return new Range(this.range.startLineNumber + start.line, start.character + 1, this.range.startLineNumber + end.line, end.character + 1);
122
}
123
124
private asValue(node: YamlNode): IValue {
125
switch (node.type) {
126
case 'string':
127
return { type: 'string', value: node.value, range: this.asRange(node) };
128
case 'number':
129
return { type: 'number', value: node.value, range: this.asRange(node) };
130
case 'boolean':
131
return { type: 'boolean', value: node.value, range: this.asRange(node) };
132
case 'null':
133
return { type: 'null', value: node.value, range: this.asRange(node) };
134
case 'array':
135
return { type: 'array', items: node.items.map(item => this.asValue(item)), range: this.asRange(node) };
136
case 'object': {
137
const properties = node.properties.map(property => ({ key: this.asValue(property.key) as IStringValue, value: this.asValue(property.value) }));
138
return { type: 'object', properties, range: this.asRange(node) };
139
}
140
}
141
}
142
143
public get attributes(): IHeaderAttribute[] {
144
return this._parsedHeader.attributes;
145
}
146
147
public getAttribute(key: string): IHeaderAttribute | undefined {
148
return this._parsedHeader.attributes.find(attr => attr.key === key);
149
}
150
151
public get errors(): ParseError[] {
152
return this._parsedHeader.errors;
153
}
154
155
private getStringAttribute(key: string): string | undefined {
156
const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);
157
if (attribute?.value.type === 'string') {
158
return attribute.value.value;
159
}
160
return undefined;
161
}
162
163
private getBooleanAttribute(key: string): boolean | undefined {
164
const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);
165
if (attribute?.value.type === 'boolean') {
166
return attribute.value.value;
167
}
168
return undefined;
169
}
170
171
public get name(): string | undefined {
172
return this.getStringAttribute(PromptHeaderAttributes.name);
173
}
174
175
public get description(): string | undefined {
176
return this.getStringAttribute(PromptHeaderAttributes.description);
177
}
178
179
public get agent(): string | undefined {
180
return this.getStringAttribute(PromptHeaderAttributes.agent) ?? this.getStringAttribute(PromptHeaderAttributes.mode);
181
}
182
183
public get model(): string | undefined {
184
return this.getStringAttribute(PromptHeaderAttributes.model);
185
}
186
187
public get applyTo(): string | undefined {
188
return this.getStringAttribute(PromptHeaderAttributes.applyTo);
189
}
190
191
public get argumentHint(): string | undefined {
192
return this.getStringAttribute(PromptHeaderAttributes.argumentHint);
193
}
194
195
public get target(): string | undefined {
196
return this.getStringAttribute(PromptHeaderAttributes.target);
197
}
198
199
public get infer(): boolean | undefined {
200
return this.getBooleanAttribute(PromptHeaderAttributes.infer);
201
}
202
203
public get tools(): string[] | undefined {
204
const toolsAttribute = this._parsedHeader.attributes.find(attr => attr.key === PromptHeaderAttributes.tools);
205
if (!toolsAttribute) {
206
return undefined;
207
}
208
if (toolsAttribute.value.type === 'array') {
209
const tools: string[] = [];
210
for (const item of toolsAttribute.value.items) {
211
if (item.type === 'string' && item.value) {
212
tools.push(item.value);
213
}
214
}
215
return tools;
216
} else if (toolsAttribute.value.type === 'object') {
217
const tools: string[] = [];
218
const collectLeafs = ({ key, value }: { key: IStringValue; value: IValue }) => {
219
if (value.type === 'boolean') {
220
tools.push(key.value);
221
} else if (value.type === 'object') {
222
value.properties.forEach(collectLeafs);
223
}
224
};
225
toolsAttribute.value.properties.forEach(collectLeafs);
226
return tools;
227
}
228
return undefined;
229
}
230
231
public get handOffs(): IHandOff[] | undefined {
232
const handoffsAttribute = this._parsedHeader.attributes.find(attr => attr.key === PromptHeaderAttributes.handOffs);
233
if (!handoffsAttribute) {
234
return undefined;
235
}
236
if (handoffsAttribute.value.type === 'array') {
237
// Array format: list of objects: { agent, label, prompt, send?, showContinueOn? }
238
const handoffs: IHandOff[] = [];
239
for (const item of handoffsAttribute.value.items) {
240
if (item.type === 'object') {
241
let agent: string | undefined;
242
let label: string | undefined;
243
let prompt: string | undefined;
244
let send: boolean | undefined;
245
let showContinueOn: boolean | undefined;
246
for (const prop of item.properties) {
247
if (prop.key.value === 'agent' && prop.value.type === 'string') {
248
agent = prop.value.value;
249
} else if (prop.key.value === 'label' && prop.value.type === 'string') {
250
label = prop.value.value;
251
} else if (prop.key.value === 'prompt' && prop.value.type === 'string') {
252
prompt = prop.value.value;
253
} else if (prop.key.value === 'send' && prop.value.type === 'boolean') {
254
send = prop.value.value;
255
} else if (prop.key.value === 'showContinueOn' && prop.value.type === 'boolean') {
256
showContinueOn = prop.value.value;
257
}
258
}
259
if (agent && label && prompt !== undefined) {
260
const handoff: IHandOff = {
261
agent,
262
label,
263
prompt,
264
...(send !== undefined ? { send } : {}),
265
...(showContinueOn !== undefined ? { showContinueOn } : {})
266
};
267
handoffs.push(handoff);
268
}
269
}
270
}
271
return handoffs;
272
}
273
return undefined;
274
}
275
}
276
277
export interface IHandOff {
278
readonly agent: string;
279
readonly label: string;
280
readonly prompt: string;
281
readonly send?: boolean;
282
readonly showContinueOn?: boolean; // treated exactly like send (optional boolean)
283
}
284
285
export interface IHeaderAttribute {
286
readonly range: Range;
287
readonly key: string;
288
readonly value: IValue;
289
}
290
291
export interface IStringValue { readonly type: 'string'; readonly value: string; readonly range: Range }
292
export interface INumberValue { readonly type: 'number'; readonly value: number; readonly range: Range }
293
export interface INullValue { readonly type: 'null'; readonly value: null; readonly range: Range }
294
export interface IBooleanValue { readonly type: 'boolean'; readonly value: boolean; readonly range: Range }
295
296
export interface IArrayValue {
297
readonly type: 'array';
298
readonly items: readonly IValue[];
299
readonly range: Range;
300
}
301
302
export interface IObjectValue {
303
readonly type: 'object';
304
readonly properties: { key: IStringValue; value: IValue }[];
305
readonly range: Range;
306
}
307
308
export type IValue = IStringValue | INumberValue | IBooleanValue | IArrayValue | IObjectValue | INullValue;
309
310
311
interface ParsedBody {
312
readonly fileReferences: readonly IBodyFileReference[];
313
readonly variableReferences: readonly IBodyVariableReference[];
314
readonly bodyOffset: number;
315
}
316
317
export class PromptBody {
318
private _parsed: ParsedBody | undefined;
319
320
constructor(public readonly range: Range, private readonly linesWithEOL: string[], public readonly uri: URI) {
321
}
322
323
public get fileReferences(): readonly IBodyFileReference[] {
324
return this.getParsedBody().fileReferences;
325
}
326
327
public get variableReferences(): readonly IBodyVariableReference[] {
328
return this.getParsedBody().variableReferences;
329
}
330
331
public get offset(): number {
332
return this.getParsedBody().bodyOffset;
333
}
334
335
private getParsedBody(): ParsedBody {
336
if (this._parsed === undefined) {
337
const markdownLinkRanges: Range[] = [];
338
const fileReferences: IBodyFileReference[] = [];
339
const variableReferences: IBodyVariableReference[] = [];
340
const bodyOffset = Iterable.reduce(Iterable.slice(this.linesWithEOL, 0, this.range.startLineNumber - 1), (len, line) => line.length + len, 0);
341
for (let i = this.range.startLineNumber - 1, lineStartOffset = bodyOffset; i < this.range.endLineNumber - 1; i++) {
342
const line = this.linesWithEOL[i];
343
// Match markdown links: [text](link)
344
const linkMatch = line.matchAll(/\[(.*?)\]\((.+?)\)/g);
345
for (const match of linkMatch) {
346
const linkEndOffset = match.index + match[0].length - 1; // before the parenthesis
347
const linkStartOffset = match.index + match[0].length - match[2].length - 1;
348
const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1);
349
fileReferences.push({ content: match[2], range, isMarkdownLink: true });
350
markdownLinkRanges.push(new Range(i + 1, match.index + 1, i + 1, match.index + match[0].length + 1));
351
}
352
// Match #file:<filePath> and #tool:<toolName>
353
// Regarding the <toolName> pattern below, see also the variableReg regex in chatRequestParser.ts.
354
const reg = /#file:(?<filePath>[^\s#]+)|#tool:(?<toolName>[\w_\-\.\/]+)/gi;
355
const matches = line.matchAll(reg);
356
for (const match of matches) {
357
const fullMatch = match[0];
358
const fullRange = new Range(i + 1, match.index + 1, i + 1, match.index + fullMatch.length + 1);
359
if (markdownLinkRanges.some(mdRange => Range.areIntersectingOrTouching(mdRange, fullRange))) {
360
continue;
361
}
362
const contentMatch = match.groups?.['filePath'] || match.groups?.['toolName'];
363
if (!contentMatch) {
364
continue;
365
}
366
const startOffset = match.index + fullMatch.length - contentMatch.length;
367
const endOffset = match.index + fullMatch.length;
368
const range = new Range(i + 1, startOffset + 1, i + 1, endOffset + 1);
369
if (match.groups?.['filePath']) {
370
fileReferences.push({ content: match.groups?.['filePath'], range, isMarkdownLink: false });
371
} else if (match.groups?.['toolName']) {
372
variableReferences.push({ name: match.groups?.['toolName'], range, offset: lineStartOffset + match.index });
373
}
374
}
375
lineStartOffset += line.length;
376
}
377
this._parsed = { fileReferences: fileReferences.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)), variableReferences, bodyOffset };
378
}
379
return this._parsed;
380
}
381
382
public getContent(): string {
383
return this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join('');
384
}
385
386
public resolveFilePath(path: string): URI | undefined {
387
try {
388
if (path.startsWith('/')) {
389
return this.uri.with({ path });
390
} else if (path.match(/^[a-zA-Z]+:\//)) {
391
return URI.parse(path);
392
} else {
393
const dirName = dirname(this.uri);
394
return joinPath(dirName, path);
395
}
396
} catch {
397
return undefined;
398
}
399
}
400
}
401
402
export interface IBodyFileReference {
403
readonly content: string;
404
readonly range: Range;
405
readonly isMarkdownLink: boolean;
406
}
407
408
export interface IBodyVariableReference {
409
readonly name: string;
410
readonly range: Range;
411
readonly offset: number;
412
}
413
414