|
| 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 | +// Copies type definitions from the sibling `agent-host-protocol` repo into |
| 7 | +// `src/vs/platform/agentHost/common/state/protocol/`. Run via: |
| 8 | +// |
| 9 | +// npx tsx scripts/sync-agent-host-protocol.ts |
| 10 | +// |
| 11 | +// Transformations applied: |
| 12 | +// 1. Converts `const enum` to `const` object + string literal union (VS Code |
| 13 | +// tsconfig uses `preserveConstEnums` which makes `const enum` nominal). |
| 14 | +// 2. Converts 2-space indentation to tabs. |
| 15 | +// 3. Merges duplicate imports from the same module. |
| 16 | +// 4. Formats with the project's tsfmt.json settings. |
| 17 | +// 5. Adds Microsoft copyright header. |
| 18 | +// |
| 19 | +// URI stays as `string` (the protocol's canonical representation). VS Code code |
| 20 | +// should call `URI.parse()` at point-of-use where a URI class is needed. |
| 21 | + |
| 22 | +import * as fs from 'fs'; |
| 23 | +import * as path from 'path'; |
| 24 | +import { execSync } from 'child_process'; |
| 25 | +import * as ts from 'typescript'; |
| 26 | + |
| 27 | +const ROOT = path.resolve(__dirname, '..'); |
| 28 | +const PROTOCOL_REPO = path.resolve(ROOT, '../agent-host-protocol'); |
| 29 | +const TYPES_DIR = path.join(PROTOCOL_REPO, 'types'); |
| 30 | +const DEST_DIR = path.join(ROOT, 'src/vs/platform/agentHost/common/state/protocol'); |
| 31 | + |
| 32 | +// Load tsfmt.json formatting options once |
| 33 | +const TSFMT_PATH = path.join(ROOT, 'tsfmt.json'); |
| 34 | +const FORMAT_OPTIONS: ts.FormatCodeSettings = JSON.parse(fs.readFileSync(TSFMT_PATH, 'utf-8')); |
| 35 | + |
| 36 | +/** |
| 37 | + * Formats a TypeScript source string using the TypeScript language service |
| 38 | + * formatter with the project's tsfmt.json settings. |
| 39 | + */ |
| 40 | +function formatTypeScript(content: string, fileName: string): string { |
| 41 | + const host: ts.LanguageServiceHost = { |
| 42 | + getCompilationSettings: () => ({}), |
| 43 | + getScriptFileNames: () => [fileName], |
| 44 | + getScriptVersion: () => '1', |
| 45 | + getScriptSnapshot: (name: string) => name === fileName ? ts.ScriptSnapshot.fromString(content) : undefined, |
| 46 | + getCurrentDirectory: () => ROOT, |
| 47 | + getDefaultLibFileName: () => '', |
| 48 | + fileExists: () => false, |
| 49 | + readFile: () => undefined, |
| 50 | + }; |
| 51 | + const ls = ts.createLanguageService(host); |
| 52 | + const edits = ls.getFormattingEditsForDocument(fileName, FORMAT_OPTIONS); |
| 53 | + // Apply edits in reverse order to preserve offsets |
| 54 | + for (let i = edits.length - 1; i >= 0; i--) { |
| 55 | + const edit = edits[i]; |
| 56 | + content = content.substring(0, edit.span.start) + edit.newText + content.substring(edit.span.start + edit.span.length); |
| 57 | + } |
| 58 | + ls.dispose(); |
| 59 | + return content; |
| 60 | +} |
| 61 | + |
| 62 | +const COPYRIGHT = `/*--------------------------------------------------------------------------------------------- |
| 63 | + * Copyright (c) Microsoft Corporation. All rights reserved. |
| 64 | + * Licensed under the MIT License. See License.txt in the project root for license information. |
| 65 | + *--------------------------------------------------------------------------------------------*/`; |
| 66 | + |
| 67 | +const BANNER = '// allow-any-unicode-comment-file\n// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts'; |
| 68 | + |
| 69 | +// Files to copy. All go into protocol/. |
| 70 | +const FILES: { src: string; dest: string }[] = [ |
| 71 | + { src: 'state.ts', dest: 'state.ts' }, |
| 72 | + { src: 'actions.ts', dest: 'actions.ts' }, |
| 73 | + { src: 'commands.ts', dest: 'commands.ts' }, |
| 74 | + { src: 'errors.ts', dest: 'errors.ts' }, |
| 75 | + { src: 'notifications.ts', dest: 'notifications.ts' }, |
| 76 | + { src: 'messages.ts', dest: 'messages.ts' }, |
| 77 | + { src: 'version/registry.ts', dest: 'version/registry.ts' }, |
| 78 | +]; |
| 79 | + |
| 80 | +function getSourceCommitHash(): string { |
| 81 | + try { |
| 82 | + return execSync('git rev-parse --short HEAD', { cwd: PROTOCOL_REPO, encoding: 'utf-8' }).trim(); |
| 83 | + } catch { |
| 84 | + return 'unknown'; |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +function stripExistingHeader(content: string): string { |
| 89 | + return content.replace(/^\/\*\*?[\s\S]*?\*\/\s*/, ''); |
| 90 | +} |
| 91 | + |
| 92 | +function convertIndentation(content: string): string { |
| 93 | + const lines = content.split('\n'); |
| 94 | + return lines.map(line => { |
| 95 | + const match = line.match(/^( +)/); |
| 96 | + if (!match) { |
| 97 | + return line; |
| 98 | + } |
| 99 | + const spaces = match[1].length; |
| 100 | + const tabs = Math.floor(spaces / 2); |
| 101 | + const remainder = spaces % 2; |
| 102 | + return '\t'.repeat(tabs) + ' '.repeat(remainder) + line.slice(spaces); |
| 103 | + }).join('\n'); |
| 104 | +} |
| 105 | + |
| 106 | +/** |
| 107 | + * Merges duplicate imports from the same module. |
| 108 | + * Combines `import type { A }` and `import { B }` from the same module into |
| 109 | + * `import { B, type A }` to satisfy the no-duplicate-imports lint rule. |
| 110 | + */ |
| 111 | +function mergeDuplicateImports(content: string): string { |
| 112 | + // Collapse multi-line imports into single lines first |
| 113 | + content = content.replace(/import\s+(type\s+)?\{([^}]+)\}\s+from\s+'([^']+)';/g, (_match, typeKeyword, names, mod) => { |
| 114 | + const collapsed = names.replace(/\s+/g, ' ').trim(); |
| 115 | + return typeKeyword ? `import type { ${collapsed} } from '${mod}';` : `import { ${collapsed} } from '${mod}';`; |
| 116 | + }); |
| 117 | + |
| 118 | + const importsByModule = new Map<string, { typeNames: string[]; valueNames: string[] }>(); |
| 119 | + const otherLines: string[] = []; |
| 120 | + const seenModules = new Set<string>(); |
| 121 | + |
| 122 | + for (const line of content.split('\n')) { |
| 123 | + const typeMatch = line.match(/^import type \{([^}]+)\} from '([^']+)';$/); |
| 124 | + const valueMatch = line.match(/^import \{([^}]+)\} from '([^']+)';$/); |
| 125 | + |
| 126 | + if (typeMatch) { |
| 127 | + const [, names, mod] = typeMatch; |
| 128 | + if (!importsByModule.has(mod)) { |
| 129 | + importsByModule.set(mod, { typeNames: [], valueNames: [] }); |
| 130 | + } |
| 131 | + importsByModule.get(mod)!.typeNames.push(...names.split(',').map(s => s.trim()).filter(s => s.length > 0)); |
| 132 | + if (!seenModules.has(mod)) { |
| 133 | + seenModules.add(mod); |
| 134 | + otherLines.push(`__IMPORT_PLACEHOLDER__${mod}`); |
| 135 | + } |
| 136 | + } else if (valueMatch) { |
| 137 | + const [, names, mod] = valueMatch; |
| 138 | + if (!importsByModule.has(mod)) { |
| 139 | + importsByModule.set(mod, { typeNames: [], valueNames: [] }); |
| 140 | + } |
| 141 | + importsByModule.get(mod)!.valueNames.push(...names.split(',').map(s => s.trim()).filter(s => s.length > 0)); |
| 142 | + if (!seenModules.has(mod)) { |
| 143 | + seenModules.add(mod); |
| 144 | + otherLines.push(`__IMPORT_PLACEHOLDER__${mod}`); |
| 145 | + } |
| 146 | + } else { |
| 147 | + otherLines.push(line); |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + return otherLines.map(line => { |
| 152 | + if (line.startsWith('__IMPORT_PLACEHOLDER__')) { |
| 153 | + const mod = line.substring('__IMPORT_PLACEHOLDER__'.length); |
| 154 | + const entry = importsByModule.get(mod)!; |
| 155 | + const uniqueTypes = [...new Set(entry.typeNames)]; |
| 156 | + const uniqueValues = [...new Set(entry.valueNames)]; |
| 157 | + |
| 158 | + if (uniqueValues.length > 0 && uniqueTypes.length > 0) { |
| 159 | + const allNames = [...uniqueValues, ...uniqueTypes.map(n => `type ${n}`)]; |
| 160 | + return `import { ${allNames.join(', ')} } from '${mod}';`; |
| 161 | + } else if (uniqueValues.length > 0) { |
| 162 | + return `import { ${uniqueValues.join(', ')} } from '${mod}';`; |
| 163 | + } else { |
| 164 | + return `import type { ${uniqueTypes.join(', ')} } from '${mod}';`; |
| 165 | + } |
| 166 | + } |
| 167 | + return line; |
| 168 | + }).join('\n'); |
| 169 | +} |
| 170 | + |
| 171 | +// Global enum definitions collected from all files before per-file processing |
| 172 | +let globalEnumDefs = new Map<string, Map<string, string>>(); |
| 173 | + |
| 174 | +function collectAllEnumDefs(): void { |
| 175 | + globalEnumDefs = new Map(); |
| 176 | + for (const file of FILES) { |
| 177 | + const srcPath = path.join(TYPES_DIR, file.src); |
| 178 | + if (!fs.existsSync(srcPath)) { |
| 179 | + continue; |
| 180 | + } |
| 181 | + const content = fs.readFileSync(srcPath, 'utf-8'); |
| 182 | + content.replace( |
| 183 | + /export const enum (\w+) \{([^}]+)\}/g, |
| 184 | + (_match, name: string, body: string) => { |
| 185 | + const members = new Map<string, string>(); |
| 186 | + for (const line of body.split('\n')) { |
| 187 | + const memberMatch = line.match(/^\s*(\w+)\s*=\s*'([^']+)'/); |
| 188 | + if (memberMatch) { |
| 189 | + members.set(memberMatch[1], memberMatch[2]); |
| 190 | + } |
| 191 | + } |
| 192 | + if (members.size > 0) { |
| 193 | + globalEnumDefs.set(name, members); |
| 194 | + } |
| 195 | + return _match; |
| 196 | + } |
| 197 | + ); |
| 198 | + } |
| 199 | +} |
| 200 | + |
| 201 | +/** |
| 202 | + * Converts `const enum Foo { A = 'a', B = 'b' }` into: |
| 203 | + * ``` |
| 204 | + * export const Foo = { A: 'a', B: 'b' } as const; |
| 205 | + * export type Foo = typeof Foo[keyof typeof Foo]; |
| 206 | + * ``` |
| 207 | + * Then replaces `Foo.A` in type positions with the string literal `'a'`, |
| 208 | + * using the global enum definitions collected from all protocol files. |
| 209 | + */ |
| 210 | +function convertConstEnums(content: string): string { |
| 211 | + // Replace the const enum declarations in this file |
| 212 | + content = content.replace( |
| 213 | + /export const enum (\w+) \{([^}]+)\}/g, |
| 214 | + (_match, name: string) => { |
| 215 | + const members = globalEnumDefs.get(name); |
| 216 | + if (!members) { |
| 217 | + return _match; |
| 218 | + } |
| 219 | + const objEntries = [...members.entries()].map(([k, v]) => ` ${k}: '${v}'`).join(',\n'); |
| 220 | + return `export const ${name} = {\n${objEntries},\n} as const;\nexport type ${name} = typeof ${name}[keyof typeof ${name}];`; |
| 221 | + } |
| 222 | + ); |
| 223 | + |
| 224 | + // Replace Enum.Member references with their resolved string literals |
| 225 | + for (const [enumName, members] of globalEnumDefs) { |
| 226 | + for (const [memberName, value] of members) { |
| 227 | + const ref = `${enumName}.${memberName}`; |
| 228 | + content = content.split(ref).join(`'${value}'`); |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + // Remove value imports of enums that are no longer referenced as values |
| 233 | + content = content.replace( |
| 234 | + /import \{([^}]+)\} from '([^']+)';/g, |
| 235 | + (_match, names: string, from: string) => { |
| 236 | + const parts = names.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0); |
| 237 | + const remaining = parts.filter((name: string) => { |
| 238 | + if (!globalEnumDefs.has(name)) { |
| 239 | + return true; |
| 240 | + } |
| 241 | + const uses = content.split(name).length - 1; |
| 242 | + return uses > 1; |
| 243 | + }); |
| 244 | + if (remaining.length === 0) { |
| 245 | + return ''; |
| 246 | + } |
| 247 | + if (remaining.length === parts.length) { |
| 248 | + return _match; |
| 249 | + } |
| 250 | + return `import { ${remaining.join(', ')} } from '${from}';`; |
| 251 | + } |
| 252 | + ); |
| 253 | + |
| 254 | + return content; |
| 255 | +} |
| 256 | + |
| 257 | +function processFile(src: string, dest: string, commitHash: string): void { |
| 258 | + let content = fs.readFileSync(src, 'utf-8'); |
| 259 | + content = stripExistingHeader(content); |
| 260 | + |
| 261 | + // Convert `const enum` to plain `const` object + string literal union |
| 262 | + content = convertConstEnums(content); |
| 263 | + |
| 264 | + // Merge duplicate imports from the same module |
| 265 | + content = mergeDuplicateImports(content); |
| 266 | + |
| 267 | + content = convertIndentation(content); |
| 268 | + content = content.split('\n').map(line => line.trimEnd()).join('\n'); |
| 269 | + |
| 270 | + const header = `${COPYRIGHT}\n\n${BANNER}\n// Synced from agent-host-protocol @ ${commitHash}\n`; |
| 271 | + content = header + '\n' + content; |
| 272 | + |
| 273 | + if (!content.endsWith('\n')) { |
| 274 | + content += '\n'; |
| 275 | + } |
| 276 | + |
| 277 | + const destPath = path.join(DEST_DIR, dest); |
| 278 | + fs.mkdirSync(path.dirname(destPath), { recursive: true }); |
| 279 | + content = formatTypeScript(content, dest); |
| 280 | + fs.writeFileSync(destPath, content, 'utf-8'); |
| 281 | + console.log(` ${dest}`); |
| 282 | +} |
| 283 | + |
| 284 | +// ---- Main ------------------------------------------------------------------- |
| 285 | + |
| 286 | +function main() { |
| 287 | + if (!fs.existsSync(TYPES_DIR)) { |
| 288 | + console.error(`ERROR: Cannot find ${TYPES_DIR}`); |
| 289 | + console.error('Clone agent-host-protocol as a sibling of the VS Code repo:'); |
| 290 | + console.error(' git clone git@github.com:microsoft/agent-host-protocol.git ../agent-host-protocol'); |
| 291 | + process.exit(1); |
| 292 | + } |
| 293 | + |
| 294 | + const commitHash = getSourceCommitHash(); |
| 295 | + console.log(`Syncing from agent-host-protocol @ ${commitHash}`); |
| 296 | + console.log(` Source: ${TYPES_DIR}`); |
| 297 | + console.log(` Dest: ${DEST_DIR}`); |
| 298 | + console.log(); |
| 299 | + |
| 300 | + // Collect all enum definitions across all protocol files |
| 301 | + collectAllEnumDefs(); |
| 302 | + console.log(` Collected ${globalEnumDefs.size} const enums`); |
| 303 | + |
| 304 | + // Copy protocol files |
| 305 | + for (const file of FILES) { |
| 306 | + const srcPath = path.join(TYPES_DIR, file.src); |
| 307 | + if (!fs.existsSync(srcPath)) { |
| 308 | + console.error(` SKIP (not found): ${file.src}`); |
| 309 | + continue; |
| 310 | + } |
| 311 | + processFile(srcPath, file.dest, commitHash); |
| 312 | + } |
| 313 | + |
| 314 | + console.log(); |
| 315 | + console.log('Done.'); |
| 316 | +} |
| 317 | + |
| 318 | +main(); |
0 commit comments