Skip to main content

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;