Skip to main content

lex_store/
deps.rs

1//! Recursive, lock-driven dependency resolution for the write-time gate
2//! (#943, #944).
3//!
4//! A non-inlined head keeps `import "<pkg>/<module>" as <alias>` edges (#930).
5//! To type-check it, the gate needs each imported module's *surface*: its
6//! function signatures, the type declarations those signatures mention, and
7//! the module's mangle prefix. A dependency's surface in turn depends on
8//! **its** dependencies, so resolution recurses:
9//!
10//! * each dependency's own dependencies resolve against **that dependency's**
11//!   committed lock ([`Store::committed_lock_inherited`] at its pinned head),
12//!   never the root's — a package is checked against exactly the pins it was
13//!   published with;
14//! * a `(store, head)` visited set turns a dependency cycle into a diagnostic
15//!   instead of unbounded recursion, and a per-call cache resolves each
16//!   `(store, head, module)` once however many paths reach it;
17//! * the same package pinned to two different heads anywhere in the closure
18//!   is a [`TypeError::DependencyConflict`] (a diamond whose sides disagree).
19//!
20//! Failures are **structured** ([`ResolvedDeps::diagnostics`]): an import the
21//! lock does not pin is [`TypeError::UnpinnedDependency`] (#944 — hosted
22//! resolution is registry-only; a git dependency has no lock entry), anything
23//! else is [`TypeError::UnresolvedDependency`] with a reason. The gate reports
24//! them instead of the downstream `unknown_identifier` they would otherwise
25//! cause.
26//!
27//! What a pin *points at* is context-specific — the hub maps a registry URL to
28//! a hosted store and enforces visibility — so it is abstracted as a
29//! [`DepLocator`]; everything else lives here so every resolver agrees.
30
31use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
32
33use lex_syntax::lock::{LockEntry, LockFile};
34use lex_types::{Ty, TypeError};
35
36use crate::render::{map_stage_names, module_file, package_head_at_op, stage_prefix, NameSite};
37use crate::store::{Store, StoreError};
38
39/// Every dependency interface resolved for one head, keyed by import
40/// reference (`"lex-nt/lib"`) — the three maps
41/// [`lex_types::check_program_with_deps`] consumes, plus the diagnostics for
42/// the imports that could not be resolved.
43#[derive(Debug, Clone, Default)]
44pub struct ResolvedDeps {
45    /// import reference → that module's value record.
46    pub modules: BTreeMap<String, Ty>,
47    /// import reference → every type declaration the module's surface needs
48    /// (its package's own, and — transitively — its dependencies'), all
49    /// prefix-named when `prefixes` has an entry for the reference.
50    pub types: BTreeMap<String, Vec<lex_ast::TypeDecl>>,
51    /// import reference → the module's mangle prefix (prefixed mode, #963).
52    pub prefixes: BTreeMap<String, String>,
53    /// Structured reasons an import did not resolve. Non-empty means the head
54    /// cannot be verified here.
55    pub diagnostics: Vec<TypeError>,
56}
57
58/// One dependency module's public surface, as a dependent sees it.
59#[derive(Debug, Clone)]
60pub struct ModuleSurface {
61    /// The module's functions keyed by bare name.
62    pub record: Ty,
63    /// Every type declaration the record can mention: the package's own and
64    /// its dependencies' (transitively), dependencies first.
65    pub types: Vec<lex_ast::TypeDecl>,
66    /// The module's canonical mangle prefix, or `None` for a legacy head whose
67    /// declarations carry no prefixes (its types are then bare-named and are
68    /// registered under the importer's alias).
69    pub prefix: Option<String>,
70}
71
72/// Classify `import "<ref>"`: `Some((pkg, module))` for a registry/git package
73/// reference, `None` for a local (`./`, `../`, `/`) or stdlib (`std.*`) import.
74/// Mirrors the loader's classification.
75pub fn split_package_import(reference: &str) -> Option<(&str, &str)> {
76    if reference.starts_with("./")
77        || reference.starts_with("../")
78        || reference.starts_with('/')
79        || reference.starts_with("std.")
80    {
81        return None;
82    }
83    reference.split_once('/')
84}
85
86/// The standard hint for an import its lock does not pin (#944).
87pub const UNPINNED_HINT: &str =
88    "hosted verification resolves dependencies only through `lex.lock` \
89pins into registry stores (it never fetches git): declare a registry source for it in `lex.toml` \
90and run `lex pkg lock`";
91
92/// Build the [`TypeError::UnpinnedDependency`] for `reference`.
93pub fn unpinned(reference: &str, package: &str) -> TypeError {
94    TypeError::UnpinnedDependency {
95        at_node: "n_0".into(),
96        reference: reference.to_string(),
97        package: package.to_string(),
98        hint: UNPINNED_HINT.to_string(),
99    }
100}
101
102fn unresolved(reference: &str, package: &str, reason: impl Into<String>) -> TypeError {
103    TypeError::UnresolvedDependency {
104        at_node: "n_0".into(),
105        reference: reference.to_string(),
106        package: package.to_string(),
107        reason: reason.into(),
108    }
109}
110
111/// Where the store a lock entry pins lives, and whether the caller may read it.
112pub trait DepLocator {
113    /// Open the store `entry` pins. `Ok((identity, store))`, where `identity`
114    /// names the store stably (it keys the cycle guard and the cache);
115    /// `Err(reason)` when it is unreachable or not visible to the caller. The
116    /// reason is shown to the user, so a store that exists but is private to
117    /// someone else must read exactly like one that does not exist.
118    fn open(&self, entry: &LockEntry) -> Result<(String, Store), String>;
119}
120
121/// The head's own import references (registry/git and otherwise), in order,
122/// deduplicated.
123fn import_refs(stages: &[lex_ast::Stage]) -> Vec<String> {
124    let mut seen = BTreeSet::new();
125    stages
126        .iter()
127        .filter_map(|s| match s {
128            lex_ast::Stage::Import(i) if seen.insert(i.reference.clone()) => {
129                Some(i.reference.clone())
130            }
131            _ => None,
132        })
133        .collect()
134}
135
136/// Resolve every package import in `stages` against `lock` (the lock that
137/// governs the head being gated), recursively. See the module docs.
138pub fn resolve_with_lock(
139    locator: &dyn DepLocator,
140    stages: &[lex_ast::Stage],
141    lock: Option<&LockFile>,
142) -> ResolvedDeps {
143    let mut walk = Walk {
144        locator,
145        surfaces: HashMap::new(),
146        deps: HashMap::new(),
147        visiting: HashSet::new(),
148        pins: BTreeMap::new(),
149    };
150    let mut out = walk.resolve_refs(&import_refs(stages), lock);
151    // Diamond check across the whole closure the walk saw.
152    for (package, heads) in &walk.pins {
153        if heads.len() > 1 {
154            out.diagnostics.push(TypeError::DependencyConflict {
155                at_node: "n_0".into(),
156                package: package.clone(),
157                heads: heads.iter().cloned().collect(),
158            });
159        }
160    }
161    out
162}
163
164/// Parse a committed lock, treating a malformed one as absent (every import
165/// then reports as unpinned, which is what it effectively is).
166pub fn parse_lock(toml: &str) -> Option<LockFile> {
167    LockFile::from_toml(toml).ok()
168}
169
170struct Walk<'a> {
171    locator: &'a dyn DepLocator,
172    /// (store identity, head, module) → surface or failure reason.
173    surfaces: HashMap<(String, String, String), Result<ModuleSurface, String>>,
174    /// (store identity, head) → that head's own resolved dependencies.
175    deps: HashMap<(String, String), ResolvedDeps>,
176    /// (store identity, head) pairs on the current resolution path.
177    visiting: HashSet<(String, String)>,
178    /// package name → every head it was pinned to anywhere in the closure.
179    pins: BTreeMap<String, BTreeSet<String>>,
180}
181
182impl Walk<'_> {
183    fn resolve_refs(&mut self, refs: &[String], lock: Option<&LockFile>) -> ResolvedDeps {
184        let mut out = ResolvedDeps::default();
185        for reference in refs {
186            let Some((pkg, module)) = split_package_import(reference) else {
187                continue;
188            };
189            let Some(entry) = lock.and_then(|l| l.entry(pkg)) else {
190                out.diagnostics.push(unpinned(reference, pkg));
191                continue;
192            };
193            let Some(head) = entry.head_op.clone() else {
194                out.diagnostics.push(unresolved(
195                    reference,
196                    pkg,
197                    format!(
198                        "the lock pins version {} but no op-log head; re-run `lex pkg lock` against a registry that serves heads",
199                        entry.version
200                    ),
201                ));
202                continue;
203            };
204            self.pins
205                .entry(pkg.to_string())
206                .or_default()
207                .insert(head.clone());
208            match self.surface(entry, &head, pkg, module) {
209                Ok(s) => {
210                    out.modules.insert(reference.clone(), s.record);
211                    if !s.types.is_empty() {
212                        out.types.insert(reference.clone(), s.types);
213                    }
214                    if let Some(p) = s.prefix {
215                        out.prefixes.insert(reference.clone(), p);
216                    }
217                }
218                Err(reason) => out.diagnostics.push(unresolved(reference, pkg, reason)),
219            }
220        }
221        out
222    }
223
224    fn surface(
225        &mut self,
226        entry: &LockEntry,
227        head: &str,
228        pkg: &str,
229        module: &str,
230    ) -> Result<ModuleSurface, String> {
231        let (id, store) = self.locator.open(entry)?;
232        let key = (id.clone(), head.to_string(), module.to_string());
233        if let Some(hit) = self.surfaces.get(&key) {
234            return hit.clone();
235        }
236        let node = (id.clone(), head.to_string());
237        if self.visiting.contains(&node) {
238            // Not cached: whether this is a cycle depends on the path taken.
239            return Err(format!(
240                "dependency cycle: `{pkg}` at {head} depends on itself"
241            ));
242        }
243        let deps = match self.deps.get(&node) {
244            Some(d) => d.clone(),
245            None => {
246                self.visiting.insert(node.clone());
247                let d = self.deps_of(&store, head);
248                self.visiting.remove(&node);
249                self.deps.insert(node.clone(), d.clone());
250                d
251            }
252        };
253        let result = if deps.diagnostics.is_empty() {
254            module_surface_at_op_with(&store, head, pkg, module, &deps).map_err(|e| e.to_string())
255        } else {
256            let inner: Vec<String> = deps.diagnostics.iter().map(|d| d.to_string()).collect();
257            Err(format!(
258                "its own dependencies do not resolve: {}",
259                inner.join("; ")
260            ))
261        };
262        self.surfaces.insert(key, result.clone());
263        result
264    }
265
266    /// A dependency head's own dependencies, resolved against **its** lock.
267    fn deps_of(&mut self, store: &Store, head: &str) -> ResolvedDeps {
268        let refs: Vec<String> = match package_head_at_op(store, head) {
269            Ok(h) => h.flat_imports.keys().cloned().collect(),
270            Err(e) => {
271                let mut d = ResolvedDeps::default();
272                d.diagnostics
273                    .push(unresolved("", "", format!("reading head {head}: {e}")));
274                return d;
275            }
276        };
277        if !refs.iter().any(|r| split_package_import(r).is_some()) {
278            return ResolvedDeps::default();
279        }
280        let lock = store
281            .committed_lock_inherited(head)
282            .ok()
283            .flatten()
284            .and_then(|t| parse_lock(&t));
285        self.resolve_refs(&refs, lock.as_ref())
286    }
287}
288
289/// The surface of `module` in the package head `head_op` of `store` (the
290/// package named `package`), type-checked against its own already-resolved
291/// dependencies `deps` (#943).
292///
293/// A package published whole (`lex publish <dir>`) carries per-file mangle
294/// prefixes. Those are **re-prefixed by package identity** —
295/// [`lex_syntax::package_file_prefix`]`(package, file)`, the prefix a client
296/// that loads the dependency from source gives it — so two packages that
297/// both ship `src/error.lex` never share an `error_<hash>` namespace, whatever
298/// prefix their op-logs were published under. References to the package's own
299/// dependencies (`c.Thing`) are rewritten in type positions to those
300/// dependencies' canonical prefixes, so the exported type declarations are
301/// meaningful in any importer's scope.
302///
303/// A legacy head with no prefixes (a single-file publish) resolves in bare
304/// mode: bare-named record and types, no prefix.
305pub fn module_surface_at_op_with(
306    store: &Store,
307    head_op: &str,
308    package: &str,
309    module: &str,
310    deps: &ResolvedDeps,
311) -> Result<ModuleSurface, StoreError> {
312    let head = package_head_at_op(store, head_op)?;
313    let pairs: Vec<(String, String)> = head
314        .map
315        .iter()
316        .map(|(s, st)| (s.clone(), st.clone()))
317        .collect();
318    let mut decls: Vec<(Option<String>, lex_ast::Stage)> = Vec::new();
319    for ((sig, _), ast) in pairs.iter().zip(store.get_asts_for_sigs_bulk(&pairs)) {
320        decls.push((head.sig_files.get(sig).cloned(), ast?));
321    }
322
323    // import alias → canonical prefix of the dependency module it names.
324    let alias_prefix: BTreeMap<String, String> = head
325        .flat_imports
326        .iter()
327        .filter_map(|(r, alias)| deps.prefixes.get(r).map(|p| (alias.clone(), p.clone())))
328        .collect();
329    let dep_types: Vec<lex_ast::TypeDecl> = {
330        let mut seen = BTreeSet::new();
331        deps.types
332            .values()
333            .flatten()
334            .filter(|d| seen.insert(d.name.clone()))
335            .cloned()
336            .collect()
337    };
338
339    let prefixed = !decls.is_empty()
340        && decls
341            .iter()
342            .all(|(file, s)| file.is_some() && stage_prefix(s).is_some());
343
344    // stored prefix → canonical prefix, and file → canonical prefix.
345    let mut rename: BTreeMap<String, String> = BTreeMap::new();
346    let mut file_prefix: BTreeMap<String, String> = BTreeMap::new();
347    if prefixed {
348        for (file, s) in &decls {
349            let (Some(file), Some(stored)) = (file, stage_prefix(s)) else {
350                continue;
351            };
352            let canonical = lex_syntax::package_file_prefix(package, file);
353            rename.insert(stored, canonical.clone());
354            file_prefix.insert(file.clone(), canonical);
355        }
356    }
357
358    let mut stages: Vec<lex_ast::Stage> = head
359        .flat_imports
360        .iter()
361        .map(|(reference, alias)| {
362            lex_ast::Stage::Import(lex_ast::Import {
363                reference: reference.clone(),
364                alias: alias.clone(),
365            })
366        })
367        .collect();
368    let body: Vec<lex_ast::Stage> = if prefixed {
369        decls
370            .into_iter()
371            .map(|(_, mut s)| {
372                map_stage_names(&mut s, &mut |site, name| {
373                    let Some((q, rest)) = name.split_once('.') else {
374                        return name.to_string();
375                    };
376                    if let Some(c) = rename.get(q) {
377                        return format!("{c}.{rest}");
378                    }
379                    match (site, alias_prefix.get(q)) {
380                        (NameSite::Type, Some(p)) => format!("{p}.{rest}"),
381                        _ => name.to_string(),
382                    }
383                });
384                s
385            })
386            .collect()
387    } else {
388        // Bare mode: de-mangle exactly as the pre-#943 single-module path did.
389        let mut v = match crate::render::demangled_module_stages(store, head_op, module) {
390            Ok(v) => v,
391            Err(_) => crate::render::demangled_head_stages(store, head_op)?,
392        };
393        v.retain(|s| !matches!(s, lex_ast::Stage::Import(_)));
394        for s in &mut v {
395            map_stage_names(s, &mut |site, name| match (site, name.split_once('.')) {
396                (NameSite::Type, Some((q, rest))) => match alias_prefix.get(q) {
397                    Some(p) => format!("{p}.{rest}"),
398                    None => name.to_string(),
399                },
400                _ => name.to_string(),
401            });
402        }
403        v
404    };
405    stages.extend(body.iter().cloned());
406
407    let types =
408        lex_types::check_program_with_deps(&stages, &deps.modules, &deps.types, &deps.prefixes)
409            .map_err(StoreError::TypeError)?;
410
411    let own_types = body.iter().filter_map(|s| match s {
412        lex_ast::Stage::TypeDecl(td) => Some(td.clone()),
413        _ => None,
414    });
415
416    if prefixed {
417        let target = module_file(file_prefix.keys(), module)
418            .or_else(|| {
419                (file_prefix.len() == 1)
420                    .then(|| file_prefix.keys().next().cloned())
421                    .flatten()
422            })
423            .ok_or(StoreError::UnsupportedMultiModuleDependency)?;
424        let prefix = file_prefix[&target].clone();
425        let dot = format!("{prefix}.");
426        let record = lex_types::module_record_from_fields(types.fn_signatures.iter().filter_map(
427            |(name, scheme)| {
428                name.strip_prefix(&dot)
429                    .map(|bare| (bare.to_string(), scheme.ty.clone()))
430            },
431        ));
432        let mut all = dep_types;
433        all.extend(own_types);
434        Ok(ModuleSurface {
435            record,
436            types: all,
437            prefix: Some(prefix),
438        })
439    } else {
440        let record = lex_types::module_record_from_fields(
441            types
442                .fn_signatures
443                .iter()
444                .filter(|(name, _)| !name.contains('.'))
445                .map(|(name, scheme)| (name.clone(), scheme.ty.clone())),
446        );
447        // Bare mode registers `types` under the importer's alias, so only the
448        // package's own bare-named declarations can travel this way.
449        let own: Vec<lex_ast::TypeDecl> = own_types.filter(|t| !t.name.contains('.')).collect();
450        Ok(ModuleSurface {
451            record,
452            types: own,
453            prefix: None,
454        })
455    }
456}