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}