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
5243 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
import { Target } from './service/promptsService.js';
13
14
export class PromptFileParser {
15
constructor() {
16
}
17
18
public parse(uri: URI, content: string): ParsedPromptFile {
19
const linesWithEOL = splitLinesIncludeSeparators(content);
20
if (linesWithEOL.length === 0) {
21
return new ParsedPromptFile(uri, undefined, undefined);
22
}
23
let header: PromptHeader | undefined = undefined;
24
let body: PromptBody | undefined = undefined;
25
let bodyStartLine = 0;
26
if (linesWithEOL[0].match(/^---[\s\r\n]*$/)) {
27
let headerEndLine = linesWithEOL.findIndex((line, index) => index > 0 && line.match(/^---[\s\r\n]*$/));
28
if (headerEndLine === -1) {
29
headerEndLine = linesWithEOL.length;
30
bodyStartLine = linesWithEOL.length;
31
} else {
32
bodyStartLine = headerEndLine + 1;
33
}
34
// range starts on the line after the ---, and ends at the beginning of the line that has the closing ---
35
const range = new Range(2, 1, headerEndLine + 1, 1);
36
header = new PromptHeader(range, uri, linesWithEOL);
37
}
38
if (bodyStartLine < linesWithEOL.length) {
39
// range starts on the line after the ---, and ends at the beginning of line after the last line
40
const range = new Range(bodyStartLine + 1, 1, linesWithEOL.length + 1, 1);
41
body = new PromptBody(range, linesWithEOL, uri);
42
}
43
return new ParsedPromptFile(uri, header, body);
44
}
45
}
46
47
48
export class ParsedPromptFile {
49
constructor(public readonly uri: URI, public readonly header?: PromptHeader, public readonly body?: PromptBody) {
50
}
51
}
52
53
export interface ParseError {
54
readonly message: string;
55
readonly range: Range;
56
readonly code: string;
57
}
58
59
interface ParsedHeader {
60
readonly node: YamlNode | undefined;
61
readonly errors: ParseError[];
62
readonly attributes: IHeaderAttribute[];
63
}
64
65
export namespace PromptHeaderAttributes {
66
export const name = 'name';
67
export const description = 'description';
68
export const agent = 'agent';
69
export const mode = 'mode';
70
export const model = 'model';
71
export const applyTo = 'applyTo';
72
export const tools = 'tools';
73
export const handOffs = 'handoffs';
74
export const advancedOptions = 'advancedOptions';
75
export const argumentHint = 'argument-hint';
76
export const excludeAgent = 'excludeAgent';
77
export const target = 'target';
78
export const infer = 'infer';
79
export const license = 'license';
80
export const compatibility = 'compatibility';
81
export const metadata = 'metadata';
82
export const agents = 'agents';
83
export const userInvokable = 'user-invokable';
84
export const disableModelInvocation = 'disable-model-invocation';
85
}
86
87
export namespace GithubPromptHeaderAttributes {
88
export const mcpServers = 'mcp-servers';
89
}
90
91
export namespace ClaudeHeaderAttributes {
92
export const disallowedTools = 'disallowedTools';
93
}
94
95
export function isTarget(value: unknown): value is Target {
96
return value === Target.VSCode || value === Target.GitHubCopilot || value === Target.Claude || value === Target.Undefined;
97
}
98
99
export class PromptHeader {
100
private _parsed: ParsedHeader | undefined;
101
102
constructor(public readonly range: Range, public readonly uri: URI, private readonly linesWithEOL: string[]) {
103
}
104
105
private get _parsedHeader(): ParsedHeader {
106
if (this._parsed === undefined) {
107
const yamlErrors: YamlParseError[] = [];
108
const lines = this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join('');
109
const node = parse(lines, yamlErrors);
110
const attributes = [];
111
const errors: ParseError[] = yamlErrors.map(err => ({ message: err.message, range: this.asRange(err), code: err.code }));
112
if (node) {
113
if (node.type !== 'object') {
114
errors.push({ message: 'Invalid header, expecting <key: value> pairs', range: this.range, code: 'INVALID_YAML' });
115
} else {
116
for (const property of node.properties) {
117
attributes.push({
118
key: property.key.value,
119
range: this.asRange({ start: property.key.start, end: property.value.end }),
120
value: this.asValue(property.value)
121
});
122
}
123
}
124
}
125
this._parsed = { node, attributes, errors };
126
}
127
return this._parsed;
128
}
129
130
private asRange({ start, end }: { start: YamlPosition; end: YamlPosition }): Range {
131
return new Range(this.range.startLineNumber + start.line, start.character + 1, this.range.startLineNumber + end.line, end.character + 1);
132
}
133
134
private asValue(node: YamlNode): IValue {
135
switch (node.type) {
136
case 'string':
137
return { type: 'string', value: node.value, range: this.asRange(node) };
138
case 'number':
139
return { type: 'number', value: node.value, range: this.asRange(node) };
140
case 'boolean':
141
return { type: 'boolean', value: node.value, range: this.asRange(node) };
142
case 'null':
143
return { type: 'null', value: node.value, range: this.asRange(node) };
144
case 'array':
145
return { type: 'array', items: node.items.map(item => this.asValue(item)), range: this.asRange(node) };
146
case 'object': {
147
const properties = node.properties.map(property => ({ key: this.asValue(property.key) as IStringValue, value: this.asValue(property.value) }));
148
return { type: 'object', properties, range: this.asRange(node) };
149
}
150
}
151
}
152
153
public get attributes(): IHeaderAttribute[] {
154
return this._parsedHeader.attributes;
155
}
156
157
public getAttribute(key: string): IHeaderAttribute | undefined {
158
return this._parsedHeader.attributes.find(attr => attr.key === key);
159
}
160
161
public get errors(): ParseError[] {
162
return this._parsedHeader.errors;
163
}
164
165
private getStringAttribute(key: string): string | undefined {
166
const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);
167
if (attribute?.value.type === 'string') {
168
return attribute.value.value;
169
}
170
return undefined;
171
}
172
173
public get name(): string | undefined {
174
return this.getStringAttribute(PromptHeaderAttributes.name);
175
}
176
177
public get description(): string | undefined {
178
return this.getStringAttribute(PromptHeaderAttributes.description);
179
}
180
181
public get agent(): string | undefined {
182
return this.getStringAttribute(PromptHeaderAttributes.agent) ?? this.getStringAttribute(PromptHeaderAttributes.mode);
183
}
184
185
public get model(): readonly string[] | undefined {
186
return this.getStringOrStringArrayAttribute(PromptHeaderAttributes.model);
187
}
188
189
public get applyTo(): string | undefined {
190
return this.getStringAttribute(PromptHeaderAttributes.applyTo);
191
}
192
193
public get argumentHint(): string | undefined {
194
return this.getStringAttribute(PromptHeaderAttributes.argumentHint);
195
}
196
197
public get target(): string | undefined {
198
return this.getStringAttribute(PromptHeaderAttributes.target);
199
}
200
201
public get infer(): boolean | undefined {
202
const attribute = this._parsedHeader.attributes.find(attr => attr.key === PromptHeaderAttributes.infer);
203
if (attribute?.value.type === 'boolean') {
204
return attribute.value.value;
205
}
206
return undefined;
207
}
208
209
public get tools(): string[] | undefined {
210
const toolsAttribute = this._parsedHeader.attributes.find(attr => attr.key === PromptHeaderAttributes.tools);
211
if (!toolsAttribute) {
212
return undefined;
213
}
214
let value = toolsAttribute.value;
215
if (value.type === 'string') {
216
value = parseCommaSeparatedList(value);
217
}
218
if (value.type === 'array') {
219
const tools: string[] = [];
220
for (const item of value.items) {
221
if (item.type === 'string' && item.value) {
222
tools.push(item.value);
223
}
224
}
225
return tools;
226
}
227
return undefined;
228
}
229
230
public get handOffs(): IHandOff[] | undefined {
231
const handoffsAttribute = this._parsedHeader.attributes.find(attr => attr.key === PromptHeaderAttributes.handOffs);
232
if (!handoffsAttribute) {
233
return undefined;
234
}
235
if (handoffsAttribute.value.type === 'array') {
236
// Array format: list of objects: { agent, label, prompt, send?, showContinueOn?, model? }
237
const handoffs: IHandOff[] = [];
238
for (const item of handoffsAttribute.value.items) {
239
if (item.type === 'object') {
240
let agent: string | undefined;
241
let label: string | undefined;
242
let prompt: string | undefined;
243
let send: boolean | undefined;
244
let showContinueOn: boolean | undefined;
245
let model: string | 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
} else if (prop.key.value === 'model' && prop.value.type === 'string') {
258
model = prop.value.value;
259
}
260
}
261
if (agent && label && prompt !== undefined) {
262
const handoff: IHandOff = {
263
agent,
264
label,
265
prompt,
266
...(send !== undefined ? { send } : {}),
267
...(showContinueOn !== undefined ? { showContinueOn } : {}),
268
...(model !== undefined ? { model } : {})
269
};
270
handoffs.push(handoff);
271
}
272
}
273
}
274
return handoffs;
275
}
276
return undefined;
277
}
278
279
private getStringArrayAttribute(key: string): string[] | undefined {
280
const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);
281
if (!attribute) {
282
return undefined;
283
}
284
if (attribute.value.type === 'array') {
285
const result: string[] = [];
286
for (const item of attribute.value.items) {
287
if (item.type === 'string' && item.value) {
288
result.push(item.value);
289
}
290
}
291
return result;
292
}
293
return undefined;
294
}
295
296
private getStringOrStringArrayAttribute(key: string): readonly string[] | undefined {
297
const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);
298
if (!attribute) {
299
return undefined;
300
}
301
if (attribute.value.type === 'string') {
302
return [attribute.value.value];
303
}
304
if (attribute.value.type === 'array') {
305
const result: string[] = [];
306
for (const item of attribute.value.items) {
307
if (item.type === 'string') {
308
result.push(item.value);
309
}
310
}
311
return result;
312
}
313
return undefined;
314
}
315
316
public get agents(): string[] | undefined {
317
return this.getStringArrayAttribute(PromptHeaderAttributes.agents);
318
}
319
320
public get userInvokable(): boolean | undefined {
321
return this.getBooleanAttribute(PromptHeaderAttributes.userInvokable);
322
}
323
324
public get disableModelInvocation(): boolean | undefined {
325
return this.getBooleanAttribute(PromptHeaderAttributes.disableModelInvocation);
326
}
327
328
private getBooleanAttribute(key: string): boolean | undefined {
329
const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);
330
if (attribute?.value.type === 'boolean') {
331
return attribute.value.value;
332
}
333
return undefined;
334
}
335
}
336
337
export interface IHandOff {
338
readonly agent: string;
339
readonly label: string;
340
readonly prompt: string;
341
readonly send?: boolean;
342
readonly showContinueOn?: boolean; // treated exactly like send (optional boolean)
343
readonly model?: string; // qualified model name to switch to (e.g., "GPT-5 (copilot)")
344
}
345
346
export interface IHeaderAttribute {
347
readonly range: Range;
348
readonly key: string;
349
readonly value: IValue;
350
}
351
352
export interface IStringValue { readonly type: 'string'; readonly value: string; readonly range: Range }
353
export interface INumberValue { readonly type: 'number'; readonly value: number; readonly range: Range }
354
export interface INullValue { readonly type: 'null'; readonly value: null; readonly range: Range }
355
export interface IBooleanValue { readonly type: 'boolean'; readonly value: boolean; readonly range: Range }
356
357
export interface IArrayValue {
358
readonly type: 'array';
359
readonly items: readonly IValue[];
360
readonly range: Range;
361
}
362
363
export interface IObjectValue {
364
readonly type: 'object';
365
readonly properties: { key: IStringValue; value: IValue }[];
366
readonly range: Range;
367
}
368
369
export type IValue = IStringValue | INumberValue | IBooleanValue | IArrayValue | IObjectValue | INullValue;
370
371
372
interface ParsedBody {
373
readonly fileReferences: readonly IBodyFileReference[];
374
readonly variableReferences: readonly IBodyVariableReference[];
375
readonly bodyOffset: number;
376
}
377
378
export class PromptBody {
379
private _parsed: ParsedBody | undefined;
380
381
constructor(public readonly range: Range, private readonly linesWithEOL: string[], public readonly uri: URI) {
382
}
383
384
public get fileReferences(): readonly IBodyFileReference[] {
385
return this.getParsedBody().fileReferences;
386
}
387
388
public get variableReferences(): readonly IBodyVariableReference[] {
389
return this.getParsedBody().variableReferences;
390
}
391
392
public get offset(): number {
393
return this.getParsedBody().bodyOffset;
394
}
395
396
private getParsedBody(): ParsedBody {
397
if (this._parsed === undefined) {
398
const markdownLinkRanges: Range[] = [];
399
const fileReferences: IBodyFileReference[] = [];
400
const variableReferences: IBodyVariableReference[] = [];
401
const bodyOffset = Iterable.reduce(Iterable.slice(this.linesWithEOL, 0, this.range.startLineNumber - 1), (len, line) => line.length + len, 0);
402
for (let i = this.range.startLineNumber - 1, lineStartOffset = bodyOffset; i < this.range.endLineNumber - 1; i++) {
403
const line = this.linesWithEOL[i];
404
// Match markdown links: [text](link)
405
const linkMatch = line.matchAll(/\[(.*?)\]\((.+?)\)/g);
406
for (const match of linkMatch) {
407
if (match.index > 0 && line[match.index - 1] === '!') {
408
continue; // skip image links
409
}
410
const linkEndOffset = match.index + match[0].length - 1; // before the parenthesis
411
const linkStartOffset = match.index + match[0].length - match[2].length - 1;
412
const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1);
413
fileReferences.push({ content: match[2], range, isMarkdownLink: true });
414
markdownLinkRanges.push(new Range(i + 1, match.index + 1, i + 1, match.index + match[0].length + 1));
415
}
416
// Match #file:<filePath> and #tool:<toolName>
417
// Regarding the <toolName> pattern below, see also the variableReg regex in chatRequestParser.ts.
418
const reg = /#file:(?<filePath>[^\s#]+)|#tool:(?<toolName>[\w_\-\.\/]+)/gi;
419
const matches = line.matchAll(reg);
420
for (const match of matches) {
421
const fullMatch = match[0];
422
const fullRange = new Range(i + 1, match.index + 1, i + 1, match.index + fullMatch.length + 1);
423
if (markdownLinkRanges.some(mdRange => Range.areIntersectingOrTouching(mdRange, fullRange))) {
424
continue;
425
}
426
const contentMatch = match.groups?.['filePath'] || match.groups?.['toolName'];
427
if (!contentMatch) {
428
continue;
429
}
430
const startOffset = match.index + fullMatch.length - contentMatch.length;
431
const endOffset = match.index + fullMatch.length;
432
const range = new Range(i + 1, startOffset + 1, i + 1, endOffset + 1);
433
if (match.groups?.['filePath']) {
434
fileReferences.push({ content: match.groups?.['filePath'], range, isMarkdownLink: false });
435
} else if (match.groups?.['toolName']) {
436
variableReferences.push({ name: match.groups?.['toolName'], range, offset: lineStartOffset + match.index });
437
}
438
}
439
lineStartOffset += line.length;
440
}
441
this._parsed = { fileReferences: fileReferences.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)), variableReferences, bodyOffset };
442
}
443
return this._parsed;
444
}
445
446
public getContent(): string {
447
return this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join('');
448
}
449
450
public resolveFilePath(path: string): URI | undefined {
451
try {
452
if (path.startsWith('/')) {
453
return this.uri.with({ path });
454
} else if (path.match(/^[a-zA-Z]+:\//)) {
455
return URI.parse(path);
456
} else {
457
const dirName = dirname(this.uri);
458
return joinPath(dirName, path);
459
}
460
} catch {
461
return undefined;
462
}
463
}
464
}
465
466
export interface IBodyFileReference {
467
readonly content: string;
468
readonly range: Range;
469
readonly isMarkdownLink: boolean;
470
}
471
472
export interface IBodyVariableReference {
473
readonly name: string;
474
readonly range: Range;
475
readonly offset: number;
476
}
477
478
/**
479
* Parses a comma-separated list of values into an array of strings.
480
* Values can be unquoted or quoted (single or double quotes).
481
*
482
* @param input A string containing comma-separated values
483
* @returns An IArrayValue containing the parsed values and their ranges
484
*/
485
export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue {
486
const result: IStringValue[] = [];
487
const input = stringValue.value;
488
const positionOffset = stringValue.range.getStartPosition();
489
let pos = 0;
490
const isWhitespace = (char: string): boolean => char === ' ' || char === '\t';
491
492
while (pos < input.length) {
493
// Skip leading whitespace
494
while (pos < input.length && isWhitespace(input[pos])) {
495
pos++;
496
}
497
498
if (pos >= input.length) {
499
break;
500
}
501
502
const startPos = pos;
503
let value = '';
504
let endPos: number;
505
506
const char = input[pos];
507
if (char === '"' || char === `'`) {
508
// Quoted string
509
const quote = char;
510
pos++; // Skip opening quote
511
512
while (pos < input.length && input[pos] !== quote) {
513
value += input[pos];
514
pos++;
515
}
516
endPos = pos + 1; // Include closing quote in the range
517
518
if (pos < input.length) {
519
pos++;
520
}
521
522
} else {
523
// Unquoted string - read until comma or end
524
const startPos = pos;
525
while (pos < input.length && input[pos] !== ',') {
526
value += input[pos];
527
pos++;
528
}
529
value = value.trimEnd();
530
endPos = startPos + value.length;
531
}
532
533
result.push({ type: 'string', value: value, range: new Range(positionOffset.lineNumber, positionOffset.column + startPos, positionOffset.lineNumber, positionOffset.column + endPos) });
534
535
// Skip whitespace after value
536
while (pos < input.length && isWhitespace(input[pos])) {
537
pos++;
538
}
539
540
// Skip comma if present
541
if (pos < input.length && input[pos] === ',') {
542
pos++;
543
}
544
}
545
546
return { type: 'array', items: result, range: stringValue.range };
547
}
548
549
550
551