Skip to main content

bynk/
compiler.rs

1//! Locate the `bynkc` compiler the driver shells, and report
2//! **driver↔compiler version skew**.
3//!
4//! Resolution order (ADR: introduce the `bynk` driver):
5//!
6//! 1. an explicit override — the `BYNK_BYNKC` environment variable (the
7//!    `bynk.executablePath`-style escape hatch);
8//! 2. `bynkc` on `PATH`;
9//! 3. a `bynkc` sibling of the running `bynk` binary (mirrors how `vscode-bynk`
10//!    resolves `bynkc-lsp` next to itself).
11//!
12//! An explicit override wins when set — an override that only applied after
13//! auto-discovery failed would be useless. The skew check exists *because* this
14//! resolution can pick a `bynkc` whose version differs from the driver's: once
15//! they are separate binaries, a global `bynk 0.46` can shell a stale `bynkc
16//! 0.44`, and `doctor`'s whole job is to surface exactly that.
17
18use std::path::{Path, PathBuf};
19
20use crate::probe::{Toolbox, Version};
21
22/// How `bynkc` was located.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Origin {
25    /// From the `BYNK_BYNKC` override.
26    Override,
27    /// From the global `PATH`.
28    Path,
29    /// A sibling of the running `bynk` binary.
30    Sibling,
31}
32
33impl Origin {
34    pub fn token(self) -> &'static str {
35        match self {
36            Origin::Override => "override",
37            Origin::Path => "path",
38            Origin::Sibling => "sibling",
39        }
40    }
41}
42
43/// Driver↔compiler version relationship. Patch differences are ignored (they
44/// are wire-compatible under the project's unified versioning); a minor drift
45/// warns; a major drift is a contract mismatch and an error.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Skew {
48    /// Versions match (ignoring patch), or the compiler version is unknown.
49    Match,
50    /// Minor drift — warn (fails only under `--strict`).
51    Minor,
52    /// Major drift — a contract mismatch; an error even on a bare run.
53    Major,
54}
55
56impl Skew {
57    /// Classify the driver version against a resolved compiler version.
58    pub fn classify(driver: Version, compiler: Version) -> Skew {
59        if driver.major != compiler.major {
60            Skew::Major
61        } else if driver.minor != compiler.minor {
62            Skew::Minor
63        } else {
64            Skew::Match
65        }
66    }
67
68    pub fn token(self) -> &'static str {
69        match self {
70            Skew::Match => "match",
71            Skew::Minor => "minor",
72            Skew::Major => "major",
73        }
74    }
75}
76
77/// A resolved (or unresolved) `bynkc`.
78#[derive(Debug, Clone)]
79pub struct Compiler {
80    /// `None` when `bynkc` could not be located at all — the broken compile
81    /// floor, which fails `doctor` even on a bare run.
82    pub path: Option<PathBuf>,
83    pub origin: Option<Origin>,
84    pub version: Option<Version>,
85    /// `None` when there is no compiler, or its version could not be read.
86    pub skew: Option<Skew>,
87}
88
89impl Compiler {
90    pub fn is_resolved(&self) -> bool {
91        self.path.is_some()
92    }
93
94    /// A major skew is a hard floor break even on a bare run.
95    pub fn has_major_skew(&self) -> bool {
96        self.skew == Some(Skew::Major)
97    }
98}
99
100/// Resolve `bynkc` against a [`Toolbox`], given the override (typically
101/// `std::env::var("BYNK_BYNKC")`), the directory of the running `bynk` binary
102/// (for the sibling fallback), and the driver's own version (to classify skew).
103pub fn resolve(
104    tb: &dyn Toolbox,
105    override_path: Option<&Path>,
106    bynk_bin_dir: Option<&Path>,
107    driver: Version,
108) -> Compiler {
109    let (path, origin) = locate(tb, override_path, bynk_bin_dir);
110    let version = path.as_deref().and_then(|p| tb.version(p));
111    let skew = version.map(|v| Skew::classify(driver, v));
112    Compiler {
113        path,
114        origin,
115        version,
116        skew,
117    }
118}
119
120fn locate(
121    tb: &dyn Toolbox,
122    override_path: Option<&Path>,
123    bynk_bin_dir: Option<&Path>,
124) -> (Option<PathBuf>, Option<Origin>) {
125    // An empty override (`BYNK_BYNKC=""`) is treated as unset — resolving a
126    // bare `bynkc` from the current directory was a mild path-hijack surface.
127    if let Some(ovr) = override_path.filter(|p| !p.as_os_str().is_empty()) {
128        // An explicit override is taken as-is when it resolves; we do not fall
129        // through on a bad override, so a typo surfaces rather than silently
130        // picking a different compiler. The lookup uses the override's full
131        // file *name* (PATHEXT-aware on Windows), never its stem — a stem
132        // lookup made `/dir/bynkc.backup` silently resolve `/dir/bynkc`, a
133        // different binary than the one named.
134        let dir = ovr
135            .parent()
136            .filter(|d| !d.as_os_str().is_empty())
137            .unwrap_or(Path::new("."));
138        let name = ovr.file_name().and_then(|n| n.to_str()).unwrap_or_default();
139        if let Some(p) = tb.in_dir(dir, name) {
140            return (Some(p), Some(Origin::Override));
141        }
142        // Set but not found: surface honestly. `doctor` renders its
143        // "override set but not found" failure and delegation refuses with
144        // the misconfigured path named — instead of reporting Ok and then
145        // failing at spawn.
146        return (None, Some(Origin::Override));
147    }
148    if let Some(p) = tb.on_path("bynkc") {
149        return (Some(p), Some(Origin::Path));
150    }
151    if let Some(dir) = bynk_bin_dir
152        && let Some(p) = tb.in_dir(dir, "bynkc")
153    {
154        return (Some(p), Some(Origin::Sibling));
155    }
156    (None, None)
157}
158
159/// #1675: the override for the skew gate below. Every gated command honours
160/// it; `bynk test` also takes it as `--allow-skew`. Any non-empty value allows
161/// a skewed `bynkc` (the same "non-empty means set" contract as the CI
162/// `BYNK_REQUIRE_*` switches).
163pub const ALLOW_SKEW_ENV: &str = "BYNK_ALLOW_SKEW";
164
165/// Whether the skew override is set in the environment.
166pub fn skew_allowed_by_env() -> bool {
167    std::env::var(ALLOW_SKEW_ENV).is_ok_and(|v| !v.is_empty())
168}
169
170/// #1675: what a command that is about to run a *second* compiler does about
171/// its skew from the driver — the same classification `doctor` renders,
172/// acted on. A second `bynkc` runs on these paths: `bynk test` always shells
173/// one, and `bynk check`, `bynk fmt`, `bynk dev` and `bynk deploy` do under a
174/// `BYNK_BYNKC` override. Without this, a skewed `bynkc` could check, format
175/// or test the code with a different compiler, many increments apart,
176/// silently.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum SkewVerdict {
179    /// Matching (patch ignored), or the version is unknown: run, silently.
180    Run,
181    /// Run, after printing this warning.
182    Warn(String),
183    /// Do not run; print this error.
184    Refuse(String),
185}
186
187/// Who is asking, for the message: the command's name, and whether it takes
188/// `--allow-skew`.
189#[derive(Debug, Clone, Copy)]
190pub struct SkewAsker<'a> {
191    /// The `bynk` subcommand (`"test"`), or `None` for the compile shared by
192    /// `dev` and `deploy`, which prints a bare `bynk:` prefix.
193    pub command: Option<&'a str>,
194    /// Whether the command accepts `--allow-skew` (only `bynk test` does), so
195    /// a refusal never advises a flag the command would reject.
196    pub has_flag: bool,
197}
198
199/// Decide what `asker` does about `compiler`'s skew. Minor skew warns; major
200/// skew refuses unless `allow`, in which case it warns instead.
201pub fn skew_verdict(compiler: &Compiler, asker: SkewAsker, allow: bool) -> SkewVerdict {
202    let (Some(skew), Some(ver), Some(path)) = (compiler.skew, compiler.version, &compiler.path)
203    else {
204        return SkewVerdict::Run;
205    };
206    let driver = crate::DRIVER_VERSION;
207    let prefix = match asker.command {
208        Some(c) => format!("bynk {c}:"),
209        None => "bynk:".to_string(),
210    };
211    let which = format!("`bynkc` {ver} ({})", path.display());
212    let remedy = "install a `bynkc` matching `bynk`, point BYNK_BYNKC at one, or run `bynk doctor`";
213    let override_how = if asker.has_flag {
214        format!("pass --allow-skew or set {ALLOW_SKEW_ENV}=1")
215    } else {
216        format!("set {ALLOW_SKEW_ENV}=1")
217    };
218    match skew {
219        Skew::Match => SkewVerdict::Run,
220        Skew::Minor => SkewVerdict::Warn(format!(
221            "{prefix} warning: {which} is a different minor version from bynk {driver} — it may \
222             check this code differently. To align: {remedy}."
223        )),
224        Skew::Major if allow => SkewVerdict::Warn(format!(
225            "{prefix} warning: {which} is a different major version from bynk {driver}; running it \
226             anyway (skew allowed)."
227        )),
228        Skew::Major => SkewVerdict::Refuse(format!(
229            "{prefix} {which} is a different major version from bynk {driver}, so the two do not \
230             share a contract. Refusing to run it. To align: {remedy}. To run it anyway, \
231             {override_how}."
232        )),
233    }
234}
235
236/// Print `verdict`'s message (if any) and say whether to go ahead. A warning
237/// prints once per process: `bynk dev` compiles on every change, and the same
238/// skew would otherwise repeat for the whole watch session.
239pub fn apply_skew_verdict(verdict: SkewVerdict) -> bool {
240    use std::collections::HashSet;
241    use std::sync::{Mutex, OnceLock};
242    static WARNED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
243    match verdict {
244        SkewVerdict::Run => true,
245        SkewVerdict::Warn(msg) => {
246            let first = WARNED
247                .get_or_init(|| Mutex::new(HashSet::new()))
248                .lock()
249                .map(|mut seen| seen.insert(msg.clone()))
250                .unwrap_or(true);
251            if first {
252                eprintln!("{msg}");
253            }
254            true
255        }
256        SkewVerdict::Refuse(msg) => {
257            eprintln!("{msg}");
258            false
259        }
260    }
261}
262
263/// #1675: the whole gate for a command about to run `compiler`: the verdict,
264/// with the override from `flag` or [`ALLOW_SKEW_ENV`], printed, and whether
265/// to go ahead.
266pub fn skew_gate(compiler: &Compiler, asker: SkewAsker, flag: bool) -> bool {
267    apply_skew_verdict(skew_verdict(compiler, asker, flag || skew_allowed_by_env()))
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    /// An in-memory toolbox: `files` are exact (dir, name) pairs that
275    /// resolve; nothing is on PATH unless listed in `on_path`.
276    struct FakeToolbox {
277        files: Vec<(PathBuf, String)>,
278        on_path: Vec<String>,
279    }
280
281    impl Toolbox for FakeToolbox {
282        fn on_path(&self, tool: &str) -> Option<PathBuf> {
283            self.on_path
284                .iter()
285                .any(|t| t == tool)
286                .then(|| PathBuf::from("/usr/bin").join(tool))
287        }
288        fn in_dir(&self, dir: &Path, tool: &str) -> Option<PathBuf> {
289            self.files
290                .iter()
291                .any(|(d, n)| d == dir && n == tool)
292                .then(|| dir.join(tool))
293        }
294        fn version(&self, _path: &Path) -> Option<Version> {
295            None
296        }
297        fn npx_available(&self) -> bool {
298            false
299        }
300    }
301
302    #[test]
303    fn missing_override_resolves_to_none() {
304        // A typo'd override must surface as unresolved — not report Ok and
305        // then fail at spawn (#514).
306        let tb = FakeToolbox {
307            files: vec![],
308            on_path: vec!["bynkc".into()],
309        };
310        let (path, origin) = locate(&tb, Some(Path::new("/opt/missing/bynkc")), None);
311        assert_eq!(path, None);
312        assert_eq!(origin, Some(Origin::Override));
313    }
314
315    #[test]
316    fn empty_override_is_unset() {
317        // `BYNK_BYNKC=""` must not resolve `./bynkc` from the CWD.
318        let tb = FakeToolbox {
319            files: vec![(PathBuf::from("."), "bynkc".into())],
320            on_path: vec!["bynkc".into()],
321        };
322        let (path, origin) = locate(&tb, Some(Path::new("")), None);
323        assert_eq!(origin, Some(Origin::Path));
324        assert_eq!(path, Some(PathBuf::from("/usr/bin/bynkc")));
325    }
326
327    #[test]
328    fn override_never_resolves_by_stem() {
329        // `/dir/bynkc.backup` names one binary; stem-stripping used to pick
330        // the *different* `/dir/bynkc` silently.
331        let tb = FakeToolbox {
332            files: vec![(PathBuf::from("/dir"), "bynkc".into())],
333            on_path: vec![],
334        };
335        let (path, origin) = locate(&tb, Some(Path::new("/dir/bynkc.backup")), None);
336        assert_eq!(path, None, "the named backup binary does not exist");
337        assert_eq!(origin, Some(Origin::Override));
338
339        // And the exact name resolves when present.
340        let tb = FakeToolbox {
341            files: vec![(PathBuf::from("/dir"), "bynkc.backup".into())],
342            on_path: vec![],
343        };
344        let (path, _) = locate(&tb, Some(Path::new("/dir/bynkc.backup")), None);
345        assert_eq!(path, Some(PathBuf::from("/dir/bynkc.backup")));
346    }
347
348    #[test]
349    fn skew_classification() {
350        let v = |a, b, c| Version {
351            major: a,
352            minor: b,
353            patch: c,
354        };
355        assert_eq!(Skew::classify(v(0, 46, 0), v(0, 46, 0)), Skew::Match);
356        // patch drift is wire-compatible
357        assert_eq!(Skew::classify(v(0, 46, 0), v(0, 46, 3)), Skew::Match);
358        assert_eq!(Skew::classify(v(0, 46, 0), v(0, 44, 0)), Skew::Minor);
359        assert_eq!(Skew::classify(v(1, 0, 0), v(0, 46, 0)), Skew::Major);
360    }
361
362    const TEST: SkewAsker<'static> = SkewAsker {
363        command: Some("test"),
364        has_flag: true,
365    };
366
367    fn skewed(skew: Skew) -> Compiler {
368        Compiler {
369            path: Some(PathBuf::from("/usr/bin/bynkc")),
370            origin: Some(Origin::Path),
371            version: Some(Version {
372                major: 0,
373                minor: 1,
374                patch: 0,
375            }),
376            skew: Some(skew),
377        }
378    }
379
380    /// #1675: match runs silently; minor skew warns and runs.
381    #[test]
382    fn minor_skew_warns_and_runs() {
383        assert_eq!(
384            skew_verdict(&skewed(Skew::Match), TEST, false),
385            SkewVerdict::Run
386        );
387        let SkewVerdict::Warn(msg) = skew_verdict(&skewed(Skew::Minor), TEST, false) else {
388            panic!("minor skew must warn");
389        };
390        assert!(msg.contains("different minor version"), "{msg}");
391        assert!(msg.contains("/usr/bin/bynkc"), "names the binary: {msg}");
392    }
393
394    /// #1675: major skew refuses, naming both overrides; allowed, it warns.
395    #[test]
396    fn major_skew_refuses_unless_allowed() {
397        let SkewVerdict::Refuse(msg) = skew_verdict(&skewed(Skew::Major), TEST, false) else {
398            panic!("major skew must refuse");
399        };
400        assert!(
401            msg.contains("--allow-skew") && msg.contains(ALLOW_SKEW_ENV),
402            "{msg}"
403        );
404        let SkewVerdict::Warn(msg) = skew_verdict(&skewed(Skew::Major), TEST, true) else {
405            panic!("allowed major skew must warn, not refuse");
406        };
407        assert!(msg.contains("running it anyway"), "{msg}");
408    }
409
410    /// No compiler, or an unreadable version: nothing to judge, so run (the
411    /// missing-compiler case is reported by the caller).
412    #[test]
413    fn unknown_skew_runs() {
414        let mut c = skewed(Skew::Major);
415        c.skew = None;
416        assert_eq!(skew_verdict(&c, TEST, false), SkewVerdict::Run);
417    }
418
419    /// An allowed *minor* skew still warns, with the minor message.
420    #[test]
421    fn allowed_minor_skew_still_warns() {
422        let SkewVerdict::Warn(msg) = skew_verdict(&skewed(Skew::Minor), TEST, true) else {
423            panic!("minor skew warns whether or not skew is allowed");
424        };
425        assert!(msg.contains("different minor version"), "{msg}");
426    }
427
428    /// A command without `--allow-skew` is never told to pass it; the shared
429    /// `dev`/`deploy` compile prints a bare `bynk:` prefix, not a fake command.
430    #[test]
431    fn refusal_advice_matches_the_command() {
432        for asker in [
433            SkewAsker {
434                command: Some("check"),
435                has_flag: false,
436            },
437            SkewAsker {
438                command: None,
439                has_flag: false,
440            },
441        ] {
442            let SkewVerdict::Refuse(msg) = skew_verdict(&skewed(Skew::Major), asker, false) else {
443                panic!("major skew refuses");
444            };
445            assert!(!msg.contains("--allow-skew"), "{msg}");
446            assert!(msg.contains(ALLOW_SKEW_ENV), "{msg}");
447        }
448        let SkewVerdict::Refuse(msg) = skew_verdict(
449            &skewed(Skew::Major),
450            SkewAsker {
451                command: None,
452                has_flag: false,
453            },
454            false,
455        ) else {
456            panic!("major skew refuses");
457        };
458        assert!(msg.starts_with("bynk: "), "{msg}");
459    }
460}