Skip to content

v.flat #

node_payload_table_load reads the published table pointer, or nil. The pointer goes through the integer atomics of its width, like sync.stdatomic: the header's atomic_load_ptr is a bare C11 generic on some compilers. The slot address is passed as an explicit voidptr, because the atomics take a uint64_t*/uint32_t* in C, and a bare &g_node_payload_table would reach them as an incompatible NodePayloadTable**.

Constants #

const empty_node = NodeId(-1)
const method_value_borrow_receiver_marker = '__v3_method_value_borrow_receiver'
const method_value_clone_receiver_marker_prefix = '__v3_method_value_clone_receiver:'
const node_flag_skip_ownership_drops = u8(1)

node_flag_skip_ownership_drops marks a block/if/for/fn node whose scope must not consume ownership drops (see Node.skip_ownership_drops()).

const node_flag_static_type_method = u8(2)

node_flag_static_type_method marks a fn Type.method() declaration.

const node_flag_embed_payload = u8(4)

node_flag_embed_payload marks the string literal holding the bytes that $embed_file materialized (see Node.is_embed_payload()).

const node_flag_freed_assignment = u8(8)

node_flag_freed_assignment marks an assignment annotated with @[freed].

const node_flag_mut_builtin_pointer_param = u8(16)

node_flag_mut_builtin_pointer_param marks a source mut p voidptr/byteptr/charptr parameter before the parser folds its mutable caller slot into the type text.

const node_flag_literal_interpolation_text = u8(32)

node_flag_literal_interpolation_text marks a string literal whose source token contained ${...} as literal text (for example, \${name} or a raw string).

const node_flag_detached_spawn = u8(64)

node_flag_detached_spawn marks a spawn whose thread handle is discarded, so the backend starts its thread detached (see Node.is_detached_spawn()).

fn child_count #

fn child_count(count int) i32

child_count converts a dynamic child count to Node's compact storage type.

fn clone_node_flags #

@[inline]
fn clone_node_flags(source &Node, skip_ownership_drops bool) u8

clone_node_flags rebuilds the flags of a node copied from source.

skip_ownership_drops describes the scope a node sits in, so a copy is given whatever its new position calls for. The rest describe the node itself and have to survive being copied: a generic specialization that dropped node_flag_embed_payload would turn the payload back into an ordinary literal, which the backend would then intern and spell out in full. Assignment attributes likewise remain attached when a statement is specialized.

fn comptime_skipped_decl_key #

fn comptime_skipped_decl_key(file string, name string) string

comptime_skipped_decl_key returns the comptime_skipped_decl_names key for name spelled in file. A private function or constant is only usable from its own module, so the checker only probes that module's files. Files, not module names, key the record: the loader may later rename a module to its import path, but it never renames a file.

fn decode_static_type_method_name #

fn decode_static_type_method_name(name string) ?(string, string)

decode_static_type_method_name recovers the receiver and method from an internal static name.

fn encode_static_type_method_name #

fn encode_static_type_method_name(receiver string, method string) string

encode_static_type_method_name makes a reversible internal name for a static type method.

fn node_flags #

@[inline]
fn node_flags(skip_ownership_drops bool, is_static_type_method bool) u8

node_flags packs rare node bools into Node.flags.

fn node_kind_from_id #

@[inline]
fn node_kind_from_id(id int) NodeKind

node_kind_from_id converts node kind from id data for flat.

fn node_payload #

fn node_payload(generic_params []string) u32

node_payload registers an uncommon node payload and returns its id, or 0 for an empty list.

fn node_payload_at #

fn node_payload_at(id u32) &NodePayload

node_payload_at resolves a payload id registered by node_payload; 0 yields nil.

fn FlatAst.new #

fn FlatAst.new() FlatAst

new creates a FlatAst value for flat.

type NodeId #

type NodeId = i32

NodeId aliases node id values used by flat. Node ids index FlatAst.nodes, so i32 is ample and keeps children (one entry per AST edge) and every node-id side table half the size of a 64-bit int.

type TextId #

type TextId = u32

TextId is the stable identity of one canonical AST text value.

enum NodeKind #

