Syntax highlighting with Tree-sitter
OpenTUI uses Tree-sitter to highlight source text in Code, Markdown, and Diff.
Prerequisites#
Meet these requirements before you create a Tree-sitter client:
- Install
@opentui/coreand itsweb-tree-sitterpeer dependency at exactly0.25.10. - Use a supported Bun or Node.js runtime with worker support.
- Let the worker read the parser worker, Tree-sitter WASM, language WASM, and query files.
- Give the worker read and write access to the client data path.
- Give the worker network access when a parser or query uses an HTTP or HTTPS URL.
Core bundles descriptors for these grammars:
- JavaScript and JSX as
javascriptand itsjavascriptreactalias - TypeScript and TSX as
typescriptand itstypescriptreactalias - Markdown as
markdown - Markdown inline as
markdown_inline - Zig as
zig
Other grammars need an explicit parser WASM file and compatible query files. A file type mapping does not install a grammar.
Highlight code#
Pass an initialized client to a code renderable. This example uses the bundled TypeScript descriptor.
import {
CodeRenderable,
RGBA,
SyntaxStyle,
createCliRenderer,
destroyTreeSitterClient,
getTreeSitterClient,
} from "@opentui/core"
const renderer = await createCliRenderer()
const client = getTreeSitterClient()
try {
await client.initialize()
const syntaxStyle = SyntaxStyle.fromStyles({
keyword: { bold: true },
string: { fg: RGBA.fromHex("#777777") },
default: { fg: RGBA.fromHex("#E6EDF3") },
})
renderer.root.add(
new CodeRenderable(renderer, {
id: "example",
content: 'const message = "Hello"',
filetype: "typescript",
syntaxStyle,
treeSitterClient: client,
}),
)
await renderer.idle()
} finally {
await destroyTreeSitterClient()
renderer.destroy()
}CodeRenderable uses the global client when you omit treeSitterClient. Markdown code blocks and diff panes can use
the same client.
Choose client ownership#
Use the global client for shared application highlighting. Create a client when a subsystem needs separate parser configuration, cache storage, or lifetime.
Initialize a client#
initialize() loads the Tree-sitter runtime WASM and registers bundled descriptors plus global overrides. Concurrent
calls use the same initialization promise.
highlightOnce() initializes the client on its first call. createBuffer() also initializes by default. Pass
autoInitialize: false to make an uninitialized createBuffer() call return false instead.
Call initialize() before preloadParser(). To replace a bundled descriptor only for one client, call
addFiletypeParser() after initialization. A new, non-bundled descriptor can be added before or after initialization.
Global client#
getTreeSitterClient() lazily creates one process-wide client. It uses the application globalDataPath and tracks
later application data-path changes.
addDefaultParsers() adds module-wide parser overrides. Call it before any client initializes. A descriptor with the
same filetype replaces the previous override during later client initialization.
import { addDefaultParsers, destroyTreeSitterClient, getTreeSitterClient } from "@opentui/core"
addDefaultParsers([
{
filetype: "python",
wasm: "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
queries: {
highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-python/v0.23.6/queries/highlights.scm"],
},
},
])
const client = getTreeSitterClient()
try {
await client.initialize()
const result = await client.highlightOnce("def answer():\n return 42\n", "python")
if (result.error) throw new Error(result.error)
if (result.warning) console.warn(result.warning)
console.log(result.highlights ?? [])
} finally {
await destroyTreeSitterClient()
}destroyTreeSitterClient() removes the singleton and awaits worker termination. The last renderer also requests
singleton cleanup. Await destroyTreeSitterClient() directly when shutdown must wait for the worker.
Per-client configuration#
new TreeSitterClient() starts its worker during construction. The caller owns the client and must await its
asynchronous destroy() method.
import { TreeSitterClient } from "@opentui/core"
const client = new TreeSitterClient({
dataPath: "./cache",
initTimeout: 10_000,
})
try {
await client.initialize()
client.addFiletypeParser({
filetype: "rust",
wasm: "https://github.com/tree-sitter/tree-sitter-rust/releases/download/v0.23.2/tree-sitter-rust.wasm",
queries: {
highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-rust/v0.23.2/queries/highlights.scm"],
},
})
if (!(await client.preloadParser("rust"))) {
throw new Error("Could not load the Rust parser")
}
} finally {
await client.destroy()
}Remote parser and query files must support the same grammar version. Pin both files to release tags or commits. Do not mix a released parser with a query from a mutable branch.
Read Lifecycle and cleanup for application shutdown ownership.
Configure a parser#
addDefaultParsers() and addFiletypeParser() accept the same descriptor.
interface FiletypeParserOptions {
filetype: string
aliases?: string[]
wasm: string
queries: {
highlights: string[]
injections?: string[]
}
injectionMapping?: {
nodeTypes?: Record<string, string>
infoStringMap?: Record<string, string>
}
}| Field | Meaning |
|---|---|
filetype |
Canonical identifier that renderables and client calls use |
aliases |
Additional identifiers that resolve to the canonical parser |
wasm |
HTTP URL, HTTPS URL, absolute path, or path relative to the current working directory |
queries.highlights |
One or more highlight-query URLs or paths, concatenated in order |
queries.injections |
Optional injection-query URLs or paths, concatenated in order |
injectionMapping.nodeTypes |
Injection node type to target file type |
injectionMapping.infoStringMap |
Code-fence language label to target file type |
The client removes duplicate aliases and ignores an alias that equals filetype. Registering the same canonical file
type again replaces its descriptor and invalidates that parser’s reusable caches.
Use local assets#
Bun file imports can supply paths for assets that a build includes.
import { addDefaultParsers } from "@opentui/core"
import pythonWasm from "./parsers/tree-sitter-python.wasm" with { type: "file" }
import pythonHighlights from "./queries/python/highlights.scm" with { type: "file" }
addDefaultParsers([
{
filetype: "python",
wasm: pythonWasm,
queries: {
highlights: [pythonHighlights],
},
},
])For plain path strings, relative paths resolve from process.cwd(). Use a URL only for workerPath. Parser and query
descriptor fields use strings.
Configure language injections#
An injection query identifies embedded source. The mapping selects the parser for that source.
This example uses local, version-pinned Markdown assets. It relies on the bundled markdown_inline, JavaScript, and
TypeScript descriptors for injection targets.
import { TreeSitterClient } from "@opentui/core"
const client = new TreeSitterClient({ dataPath: "./cache" })
try {
await client.initialize()
client.addFiletypeParser({
filetype: "markdown",
wasm: "./assets/markdown/tree-sitter-markdown.wasm",
queries: {
highlights: ["./assets/markdown/highlights.scm"],
injections: ["./assets/markdown/injections.scm"],
},
injectionMapping: {
nodeTypes: {
inline: "markdown_inline",
pipe_table_cell: "markdown_inline",
},
infoStringMap: {
js: "javascript",
jsx: "javascriptreact",
ts: "typescript",
tsx: "typescriptreact",
},
},
})
if (!(await client.preloadParser("markdown"))) {
throw new Error("Could not load the Markdown parser")
}
} finally {
await client.destroy()
}If infoStringMap has no matching label, OpenTUI uses the fence label as the target file type. Each target must have a
registered parser or alias. Missing injection parsers produce worker warnings and leave that source without injected
highlights.
Resolve file types#
The exported lookup functions normalize file names, extensions, and Markdown fence info strings.
import {
basenameToFiletype,
extToFiletype,
extensionToFiletype,
infoStringToFiletype,
pathToFiletype,
} from "@opentui/core"
const rust = pathToFiletype("src/main.rs")
const typescript = extToFiletype(".TS")
const tsx = infoStringToFiletype("TSX title=Button.tsx")
extensionToFiletype.set("templ", "html")
basenameToFiletype.set("mytoolrc", "yaml")
console.log({ rust, typescript, tsx })| API | Resolution behavior |
|---|---|
extToFiletype(extension) |
Removes one leading dot, converts to lowercase, and reads extensionToFiletype |
pathToFiletype(path) |
Checks the lowercase basename map, then checks the last extension |
infoStringToFiletype(infoString) |
Reads the first token, then checks basename, path, extension, and the normalized token |
extensionToFiletype |
Mutable extension-to-file-type Map<string, string> |
basenameToFiletype |
Mutable lowercase-basename-to-file-type Map<string, string> |
infoStringToFiletype() returns an unknown normalized token when no map matches. A successful lookup still does not
mean that a parser is registered.
Manage assets at build time#
@opentui/core/tree-sitter/update-assets exports updateAssets, runUpdateAssetsCli, and UpdateOptions. This Bun
entry point downloads or copies parsers, combines each query list, and generates a TypeScript asset loader.
Use immutable URLs in the parser configuration:
{
"parsers": [
{
"filetype": "python",
"wasm": "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
"queries": {
"highlights": [
"https://raw.githubusercontent.com/tree-sitter/tree-sitter-python/v0.23.6/queries/highlights.scm"
]
}
}
]
}The package does not publish a command in its bin map. Create a Bun script that imports the public entry point:
import { runUpdateAssetsCli } from "@opentui/core/tree-sitter/update-assets"
await runUpdateAssetsCli()Pass the CLI options through a package script:
{
"scripts": {
"update:tree-sitter": "bun ./scripts/update-tree-sitter-assets.ts --config ./parsers-config.json --assets ./src/parsers --output ./src/parsers.ts"
}
}The CLI accepts --config, --assets, --output, and --help. Run it at build time, not during application startup.
You can also call the build helper directly:
import { updateAssets } from "@opentui/core/tree-sitter/update-assets"
await updateAssets({
configPath: "./parsers-config.json",
assetsDir: "./src/parsers",
outputPath: "./src/parsers.ts",
})updateAssets() currently shares the CLI failure policy. An operational error prints a message and calls
process.exit(1) instead of rejecting to the caller. Run it in a dedicated build process. Do not call it from a
long-lived application or depend on an outer catch or finally block for cleanup.
The generated module exports getParsers() and defaultParserAssetPaths. Register its descriptors before a client
initializes:
import { addDefaultParsers } from "@opentui/core"
import { getParsers } from "./parsers.js"
addDefaultParsers(await getParsers())See Package entry points for the published build-time boundary.
Configure the worker#
TreeSitterClientOptions has three fields:
| Field | Type | Default |
|---|---|---|
dataPath |
string |
Required for a custom client |
workerPath |
string | URL |
Resolved OpenTUI parser worker |
initTimeout |
number |
10000 milliseconds |
The client selects its worker path in this order:
- The
workerPathclient option - The
OTUI_TREE_SITTER_WORKER_PATHenvironment value - The build-time
OTUI_TREE_SITTER_WORKER_PATHglobal - The default OpenTUI runtime asset
On Node.js, a plain relative workerPath string resolves from the current working directory. Pass new URL(path, import.meta.url) for a module-relative worker.
Node.js does not require permission mode. If you enable it, grant worker access, parser-asset reads, and data-path reads and writes. Grant network access for remote parser or query URLs. See Runtime and platform support for the exact Node.js permission and runtime requirements.
OTUI_ASSET_ROOT can relocate the default worker, bundled grammars, and Tree-sitter WASM. Bun executables can embed
these assets. Node.js single executable applications must extract them to the filesystem. See
Standalone executables for both workflows.
Manage cache data#
The worker stores remote assets below <dataPath>/tree-sitter. Language files go in languages, and query files go in
queries.
The global client uses the path from Application data paths. A custom client uses its
required dataPath option.
| API | Behavior |
|---|---|
setDataPath(dataPath) |
Creates the new Tree-sitter cache directories and uses them for later asset loads |
clearCache() |
Removes the Tree-sitter cache directory, recreates it, and clears loaded parser caches |
clearCache() rejects when the client is not initialized. Local parser and query paths remain application-owned.
Handle errors and warnings#
initialize() rejects for worker startup, Tree-sitter WASM, filesystem, or timeout failures. Parser and query load
failures usually appear when the client preloads, highlights, or creates a buffer.
highlightOnce() converts an initialization failure to this result:
{
error: "Could not highlight because of initialization error"
}Otherwise, it resolves with optional highlights, warning, and error fields. Direct worker requests can reject if
the worker fails or the client is destroyed.
Attach listeners when the application needs diagnostics:
client.on("error", (message, bufferId) => console.error({ message, bufferId }))
client.on("warning", (message, bufferId) => console.warn({ message, bufferId }))
client.on("worker:log", (type, message) => console.log({ type, message }))| Event | Arguments |
|---|---|
highlights:response |
bufferId, version, HighlightResponse[] |
buffer:initialized |
bufferId, hasParser |
buffer:disposed |
bufferId |
worker:log |
TreeSitterWorkerLogType, message |
error |
message, optional bufferId |
warning |
message, optional bufferId |
Client API#
The renderables manage incremental buffers for normal display. These public methods support direct and advanced use.
| API | Behavior |
|---|---|
initialize(): Promise<void> |
Initializes the worker and registers bundled descriptors plus global overrides |
addFiletypeParser(options): void |
Adds or replaces one descriptor in this client |
preloadParser(filetype): Promise<boolean> |
Loads a parser and its queries without creating a buffer |
highlightOnce(content, filetype) |
Returns SimpleHighlight[], a warning, or an error without retaining a buffer |
createBuffer(id, content, filetype, version?, autoInitialize?) |
Creates a retained parser buffer and returns whether it has a parser |
updateBuffer(id, edits, newContent, version): Promise<void> |
Queues incremental edits for an existing parsed buffer |
resetBuffer(id, version, content): Promise<void> |
Debounces a full parse reset for an existing parsed buffer |
removeBuffer(id): Promise<void> |
Removes local state and asks the worker to dispose the parser buffer |
getBuffer(id): BufferState | undefined |
Returns one local buffer state |
getAllBuffers(): BufferState[] |
Returns all local buffer states |
getPerformance(): Promise<PerformanceStats> |
Returns recent worker parse and query timings |
isInitialized(): boolean |
Reports whether initialization completed |
setDataPath(dataPath): Promise<void> |
Changes the worker data path |
clearCache(): Promise<void> |
Clears cached parser and query files |
onDestroy(callback): () => void |
Adds a synchronous destroy callback and returns its removal function |
destroy(): Promise<void> |
Rejects pending work, clears local state, runs callbacks, and terminates the worker |
createBuffer() defaults version to 1 and autoInitialize to true. Buffer IDs must be unique within a client.
updateBuffer() and resetBuffer() do nothing when the client or target parser buffer is unavailable.
The direct result types are:
type SimpleHighlight = [startIndex: number, endIndex: number, group: string, meta?: HighlightMeta]
interface HighlightMeta {
isInjection?: boolean
injectionLang?: string
containsInjection?: boolean
conceal?: string | null
concealLines?: string | null
}
interface PerformanceStats {
averageParseTime: number
parseTimes: number[]
averageQueryTime: number
queryTimes: number[]
}Incremental clients also export Edit, BufferState, ParsedBuffer, HighlightRange, and HighlightResponse. The
worker request and response unions are public advanced types. See the API and symbol index
for the complete symbol inventory and support classes.
Convert highlights to styled text#
The root entry point exports two conversion helpers:
| API | Result |
|---|---|
treeSitterToStyledText(content, filetype, syntaxStyle, client, options?) |
Runs one highlight and returns StyledText |
treeSitterToTextChunks(content, highlights, syntaxStyle, options?) |
Converts existing highlights to TextChunk[] |
TreeSitterToStyledTextOptions supports conceal?: { enabled?: boolean } and baseHighlight?: string. The chunk helper
accepts enabled and baseHighlight directly in its options object.
Related documentation#
- Code defines syntax styles and source display.
- Markdown uses file type resolution for fenced code.
- Diff highlights patch content.
- Package entry points lists the Core root, worker, and asset-update entry points.
- Runtime and platform support defines runtime, worker, and permission support.
- Lifecycle and cleanup defines application shutdown ownership.
- Application data paths defines the singleton data directory.
- Standalone executables covers embedded and extracted Tree-sitter assets.
- API and symbol index lists all exported Tree-sitter and syntax-style symbols.