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#[cfg(test)]
160mod tests {
161    use super::*;
162
163    /// An in-memory toolbox: `files` are exact (dir, name) pairs that
164    /// resolve; nothing is on PATH unless listed in `on_path`.
165    struct FakeToolbox {
166        files: Vec<(PathBuf, String)>,
167        on_path: Vec<String>,
168    }
169
170    impl Toolbox for FakeToolbox {
171        fn on_path(&self, tool: &str) -> Option<PathBuf> {
172            self.on_path
173                .iter()
174                .any(|t| t == tool)
175                .then(|| PathBuf::from("/usr/bin").join(tool))
176        }
177        fn in_dir(&self, dir: &Path, tool: &str) -> Option<PathBuf> {
178            self.files
179                .iter()
180                .any(|(d, n)| d == dir && n == tool)
181                .then(|| dir.join(tool))
182        }
183        fn version(&self, _path: &Path) -> Option<Version> {
184            None
185        }
186        fn npx_available(&self) -> bool {
187            false
188        }
189    }
190
191    #[test]
192    fn missing_override_resolves_to_none() {
193        // A typo'd override must surface as unresolved — not report Ok and
194        // then fail at spawn (#514).
195        let tb = FakeToolbox {
196            files: vec![],
197            on_path: vec!["bynkc".into()],
198        };
199        let (path, origin) = locate(&tb, Some(Path::new("/opt/missing/bynkc")), None);
200        assert_eq!(path, None);
201        assert_eq!(origin, Some(Origin::Override));
202    }
203
204    #[test]
205    fn empty_override_is_unset() {
206        // `BYNK_BYNKC=""` must not resolve `./bynkc` from the CWD.
207        let tb = FakeToolbox {
208            files: vec![(PathBuf::from("."), "bynkc".into())],
209            on_path: vec!["bynkc".into()],
210        };
211        let (path, origin) = locate(&tb, Some(Path::new("")), None);
212        assert_eq!(origin, Some(Origin::Path));
213        assert_eq!(path, Some(PathBuf::from("/usr/bin/bynkc")));
214    }
215
216    #[test]
217    fn override_never_resolves_by_stem() {
218        // `/dir/bynkc.backup` names one binary; stem-stripping used to pick
219        // the *different* `/dir/bynkc` silently.
220        let tb = FakeToolbox {
221            files: vec![(PathBuf::from("/dir"), "bynkc".into())],
222            on_path: vec![],
223        };
224        let (path, origin) = locate(&tb, Some(Path::new("/dir/bynkc.backup")), None);
225        assert_eq!(path, None, "the named backup binary does not exist");
226        assert_eq!(origin, Some(Origin::Override));
227
228        // And the exact name resolves when present.
229        let tb = FakeToolbox {
230            files: vec![(PathBuf::from("/dir"), "bynkc.backup".into())],
231            on_path: vec![],
232        };
233        let (path, _) = locate(&tb, Some(Path::new("/dir/bynkc.backup")), None);
234        assert_eq!(path, Some(PathBuf::from("/dir/bynkc.backup")));
235    }
236
237    #[test]
238    fn skew_classification() {
239        let v = |a, b, c| Version {
240            major: a,
241            minor: b,
242            patch: c,
243        };
244        assert_eq!(Skew::classify(v(0, 46, 0), v(0, 46, 0)), Skew::Match);
245        // patch drift is wire-compatible
246        assert_eq!(Skew::classify(v(0, 46, 0), v(0, 46, 3)), Skew::Match);
247        assert_eq!(Skew::classify(v(0, 46, 0), v(0, 44, 0)), Skew::Minor);
248        assert_eq!(Skew::classify(v(1, 0, 0), v(0, 46, 0)), Skew::Major);
249    }
250}