Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.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 { findNodeAtLocation, Node, parseTree } from '../../../../../base/common/json.js';
7
import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js';
8
import { URI } from '../../../../../base/common/uri.js';
9
import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js';
10
import { PromptsType } from '../../common/promptSyntax/promptTypes.js';
11
import { IFileService } from '../../../../../platform/files/common/files.js';
12
import { CancellationToken } from '../../../../../base/common/cancellation.js';
13
import { formatHookCommandLabel, HOOK_TYPES, HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js';
14
import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js';
15
import * as nls from '../../../../../nls.js';
16
import { ILabelService } from '../../../../../platform/label/common/label.js';
17
import { OperatingSystem } from '../../../../../base/common/platform.js';
18
19
/**
20
* Converts an offset in content to a 1-based line and column.
21
*/
22
function offsetToPosition(content: string, offset: number): { line: number; column: number } {
23
let line = 1;
24
let column = 1;
25
for (let i = 0; i < offset && i < content.length; i++) {
26
if (content[i] === '\n') {
27
line++;
28
column = 1;
29
} else {
30
column++;
31
}
32
}
33
return { line, column };
34
}
35
36
/**
37
* Finds the n-th command field node in a hook type array, handling both simple and nested formats.
38
* This iterates through the structure in the same order as the parser flattens hooks.
39
*/
40
function findNthCommandNode(tree: Node, hookType: string, targetIndex: number, fieldName: string): Node | undefined {
41
const hookTypeArray = findNodeAtLocation(tree, ['hooks', hookType]);
42
if (!hookTypeArray || hookTypeArray.type !== 'array' || !hookTypeArray.children) {
43
return undefined;
44
}
45
46
let currentIndex = 0;
47
48
for (let i = 0; i < hookTypeArray.children.length; i++) {
49
const item = hookTypeArray.children[i];
50
if (item.type !== 'object') {
51
continue;
52
}
53
54
// Check if this item has nested hooks (matcher format)
55
const nestedHooksNode = findNodeAtLocation(tree, ['hooks', hookType, i, 'hooks']);
56
if (nestedHooksNode && nestedHooksNode.type === 'array' && nestedHooksNode.children) {
57
// Iterate through nested hooks
58
for (let j = 0; j < nestedHooksNode.children.length; j++) {
59
if (currentIndex === targetIndex) {
60
return findNodeAtLocation(tree, ['hooks', hookType, i, 'hooks', j, fieldName]);
61
}
62
currentIndex++;
63
}
64
} else {
65
// Simple format - direct command
66
if (currentIndex === targetIndex) {
67
return findNodeAtLocation(tree, ['hooks', hookType, i, fieldName]);
68
}
69
currentIndex++;
70
}
71
}
72
73
return undefined;
74
}
75
76
/**
77
* Finds the selection range for a hook command field value in JSON content.
78
* Supports both simple format and nested matcher format:
79
* - Simple: { hooks: { hookType: [{ command: "..." }] } }
80
* - Nested: { hooks: { hookType: [{ matcher: "", hooks: [{ command: "..." }] }] } }
81
*
82
* The index is a flattened index across all commands in the hook type, regardless of nesting.
83
*
84
* @param content The JSON file content
85
* @param hookType The hook type (e.g., "sessionStart")
86
* @param index The flattened index of the hook command within the hook type
87
* @param fieldName The field name to find ('command', 'bash', or 'powershell')
88
* @returns The selection range for the field value, or undefined if not found
89
*/
90
export function findHookCommandSelection(content: string, hookType: string, index: number, fieldName: string): ITextEditorSelection | undefined {
91
const tree = parseTree(content);
92
if (!tree) {
93
return undefined;
94
}
95
96
const node = findNthCommandNode(tree, hookType, index, fieldName);
97
if (!node || node.type !== 'string') {
98
return undefined;
99
}
100
101
// Node offset/length includes quotes, so adjust to select only the value content
102
const valueStart = node.offset + 1; // After opening quote
103
const valueEnd = node.offset + node.length - 1; // Before closing quote
104
105
const start = offsetToPosition(content, valueStart);
106
const end = offsetToPosition(content, valueEnd);
107
108
return {
109
startLineNumber: start.line,
110
startColumn: start.column,
111
endLineNumber: end.line,
112
endColumn: end.column
113
};
114
}
115
116
/**
117
* Parsed hook information.
118
*/
119
export interface IParsedHook {
120
hookType: HookType;
121
hookTypeLabel: string;
122
command: IHookCommand;
123
commandLabel: string;
124
fileUri: URI;
125
filePath: string;
126
index: number;
127
/** The original hook type ID as it appears in the JSON file */
128
originalHookTypeId: string;
129
}
130
131
/**
132
* Parses all hook files and extracts individual hooks.
133
* This is a shared helper used by both the configure action and diagnostics.
134
*/
135
export async function parseAllHookFiles(
136
promptsService: IPromptsService,
137
fileService: IFileService,
138
labelService: ILabelService,
139
workspaceRootUri: URI | undefined,
140
userHome: string,
141
os: OperatingSystem,
142
token: CancellationToken
143
): Promise<IParsedHook[]> {
144
const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, token);
145
const parsedHooks: IParsedHook[] = [];
146
147
for (const hookFile of hookFiles) {
148
try {
149
const content = await fileService.readFile(hookFile.uri);
150
const json = JSON.parse(content.value.toString());
151
152
// Use format-aware parsing
153
const { hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome);
154
155
for (const [hookType, { hooks: commands, originalId }] of hooks) {
156
const hookTypeMeta = HOOK_TYPES.find(h => h.id === hookType);
157
if (!hookTypeMeta) {
158
continue;
159
}
160
161
for (let i = 0; i < commands.length; i++) {
162
const command = commands[i];
163
const commandLabel = formatHookCommandLabel(command, os) || nls.localize('commands.hook.emptyCommand', '(empty command)');
164
parsedHooks.push({
165
hookType,
166
hookTypeLabel: hookTypeMeta.label,
167
command,
168
commandLabel,
169
fileUri: hookFile.uri,
170
filePath: labelService.getUriLabel(hookFile.uri, { relative: true }),
171
index: i,
172
originalHookTypeId: originalId
173
});
174
}
175
}
176
} catch (error) {
177
// Skip files that can't be parsed, but surface the failure for diagnostics
178
console.error('Failed to read or parse hook file', hookFile.uri.toString(), error);
179
}
180
}
181
182
return parsedHooks;
183
}
184
185