This guide explains how to add first-class language support to opencode-swarm. The plugin's language layer (introduced in v7.x) separates data (build/test/lint metadata) from behavior (how to detect projects, select test frameworks, extract imports). Adding a language is a small, well-bounded change.
- A new language with default behavior: edit
src/lang/profiles.ts, add aLanguageProfileentry. No backend file needed. - A new language with custom behavior (e.g., a project-specific framework heuristic, custom import-graph extractor): also add a file under
src/lang/backends/<id>.tsand one import line insrc/lang/backends/index.ts.
The repo already ships:
- 13 profiles (TypeScript, JavaScript, Python, Rust, Go, Java, Kotlin, C#, C/C++, Swift, Dart, Ruby, PHP) in
src/lang/profiles.ts. - 20 tree-sitter parser entries in
src/lang/registry.ts. - 3 concrete backends (TypeScript, Python, Go) in
src/lang/backends/.
Three registries collaborate:
| Registry | File | Purpose | Entries |
|---|---|---|---|
LANGUAGE_REGISTRY |
src/lang/profiles.ts |
High-level language profiles: build commands, test frameworks, linters, audit tooling, SAST rules, prompt constraints, tree-sitter grammar id. | 13 |
languageDefinitions |
src/lang/registry.ts |
Fine-grained tree-sitter parser entries. Intentionally has a different id space (e.g. .tsx → 'tsx', .c → 'c') because parsers are grammar-specific while profiles are dispatch-target-specific. |
20 |
LANGUAGE_BACKEND_REGISTRY |
src/lang/registry-backend.ts |
Per-language behavior overrides — selectTestFramework, extractImports, etc. When no backend is registered for a language id, the default backend (src/lang/default-backend.ts) is synthesized from the profile. |
3 |
The dispatch entry point is pickBackend(dir) in src/lang/dispatch.ts — walks up to the nearest manifest, runs language detection, returns the registered (or defaulted) backend for the dominant language. Bounded LRU cache keyed by manifest content hash.
Open src/lang/profiles.ts and call LANGUAGE_REGISTRY.register({...}) with your new language. Schema in LanguageProfile interface (top of file). Example:
LANGUAGE_REGISTRY.register({
id: 'zig',
displayName: 'Zig',
tier: 2,
extensions: ['.zig'],
treeSitter: {
grammarId: 'zig',
wasmFile: 'tree-sitter-zig.wasm',
commentNodes: ['line_comment', 'doc_comment'], // optional but recommended
},
build: {
detectFiles: ['build.zig'],
commands: [
{ name: 'zig build', detectFile: 'build.zig', cmd: 'zig build', priority: 10 },
],
},
test: {
detectFiles: ['build.zig'],
frameworks: [
{ name: 'zig test', detect: 'build.zig', cmd: 'zig build test', priority: 10 },
],
},
lint: {
detectFiles: ['build.zig'],
linters: [
{ name: 'zig fmt --check', detect: 'build.zig', cmd: 'zig fmt --check .', priority: 10 },
],
},
audit: { detectFiles: [], command: null, outputFormat: 'json' },
sast: { nativeRuleSet: null, semgrepSupport: 'none' },
prompts: {
coderConstraints: [
'Use `zig fmt` formatting; line length 120 by default',
'Prefer comptime over runtime polymorphism',
],
reviewerChecklist: [
'Verify error-set unions are exhaustive in `try` chains',
'Check `defer` ordering for cleanup correctness',
],
},
});Constraints enforced by LanguageRegistry.register():
- Profile
idmust be unique (throws if duplicate). - File extensions must not conflict with another non-
parserOnlyprofile (throws if collision). Mark a profileparserOnly: trueif it should provide tree-sitter parsing without claiming dispatch.
If your language has a tree-sitter grammar, also add an entry to src/lang/registry.ts's languageDefinitions array so getLanguageForExtension(ext) returns the parser metadata. Use the same id as the profile when possible:
{ id: 'zig', extensions: ['.zig'], commentNodes: ['line_comment', 'doc_comment'] },Drop the WASM grammar into src/lang/grammars/ (filename matches treeSitter.wasmFile). The grammars directory is not bundled — it's copied to dist/lang/grammars/ by scripts/copy-grammars.ts, run as part of bun run build.
The parity test (tests/unit/lang/profile-registry-parity.test.ts) asserts that every shared id between the two registries agrees on commentNodes. Update the documented asymmetry list in that file if your language has a profile but no parser (or vice versa).
Default behavior (src/lang/default-backend.ts) covers most languages: highest-priority framework whose detect file exists AND whose binary is on PATH wins. Override only when a language has a non-default heuristic.
Examples:
- TypeScript backend (
src/lang/backends/typescript.ts) overridesselectTestFrameworkto honorpackage.json#scripts.testfirst, thendevDependencies— neither is registry-driven. - Python backend (
src/lang/backends/python.ts) overridesextractImportswith Python regexes (import x,from x import y). - Go backend (
src/lang/backends/go.ts) overridesextractImportswith Go's single-line + grouped import syntax.
To add a backend:
- Create
src/lang/backends/<your-id>.ts. Usepython.tsas a small template. Export abuild<Id>Backend(): LanguageBackendfactory. - In
src/lang/backends/index.ts, add animport { build<Id>Backend } from './<your-id>';and aLANGUAGE_BACKEND_REGISTRY.register(build<Id>Backend());line inregisterAllBackends.
The tests/unit/lang/backend-purity.test.ts static-analysis test will fail your PR if your backend file:
- Imports from
bun:...(Invariant 2 — runtime portability; the plugin must run under Node, not just Bun). - References the global
Bun.*API. - Imports
bunSpawn/bunSpawnSyncornode:child_process'sspawn/spawnSync. Backends never spawn. They return command-arrays only; the single spawn site stays insrc/tools/test-runner.tsandsrc/build/discovery.ts:isCommandAvailable(which already satisfies Invariant 3).
For binary-availability checks, import isCommandAvailable from ../../build/discovery — that helper's invariant-3 properties (cwd, stdin: 'ignore', timeout, bounded stdio) are validated by tests/unit/build/discovery.test.ts.
Three tests should be added or updated:
-
Profile parity —
tests/unit/lang/profile-registry-parity.test.tsasserts the asymmetry list. If your new language is in both registries (most common case), bump the count assertions and remove your id fromREGISTRY_ONLY_DOCUMENTEDorPROFILE_ONLY_DOCUMENTEDif it appears there. -
Tier profile test — add an entry to
tests/unit/lang/profiles-tier{1,2,3}.test.tsmatching your language's tier. -
Backend behavior (if you added one) — write tests parallel to
tests/unit/lang/python-go-backends.test.tscovering every supported import shape, framework heuristic, etc.
The full lang-test suite must pass per-file isolated AND in a single process (cross-file singleton pollution check):
for f in tests/unit/lang/*.test.ts; do bun --smol test "$f" --timeout 30000; done
bun --smol test tests/unit/langThe architect prompt's {{PROJECT_LANGUAGE}}, {{BUILD_CMD}}, {{TEST_CMD}}, {{LINT_CMD}}, and {{ENTRY_POINTS}} placeholders are populated at session-init from your profile via src/agents/project-context.ts:buildProjectContext. You don't need to add anything for these to work — adding the profile is sufficient.
If your language's prompts.coderConstraints or prompts.reviewerChecklist are non-empty, they're rendered as bullet lists into {{CODER_CONSTRAINTS}} and {{REVIEWER_CHECKLIST}} placeholders for any agent prompt that references them.
- Tree-sitter grammar mismatch —
treeSitter.grammarIdmust match the language constructor name in the WASM (e.g.,tree-sitter-c-sharp.wasmexportscsharp, notc-sharp). The runtime-wasm-map test (tests/unit/lang/runtime-wasm-map.test.ts) catches mismatches. - Extension collision — if another profile already claims your extension, the registry throws at registration time with a clear error. Pick a different extension or mark one profile
parserOnly: true. - Forgotten
commentNodes— optional in the type but enforced in the parity test for production profiles. Without it, comment-stripping in ast-diff and syntax-check will not work for your language. - Backend importing
bun:...— even abun:testimport in a backend file will fail backend-purity. Backends are bundled into the Node-targetdist/index.jsand must not have Bun-only dependencies. - Unbounded subprocess — the bait-and-switch error: a hand-written
bunSpawncall in a backend would silently violate Invariant 3. The purity test rejects it. If you need to probe a binary, useisCommandAvailable.
-
LANGUAGE_REGISTRY.register({...})entry added insrc/lang/profiles.ts. -
languageDefinitionsentry added insrc/lang/registry.ts(if tree-sitter grammar is shipped). - WASM grammar in
src/lang/grammars/<filename>.wasm. - (Optional) Backend file in
src/lang/backends/<id>.tsplus registration line insrc/lang/backends/index.ts. - Tests added/updated: profile-registry-parity, profiles-tier{1,2,3}, backend-behavior (if applicable).
- Full test pass:
bun --smol test tests/unit/langin a single process. - Build clean:
bun run build+node --input-type=module -e "await import('./dist/index.js')"returns the v1 plugin shape. - Biome clean:
bunx biome ci .. - Typecheck clean:
bun run typecheck.