Skip to main content

bynk_check/
exports.rs

1//! The shareable-unit set (packaging slice 1, #2207; track D1): a package's
2//! `[package] exports` lists the units it shares, and each must be a commons
3//! or an adapter the project declares. A context is never shareable — it is a
4//! deployment unit, whose Worker, secrets and deploy state belong to the
5//! project that deploys it.
6//!
7//! `bynk.toml`'s shape is `bynk-project`'s to check; whether each entry names
8//! a shareable unit needs the project's units, so it is checked here, once
9//! they are known, and attributed to the manifest like the `[unstable]`
10//! diagnostics ([`crate::stability::MANIFEST`]). The build and the editor read
11//! `[package]` the same way ([`bynk_project::read_package_config`]).
12
13use std::collections::{BTreeMap, HashMap};
14use std::path::{Path, PathBuf};
15
16use bynk_project::UnitKind;
17use bynk_syntax::CompileError;
18use bynk_syntax::span::Span;
19
20/// The error for an `exports` entry naming a context.
21pub const EXPORT_CONTEXT: &str = "bynk.project.export_context";
22/// The error for an `exports` entry naming no unit the project declares.
23pub const EXPORT_UNKNOWN: &str = "bynk.project.export_unknown";
24
25/// The `[package] exports` diagnostics for the project at `project_root`,
26/// against its units' `kinds` (as `phase_group` builds them). No project root,
27/// no `[package]`, or no `exports` list is nothing to check.
28pub fn manifest_diagnostics(
29    project_root: Option<&Path>,
30    overlay: &HashMap<PathBuf, String>,
31    kinds: &BTreeMap<String, UnitKind>,
32) -> Vec<CompileError> {
33    let exports = project_root
34        .and_then(|root| bynk_project::read_package_config(root, overlay))
35        .and_then(|package| package.exports)
36        .unwrap_or_default();
37    check_exports(&exports, kinds)
38}
39
40/// The diagnostics for `exports` against `kinds`. A first-party unit is never
41/// the project's own, so it counts as undeclared.
42pub fn check_exports(exports: &[String], kinds: &BTreeMap<String, UnitKind>) -> Vec<CompileError> {
43    let mut out = Vec::new();
44    let mut seen = std::collections::HashSet::new();
45    // An entry listed twice is reported once.
46    for entry in exports.iter().filter(|e| seen.insert(e.as_str())) {
47        let kind = kinds
48            .get(entry)
49            .filter(|_| crate::firstparty::unit(entry).is_none());
50        match kind {
51            Some(UnitKind::Commons | UnitKind::Adapter) => {}
52            Some(UnitKind::Context) => out.push(
53                CompileError::new(
54                    EXPORT_CONTEXT,
55                    Span::default(),
56                    format!(
57                        "`bynk.toml`'s `[package] exports` lists `{entry}`, which is a context — a package shares commons and adapters, never a context"
58                    ),
59                )
60                .with_note(
61                    "a context is a deployment unit, owned by the project that deploys it; remove it from `exports`",
62                ),
63            ),
64            _ => out.push(
65                CompileError::new(
66                    EXPORT_UNKNOWN,
67                    Span::default(),
68                    format!(
69                        "`bynk.toml`'s `[package] exports` lists `{entry}`, which isn't a commons or adapter this project declares"
70                    ),
71                )
72                .with_note(match did_you_mean(entry, kinds) {
73                    Some(name) => format!("did you mean `{name}`?"),
74                    None => "an entry names a commons or adapter declared in this project's sources"
75                        .to_string(),
76                }),
77            ),
78        }
79    }
80    out
81}
82
83/// The shareable unit nearest `entry`, within two edits.
84fn did_you_mean<'a>(entry: &str, kinds: &'a BTreeMap<String, UnitKind>) -> Option<&'a str> {
85    kinds
86        .iter()
87        .filter(|(name, kind)| {
88            matches!(kind, UnitKind::Commons | UnitKind::Adapter)
89                && crate::firstparty::unit(name).is_none()
90        })
91        .map(|(name, _)| (strsim(entry, name), name.as_str()))
92        .filter(|(d, _)| *d <= 2)
93        .min_by_key(|(d, _)| *d)
94        .map(|(_, name)| name)
95}
96
97/// Levenshtein distance over chars.
98fn strsim(a: &str, b: &str) -> usize {
99    let b: Vec<char> = b.chars().collect();
100    let mut row: Vec<usize> = (0..=b.len()).collect();
101    for (i, ca) in a.chars().enumerate() {
102        let mut prev = row[0];
103        row[0] = i + 1;
104        for (j, cb) in b.iter().enumerate() {
105            let cur = row[j + 1];
106            row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1);
107            prev = cur;
108        }
109    }
110    row[b.len()]
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn kinds() -> BTreeMap<String, UnitKind> {
118        BTreeMap::from([
119            ("money".to_string(), UnitKind::Commons),
120            ("payments".to_string(), UnitKind::Adapter),
121            ("shop.checkout".to_string(), UnitKind::Context),
122            ("bynk".to_string(), UnitKind::Commons),
123        ])
124    }
125
126    fn codes(exports: &[&str]) -> Vec<&'static str> {
127        let exports: Vec<String> = exports.iter().map(|s| s.to_string()).collect();
128        check_exports(&exports, &kinds())
129            .iter()
130            .map(|e| {
131                if e.category == EXPORT_CONTEXT {
132                    EXPORT_CONTEXT
133                } else {
134                    EXPORT_UNKNOWN
135                }
136            })
137            .collect()
138    }
139
140    #[test]
141    fn commons_and_adapters_are_shareable() {
142        assert!(codes(&["money", "payments"]).is_empty());
143    }
144
145    #[test]
146    fn a_repeated_entry_is_reported_once() {
147        assert_eq!(codes(&["shop.checkout", "shop.checkout"]), [EXPORT_CONTEXT]);
148    }
149
150    #[test]
151    fn a_context_is_never_shareable() {
152        assert_eq!(codes(&["shop.checkout"]), [EXPORT_CONTEXT]);
153    }
154
155    #[test]
156    fn an_undeclared_or_first_party_unit_is_unknown() {
157        assert_eq!(codes(&["mony", "bynk"]), [EXPORT_UNKNOWN, EXPORT_UNKNOWN]);
158        let notes = &check_exports(&["mony".to_string()], &kinds())[0].notes;
159        assert_eq!(notes, &["did you mean `money`?".to_string()]);
160    }
161}