Path: blob/main/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.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 { findNodeAtLocation, Node, parseTree } from '../../../../../base/common/json.js';6import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js';7import { URI } from '../../../../../base/common/uri.js';8import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js';9import { PromptsType } from '../../common/promptSyntax/promptTypes.js';10import { IFileService } from '../../../../../platform/files/common/files.js';11import { CancellationToken } from '../../../../../base/common/cancellation.js';12import { formatHookCommandLabel, HOOK_TYPES, HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js';13import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js';14import * as nls from '../../../../../nls.js';15import { ILabelService } from '../../../../../platform/label/common/label.js';16import { OperatingSystem } from '../../../../../base/common/platform.js';1718/**19* Converts an offset in content to a 1-based line and column.20*/21function offsetToPosition(content: string, offset: number): { line: number; column: number } {22let line = 1;23let column = 1;24for (let i = 0; i < offset && i < content.length; i++) {25if (content[i] === '\n') {26line++;27column = 1;28} else {29column++;30}31}32return { line, column };33}3435/**36* Finds the n-th command field node in a hook type array, handling both simple and nested formats.37* This iterates through the structure in the same order as the parser flattens hooks.38*/39function findNthCommandNode(tree: Node, hookType: string, targetIndex: number, fieldName: string): Node | undefined {40const hookTypeArray = findNodeAtLocation(tree, ['hooks', hookType]);41if (!hookTypeArray || hookTypeArray.type !== 'array' || !hookTypeArray.children) {42return undefined;43}4445let currentIndex = 0;4647for (let i = 0; i < hookTypeArray.children.length; i++) {48const item = hookTypeArray.children[i];49if (item.type !== 'object') {50continue;51}5253// Check if this item has nested hooks (matcher format)54const nestedHooksNode = findNodeAtLocation(tree, ['hooks', hookType, i, 'hooks']);55if (nestedHooksNode && nestedHooksNode.type === 'array' && nestedHooksNode.children) {56// Iterate through nested hooks57for (let j = 0; j < nestedHooksNode.children.length; j++) {58if (currentIndex === targetIndex) {59return findNodeAtLocation(tree, ['hooks', hookType, i, 'hooks', j, fieldName]);60}61currentIndex++;62}63} else {64// Simple format - direct command65if (currentIndex === targetIndex) {66return findNodeAtLocation(tree, ['hooks', hookType, i, fieldName]);67}68currentIndex++;69}70}7172return undefined;73}7475/**76* Finds the selection range for a hook command field value in JSON content.77* Supports both simple format and nested matcher format:78* - Simple: { hooks: { hookType: [{ command: "..." }] } }79* - Nested: { hooks: { hookType: [{ matcher: "", hooks: [{ command: "..." }] }] } }80*81* The index is a flattened index across all commands in the hook type, regardless of nesting.82*83* @param content The JSON file content84* @param hookType The hook type (e.g., "sessionStart")85* @param index The flattened index of the hook command within the hook type86* @param fieldName The field name to find ('command', 'bash', or 'powershell')87* @returns The selection range for the field value, or undefined if not found88*/89export function findHookCommandSelection(content: string, hookType: string, index: number, fieldName: string): ITextEditorSelection | undefined {90const tree = parseTree(content);91if (!tree) {92return undefined;93}9495const node = findNthCommandNode(tree, hookType, index, fieldName);96if (!node || node.type !== 'string') {97return undefined;98}99100// Node offset/length includes quotes, so adjust to select only the value content101const valueStart = node.offset + 1; // After opening quote102const valueEnd = node.offset + node.length - 1; // Before closing quote103104const start = offsetToPosition(content, valueStart);105const end = offsetToPosition(content, valueEnd);106107return {108startLineNumber: start.line,109startColumn: start.column,110endLineNumber: end.line,111endColumn: end.column112};113}114115/**116* Parsed hook information.117*/118export interface IParsedHook {119hookType: HookType;120hookTypeLabel: string;121command: IHookCommand;122commandLabel: string;123fileUri: URI;124filePath: string;125index: number;126/** The original hook type ID as it appears in the JSON file */127originalHookTypeId: string;128}129130/**131* Parses all hook files and extracts individual hooks.132* This is a shared helper used by both the configure action and diagnostics.133*/134export async function parseAllHookFiles(135promptsService: IPromptsService,136fileService: IFileService,137labelService: ILabelService,138workspaceRootUri: URI | undefined,139userHome: string,140os: OperatingSystem,141token: CancellationToken142): Promise<IParsedHook[]> {143const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, token);144const parsedHooks: IParsedHook[] = [];145146for (const hookFile of hookFiles) {147try {148const content = await fileService.readFile(hookFile.uri);149const json = JSON.parse(content.value.toString());150151// Use format-aware parsing152const { hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome);153154for (const [hookType, { hooks: commands, originalId }] of hooks) {155const hookTypeMeta = HOOK_TYPES.find(h => h.id === hookType);156if (!hookTypeMeta) {157continue;158}159160for (let i = 0; i < commands.length; i++) {161const command = commands[i];162const commandLabel = formatHookCommandLabel(command, os) || nls.localize('commands.hook.emptyCommand', '(empty command)');163parsedHooks.push({164hookType,165hookTypeLabel: hookTypeMeta.label,166command,167commandLabel,168fileUri: hookFile.uri,169filePath: labelService.getUriLabel(hookFile.uri, { relative: true }),170index: i,171originalHookTypeId: originalId172});173}174}175} catch (error) {176// Skip files that can't be parsed, but surface the failure for diagnostics177console.error('Failed to read or parse hook file', hookFile.uri.toString(), error);178}179}180181return parsedHooks;182}183184185