enum NodeKind as u8 {
	empty
	// expressions
	int_literal
	float_literal
	bool_literal
	char_literal
	string_literal
	string_interp
	ident
	infix
	prefix
	postfix
	paren
	call
	selector
	index
	if_expr
	struct_init
	field_init
	array_literal
	array_init
	map_init
	fn_literal
	or_expr
	cast_expr
	as_expr
	enum_val
	assoc
	range
	nil_literal
	none_expr
	spawn_expr
	lock_expr
	lambda_expr
	sizeof_expr
	typeof_expr
	dump_expr
	offsetof_expr
	is_expr
	in_expr
	// statements
	expr_stmt
	assign
	decl_assign
	selector_assign
	index_assign
	return_stmt
	block
	for_stmt
	for_in_stmt
	break_stmt
	continue_stmt
	match_stmt
	match_branch
	defer_stmt
	assert_stmt
	goto_stmt
	label_stmt
	select_stmt
	select_branch
	comptime_if
	comptime_for
	asm_stmt
	// declarations
	fn_decl
	struct_decl
	field_decl
	global_decl
	const_decl
	const_field
	enum_decl
	enum_field
	type_decl
	interface_decl
	interface_field
	import_decl
	module_decl
	directive
	param
	c_fn_decl
	// top-level
	file
	sql_expr
	// A `$veb.html(...)` / `$tmpl(...)` comptime-template placeholder. It is produced
	// by parse_comptime_expr and fully expanded (into builder statements) by
	// parse_block_body at parse time, so it never reaches later phases.
	veb_template
	// A `$res()` / `$res(index)` expression. This must remain distinct from
	// `.ident` so user-spellable names cannot be reinterpreted as defer results.
	defer_result
	// A `$dbg;` statement. Keep new node kinds at the end because the hot phase
	// dispatchers use stable numeric ids for the older kinds.
	debugger_stmt
}

NodeKind lists node kind values used by flat.

enum Op #

enum Op as u8 {
	none
	plus
	minus
	mul
	div
	mod
	eq
	ne
	lt
	gt
	le
	ge
	amp
	pipe
	xor
	left_shift
	right_shift
	right_shift_unsigned
	logical_and
	logical_or
	not
	bit_not
	assign
	plus_assign
	minus_assign
	mul_assign
	div_assign
	mod_assign
	amp_assign
	pipe_assign
	xor_assign
	left_shift_assign
	right_shift_assign
	right_shift_unsigned_assign
	inc
	dec
	dot
	arrow
	gated_index
	power
	power_assign
}

Op lists op values used by flat.

struct Comment #

struct Comment {
pub:
	text string
	pos  token.Pos
}

Comment retains source comments for syntax-preserving tools such as vfmt.

struct FlatAst #

