Skip to main content

Languages

This guide describes the fastest way to bring a new programming language to Rune. You will start with Rune's Python integration, change it into an extension for your language, add syntax and indexing support, and turn the result into an installable package.

The Python extension is a good starting point because it has all the important parts without requiring you to understand every Rune subsystem first. It finds projects, performs the project-specific setup needed by its language server, requests LSP initialization, and registers Python-specific commands. It is a Go program, but it supports Python. The Rust and Zig extensions work the same way: the language an extension is written in does not have to be the language it supports.

Your first useful milestone is deliberately small:

  1. Open a representative project in Rune.
  2. Have extension_<language> recognize its project root.
  3. Have the extension ask Rune to start one language server for that root.
  4. See diagnostics or go-to-definition work in a source file.

Get that path working before packaging a debugger or toolchain, or advertising every language-server capability.

How the pieces fit together

Rune and the language extension have separate jobs. Rune owns the editor, the indexer, language-server processes, and debugger sessions. The extension runs as a separate subprocess and communicates with Rune through the workspace APIs over gRPC. For language integration, it discovers project roots and asks Rune's LSP Manager to initialize the appropriate language server for each root. It does not configure the Debugger Manager or supply data to the Indexer.

The installed language package also contributes configuration defaults, including the debugger.<langid> configuration used by Rune's Debugger Manager. Rune merges those defaults with the user's .rune/config.yaml; the extension receives the resulting configuration through its workspace session.

Adding a language requires syntax support inside Rune as well as an extension. There are two layers to that syntax support:

  • The language package supplies a native Tree-sitter grammar and .scm queries for highlighting, indentation, folding, local definitions, references, and scopes. Rune loads and executes those files; the extension does not.
  • Rune core contains a language-specific indexer specification for behavior that a local syntax query cannot explain by itself: packages or modules, imports, aliases, methods, visibility, and re-exports. Read the existing Go, Rust, and Zig specifications before writing yours.

The extension is responsible for the parts that depend on a real workspace and its tools:

  • Initialize the language server. The extension builds the standard initialize request for each project root, including the server command, initialization options, and the capabilities Rune should advertise. It sends that request to Rune's LSP Manager over gRPC. See the exact calls for Go, Python, Rust, and Zig.
  • Find and prepare projects. A workspace may contain several independent projects. The extension decides which files mark a project root, associates opened source files with the nearest root, and prepares that project's environment before starting its server. Python, for example, recognizes pyproject.toml, requirements files, and .venv directories in env.go.
  • Resolve the language server used for each project. The extension selects the packaged server or a configured override and includes its command in the initialization request. Any project-specific environment work needed before that request also belongs here. This is separate from packaging the server, compiler, formatter, or debug adapter for distribution.
  • Add the language's own workflows. LSP provides a common baseline, but the best integrations also expose useful ecosystem operations as Rune commands. Rust's extension, for example, exposes rust-analyzer operations and rustup workflows rather than hiding them behind a generic feature set.

All of this eventually needs to be packaged. The packaging layer supplies the extension executable, grammar, syntax queries, and language tools. Its config.yaml registers the extension and configures Rune-owned services such as debugger.<langid>; see the Go debugger configuration and Python debugger configuration. Keep the Python language package open while following this guide; it is the complete example used below.

1. Fork Rune and copy the Python extension

Start by following the Rune build guide. Build Rune and run its tests before changing anything, so failures introduced by the new integration are easy to recognize.

Create a branch in your Rune fork, then copy the Python extension:

git checkout -b language-<language>
cp -R cmd/extension_python cmd/extension_<language>

For example, an Elixir integration would begin in cmd/extension_elixir.

Do not try to preserve every Python feature while renaming the directory. Strip the copy down until it builds, then add your language one responsibility at a time. The files worth understanding first are:

  • main.go, which starts the extension process.
  • extension.go, which declares permissions, connects workspace services, discovers projects, and initializes each project root.
  • initialize.go, which builds the LSP initialization request.
  • env.go, which recognizes Python projects and prepares their environment.
  • lookup.go, which finds tools on the workspace host.
  • handler.go, which implements Python-specific console commands.

For the first compile, remove Python's command handler, uv environment setup, debugpy prewarming, and pyshim package. Rename the remaining py... types and functions, replace .py with your source extension, and temporarily make your per-project initialization function do nothing. Keep the extension entry point, metadata, workspace clients, and langext.Initializer wiring. This leaves a small program that Rune can start and that you can extend in the order used by this guide.

