Skip to main content

bynk_project/
packages.rs

1//! Packaging slice 2a (#2223, track `design/tracks/packaging.md` §3.3, §6.3):
2//! the dependency graph of a project's path dependencies.
3//!
4//! [`resolve_graph`] reads the project's `[dependencies]`, then each path
5//! dependency's own `bynk.toml`, and returns the packages the build loads:
6//! where each lives, the source trees it contributes, what it `exports`, and
7//! which package each of its aliases names. It resolves nothing over a
8//! network and reads only manifests — the overlay first, then the disk, the
9//! way [`crate::read_package_config`] already does — so the CLI, `check` and
10//! the editor build the same graph from the same project root.
11//!
12//! A dependency's units get a **canonical name** that source can't spell
13//! ([`PackageId::encoded`]): the unit `currency` of `acme/money` is
14//! `$acme_money.currency` inside the compiler. `$` is never part of a Bynk
15//! identifier, so the name can't collide with any unit a project declares; it
16//! is a valid TypeScript identifier once `.` becomes `_`, so the emitter's
17//! identifier sites need no change; and [`crate::commons_dir_for`] maps it to
18//! `deps/<org>/<package>/…`, the output layout (decision G). Diagnostics show
19//! it as `acme/money:currency` ([`display_names`]).
20
21use std::collections::{BTreeMap, BTreeSet, HashMap};
22use std::path::{Component, Path, PathBuf};
23
24use crate::discovery::read_source;
25use crate::manifest::{DependencySource, DependencySpec, dependencies_from, package_config_from};
26
27/// A package's identity: `org/package`.
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub struct PackageId {
30    pub org: String,
31    pub package: String,
32}
33
34impl PackageId {
35    /// `org/package`, as `bynk.toml` writes it.
36    pub fn name(&self) -> String {
37        format!("{}/{}", self.org, self.package)
38    }
39
40    /// The first segment of every canonical unit name in this package:
41    /// `$` + the org (each `-` written `$`) + `_` + the package. An org is
42    /// `[a-z][a-z0-9-]*`, so it never contains `_`, and the first `_` always
43    /// ends it: the encoding is injective, and [`PackageId::decode`] inverts it.
44    pub fn encoded(&self) -> String {
45        format!("${}_{}", self.org.replace('-', "$"), self.package)
46    }
47
48    /// Where this package's units are emitted, relative to `out/`.
49    pub fn out_dir(&self) -> PathBuf {
50        PathBuf::from("deps").join(&self.org).join(&self.package)
51    }
52
53    /// The canonical name of `unit` in this package.
54    pub fn canonical(&self, unit: &str) -> String {
55        format!("{}.{unit}", self.encoded())
56    }
57
58    /// The package an encoded first segment names, or `None` when `segment`
59    /// isn't one.
60    pub fn decode(segment: &str) -> Option<PackageId> {
61        let rest = segment.strip_prefix('$')?;
62        let (org, package) = rest.split_once('_')?;
63        if org.is_empty() || package.is_empty() {
64            return None;
65        }
66        Some(PackageId {
67            org: org.replace('$', "-"),
68            package: package.to_owned(),
69        })
70    }
71}
72
73/// Split a canonical dependency unit name into its package and the unit name
74/// the package itself declares. `None` for a project's own or first-party unit.
75pub fn split_canonical(name: &str) -> Option<(PackageId, &str)> {
76    let (first, rest) = name.split_once('.')?;
77    Some((PackageId::decode(first)?, rest))
78}
79
80/// `name` as a person reads it: a canonical dependency unit name becomes
81/// `org/package:unit`; any other name is returned unchanged.
82pub fn display_unit_name(name: &str) -> String {
83    match split_canonical(name) {
84        Some((id, unit)) => format!("{}:{unit}", id.name()),
85        None => name.to_owned(),
86    }
87}
88
89/// Rewrite every canonical dependency unit name in `text` (a diagnostic's
90/// message or note) to its [`display_unit_name`] form, and a bare encoded
91/// package segment to `org/package`.
92pub fn display_names(text: &str) -> String {
93    if !text.contains('$') {
94        return text.to_owned();
95    }
96    let mut out = String::with_capacity(text.len());
97    let mut rest = text;
98    while let Some(at) = rest.find('$') {
99        out.push_str(&rest[..at]);
100        let tail = &rest[at..];
101        // The encoded segment runs over `$`, `_` and `[a-z0-9]`.
102        let end = tail[1..]
103            .find(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '$'))
104            .map_or(tail.len(), |i| i + 1);
105        let segment = &tail[..end];
106        match PackageId::decode(segment) {
107            Some(id) if tail[end..].starts_with('.') => {
108                out.push_str(&id.name());
109                out.push(':');
110                rest = &tail[end + 1..];
111            }
112            Some(id) => {
113                out.push_str(&id.name());
114                rest = &tail[end..];
115            }
116            None => {
117                out.push('$');
118                rest = &tail[1..];
119            }
120        }
121    }
122    out.push_str(rest);
123    out
124}
125
126/// One dependency package in a resolved graph.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Package {
129    pub id: PackageId,
130    /// The directory holding its `bynk.toml`, as the graph reached it.
131    pub dir: PathBuf,
132    /// Its source trees as `(root, identity prefix)` pairs, in the shape
133    /// [`crate::Roots::trees`] returns. The prefix is the tree's path relative
134    /// to the consuming project's root, so a diagnostic in a dependency names
135    /// a real file.
136    pub trees: Vec<(PathBuf, PathBuf)>,
137    /// `[package] exports`, when listed; `None` shares every commons and
138    /// adapter (ADR 0542).
139    pub exports: Option<Vec<String>>,
140    /// Each of its own aliases, and the package (an index into
141    /// [`PackageGraph::packages`]) it names.
142    pub aliases: BTreeMap<String, usize>,
143    /// #2240: whether it came from the store (a git dependency), so its own
144    /// path dependencies are never honoured.
145    pub fetched: bool,
146}
147
148/// A project's resolved dependency graph (single version: one entry per
149/// package, however many aliases reach it).
150#[derive(Debug, Clone, Default, PartialEq, Eq)]
151pub struct PackageGraph {
152    /// The consuming project's aliases.
153    pub root_aliases: BTreeMap<String, usize>,
154    /// #2240: the aliases of `root_aliases` that come only from
155    /// `[dev-dependencies]`, so only a suite may reach them.
156    pub dev_aliases: BTreeSet<String>,
157    /// #2240: the project's aliases whose package couldn't be resolved (a
158    /// problem already reported, such as `not_fetched`), so a reference
159    /// through one is dropped rather than reported again as unknown.
160    pub unresolved_aliases: BTreeSet<String>,
161    pub packages: Vec<Package>,
162    /// #2296: the npm `(package, range)` pairs `bynk.lock` approves — what a
163    /// fetched package's adapter may bring into the build.
164    pub npm_approved: BTreeSet<(String, String)>,
165}
166
167impl PackageGraph {
168    /// Every dependency source tree, in package order.
169    pub fn trees(&self) -> Vec<(PathBuf, PathBuf)> {
170        self.packages
171            .iter()
172            .flat_map(|p| p.trees.iter().cloned())
173            .collect()
174    }
175
176    /// The package whose source tree contains `path` — a parsed file's
177    /// `abs_path`, which is `std::path::absolute` of the path discovery found,
178    /// so each tree root is compared in that same form.
179    ///
180    /// Both sides are resolved lexically too, so `shop/../packages/money/…`
181    /// (a project root joined with a dependency file's identity path) matches.
182    pub fn package_of(&self, path: &Path) -> Option<usize> {
183        let path = lexical(&std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()));
184        self.packages.iter().position(|p| {
185            p.trees.iter().any(|(root, _)| {
186                let root = lexical(&std::path::absolute(root).unwrap_or_else(|_| root.clone()));
187                path.starts_with(&root)
188            })
189        })
190    }
191
192    /// #2296: the shortest chain of packages from the consuming project to
193    /// package `target`, as their `org/package` names.
194    pub fn chain_to(&self, target: usize) -> Vec<String> {
195        let mut parent: Vec<Option<Option<usize>>> = vec![None; self.packages.len()];
196        let mut queue: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
197        for &i in self.root_aliases.values() {
198            if parent[i].is_none() {
199                parent[i] = Some(None);
200                queue.push_back(i);
201            }
202        }
203        while let Some(i) = queue.pop_front() {
204            for &j in self.packages[i].aliases.values() {
205                if parent[j].is_none() {
206                    parent[j] = Some(Some(i));
207                    queue.push_back(j);
208                }
209            }
210        }
211        let mut chain = Vec::new();
212        let mut at = Some(target);
213        while let Some(i) = at {
214            chain.push(self.packages[i].id.name());
215            at = parent[i].flatten();
216            if chain.len() > self.packages.len() {
217                break;
218            }
219        }
220        chain.reverse();
221        chain
222    }
223
224    /// The package with this identity, if the graph has it.
225    pub fn by_id(&self, id: &PackageId) -> Option<usize> {
226        self.packages.iter().position(|p| &p.id == id)
227    }
228}
229
230/// What is wrong with a graph, as a coded diagnostic's parts. `bynk-check`
231/// turns each into a `CompileError` attributed to `bynk.toml`.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct GraphProblem {
234    pub code: &'static str,
235    pub message: String,
236    pub note: Option<String>,
237}
238
239impl GraphProblem {
240    fn new(code: &'static str, message: String) -> Self {
241        GraphProblem {
242            code,
243            message,
244            note: None,
245        }
246    }
247
248    fn with_note(mut self, note: impl Into<String>) -> Self {
249        self.note = Some(note.into());
250        self
251    }
252}
253
254/// A dependency's `bynk.toml` isn't readable, or isn't a well-formed package.
255pub const DEPENDENCY_NOT_FOUND: &str = "bynk.package.dependency_not_found";
256/// A path dependency's `[package] name` isn't the package its entry declares.
257pub const NAME_MISMATCH: &str = "bynk.package.name_mismatch";
258/// One package reached from two different directories.
259pub const DUPLICATE_SOURCE: &str = "bynk.package.duplicate_source";
260/// The package graph has a cycle.
261pub const DEPENDENCY_CYCLE: &str = "bynk.package.dependency_cycle";
262/// A path dependency declared by a dependency, outside the workspace.
263pub const PATH_OUTSIDE_ROOT: &str = "bynk.dependency.path_outside_root";
264/// #2240: a git dependency with no lock entry, or whose store entry is
265/// missing: `bynk fetch` fetches it.
266pub const NOT_FETCHED: &str = "bynk.package.not_fetched";
267/// #2240: one package reached at two different git commits.
268pub const VERSION_CONFLICT: &str = "bynk.package.version_conflict";
269/// #2240: `bynk.lock` can't be read.
270pub const LOCK_INVALID: &str = "bynk.package.lock_invalid";
271/// #2274: a locked registry package whose organisation now routes elsewhere.
272pub const SOURCE_MISMATCH: &str = "bynk.registry.source_mismatch";
273/// #2274: an organisation no registry serves (the first-party registry isn't
274/// live yet).
275pub const UNAVAILABLE: &str = "bynk.registry.unavailable";
276
277/// #2274: organisation → registry name, from the `[registries]` of the
278/// manifest at `root`.
279pub fn routes_of(root: &Path, overlay: &HashMap<PathBuf, String>) -> BTreeMap<String, String> {
280    let mut out = BTreeMap::new();
281    let Some((doc, _)) = read_manifest(root, overlay) else {
282        return out;
283    };
284    let Some(table) = doc.get("registries").and_then(|v| v.as_table()) else {
285        return out;
286    };
287    for r in crate::manifest::registries_from(table).unwrap_or_default() {
288        for org in r.organisations {
289            out.insert(org, r.name.clone());
290        }
291    }
292    out
293}
294
295/// Resolve the path-dependency graph of the project at `project_root`.
296/// `None` with no problems when the project declares no dependencies.
297///
298/// A path dependency in the project's own manifest is the author's own file
299/// and is honoured wherever it points. One declared by a *dependency* is
300/// honoured only inside the workspace the project belongs to (track §6.3,
301/// decision F): outside a workspace it is refused, so no dependency can make
302/// the build read a directory the author never named.
303pub fn resolve_graph(
304    project_root: &Path,
305    overlay: &HashMap<PathBuf, String>,
306) -> (Option<PackageGraph>, Vec<GraphProblem>) {
307    let mut problems = Vec::new();
308    let Some(root_doc) = read_manifest(project_root, overlay) else {
309        return (None, problems);
310    };
311    let deps = specs_of(&root_doc);
312    let dev = dev_specs_of(&root_doc);
313    if deps.is_empty() && dev.is_empty() {
314        return (None, problems);
315    }
316    let normal: BTreeSet<String> = deps.iter().map(|s| s.alias.clone()).collect();
317    let root_specs: Vec<(DependencySpec, bool)> = deps
318        .into_iter()
319        .map(|s| (s, false))
320        .chain(
321            dev.into_iter()
322                .filter(|s| !normal.contains(&s.alias))
323                .map(|s| (s, true)),
324        )
325        .collect();
326    // #2240: git dependencies resolve through the lock to the store.
327    let lock = match crate::lock::read_lock(&crate::lock::lock_root(project_root), overlay) {
328        Ok(lock) => lock,
329        Err(e) => {
330            problems.push(GraphProblem::new(LOCK_INVALID, e.0));
331            None
332        }
333    };
334    let home = crate::lock::bynk_home();
335    // #2274: organisation → registry name, from the root's (or its
336    // workspace's) `[registries]` — the only map that routes.
337    let routes = routes_of(&crate::lock::lock_root(project_root), overlay);
338    let workspace = crate::workspace::find_parent_workspace(project_root).or_else(|| {
339        crate::workspace::is_workspace_root(project_root).then(|| project_root.to_path_buf())
340    });
341    let workspace = workspace.map(|w| real(&w));
342    let root_real = real(project_root);
343
344    let mut graph = PackageGraph {
345        npm_approved: lock
346            .iter()
347            .flat_map(|l| &l.npm)
348            .filter(|n| n.approved)
349            .map(|n| (n.package.clone(), n.range.clone()))
350            .collect(),
351        ..PackageGraph::default()
352    };
353    // Directory (real) of each package, for duplicate and cycle detection.
354    let mut dirs: Vec<PathBuf> = Vec::new();
355    // (declaring package or `None` for the root, its directory, spec, dev-only)
356    let mut queue: Vec<(Option<usize>, PathBuf, DependencySpec, bool)> = root_specs
357        .into_iter()
358        .map(|(s, dev)| (None, project_root.to_path_buf(), s, dev))
359        .collect();
360    let mut cursor = 0;
361    while cursor < queue.len() {
362        let (declarer, declarer_dir, spec, dev) = queue[cursor].clone();
363        cursor += 1;
364        let declarer_fetched = declarer.is_some_and(|i| graph.packages[i].fetched);
365        let who = match declarer {
366            None => "this project".to_string(),
367            Some(i) => format!("dependency `{}`", graph.packages[i].id.name()),
368        };
369        let mut fetched = false;
370        let (dir, rel) = match &spec.source {
371            DependencySource::Path(p) => {
372                if declarer_fetched {
373                    problems.push(GraphProblem::new(
374                        PATH_OUTSIDE_ROOT,
375                        format!(
376                            "{who} declares the path dependency `{}` (`{p}`), and a fetched package's path dependencies are never honoured",
377                            spec.alias
378                        ),
379                    ));
380                    continue;
381                }
382                (lexical_join(&declarer_dir, Path::new(p)), p.clone())
383            }
384            DependencySource::Git { url, rev } => {
385                if declarer_fetched && url.to_ascii_lowercase().starts_with("file:") {
386                    problems.push(GraphProblem::new(
387                        PATH_OUTSIDE_ROOT,
388                        format!(
389                            "{who} declares the git dependency `{}` at the local URL `{url}`, and a fetched package can't reach a local repository",
390                            spec.alias
391                        ),
392                    ));
393                    continue;
394                }
395                let store = lock
396                    .as_ref()
397                    .and_then(|l| l.git(&spec.package, url, rev))
398                    .and_then(|(entry, _)| {
399                        crate::lock::store_dir(home.as_deref()?, entry.checksum.as_deref()?)
400                    })
401                    .filter(|d| d.is_dir());
402                let Some(store) = store else {
403                    problems.push(GraphProblem::new(
404                        NOT_FETCHED,
405                        format!(
406                            "{who}'s git dependency `{}` (`{url}` at `{rev}`) hasn't been fetched",
407                            spec.alias
408                        ),
409                    ).with_note("run `bynk fetch` (or any `bynk` command without `--offline`) to fetch it and update `bynk.lock`"));
410                    continue;
411                };
412                fetched = true;
413                (store, format!("{url} at {rev}"))
414            }
415            DependencySource::Version(range) => {
416                // #2274: a registry dependency resolves through the lock to
417                // the store, on the registry its organisation routes to.
418                let org = spec.package.split('/').next().unwrap_or_default();
419                let routed = routes
420                    .get(org)
421                    .cloned()
422                    .unwrap_or_else(|| crate::lock::FIRST_PARTY_REGISTRY.to_string());
423                let locked = lock.as_ref().and_then(|l| l.package(&spec.package));
424                let locked_registry =
425                    locked.and_then(|p| crate::lock::parse_registry_source(&p.source));
426                if let Some(other) = locked_registry
427                    && other != routed
428                {
429                    problems.push(GraphProblem::new(
430                        SOURCE_MISMATCH,
431                        format!(
432                            "`bynk.lock` locks `{}` to the registry `{other}`, but its organisation `{org}` now routes to `{routed}`",
433                            spec.package
434                        ),
435                    ).with_note("a misrouted organisation fails rather than resolving a same-named package elsewhere: restore the `[registries]` entry, or delete the lock entry and run `bynk update` to move it deliberately"));
436                    continue;
437                }
438                let store = locked
439                    .filter(|_| locked_registry.is_some())
440                    .and_then(|p| crate::lock::store_dir(home.as_deref()?, p.checksum.as_deref()?))
441                    .filter(|d| d.is_dir());
442                let Some(store) = store else {
443                    if routed == crate::lock::FIRST_PARTY_REGISTRY
444                        && crate::lock::FIRST_PARTY.is_none()
445                    {
446                        problems.push(GraphProblem::new(
447                            UNAVAILABLE,
448                            format!(
449                                "{who}'s dependency `{}` is in the organisation `{org}`, which no registry serves — the first-party registry isn't live yet",
450                                spec.alias
451                            ),
452                        ).with_note(format!("add a `[registries.<name>]` whose `organisations` lists `{org}`")));
453                    } else {
454                        problems.push(GraphProblem::new(
455                            NOT_FETCHED,
456                            format!(
457                                "{who}'s registry dependency `{}` (`{}` at `{range}`, from `{routed}`) hasn't been fetched",
458                                spec.alias, spec.package
459                            ),
460                        ).with_note("run `bynk fetch` (or any `bynk` command without `--offline`) to fetch it and update `bynk.lock`"));
461                    }
462                    continue;
463                };
464                fetched = true;
465                (store, format!("{} {range} from {routed}", spec.package))
466            }
467        };
468        let dir_real = real(&dir);
469        if declarer.is_some() && !fetched {
470            let inside = workspace.as_ref().is_some_and(|w| dir_real.starts_with(w));
471            if !inside {
472                problems.push(GraphProblem::new(
473                    PATH_OUTSIDE_ROOT,
474                    format!(
475                        "{who} declares the path dependency `{}` (`{rel}`), which is outside {}",
476                        spec.alias,
477                        if workspace.is_some() { "the workspace" } else { "any workspace" }
478                    ),
479                ).with_note("a dependency's own path dependencies are honoured only between members of one workspace"));
480                continue;
481            }
482        }
483        if dir_real == root_real {
484            problems.push(GraphProblem::new(
485                DEPENDENCY_CYCLE,
486                format!(
487                    "{who}'s dependency `{}` is this project itself, so the package graph has a cycle",
488                    spec.alias
489                ),
490            ));
491            continue;
492        }
493        let index = if let Some(existing) = dirs.iter().position(|d| *d == dir_real) {
494            let id = &graph.packages[existing].id;
495            if id.name() != spec.package {
496                problems.push(GraphProblem::new(
497                    NAME_MISMATCH,
498                    format!(
499                        "{who}'s dependency `{}` declares `package = \"{}\"`, but `{rel}` is the package `{}`",
500                        spec.alias,
501                        spec.package,
502                        id.name()
503                    ),
504                ));
505                continue;
506            }
507            existing
508        } else {
509            let Some(doc) = read_manifest(&dir, overlay) else {
510                problems.push(GraphProblem::new(
511                    DEPENDENCY_NOT_FOUND,
512                    format!(
513                        "{who}'s dependency `{}` points at `{rel}`, which has no readable `bynk.toml`",
514                        spec.alias
515                    ),
516                ));
517                continue;
518            };
519            if let Err(e) = crate::check_manifest_str(&doc.1) {
520                problems.push(GraphProblem::new(
521                    DEPENDENCY_NOT_FOUND,
522                    format!(
523                        "dependency `{}`'s `bynk.toml` is invalid: {e}",
524                        spec.package
525                    ),
526                ));
527                continue;
528            }
529            let Some(config) = doc
530                .0
531                .get("package")
532                .and_then(|v| v.as_table())
533                .and_then(|t| package_config_from(t).ok())
534            else {
535                problems.push(GraphProblem::new(
536                    DEPENDENCY_NOT_FOUND,
537                    format!(
538                        "{who}'s dependency `{}` points at `{rel}`, which isn't a package: its `bynk.toml` has no `[package]`",
539                        spec.alias
540                    ),
541                ));
542                continue;
543            };
544            let id = PackageId {
545                org: config.org.clone(),
546                package: config.package.clone(),
547            };
548            if id.name() != spec.package {
549                problems.push(GraphProblem::new(
550                    NAME_MISMATCH,
551                    format!(
552                        "{who}'s dependency `{}` declares `package = \"{}\"`, but `{rel}` is the package `{}`",
553                        spec.alias,
554                        spec.package,
555                        id.name()
556                    ),
557                ));
558                continue;
559            }
560            if let Some(other) = graph.by_id(&id)
561                && (fetched || graph.packages[other].fetched)
562            {
563                problems.push(GraphProblem::new(
564                    VERSION_CONFLICT,
565                    format!(
566                        "the package `{}` is reached at two versions: `{}` and `{}`",
567                        id.name(),
568                        graph.packages[other].dir.display(),
569                        dir.display()
570                    ),
571                ).with_note("a build holds one copy of each package; point every dependency on it at the same revision"));
572                continue;
573            }
574            if let Some(other) = graph.by_id(&id) {
575                problems.push(GraphProblem::new(
576                    DUPLICATE_SOURCE,
577                    format!(
578                        "the package `{}` is reached from two directories: `{}` and `{}`",
579                        id.name(),
580                        graph.packages[other].dir.display(),
581                        dir.display()
582                    ),
583                ).with_note("a build holds one copy of each package; point every dependency on it at the same directory"));
584                continue;
585            }
586            let trees = source_trees(project_root, &dir, &doc.0, fetched);
587            graph.packages.push(Package {
588                id,
589                dir: dir.clone(),
590                trees,
591                exports: config.exports,
592                aliases: BTreeMap::new(),
593                fetched,
594            });
595            dirs.push(dir_real.clone());
596            let index = graph.packages.len() - 1;
597            for s in specs_of(&doc) {
598                queue.push((Some(index), dir.clone(), s, false));
599            }
600            index
601        };
602        match declarer {
603            None => {
604                graph.root_aliases.insert(spec.alias.clone(), index);
605                if dev {
606                    graph.dev_aliases.insert(spec.alias.clone());
607                }
608            }
609            Some(i) => {
610                graph.packages[i].aliases.insert(spec.alias.clone(), index);
611            }
612        }
613    }
614    for (_, _, spec, _) in queue.iter().filter(|(d, ..)| d.is_none()) {
615        if !graph.root_aliases.contains_key(&spec.alias) {
616            graph.unresolved_aliases.insert(spec.alias.clone());
617        }
618    }
619    if let Some(cycle) = find_cycle(&graph) {
620        problems.push(GraphProblem::new(
621            DEPENDENCY_CYCLE,
622            format!(
623                "the package graph has a cycle: {}",
624                cycle
625                    .iter()
626                    .map(|&i| format!("`{}`", graph.packages[i].id.name()))
627                    .collect::<Vec<_>>()
628                    .join(" → ")
629            ),
630        ));
631    }
632    if graph.packages.is_empty()
633        && graph.root_aliases.is_empty()
634        && graph.unresolved_aliases.is_empty()
635    {
636        return (None, problems);
637    }
638    (Some(graph), problems)
639}
640
641/// Every dependency source tree of the project at `project_root` — what
642/// discovery walks and what the CLI's source map partitions into. Problems are
643/// [`resolve_graph`]'s to report.
644pub fn dependency_trees(
645    project_root: &Path,
646    overlay: &HashMap<PathBuf, String>,
647) -> Vec<(PathBuf, PathBuf)> {
648    resolve_graph(project_root, overlay)
649        .0
650        .map(|g| g.trees())
651        .unwrap_or_default()
652}
653
654/// The parsed manifest at `dir` and its text: the overlay first, then the disk,
655/// as [`crate::read_package_config`] reads it.
656fn read_manifest(dir: &Path, overlay: &HashMap<PathBuf, String>) -> Option<(toml::Table, String)> {
657    let path = dir.join("bynk.toml");
658    let text = match read_source(&path, overlay) {
659        Ok(text) => text,
660        Err(_) => std::fs::read_to_string(&path).ok()?,
661    };
662    let doc = text.parse::<toml::Table>().ok()?;
663    Some((doc, text))
664}
665
666/// A manifest's `[dependencies]` entries. A malformed table is
667/// `check_manifest`'s to report, so it contributes nothing here.
668fn specs_of(doc: &(toml::Table, String)) -> Vec<DependencySpec> {
669    doc.0
670        .get("dependencies")
671        .and_then(|v| v.as_table())
672        .and_then(|t| dependencies_from("dependencies", t).ok())
673        .unwrap_or_default()
674}
675
676/// #2240: a manifest's `[dev-dependencies]` entries — read from the project
677/// itself only; a dependency's dev-dependencies are its own business.
678fn dev_specs_of(doc: &(toml::Table, String)) -> Vec<DependencySpec> {
679    doc.0
680        .get("dev-dependencies")
681        .and_then(|v| v.as_table())
682        .and_then(|t| dependencies_from("dev-dependencies", t).ok())
683        .unwrap_or_default()
684}
685
686/// A dependency's source trees: its `[paths] include`, or `src`, without any
687/// `tests` tree — a dependency's suites are its own to run.
688fn source_trees(
689    project_root: &Path,
690    dir: &Path,
691    doc: &toml::Table,
692    fetched: bool,
693) -> Vec<(PathBuf, PathBuf)> {
694    let include: Vec<String> = match doc
695        .get("paths")
696        .and_then(|v| v.as_table())
697        .and_then(|t| t.get("include"))
698    {
699        Some(toml::Value::Array(items)) => items
700            .iter()
701            .filter_map(|v| v.as_str().map(str::to_owned))
702            .collect(),
703        Some(toml::Value::String(s)) => vec![s.clone()],
704        _ => vec!["src".to_string()],
705    };
706    let mut out: Vec<(PathBuf, PathBuf)> = Vec::new();
707    for entry in include {
708        if Path::new(&entry).file_name().is_some_and(|n| n == "tests") {
709            continue;
710        }
711        let root = lexical_join(dir, Path::new(&entry));
712        if out.iter().any(|(r, _)| *r == root) {
713            continue;
714        }
715        // A store tree is named by its own (absolute) path: a path relative
716        // to the project would climb out through the user's home directory.
717        let prefix = if fetched {
718            root.clone()
719        } else {
720            relative_to(project_root, &root)
721        };
722        out.push((root, prefix));
723    }
724    out
725}
726
727/// #2255: a dependency package's source roots — what the compile walks for
728/// it ([`resolve_graph`]): its `[paths] include`, or `src`, never `tests`.
729pub fn package_source_roots(dir: &Path, doc: &toml::Table) -> Vec<PathBuf> {
730    source_trees(dir, dir, doc, true)
731        .into_iter()
732        .map(|(root, _)| root)
733        .collect()
734}
735
736/// A cycle among dependency packages, as the packages on it in order.
737fn find_cycle(graph: &PackageGraph) -> Option<Vec<usize>> {
738    fn visit(
739        graph: &PackageGraph,
740        node: usize,
741        state: &mut Vec<u8>,
742        stack: &mut Vec<usize>,
743    ) -> Option<Vec<usize>> {
744        state[node] = 1;
745        stack.push(node);
746        for &next in graph.packages[node].aliases.values() {
747            if state[next] == 1 {
748                let start = stack.iter().position(|&n| n == next).unwrap_or(0);
749                let mut cycle = stack[start..].to_vec();
750                cycle.push(next);
751                return Some(cycle);
752            }
753            if state[next] == 0
754                && let Some(c) = visit(graph, next, state, stack)
755            {
756                return Some(c);
757            }
758        }
759        stack.pop();
760        state[node] = 2;
761        None
762    }
763    let mut state = vec![0u8; graph.packages.len()];
764    for start in 0..graph.packages.len() {
765        if state[start] == 0 {
766            let mut stack = Vec::new();
767            if let Some(c) = visit(graph, start, &mut state, &mut stack) {
768                return Some(c);
769            }
770        }
771    }
772    None
773}
774
775/// `base.join(rel)` with `.` and `..` resolved lexically, keeping `base`'s
776/// own form (relative or absolute), so the trees the CLI walks and the trees
777/// `compile_project` partitions by agree on every path.
778fn lexical_join(base: &Path, rel: &Path) -> PathBuf {
779    if rel.is_absolute() {
780        return lexical(rel);
781    }
782    lexical(&base.join(rel))
783}
784
785/// `p` with `.` and `..` resolved lexically. A leading `..` that can't pop is
786/// kept, so `../money` stays `../money`.
787fn lexical(p: &Path) -> PathBuf {
788    let mut out: Vec<Component> = Vec::new();
789    for c in p.components() {
790        match c {
791            Component::CurDir => {}
792            Component::ParentDir => match out.last() {
793                Some(Component::Normal(_)) => {
794                    out.pop();
795                }
796                Some(Component::RootDir | Component::Prefix(_)) => {}
797                _ => out.push(c),
798            },
799            other => out.push(other),
800        }
801    }
802    if out.is_empty() {
803        return PathBuf::from(".");
804    }
805    out.iter().collect()
806}
807
808/// `target` relative to `base`, lexically: the identity prefix of a dependency
809/// tree, so a diagnostic in it names a real path from the project root. When
810/// the two can't be related (one absolute, one not), `target` itself.
811fn relative_to(base: &Path, target: &Path) -> PathBuf {
812    let base = lexical(base);
813    let target = lexical(target);
814    if base.is_absolute() != target.is_absolute() {
815        return target;
816    }
817    let b: Vec<Component> = base
818        .components()
819        .filter(|c| *c != Component::CurDir)
820        .collect();
821    let t: Vec<Component> = target
822        .components()
823        .filter(|c| *c != Component::CurDir)
824        .collect();
825    let common = b.iter().zip(&t).take_while(|(x, y)| x == y).count();
826    let mut out = PathBuf::new();
827    for _ in common..b.len() {
828        out.push("..");
829    }
830    for c in &t[common..] {
831        out.push(c.as_os_str());
832    }
833    out
834}
835
836/// `p` with symlinks followed, when it exists; otherwise lexically resolved.
837/// Confinement and duplicate detection compare these.
838fn real(p: &Path) -> PathBuf {
839    p.canonicalize().unwrap_or_else(|_| {
840        std::env::current_dir()
841            .map(|cwd| lexical_join(&cwd, p))
842            .unwrap_or_else(|_| lexical(p))
843    })
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    #[test]
851    fn encoding_round_trips_and_is_injective() {
852        let ids = [
853            ("acme", "money"),
854            ("acme-labs", "ledger_kit"),
855            ("a--b", "c"),
856            ("a-", "b_c"),
857            ("a", "b_c"),
858        ];
859        let mut seen = std::collections::HashSet::new();
860        for (org, package) in ids {
861            let id = PackageId {
862                org: org.into(),
863                package: package.into(),
864            };
865            let enc = id.encoded();
866            assert!(seen.insert(enc.clone()), "{enc} collides");
867            assert_eq!(PackageId::decode(&enc), Some(id.clone()));
868            // A valid TypeScript identifier once `.` becomes `_`.
869            let ident = id.canonical("currency.rates").replace('.', "_");
870            assert!(
871                ident
872                    .chars()
873                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
874            );
875        }
876    }
877
878    #[test]
879    fn display_rewrites_canonical_names_only() {
880        assert_eq!(
881            display_names("unknown commons `$acme_money.currency` here"),
882            "unknown commons `acme/money:currency` here"
883        );
884        assert_eq!(
885            display_names("`$acme$labs_ledger_kit.a.b`"),
886            "`acme-labs/ledger_kit:a.b`"
887        );
888        assert_eq!(display_names("costs $5"), "costs $5");
889        assert_eq!(display_names("no dollars"), "no dollars");
890    }
891
892    #[test]
893    fn relative_and_lexical_paths() {
894        assert_eq!(lexical(Path::new("a/./b/../c")), PathBuf::from("a/c"));
895        assert_eq!(lexical(Path::new("../money")), PathBuf::from("../money"));
896        assert_eq!(
897            relative_to(
898                Path::new("/w/apps/shop"),
899                Path::new("/w/packages/money/src")
900            ),
901            PathBuf::from("../../packages/money/src")
902        );
903        assert_eq!(
904            relative_to(Path::new("."), Path::new("vendor/money/src")),
905            PathBuf::from("vendor/money/src")
906        );
907    }
908}