Skip to main content

rustyfi_syntax/
version.rs

1//! Target SATySFi language version.
2//!
3//! 0.0.6 and 0.1.x diverge at several points in the pipeline (lexing headers,
4//! module resolution in the loader, elaboration in `rustyfi-lang`, ...).
5//! Rather than let each divergence point grow its own ad-hoc flag, the target
6//! version is threaded through the pipeline as a single [`RustyfiVersion`]
7//! value (see [`crate`]'s consumers: `rustyfi-loader`'s `LoadOptions` and
8//! `rustyfi`'s `--lang` flag), and each divergence point is expressed as a
9//! method on it, rather than scattering `if opt_a && opt_b` checks across the
10//! crate graph.
11//!
12//! ## Verification note on the 0.1 header syntax
13//!
14//! The exact SATySFi 0.1.0 `use` header grammar is **not independently
15//! confirmed** from upstream sources here (the sandbox this was written in had
16//! no network access), so the `sniff_version` heuristic below for 0.1
17//! (`use `-prefixed header lines) is best-effort. The 0.0.6 side (`@require:` /
18//! `@import:` / `@stage:`) *is* verified, directly against this port's own
19//! [`crate::cst`]/[`crate::lexer`] implementation of the v0.0.6 grammar.
20
21use std::fmt;
22use std::str::FromStr;
23
24/// Target SATySFi language version.
25///
26/// `#[non_exhaustive]` because more 0.1.z-era (and later) variants are
27/// expected as the upstream module system design settles; treat any `match`
28/// on this type as needing a wildcard arm.
29#[non_exhaustive]
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum RustyfiVersion {
32    /// SATySFi 0.0.6 (and, as far as this port is concerned, the rest of the
33    /// 0.0.x series): `@require:` / `@import:` headers, the module-less
34    /// surface syntax this port's `rustyfi-syntax` / `rustyfi-loader` /
35    /// `rustyfi-lang` implement.
36    V0_0,
37    /// SATySFi 0.1.x: the `dev-0-1-0` language generation — an ML-style
38    /// module system (F-ing Modules-based), row-polymorphic records and
39    /// optional-argument encodings, and a reworked surface grammar — shared
40    /// near-identically by `saphe-split` (confirmed by direct diff). This
41    /// says nothing about *packaging*: `V0_1` documents may resolve
42    /// dependencies via either today's `@require:`/`@import:` headers
43    /// (`dev-0-1-0`'s own model, and this port's `LoadMode::Legacy`) or the
44    /// `use`/manifest/lockfile model (`saphe-split`'s `LoadMode::Envelopes`,
45    /// a later milestone) — see `rustyfi_loader::LoadMode`.
46    V0_1,
47}
48
49impl RustyfiVersion {
50    /// The version this port targets when none is specified.
51    pub const DEFAULT: Self = Self::V0_0;
52
53    /// Whether this version has an ML-style module system (`module M = struct
54    /// ... end` bindings that erase to stamped-flat names, `val` bindings
55    /// inside them, later: signatures/functors). `false` for `V0_0` (which
56    /// has only its own non-parameterized, single-level `module`/`sig`
57    /// surface — not a real module *system*); `true` for `V0_1`.
58    ///
59    /// Unlike every sibling capability probe, this one has no non-test
60    /// caller — only this file's own `capability_probes` unit test —
61    /// hence `cfg(test)` rather than a live `pub`/`pub(crate)` method.
62    #[cfg(test)]
63    fn has_module_system(&self) -> bool {
64        matches!(self, Self::V0_1)
65    }
66
67    /// Whether this version's type system has row-polymorphic records and
68    /// optional-argument rows (`?(l = e)` bundles, `?'r` row variables).
69    /// `false` for `V0_0` (closed `Kind::Record` rows only); `true` for
70    /// `V0_1`.
71    pub fn has_row_polymorphism(&self) -> bool {
72        matches!(self, Self::V0_1)
73    }
74
75    /// Whether `page-break`/`page-break-multicolumn`/`page-break-two-column`
76    /// take the `page` ADT (`A4Paper`/`UserDefinedPaper`) as their paper-size
77    /// argument, as opposed to `V0_1`'s plain `length * length`. Deliberately
78    /// phrased as an assertion about `V0_0`'s surface (not "is it 0.0.6"),
79    /// so a future third generation that also drops the ADT reads correctly
80    /// without touching call sites.
81    pub fn has_page_adt(&self) -> bool {
82        matches!(self, Self::V0_0)
83    }
84
85    /// Whether the `math` type is split into `math-text` (unparsed `${...}`
86    /// source) / `math-boxes` (evaluated tree) with a `read-math` primitive
87    /// bridging them, as opposed to `V0_0`'s single unsplit `math` type.
88    /// `false` for `V0_0`; `true` for `V0_1`.
89    pub fn math_is_split(&self) -> bool {
90        matches!(self, Self::V0_1)
91    }
92
93    /// Whether the `graphics` type is a **collection** (0.1's `GraphicD.t =
94    /// 'a element list`, with `Clip`/`Group` container elements — a
95    /// graphics-producing callback returns ONE `graphics` value) as opposed
96    /// to `V0_0`'s single drawing element (a callback returns `list
97    /// graphics`). `false` for `V0_0`; `true` for `V0_1`. Backs the
98    /// graphics-collection sweep: every fork in the shared
99    /// `place_graphics`/`coerce_graphics_result` machinery keys on this one
100    /// method, so the env and type-env agree by construction (mirrors
101    /// `math_is_split`'s role for the math slice).
102    pub fn graphics_is_collection(&self) -> bool {
103        matches!(self, Self::V0_1)
104    }
105
106    /// Whether a single BINDING may carry its own stage qualifier — 0.1's
107    /// `val ~x = e` / `val persistent ~x = e` (`dev-0-1-0`
108    /// `parser.mly:416-421`, `UTBindValue(Stage0 | Persistent0, _)`). `false`
109    /// for `V0_0`, which declares one stage per FILE with a `@stage:` header
110    /// and has no binding-level spelling at all: 0.0.6's `EXACT_TILDE` occurs
111    /// only as a splice operand prefix (`v0.0.6 parser.mly:797`) and as macro
112    /// syntax (`:608`, `:1199`).
113    ///
114    /// The two generations share one `cst::TopLet`/`TopBinding` (the 0.1
115    /// lowering builds them), so the `~` prefix is *parseable* under both and
116    /// this is what makes it an ERROR under 0.0.6 rather than a silent accept
117    /// — see `elaborate.rs`'s `binding_stage`.
118    pub fn has_per_binding_stage(&self) -> bool {
119        matches!(self, Self::V0_1)
120    }
121
122    /// Whether the `code` TYPE has a surface spelling — 0.1's `code τ`, a
123    /// one-argument prefix type application decoded alongside `list`/`ref`
124    /// (`dev-0-1-0 src/frontend/manualTypeDecoder.ml:31-36`). `false` for
125    /// `V0_0`, whose own manual-type decoder (`v0.0.6
126    /// src/frontend/typeenv.ml:527-530`) special-cases `list` and `ref` and
127    /// nothing else, so `CodeType` there is inference-only.
128    pub fn has_code_type_syntax(&self) -> bool {
129        matches!(self, Self::V0_1)
130    }
131
132    /// Whether this port actually implements this version end-to-end
133    /// (lexer through PDF rendering). True for both generations.
134    pub fn is_implemented(&self) -> bool {
135        matches!(self, Self::V0_0 | Self::V0_1)
136    }
137
138    /// Every version this enum currently distinguishes (implemented or
139    /// not), in a stable order, for building help/error text.
140    ///
141    /// `cfg(test)`-gated: only this file's own round-trip unit test calls
142    /// it; `Self::supported`, the method callers outside this module
143    /// actually want, does not go through it.
144    #[cfg(test)]
145    fn all() -> &'static [RustyfiVersion] {
146        &[Self::V0_0, Self::V0_1]
147    }
148
149    /// The subset of `RustyfiVersion::all` this port can actually load.
150    pub fn supported() -> &'static [RustyfiVersion] {
151        &[Self::V0_0, Self::V0_1]
152    }
153}
154
155impl Default for RustyfiVersion {
156    fn default() -> Self {
157        Self::DEFAULT
158    }
159}
160
161impl fmt::Display for RustyfiVersion {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        match self {
164            Self::V0_0 => write!(f, "0.0"),
165            Self::V0_1 => write!(f, "0.1"),
166        }
167    }
168}
169
170/// Error returned by `RustyfiVersion`'s [`FromStr`] impl for a string that
171/// does not name a recognized version.
172#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
173#[error(
174    "unrecognized SATySFi version {input:?}; supported values: \
175     0.0 (alias: v0.0), 0.1 (aliases: 0.1.x, v0.1, v0.1.0; not yet implemented)"
176)]
177pub struct ParseVersionError {
178    /// The string that failed to parse.
179    pub input: String,
180}
181
182impl FromStr for RustyfiVersion {
183    type Err = ParseVersionError;
184
185    fn from_str(s: &str) -> Result<Self, Self::Err> {
186        let normalized = s.trim();
187        let normalized = normalized
188            .strip_prefix('v')
189            .or_else(|| normalized.strip_prefix('V'))
190            .unwrap_or(normalized);
191        match normalized {
192            "0.0" => Ok(Self::V0_0),
193            "0.1" | "0.1.x" | "0.1.0" => Ok(Self::V0_1),
194            _ => Err(ParseVersionError {
195                input: s.to_string(),
196            }),
197        }
198    }
199}
200
201/// Best-effort detection of a document's target version from its source
202/// text, by inspecting the header-like lines at the top of the file (before
203/// any prelude bindings), and, failing that, the first content line.
204///
205/// - A `@stage:` header line is a real, direct signal: 0.1's lexer rejects it
206///   outright, so seeing it at all yields `Some(V0_0)`.
207/// - `@require:` / `@import:` header lines are *transparent*: byte-identical
208///   in both v0.0.6 and dev-0-1-0, so their presence pins neither axis — they
209///   are skipped just like a blank/comment line.
210/// - A `use`-shaped header line — 0.1/Saphe's module-header syntax, see
211///   `is_use_header` — yields `Some(V0_1)`. **This half of the heuristic is
212///   best-effort**: the exact grammar could not be confirmed against
213///   upstream from this sandbox (no network access to GitHub / zenn.dev at
214///   the time this was written).
215/// - Blank lines and `%`-comments (SATySFi's line-comment syntax, both
216///   versions) are skipped while looking for the first header-shaped line.
217/// - Once a non-blank, non-comment, non-header line is reached (headers are
218///   only valid at the top of a file), that single line is inspected for a
219///   content-level signal (see `sniff_content_line`) and the result —
220///   including `None` — is returned regardless.
221/// - Returns `None` if no signal is found at all (e.g. a bare `let ... in
222///   ...` document with no headers, which is valid and version-ambiguous in
223///   0.0.x).
224pub fn sniff_version(src: &str) -> Option<RustyfiVersion> {
225    sniff_headers(src).version
226}
227
228/// What [`sniff_headers`] learned from the header block. `version` is exactly
229/// [`sniff_version`]'s result (Axis A); `envelope_headers` is the Axis-B
230/// signal the CLI's detection ladder needs — a `use`-shaped header pins Axis
231/// B = `LoadMode::Envelopes`, and sets BOTH fields.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
233pub struct HeaderSniff {
234    /// The detected target version, if any (`None` = ambiguous/no signal).
235    pub version: Option<RustyfiVersion>,
236    /// Whether a `use`-shaped (Envelopes/Saphe) header was seen. When `true`,
237    /// `version` is also `Some(V0_1)` (a `use` header is a 0.1-only construct).
238    pub envelope_headers: bool,
239}
240
241/// Best-effort detection of a document's target version AND packaging axis
242/// from its source text — see [`sniff_version`]'s doc comment for the
243/// version-detection rules. The one addition: a `use`-shaped header line
244/// (`is_use_header`) sets both `version = Some(V0_1)` and
245/// `envelope_headers = true`, so the CLI's detection ladder can pin
246/// `LoadMode::Envelopes` off it.
247pub fn sniff_headers(src: &str) -> HeaderSniff {
248    for raw_line in src.lines() {
249        let line = match raw_line.find('%') {
250            Some(idx) => &raw_line[..idx],
251            None => raw_line,
252        };
253        let line = line.trim();
254
255        if line.is_empty() {
256            continue;
257        }
258
259        // `@stage:` is a real signal: 0.1's lexer rejects it outright (the
260        // header-lexing rule dropped the `"stage" -> HEADER_STAGE*` arm
261        // between v0.0.6 and dev-0-1-0), so seeing it at all means 0.0.6.
262        if line.starts_with("@stage:") {
263            return HeaderSniff {
264                version: Some(RustyfiVersion::V0_0),
265                envelope_headers: false,
266            };
267        }
268
269        // `@require:`/`@import:` are *transparent*: byte-identical in both
270        // v0.0.6 and dev-0-1-0 (same citation), so their presence pins
271        // neither axis. Skip past them exactly like a blank/comment line —
272        // do NOT return here: returning `Some(V0_0)` on the very first
273        // `@require:` misclassifies every 0.1-syntax-body-with-legacy-headers
274        // document, which is a legitimate document shape this loader must
275        // support.
276        if line.starts_with("@require:") || line.starts_with("@import:") {
277            continue;
278        }
279
280        if is_use_header(line) {
281            return HeaderSniff {
282                version: Some(RustyfiVersion::V0_1),
283                envelope_headers: true,
284            };
285        }
286
287        // First non-blank, non-comment, non-header line: headers are only
288        // valid at the top of a file, so inspect this one line for a
289        // content-level signal, then stop looking regardless of the result.
290        return HeaderSniff {
291            version: sniff_content_line(line),
292            envelope_headers: false,
293        };
294    }
295    HeaderSniff::default()
296}
297
298/// Recognize a `use`-shaped 0.1/Saphe header line: bare `use Ident[.Ident]*`,
299/// `use package ...`, `use open ...`, or `use #[attr] ...`. Best-effort
300/// (Saphe's exact grammar is `saphe-split`-only and not yet ported), but
301/// deliberately broader than "bare `use Ident`" — narrow enough that
302/// no 0.0.6 keyword or identifier can start a line with `use ` (0.0.6 has no
303/// `use` keyword at all), so widening this can only ever gain true positives,
304/// never introduce a false positive against the 0.0.6 corpus.
305fn is_use_header(line: &str) -> bool {
306    let Some(rest) = line.strip_prefix("use") else {
307        return false;
308    };
309    let Some(rest) = rest.strip_prefix(|c: char| c.is_whitespace()) else {
310        return false; // `used`, `user`, ... — not the `use` keyword
311    };
312    let rest = rest.trim_start();
313    if rest.is_empty() {
314        return false;
315    }
316    if rest.starts_with('#') {
317        return true; // `use #[attr] ...`
318    }
319    let first_word = rest.split_whitespace().next().unwrap_or("");
320    if first_word == "package" || first_word == "open" {
321        return true; // `use package ...` / `use open ...`
322    }
323    // bare `use Ident[.Ident]*` (possibly followed by `as .../of ...`, which
324    // this only requires the *first* word to look like a module path for).
325    first_word
326        .chars()
327        .next()
328        .map(|c| c.is_ascii_uppercase())
329        .unwrap_or(false)
330        && first_word
331            .chars()
332            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
333}
334
335/// Inspect the first non-header content line for a version signal.
336/// `module <Upper> ... = struct` is deliberately **not** checked here:
337/// 0.0.6's `TopBinding::Module` makes the sig annotation optional and all
338/// 29 shipped 0.0.6 packages open with a `module` head after their
339/// headers, so a `module` head is no signal in either direction and
340/// correctly falls through to `None` below.
341fn sniff_content_line(line: &str) -> Option<RustyfiVersion> {
342    if starts_with_word(line, "val") {
343        return Some(RustyfiVersion::V0_1);
344    }
345    for kw in ["let-rec", "let-inline", "let-block", "let-math", "let-mutable"] {
346        if starts_with_word(line, kw) {
347            return Some(RustyfiVersion::V0_0);
348        }
349    }
350    None
351}
352
353/// True if `line` starts with `word` followed by a word boundary (end of
354/// string or non-identifier character) — so `"val"` matches `"val f = .."`
355/// but not an identifier like `"values"` or `"val-like"`.
356fn starts_with_word(line: &str, word: &str) -> bool {
357    match line.strip_prefix(word) {
358        Some(rest) => rest
359            .chars()
360            .next()
361            .map(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
362            .unwrap_or(true),
363        None => false,
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn from_str_accepts_0_0_forms() {
373        for s in ["0.0", "v0.0", "V0.0"] {
374            assert_eq!(
375                s.parse::<RustyfiVersion>().unwrap_or_else(|e| panic!("{s:?}: {e}")),
376                RustyfiVersion::V0_0,
377                "input {s:?}"
378            );
379        }
380    }
381
382    #[test]
383    fn from_str_accepts_0_1_forms() {
384        for s in ["0.1", "0.1.x", "0.1.0", "v0.1"] {
385            assert_eq!(
386                s.parse::<RustyfiVersion>().unwrap_or_else(|e| panic!("{s:?}: {e}")),
387                RustyfiVersion::V0_1,
388                "input {s:?}"
389            );
390        }
391    }
392
393    #[test]
394    fn from_str_rejects_unknown_forms() {
395        // `0.0.6`/`v0.0.6` are deliberately NOT aliases: the language tag
396        // names a GENERATION, never an upstream patch release.
397        for s in ["", "1.0", "0.0.6", "v0.0.6", "0.0.7", "garbage", "0.2"] {
398            let err = s.parse::<RustyfiVersion>().unwrap_err();
399            assert_eq!(err.input, s);
400            let msg = err.to_string();
401            assert!(msg.contains("0.0"), "message should list supported values: {msg}");
402            assert!(msg.contains("0.1"), "message should list supported values: {msg}");
403        }
404    }
405
406    #[test]
407    fn default_is_v0_0() {
408        assert_eq!(RustyfiVersion::DEFAULT, RustyfiVersion::V0_0);
409        assert_eq!(RustyfiVersion::default(), RustyfiVersion::V0_0);
410    }
411
412    #[test]
413    fn capability_probes() {
414        assert!(RustyfiVersion::V0_0.is_implemented());
415        assert!(RustyfiVersion::V0_1.is_implemented());
416
417        assert!(!RustyfiVersion::V0_0.has_module_system());
418        assert!(RustyfiVersion::V0_1.has_module_system());
419
420        assert!(!RustyfiVersion::V0_0.has_row_polymorphism());
421        assert!(RustyfiVersion::V0_1.has_row_polymorphism());
422
423        assert!(RustyfiVersion::V0_0.has_page_adt());
424        assert!(!RustyfiVersion::V0_1.has_page_adt());
425
426        assert!(!RustyfiVersion::V0_0.math_is_split());
427        assert!(RustyfiVersion::V0_1.math_is_split());
428
429        assert!(!RustyfiVersion::V0_0.graphics_is_collection());
430        assert!(RustyfiVersion::V0_1.graphics_is_collection());
431
432        assert!(!RustyfiVersion::V0_0.has_per_binding_stage());
433        assert!(RustyfiVersion::V0_1.has_per_binding_stage());
434
435        assert!(!RustyfiVersion::V0_0.has_code_type_syntax());
436        assert!(RustyfiVersion::V0_1.has_code_type_syntax());
437    }
438
439    #[test]
440    fn display_round_trips_through_from_str() {
441        for v in RustyfiVersion::all() {
442            let s = v.to_string();
443            assert_eq!(&s.parse::<RustyfiVersion>().unwrap(), v, "round-trip of {s:?}");
444        }
445    }
446
447    #[test]
448    fn sniff_none_for_headerless_document() {
449        assert_eq!(sniff_version("let x = 1 in x"), None);
450        assert_eq!(sniff_version(""), None);
451        assert_eq!(sniff_version("   \n% just a comment\n"), None);
452    }
453
454    #[test]
455    fn sniff_require_import_are_transparent_stage_still_pins() {
456        // `@require:`/`@import:` do not pin a version by themselves —
457        // with no other signal on the first content line (a bare,
458        // non-hyphenated `let`), the result is `None` (falling to
459        // `RustyfiVersion::DEFAULT` downstream in `resolve_version`, not
460        // sniffed here).
461        assert_eq!(sniff_version("@require: stdlib\nlet x = 1 in x"), None);
462        assert_eq!(sniff_version("@import: helper\nlet x = 1 in x"), None);
463        // Leading blank lines / comments before a transparent header must
464        // still not confuse the sniffer into inventing a signal.
465        assert_eq!(
466            sniff_version("% a comment\n\n@require: stdlib\nlet x = 1 in x"),
467            None
468        );
469        // `@stage:` is the one header that IS still a real, direct signal
470        // (0.1's lexer rejects it outright).
471        assert_eq!(
472            sniff_version("@stage: 0\nlet x = 1 in x"),
473            Some(RustyfiVersion::V0_0)
474        );
475    }
476
477    #[test]
478    fn sniff_require_then_module_is_none() {
479        // A legacy-header-then-module-body file (a V0_1-syntax library
480        // reached through the unmodified `@require:` loader) must sniff
481        // `None`, not `V0_0` and not `V0_1`
482        // (no positive signal either way — `module` is deliberately not a
483        // signal).
484        assert_eq!(
485            sniff_version("@require: pervasives\nmodule V01Mini = struct\nval x = 1\nend"),
486            None
487        );
488        assert_eq!(
489            sniff_version("@import: helper\n@require: pervasives\nmodule M = struct\nend"),
490            None
491        );
492    }
493
494    #[test]
495    fn sniff_val_head_is_v0_1() {
496        assert_eq!(
497            sniff_version("@require: pervasives\nval x = 1"),
498            Some(RustyfiVersion::V0_1)
499        );
500        // No headers at all — `val` at the very first content line still
501        // signals V0_1.
502        assert_eq!(sniff_version("val f x = x"), Some(RustyfiVersion::V0_1));
503    }
504
505    #[test]
506    fn sniff_hyphenated_let_head_is_v0_0() {
507        for src in [
508            "let-rec f x = x",
509            "let-inline ctx \\emph x = x",
510            "let-block ctx +p x = x",
511            "let-math \\frac x y = x",
512            "let-mutable r <- 0",
513        ] {
514            assert_eq!(sniff_version(src), Some(RustyfiVersion::V0_0), "src: {src:?}");
515        }
516    }
517
518    #[test]
519    fn sniff_use_shapes_broader_than_bare_ident() {
520        for src in ["use package foo", "use open Foo", "use #[attr] Foo"] {
521            assert_eq!(sniff_version(src), Some(RustyfiVersion::V0_1), "src: {src:?}");
522        }
523    }
524
525    #[test]
526    fn sniff_lib_rustyfi_corpus_never_v0_1() {
527        // Every vendored 0.0.6 package must sniff `None` or `Some(V0_0)`,
528        // never `Some(V0_1)`.
529        let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../../lib-rustyfi/dist/packages");
530        let mut checked = 0usize;
531        for entry in std::fs::read_dir(root).expect("lib-rustyfi/dist/packages must exist") {
532            let path = entry.expect("readable dir entry").path();
533            // The vendored 29-package corpus uses both `.satyh` (27 of them,
534            // plus this port's own `stdja-mini.satyh`) and `.satyg` (`list`,
535            // `option` — 2 of them) extensions; a `.satyh`-only filter would
536            // undercount the real corpus to 28 and never reach the `>= 29`
537            // floor below.
538            if !matches!(path.extension().and_then(|e| e.to_str()), Some("satyh") | Some("satyg")) {
539                continue;
540            }
541            let src = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path:?}: {e}"));
542            let sniffed = sniff_version(&src);
543            assert_ne!(
544                sniffed,
545                Some(RustyfiVersion::V0_1),
546                "{path:?} sniffed as V0_1 (got {sniffed:?})"
547            );
548            checked += 1;
549        }
550        assert!(checked >= 29, "expected to check the full 29-package corpus, got {checked}");
551    }
552
553    #[test]
554    fn sniff_v0_0_fixtures_are_never_mistaken_for_v0_1() {
555        // A representative sample of this port's own 0.0.6 fixtures/tests
556        // must not sniff as V0_1.
557        for src in [
558            "document (|title = {Hello};|) '<+p{Hello, world!}>",
559            "@import: helper\nlet x = 1 in x",
560            "@require: stdlib\nlet x = 1 in x",
561            "let x = 1",
562        ] {
563            assert_ne!(sniff_version(src), Some(RustyfiVersion::V0_1), "src: {src:?}");
564        }
565    }
566
567    #[test]
568    fn sniff_best_effort_v0_1_use_header() {
569        assert_eq!(
570            sniff_version("use Foo\nlet x = 1 in x"),
571            Some(RustyfiVersion::V0_1)
572        );
573    }
574
575    #[test]
576    fn sniff_headers_reports_envelope_axis() {
577        // A `use`-shaped header pins BOTH axes: version V0_1 and the Axis-B
578        // Envelopes signal.
579        for src in ["use package foo", "use open Foo", "use Foo\nlet x = 1 in x"] {
580            let sniff = sniff_headers(src);
581            assert_eq!(sniff.version, Some(RustyfiVersion::V0_1), "src: {src:?}");
582            assert!(sniff.envelope_headers, "src: {src:?}");
583        }
584    }
585
586    #[test]
587    fn sniff_headers_no_envelope_axis_for_legacy_or_ambiguous() {
588        // `@require:`-only / `val`-head / headerless files never set the
589        // Envelopes signal.
590        for src in [
591            "@require: pervasives\nval x = 1",
592            "@stage: 0\nlet x = 1 in x",
593            "let x = 1 in x",
594            "",
595        ] {
596            assert!(
597                !sniff_headers(src).envelope_headers,
598                "src {src:?} must not pin Envelopes"
599            );
600        }
601    }
602
603    #[test]
604    fn sniff_version_is_a_sniff_headers_wrapper() {
605        // The wrapper must agree with the struct's `version` field for every
606        // representative input.
607        for src in [
608            "use package foo",
609            "@require: stdlib\nlet x = 1 in x",
610            "@stage: 0\nx",
611            "val f x = x",
612            "let-rec f x = x",
613            "",
614        ] {
615            assert_eq!(sniff_version(src), sniff_headers(src).version, "src: {src:?}");
616        }
617    }
618}