use std::fmt;
use std::str::FromStr;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RustyfiVersion {
V0_0,
V0_1,
}
impl RustyfiVersion {
pub const DEFAULT: Self = Self::V0_0;
#[cfg(test)]
fn has_module_system(&self) -> bool {
matches!(self, Self::V0_1)
}
pub fn has_row_polymorphism(&self) -> bool {
matches!(self, Self::V0_1)
}
pub fn has_page_adt(&self) -> bool {
matches!(self, Self::V0_0)
}
pub fn math_is_split(&self) -> bool {
matches!(self, Self::V0_1)
}
pub fn graphics_is_collection(&self) -> bool {
matches!(self, Self::V0_1)
}
pub fn has_per_binding_stage(&self) -> bool {
matches!(self, Self::V0_1)
}
pub fn has_code_type_syntax(&self) -> bool {
matches!(self, Self::V0_1)
}
pub fn is_implemented(&self) -> bool {
matches!(self, Self::V0_0 | Self::V0_1)
}
#[cfg(test)]
fn all() -> &'static [RustyfiVersion] {
&[Self::V0_0, Self::V0_1]
}
pub fn supported() -> &'static [RustyfiVersion] {
&[Self::V0_0, Self::V0_1]
}
}
impl Default for RustyfiVersion {
fn default() -> Self {
Self::DEFAULT
}
}
impl fmt::Display for RustyfiVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::V0_0 => write!(f, "0.0"),
Self::V0_1 => write!(f, "0.1"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"unrecognized SATySFi version {input:?}; supported values: \
0.0 (alias: v0.0), 0.1 (aliases: 0.1.x, v0.1, v0.1.0; not yet implemented)"
)]
pub struct ParseVersionError {
pub input: String,
}
impl FromStr for RustyfiVersion {
type Err = ParseVersionError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let normalized = s.trim();
let normalized = normalized
.strip_prefix('v')
.or_else(|| normalized.strip_prefix('V'))
.unwrap_or(normalized);
match normalized {
"0.0" => Ok(Self::V0_0),
"0.1" | "0.1.x" | "0.1.0" => Ok(Self::V0_1),
_ => Err(ParseVersionError {
input: s.to_string(),
}),
}
}
}
pub fn sniff_version(src: &str) -> Option<RustyfiVersion> {
sniff_headers(src).version
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HeaderSniff {
pub version: Option<RustyfiVersion>,
pub envelope_headers: bool,
}
pub fn sniff_headers(src: &str) -> HeaderSniff {
for raw_line in src.lines() {
let line = match raw_line.find('%') {
Some(idx) => &raw_line[..idx],
None => raw_line,
};
let line = line.trim();
if line.is_empty() {
continue;
}
if line.starts_with("@stage:") {
return HeaderSniff {
version: Some(RustyfiVersion::V0_0),
envelope_headers: false,
};
}
if line.starts_with("@require:") || line.starts_with("@import:") {
continue;
}
if is_use_header(line) {
return HeaderSniff {
version: Some(RustyfiVersion::V0_1),
envelope_headers: true,
};
}
return HeaderSniff {
version: sniff_content_line(line),
envelope_headers: false,
};
}
HeaderSniff::default()
}
fn is_use_header(line: &str) -> bool {
let Some(rest) = line.strip_prefix("use") else {
return false;
};
let Some(rest) = rest.strip_prefix(|c: char| c.is_whitespace()) else {
return false; };
let rest = rest.trim_start();
if rest.is_empty() {
return false;
}
if rest.starts_with('#') {
return true; }
let first_word = rest.split_whitespace().next().unwrap_or("");
if first_word == "package" || first_word == "open" {
return true; }
first_word
.chars()
.next()
.map(|c| c.is_ascii_uppercase())
.unwrap_or(false)
&& first_word
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
}
fn sniff_content_line(line: &str) -> Option<RustyfiVersion> {
if starts_with_word(line, "val") {
return Some(RustyfiVersion::V0_1);
}
for kw in ["let-rec", "let-inline", "let-block", "let-math", "let-mutable"] {
if starts_with_word(line, kw) {
return Some(RustyfiVersion::V0_0);
}
}
None
}
fn starts_with_word(line: &str, word: &str) -> bool {
match line.strip_prefix(word) {
Some(rest) => rest
.chars()
.next()
.map(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
.unwrap_or(true),
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_str_accepts_0_0_forms() {
for s in ["0.0", "v0.0", "V0.0"] {
assert_eq!(
s.parse::<RustyfiVersion>().unwrap_or_else(|e| panic!("{s:?}: {e}")),
RustyfiVersion::V0_0,
"input {s:?}"
);
}
}
#[test]
fn from_str_accepts_0_1_forms() {
for s in ["0.1", "0.1.x", "0.1.0", "v0.1"] {
assert_eq!(
s.parse::<RustyfiVersion>().unwrap_or_else(|e| panic!("{s:?}: {e}")),
RustyfiVersion::V0_1,
"input {s:?}"
);
}
}
#[test]
fn from_str_rejects_unknown_forms() {
for s in ["", "1.0", "0.0.6", "v0.0.6", "0.0.7", "garbage", "0.2"] {
let err = s.parse::<RustyfiVersion>().unwrap_err();
assert_eq!(err.input, s);
let msg = err.to_string();
assert!(msg.contains("0.0"), "message should list supported values: {msg}");
assert!(msg.contains("0.1"), "message should list supported values: {msg}");
}
}
#[test]
fn default_is_v0_0() {
assert_eq!(RustyfiVersion::DEFAULT, RustyfiVersion::V0_0);
assert_eq!(RustyfiVersion::default(), RustyfiVersion::V0_0);
}
#[test]
fn capability_probes() {
assert!(RustyfiVersion::V0_0.is_implemented());
assert!(RustyfiVersion::V0_1.is_implemented());
assert!(!RustyfiVersion::V0_0.has_module_system());
assert!(RustyfiVersion::V0_1.has_module_system());
assert!(!RustyfiVersion::V0_0.has_row_polymorphism());
assert!(RustyfiVersion::V0_1.has_row_polymorphism());
assert!(RustyfiVersion::V0_0.has_page_adt());
assert!(!RustyfiVersion::V0_1.has_page_adt());
assert!(!RustyfiVersion::V0_0.math_is_split());
assert!(RustyfiVersion::V0_1.math_is_split());
assert!(!RustyfiVersion::V0_0.graphics_is_collection());
assert!(RustyfiVersion::V0_1.graphics_is_collection());
assert!(!RustyfiVersion::V0_0.has_per_binding_stage());
assert!(RustyfiVersion::V0_1.has_per_binding_stage());
assert!(!RustyfiVersion::V0_0.has_code_type_syntax());
assert!(RustyfiVersion::V0_1.has_code_type_syntax());
}
#[test]
fn display_round_trips_through_from_str() {
for v in RustyfiVersion::all() {
let s = v.to_string();
assert_eq!(&s.parse::<RustyfiVersion>().unwrap(), v, "round-trip of {s:?}");
}
}
#[test]
fn sniff_none_for_headerless_document() {
assert_eq!(sniff_version("let x = 1 in x"), None);
assert_eq!(sniff_version(""), None);
assert_eq!(sniff_version(" \n% just a comment\n"), None);
}
#[test]
fn sniff_require_import_are_transparent_stage_still_pins() {
assert_eq!(sniff_version("@require: stdlib\nlet x = 1 in x"), None);
assert_eq!(sniff_version("@import: helper\nlet x = 1 in x"), None);
assert_eq!(
sniff_version("% a comment\n\n@require: stdlib\nlet x = 1 in x"),
None
);
assert_eq!(
sniff_version("@stage: 0\nlet x = 1 in x"),
Some(RustyfiVersion::V0_0)
);
}
#[test]
fn sniff_require_then_module_is_none() {
assert_eq!(
sniff_version("@require: pervasives\nmodule V01Mini = struct\nval x = 1\nend"),
None
);
assert_eq!(
sniff_version("@import: helper\n@require: pervasives\nmodule M = struct\nend"),
None
);
}
#[test]
fn sniff_val_head_is_v0_1() {
assert_eq!(
sniff_version("@require: pervasives\nval x = 1"),
Some(RustyfiVersion::V0_1)
);
assert_eq!(sniff_version("val f x = x"), Some(RustyfiVersion::V0_1));
}
#[test]
fn sniff_hyphenated_let_head_is_v0_0() {
for src in [
"let-rec f x = x",
"let-inline ctx \\emph x = x",
"let-block ctx +p x = x",
"let-math \\frac x y = x",
"let-mutable r <- 0",
] {
assert_eq!(sniff_version(src), Some(RustyfiVersion::V0_0), "src: {src:?}");
}
}
#[test]
fn sniff_use_shapes_broader_than_bare_ident() {
for src in ["use package foo", "use open Foo", "use #[attr] Foo"] {
assert_eq!(sniff_version(src), Some(RustyfiVersion::V0_1), "src: {src:?}");
}
}
#[test]
fn sniff_lib_rustyfi_corpus_never_v0_1() {
let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../../lib-rustyfi/dist/packages");
let mut checked = 0usize;
for entry in std::fs::read_dir(root).expect("lib-rustyfi/dist/packages must exist") {
let path = entry.expect("readable dir entry").path();
if !matches!(path.extension().and_then(|e| e.to_str()), Some("satyh") | Some("satyg")) {
continue;
}
let src = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path:?}: {e}"));
let sniffed = sniff_version(&src);
assert_ne!(
sniffed,
Some(RustyfiVersion::V0_1),
"{path:?} sniffed as V0_1 (got {sniffed:?})"
);
checked += 1;
}
assert!(checked >= 29, "expected to check the full 29-package corpus, got {checked}");
}
#[test]
fn sniff_v0_0_fixtures_are_never_mistaken_for_v0_1() {
for src in [
"document (|title = {Hello};|) '<+p{Hello, world!}>",
"@import: helper\nlet x = 1 in x",
"@require: stdlib\nlet x = 1 in x",
"let x = 1",
] {
assert_ne!(sniff_version(src), Some(RustyfiVersion::V0_1), "src: {src:?}");
}
}
#[test]
fn sniff_best_effort_v0_1_use_header() {
assert_eq!(
sniff_version("use Foo\nlet x = 1 in x"),
Some(RustyfiVersion::V0_1)
);
}
#[test]
fn sniff_headers_reports_envelope_axis() {
for src in ["use package foo", "use open Foo", "use Foo\nlet x = 1 in x"] {
let sniff = sniff_headers(src);
assert_eq!(sniff.version, Some(RustyfiVersion::V0_1), "src: {src:?}");
assert!(sniff.envelope_headers, "src: {src:?}");
}
}
#[test]
fn sniff_headers_no_envelope_axis_for_legacy_or_ambiguous() {
for src in [
"@require: pervasives\nval x = 1",
"@stage: 0\nlet x = 1 in x",
"let x = 1 in x",
"",
] {
assert!(
!sniff_headers(src).envelope_headers,
"src {src:?} must not pin Envelopes"
);
}
}
#[test]
fn sniff_version_is_a_sniff_headers_wrapper() {
for src in [
"use package foo",
"@require: stdlib\nlet x = 1 in x",
"@stage: 0\nx",
"val f x = x",
"let-rec f x = x",
"",
] {
assert_eq!(sniff_version(src), sniff_headers(src).version, "src: {src:?}");
}
}
}