@[heap]
struct FlatAst {
pub mut:
	nodes           []Node
	children        []NodeId
	user_code_start int
	disabled_fns    map[string]bool
	// The names spelled inside a `$if`/`$match` body this build does not take,
	// keyed by `<file>:<fn name offset>|<name>`. The body is never parsed, so
	// nothing in the AST records its identifier occurrences or reads.
	comptime_skipped_names      map[string]bool
	comptime_skipped_read_names map[string]bool
	// Every name spelled in such a skipped body, whatever it refers to, keyed by
	// comptime_skipped_decl_key. A function or constant used only on another
	// target is not unused.
	comptime_skipped_decl_names map[string]bool
	// Goto label operands use the same key format, but are not local-name uses.
	comptime_skipped_goto_labels map[string]bool
	export_fn_names              map[string]string
	noreturn_fns                 map[string]bool
	source_files                 map[int]&token.File
	// resolved_source_paths maps source paths to their resolved form. The owning
	// thread fills it through record_source_path and resolve_source_paths, which
	// sets source_paths_frozen; from then on it is only read, see real_source_path.
	resolved_source_paths map[string]string
	source_paths_frozen   bool
	comments              []Comment
	// formatter_sources retains exact source spans or prefixes for constructs whose
	// source syntax is intentionally opaque to compiler backends.
	formatter_sources      map[int]string
	formatter_file_sources map[int]string
	// formatter_node_ends retains the full source end for nodes whose compiler-facing
	// position deliberately covers only their name or another diagnostic token.
	formatter_node_ends map[int]int
	// formatter_expanded_calls records calls whose arguments started on the next line
	// and ended with a trailing comma.
	formatter_expanded_calls map[int]bool
	// formatter_assignment_ops retains compound operator spellings that share one flat op.
	formatter_assignment_ops map[int]string
	// formatter_param_list_end retains the closing-parenthesis offset for parameter lists.
	formatter_param_list_end map[int]int
	// formatter_for_in_mut retains which for-in binder carried `mut`:
	// bit 0 is the first binder and bit 1 is the second binder.
	formatter_for_in_mut map[int]u8
	// formatter_local_sels records selectors whose direct receiver is a lexical binding.
	formatter_local_sels    map[int]bool
	formatter_migrate_json2 bool
	// Template-generated nodes keep their original template source location while
	// retaining the comptime call site used for v1-compatible diagnostic stacks.
	template_call_sites map[int]token.Pos
	template_actions    map[int]string
	// missing_imports retains source import paths for unresolved import nodes.
	missing_imports map[int]string
	// missing_import_hints holds the migration hint the resolver produced for an
	// unresolved import node, when it can explain the failure. Usually empty.
	missing_import_hints map[int]string
	// resolved_module_dirs maps canonical module identities to their resolved directories.
	resolved_module_dirs map[string]string
	// cached_header_sources maps each module cache header parsed in place of a
	// module's sources to one of those sources, so diagnostics and ownership can
	// judge a warm header by the code it stands for rather than by where the
	// cache directory happens to be.
	cached_header_sources map[string]string
	// file_node_ids records every .file node the parser creates, in creation
	// order: (marker, trailing) pairs per source file. The trailing node's
	// children are the file's top-level declarations, letting collect build
	// its top-level index without a full node scan. Stages that renumber
	// nodes clear the list; consumers fall back to scanning when it is empty
	// or file_index_incomplete is set (a source file failed to read).
	file_node_ids         []i32
	file_index_incomplete bool
	// has_vsh_source records that at least one parsed source file is a `.vsh`
	// script. V script mode makes the `os` module global inside such files, and
	// the checker only pays for that lookup when this flag is set.
	has_vsh_source bool
	// raw_vsh_file is the parsed path of the script compiled without the `.vsh`
	// extension (`-raw-vsh-tmp-prefix`), if any.
	raw_vsh_file string
	// source_buffers owns the storage behind zero-copy scanner strings retained
	// by AST nodes. Keeping the buffers on the AST makes the lifetime boundary
	// explicit and lets parser workers transfer ownership with their nodes.
	source_buffers []string
	// text_values/text_ids own one canonical copy of every non-empty string
	// stored in a node payload. Nodes keep string compatibility views while
	// semantic/compiler caches can use compact TextId identities.
	text_values []string
	text_ids    map[string]TextId
	worker_pool &workers.Pool = unsafe { nil }
	// contextual_anon_struct_types names the anonymous aggregates the parser
	// synthesized to type a `struct { ... }` literal rather than to declare a field.
	// Such a name only ever stands in for a literal whose type the context supplies, so
	// the checker lets it initialize another module's anonymous field. The declarations
	// themselves keep the visibility they were written with, so neither their generated
	// names nor a type a user happened to call `AnonStruct_...` become reachable across
	// module boundaries.
	contextual_anon_struct_types map[string]bool
	// synthesized_anon_struct_types names every anonymous aggregate the parser made up,
	// for a declaration as well as for a literal. A name matching `AnonStruct_` proves
	// nothing on its own - a user may declare a type so named - so this is what tells
	// the checker that an expected type really is one it may adopt a bare
	// `struct { ... }` literal into.
	synthesized_anon_struct_types map[string]bool
	// specialized_fn_nodes identifies program-specific monomorphized function
	// declarations appended after parsing. Module-cache cgen keeps them with main.
	specialized_fn_nodes   map[int]bool
	specialized_fn_modules map[int]string
	specialized_fn_files   map[int]string
}

FlatAst represents flat ast data used by flat.

fn (FlatAst) close_workers #

fn (mut a FlatAst) close_workers()

close_workers stops the compilation-owned persistent worker pool.

fn (FlatAst) ensure_workers #

fn (mut a FlatAst) ensure_workers(count int)

ensure_workers creates the compilation-owned persistent worker pool when needed.

fn (FlatAst) worker_count #

fn (a &FlatAst) worker_count() int

worker_count reports the number of persistent compiler helper threads.

fn (FlatAst) worker_tasks_run #

fn (a &FlatAst) worker_tasks_run() u64

