diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0b3388f9aeb..9d23fc89077 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -17,3 +17,4 @@ build/lib/policies/policyData.jsonc @joshspicer @rebornix @joaomoreno @pwang347 # Ensure the API team is aware of changes to the vscode-dts file # this is only about the final API, not about proposed API changes src/vscode-dts/vscode.d.ts @jrieken @mjbvz @alexr00 +src/vs/workbench/services/extensions/common/extensionPoints.json @jrieken @mjbvz @alexr00 diff --git a/build/gulpfile.ts b/build/gulpfile.ts index 1c21618ca60..a3e6a82d572 100644 --- a/build/gulpfile.ts +++ b/build/gulpfile.ts @@ -15,6 +15,9 @@ import * as task from './lib/task.ts'; import * as util from './lib/util.ts'; import { useEsbuildTranspile } from './buildConfig.ts'; +// Extension point names +gulp.task(compilation.compileExtensionPointNamesTask); + const require = createRequire(import.meta.url); // API proposal names @@ -30,12 +33,12 @@ const transpileClientTask = task.define('transpile-client', task.series(util.rim gulp.task(transpileClientTask); // Fast compile for development time -const compileClientTask = task.define('compile-client', task.series(util.rimraf('out'), compilation.copyCodiconsTask, compilation.compileApiProposalNamesTask, compilation.compileTask('src', 'out', false))); +const compileClientTask = task.define('compile-client', task.series(util.rimraf('out'), compilation.copyCodiconsTask, compilation.compileApiProposalNamesTask, compilation.compileExtensionPointNamesTask, compilation.compileTask('src', 'out', false))); gulp.task(compileClientTask); const watchClientTask = useEsbuildTranspile - ? task.define('watch-client', task.parallel(compilation.watchTask('out', false, 'src', { noEmit: true }), compilation.watchApiProposalNamesTask, compilation.watchCodiconsTask)) - : task.define('watch-client', task.series(util.rimraf('out'), task.parallel(compilation.watchTask('out', false), compilation.watchApiProposalNamesTask, compilation.watchCodiconsTask))); + ? task.define('watch-client', task.parallel(compilation.watchTask('out', false, 'src', { noEmit: true }), compilation.watchApiProposalNamesTask, compilation.watchExtensionPointNamesTask, compilation.watchCodiconsTask)) + : task.define('watch-client', task.series(util.rimraf('out'), task.parallel(compilation.watchTask('out', false), compilation.watchApiProposalNamesTask, compilation.watchExtensionPointNamesTask, compilation.watchCodiconsTask))); gulp.task(watchClientTask); // All diff --git a/build/lib/compilation.ts b/build/lib/compilation.ts index 2484e884a55..0d6209e66c8 100644 --- a/build/lib/compilation.ts +++ b/build/lib/compilation.ts @@ -25,6 +25,9 @@ import * as tsb from './tsb/index.ts'; import sourcemaps from 'gulp-sourcemaps'; +import { extractExtensionPointNamesFromFile } from './extractExtensionPoints.ts'; + + // --- gulp-tsb: compile and transpile -------------------------------- const reporter = createReporter(); @@ -351,6 +354,49 @@ export const compileApiProposalNamesTask = task.define('compile-api-proposal-nam .pipe(apiProposalNamesReporter.end(true)); }); +function generateExtensionPointNames() { + const collectedNames: string[] = []; + + const input = es.through(); + const output = input + .pipe(es.through(function (file: File) { + const contents = file.contents?.toString('utf-8'); + if (contents && contents.includes('registerExtensionPoint')) { + const sourceFile = ts.createSourceFile(file.path, contents, ts.ScriptTarget.Latest, true); + collectedNames.push(...extractExtensionPointNamesFromFile(sourceFile)); + } + }, function () { + collectedNames.sort(); + const content = JSON.stringify(collectedNames, undefined, '\t') + '\n'; + this.emit('data', new File({ + path: 'vs/workbench/services/extensions/common/extensionPoints.json', + contents: Buffer.from(content) + })); + this.emit('end'); + })); + + return es.duplex(input, output); +} + +const extensionPointNamesReporter = createReporter('extension-point-names'); + +export const compileExtensionPointNamesTask = task.define('compile-extension-point-names', () => { + return gulp.src('src/vs/workbench/**/*.ts') + .pipe(generateExtensionPointNames()) + .pipe(gulp.dest('src')) + .pipe(extensionPointNamesReporter.end(true)); +}); + +export const watchExtensionPointNamesTask = task.define('watch-extension-point-names', () => { + const task = () => gulp.src('src/vs/workbench/**/*.ts') + .pipe(generateExtensionPointNames()) + .pipe(extensionPointNamesReporter.end(true)); + + return watch('src/vs/workbench/**/*.ts', { readDelay: 200 }) + .pipe(util.debounce(task)) + .pipe(gulp.dest('src')); +}); + export const watchApiProposalNamesTask = task.define('watch-api-proposal-names', () => { const task = () => gulp.src('src/vscode-dts/**') .pipe(generateApiProposalNames()) diff --git a/build/lib/extractExtensionPoints.ts b/build/lib/extractExtensionPoints.ts new file mode 100644 index 00000000000..1c442564026 --- /dev/null +++ b/build/lib/extractExtensionPoints.ts @@ -0,0 +1,224 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Extracts extension point names from TypeScript source files by parsing the AST + * to find all calls to `ExtensionsRegistry.registerExtensionPoint(...)`. + * + * Handles: + * - Inline string literals: `{ extensionPoint: 'foo' }` + * - Enum member values passed via function parameters + * - Imported descriptor variables where the `extensionPoint` property is in another file + * + * This module can be used standalone (`node build/lib/extractExtensionPoints.ts`) + * to regenerate the extension points file, or imported for use in gulp build tasks. + */ + +import ts from 'typescript'; +import path from 'path'; +import fs from 'fs'; + +/** + * Extract extension point names registered via `registerExtensionPoint` from + * a single TypeScript source file's AST. No type checker is needed. + */ +export function extractExtensionPointNamesFromFile(sourceFile: ts.SourceFile): string[] { + const results: string[] = []; + visit(sourceFile); + return results; + + function visit(node: ts.Node): void { + if (ts.isCallExpression(node)) { + const expr = node.expression; + if (ts.isPropertyAccessExpression(expr) && expr.name.text === 'registerExtensionPoint') { + handleRegisterCall(node); + } + } + ts.forEachChild(node, visit); + } + + function handleRegisterCall(call: ts.CallExpression): void { + const arg = call.arguments[0]; + if (!arg) { + return; + } + if (ts.isObjectLiteralExpression(arg)) { + handleInlineDescriptor(call, arg); + } else if (ts.isIdentifier(arg)) { + handleImportedDescriptor(arg); + } + } + + function handleInlineDescriptor(call: ts.CallExpression, obj: ts.ObjectLiteralExpression): void { + const epProp = findExtensionPointProperty(obj); + if (!epProp) { + return; + } + if (ts.isStringLiteral(epProp.initializer)) { + results.push(epProp.initializer.text); + } else if (ts.isIdentifier(epProp.initializer)) { + // The value references a function parameter - resolve via call sites + handleParameterReference(call, epProp.initializer.text); + } + } + + function handleParameterReference(registerCall: ts.CallExpression, paramName: string): void { + // Walk up to find the containing function + let current: ts.Node | undefined = registerCall.parent; + while (current && !ts.isFunctionDeclaration(current) && !ts.isFunctionExpression(current) && !ts.isArrowFunction(current)) { + current = current.parent; + } + if (!current) { + return; + } + const fn = current as ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction; + + // Find which parameter position matches paramName + const paramIndex = fn.parameters.findIndex( + p => ts.isIdentifier(p.name) && p.name.text === paramName + ); + if (paramIndex < 0) { + return; + } + + // Find the function name to locate call sites + const fnName = ts.isFunctionDeclaration(fn) && fn.name ? fn.name.text : undefined; + if (!fnName) { + return; + } + + // Find all call sites of this function in the same file + ts.forEachChild(sourceFile, function findCalls(node) { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === fnName) { + const callArg = node.arguments[paramIndex]; + if (callArg) { + const value = resolveStringValue(callArg); + if (value) { + results.push(value); + } + } + } + ts.forEachChild(node, findCalls); + }); + } + + function handleImportedDescriptor(identifier: ts.Identifier): void { + const name = identifier.text; + for (const stmt of sourceFile.statements) { + if (!ts.isImportDeclaration(stmt) || !stmt.importClause?.namedBindings) { + continue; + } + if (!ts.isNamedImports(stmt.importClause.namedBindings)) { + continue; + } + for (const element of stmt.importClause.namedBindings.elements) { + if (element.name.text !== name || !ts.isStringLiteral(stmt.moduleSpecifier)) { + continue; + } + const modulePath = stmt.moduleSpecifier.text; + const resolvedPath = path.resolve( + path.dirname(sourceFile.fileName), + modulePath.replace(/\.js$/, '.ts') + ); + try { + const content = fs.readFileSync(resolvedPath, 'utf-8'); + const importedFile = ts.createSourceFile(resolvedPath, content, ts.ScriptTarget.Latest, true); + const originalName = element.propertyName?.text || element.name.text; + const value = findExtensionPointInVariable(importedFile, originalName); + if (value) { + results.push(value); + } + } catch { + // Imported file not found, skip + } + return; + } + } + } + + function resolveStringValue(node: ts.Node): string | undefined { + if (ts.isStringLiteral(node)) { + return node.text; + } + // Property access: Enum.Member + if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) { + const enumName = node.expression.text; + const memberName = node.name.text; + for (const stmt of sourceFile.statements) { + if (ts.isEnumDeclaration(stmt) && stmt.name.text === enumName) { + for (const member of stmt.members) { + if (ts.isIdentifier(member.name) && member.name.text === memberName + && member.initializer && ts.isStringLiteral(member.initializer)) { + return member.initializer.text; + } + } + } + } + } + return undefined; + } +} + +function findExtensionPointProperty(obj: ts.ObjectLiteralExpression): ts.PropertyAssignment | undefined { + for (const prop of obj.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && prop.name.text === 'extensionPoint') { + return prop; + } + } + return undefined; +} + +function findExtensionPointInVariable(sourceFile: ts.SourceFile, varName: string): string | undefined { + for (const stmt of sourceFile.statements) { + if (!ts.isVariableStatement(stmt)) { + continue; + } + for (const decl of stmt.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.name.text === varName + && decl.initializer && ts.isObjectLiteralExpression(decl.initializer)) { + const epProp = findExtensionPointProperty(decl.initializer); + if (epProp && ts.isStringLiteral(epProp.initializer)) { + return epProp.initializer.text; + } + } + } + } + return undefined; +} + +// --- Standalone CLI --- + +const rootDir = path.resolve(import.meta.dirname, '..', '..'); +const srcDir = path.join(rootDir, 'src'); +const outputPath = path.join(srcDir, 'vs', 'workbench', 'services', 'extensions', 'common', 'extensionPoints.json'); + +function scanDirectory(dir: string): string[] { + const names: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + names.push(...scanDirectory(fullPath)); + } else if (entry.name.endsWith('.ts')) { + const content = fs.readFileSync(fullPath, 'utf-8'); + if (content.includes('registerExtensionPoint')) { + const sourceFile = ts.createSourceFile(fullPath, content, ts.ScriptTarget.Latest, true); + names.push(...extractExtensionPointNamesFromFile(sourceFile)); + } + } + } + return names; +} + +function main(): void { + const names = scanDirectory(path.join(srcDir, 'vs', 'workbench')); + names.sort(); + const output = JSON.stringify(names, undefined, '\t') + '\n'; + fs.writeFileSync(outputPath, output, 'utf-8'); + console.log(`Wrote ${names.length} extension points to ${path.relative(rootDir, outputPath)}`); +} + +if (import.meta.main) { + main(); +} diff --git a/src/vs/workbench/services/extensions/common/extensionPoints.json b/src/vs/workbench/services/extensions/common/extensionPoints.json new file mode 100644 index 00000000000..4d531bcf87d --- /dev/null +++ b/src/vs/workbench/services/extensions/common/extensionPoints.json @@ -0,0 +1,58 @@ +[ + "authentication", + "breakpoints", + "chatAgents", + "chatContext", + "chatInstructions", + "chatOutputRenderers", + "chatParticipants", + "chatPromptFiles", + "chatSessions", + "chatSkills", + "chatViewsWelcome", + "colors", + "commands", + "configuration", + "configurationDefaults", + "continueEditSession", + "css", + "customEditors", + "debugVisualizers", + "debuggers", + "grammars", + "iconThemes", + "icons", + "jsonValidation", + "keybindings", + "languageModelChatProviders", + "languageModelToolSets", + "languageModelTools", + "languages", + "localizations", + "mcpServerDefinitionProviders", + "menus", + "notebookPreload", + "notebookRenderer", + "notebooks", + "problemMatchers", + "problemPatterns", + "productIconThemes", + "remoteCodingAgents", + "remoteHelp", + "resourceLabelFormatters", + "semanticTokenModifiers", + "semanticTokenScopes", + "semanticTokenTypes", + "snippets", + "speechProviders", + "statusBarItems", + "submenus", + "taskDefinitions", + "terminal", + "terminalQuickFixes", + "themes", + "views", + "viewsContainers", + "viewsWelcome", + "walkthroughs" +]