Run the package tests after every step:

go test ./cmd/extension_<language>

Change the metadata in NewExtension first. Give the extension a stable ID and human-readable name, and remove permissions you do not need. Python's current metadata is here. The ID should be short, lowercase, and consistent everywhere. If the ID is elixir, use it for the extension ID, LSP language ID, package name, and syntax language ID; name the executable extension_elixir.

Keep the copied entry point. ServeWorkspaceExtension performs the initial handshake with Rune and keeps the extension process alive for the workspace session:

func main() {
ext, metadata := NewExtension()
if err := extensionapi.ServeWorkspaceExtension(ext, metadata); err != nil {
slog.Error("serve extension", "error", err)
os.Exit(1)
}
}

An extension's standard output is used by the startup protocol, so do not print banners or debug messages there. Use structured logging instead. For more about the process model, permissions, and configuration, read Extensions.

2. Teach the extension to recognize a project

A language server needs a root directory. In a simple repository that may be the workspace root, but real workspaces often contain several projects:

workspace/
├── service-a/
│ └── <project marker>
├── service-b/
│ └── <project marker>
└── scripts/
└── one-off.<ext>

Choose the files that unambiguously identify a project in your ecosystem. Rust uses Cargo.toml; Zig uses build.zig and build.zig.zon; Python checks pyproject.toml, several requirements filenames, and .venv. Decide whether a loose source file with no manifest should start a server at the workspace root.

The built-in language extensions use Rune's langext.Initializer to combine two behaviors:

  1. If the workspace root is already a project, initialize it immediately.
  2. When a source file is opened later, walk toward the workspace root, find its nearest project marker, and initialize that root once.

Python wires those pieces together in a small block:

init := langext.NewInitializer(ctx, fs, editor, langext.ProjectConfig{
LanguageID: "python",
Markers: pyMarkers,
FileMatch: isPythonFile,
InitRoot: func(ctx context.Context, root langext.Root) error {
return initializeProjectRoot(ctx, fs, exec, notify, lsp, inst, cfg, dataDir, root)
},
})
if err := init.Start(); err != nil {
return fmt.Errorf("subscribe python open events: %w", err)
}

See the complete code, including eager initialization of a root project, in extension.go. Replace python, pyMarkers, and isPythonFile with your language's values, then make InitRoot call your own bring-up function.

langext is an internal helper for integrations built in the Rune repository. An extension maintained in another Go module cannot import it as public SDK. It can implement the same behavior with the editor and filesystem clients from the public SDK.

Always use the workspace filesystem and executor passed by Rune. Do not replace them with os.Stat or os/exec: the workspace may be on another machine over SSH, while the extension process and Rune UI are local. The workspace clients make the same extension work in both cases.

Before moving on, test these cases:

  • The workspace root contains a project marker.
  • A project lives in a nested directory.
  • Two sibling projects are open at the same time.
  • A source file has no enclosing project marker.
  • Opening several files in one project does not start several servers.

3. Prepare the project for its language server

Once you have a project root, prepare whatever the language server expects to find there. Keep this logic in one function that receives the root and ends by initializing its language server. Python's initializeProjectRoot is a useful model: it prepares the project's uv environment, resolves ty and Ruff, and then sends the LSP initialization request. The debugpy prewarming in the same function is specific to Python's runtime packaging choice, not a general responsibility of a language extension.

Use the lightest setup that makes a fresh project useful. For example:

  • An interpreted language may need an interpreter and dependency environment.
  • A compiled language may need a compiler or sysroot that the server can inspect.
  • A server may need the absolute path to the formatter or compiler in its initialization options.

Not every setup failure needs to disable language support. Python warns when environment synchronization fails and still starts its language server, because partial analysis is better than no analysis. Make the same decision explicitly for your ecosystem: fail only when the server cannot do useful work.

When the package includes the language server, its executable should be resolved through FindInstalledExecutable, as Python does in lookup.go. Support a user-configured path when appropriate, then decide whether a missing packaged binary should fall back to the workspace host's PATH. Treat “not installed” differently from permission, filesystem, and network errors; those usually deserve a visible warning rather than a silent fallback.

4. Initialize the language server

The extension does not launch the language server and speak LSP itself. It asks Rune's LSP Manager to do that through the SDK's semanticapi.LSP client. Rune owns the server process, routes editor requests to it, restarts or stops it with the workspace, and keeps LSP concerns out of the extension process.