worker_tasks_run reports completed callbacks across all parallel phases.

fn (FlatAst) worker_stats #

fn (a &FlatAst) worker_stats() workers.Stats

worker_stats reports scheduling and utilization across compiler phases.

fn (FlatAst) set_node_is_mut #

fn (mut a FlatAst) set_node_is_mut(id NodeId, is_mut bool)

set_node_is_mut updates a node's mut declaration marker in place.

fn (FlatAst) intern_text #

fn (mut a FlatAst) intern_text(value string) (TextId, string)

intern_text returns the canonical AST-owned copy and stable identity of text.

fn (FlatAst) intern_texts_from #

fn (mut a FlatAst) intern_texts_from(source &FlatAst)

intern_texts_from replays the source AST's compact text table into a. The source table is already ordered by first occurrence, so this preserves deterministic text identities while avoiding a second walk over every node.

fn (FlatAst) reserve_transform_texts #

fn (mut a FlatAst) reserve_transform_texts(headroom int)

reserve_transform_texts keeps canonical text-table backing in the compilation arena before a disposable transform scope starts.

fn (FlatAst) promote_transform_texts_from #

fn (mut a FlatAst) promote_transform_texts_from(start int, scope voidptr)

promote_transform_texts_from moves canonical strings inserted by a scoped transform into the current arena and rebuilds table backing only if it grew into the disposable scope.

fn (FlatAst) clone_text_table_owned #

fn (a &FlatAst) clone_text_table_owned() ([]string, map[string]TextId)

clone_text_table_owned copies the canonical text table and rebuilds its lookup map with storage owned by the current allocation arena.

fn (FlatAst) text #

fn (a &FlatAst) text(id TextId) string

text resolves a stable AST text identity.

fn (FlatAst) text_count #

fn (a &FlatAst) text_count() int

text_count returns the number of unique non-empty AST text values.

fn (FlatAst) type_text_id #

fn (a &FlatAst) type_text_id(value string) u16

type_text_id returns the compact canonical identity for value, or 0 when it has not been interned or the compilation's text table exceeds u16 range.

fn (FlatAst) node_type_text_id #

@[inline]
fn (a &FlatAst) node_type_text_id(value string, current u16) u16

node_type_text_id preserves an already-matching identity without a map lookup. A changed or newly synthesized spelling is assigned during the ordered canonicalization pass after worker merge.

fn (FlatAst) intern_node_texts_from #

fn (mut a FlatAst) intern_node_texts_from(start int)

intern_node_texts_from canonicalizes managed payloads in nodes[start..]. This runs serially after parse/transform worker merges, so the text table itself requires no synchronization and cannot retain worker-arena storage.

fn (FlatAst) intern_node_texts_range #

fn (mut a FlatAst) intern_node_texts_range(start int, end int)

intern_node_texts_range canonicalizes managed payloads in nodes[start..end). The bounded form serves the parallel parse merge, where later chunks are already present in the node array but must be interned in chunk order.

fn (FlatAst) intern_node_texts_at #

fn (mut a FlatAst) intern_node_texts_at(indexes []int)

intern_node_texts_at canonicalizes only the listed node indexes (in list order). The parallel parse merge pre-rebinds table hits on the copy threads and records the remaining nodes here, so canonical-table insertion order still matches the serial merge exactly.

fn (FlatAst) intern_type_text_ptr_cached #

fn (mut a FlatAst) intern_type_text_ptr_cached(value string, mut cache_ptrs [4096]voidptr, mut cache_vals [4096]string, mut cache_ids [4096]u16) (u16, string)

intern_type_text_ptr_cached canonicalizes a node type spelling and returns its compact identity. Programs with more than 65535 texts use 0 and retain the existing string-keyed fallback.

fn (FlatAst) probe_text_ptr_cached #

fn (a &FlatAst) probe_text_ptr_cached(value string, mut cache_ptrs [4096]voidptr, mut cache_vals [4096]string) (string, bool)

probe_text_ptr_cached is the read-only twin of intern_text_ptr_cached: it rebinds value to its canonical copy when the text table already holds the content and reports a miss otherwise, never mutating the table. Safe on worker threads only while no thread inserts into the table.

fn (FlatAst) probe_type_text_ptr_cached #

