mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-02 16:25:00 +01:00
- Migrates to use AHP types that are synced via `npx tsx scripts/sync-agent-host-protocol.ts` - One big churn was migrating out of URIs as rich objects in the protocol. We can't really shove our own `$mid`-type objects in there. I also explored doing a generated translation layer but I had trouble getting one I was happy with. - This tightens up some type safety in general and fixes some areas where vscode had silently/sloppily diverged from the protocol types.
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
// Copies type definitions from the sibling `agent-host-protocol` repo into
|
|
// `src/vs/platform/agentHost/common/state/protocol/`. Run via:
|
|
//
|
|
// npx tsx scripts/sync-agent-host-protocol.ts
|
|
//
|
|
// Transformations applied:
|
|
// 1. Converts `const enum` to `const` object + string literal union (VS Code
|
|
// tsconfig uses `preserveConstEnums` which makes `const enum` nominal).
|
|
// 2. Converts 2-space indentation to tabs.
|
|
// 3. Merges duplicate imports from the same module.
|
|
// 4. Formats with the project's tsfmt.json settings.
|
|
// 5. Adds Microsoft copyright header.
|
|
//
|
|
// URI stays as `string` (the protocol's canonical representation). VS Code code
|
|
// should call `URI.parse()` at point-of-use where a URI class is needed.
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { execSync } from 'child_process';
|
|
import * as ts from 'typescript';
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const PROTOCOL_REPO = path.resolve(ROOT, '../agent-host-protocol');
|
|
const TYPES_DIR = path.join(PROTOCOL_REPO, 'types');
|
|
const DEST_DIR = path.join(ROOT, 'src/vs/platform/agentHost/common/state/protocol');
|
|
|
|
// Load tsfmt.json formatting options once
|
|
const TSFMT_PATH = path.join(ROOT, 'tsfmt.json');
|
|
const FORMAT_OPTIONS: ts.FormatCodeSettings = JSON.parse(fs.readFileSync(TSFMT_PATH, 'utf-8'));
|
|
|
|
/**
|
|
* Formats a TypeScript source string using the TypeScript language service
|
|
* formatter with the project's tsfmt.json settings.
|
|
*/
|
|
function formatTypeScript(content: string, fileName: string): string {
|
|
const host: ts.LanguageServiceHost = {
|
|
getCompilationSettings: () => ({}),
|
|
getScriptFileNames: () => [fileName],
|
|
getScriptVersion: () => '1',
|
|
getScriptSnapshot: (name: string) => name === fileName ? ts.ScriptSnapshot.fromString(content) : undefined,
|
|
getCurrentDirectory: () => ROOT,
|
|
getDefaultLibFileName: () => '',
|
|
fileExists: () => false,
|
|
readFile: () => undefined,
|
|
};
|
|
const ls = ts.createLanguageService(host);
|
|
const edits = ls.getFormattingEditsForDocument(fileName, FORMAT_OPTIONS);
|
|
// Apply edits in reverse order to preserve offsets
|
|
for (let i = edits.length - 1; i >= 0; i--) {
|
|
const edit = edits[i];
|
|
content = content.substring(0, edit.span.start) + edit.newText + content.substring(edit.span.start + edit.span.length);
|
|
}
|
|
ls.dispose();
|
|
return content;
|
|
}
|
|
|
|
const COPYRIGHT = `/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/`;
|
|
|
|
const BANNER = '// allow-any-unicode-comment-file\n// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts';
|
|
|
|
// Files to copy. All go into protocol/.
|
|
const FILES: { src: string; dest: string }[] = [
|
|
{ src: 'state.ts', dest: 'state.ts' },
|
|
{ src: 'actions.ts', dest: 'actions.ts' },
|
|
{ src: 'commands.ts', dest: 'commands.ts' },
|
|
{ src: 'errors.ts', dest: 'errors.ts' },
|
|
{ src: 'notifications.ts', dest: 'notifications.ts' },
|
|
{ src: 'messages.ts', dest: 'messages.ts' },
|
|
{ src: 'version/registry.ts', dest: 'version/registry.ts' },
|
|
];
|
|
|
|
function getSourceCommitHash(): string {
|
|
try {
|
|
return execSync('git rev-parse --short HEAD', { cwd: PROTOCOL_REPO, encoding: 'utf-8' }).trim();
|
|
} catch {
|
|
return 'unknown';
|
|
}
|
|
}
|
|
|
|
function stripExistingHeader(content: string): string {
|
|
return content.replace(/^\/\*\*?[\s\S]*?\*\/\s*/, '');
|
|
}
|
|
|
|
function convertIndentation(content: string): string {
|
|
const lines = content.split('\n');
|
|
return lines.map(line => {
|
|
const match = line.match(/^( +)/);
|
|
if (!match) {
|
|
return line;
|
|
}
|
|
const spaces = match[1].length;
|
|
const tabs = Math.floor(spaces / 2);
|
|
const remainder = spaces % 2;
|
|
return '\t'.repeat(tabs) + ' '.repeat(remainder) + line.slice(spaces);
|
|
}).join('\n');
|
|
}
|
|
|
|
/**
|
|
* Merges duplicate imports from the same module.
|
|
* Combines `import type { A }` and `import { B }` from the same module into
|
|
* `import { B, type A }` to satisfy the no-duplicate-imports lint rule.
|
|
*/
|
|
function mergeDuplicateImports(content: string): string {
|
|
// Collapse multi-line imports into single lines first
|
|
content = content.replace(/import\s+(type\s+)?\{([^}]+)\}\s+from\s+'([^']+)';/g, (_match, typeKeyword, names, mod) => {
|
|
const collapsed = names.replace(/\s+/g, ' ').trim();
|
|
return typeKeyword ? `import type { ${collapsed} } from '${mod}';` : `import { ${collapsed} } from '${mod}';`;
|
|
});
|
|
|
|
const importsByModule = new Map<string, { typeNames: string[]; valueNames: string[] }>();
|
|
const otherLines: string[] = [];
|
|
const seenModules = new Set<string>();
|
|
|
|
for (const line of content.split('\n')) {
|
|
const typeMatch = line.match(/^import type \{([^}]+)\} from '([^']+)';$/);
|
|
const valueMatch = line.match(/^import \{([^}]+)\} from '([^']+)';$/);
|
|
|
|
if (typeMatch) {
|
|
const [, names, mod] = typeMatch;
|
|
if (!importsByModule.has(mod)) {
|
|
importsByModule.set(mod, { typeNames: [], valueNames: [] });
|
|
}
|
|
importsByModule.get(mod)!.typeNames.push(...names.split(',').map(s => s.trim()).filter(s => s.length > 0));
|
|
if (!seenModules.has(mod)) {
|
|
seenModules.add(mod);
|
|
otherLines.push(`__IMPORT_PLACEHOLDER__${mod}`);
|
|
}
|
|
} else if (valueMatch) {
|
|
const [, names, mod] = valueMatch;
|
|
if (!importsByModule.has(mod)) {
|
|
importsByModule.set(mod, { typeNames: [], valueNames: [] });
|
|
}
|
|
importsByModule.get(mod)!.valueNames.push(...names.split(',').map(s => s.trim()).filter(s => s.length > 0));
|
|
if (!seenModules.has(mod)) {
|
|
seenModules.add(mod);
|
|
otherLines.push(`__IMPORT_PLACEHOLDER__${mod}`);
|
|
}
|
|
} else {
|
|
otherLines.push(line);
|
|
}
|
|
}
|
|
|
|
return otherLines.map(line => {
|
|
if (line.startsWith('__IMPORT_PLACEHOLDER__')) {
|
|
const mod = line.substring('__IMPORT_PLACEHOLDER__'.length);
|
|
const entry = importsByModule.get(mod)!;
|
|
const uniqueTypes = [...new Set(entry.typeNames)];
|
|
const uniqueValues = [...new Set(entry.valueNames)];
|
|
|
|
if (uniqueValues.length > 0 && uniqueTypes.length > 0) {
|
|
const allNames = [...uniqueValues, ...uniqueTypes.map(n => `type ${n}`)];
|
|
return `import { ${allNames.join(', ')} } from '${mod}';`;
|
|
} else if (uniqueValues.length > 0) {
|
|
return `import { ${uniqueValues.join(', ')} } from '${mod}';`;
|
|
} else {
|
|
return `import type { ${uniqueTypes.join(', ')} } from '${mod}';`;
|
|
}
|
|
}
|
|
return line;
|
|
}).join('\n');
|
|
}
|
|
|
|
// Global enum definitions collected from all files before per-file processing
|
|
let globalEnumDefs = new Map<string, Map<string, string>>();
|
|
|
|
function collectAllEnumDefs(): void {
|
|
globalEnumDefs = new Map();
|
|
for (const file of FILES) {
|
|
const srcPath = path.join(TYPES_DIR, file.src);
|
|
if (!fs.existsSync(srcPath)) {
|
|
continue;
|
|
}
|
|
const content = fs.readFileSync(srcPath, 'utf-8');
|
|
content.replace(
|
|
/export const enum (\w+) \{([^}]+)\}/g,
|
|
(_match, name: string, body: string) => {
|
|
const members = new Map<string, string>();
|
|
for (const line of body.split('\n')) {
|
|
const memberMatch = line.match(/^\s*(\w+)\s*=\s*'([^']+)'/);
|
|
if (memberMatch) {
|
|
members.set(memberMatch[1], memberMatch[2]);
|
|
}
|
|
}
|
|
if (members.size > 0) {
|
|
globalEnumDefs.set(name, members);
|
|
}
|
|
return _match;
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts `const enum Foo { A = 'a', B = 'b' }` into:
|
|
* ```
|
|
* export const Foo = { A: 'a', B: 'b' } as const;
|
|
* export type Foo = typeof Foo[keyof typeof Foo];
|
|
* ```
|
|
* Then replaces `Foo.A` in type positions with the string literal `'a'`,
|
|
* using the global enum definitions collected from all protocol files.
|
|
*/
|
|
function convertConstEnums(content: string): string {
|
|
// Replace the const enum declarations in this file
|
|
content = content.replace(
|
|
/export const enum (\w+) \{([^}]+)\}/g,
|
|
(_match, name: string) => {
|
|
const members = globalEnumDefs.get(name);
|
|
if (!members) {
|
|
return _match;
|
|
}
|
|
const objEntries = [...members.entries()].map(([k, v]) => ` ${k}: '${v}'`).join(',\n');
|
|
return `export const ${name} = {\n${objEntries},\n} as const;\nexport type ${name} = typeof ${name}[keyof typeof ${name}];`;
|
|
}
|
|
);
|
|
|
|
// Replace Enum.Member references with their resolved string literals
|
|
for (const [enumName, members] of globalEnumDefs) {
|
|
for (const [memberName, value] of members) {
|
|
const ref = `${enumName}.${memberName}`;
|
|
content = content.split(ref).join(`'${value}'`);
|
|
}
|
|
}
|
|
|
|
// Remove value imports of enums that are no longer referenced as values
|
|
content = content.replace(
|
|
/import \{([^}]+)\} from '([^']+)';/g,
|
|
(_match, names: string, from: string) => {
|
|
const parts = names.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0);
|
|
const remaining = parts.filter((name: string) => {
|
|
if (!globalEnumDefs.has(name)) {
|
|
return true;
|
|
}
|
|
const uses = content.split(name).length - 1;
|
|
return uses > 1;
|
|
});
|
|
if (remaining.length === 0) {
|
|
return '';
|
|
}
|
|
if (remaining.length === parts.length) {
|
|
return _match;
|
|
}
|
|
return `import { ${remaining.join(', ')} } from '${from}';`;
|
|
}
|
|
);
|
|
|
|
return content;
|
|
}
|
|
|
|
function processFile(src: string, dest: string, commitHash: string): void {
|
|
let content = fs.readFileSync(src, 'utf-8');
|
|
content = stripExistingHeader(content);
|
|
|
|
// Convert `const enum` to plain `const` object + string literal union
|
|
content = convertConstEnums(content);
|
|
|
|
// Merge duplicate imports from the same module
|
|
content = mergeDuplicateImports(content);
|
|
|
|
content = convertIndentation(content);
|
|
content = content.split('\n').map(line => line.trimEnd()).join('\n');
|
|
|
|
const header = `${COPYRIGHT}\n\n${BANNER}\n// Synced from agent-host-protocol @ ${commitHash}\n`;
|
|
content = header + '\n' + content;
|
|
|
|
if (!content.endsWith('\n')) {
|
|
content += '\n';
|
|
}
|
|
|
|
const destPath = path.join(DEST_DIR, dest);
|
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
content = formatTypeScript(content, dest);
|
|
fs.writeFileSync(destPath, content, 'utf-8');
|
|
console.log(` ${dest}`);
|
|
}
|
|
|
|
// ---- Main -------------------------------------------------------------------
|
|
|
|
function main() {
|
|
if (!fs.existsSync(TYPES_DIR)) {
|
|
console.error(`ERROR: Cannot find ${TYPES_DIR}`);
|
|
console.error('Clone agent-host-protocol as a sibling of the VS Code repo:');
|
|
console.error(' git clone git@github.com:microsoft/agent-host-protocol.git ../agent-host-protocol');
|
|
process.exit(1);
|
|
}
|
|
|
|
const commitHash = getSourceCommitHash();
|
|
console.log(`Syncing from agent-host-protocol @ ${commitHash}`);
|
|
console.log(` Source: ${TYPES_DIR}`);
|
|
console.log(` Dest: ${DEST_DIR}`);
|
|
console.log();
|
|
|
|
// Collect all enum definitions across all protocol files
|
|
collectAllEnumDefs();
|
|
console.log(` Collected ${globalEnumDefs.size} const enums`);
|
|
|
|
// Copy protocol files
|
|
for (const file of FILES) {
|
|
const srcPath = path.join(TYPES_DIR, file.src);
|
|
if (!fs.existsSync(srcPath)) {
|
|
console.error(` SKIP (not found): ${file.src}`);
|
|
continue;
|
|
}
|
|
processFile(srcPath, file.dest, commitHash);
|
|
}
|
|
|
|
console.log();
|
|
console.log('Done.');
|
|
}
|
|
|
|
main();
|