perf: Improved performance of highlighter code.

This commit is contained in:
Spencer Brower
2026-07-24 17:18:06 -04:00
parent 71f12e5280
commit 62d608ef86
8 changed files with 339 additions and 79 deletions
+88
View File
@@ -0,0 +1,88 @@
# load_grammar Optimization Ideas
## Current bottleneck
`load_grammar` is called once per unique language on first use. Each call costs ~30-40ms:
- `dlopen` — loads `.so` from disk (dominant)
- `read_entire_file_from_path` — reads `.scm` from disk
- `query_new` — compiles query
- `query_cursor_new` — allocates cursor (cheap)
With ~7 languages, first-load cost is ~210-280ms. Subsequent calls hit cache (~0ms).
## Code-level optimizations
### Avoid double-cloning query source
Currently: `read_entire_file_from_path``[]byte``string(raw)``strings.clone_to_cstring(query_src)`. Two copies of the entire `.scm` file.
Fix: read into a buffer with a trailing null byte, use directly as cstring. One read, zero copies.
### Reuse the grammar path string
`fmt.tprintf("%s/%s.so", grammar_dir, lang)` is computed in `ensure_parser` AND again in error diagnostics (line 284). Compute once, store in `Grammar_Cache`.
### Avoid `fmt.tprintf` for symbol name
`"tree_sitter_" + lang` can be simple concatenation instead of format string parsing.
### Extract error diagnostics to a separate proc
The query error diagnostics (lines 250-300) make up more than half of `load_grammar`. Extract to `report_query_error(lang, query_src, err_offset, err_type, query_path)`. Makes `load_grammar` focused on the happy path.
## Architectural optimizations
### Pre-scan content for languages, then batch-load
Before rendering, scan all markdown for `language-X` code blocks. Collect the unique set. Load all grammars in one pass.
Benefits:
- Makes cost visible ("loading grammars for: bash, odin, nu...")
- Enables parallel loading
- Moves cost to a predictable point in the build
### Parallel grammar loading
`dlopen` is thread-safe (POSIX). Load N grammars on N threads simultaneously.
- Sequential: ~210ms (7 languages × ~30ms each)
- Parallel (4 cores): ~60ms
- Parallel (7 threads): ~30ms
Biggest single win for first-load time. Odin's `core:thread` or `core:sync` can manage the thread pool.
### Static-link more grammars
Extend the `mkGrammarStaticLib` pattern (already used for HTML/CSS in `flake.nix`) to bash, odin, nu, etc.
- Zero `dlopen` — grammars baked into binary
- Zero `read_entire_file_from_path` — queries embedded via `#load` or `#directory`
- Zero `query_new` compilation — could pre-compile or use compile-time embedding
- Downside: binary size grows (~500KB per language), adding languages requires recompiling
### Embed queries at compile time
Even without static-linking grammars, the `.scm` query files could be embedded via `#load` or `#directory`.
- Eliminates `read_entire_file_from_path` per language
- Smaller win than static linking but simpler
- Queries are small text files (~2-10KB each)
### Cache compiled queries across runs
`query_new` compiles the `.scm` source into an internal representation. If this could be serialized and cached to disk (like a `.thorcache`), subsequent builds could skip compilation.
- Tree-sitter's query format isn't documented as serializable — would need investigation
- Even if not serializable, caching the raw `.scm` source in memory avoids re-reading from disk in watch mode (already handled by `grammar_cache` persistence)
### Pre-link common grammars in the flake
The flake could compile grammar `.so` files as Nix build inputs and pass their paths to thor at runtime via the `grammars` config field. This doesn't eliminate `dlopen` but ensures the files are always available in the Nix store.
## Priority ranking
1. **Parallel grammar loading** — immediate ~4-7x speedup on first load, no binary changes
2. **Static-link common grammars** — eliminates the problem entirely, but requires flake work
3. **Pre-scan + batch-load** — enables parallel loading and better UX logging
4. **Avoid double-clone of query source** — quick code cleanup, small win
5. **Embed queries at compile time** — eliminates file reads, moderate effort
+115
View File
@@ -0,0 +1,115 @@
# Parallelization Plan
## Current bottleneck
`compile_query` (which contains `query_new`) is ~100% of the grammar loading cost. With ~7 languages, sequential loading costs ~200-280ms. The rest of the build is ~12ms.
## Thread safety concerns
Current code is single-threaded and assumes it:
- `grammar_cache` map — concurrent writes unsafe
- `context.temp_allocator` — shared, not thread-safe
- `Site.pages` dynamic array — concurrent appends unsafe
- Site arena — bump pointer, not atomic
Any parallelization must address these.
## Dependency graph
```
init_site → build_vfs → scan_content (discover files)
→ grammar loading (must finish before any highlight_code)
→ load_page × N (markdown pipeline + highlight_code)
→ render_site (template rendering × N + minify × N)
→ RSS + sitemap (needs all pages loaded)
→ asset copying (independent)
```
Key barriers:
- Grammar loading must finish before any highlighting
- Pages must be loaded before rendering
- All pages must be rendered before RSS/sitemap
- Minify + write are independent per page after render
## Three approaches
### Option 1: Per-subsystem parallelism
```
Phase 1: Parallel grammar loading (7 threads)
Phase 2: Parallel content loading (N pages on M threads)
Phase 3: Parallel template rendering (N pages on M threads)
Phase 4: Parallel minification (N pages on M threads)
Phase 5: Sequential: RSS, sitemap, assets
```
Pros:
- Simple barriers — each phase completes before the next starts
- Easy to reason about
Cons:
- Thread pool setup/teardown per phase (or reuse a pool with barriers)
- Memory pressure: all pages loaded before any rendered
- Load imbalance within phases (complex pages vs simple)
### Option 2: Per-page parallelism
```
Pre-load all grammars (sequential or parallel)
Then for each page (in parallel):
load_page → md.process → render → minify → write
Then sequential: RSS, sitemap, assets
```
Pros:
- Natural work unit — each page flows through the full pipeline independently
- No barriers between phases
Cons:
- Complex pages clog workers while simple pages finish fast
- Grammar loading must happen first (barrier)
- Needs thread-safe shared state (grammar cache, template cache, allocator)
### Option 3: Generic worker queue
```
Single thread pool with work stealing.
Jobs: grammar_load(lang), load_page(file), render_page(page), minify(html), write_output(path)
Dependencies tracked via futures or callbacks.
```
Pros:
- Most flexible — handles all work types
- Best load balancing (work stealing across types)
- No wasted thread setup between phases
Cons:
- Most complex to implement
- Need dependency tracking (can't render before load completes)
- Careful shared-state management needed
- Memory management with arena allocator (thread safety)
## Recommended starting point
**Pre-load grammars in parallel, keep everything else sequential.**
```
Phase 0: Pre-scan content for unique languages (fast, sequential)
Phase 1: Parallel grammar loading (one thread per language, ~30ms)
→ each thread writes to a pre-assigned slot (no map contention)
→ each thread uses its own temp allocator
→ main thread merges results into grammar_cache after join
Phase 2: Sequential build as today (~12ms)
```
Expected: ~42ms total instead of ~210ms. Minimal architecture change — no thread-safe allocators needed for the rest of the pipeline.
This can later evolve toward option 3 (generic queue) if page count grows or per-page processing becomes a bottleneck.
## Implementation notes
- Odin's `core:thread` or `core:sync` can manage the thread pool
- Each grammar-loading thread needs its own `context.temp_allocator` for path strings
- The `grammar_cache` map write happens on the main thread after all threads join
- `dlopen` is thread-safe (POSIX guarantee)
- `query_new` is likely thread-safe (independent computation per language, no shared state in tree-sitter)
+6
View File
@@ -17,9 +17,15 @@
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely - [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely
- [ ] Use spall to find ways to reduce run time. - [ ] Use spall to find ways to reduce run time.
- [ ] Consider using `#soa` for Page lists. - [ ] Consider using `#soa` for Page lists.
- [ ] too many `write_string` calls in `highlight_block`
- [ ] return `src: cstring` from `load_query`.
- [ ] Improve `unescape_html` with simd.
- [ ] generate summary before syntax highlighting.
## Memory Management ## Memory Management
- [ ] Leaks in highlighter code.
- [ ] Not sure whether to use temp allocator or site_allocator in opengraph.odin. - [ ] Not sure whether to use temp allocator or site_allocator in opengraph.odin.
- [ ] Not sure whether to use temp allocator or site_allocator in `site_load_content`. - [ ] Not sure whether to use temp allocator or site_allocator in `site_load_content`.
- [ ] Might not need to allocate in `strip_html_tags` - [ ] Might not need to allocate in `strip_html_tags`
+1 -1
View File
@@ -133,7 +133,7 @@
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
odin build . -o:speed -out:${pname}-keep odin build . -o:speed -no-bounds-check -out:${pname}-keep
runHook postBuild runHook postBuild
''; '';
+16 -9
View File
@@ -29,7 +29,7 @@ strip_html_tags :: proc(s: string, allocator := context.allocator) -> string {
} }
unescape_html :: proc(s: string) -> string { unescape_html :: proc(s: string) -> string {
sb := strings.builder_make() sb := strings.builder_make_len(len(s))
defer strings.builder_destroy(&sb) defer strings.builder_destroy(&sb)
start := 0 start := 0
@@ -41,15 +41,21 @@ unescape_html :: proc(s: string) -> string {
if semi < 0 { if semi < 0 {
break break
} }
entity := s[i : i + semi + 1] entity := s[i:i + semi + 1]
replacement := "" replacement := ""
switch entity { switch entity {
case "&amp;": replacement = "&" case "&amp;":
case "&lt;": replacement = "<" replacement = "&"
case "&gt;": replacement = ">" case "&lt;":
case "&quot;": replacement = "\"" replacement = "<"
case "&#39;", "&apos;": replacement = "'" case "&gt;":
case: continue replacement = ">"
case "&quot;":
replacement = "\""
case "&#39;", "&apos;":
replacement = "'"
case:
continue
} }
if i > start { if i > start {
strings.write_string(&sb, s[start:i]) strings.write_string(&sb, s[start:i])
@@ -77,7 +83,7 @@ generate_summary :: proc(html: string, max_words: int = 70) -> string {
separated, _ = strings.replace_all(separated, "</h1>", "\n\n") separated, _ = strings.replace_all(separated, "</h1>", "\n\n")
separated, _ = strings.replace_all(separated, "</h2>", "\n\n") separated, _ = strings.replace_all(separated, "</h2>", "\n\n")
separated, _ = strings.replace_all(separated, "</h3>", "\n\n") separated, _ = strings.replace_all(separated, "</h3>", "\n\n")
separated,_ = strings.replace_all(separated, "</h4>", "\n\n") separated, _ = strings.replace_all(separated, "</h4>", "\n\n")
separated, _ = strings.replace_all(separated, "</h5>", "\n\n") separated, _ = strings.replace_all(separated, "</h5>", "\n\n")
separated, _ = strings.replace_all(separated, "</h6>", "\n\n") separated, _ = strings.replace_all(separated, "</h6>", "\n\n")
separated, _ = strings.replace_all(separated, "</li>", "\n\n") separated, _ = strings.replace_all(separated, "</li>", "\n\n")
@@ -135,3 +141,4 @@ generate_summary :: proc(html: string, max_words: int = 70) -> string {
return strings.to_string(sb) return strings.to_string(sb)
} }
+83 -57
View File
@@ -16,7 +16,7 @@ find_first_error_line :: proc(root: ts.Node) -> int {
if ts.node_is_error(root) { if ts.node_is_error(root) {
return int(ts.node_start_point(root).row) + 1 return int(ts.node_start_point(root).row) + 1
} }
for i in 0..<ts.node_child_count(root) { for i in 0 ..< ts.node_child_count(root) {
child := ts.node_child(root, u32(i)) child := ts.node_child(root, u32(i))
if ts.node_has_error(child) { if ts.node_has_error(child) {
line := find_first_error_line(child) line := find_first_error_line(child)
@@ -28,55 +28,66 @@ find_first_error_line :: proc(root: ts.Node) -> int {
return 0 return 0
} }
capture_name_to_css :: proc(name: string) -> string { write_span_open :: proc(b: ^strings.Builder, buf: ^[128]u8, name: string) {
sb := strings.builder_make() pos := 0
seg := strings.builder_make()
prefix := "<span class=\""
for i in 0 ..< len(prefix) {
buf[pos] = prefix[i]
pos += 1
}
first := true first := true
for i in 0..<len(name) { for i in 0 ..< len(name) {
if name[i] == '.' { if name[i] == '.' {
if !first do strings.write_byte(&sb, ' ') if !first {buf[pos] = ' '; pos += 1}
first = false first = false
strings.write_string(&sb, "hl-") buf[pos] = 'h'; buf[pos + 1] = 'l'; buf[pos + 2] = '-'; pos += 3
strings.write_string(&sb, strings.to_string(seg)) for j in 0 ..< i {
strings.write_byte(&seg, '-') buf[pos] = '-' if name[j] == '.' else name[j]
} else { pos += 1
strings.write_byte(&seg, name[i]) }
} }
} }
if !first do strings.write_byte(&sb, ' ') if !first {buf[pos] = ' '; pos += 1}
strings.write_string(&sb, "hl-") buf[pos] = 'h'; buf[pos + 1] = 'l'; buf[pos + 2] = '-'; pos += 3
strings.write_string(&sb, strings.to_string(seg)) for j in 0 ..< len(name) {
return strings.to_string(sb) buf[pos] = '-' if name[j] == '.' else name[j]
pos += 1
}
buf[pos] = '"'; pos += 1
buf[pos] = '>'; pos += 1
strings.write_string(b, string(buf[:pos]))
} }
escape_html :: proc(s: string) -> string { write_escaped :: proc(b: ^strings.Builder, s: string) {
sb := strings.builder_make()
defer strings.builder_destroy(&sb)
start := 0 start := 0
for i in 0..<len(s) { for i in 0 ..< len(s) {
switch s[i] { switch s[i] {
case '&': case '&':
if i > start do strings.write_string(&sb, s[start:i]) if i > start do strings.write_string(b, s[start:i])
strings.write_string(&sb, "&amp;") strings.write_string(b, "&amp;")
start = i + 1 start = i + 1
case '<': case '<':
if i > start do strings.write_string(&sb, s[start:i]) if i > start do strings.write_string(b, s[start:i])
strings.write_string(&sb, "&lt;") strings.write_string(b, "&lt;")
start = i + 1 start = i + 1
case '>': case '>':
if i > start do strings.write_string(&sb, s[start:i]) if i > start do strings.write_string(b, s[start:i])
strings.write_string(&sb, "&gt;") strings.write_string(b, "&gt;")
start = i + 1 start = i + 1
case '"': case '"':
if i > start do strings.write_string(&sb, s[start:i]) if i > start do strings.write_string(b, s[start:i])
strings.write_string(&sb, "&quot;") strings.write_string(b, "&quot;")
start = i + 1 start = i + 1
} }
} }
if start == 0 do return s if start == 0 {
if start < len(s) do strings.write_string(&sb, s[start:]) strings.write_string(b, s)
return strings.to_string(sb) } else if start < len(s) {
strings.write_string(b, s[start:])
}
} }
unescape_html :: proc(s: string) -> string { unescape_html :: proc(s: string) -> string {
@@ -84,19 +95,25 @@ unescape_html :: proc(s: string) -> string {
defer strings.builder_destroy(&sb) defer strings.builder_destroy(&sb)
start := 0 start := 0
for i in 0..<len(s) { for i in 0 ..< len(s) {
if s[i] != '&' do continue if s[i] != '&' do continue
semi := strings.index(s[i:], ";") semi := strings.index(s[i:], ";")
if semi < 0 do break if semi < 0 do break
entity := s[i : i + semi + 1] entity := s[i:i + semi + 1]
replacement := "" replacement := ""
switch entity { switch entity {
case "&amp;": replacement = "&" case "&amp;":
case "&lt;": replacement = "<" replacement = "&"
case "&gt;": replacement = ">" case "&lt;":
case "&quot;": replacement = "\"" replacement = "<"
case "&#39;", "&apos;": replacement = "'" case "&gt;":
case: continue replacement = ">"
case "&quot;":
replacement = "\""
case "&#39;", "&apos;":
replacement = "'"
case:
continue
} }
if i > start do strings.write_string(&sb, s[start:i]) if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, replacement) strings.write_string(&sb, replacement)
@@ -128,21 +145,25 @@ highlight_block :: proc(code: string, lang: string, file_path: string) -> string
if ts.node_has_error(root) { if ts.node_has_error(root) {
line := find_first_error_line(root) line := find_first_error_line(root)
if line > 0 { if line > 0 {
log.warnf("highlight: syntax errors in %s code block at line %d (%s)", lang, line, file_path) log.warnf(
"highlight: syntax errors in %s code block at line %d (%s)",
lang,
line,
file_path,
)
} else { } else {
log.warnf("highlight: syntax errors in %s code block (%s)", lang, file_path) log.warnf("highlight: syntax errors in %s code block (%s)", lang, file_path)
} }
} }
cursor := ts.query_cursor_new() cursor := gc.cursor
if cursor == nil { if cursor == nil {
return code return code
} }
defer ts.query_cursor_delete(cursor)
ts.query_cursor_exec(cursor, gc.query, root) ts.query_cursor_exec(cursor, gc.query, root)
captures: [dynamic]Capture captures := make([dynamic]Capture, 0, 64, context.temp_allocator)
defer delete(captures) defer delete(captures)
match: ts.Query_Match match: ts.Query_Match
@@ -162,29 +183,34 @@ highlight_block :: proc(code: string, lang: string, file_path: string) -> string
if len(name_full) > int(name_len) { if len(name_full) > int(name_len) {
name = name_full[:int(name_len)] name = name_full[:int(name_len)]
} }
append(&captures, Capture{ append(
start = ts.node_start_byte(cap.node), &captures,
end = ts.node_end_byte(cap.node), Capture {
name = name, start = ts.node_start_byte(cap.node),
}) end = ts.node_end_byte(cap.node),
name = name,
},
)
} }
if len(captures) == 0 { if len(captures) == 0 {
return code return code
} }
sb := strings.builder_make() sb := strings.builder_make_len(len(code) * 2)
last_pos: u32 = 0 last_pos: u32 = 0
stack: [dynamic]Capture stack := make([dynamic]Capture, 0, 16, context.temp_allocator)
defer delete(stack) defer delete(stack)
buf: [128]u8
for cap in captures { for cap in captures {
for len(stack) > 0 { for len(stack) > 0 {
top := stack[len(stack) - 1] top := stack[len(stack) - 1]
if top.end <= cap.start { if top.end <= cap.start {
if top.end > last_pos { if top.end > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:top.end])) write_escaped(&sb, raw_code[last_pos:top.end])
} }
strings.write_string(&sb, "</span>") strings.write_string(&sb, "</span>")
last_pos = top.end last_pos = top.end
@@ -195,26 +221,25 @@ highlight_block :: proc(code: string, lang: string, file_path: string) -> string
} }
if cap.start > last_pos { if cap.start > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:cap.start])) write_escaped(&sb, raw_code[last_pos:cap.start])
last_pos = cap.start last_pos = cap.start
} }
css_class := capture_name_to_css(cap.name) write_span_open(&sb, &buf, cap.name)
strings.write_string(&sb, fmt.tprintf("<span class=\"%s\">", css_class))
append(&stack, cap) append(&stack, cap)
} }
for len(stack) > 0 { for len(stack) > 0 {
top := pop(&stack) top := pop(&stack)
if top.end > last_pos { if top.end > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:top.end])) write_escaped(&sb, raw_code[last_pos:top.end])
} }
strings.write_string(&sb, "</span>") strings.write_string(&sb, "</span>")
last_pos = top.end last_pos = top.end
} }
if int(last_pos) < len(raw_code) { if int(last_pos) < len(raw_code) {
strings.write_string(&sb, escape_html(raw_code[last_pos:])) write_escaped(&sb, raw_code[last_pos:])
} }
return strings.to_string(sb) return strings.to_string(sb)
@@ -266,7 +291,7 @@ highlight_code :: proc(html: string, file_path: string) -> string {
code := html[code_start:end_idx] code := html[code_start:end_idx]
highlighted := highlight_block(code, lang, file_path) highlighted := highlight_block(code, lang, file_path)
strings.write_string(&sb, fmt.tprintf(`<pre><code class="language-%s">%s</code></pre>`, lang, highlighted)) fmt.sbprintf(&sb, `<pre><code class="language-%s">%s</code></pre>`, lang, highlighted)
pos = end_idx + len(CODE_END) pos = end_idx + len(CODE_END)
} }
@@ -280,3 +305,4 @@ highlight_code :: proc(html: string, file_path: string) -> string {
} }
return strings.to_string(sb) return strings.to_string(sb)
} }
+21
View File
@@ -179,3 +179,24 @@ test_init_site_md_enable_disable :: proc(t: ^testing.T) {
testing.expect(t, .Sidenotes in site.markdown_extensions) testing.expect(t, .Sidenotes in site.markdown_extensions)
} }
@(test)
test_init_site_config_paths :: proc(t: ^testing.T) {
path := write_temp_config("paths", `{
"content_dir": "/custom/content",
"assets_dir": "/custom/assets",
"output_dir": "/custom/output",
"layouts_dir": "/custom/layouts"
}`)
defer os.remove(path)
site: Site
args := []string{"thor", fmt.tprintf("-config:%s", path)}
init_site(&site, args)
defer destroy_site(&site)
testing.expect_value(t, site.content_dir, "/custom/content")
testing.expect_value(t, site.assets_dir, "/custom/assets")
testing.expect_value(t, site.output_dir, "/custom/output")
testing.expect_value(t, site.layouts_dir, "/custom/layouts")
}
+9 -12
View File
@@ -7,10 +7,10 @@ import "core:os"
import "core:strings" import "core:strings"
grammar_dir: string grammar_dir: string
query_dir: string query_dir: string
HTML_HIGHLIGHTS :: #load(#directory + "queries/html/highlights.scm", string) HTML_HIGHLIGHTS :: #load(#directory + "queries/html/highlights.scm", string)
CSS_HIGHLIGHTS :: #load(#directory + "queries/css/highlights.scm", string) CSS_HIGHLIGHTS :: #load(#directory + "queries/css/highlights.scm", string)
Language :: distinct rawptr Language :: distinct rawptr
Parser :: distinct rawptr Parser :: distinct rawptr
@@ -121,6 +121,7 @@ Grammar_Cache :: struct {
language: Language, language: Language,
parser: Parser, parser: Parser,
query: Query, query: Query,
cursor: Query_Cursor,
query_failed: bool, query_failed: bool,
} }
@@ -184,19 +185,15 @@ ensure_parser :: proc(lang: string) -> ^Grammar_Cache {
return nil return nil
} }
so_path := fmt.tprintf("%s/%s.so", grammar_dir, lang) so_path := fmt.caprintf("%s/%s.so", grammar_dir, lang, allocator = context.temp_allocator)
so_c := strings.clone_to_cstring(so_path) handle := dlopen(so_path, RTLD_LAZY)
defer delete(so_c)
handle := dlopen(so_c, RTLD_LAZY)
if handle == nil { if handle == nil {
log.warnf("treesitter: cannot load grammar %s (%s)", lang, so_path) log.warnf("treesitter: cannot load grammar %s (%s)", lang, so_path)
return nil return nil
} }
sym_name := fmt.tprintf("tree_sitter_%s", lang) sym_name := fmt.caprintf("tree_sitter_%s", lang, allocator = context.temp_allocator)
sym_c := strings.clone_to_cstring(sym_name) sym := dlsym(handle, sym_name)
defer delete(sym_c)
sym := dlsym(handle, sym_c)
if sym == nil { if sym == nil {
log.errorf("treesitter: cannot find symbol %s in %s", sym_name, so_path) log.errorf("treesitter: cannot find symbol %s in %s", sym_name, so_path)
return nil return nil
@@ -240,8 +237,7 @@ load_grammar :: proc(lang: string) -> ^Grammar_Cache {
gc.query_failed = true gc.query_failed = true
return nil return nil
} }
query_c := strings.clone_to_cstring(query_src) query_c := strings.clone_to_cstring(query_src, context.temp_allocator)
defer delete(query_c)
err_offset: u32 err_offset: u32
err_type: Query_Error err_type: Query_Error
@@ -299,6 +295,7 @@ load_grammar :: proc(lang: string) -> ^Grammar_Cache {
} }
gc.query = query gc.query = query
gc.cursor = query_cursor_new()
return gc return gc
} }