fn (a &FlatAst) probe_type_text_ptr_cached(value string, mut cache_ptrs [4096]voidptr, mut cache_vals [4096]string, mut cache_ids [4096]u16) (u16, string, bool)

probe_type_text_ptr_cached is the compact-id form used while parallel parse workers probe the frozen master text table.

fn (FlatAst) intern_text_ptr_cached #

fn (mut a FlatAst) intern_text_ptr_cached(value string, mut cache_ptrs [4096]voidptr, mut cache_vals [4096]string) string

intern_text_ptr_cached interns text using a caller-owned scratch cache.

fn (FlatAst) intern_metadata_texts #

fn (mut a FlatAst) intern_metadata_texts()

intern_metadata_texts canonicalizes all source-derived FlatAst map keys and values. Once this and intern_node_texts_from have run, source buffers are no longer part of the AST representation and can be released.

fn (FlatAst) source_position #

fn (a &FlatAst) source_position(pos token.Pos) ?token.Position

source_position resolves an AST source position to file/line/column metadata.

fn (FlatAst) add #

fn (mut a FlatAst) add(kind NodeKind) NodeId

add updates add state for FlatAst.

fn (FlatAst) add_id #

fn (mut a FlatAst) add_id(kind_id int) NodeId

add_id updates add id state for FlatAst.

fn (FlatAst) add_val #

fn (mut a FlatAst) add_val(kind NodeKind, value string) NodeId

add_val updates add val state for FlatAst.

fn (FlatAst) add_val_id #

fn (mut a FlatAst) add_val_id(kind_id int, value string) NodeId

add_val_id updates add val id state for FlatAst.

fn (FlatAst) add_node #

fn (mut a FlatAst) add_node(node Node) NodeId

add_node updates add node state for FlatAst.

fn (FlatAst) begin_children #

fn (mut a FlatAst) begin_children() int

begin_children supports begin children handling for FlatAst.

fn (FlatAst) add_child #

fn (mut a FlatAst) add_child(id NodeId)

add_child updates add child state for FlatAst.

fn (FlatAst) child #

@[direct_array_access; inline]
fn (a &FlatAst) child(node &Node, index int) NodeId

child supports child handling for FlatAst.

fn (FlatAst) child_node #

@[direct_array_access; inline]
fn (a &FlatAst) child_node(node &Node, index int) &Node

child_node supports child node handling for FlatAst.

fn (FlatAst) node #

@[direct_array_access; inline]
fn (a &FlatAst) node(id NodeId) &Node

node supports node handling for FlatAst.

fn (FlatAst) children_of #

fn (a &FlatAst) children_of(node &Node) []NodeId

children_of supports children of handling for FlatAst.

fn (FlatAst) print_tree #

fn (a &FlatAst) print_tree(id NodeId, indent int)

print_tree updates print tree state for FlatAst.

fn (FlatAst) record_source_path #

fn (mut a FlatAst) record_source_path(path string) string

record_source_path returns os.real_path(path) through the table of resolved source paths. Until resolve_source_paths freezes the table it also adds the missing answers, so only the thread that owns the AST may call it, outside any disposable allocation scope. Once the table is frozen it only reads it, like real_source_path.

fn (FlatAst) resolve_source_paths #

fn (mut a FlatAst) resolve_source_paths()

resolve_source_paths completes the table of resolved source paths with every parsed source file, reusing the answers record_source_path already recorded, and freezes it: nothing adds to it afterwards, including another call to resolve_source_paths. Call it on the thread that owns the AST, outside any disposable allocation scope and before other threads read the AST.

fn (FlatAst) real_source_path #

fn (a &FlatAst) real_source_path(path string) string

real_source_path returns the same result as os.real_path(path), answering from the table of resolved source paths when it can. It never adds to the table, so any thread may call it once resolve_source_paths has frozen it.

fn (FlatAst) discard_unused_capacity #

@[unsafe]
fn (a &FlatAst) discard_unused_capacity()

discard_unused_capacity releases resident pages beyond the live AST after parallel workers have joined and their append regions have been compacted. The virtual reservation stays writable, so later appends retain their capacity. No surviving view may read or write beyond this AST's live array lengths.

struct Node #

struct Node {
pub mut:
	value          string
	typ            string
	children_start i32
	payload        u32 // node_payload() id, 0 = none; see payload_ptr/generic_params
	is_mut         bool
	kind           NodeKind
	op             Op
	flags          u8 // node_flag_* bits; see skip_ownership_drops/is_static_type_method
	children_count i32
	pos            token.Pos
}