At the end of your per-project bring-up, build an InitializeParams value and call Initialize. The Go integration shows the essential control flow clearly:

params, err := goplsInitializeParams(root.URI, dbg, goplsBin)
if err != nil {
return fmt.Errorf("build init params: %w", err)
}
if _, err := lsp.Initialize(ctx, params); err != nil {
return fmt.Errorf("initialize gopls: %w", err)
}

That example is taken from initializeGoRoot. The complete parameter builders for Go, Python, Rust, and Zig show how four different servers are configured.

An initialization request has three important parts.

Root URI

Use the URI of the project discovered in the previous step, not automatically the top-level workspace URI. In a monorepo, each project may need its own server, environment, and initialization options.

Initialization options

The map begins with two fields Rune consumes:

initOptions := map[string]any{
"langID": "<language>",
"command": "<language-server> --stdio",
}

langID selects the language and command tells Rune what to start. Rune also understands alternate_commands, which can route selected methods to another server, and env, which supplies the server's environment. Python uses alternate_commands to send formatting to Ruff while ty remains the primary server; see pyInitializeParams.

Rune removes its routing fields before forwarding the remaining initialization options to the language server. This lets you place server-specific settings in the same map. Consult your server's documentation rather than copying Python's options.

Client capabilities

Capabilities are promises about what Rune can handle, not a wish list of what you want the server to provide. Start with the capabilities needed for your first test—diagnostics, hover, definitions, and completion are common choices— then add more as you test them.

Different servers interpret the same capability map differently. Zig's server, for example, needs workspaceFolders and an advertised diagnostics capability; the integration also disables snippet placeholders because Rune inserts its completion text literally. Rust's initialization code is intentionally careful about rust-analyzer client commands that Rune can actually service. Read those comments before copying either map.

At this point, build the extension and test it with the real server against one small project. Confirm the server starts at the expected root and inspect its logs before adding more features.

Do not stop at an extension test. Add a Rune-core LSP suite for the language in internal/ide/idelsp/<file-id>_test.go. It must start the real server through Rune's LSP Manager and exercise the features you claim to support against a committed project under internal/ide/idelsp/testdata/. The existing Go, Python, Rust, and Zig suites are the pattern to follow. These are end-to-end tests behind the e2e build tag, not mocked protocol tests.

5. Add the grammar and syntax queries

LSP gives Rune semantic answers from a server. It does not provide the syntax tree Rune uses while editing. Highlighting, indentation, folding, local symbols, and much of indexing begin with a Tree-sitter grammar and query files.

Your installed language package must contain:

FileWhat Rune uses it for
lib/tree-sitter.soNative parser for the language.
lib/highlights.scmSyntax highlighting captures.
lib/indents.scmStructural indentation.
lib/folds.scmFoldable regions.
lib/locals.scmDefinitions, references, scopes, and local symbols.

Some grammars already publish useful queries. Others need queries from a project such as nvim-treesitter or custom queries written for Rune. Python's package currently combines the grammar's highlighting query with nvim-treesitter's indentation, folding, and locals.scm. The exact files copied into the package are visible in the Python Makefile.

The most important file for indexing is locals.scm. It labels syntax nodes with captures such as:

@local.definition.function
@local.definition.method
@local.definition.type
@local.definition.var
@local.reference
@local.scope

Do not write these captures by guessing node names. Inspect representative files with your grammar's Tree-sitter tooling, look at the actual parse trees, and write one query at a time. The existing Go, Rust, Python, and Zig queries are practical references.

Rune looks up syntax assets by language ID. The native library must also export the grammar symbol Rune expects, usually tree_sitter_<languageID>. Renaming a shared library does not change that exported symbol. If the grammar's identifier and your Rune language ID differ, resolve that mismatch deliberately.

Finally, make sure Rune maps your source filenames to the same language ID. The normal case is inferred from the filename extension. Languages with several extensions, special filenames, or an ID that differs from the extension need an entry in languages.go.

Add the corresponding Rune-core syntax suite at internal/ide/syntax/syntaxtest/<file-id>_test.go. Commit a package-shaped fixture directory containing the real native parser and the real highlights.scm, indents.scm, folds.scm, and locals.scm files. The suite must load those assets through Rune and run them against representative source, including incomplete code. Follow the existing Go, Python, Rust, and Zig suites. A test that mocks captures or reads different query files from the ones you package does not satisfy this requirement.

