logicaffeine_compile/analysis/assemble.rs
1//! Source-level assembly of a multi-file / multi-directory LOGOS project.
2//!
3//! A project is ONE compilation unit. [`assemble_project`] resolves the entry
4//! file's explicit imports (markdown links in its abstract), transitively loads
5//! every reachable module, and splices their code into a single combined source
6//! string — the same string-level seam the stdlib prelude already uses
7//! ([`apply_prelude`](crate::loader::apply_prelude)). Because every execution
8//! tier (AOT, interpreter, VM, wasm, isa, C) re-parses from a source string, one
9//! assembled string feeds them all: the imported functions and types reach the
10//! parser, the call graph, and codegen exactly as if the program had been
11//! written in a single file.
12//!
13//! ## Ordering
14//!
15//! Modules are collected in **post-order DFS** over the import graph, so a
16//! module's dependencies are spliced before it and the entry's own body (with
17//! its `## Main`) comes last. The tree-walking interpreter registers each
18//! `## To` definition as it walks and has no hoisting pass, so "definitions
19//! before the Main that uses them" must hold structurally — post-order gives it.
20//!
21//! ## Cycle & diamond safety
22//!
23//! Collection dedups by **canonical path** through a `visited` set, seeded with
24//! the entry. Mutual imports (`A` uses `B`, `B` uses `A`) are legal — required
25//! for cross-file mutual recursion — and contribute each module's code exactly
26//! once. A module that imports the entry back never re-splices the entry's
27//! `## Main`.
28//!
29//! ## Foreign `## Main`
30//!
31//! Only the ENTRY's `## Main` survives; an imported module contributes its
32//! functions and type definitions but never a `## Main` (stripped by
33//! `strip_main_block`). This preserves the single-entry-Main contract every
34//! tier relies on.
35
36use std::collections::{HashMap, HashSet};
37use std::path::{Path, PathBuf};
38
39use logicaffeine_base::{Interner, Symbol};
40use logicaffeine_language::ast::stmt::Stmt;
41use logicaffeine_language::token::{RawKind, Token, TokenType};
42
43use crate::analysis::{first_section_header, misplaced_imports, scan_dependencies};
44use crate::loader::{
45 defined_names_with_visibility, function_header, module_code, strip_note_blocks, DepRoot, Loader,
46};
47use crate::Lexer;
48use logicaffeine_language::analysis::SelectedName;
49
50/// One assembled compilation unit: the combined source plus the metadata later
51/// passes need for qualified-name resolution, collision diagnostics, and
52/// cross-file error attribution.
53pub struct AssembledProgram {
54 /// The single combined source string: the entry (with its leading prose and
55 /// `## Main`) with every imported module's code spliced in before its first
56 /// `##` section, dependencies first. Fed verbatim to the existing
57 /// lex/parse/codegen seam.
58 pub source: String,
59 /// Which module defines each bare name — the ground truth for the
60 /// hard-error on an ambiguous bare reference and for qualified→canonical
61 /// resolution.
62 pub provenance: Provenance,
63 /// Maps a byte offset in [`source`](Self::source) back to the file it came
64 /// from, so a diagnostic in an imported module reports against THAT file
65 /// rather than the combined blob.
66 pub source_map: SourceMap,
67 /// Per-file name scope — what each file may reference (its own definitions
68 /// plus the exports of the modules it explicitly imports). Drives
69 /// [`check_scopes`], which forbids transitive leakage.
70 pub scope: ScopeInfo,
71}
72
73/// Per-file lexical scope for the whole project: what each file is allowed to
74/// reference. A file sees its own definitions and the exports of the modules it
75/// *directly* imports — never anything reached only transitively.
76#[derive(Debug, Default)]
77pub struct ScopeInfo {
78 /// Module canonical path → the names in scope for code in that file (own
79 /// definitions ∪ directly-imported modules' exports).
80 in_scope: HashMap<PathBuf, HashSet<String>>,
81 /// Every name that is *some* user module's export — the only names a scope
82 /// violation can be reported for (builtins/stdlib/unknowns are never
83 /// flagged, so the check cannot false-positive a valid program).
84 all_exports: HashSet<String>,
85 /// Name → the module that defines it (for the diagnostic).
86 defn_file: HashMap<String, PathBuf>,
87 /// Names declared `private` by their module — reachable only from the module
88 /// that owns them. A reference from any other file is a hard error.
89 private_names: HashSet<String>,
90 /// Original private name → the module that owns it (for the diagnostic).
91 private_owner: HashMap<String, PathBuf>,
92 /// Per-name import aliases (`Import double as dbl from Util.`): surface alias
93 /// → real exported name. Rewritten to the real name before every scope check
94 /// and codegen so a `dbl` reference resolves to `double`.
95 selective_aliases: HashMap<String, String>,
96 /// The entry file — the home of the top-level `## Main` body.
97 entry: PathBuf,
98}
99
100impl ScopeInfo {
101 /// The project's entry file (home of the top-level `## Main`).
102 pub fn entry(&self) -> &Path {
103 &self.entry
104 }
105
106 /// Per-name import aliases: surface alias → real exported name.
107 pub fn selective_aliases(&self) -> &HashMap<String, String> {
108 &self.selective_aliases
109 }
110}
111
112/// Enforce per-file scoping over the assembled program: a reference to a name
113/// that is another user module's export, from a file that neither defines nor
114/// imports it, is a hard error (no transitive leakage). Builtins, stdlib, and a
115/// file's own/imported names are always fine, so a valid program never trips.
116pub fn check_scopes(
117 stmts: &[Stmt<'_>],
118 scope: &ScopeInfo,
119 interner: &Interner,
120) -> Result<(), String> {
121 use crate::analysis::callgraph::{collect_calls_from_stmts, collect_type_refs_from_stmts};
122
123 // Each top-level DEFINITION's body is checked against the scope of the file that defines
124 // it — both the functions it CALLS and the types it REFERENCES (constructions,
125 // annotations) — since a private name of either kind is reachable only from its owning
126 // module. `defining_module_key` says which statements carry their own scope this way;
127 // everything else falls to the entry-file bucket below, exactly like plain `## Main` code.
128 for stmt in stmts {
129 let Some(key) = defining_module_key(stmt, interner) else {
130 continue;
131 };
132 let Some(file) = scope.defn_file.get(&key) else {
133 continue;
134 };
135 let mut refs = HashSet::new();
136 if let Stmt::FunctionDef { body, .. } = stmt {
137 collect_calls_from_stmts(body, &mut refs);
138 collect_type_refs_from_stmts(body, &mut refs);
139 } else {
140 let one = std::slice::from_ref(stmt);
141 collect_calls_from_stmts(one, &mut refs);
142 collect_type_refs_from_stmts(one, &mut refs);
143 }
144 check_call_set(&refs, file, scope, interner)?;
145 }
146 // The top-level `## Main` body belongs to the entry file — everything
147 // `defining_module_key` does not itself attribute.
148 let mut main_refs = HashSet::new();
149 for stmt in stmts {
150 if defining_module_key(stmt, interner).is_none() {
151 collect_calls_from_stmts(std::slice::from_ref(stmt), &mut main_refs);
152 collect_type_refs_from_stmts(std::slice::from_ref(stmt), &mut main_refs);
153 }
154 }
155 check_call_set(&main_refs, &scope.entry, scope, interner)
156}
157
158/// The key `scope.defn_file` records for a top-level `Stmt`'s OWN definition, when it has one —
159/// the file whose scope its body must be checked against, rather than the entry's. `None` means
160/// the statement is not itself a named definition another module could import; its calls belong
161/// to whichever surrounding block is checked (today, always the entry's `## Main`).
162///
163/// EXHAUSTIVE, no wildcard: a new `Stmt` variant must say which bucket it belongs to here, so it
164/// can never silently fall into the entry bucket the way `Stmt::ComponentDef` once did — every
165/// call inside a `## Component`'s `Actions:`/`View:` (a `{greeting(count)}` interpolation, an
166/// event handler's own body) was checked against the ENTRY file's imports instead of the
167/// component's OWN module's, because the LGX parser desugars a component to a *structured*
168/// `ComponentDef` node, never the retired dialect's flat `FunctionDef` this dispatch used to
169/// require.
170///
171/// `## Page`/`## Store`/`## Error page` share `ComponentDef`'s shape but are not yet covered here:
172/// their render function (`__render_<name>`, `lgx_lower::render_fn_name`) is real, but
173/// `record_provenance`'s text scan (`component_render_name`) only recognizes a literal
174/// `"## Component "` header, so `defn_file` never gains an entry for them — the lookup above
175/// finds nothing and the call falls through to the entry bucket, same as before this fix (a
176/// documented gap, not a silent one). `## Route`/`## Directive`/
177/// `## Before each request`/`## Redirect` are deliberately `None`: none of them exports a NAME
178/// another module calls (a route answers an HTTP method+path, never a LOGOS call site), so the
179/// misattribution this fixes does not apply to them.
180fn defining_module_key(stmt: &Stmt<'_>, interner: &Interner) -> Option<String> {
181 match stmt {
182 Stmt::FunctionDef { name, .. } => Some(interner.resolve(*name).to_string()),
183 Stmt::ComponentDef(def) => Some(format!("__render_{}", interner.resolve(def.name))),
184 Stmt::Let { .. }
185 | Stmt::Set { .. }
186 | Stmt::Swap { .. }
187 | Stmt::Call { .. }
188 | Stmt::If { .. }
189 | Stmt::While { .. }
190 | Stmt::Repeat { .. }
191 | Stmt::Return { .. }
192 | Stmt::Break
193 | Stmt::Assert { .. }
194 | Stmt::Trust { .. }
195 | Stmt::RuntimeAssert { .. }
196 | Stmt::Fail { .. }
197 | Stmt::Give { .. }
198 | Stmt::Show { .. }
199 | Stmt::SetField { .. }
200 | Stmt::StructDef { .. }
201 | Stmt::TestDef { .. }
202 | Stmt::Expect { .. }
203 | Stmt::ExpectFail { .. }
204 | Stmt::ExpectOutput { .. }
205 | Stmt::AdvanceTime { .. }
206 | Stmt::GivenFile { .. }
207 | Stmt::Observe { .. }
208 | Stmt::During { .. }
209 | Stmt::ForAll { .. }
210 | Stmt::Inspect { .. }
211 | Stmt::Push { .. }
212 | Stmt::Pop { .. }
213 | Stmt::Add { .. }
214 | Stmt::Remove { .. }
215 | Stmt::SetIndex { .. }
216 | Stmt::Splice { .. }
217 | Stmt::Reversibly { .. }
218 | Stmt::Zone { .. }
219 | Stmt::Concurrent { .. }
220 | Stmt::Parallel { .. }
221 | Stmt::ReadFrom { .. }
222 | Stmt::WriteFile { .. }
223 | Stmt::Spawn { .. }
224 | Stmt::SendMessage { .. }
225 | Stmt::AwaitMessage { .. }
226 | Stmt::StreamMessage { .. }
227 | Stmt::MergeCrdt { .. }
228 | Stmt::JoinCrdt { .. }
229 | Stmt::IncreaseCrdt { .. }
230 | Stmt::DecreaseCrdt { .. }
231 | Stmt::AppendToSequence { .. }
232 | Stmt::ResolveConflict { .. }
233 | Stmt::Check { .. }
234 | Stmt::Listen { .. }
235 | Stmt::Serve { .. }
236 | Stmt::ConnectTo { .. }
237 | Stmt::LetPeerAgent { .. }
238 | Stmt::Sleep { .. }
239 | Stmt::Sync { .. }
240 | Stmt::Ui(..)
241 | Stmt::Fetch { .. }
242 | Stmt::Replay { .. }
243 | Stmt::ServeFetches
244 | Stmt::GivenFetch { .. }
245 | Stmt::DirectiveDef { .. }
246 | Stmt::RouteDef { .. }
247 | Stmt::MiddlewareDef { .. }
248 | Stmt::RedirectDef { .. }
249 | Stmt::Mount { .. }
250 | Stmt::LaunchTask { .. }
251 | Stmt::LaunchTaskWithHandle { .. }
252 | Stmt::CreatePipe { .. }
253 | Stmt::SendPipe { .. }
254 | Stmt::ReceivePipe { .. }
255 | Stmt::TrySendPipe { .. }
256 | Stmt::TryReceivePipe { .. }
257 | Stmt::StopTask { .. }
258 | Stmt::CloseChannel { .. }
259 | Stmt::AwaitTask { .. }
260 | Stmt::Select { .. }
261 | Stmt::Theorem(..)
262 | Stmt::Definition(..)
263 | Stmt::Axiom(..)
264 | Stmt::Theory(..)
265 | Stmt::Escape { .. }
266 | Stmt::Require { .. }
267 | Stmt::Site(_) => None,
268 }
269}
270
271/// Reject any call in `file` to a user-module export that `file` doesn't have in
272/// scope.
273fn check_call_set(
274 calls: &HashSet<Symbol>,
275 file: &Path,
276 scope: &ScopeInfo,
277 interner: &Interner,
278) -> Result<(), String> {
279 // No recorded scope for this file means we can't determine what it may see —
280 // stay permissive (never raise a false out-of-scope error) rather than flag
281 // every export. Every contributing file is populated in `in_scope`, so this
282 // only guards against an incomplete `ScopeInfo`.
283 let Some(in_scope) = scope.in_scope.get(file) else {
284 return Ok(());
285 };
286 for callee in calls {
287 let name = interner.resolve(*callee);
288 // Qualified `Alias::tail`: a PUBLIC tail was already rewritten to bare by
289 // `resolve_qualified_names`, so a residual one is private-or-unknown. Gate
290 // a private tail the file can't otherwise see (its real definition is
291 // owner-mangled, so it never rewrote to bare), then skip.
292 if let Some((_, tail)) = name.rsplit_once("::") {
293 if !in_scope.contains(tail) {
294 if let Some(err) = private_violation(tail, file, scope) {
295 return Err(err);
296 }
297 }
298 continue;
299 }
300 // A name in scope — this file's own definitions or an imported module's
301 // PUBLIC export — is always fine, even if the SAME spelling is `private` in
302 // some OTHER module. Checking visibility FIRST is what keeps a private name
303 // from poisoning a public name of the same name elsewhere.
304 if in_scope.contains(name) {
305 continue;
306 }
307 // Out of scope: a private name owned by another module is a privacy
308 // violation…
309 if let Some(err) = private_violation(name, file, scope) {
310 return Err(err);
311 }
312 // …and any OTHER module's public export used without importing it is a leak.
313 if scope.all_exports.contains(name) {
314 let owner = scope
315 .defn_file
316 .get(name)
317 .and_then(|p| p.file_name())
318 .and_then(|s| s.to_str())
319 .unwrap_or("another module");
320 let here = file
321 .file_name()
322 .and_then(|s| s.to_str())
323 .unwrap_or("this file");
324 return Err(format!(
325 "`{name}` is used in `{here}` but not imported there — it is defined in `{owner}`. \
326 Import that module (e.g. `Import {}.`), or you cannot reach it.",
327 module_alias_for(owner)
328 ));
329 }
330 }
331 Ok(())
332}
333
334/// A human module alias from a file name (`geometry.lg` → `Geometry`) for the
335/// import hint.
336fn module_alias_for(file_name: &str) -> String {
337 let stem = file_name
338 .rsplit_once('.')
339 .map(|(s, _)| s)
340 .unwrap_or(file_name);
341 let mut chars = stem.chars();
342 match chars.next() {
343 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
344 None => stem.to_string(),
345 }
346}
347
348/// The "private to X" error if `name` is a private name referenced from a file
349/// that does NOT own it; `None` if it isn't private or the owner is referencing
350/// its own private name (always allowed).
351fn private_violation(name: &str, file: &Path, scope: &ScopeInfo) -> Option<String> {
352 if !scope.private_names.contains(name) {
353 return None;
354 }
355 let owner = scope.private_owner.get(name)?;
356 if owner == file {
357 return None;
358 }
359 let owner_name = owner
360 .file_name()
361 .and_then(|s| s.to_str())
362 .unwrap_or("its module");
363 let here = file
364 .file_name()
365 .and_then(|s| s.to_str())
366 .unwrap_or("this file");
367 Some(format!(
368 "`{name}` is private to `{owner_name}` and cannot be used in `{here}`. \
369 Drop `private` from its definition to export it, or call a public \
370 function of `{}` instead.",
371 module_alias_for(owner_name)
372 ))
373}
374
375/// The kind of a top-level definition, for collision messages.
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub enum DefKind {
378 /// A `## To <name>` procedure.
379 Function,
380 /// A `A <Name> has/is …` type.
381 Type,
382}
383
384/// A single definition site: which module (canonical path) declared a bare name,
385/// and the normalized content of the definition (its identity — two defs with the
386/// same normalized body are THE SAME definition).
387#[derive(Debug, Clone)]
388pub struct DefSite {
389 /// Canonical path of the defining module file.
390 pub path: PathBuf,
391 /// Function or type.
392 pub kind: DefKind,
393 /// Whitespace-normalized definition body — the content-identity key.
394 pub body: String,
395}
396
397/// Bare-name → the modules that define it. A bare name owned by more than one
398/// distinct module is *ambiguous*: a bare reference to it is a hard error, and
399/// the qualified form disambiguates.
400#[derive(Debug, Default)]
401pub struct Provenance {
402 defs: std::collections::HashMap<String, Vec<DefSite>>,
403}
404
405impl Provenance {
406 fn record(&mut self, name: &str, site: DefSite) {
407 let entry = self.defs.entry(name.to_string()).or_default();
408 // One module may textually redeclare a name; dedup by path so a single
409 // module never looks like a self-collision.
410 if !entry.iter().any(|s| s.path == site.path) {
411 entry.push(site);
412 }
413 }
414
415 /// The distinct modules that define `name` (empty if none).
416 pub fn definitions(&self, name: &str) -> &[DefSite] {
417 self.defs.get(name).map(|v| v.as_slice()).unwrap_or(&[])
418 }
419
420 /// True when `name` is defined by two or more modules with DIFFERENT bodies —
421 /// a genuine conflict a bare reference can't resolve. Two identical
422 /// definitions (same normalized body) are the same thing and merge, so they
423 /// are NOT ambiguous.
424 pub fn is_ambiguous(&self, name: &str) -> bool {
425 let mut bodies: HashSet<&str> = HashSet::new();
426 for site in self.definitions(name) {
427 bodies.insert(site.body.as_str());
428 }
429 bodies.len() > 1
430 }
431
432 /// The first bare name defined by two or more distinct modules, if any —
433 /// the collision the assembler rejects. Returns `(name, defining files)`.
434 /// Deterministic: names are checked in sorted order so the message is stable.
435 fn first_collision(&self) -> Option<(String, Vec<PathBuf>)> {
436 let mut names: Vec<&String> = self.defs.keys().filter(|n| self.is_ambiguous(n)).collect();
437 names.sort();
438 names.into_iter().next().map(|name| {
439 let mut paths: Vec<PathBuf> = self.defs[name].iter().map(|s| s.path.clone()).collect();
440 paths.sort();
441 (name.clone(), paths)
442 })
443 }
444}
445
446/// One contributed segment of the combined source: its `[start, end)` byte range
447/// in the combined string, the ORIGINAL file it came from, and — for a byte-intact
448/// segment — the offset in that file its `start` corresponds to.
449#[derive(Debug, Clone)]
450pub struct Segment {
451 /// Segment start in the combined source.
452 pub start: usize,
453 /// Segment end (exclusive) in the combined source.
454 pub end: usize,
455 /// The offset in the ORIGINAL file that `start` maps to. Meaningful only when
456 /// `exact`; a transformed module segment leaves it 0.
457 pub user_start: usize,
458 /// The file that contributed this segment.
459 pub file: PathBuf,
460 /// True when the combined text over this segment is byte-identical to the
461 /// original file's text (the entry's own leading prose + code, absent privacy
462 /// mangling), so `user_start + (offset - start)` indexes the original file
463 /// directly. A module segment is transformed (note-block / privacy stripping,
464 /// owner-mangling) and is NOT exact.
465 pub exact: bool,
466}
467
468/// A combined-source offset resolved back to its origin.
469#[derive(Debug, Clone, Copy)]
470pub struct Located<'a> {
471 /// The originating file.
472 pub file: &'a Path,
473 /// Byte offset within the ORIGINAL file (valid to index it) when `exact`.
474 pub user_offset: usize,
475 /// The combined-source offset where the owning segment begins — lets a caller
476 /// compute a best-effort segment-relative line for a non-exact segment.
477 pub seg_start: usize,
478 /// Whether `user_offset` indexes the original file exactly (see [`Segment::exact`]).
479 pub exact: bool,
480}
481
482/// File-level attribution: for each contributed segment of the combined source,
483/// its byte range, originating file, and (for byte-intact segments) the
484/// offset-within-file mapping — so a diagnostic on the combined blob is
485/// attributed to the user's own file and line.
486#[derive(Debug, Default)]
487pub struct SourceMap {
488 segments: Vec<Segment>,
489}
490
491impl SourceMap {
492 /// The file that contributed the text at `offset` in the combined source.
493 pub fn file_for(&self, offset: usize) -> Option<&Path> {
494 self.segments
495 .iter()
496 .find(|s| s.start <= offset && offset < s.end)
497 .map(|s| s.file.as_path())
498 }
499
500 /// Resolve `offset` back to its originating file, offset-within-file, and
501 /// whether that mapping is exact. `None` when `offset` is outside every
502 /// segment (e.g. inside spliced trailing whitespace at the very end).
503 pub fn locate(&self, offset: usize) -> Option<Located<'_>> {
504 self.segments
505 .iter()
506 .find(|s| s.start <= offset && offset < s.end)
507 .map(|s| Located {
508 file: s.file.as_path(),
509 user_offset: s.user_start + (offset - s.start),
510 seg_start: s.start,
511 exact: s.exact,
512 })
513 }
514}
515
516/// A module collected during the DFS, ready to be spliced.
517struct Collected {
518 /// Canonical path of the module file.
519 path: PathBuf,
520 /// The module's raw source.
521 raw: String,
522}
523
524/// One import edge: the imported module and, for a SELECTIVE import, the specific
525/// names it brings into scope. `None` selection = whole-module import.
526struct ImportEdge {
527 canon: PathBuf,
528 selection: Option<Vec<SelectedName>>,
529}
530
531/// Assemble the whole project reachable from `entry_path` into one compilation
532/// unit. Returns an error only for unreadable/unresolvable imports; the combined
533/// source is then parsed by the normal pipeline.
534pub fn assemble_project(entry_path: &Path) -> Result<AssembledProgram, String> {
535 assemble_project_with_deps(entry_path, Vec::new())
536}
537
538/// [`assemble_project`], additionally linking the declared dependency roots so a
539/// bare `Import <Dep>.` resolves against a `path`/`git`-local package outside the
540/// project root. `dep_roots` empty is byte-for-byte identical to
541/// [`assemble_project`], so a single-project build is unchanged.
542pub fn assemble_project_with_deps(
543 entry_path: &Path,
544 dep_roots: Vec<DepRoot>,
545) -> Result<AssembledProgram, String> {
546 let root = entry_path.parent().unwrap_or(Path::new(".")).to_path_buf();
547 let mut loader = Loader::with_dep_roots(root, dep_roots);
548
549 let entry_raw = std::fs::read_to_string(entry_path).map_err(|e| {
550 format!(
551 "Failed to read entry file '{}': {}",
552 entry_path.display(),
553 e
554 )
555 })?;
556
557 let entry_canon = canonicalize(entry_path);
558
559 // Seed `visited` with the entry so a module that imports the entry back
560 // never re-splices the entry's `## Main`.
561 let mut visited: HashSet<PathBuf> = HashSet::new();
562 visited.insert(entry_canon.clone());
563
564 // Collect imported modules in post-order (dependencies first), recording
565 // each module's direct import edges for per-file scoping.
566 let mut ordered: Vec<Collected> = Vec::new();
567 let mut imports_map: HashMap<PathBuf, Vec<ImportEdge>> = HashMap::new();
568 let mut entry_imports: Vec<ImportEdge> = Vec::new();
569 for dep in scan_dependencies(&entry_raw) {
570 let dep_canon = {
571 let module = loader.resolve(entry_path, &dep.uri)?;
572 canonicalize(&module.path)
573 };
574 entry_imports.push(ImportEdge {
575 canon: dep_canon,
576 selection: dep.selection.clone(),
577 });
578 collect(
579 entry_path,
580 &dep.uri,
581 &mut loader,
582 &mut visited,
583 &mut ordered,
584 &mut imports_map,
585 )?;
586 }
587 imports_map.insert(entry_canon.clone(), entry_imports);
588
589 // An `Import` below a file's first `##` header imports nothing; refuse it at its own line before
590 // the program assembles without the module it names.
591 refuse_misplaced_imports(entry_path, &entry_raw)?;
592 for m in &ordered {
593 refuse_misplaced_imports(&m.path, &m.raw)?;
594 }
595
596 // Build provenance from every module (imports + entry) so a bare name
597 // defined in two files is detectable regardless of where it is used.
598 let mut provenance = Provenance::default();
599 for m in &ordered {
600 record_provenance(&mut provenance, &m.path, &m.raw);
601 }
602 record_provenance(&mut provenance, &entry_canon, &entry_raw);
603
604 // Collisions are a hard error (never silent shadowing): a bare name defined
605 // by two or more modules is ambiguous. The fix is to rename one, or qualify
606 // the reference (`Tokens::parse` vs `Expr::parse`).
607 if let Some((name, files)) = provenance.first_collision() {
608 let files = files
609 .iter()
610 .map(|p| p.display().to_string())
611 .collect::<Vec<_>>()
612 .join("\n and ");
613 return Err(format!(
614 "`{name}` is defined in more than one module:\n {files}\n\
615 Rename one, or qualify the reference (e.g. `Module::{name}`)."
616 ));
617 }
618
619 // Splice imported code into the entry, after leading prose and before the
620 // entry's first `##` section — the same insertion point `apply_prelude`
621 // uses, so the join parses cleanly.
622 let insert_at = if entry_raw.starts_with("##") {
623 0
624 } else {
625 entry_raw.find("\n##").map(|i| i + 1).unwrap_or(0)
626 };
627 let (head, orig_tail) = entry_raw.split_at(insert_at);
628
629 let mut source = String::new();
630 let mut segments: Vec<Segment> = Vec::new();
631
632 source.push_str(head);
633 if !head.is_empty() {
634 // The head (the entry's leading prose) is spliced verbatim — byte-intact,
635 // so a combined offset here IS the same offset in the entry file.
636 segments.push(Segment {
637 start: 0,
638 end: source.len(),
639 user_start: 0,
640 file: entry_canon.clone(),
641 exact: true,
642 });
643 }
644
645 // Splice module code block-by-block, MERGING content-identical definitions
646 // reached from more than one module (same name + byte-normalized body) so
647 // they're emitted once; genuinely-different same-named defs were already
648 // rejected by the collision check above.
649 let mut spliced: HashMap<String, String> = HashMap::new();
650 for m in &ordered {
651 let code = strip_main_block(&strip_and_mangle(&m.raw, &m.path));
652 if code.trim().is_empty() {
653 continue;
654 }
655 let start = source.len();
656 for (def, text) in split_blocks(&code) {
657 if let Some((name, _)) = &def {
658 let body = normalize_def(&text);
659 if spliced.get(name).is_some_and(|prev| *prev == body) {
660 continue;
661 }
662 spliced.insert(name.clone(), body);
663 }
664 source.push_str(&text);
665 }
666 source.push_str("\n\n");
667 // A module segment is transformed (note-block / privacy stripping,
668 // owner-mangling), so its combined text is not byte-identical to the file:
669 // file-accurate, line best-effort (see [`Segment::exact`]).
670 segments.push(Segment {
671 start,
672 end: source.len(),
673 user_start: 0,
674 file: m.path.clone(),
675 exact: false,
676 });
677 }
678
679 // The entry's own code (its `## To` defs + `## Main`) also carries privacy
680 // markers to strip + private functions to owner-mangle — but only the code
681 // section, not the leading prose.
682 let entry_map = private_mangle_map(&entry_raw, &entry_canon);
683 let tail = mangle_names_in_code(&strip_privacy_and_mangle(orig_tail, &entry_map), &entry_map);
684 let tail_start = source.len();
685 // The tail is byte-intact iff no privacy mangling rewrote it (the common case:
686 // an entry with no `private` functions). Then a combined offset maps to
687 // `insert_at + (offset - tail_start)` in the entry file — an exact line.
688 let tail_exact = tail == orig_tail;
689 source.push_str(&tail);
690 segments.push(Segment {
691 start: tail_start,
692 end: source.len(),
693 user_start: insert_at,
694 file: entry_canon.clone(),
695 exact: tail_exact,
696 });
697
698 // Per-file scope: each module's own exports plus the exports of the modules
699 // it directly imports. A reference to any OTHER module's export is a leak.
700 let mut exports: HashMap<PathBuf, HashSet<String>> = HashMap::new();
701 let mut private_names: HashSet<String> = HashSet::new();
702 // Original private name → the module that owns it (for the "private to X"
703 // diagnostic). `defn_file` can't serve this — after owner-mangling it holds
704 // the mangled `stem__helper`, not the bare `helper` a foreign file writes.
705 let mut private_owner: HashMap<String, PathBuf> = HashMap::new();
706 for m in &ordered {
707 let (public, private) = module_visibility(&m.raw);
708 exports.insert(m.path.clone(), public);
709 for name in &private {
710 private_owner
711 .entry(name.clone())
712 .or_insert_with(|| m.path.clone());
713 }
714 private_names.extend(private);
715 }
716 let (entry_public, entry_private) = module_visibility(&entry_raw);
717 exports.insert(entry_canon.clone(), entry_public);
718 for name in &entry_private {
719 private_owner
720 .entry(name.clone())
721 .or_insert_with(|| entry_canon.clone());
722 }
723 private_names.extend(entry_private);
724
725 // Per-file scope + selective narrowing. A whole-module edge brings all of a
726 // dependency's public exports; a selective edge brings only its named ones.
727 // A per-name alias binds the surface name to the REAL name via
728 // `selective_aliases` (rewritten before every scope check / codegen), so the
729 // REAL name is what lands in scope.
730 let mut in_scope: HashMap<PathBuf, HashSet<String>> = HashMap::new();
731 // Per-importing-file alias maps: an alias is local to the file that declared
732 // it, so it's baked only within that file's segment.
733 let mut per_file_aliases: HashMap<PathBuf, HashMap<String, String>> = HashMap::new();
734 for (path, own) in &exports {
735 let mut names = own.clone();
736 if let Some(edges) = imports_map.get(path) {
737 for edge in edges {
738 let Some(dep_exports) = exports.get(&edge.canon) else {
739 continue;
740 };
741 match &edge.selection {
742 None => names.extend(dep_exports.iter().cloned()),
743 Some(sel) => {
744 for s in sel {
745 if dep_exports.contains(&s.name) {
746 names.insert(s.name.clone());
747 if let Some(alias) = &s.alias {
748 per_file_aliases
749 .entry(path.clone())
750 .or_default()
751 .insert(alias.clone(), s.name.clone());
752 }
753 }
754 }
755 }
756 }
757 }
758 }
759 in_scope.insert(path.clone(), names);
760 }
761 // Flattened (surface alias → real name) for the LSP getter.
762 let selective_aliases: HashMap<String, String> = per_file_aliases
763 .values()
764 .flat_map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())))
765 .collect();
766 let all_exports: HashSet<String> = exports.values().flat_map(|s| s.iter().cloned()).collect();
767 let defn_file: HashMap<String, PathBuf> = provenance
768 .defs
769 .iter()
770 .filter_map(|(name, sites)| sites.first().map(|s| (name.clone(), s.path.clone())))
771 .collect();
772
773 // Bake per-name import aliases into the source so every tier resolves them —
774 // each alias scoped to its importing file's segment.
775 let source = bake_selective_aliases(&source, &segments, &per_file_aliases);
776
777 Ok(AssembledProgram {
778 source,
779 provenance,
780 source_map: SourceMap { segments },
781 scope: ScopeInfo {
782 in_scope,
783 all_exports,
784 defn_file,
785 private_names,
786 private_owner,
787 selective_aliases,
788 entry: entry_canon,
789 },
790 })
791}
792
793/// A module's names split by visibility: `(public, private)`. Public names are
794/// what importers may reference; private names are usable only by the owning
795/// module. Note blocks and prose are stripped first.
796fn module_visibility(raw: &str) -> (HashSet<String>, HashSet<String>) {
797 let code = strip_note_blocks(module_code(raw));
798 let mut public = HashSet::new();
799 let mut private = HashSet::new();
800 for (name, is_private) in defined_names_with_visibility(&code) {
801 if is_private {
802 private.insert(name);
803 } else {
804 public.insert(name);
805 }
806 }
807 // Private TYPE headers (`## A private Name` / `## An private Name`). Public
808 // types are intentionally NOT added to the public set — they resolve through
809 // the type registry, never the call/name scope — but a PRIVATE type must be
810 // gated, so its name is recorded here.
811 //
812 // `## Component <Name>:` blocks: their synthesized `__render_<Name>` function
813 // is a PUBLIC export of the defining module — a `Mount <Name>` or view-child
814 // `<Name>` in an importing file composes it by calling that render function,
815 // so an importer must have it in scope. (Components have no `private` form.)
816 for line in code.lines() {
817 let t = line.trim();
818 let type_name = t
819 .strip_prefix("## A private ")
820 .or_else(|| t.strip_prefix("## An private "));
821 if let Some(rest) = type_name {
822 if let Some(name) = rest
823 .split(|c: char| c == '(' || c == '.' || c.is_whitespace())
824 .next()
825 {
826 if !name.is_empty() {
827 private.insert(name.to_string());
828 }
829 }
830 }
831 if let Some(render) = component_render_name(t) {
832 public.insert(render);
833 }
834 }
835 (public, private)
836}
837
838/// Drop the `private` visibility markers from definition headers AND owner-mangle
839/// the header name of a private FUNCTION (`## To private helper` → `## To m__helper`
840/// via `map`), so the parser sees a plain header and two modules can each own a
841/// private `helper` without colliding in the flat unit. Private TYPES are not
842/// mangled (only their marker dropped). Body references are mangled separately by
843/// [`mangle_names_in_code`].
844fn strip_privacy_and_mangle(code: &str, map: &HashMap<String, String>) -> String {
845 let mut out = String::with_capacity(code.len());
846 for line in code.split_inclusive('\n') {
847 let indent = line.len() - line.trim_start().len();
848 let (prefix, rest) = line.split_at(indent);
849 let rewritten = if let Some(after) = rest.strip_prefix("## To native private ") {
850 format!("## To native {}", mangle_first_word(after, map))
851 } else if let Some(after) = rest.strip_prefix("## To private ") {
852 format!("## To {}", mangle_first_word(after, map))
853 } else if let Some(after) = rest.strip_prefix("## A private ") {
854 format!("## A {after}")
855 } else if let Some(after) = rest.strip_prefix("## An private ") {
856 format!("## An {after}")
857 } else if let Some(after) = rest.strip_prefix("A private ") {
858 format!("A {after}")
859 } else if let Some(after) = rest.strip_prefix("An private ") {
860 format!("An {after}")
861 } else {
862 rest.to_string()
863 };
864 out.push_str(prefix);
865 out.push_str(&rewritten);
866 }
867 out
868}
869
870/// If the first identifier word of `s` is in `map`, replace it with its mangled
871/// form, preserving the rest verbatim (`"helper (n: Int):\n"` → `"m__helper (n: Int):\n"`).
872fn mangle_first_word(s: &str, map: &HashMap<String, String>) -> String {
873 let idx = s
874 .find(|c: char| c == '(' || c.is_whitespace())
875 .unwrap_or(s.len());
876 let (word, rest) = s.split_at(idx);
877 match map.get(word) {
878 Some(mangled) => format!("{mangled}{rest}"),
879 None => s.to_string(),
880 }
881}
882
883/// The `## To private` FUNCTION names a module declares. Only functions are
884/// owner-mangled (a private TYPE keeps its name — it is gate-only).
885fn module_private_functions(raw: &str) -> HashSet<String> {
886 let code = strip_note_blocks(module_code(raw));
887 let mut out = HashSet::new();
888 for header in code.lines().filter_map(function_header) {
889 if header.private {
890 out.insert(header.name.to_string());
891 }
892 }
893 out
894}
895
896/// Owner-mangle map for a module's private functions: `helper` → `<stem>__helper`.
897/// The file stem is machine-independent, so the mangled source stays byte-stable
898/// across independent copies (the determinism invariant). The stem is sanitized to
899/// a valid identifier first (a file like `math-utils.lg` or `2d.lg` must not mint
900/// `math-utils__helper` / `2d__helper`, which the lexer/rustc would reject).
901fn private_mangle_map(raw: &str, path: &Path) -> HashMap<String, String> {
902 let stem = sanitize_stem(path.file_stem().and_then(|s| s.to_str()).unwrap_or("mod"));
903 module_private_functions(raw)
904 .into_iter()
905 .map(|name| {
906 let mangled = format!("{stem}__{name}");
907 (name, mangled)
908 })
909 .collect()
910}
911
912/// A file stem reduced to a valid leading identifier fragment: non-`[A-Za-z0-9_]`
913/// runs become `_`, and a leading digit (or empty stem) is prefixed with `m`.
914fn sanitize_stem(stem: &str) -> String {
915 let mut out: String = stem
916 .chars()
917 .map(|c| {
918 if c.is_ascii_alphanumeric() || c == '_' {
919 c
920 } else {
921 '_'
922 }
923 })
924 .collect();
925 if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) {
926 out.insert(0, 'm');
927 }
928 out
929}
930
931/// Visit each `{…}` interpolation hole of `raw` EXACTLY as the parser's
932/// `parse_interpolation_parts` splits them — `{{` is an escaped brace, and a hole
933/// ends at its brace-DEPTH-matched `}` (so a nested `{…}` inside the expression
934/// doesn't close it early). `f(content_start, content_end)` gets each hole's inner
935/// byte range. Keeping this identical to the parser is the invariant that makes
936/// every source-level hole rewrite land on the same span the parser later reads.
937fn for_each_interpolation_hole(raw: &str, mut f: impl FnMut(usize, usize)) {
938 let bytes = raw.as_bytes();
939 let mut i = 0;
940 while i < bytes.len() {
941 if bytes[i] == b'{' {
942 if bytes.get(i + 1) == Some(&b'{') {
943 i += 2; // an escaped `{{`, not a hole
944 continue;
945 }
946 let content_start = i + 1;
947 let mut depth = 1usize;
948 let mut end = content_start;
949 while end < bytes.len() && depth > 0 {
950 match bytes[end] {
951 b'{' => depth += 1,
952 b'}' => depth -= 1,
953 _ => {}
954 }
955 if depth > 0 {
956 end += 1;
957 }
958 }
959 // `end` is the depth-matched `}` (or the raw end if unclosed).
960 f(content_start, end);
961 i = if end < bytes.len() { end + 1 } else { end };
962 } else {
963 i += 1;
964 }
965 }
966}
967
968/// Identifier edits INSIDE the `{…}` interpolation holes of an interpolated
969/// string's source `span` (at absolute `base`) — the mangle/alias passes rewrite
970/// a `{helper(n)}` call just like a bare one, while the surrounding string text
971/// and a `{{` escape are left untouched. Only whole-word ASCII identifiers
972/// matching `map` are rewritten (a non-ASCII byte can't start/continue one, so
973/// every slice stays on a char boundary).
974fn interpolation_hole_edits(
975 span: &str,
976 base: usize,
977 map: &HashMap<String, String>,
978) -> Vec<(usize, usize, String)> {
979 let bytes = span.as_bytes();
980 let is_start = |b: u8| b.is_ascii_alphabetic() || b == b'_';
981 let is_cont = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
982 let mut edits = Vec::new();
983 for_each_interpolation_hole(span, |hole_start, end| {
984 let mut w = hole_start;
985 while w < end {
986 if is_start(bytes[w]) {
987 let ws = w;
988 while w < end && is_cont(bytes[w]) {
989 w += 1;
990 }
991 if let Some(rep) = map.get(&span[ws..w]) {
992 edits.push((base + ws, base + w, rep.clone()));
993 }
994 } else {
995 w += 1;
996 }
997 }
998 });
999 edits
1000}
1001
1002/// Identifier edits inside the `{…}` holes of a `View:`/`Head:` raw section body — the same
1003/// rewrite [`interpolation_hole_edits`] performs for a legacy interpolated string, mapped back
1004/// through [`RawMap::offset`](logicaffeine_language::lgx::raw::RawMap::offset) since a raw body's
1005/// offsets are DEDENTED text, never a flat shift from the file the way a quoted string's are.
1006fn raw_block_hole_edits(
1007 raw: &logicaffeine_language::lgx::raw::RawMap,
1008 map: &HashMap<String, String>,
1009) -> Vec<(usize, usize, String)> {
1010 let text = raw.text.as_str();
1011 let bytes = text.as_bytes();
1012 let is_start = |b: u8| b.is_ascii_alphabetic() || b == b'_';
1013 let is_cont = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1014 let mut edits = Vec::new();
1015 for_each_interpolation_hole(text, |hole_start, hole_end| {
1016 let mut w = hole_start;
1017 while w < hole_end {
1018 if is_start(bytes[w]) {
1019 let ws = w;
1020 while w < hole_end && is_cont(bytes[w]) {
1021 w += 1;
1022 }
1023 if let Some(rep) = map.get(&text[ws..w]) {
1024 edits.push((raw.offset(ws), raw.offset(w), rep.clone()));
1025 }
1026 } else {
1027 w += 1;
1028 }
1029 }
1030 });
1031 edits
1032}
1033
1034/// Rewrite every identifier token named in `map` to its mangled form within
1035/// `code`. Plain string/char-literal content is left alone; INSIDE an interpolated
1036/// string's `{…}` holes, real identifiers ARE rewritten (a private call in a
1037/// `"{…}"` resolves like any other) — and the same is true of a `View:`/`Head:`
1038/// raw section's `{…}` holes, mapped back through [`RawMap`](logicaffeine_language::lgx::raw::RawMap).
1039/// Exact spans are replaced right-to-left so
1040/// earlier offsets stay valid. A no-op with an empty map.
1041fn mangle_names_in_code(code: &str, map: &HashMap<String, String>) -> String {
1042 if map.is_empty() {
1043 return code.to_string();
1044 }
1045 let mut interner = Interner::new();
1046 let mut lexer = Lexer::new(code, &mut interner);
1047 let tokens = lexer.tokenize();
1048 let mut edits: Vec<(usize, usize, String)> = Vec::new();
1049 for token in &tokens {
1050 match token.kind {
1051 TokenType::InterpolatedString(_) => {
1052 if let Some(span) = code.get(token.span.start..token.span.end) {
1053 edits.extend(interpolation_hole_edits(span, token.span.start, map));
1054 }
1055 }
1056 // A `View:`/`Head:` body is captured whole, DEDENTED (each line's leading
1057 // whitespace stripped), so a hole's offset inside it is not a flat shift from the
1058 // file the way an interpolated string's is — `RawMap` is the one place that maps
1059 // it back. `Style:` is CSS, never scanned: a private LOGOS name colliding with a
1060 // property or selector would otherwise get rewritten inside someone's stylesheet.
1061 TokenType::RawBlock {
1062 kind: RawKind::Style,
1063 ..
1064 } => {}
1065 TokenType::RawBlock {
1066 kind: RawKind::View | RawKind::Head,
1067 body,
1068 content_start,
1069 indent,
1070 } => {
1071 let raw = logicaffeine_language::lgx::raw::RawMap::new(
1072 interner.resolve(body).to_string(),
1073 indent,
1074 content_start,
1075 );
1076 edits.extend(raw_block_hole_edits(&raw, map));
1077 }
1078 TokenType::StringLiteral(_) | TokenType::CharLiteral(_) => {}
1079 _ => {
1080 let text = interner.resolve(token.lexeme);
1081 if let Some(mangled) = map.get(text) {
1082 edits.push((token.span.start, token.span.end, mangled.clone()));
1083 }
1084 }
1085 }
1086 }
1087 edits.sort_by(|a, b| b.0.cmp(&a.0));
1088 let mut out = code.to_string();
1089 for (start, end, mangled) in edits {
1090 if start <= end
1091 && end <= out.len()
1092 && out.is_char_boundary(start)
1093 && out.is_char_boundary(end)
1094 {
1095 out.replace_range(start..end, &mangled);
1096 }
1097 }
1098 out
1099}
1100
1101/// Strip privacy markers and owner-mangle private functions in one module's code.
1102fn strip_and_mangle(raw: &str, path: &Path) -> String {
1103 let map = private_mangle_map(raw, path);
1104 mangle_names_in_code(
1105 &strip_privacy_and_mangle(&strip_note_blocks(module_code(raw)), &map),
1106 &map,
1107 )
1108}
1109
1110/// Resolve `uri` relative to `base_path`, then recurse into its imports
1111/// post-order. Dedups by canonical path (cycle- and diamond-safe), and records
1112/// each module's direct import edges for per-file scope resolution.
1113fn collect(
1114 base_path: &Path,
1115 uri: &str,
1116 loader: &mut Loader,
1117 visited: &mut HashSet<PathBuf>,
1118 ordered: &mut Vec<Collected>,
1119 imports: &mut HashMap<PathBuf, Vec<ImportEdge>>,
1120) -> Result<(), String> {
1121 let (path, raw) = {
1122 let module = loader.resolve(base_path, uri)?;
1123 (module.path.clone(), module.content.clone())
1124 };
1125 let canon = canonicalize(&path);
1126 if !visited.insert(canon.clone()) {
1127 return Ok(());
1128 }
1129 let mut my_imports = Vec::new();
1130 for dep in scan_dependencies(&raw) {
1131 let dep_canon = {
1132 let module = loader.resolve(&path, &dep.uri)?;
1133 canonicalize(&module.path)
1134 };
1135 my_imports.push(ImportEdge {
1136 canon: dep_canon,
1137 selection: dep.selection.clone(),
1138 });
1139 collect(&path, &dep.uri, loader, visited, ordered, imports)?;
1140 }
1141 imports.insert(canon.clone(), my_imports);
1142 ordered.push(Collected { path: canon, raw });
1143 Ok(())
1144}
1145
1146/// Record every definition a module's code declares (name → site + content) so
1147/// collisions and content-identity can be judged.
1148fn record_provenance(provenance: &mut Provenance, path: &Path, raw: &str) {
1149 let code = strip_and_mangle(raw, path);
1150 for (def, text) in split_blocks(&code) {
1151 if let Some((name, kind)) = def {
1152 provenance.record(
1153 &name,
1154 DefSite {
1155 path: path.to_path_buf(),
1156 kind,
1157 body: normalize_def(&text),
1158 },
1159 );
1160 }
1161 }
1162}
1163
1164/// Split a module's code into `## `-headed blocks. Each entry is
1165/// `(Some((name, kind)) for a function/type definition else None, block text)`,
1166/// covering the whole code so re-joining the block texts reproduces it.
1167fn split_blocks(code: &str) -> Vec<(Option<(String, DefKind)>, String)> {
1168 let mut out = Vec::new();
1169 let mut cur_def: Option<(String, DefKind)> = None;
1170 let mut cur_text = String::new();
1171 let mut started = false;
1172 for line in code.split_inclusive('\n') {
1173 if line.trim_start().starts_with("## ") {
1174 if started {
1175 out.push((cur_def.take(), std::mem::take(&mut cur_text)));
1176 }
1177 started = true;
1178 cur_def = header_def(line);
1179 }
1180 if started {
1181 cur_text.push_str(line);
1182 }
1183 }
1184 if started {
1185 out.push((cur_def, cur_text));
1186 }
1187 out
1188}
1189
1190/// Parse a `## ` header line into `(defined name, kind)`, or `None` for a
1191/// non-definition block (`## Main`, `## Requires`, …).
1192fn header_def(header: &str) -> Option<(String, DefKind)> {
1193 let h = header.trim();
1194 if h.starts_with("## To ") {
1195 return function_header(h).map(|header| (header.name.to_string(), DefKind::Function));
1196 }
1197 // A `## Component <Name>:` block desugars (in the parser) to a synthesized
1198 // FUNCTION `__render_<Name>` whose body — the View element tree, event
1199 // handlers, signal state, and `{…}` interpolations — carries the component's
1200 // real call sites. Recording that function here is what lets [`check_scopes`]
1201 // attribute those calls to the module the component is DEFINED in (rather than
1202 // silently skipping the body, or misattributing it to the entry): the render
1203 // function lands in `defn_file`, and its module's imports gate what it may call.
1204 if let Some(name) = component_render_name(h) {
1205 return Some((name, DefKind::Function));
1206 }
1207 let after_article = h.strip_prefix("## A ").or_else(|| h.strip_prefix("## An "));
1208 if let Some(rest) = after_article {
1209 let name = rest
1210 .split(|c: char| c == '(' || c == '.' || c.is_whitespace())
1211 .next()
1212 .unwrap_or("");
1213 return (!name.is_empty()).then(|| (name.to_string(), DefKind::Type));
1214 }
1215 None
1216}
1217
1218/// The synthesized render-function name of a `## Component <Name>:` header, or
1219/// `None` for any other line. The parser desugars each component to a function
1220/// `__render_<Name>(__parent)` (and composes a reference like `Mount <Name>` or a
1221/// view child `<Name>` to a call of it), so the render function is the durable
1222/// identity of the component in the flat compilation unit — the one every scope /
1223/// provenance surface must agree on. The match mirrors the parser's own header
1224/// recognition (`BlockType::Component` on `## Component`, name = the next word).
1225fn component_render_name(header: &str) -> Option<String> {
1226 let rest = header.trim().strip_prefix("## Component ")?;
1227 let name = rest
1228 .split(|c: char| c == '(' || c == ':' || c.is_whitespace())
1229 .next()
1230 .unwrap_or("");
1231 (!name.is_empty()).then(|| format!("__render_{name}"))
1232}
1233
1234/// Normalize a definition block to its content identity — collapse all
1235/// whitespace runs so cosmetic formatting never counts as a different definition.
1236fn normalize_def(block: &str) -> String {
1237 block.split_whitespace().collect::<Vec<_>>().join(" ")
1238}
1239
1240/// The bare names a source's code defines as FUNCTIONS (`## To <name>` /
1241/// `## To native <name>`), across every module spliced into it. This is the
1242/// self-describing set the token resolver uses to tell a qualified FUNCTION
1243/// reference (`Util::double`, tail is a function) from a qualified TYPE
1244/// reference (`Geometry::Point`, tail is a type — left namespaced).
1245pub(crate) fn defined_function_names(code: &str) -> HashSet<String> {
1246 code.lines()
1247 .filter_map(function_header)
1248 .map(|header| header.name.to_string())
1249 .collect()
1250}
1251
1252/// Resolve the QUALIFIED escape hatch for FUNCTION calls: a `Alias::name` token
1253/// whose tail (`name`) is a known function is rewritten in place to be exactly
1254/// the token the bare `name` would have produced, so the call resolves to the
1255/// flat canonical function on every tier. The lexer glues `Alias::name` into one
1256/// token; the parser reads a call's callee from that token's classified kind
1257/// (`Noun`/`Verb`/…) as well as its `lexeme`, so we RE-LEX the bare name to get
1258/// the correct classification and symbol, keeping the original span.
1259///
1260/// A qualified TYPE reference (`Geometry::Point`) is left untouched: its tail is
1261/// a type, not a function, so it never matches. For a single-file program (no
1262/// `::` tokens, or none whose tail is a function) this is a no-op — the token
1263/// stream is byte-identical.
1264pub(crate) fn resolve_qualified_names(
1265 tokens: &mut [Token],
1266 interner: &mut Interner,
1267 functions: &HashSet<String>,
1268) {
1269 // Collect the tokens to rewrite first (their `interner.resolve` borrow must
1270 // end before we re-lex the bare name / re-intern the hole content, which
1271 // needs `&mut interner`). A bare qualified token re-lexes; an interpolated
1272 // string re-interns its hole-stripped content.
1273 let mut rewrites: Vec<(usize, String)> = Vec::new();
1274 let mut interp_rewrites: Vec<(usize, String)> = Vec::new();
1275 for (idx, token) in tokens.iter().enumerate() {
1276 match token.kind {
1277 // A `::` in a plain string/char literal is content, never a
1278 // namespace — but an interpolation `{Util::double(21)}` hole IS a real
1279 // qualified call and resolves like a bare `{double(21)}`.
1280 TokenType::StringLiteral(_) | TokenType::CharLiteral(_) => continue,
1281 TokenType::InterpolatedString(_) => {
1282 let raw = interner.resolve(token.lexeme);
1283 let edits = qualified_hole_edits(raw, functions);
1284 if !edits.is_empty() {
1285 let mut content = raw.to_string();
1286 // Right-to-left: each edit shrinks the span, so applying in
1287 // reverse keeps every earlier offset valid.
1288 for (start, end, tail) in edits.into_iter().rev() {
1289 content.replace_range(start..end, &tail);
1290 }
1291 interp_rewrites.push((idx, content));
1292 }
1293 continue;
1294 }
1295 _ => {}
1296 }
1297 let text = interner.resolve(token.lexeme);
1298 if let Some(i) = text.rfind("::") {
1299 if functions.contains(&text[i + 2..]) {
1300 rewrites.push((idx, text[i + 2..].to_string()));
1301 }
1302 }
1303 }
1304 for (idx, bare) in rewrites {
1305 let span = tokens[idx].span;
1306 let mut lexer = Lexer::new(&bare, interner);
1307 let relexed = lexer.tokenize();
1308 if let Some(first) = relexed
1309 .into_iter()
1310 .find(|t| !matches!(t.kind, TokenType::EOF))
1311 {
1312 tokens[idx] = Token::new(first.kind, first.lexeme, span);
1313 }
1314 }
1315 for (idx, content) in interp_rewrites {
1316 let sym = interner.intern(&content);
1317 let span = tokens[idx].span;
1318 tokens[idx] = Token::new(TokenType::InterpolatedString(sym), sym, span);
1319 }
1320}
1321
1322/// Qualified-call edits INSIDE the `{…}` holes of an interpolated string's raw
1323/// content: a `Util::double` (or `A::B::fn`) whose FINAL segment is a known
1324/// function is replaced by that bare tail, so a qualified call in an
1325/// interpolation resolves on every tier like a top-level one. Literal text, a
1326/// `{{` escape, and a qualified TYPE reference (tail not a function, e.g.
1327/// `Geometry::Point`) are left untouched. Returns byte-span edits over `raw`
1328/// (right-to-left when applied, since spans only shrink).
1329fn qualified_hole_edits(raw: &str, functions: &HashSet<String>) -> Vec<(usize, usize, String)> {
1330 let bytes = raw.as_bytes();
1331 let is_start = |b: u8| b.is_ascii_alphabetic() || b == b'_';
1332 let is_seg = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1333 let mut edits = Vec::new();
1334 for_each_interpolation_hole(raw, |hole_start, end| {
1335 let mut w = hole_start;
1336 while w < end {
1337 if is_start(bytes[w]) {
1338 let word_start = w;
1339 let mut tail_start = w;
1340 let mut saw_colon = false;
1341 loop {
1342 while w < end && is_seg(bytes[w]) {
1343 w += 1;
1344 }
1345 // A `::` joins segments only when another identifier follows.
1346 if w + 2 < end
1347 && bytes[w] == b':'
1348 && bytes[w + 1] == b':'
1349 && is_start(bytes[w + 2])
1350 {
1351 w += 2;
1352 tail_start = w;
1353 saw_colon = true;
1354 } else {
1355 break;
1356 }
1357 }
1358 if saw_colon && functions.contains(&raw[tail_start..w]) {
1359 edits.push((word_start, w, raw[tail_start..w].to_string()));
1360 }
1361 } else {
1362 w += 1;
1363 }
1364 }
1365 });
1366 edits
1367}
1368
1369/// Rewrite each per-name import alias to its real exported name DIRECTLY IN THE
1370/// ASSEMBLED SOURCE, so that a `dbl` reference (`Import double as dbl from Util.`)
1371/// resolves to `double` on every tier. An alias is LOCAL to the importing file, so
1372/// a token is rewritten ONLY when it sits inside that file's own segment (per
1373/// `segments`) — an unrelated `dbl` in another module is never disturbed. We lex,
1374/// map each token to its owning file, and replace the exact spans right-to-left. A
1375/// no-op with no aliases (byte-identical source).
1376fn bake_selective_aliases(
1377 source: &str,
1378 segments: &[Segment],
1379 per_file: &HashMap<PathBuf, HashMap<String, String>>,
1380) -> String {
1381 if per_file.is_empty() {
1382 return source.to_string();
1383 }
1384 let mut interner = Interner::new();
1385 let mut lexer = Lexer::new(source, &mut interner);
1386 let tokens = lexer.tokenize();
1387 let mut edits: Vec<(usize, usize, String)> = Vec::new();
1388 for token in &tokens {
1389 // A `dbl` inside a plain string/char literal is content; inside an
1390 // interpolation `{…}` hole it is a real reference and IS rewritten.
1391 if matches!(
1392 token.kind,
1393 TokenType::StringLiteral(_) | TokenType::CharLiteral(_)
1394 ) {
1395 continue;
1396 }
1397 let owner = segments
1398 .iter()
1399 .find(|s| s.start <= token.span.start && token.span.start < s.end)
1400 .map(|s| &s.file);
1401 let Some(map) = owner.and_then(|f| per_file.get(f)) else {
1402 continue;
1403 };
1404 if matches!(token.kind, TokenType::InterpolatedString(_)) {
1405 if let Some(span) = source.get(token.span.start..token.span.end) {
1406 edits.extend(interpolation_hole_edits(span, token.span.start, map));
1407 }
1408 continue;
1409 }
1410 let text = interner.resolve(token.lexeme);
1411 if let Some(real) = map.get(text) {
1412 edits.push((token.span.start, token.span.end, real.clone()));
1413 }
1414 }
1415 edits.sort_by(|a, b| b.0.cmp(&a.0));
1416 let mut out = source.to_string();
1417 for (start, end, real) in edits {
1418 if start <= end
1419 && end <= out.len()
1420 && out.is_char_boundary(start)
1421 && out.is_char_boundary(end)
1422 {
1423 out.replace_range(start..end, &real);
1424 }
1425 }
1426 out
1427}
1428
1429/// A module's code with any `## Main` block removed — from the `## Main` header
1430/// through the line before the next `##` header (or EOF). Only the entry's
1431/// `## Main` is kept; an imported module contributes definitions only.
1432pub(crate) fn strip_main_block(code: &str) -> String {
1433 let mut out = String::with_capacity(code.len());
1434 let mut in_main = false;
1435 for line in code.split_inclusive('\n') {
1436 let trimmed = line.trim_start();
1437 if trimmed.starts_with("## ") {
1438 in_main = trimmed.trim_end() == "## Main";
1439 if in_main {
1440 continue;
1441 }
1442 }
1443 if in_main {
1444 continue;
1445 }
1446 out.push_str(line);
1447 }
1448 out
1449}
1450
1451/// Every `Import` sentence in `raw` that stands below the file's first `##` header, refused at its own
1452/// `path:line:col`: `scan_dependencies` reads imports only from the prose above that header, so such a
1453/// sentence imports nothing and the program would otherwise run on without the module it names.
1454fn refuse_misplaced_imports(path: &Path, raw: &str) -> Result<(), String> {
1455 let misplaced = misplaced_imports(raw);
1456 let Some((header_line, header)) = first_section_header(raw).filter(|_| !misplaced.is_empty())
1457 else {
1458 return Ok(());
1459 };
1460 let mut seen = HashSet::new();
1461 let refusals: Vec<String> = misplaced
1462 .iter()
1463 .filter(|dep| seen.insert(dep.start))
1464 .map(|dep| {
1465 let line = raw[..dep.start].matches('\n').count() + 1;
1466 let col = dep.start - raw[..dep.start].rfind('\n').map_or(0, |i| i + 1) + 1;
1467 let sentence = raw[dep.start..dep.end].trim();
1468 format!(
1469 "`{sentence}` at {}:{line}:{col} is below the `{header}` header on line {header_line}: an Import \
1470 imports only from the prose above a file's first `##` section, so this one imports nothing. \
1471 Move it above line {header_line}.",
1472 path.display()
1473 )
1474 })
1475 .collect();
1476 Err(refusals.join("\n"))
1477}
1478
1479/// Canonicalize a path for dedup; fall back to the path itself if the file
1480/// cannot be canonicalized (it was just read, so this rarely fails).
1481fn canonicalize(path: &Path) -> PathBuf {
1482 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
1483}
1484
1485#[cfg(test)]
1486#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/analysis/assemble/tests.rs"]
1487mod tests;