Node represents node data used by flat.

fn (Node) is_detached_spawn #

@[inline]
fn (n &Node) is_detached_spawn() bool

is_detached_spawn reports whether this spawn has its thread handle discarded: nothing can join its thread, so the backend starts it detached.

fn (Node) skip_ownership_drops #

@[inline]
fn (n &Node) skip_ownership_drops() bool

skip_ownership_drops reports whether this scope node must not consume ownership drops.

fn (Node) set_skip_ownership_drops #

@[inline]
fn (mut n Node) set_skip_ownership_drops(value bool)

set_skip_ownership_drops updates the skip-ownership-drops flag.

fn (Node) is_static_type_method #

@[inline]
fn (n &Node) is_static_type_method() bool

is_static_type_method reports whether this fn_decl is a fn Type.method().

fn (Node) is_embed_payload #

@[inline]
fn (n &Node) is_embed_payload() bool

is_embed_payload reports whether this string literal holds the bytes that $embed_file materialized. Such a literal is written out by the embed codegen alone, so it must be kept out of the interned literal table: an entry there is never referenced, and it repeats the whole payload on a single source line.

fn (Node) is_freed_assignment #

@[inline]
fn (n &Node) is_freed_assignment() bool

is_freed_assignment reports whether an assignment has the @[freed] attribute.

fn (Node) has_literal_interpolation_text #

@[inline]
fn (n &Node) has_literal_interpolation_text() bool

has_literal_interpolation_text reports whether ${...} in this string literal was parsed as text and must not be reinterpreted by nested-interpolation lowering.

fn (Node) is_mut_builtin_pointer_param #

@[inline]
fn (n &Node) is_mut_builtin_pointer_param() bool

is_mut_builtin_pointer_param reports whether this parameter was declared as mut p voidptr, mut p byteptr, or mut p charptr in source.

fn (Node) set_freed_assignment #

@[inline]
fn (mut n Node) set_freed_assignment(value bool)

set_freed_assignment updates the assignment's @[freed] marker.

fn (Node) set_is_static_type_method #

@[inline]
fn (mut n Node) set_is_static_type_method(value bool)

set_is_static_type_method updates the static-type-method flag.

fn (Node) payload_ptr #

@[inline]
fn (n &Node) payload_ptr() &NodePayload

payload_ptr resolves this node's uncommon payload, or nil when it has none.

fn (Node) type_text_id #

@[inline]
fn (n &Node) type_text_id() u16

type_text_id returns the compact canonical identity carried in this node's otherwise-unused source-position metadata.

fn (Node) set_type_text_id #

@[inline]
fn (mut n Node) set_type_text_id(id u16)

set_type_text_id updates the compact type identity without disturbing a diagnostic reported-column override.

fn (Node) generic_params #

@[inline]
fn (n &Node) generic_params() []string

generic_params returns this node's uncommon generic/attribute metadata.

fn (Node) set_generic_params #

@[inline]
fn (mut n Node) set_generic_params(params []string)

set_generic_params replaces this node's uncommon managed payload.

fn (Node) with_shifted_children #

fn (n Node) with_shifted_children(shift i32) Node

with_shifted_children returns a copy of the node whose children_start is moved by shift. children_start lives in the immutable section of Node, so callers that relocate a node's children block (e.g. the parallel-transform merge) build a fresh node instead of mutating in place.

fn (Node) with_pos #

fn (n Node) with_pos(pos token.Pos) Node

with_pos returns a copy of the node with source position pos.

fn (Node) clone_owned #

fn (n Node) clone_owned() Node

clone_owned returns a deep copy whose managed text and payload survive scoped arenas.

struct NodePayload #

@[heap]
struct NodePayload {
pub:
	generic_params []string
}

NodePayload holds the uncommon managed fields used only by declarations and a handful of lowering markers. Keeping it out of Node makes the hot flat header substantially smaller without changing string-facing phase APIs.

struct TextProbeCache #

struct TextProbeCache {
pub mut:
	ptrs   [4096]voidptr
	values [4096]string
	ids    [4096]u16
}

TextProbeCache holds per-pass scratch on the stack. Its strings borrow canonical text owned by the AST; the cache itself never escapes the pass.