6. Teach the indexer how names cross files

locals.scm can tell Rune that a node defines a function or references a name. It cannot, by itself, answer questions such as:

  • Does http.Client refer to a package, module, type, or field?
  • What local name does an import introduce?
  • Is a declaration visible from another file?
  • Which type owns a method?
  • Does an index file re-export a name from another module?

Those rules live in a language-specific symbolresolve.Spec inside Rune core. Copy the existing spec closest to your language's module system, rename it, and replace its queries and callbacks. Then add it to the registry.

Choose examples by semantics, not syntax:

  • Go is useful for package clauses, import aliases, exported-name rules, and receiver methods.
  • Python demonstrates dotted modules, file-derived qualifiers, and re-exports from __init__.py.
  • Rust demonstrates path expressions, file-derived module names, and methods inside impl blocks.
  • Zig demonstrates @import aliases, field expressions, and methods declared in container types.

Add focused fixtures while writing the spec. Test imports with and without aliases, two symbols with the same name, nested modules, private declarations, methods, unresolved references, and incomplete code. It is better to leave a dynamic or ambiguous construct unresolved than to jump to the wrong definition.

Put those cases in internal/ide/idelsp/symbolresolve/<file-id>_test.go, backed by a committed, multi-file testdata_<file-id>/ project. The suite must use the real native parser, the packaged syntax queries, and the real resolver specification. The Go, Python, Rust, and Zig suites show the required structure. A passing language-server test does not replace this suite because Rune's indexer resolves these names independently of the language server.

7. Add debugging and language-specific commands

Do this after project discovery, LSP, and syntax work. Debugging introduces a second protocol and another tool process, while ecosystem commands are easiest to design after you have used the integration on real projects.

Rune's Debugger Manager owns debug sessions and speaks the Debug Adapter Protocol. The language package tells it how to start an adapter through config.yaml. Python registers debugpy with its command, adapter ID, and launch/attach defaults in config.yaml.

The language package may include an adapter binary or configure a command that resolves one from the packaged toolchain at runtime. That decision belongs to the package and its debugger.<langid> configuration, not to the extension-to-LSP relationship shown above. Whichever model you choose, test it with the real adapter: set a breakpoint, launch a program, inspect stack frames and variables, and attach to an existing process when the adapter supports it.

If the language supports debugging, add internal/ide/ideshell/debugshell/<file-id>_test.go and a deterministic program under internal/ide/ideshell/debugshell/testdata/. The test must use Rune's real Debugger Manager, the supported debug adapter, the real compiler or interpreter, and the language's real syntax assets. Follow the Go, Python, Rust, and Zig suites. These tests belong behind the e2e build tag because they launch real tools and speak the Debug Adapter Protocol through Rune's production path.

Then expose operations that make your ecosystem distinctive. The copied Python handler.go shows how an extension registers a command and executes tools through the workspace API. Replace it with workflows developers of your language already use: test selection, dependency management, code generation, project tasks, toolchain selection, server-specific actions, or framework commands.

8. Package the integration

First-party languages ship from repositories separate from Rune. The existing repositories are Go, Python, Rust, and Zig.

Fork or copy rune-language-python into rune-language-<language>. Remove the Python-specific tools, then use the same layout for your inputs and build. The repository should pin the exact Rune revision containing your extension and the exact grammar/query revisions used by the package. Python does that through Git submodules.

The build assembles this staging directory:

pkg/
├── bin/
│ ├── extension_<language>
│ ├── <language-server>
│ └── <formatter-or-debug-adapter>
├── lib/
│ ├── tree-sitter.so
│ ├── highlights.scm
│ ├── indents.scm
│ ├── folds.scm
│ └── locals.scm
└── config.yaml

Not every tool has to be bundled. Python packages uv, uvx, Ruff, and ty, uses uv to install an interpreter on first run, and resolves a compatible debugpy environment through uvx. The comments and targets in its Makefile explain that trade-off. Pick the model that is reliable for your language on a fresh machine and in a remote workspace.

The package's config.yaml connects the files in pkg/ to Rune. At minimum it registers the extension executable and editor conventions. It may also add environment variables, command aliases, and a debugger adapter. A reduced example looks like this:

extensions:
<language>:
path: '$RUNE_DATADIR/bin/extension_<language>'
config:
lsp_path: ""

