feat(core): add plugin registry for community analyzer plugins

Introduces PluginRegistry class that discovers, registers, and manages
analyzer plugins. Maps file extensions to languages, provides a unified
analyzeFile entrypoint, and supports plugin priority via registration
order. Foundation for the community plugin system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-14 23:04:56 +08:00
Unverified
parent 09fc78f321
commit f977ce8a48
3 changed files with 193 additions and 0 deletions
@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import { PluginRegistry } from "../plugins/registry.js";
import type { AnalyzerPlugin, StructuralAnalysis, ImportResolution } from "../types.js";
const emptyAnalysis: StructuralAnalysis = {
functions: [],
classes: [],
imports: [],
exports: [],
};
function createMockPlugin(name: string, languages: string[]): AnalyzerPlugin {
return {
name,
languages,
analyzeFile: () => ({ ...emptyAnalysis }),
resolveImports: () => [],
};
}
describe("PluginRegistry", () => {
it("registers a plugin", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("test", ["typescript"]);
registry.register(plugin);
expect(registry.getPlugins()).toHaveLength(1);
});
it("finds plugin by language", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("ts-plugin", ["typescript", "javascript"]);
registry.register(plugin);
expect(registry.getPluginForLanguage("typescript")).toBe(plugin);
expect(registry.getPluginForLanguage("javascript")).toBe(plugin);
});
it("returns null for unsupported language", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("ts-plugin", ["typescript"]));
expect(registry.getPluginForLanguage("python")).toBeNull();
});
it("finds plugin by file extension", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("ts-plugin", ["typescript"]);
registry.register(plugin);
expect(registry.getPluginForFile("src/index.ts")).toBe(plugin);
expect(registry.getPluginForFile("src/app.tsx")).toBe(plugin);
});
it("maps common extensions to languages", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("multi", ["python", "go", "rust"]);
registry.register(plugin);
expect(registry.getPluginForFile("main.py")).toBe(plugin);
expect(registry.getPluginForFile("main.go")).toBe(plugin);
expect(registry.getPluginForFile("main.rs")).toBe(plugin);
});
it("lists all registered plugins", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("a", ["typescript"]));
registry.register(createMockPlugin("b", ["python"]));
expect(registry.getPlugins()).toHaveLength(2);
});
it("lists supported languages", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("a", ["typescript", "javascript"]));
registry.register(createMockPlugin("b", ["python"]));
const langs = registry.getSupportedLanguages();
expect(langs).toContain("typescript");
expect(langs).toContain("python");
});
it("unregisters a plugin by name", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("removable", ["typescript"]));
expect(registry.getPlugins()).toHaveLength(1);
registry.unregister("removable");
expect(registry.getPlugins()).toHaveLength(0);
});
it("later registration takes priority for same language", () => {
const registry = new PluginRegistry();
const first = createMockPlugin("first", ["typescript"]);
const second = createMockPlugin("second", ["typescript"]);
registry.register(first);
registry.register(second);
expect(registry.getPluginForLanguage("typescript")?.name).toBe("second");
});
it("analyzeFile delegates to correct plugin", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("ts-plugin", ["typescript"]);
plugin.analyzeFile = () => ({
...emptyAnalysis,
functions: [{ name: "hello", lineRange: [1, 5], params: [] }],
});
registry.register(plugin);
const result = registry.analyzeFile("src/test.ts", "const x = 1;");
expect(result).not.toBeNull();
expect(result!.functions).toHaveLength(1);
});
it("analyzeFile returns null for unsupported files", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("ts-plugin", ["typescript"]));
const result = registry.analyzeFile("main.py", "print('hello')");
expect(result).toBeNull();
});
});
+1
View File
@@ -35,3 +35,4 @@ export {
detectLanguageConcepts,
type LanguageLessonResult,
} from "./analyzer/language-lesson.js";
export { PluginRegistry } from "./plugins/registry.js";
+79
View File
@@ -0,0 +1,79 @@
import type { AnalyzerPlugin, StructuralAnalysis, ImportResolution } from "../types.js";
const EXTENSION_TO_LANGUAGE: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
py: "python",
go: "go",
rs: "rust",
rb: "ruby",
java: "java",
kt: "kotlin",
cs: "csharp",
cpp: "cpp",
c: "c",
swift: "swift",
php: "php",
};
/**
* Registry for analyzer plugins. Maps languages to plugins and provides
* a unified interface for analyzing files across languages.
*/
export class PluginRegistry {
private plugins: AnalyzerPlugin[] = [];
private languageMap = new Map<string, AnalyzerPlugin>();
register(plugin: AnalyzerPlugin): void {
this.plugins.push(plugin);
for (const lang of plugin.languages) {
this.languageMap.set(lang, plugin);
}
}
unregister(name: string): void {
const plugin = this.plugins.find((p) => p.name === name);
if (!plugin) return;
this.plugins = this.plugins.filter((p) => p.name !== name);
this.languageMap.clear();
for (const p of this.plugins) {
for (const lang of p.languages) {
this.languageMap.set(lang, p);
}
}
}
getPluginForLanguage(language: string): AnalyzerPlugin | null {
return this.languageMap.get(language) ?? null;
}
getPluginForFile(filePath: string): AnalyzerPlugin | null {
const ext = filePath.split(".").pop()?.toLowerCase();
if (!ext) return null;
const language = EXTENSION_TO_LANGUAGE[ext];
if (!language) return null;
return this.getPluginForLanguage(language);
}
analyzeFile(filePath: string, content: string): StructuralAnalysis | null {
const plugin = this.getPluginForFile(filePath);
if (!plugin) return null;
return plugin.analyzeFile(filePath, content);
}
resolveImports(filePath: string, content: string): ImportResolution[] | null {
const plugin = this.getPluginForFile(filePath);
if (!plugin) return null;
return plugin.resolveImports(filePath, content);
}
getPlugins(): AnalyzerPlugin[] {
return [...this.plugins];
}
getSupportedLanguages(): string[] {
return [...this.languageMap.keys()];
}
}