mirror of
https://github.com/sbrow/thor.git
synced 2026-10-25 10:11:55 -04:00
516 lines
16 KiB
Odin
516 lines
16 KiB
Odin
package treesitter
|
|
|
|
import "core:c"
|
|
import "core:fmt"
|
|
import "core:log"
|
|
import "core:mem"
|
|
import "core:os"
|
|
import "core:strings"
|
|
import "core:sync"
|
|
import si "core:sys/info"
|
|
import "core:thread"
|
|
|
|
grammar_dir: string
|
|
query_dir: string
|
|
|
|
HTML_HIGHLIGHTS :: #load(#directory + "queries/html/highlights.scm", string)
|
|
CSS_HIGHLIGHTS :: #load(#directory + "queries/css/highlights.scm", string)
|
|
|
|
Language :: distinct rawptr
|
|
Parser :: distinct rawptr
|
|
Tree :: distinct rawptr
|
|
Query :: distinct rawptr
|
|
Query_Cursor :: distinct rawptr
|
|
|
|
Point :: struct {
|
|
row: u32,
|
|
column: u32,
|
|
}
|
|
|
|
Node :: struct {
|
|
ctx: [4]u32,
|
|
id: rawptr,
|
|
tree: rawptr,
|
|
}
|
|
|
|
Query_Capture :: struct {
|
|
node: Node,
|
|
index: u32,
|
|
_: u32,
|
|
}
|
|
|
|
Query_Match :: struct {
|
|
id: u32,
|
|
pattern_index: u16,
|
|
capture_count: u16,
|
|
captures: [^]Query_Capture,
|
|
}
|
|
|
|
Query_Error :: enum c.int {
|
|
None = 0,
|
|
Syntax,
|
|
NodeType,
|
|
Field,
|
|
Capture,
|
|
Structure,
|
|
Language,
|
|
}
|
|
|
|
RTLD_LAZY :: c.int(1)
|
|
|
|
foreign import lib "system:tree-sitter"
|
|
foreign import libdl "system:dl"
|
|
foreign import html_grammar "system:tree-sitter-html"
|
|
foreign import css_grammar "system:tree-sitter-css"
|
|
|
|
@(link_prefix = "ts_")
|
|
foreign lib {
|
|
parser_new :: proc() -> Parser ---
|
|
parser_delete :: proc(self: Parser) ---
|
|
parser_set_language :: proc(self: Parser, language: Language) -> bool ---
|
|
parser_parse_string :: proc(self: Parser, old_tree: Tree, string: cstring, length: u32) -> Tree ---
|
|
}
|
|
|
|
@(link_prefix = "ts_")
|
|
foreign lib {
|
|
tree_root_node :: proc(self: Tree) -> Node ---
|
|
tree_delete :: proc(self: Tree) ---
|
|
}
|
|
|
|
@(link_prefix = "ts_")
|
|
foreign lib {
|
|
node_start_byte :: proc(self: Node) -> u32 ---
|
|
node_end_byte :: proc(self: Node) -> u32 ---
|
|
node_has_error :: proc(self: Node) -> bool ---
|
|
node_is_error :: proc(self: Node) -> bool ---
|
|
node_child_count :: proc(self: Node) -> u32 ---
|
|
node_child :: proc(self: Node, child_index: u32) -> Node ---
|
|
node_named_child_count :: proc(self: Node) -> u32 ---
|
|
node_named_child :: proc(self: Node, child_index: u32) -> Node ---
|
|
node_start_point :: proc(self: Node) -> Point ---
|
|
node_type :: proc(self: Node) -> cstring ---
|
|
node_parent :: proc(self: Node) -> Node ---
|
|
}
|
|
|
|
@(link_prefix = "ts_")
|
|
foreign lib {
|
|
query_new :: proc(language: Language, source: cstring, source_len: u32, error_offset: ^u32, error_type: ^Query_Error) -> Query ---
|
|
query_delete :: proc(self: Query) ---
|
|
query_capture_name_for_id :: proc(self: Query, index: u32, length: ^u32) -> cstring ---
|
|
}
|
|
|
|
@(link_prefix = "ts_")
|
|
foreign lib {
|
|
query_cursor_new :: proc() -> Query_Cursor ---
|
|
query_cursor_delete :: proc(self: Query_Cursor) ---
|
|
query_cursor_exec :: proc(self: Query_Cursor, query: Query, node: Node) ---
|
|
query_cursor_next_capture :: proc(self: Query_Cursor, match: ^Query_Match, capture_index: ^u32) -> bool ---
|
|
}
|
|
|
|
foreign libdl {
|
|
dlopen :: proc(filename: cstring, flags: c.int) -> rawptr ---
|
|
dlsym :: proc(handle: rawptr, symbol: cstring) -> rawptr ---
|
|
dlclose :: proc(handle: rawptr) -> c.int ---
|
|
}
|
|
|
|
foreign html_grammar {
|
|
tree_sitter_html :: proc() -> Language ---
|
|
}
|
|
|
|
foreign css_grammar {
|
|
tree_sitter_css :: proc() -> Language ---
|
|
}
|
|
|
|
// A Grammar bundles only the immutable, shareable tree-sitter objects for a
|
|
// language: `language` (from tree_sitter_x() or a dlopen'd .so) and `query` (the
|
|
// compiled highlight query). Both are immutable after construction and safe to
|
|
// read from any number of threads. The stateful, single-thread-use objects —
|
|
// parser and query cursor — are deliberately NOT stored here; the caller creates
|
|
// and owns those (see `open_parser`). See GRAMMAR_CACHE_THREADING.md.
|
|
Grammar :: struct {
|
|
language: Language,
|
|
query: Query,
|
|
query_failed: bool,
|
|
}
|
|
|
|
Get_Language_Proc :: #type proc() -> Language
|
|
|
|
SPALL :: #config(SPALL, false)
|
|
|
|
// registry is the process-lifetime cache of immutable Grammars, keyed by language
|
|
// name. It is the library's ONLY shared mutable state; `mu` serializes all access.
|
|
// A nil value memoizes "tried and unavailable" so misses don't re-dlopen. Parsers
|
|
// live in the caller, so the registry holds nothing single-thread-use.
|
|
Grammar_Registry :: struct {
|
|
mu: sync.Mutex,
|
|
grammars: map[string]^Grammar,
|
|
allocator: mem.Allocator,
|
|
}
|
|
|
|
registry: Grammar_Registry
|
|
|
|
// init_persistent is optional — grammar() lazily initializes the registry on first
|
|
// use — but callers (main) may call it to bind the cache early. The allocator is
|
|
// pinned to the OS heap, NOT context.allocator, so the cache survives any transient
|
|
// allocator (per-build arena, per-test tracking) that happens to be live.
|
|
init_persistent :: proc() {
|
|
sync.mutex_lock(®istry.mu)
|
|
defer sync.mutex_unlock(®istry.mu)
|
|
ensure_registry()
|
|
}
|
|
|
|
// ensure_registry binds the registry to the heap on first use. Caller must hold mu.
|
|
@(private)
|
|
ensure_registry :: proc() {
|
|
if registry.grammars == nil {
|
|
registry.allocator = os.heap_allocator()
|
|
registry.grammars = make(map[string]^Grammar, registry.allocator)
|
|
}
|
|
}
|
|
|
|
// grammar returns the immutable Grammar for `lang`, loading it on first use.
|
|
// Thread-safe: concurrent callers serialize on `mu`. Returns (nil, false) when the
|
|
// grammar cannot be loaded. The returned pointer is immutable — callers may cache
|
|
// and read it (its `language`/`query`) without any further locking.
|
|
grammar :: proc(lang: string) -> (^Grammar, bool) {
|
|
sync.mutex_lock(®istry.mu)
|
|
defer sync.mutex_unlock(®istry.mu)
|
|
ensure_registry()
|
|
if g, seen := registry.grammars[lang]; seen {
|
|
return g, g != nil
|
|
}
|
|
g := build_grammar(lang)
|
|
registry.grammars[lang] = g
|
|
return g, g != nil
|
|
}
|
|
|
|
// build_grammar loads a language and compiles its highlight query. It touches no
|
|
// shared mutable state — only the set-once `registry.allocator` (for the heap
|
|
// `new`), read-only globals, and the per-thread temp allocator — so it is safe to
|
|
// run OFF the lock and concurrently, provided `ensure_registry` has already run
|
|
// (see preload_grammars). Returns nil if the language itself cannot be loaded; a
|
|
// grammar whose query is absent/failed is still returned (parsing works,
|
|
// highlighting does not).
|
|
@(private)
|
|
build_grammar :: proc(lang: string) -> ^Grammar {
|
|
language, ok := load_language(lang)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
g := new(Grammar, registry.allocator)
|
|
g.language = language
|
|
if query, qok := compile_query(lang, language); qok {
|
|
g.query = query
|
|
} else {
|
|
g.query_failed = true
|
|
}
|
|
return g
|
|
}
|
|
|
|
// open_parser creates a fresh parser bound to the grammar's language. The CALLER
|
|
// owns the returned parser and must free it with parser_delete. This is the seam
|
|
// for parser lifetime: today callers open one and reuse it across a batch of
|
|
// parses on a single thread (render opens an html+css pair per render_site;
|
|
// assets opens one per copy_assets_dir), which is safe because nothing is shared.
|
|
// A future pooling or thread-local strategy can be dropped in here without
|
|
// touching any call site. Returns nil on failure.
|
|
open_parser :: proc(g: ^Grammar) -> Parser {
|
|
if g == nil {
|
|
return nil
|
|
}
|
|
parser := parser_new()
|
|
if parser == nil {
|
|
log.errorf("treesitter: cannot create parser")
|
|
return nil
|
|
}
|
|
if !parser_set_language(parser, g.language) {
|
|
log.errorf("treesitter: ABI mismatch setting parser language")
|
|
parser_delete(parser)
|
|
return nil
|
|
}
|
|
return parser
|
|
}
|
|
|
|
when SPALL {
|
|
_thread_init: proc() = nil
|
|
_thread_cleanup: proc() = nil
|
|
|
|
set_thread_callbacks :: proc(init: proc() = nil, cleanup: proc() = nil) {
|
|
_thread_init = init
|
|
_thread_cleanup = cleanup
|
|
}
|
|
|
|
// Pool-shaped adapters: the thread pool calls these once per worker thread at
|
|
// startup/shutdown, so each worker's parses land in their own SPALL thread.
|
|
spall_pool_thread_init :: proc(t: ^thread.Thread, data: rawptr) {
|
|
if _thread_init != nil {
|
|
_thread_init()
|
|
}
|
|
}
|
|
spall_pool_thread_fini :: proc(t: ^thread.Thread, data: rawptr) {
|
|
if _thread_cleanup != nil {
|
|
_thread_cleanup()
|
|
}
|
|
}
|
|
}
|
|
|
|
builtin_language :: proc(lang: string) -> (language: Language, ok: bool) {
|
|
switch lang {
|
|
case "html":
|
|
language = tree_sitter_html()
|
|
ok = true
|
|
case "css":
|
|
language = tree_sitter_css()
|
|
ok = true
|
|
}
|
|
return
|
|
}
|
|
|
|
// load_query returns the highlight query source for a language. Builtin
|
|
// languages (html/css) are baked into the binary via `#load`; all others are
|
|
// read from the runtime `query_dir`. `path` is the on-disk location for
|
|
// diagnostics ("(builtin)" for embedded queries). Mirrors `load_language`.
|
|
load_query :: proc(lang: string) -> (src: string, path: string, ok: bool) {
|
|
switch lang {
|
|
case "html":
|
|
return HTML_HIGHLIGHTS, "(builtin)", true
|
|
case "css":
|
|
return CSS_HIGHLIGHTS, "(builtin)", true
|
|
}
|
|
if query_dir == "" {
|
|
log.warnf("treesitter: no query path set, skipping %s", lang)
|
|
return "", "", false
|
|
}
|
|
path = fmt.tprintf("%s/%s/highlights.scm", query_dir, lang)
|
|
raw, err := os.read_entire_file_from_path(path, context.allocator)
|
|
if err != nil {
|
|
log.warnf("treesitter: cannot load query %s", path)
|
|
return "", "", false
|
|
}
|
|
return string(raw), path, true
|
|
}
|
|
|
|
load_language :: proc(lang: string) -> (language: Language, ok: bool) {
|
|
if builtin, bok := builtin_language(lang); bok {
|
|
language = builtin
|
|
ok = true
|
|
return
|
|
}
|
|
if grammar_dir == "" {
|
|
log.warnf("treesitter: no grammar path set, skipping %s", lang)
|
|
return
|
|
}
|
|
so_path := fmt.caprintf("%s/%s.so", grammar_dir, lang, allocator = context.temp_allocator)
|
|
handle := dlopen(so_path, RTLD_LAZY)
|
|
if handle == nil {
|
|
log.warnf("treesitter: cannot load grammar %s (%s)", lang, so_path)
|
|
return
|
|
}
|
|
sym_name := fmt.caprintf("tree_sitter_%s", lang, allocator = context.temp_allocator)
|
|
sym := dlsym(handle, sym_name)
|
|
if sym == nil {
|
|
log.errorf("treesitter: cannot find symbol %s in %s", sym_name, so_path)
|
|
return
|
|
}
|
|
get_language := transmute(Get_Language_Proc)(sym)
|
|
language = get_language()
|
|
ok = true
|
|
return
|
|
}
|
|
|
|
compile_query :: proc(lang: string, language: Language) -> (query: Query, ok: bool) {
|
|
query_src, query_path, qok := load_query(lang)
|
|
if !qok {
|
|
return
|
|
}
|
|
query_c := strings.clone_to_cstring(query_src, context.temp_allocator)
|
|
|
|
err_offset: u32
|
|
err_type: Query_Error
|
|
query = query_new(language, query_c, u32(len(query_src)), &err_offset, &err_type)
|
|
if query == nil {
|
|
tok := extract_query_token(transmute([]byte)query_src, err_offset)
|
|
cause := fmt.tprintf("query error at byte %d (type %v)", err_offset, err_type)
|
|
switch err_type {
|
|
case .NodeType:
|
|
if tok != "" {
|
|
cause = fmt.tprintf(
|
|
"query references unknown node type '%s' (byte %d); the grammar (.so) and query (.scm) are likely from different tree-sitter-%s versions",
|
|
tok,
|
|
err_offset,
|
|
lang,
|
|
)
|
|
} else {
|
|
cause = fmt.tprintf(
|
|
"query references an unknown node type at byte %d; the grammar (.so) and query (.scm) are likely from different tree-sitter-%s versions",
|
|
err_offset,
|
|
lang,
|
|
)
|
|
}
|
|
case .Field:
|
|
cause = fmt.tprintf("query references unknown field '%s' at byte %d", tok, err_offset)
|
|
case .Capture:
|
|
cause = fmt.tprintf("query uses an invalid capture '%s' at byte %d", tok, err_offset)
|
|
case .Syntax:
|
|
cause = fmt.tprintf("query has a syntax error at byte %d", err_offset)
|
|
case .Structure:
|
|
cause = fmt.tprintf("query has an illegal pattern structure at byte %d", err_offset)
|
|
case .Language:
|
|
cause = "grammar language is null (broken grammar .so)"
|
|
case .None:
|
|
}
|
|
log.errorf("treesitter: %s query failed: %s", lang, cause)
|
|
|
|
_, is_builtin := builtin_language(lang)
|
|
if !is_builtin {
|
|
so_path := fmt.tprintf("%s/%s.so", grammar_dir, lang)
|
|
gram_v := helix_version_from_path(so_path)
|
|
query_v := helix_version_from_path(query_path)
|
|
gram_note := "(version unknown)"
|
|
if gram_v != "" do gram_note = fmt.tprintf("helix %s", gram_v)
|
|
query_note := "(version unknown)"
|
|
if query_v != "" do query_note = fmt.tprintf("helix %s", query_v)
|
|
log.errorf(" grammar: %s [%s]", so_path, gram_note)
|
|
log.errorf(" query: %s [%s]", query_path, query_note)
|
|
if gram_v != "" && query_v != "" && gram_v != query_v {
|
|
log.errorf(" >> helix VERSION MISMATCH: grammar %s vs query %s", gram_v, query_v)
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
ok = true
|
|
return
|
|
}
|
|
|
|
// PRELOAD_MAX_WORKERS caps the pool when the CPU count is unknown or absurd.
|
|
PRELOAD_MAX_WORKERS :: 4
|
|
|
|
// Preload_Task is the per-language work item handed to a pool worker.
|
|
Preload_Task :: struct {
|
|
lang: string,
|
|
}
|
|
|
|
// preload_worker builds one grammar off the lock (dlopen + query compile — the
|
|
// expensive, parallelizable part), then takes `mu` only to publish the finished
|
|
// pointer. Publishing nil memoizes a failed load, matching grammar()/build_grammar.
|
|
@(private)
|
|
preload_worker :: proc(task: thread.Task) {
|
|
pt := cast(^Preload_Task)task.data
|
|
g := build_grammar(pt.lang)
|
|
sync.mutex_lock(®istry.mu)
|
|
registry.grammars[pt.lang] = g
|
|
sync.mutex_unlock(®istry.mu)
|
|
}
|
|
|
|
// preload_grammars warms the registry for the given languages ahead of the render
|
|
// loop, so the first page that needs one doesn't pay the load cost inline. The
|
|
// expensive per-language work (dlopen + compile_query) runs concurrently on a
|
|
// bounded thread pool. Builtins are always available lazily and are skipped.
|
|
//
|
|
// INVARIANT: preload runs to completion before any concurrent lazy grammar() use
|
|
// (render is single-threaded and runs afterward), so the registry is never read
|
|
// while workers publish. Workers still take `mu` for the map insert, keeping the
|
|
// "registry is only ever mutated under mu" discipline uniform with grammar().
|
|
preload_grammars :: proc(languages: []string) {
|
|
// Snapshot (main thread, under mu): pin the registry allocator before any
|
|
// worker reads it, and collect the languages still needing a load. Dedupes
|
|
// against builtins and already-cached grammars (watch-mode reuse).
|
|
sync.mutex_lock(®istry.mu)
|
|
ensure_registry()
|
|
to_load := make([dynamic]string, 0, len(languages), context.temp_allocator)
|
|
for lang in languages {
|
|
if _, bok := builtin_language(lang); bok {
|
|
continue
|
|
}
|
|
if _, seen := registry.grammars[lang]; seen {
|
|
continue
|
|
}
|
|
append(&to_load, lang)
|
|
}
|
|
sync.mutex_unlock(®istry.mu)
|
|
|
|
if len(to_load) == 0 {
|
|
return
|
|
}
|
|
|
|
// Bind the pool to the logical core count (load is dlopen/CPU-bound), but
|
|
// never more workers than tasks, never fewer than one.
|
|
worker_count: int = ---
|
|
if _, logical, ok := si.cpu_core_count(); ok {
|
|
assert(logical > 0)
|
|
worker_count = min(logical, len(to_load))
|
|
} else {
|
|
worker_count = min(PRELOAD_MAX_WORKERS, len(to_load))
|
|
}
|
|
|
|
// Serial fast path: a single worker gains nothing from the pool machinery.
|
|
if worker_count == 1 {
|
|
for lang in to_load {
|
|
g := build_grammar(lang)
|
|
sync.mutex_lock(®istry.mu)
|
|
registry.grammars[lang] = g
|
|
sync.mutex_unlock(®istry.mu)
|
|
}
|
|
return
|
|
}
|
|
|
|
// The pool appends to its own `tasks_done` from worker threads, so its
|
|
// bookkeeping allocator MUST be thread-safe — use the heap, not the (possibly
|
|
// arena) context allocator that is live during a build.
|
|
pool: thread.Pool
|
|
init_proc: thread.Thread_Init_Proc = nil
|
|
fini_proc: thread.Thread_Init_Proc = nil
|
|
when SPALL {
|
|
init_proc = spall_pool_thread_init
|
|
fini_proc = spall_pool_thread_fini
|
|
}
|
|
thread.pool_init(&pool, os.heap_allocator(), worker_count, init_proc, nil, fini_proc, nil)
|
|
defer thread.pool_destroy(&pool)
|
|
|
|
tasks := make([]Preload_Task, len(to_load), context.temp_allocator)
|
|
thread.pool_start(&pool)
|
|
for lang, i in to_load {
|
|
tasks[i].lang = lang
|
|
// Per-task context allocator = heap, so any stray context.allocator use
|
|
// inside a worker is thread-safe.
|
|
thread.pool_add_task(&pool, os.heap_allocator(), preload_worker, &tasks[i], i)
|
|
}
|
|
thread.pool_finish(&pool) // barrier: all workers joined before we return
|
|
}
|
|
|
|
extract_query_token :: proc(src: []byte, offset: u32) -> string {
|
|
end := offset
|
|
for int(end) < len(src) {
|
|
c := src[end]
|
|
is_ident :=
|
|
(c >= 'A' && c <= 'Z') ||
|
|
(c >= 'a' && c <= 'z') ||
|
|
(c >= '0' && c <= '9') ||
|
|
c == '_' ||
|
|
c == '-' ||
|
|
c == '.'
|
|
if !is_ident do break
|
|
end += 1
|
|
}
|
|
if end <= offset do return ""
|
|
return string(src[offset:end])
|
|
}
|
|
|
|
helix_version_from_path :: proc(path: string) -> string {
|
|
tag := "-helix-"
|
|
idx := strings.index(path, tag)
|
|
if idx < 0 do return ""
|
|
start := idx + len(tag)
|
|
end := start
|
|
for end < len(path) {
|
|
c := path[end]
|
|
if !((c >= '0' && c <= '9') || c == '.') do break
|
|
end += 1
|
|
}
|
|
if end <= start do return ""
|
|
return path[start:end]
|
|
}
|
|
|