debugger:
<language>:
command: '<debug-adapter> --port {port}'
adapter_id: '<adapter-id>'
launch:
request: launch
type: '<adapter-id>'

editor:
comments:
<language>:
line:
- "//"

Do not copy that verbatim. Start from the complete Python configuration or a language with a toolchain similar to yours.

Use the package Makefile as the executable release specification. It should:

  1. Fetch or build the language tools for the target OS and architecture.
  2. Compile the Tree-sitter grammar, including any external scanner it needs.
  3. Copy all required .scm queries into pkg/lib.
  4. Build cmd/extension_<language> into pkg/bin.
  5. Copy config.yaml to the root of pkg.
  6. Sign and notarize executable code on macOS.
  7. Archive the contents of pkg/ as <language>.tar.gz.

Compare the concrete build recipes for Python, Rust, and Zig. Native grammars, external scanners, language servers, debuggers, and toolchains may all have different cross-compilation requirements; successfully building the Go extension does not prove the package works on the target.

Test the artifacts users will download, not only the staging directory. Inspect the final tarball and, when produced, the macOS notarization zip. Verify that required binaries and query files are present, executable permissions are correct, debugger libraries are beside the adapter where it expects them, and no .go files from the Rune submodule leaked into a non-Go language package. The Rust and Zig release tests show these checks in practice.

Publishing a new first-party package requires coordination with Rune's release infrastructure. You can build and install the package locally before that. Once it is published and registered, users should be able to install the complete integration with:

pkg install <language>

9. Test the integration as a whole

The Rune-core language suites are a requirement for first-class support. When you add functionality at one of these layers, add or extend the corresponding <file-id>_test.go suite in the same change. Do not rely only on unit tests in cmd/extension_<language>.

The filename token normally follows the source suffix used by the existing suite. Go uses go_test.go, Python uses py_test.go, Rust uses rs_test.go, and Zig uses zig_test.go. This token is not always the runtime language ID. For example, py_test.go still initializes the server with langID: "python", and rs_test.go uses langID: "rust".

Each suite protects a different boundary:

Required core suiteWhat it must proveFixture
internal/ide/idelsp/<file-id>_test.goRune can initialize and use the real supported LSP server, including the capabilities and server-specific options the extension advertises.A buildable project in internal/ide/idelsp/testdata/.
internal/ide/syntax/syntaxtest/<file-id>_test.goRune can load the real native grammar and packaged .scm queries and use them for highlighting, indentation, folds, definitions, references, scopes, and incremental edits.A package-shaped syntax fixture plus representative valid and incomplete source.
internal/ide/idelsp/symbolresolve/<file-id>_test.goRune's own indexer understands the language's modules, imports, aliases, methods, visibility, ambiguity, and re-exports.A real multi-file project in symbolresolve/testdata_<file-id>/.
internal/ide/ideshell/debugshell/<file-id>_test.goRune can launch or attach through the real debug adapter, set breakpoints, stop, inspect stack frames and variables, continue, and terminate. Required when debugging is supported.A deterministic executable project in debugshell/testdata/.

The extension should also retain its own end-to-end suite, such as Python's cmd/extension_python/e2e_test.go. That suite proves project discovery, environment preparation, tool lookup, and the request sent to Rune. It does not replace the core suites above.

Keep tests that spawn real language servers, compilers, interpreters, or debug adapters behind the e2e build tag. Syntax and symbol-resolution suites normally run without that tag, but they still use real native parser and query fixtures. Make missing prerequisites visible: a green run in which every case skipped because the server or adapter was absent does not verify the integration. When the package pins a tool version, verify that version in the suite as Python's LSP test does.

Before proposing first-class support, work through this checklist:

  • Open root, nested, and multi-project workspaces.
  • Test local and SSH workspaces.
  • Remove the server or toolchain and check the error shown to the user.
  • Test diagnostics, hover, definitions, references, completion, rename, and formatting against the real server.
  • Test highlighting, indentation, folds, local symbols, imports, methods, and cross-file definitions against incomplete as well as valid source code.
  • Launch and attach with the real debug adapter.
  • Install the final package archive on every supported OS and architecture.
  • Reopen the workspace and verify that initialization remains idempotent.

When the first vertical slice works, open an issue or draft pull request in the Rune repository. Include the language server and grammar you chose, the project markers you recognize, how tools will be provisioned, and which parts still need help. That gives Rune maintainers a concrete integration to run and a clear place to coordinate the core indexer and package work.

Ask Rune Agent