add tree-sitter syntax highlighting via dart:ffi

Vendor libtree-sitter.so (v0.26.8, wasmtime 44.0 statically linked)
and call its C API through dart:ffi. Grammar WASM files are loaded by
tree-sitter's embedded wasmtime engine via ts_wasm_store_load_language.

48 grammars compiled with tree-sitter build --wasm, 48 highlight
queries (42 from upstream repos, 6 written in-house for comment,
erlang, gdscript, gitignore, hcl, hlsl, swift). SQLite grammar
replaces full SQL (974K-line parser exhausted RAM at compile time).

Removes wasm_run and wasm_run_flutter — they downloaded a 22 MB
binary from GitHub at first launch, violating the no-network-on-
default-launch-path policy.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-22 22:08:00 +02:00
co-authored by Claude
parent 4f5d97ebc5
commit ce8529e424
111 changed files with 7046 additions and 9 deletions
+87
View File
@@ -0,0 +1,87 @@
// Tree-sitter WASM bridge — compiled per grammar into a self-contained
// WASM module. Exports: init, set_query, parse_and_highlight,
// capture_count, capture_name, ts_alloc, ts_dealloc.
#include "tree_sitter/api.h"
#include <stdlib.h>
#include <stdint.h>
// Linked at compile time via -DGRAMMAR_FN=tree_sitter_<lang>.
extern const TSLanguage *GRAMMAR_FN(void);
static TSParser *g_parser = NULL;
static TSQuery *g_query = NULL;
static TSQueryCursor *g_cursor = NULL;
// 12-byte packed span: start_byte, end_byte, capture_index.
typedef struct { uint32_t start; uint32_t end; uint32_t capture; } Span;
__attribute__((export_name("init")))
int32_t init(void) {
g_parser = ts_parser_new();
if (!g_parser) return -1;
if (!ts_parser_set_language(g_parser, GRAMMAR_FN())) return -2;
g_cursor = ts_query_cursor_new();
if (!g_cursor) return -3;
return 0;
}
// Returns 0 on success, or (error_offset + 1) on failure.
__attribute__((export_name("set_query")))
int32_t set_query(const char *src, uint32_t len) {
if (g_query) { ts_query_delete(g_query); g_query = NULL; }
uint32_t error_offset;
TSQueryError error_type;
g_query = ts_query_new(GRAMMAR_FN(), src, len, &error_offset, &error_type);
return g_query ? 0 : (int32_t)(error_offset + 1);
}
// Parse source and run the active query. Writes Span structs into |out|.
// Returns the number of spans written.
__attribute__((export_name("parse_and_highlight")))
uint32_t parse_and_highlight(
const char *src, uint32_t src_len,
Span *out, uint32_t max_spans
) {
if (!g_parser || !g_query || !g_cursor) return 0;
TSTree *tree = ts_parser_parse_string(g_parser, NULL, src, src_len);
if (!tree) return 0;
TSNode root = ts_tree_root_node(tree);
ts_query_cursor_exec(g_cursor, g_query, root);
TSQueryMatch match;
uint32_t count = 0;
while (ts_query_cursor_next_match(g_cursor, &match) && count < max_spans) {
for (uint16_t i = 0; i < match.capture_count && count < max_spans; i++) {
TSQueryCapture cap = match.captures[i];
out[count].start = ts_node_start_byte(cap.node);
out[count].end = ts_node_end_byte(cap.node);
out[count].capture = cap.index;
count++;
}
}
ts_tree_delete(tree);
return count;
}
__attribute__((export_name("capture_count")))
uint32_t capture_count(void) {
return g_query ? ts_query_capture_count(g_query) : 0;
}
// Returns a pointer into WASM linear memory. Caller reads |*out_len|
// bytes starting at the returned address.
__attribute__((export_name("capture_name")))
const char *capture_name(uint32_t index, uint32_t *out_len) {
if (!g_query) { *out_len = 0; return NULL; }
return ts_query_capture_name_for_id(g_query, index, out_len);
}
__attribute__((export_name("ts_alloc")))
void *ts_alloc(uint32_t size) { return malloc(size); }
__attribute__((export_name("ts_dealloc")))
void ts_dealloc(void *ptr) { free(ptr); }
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# Compile tree-sitter core + each grammar + bridge.c into self-contained
# WASM modules. Requires wasi-sdk (cached by `tree-sitter build --wasm`).
#
# Usage:
# ./tools/ts-wasm/build.sh # build all grammars
# ./tools/ts-wasm/build.sh dart rust # build specific grammars
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
WASI_SDK="${WASI_SDK:-$HOME/.cache/tree-sitter/wasi-sdk}"
TS_CORE="${TS_CORE:-/var/mnt/data/projects/treesitter/tree-sitter}"
GRAMMARS_ROOT="${GRAMMARS_ROOT:-/var/mnt/data/projects/treesitter}"
OUT_DIR="$REPO_ROOT/app/assets/grammars"
QUERIES_DIR="$REPO_ROOT/app/assets/queries"
CC="$WASI_SDK/bin/clang"
CXX="$WASI_SDK/bin/clang++"
SYSROOT="$WASI_SDK/share/wasi-sysroot"
CFLAGS=(
--sysroot="$SYSROOT"
--target=wasm32-wasip1
-O2 -flto
-fno-exceptions
-I"$TS_CORE/lib/include"
-I"$TS_CORE/lib/src"
-DTREE_SITTER_HIDE_SYMBOLS
)
LDFLAGS=(
-Wl,--no-entry
-Wl,--export=init
-Wl,--export=set_query
-Wl,--export=parse_and_highlight
-Wl,--export=capture_count
-Wl,--export=capture_name
-Wl,--export=ts_alloc
-Wl,--export=ts_dealloc
-Wl,--export=__heap_base
-Wl,--strip-all
-Wl,--gc-sections
)
mkdir -p "$OUT_DIR" "$QUERIES_DIR"
# Grammar source-dir resolution. Multi-grammar repos (markdown, php,
# typescript, xml) nest the actual grammar under a subdirectory.
grammar_src_dir() {
local name="$1" base="$GRAMMARS_ROOT/tree-sitter-$name"
# Multi-grammar repos: check for a nested dir matching the grammar name.
for sub in "$base/$name" "$base/tree-sitter-$name"; do
[ -f "$sub/src/parser.c" ] && echo "$sub" && return
done
# Fallback: repo root.
[ -f "$base/src/parser.c" ] && echo "$base" && return
echo >&2 "error: no parser.c for $name"; return 1
}
# Derive the C function name (tree_sitter_<id>) from parser.c.
grammar_fn_name() {
grep -oP 'TSLanguage \*\K(tree_sitter_\w+)(?=\(void\))' "$1/src/parser.c" | head -1
}
# Find highlight queries, preferring the grammar's own queries/ dir.
copy_queries() {
local name="$1" src_dir="$2"
# Try grammar-specific queries first, then repo-level.
local q=""
for candidate in \
"$src_dir/queries/highlights.scm" \
"$GRAMMARS_ROOT/tree-sitter-$name/queries/highlights.scm" \
"$GRAMMARS_ROOT/tree-sitter-$name/queries/$name/highlights.scm" \
"$GRAMMARS_ROOT/tree-sitter-$name/queries-src/highlights.scm"; do
[ -f "$candidate" ] && q="$candidate" && break
done
[ -n "$q" ] && cp "$q" "$QUERIES_DIR/$name.scm" || true
}
build_grammar() {
local name="$1"
local src_dir; src_dir="$(grammar_src_dir "$name")" || return 1
local fn; fn="$(grammar_fn_name "$src_dir")"
[ -z "$fn" ] && echo >&2 "error: can't find function name for $name" && return 1
local c_sources=( "$SCRIPT_DIR/bridge.c" "$TS_CORE/lib/src/lib.c" "$src_dir/src/parser.c" )
local cxx_sources=()
# Add external scanner.
if [ -f "$src_dir/src/scanner.c" ]; then
c_sources+=( "$src_dir/src/scanner.c" )
fi
if [ -f "$src_dir/src/scanner.cc" ]; then
cxx_sources+=( "$src_dir/src/scanner.cc" )
fi
local mem; mem="$(free -h | awk '/Mem:/{print $3" used / "$7" avail"}')"
echo " $name ($fn) [$mem]"
local tmpdir; tmpdir="$(mktemp -d)"
trap "rm -rf '$tmpdir'" RETURN
# Drop -flto for C++ grammars — wasm-ld LTO on C++ eats 20 GB+.
local compile_flags=("${CFLAGS[@]}")
if [ ${#cxx_sources[@]} -gt 0 ]; then
compile_flags=("${compile_flags[@]/-flto/}")
fi
# Compile C sources.
local objs=()
local i=0
for src in "${c_sources[@]}"; do
$CC "${compile_flags[@]}" -DGRAMMAR_FN="$fn" -I"$src_dir/src" \
-c "$src" -o "$tmpdir/$i.o" 2>&1
objs+=( "$tmpdir/$i.o" )
i=$((i + 1))
done
# Compile C++ sources (if any).
for src in "${cxx_sources[@]}"; do
$CXX "${compile_flags[@]}" -DGRAMMAR_FN="$fn" -I"$src_dir/src" \
-fno-rtti -c "$src" -o "$tmpdir/$i.o" 2>&1
objs+=( "$tmpdir/$i.o" )
i=$((i + 1))
done
# Link.
local linker="$CC"
[ ${#cxx_sources[@]} -gt 0 ] && linker="$CXX"
$linker "${compile_flags[@]}" "${objs[@]}" "${LDFLAGS[@]}" \
-o "$OUT_DIR/$name.wasm" 2>&1
copy_queries "$name" "$src_dir"
}
# Determine which grammars to build.
if [ $# -gt 0 ]; then
targets=("$@")
else
targets=()
for d in "$GRAMMARS_ROOT"/tree-sitter-*/; do
g="$(basename "$d" | sed 's/^tree-sitter-//')"
targets+=("$g")
done
fi
echo "Building ${#targets[@]} grammars..."
failed=()
for g in "${targets[@]}"; do
if ! build_grammar "$g"; then
failed+=("$g")
fi
done
echo ""
echo "Built $((${#targets[@]} - ${#failed[@]})) / ${#targets[@]} grammars."
if [ ${#failed[@]} -gt 0 ]; then
echo "Failed: ${failed[*]}"
exit 1
fi