diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index 9d9536e..e1d53dc 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -57,6 +57,7 @@ pub mod rd_renderer; pub mod repl; pub mod standard_library; pub mod syntax; +pub mod type_definition; pub mod vignette_renderer; // Re-export commonly used items diff --git a/crates/typr-cli/src/md_renderer.rs b/crates/typr-cli/src/md_renderer.rs index 59ff4a2..2399cc0 100644 --- a/crates/typr-cli/src/md_renderer.rs +++ b/crates/typr-cli/src/md_renderer.rs @@ -216,6 +216,8 @@ mod tests { examples: vec!["abs(-5) # -> 5".to_string()], seealso: vec!["sign".to_string()], pkg: Some(pkg.to_string()), + since: None, + until: None, }); node } diff --git a/crates/typr-cli/src/type_definition.rs b/crates/typr-cli/src/type_definition.rs new file mode 100644 index 0000000..156c70b --- /dev/null +++ b/crates/typr-cli/src/type_definition.rs @@ -0,0 +1,224 @@ +//! `typr-def.toml` — the manifest of an external Type Definition repository. +//! +//! This is the "spec du format figée" item of `typR/registry.md` §13 J2: it +//! fixes the manifest's shape and enforces the `format_version` gate, turning +//! `typr/rfcs/0031-external-type-definitions.md`'s "Definition repository +//! layout and manifest" section into a real type instead of a TOML example in +//! prose. Nothing here fetches a repository, resolves `typr.lock`, or loads a +//! definition into the type checker — those are the RFC's remaining +//! checklist items (registry.md §13 J2: loading into `standard_library.rs`, +//! the `trust` threshold, `typr types add|update|list|vendor`, `cases/`). +//! +//! Example manifest this module parses (RFC §"Definition repository layout +//! and manifest"): +//! +//! ```toml +//! format_version = 1 +//! +//! [package] +//! name = "shiny" +//! since = "1.11.0" +//! # until = "2.0.0" +//! +//! [definition] +//! version = "0.3.0" +//! tier = "T2" +//! +//! [provider] +//! type = "community" +//! repository = "github:alice/typr-shiny" +//! +//! [capabilities] +//! r_shims = false +//! extern_raw = false +//! ``` + +#![allow(dead_code)] + +use serde::Deserialize; + +/// The only `format_version` this build of typr understands. A manifest +/// declaring anything else is refused outright rather than guessed at — see +/// rfcs/0031: "`format_version` is what keeps this survivable across N +/// repositories the project does not control: when the definition format +/// changes, the compiler reads old manifests it recognizes or refuses the +/// ones it doesn't — it never silently misparses one." +pub const CURRENT_FORMAT_VERSION: i64 = 1; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DefinitionManifest { + pub format_version: i64, + pub package: PackageSection, + pub definition: DefinitionSection, + pub provider: ProviderSection, + #[serde(default)] + pub capabilities: CapabilitiesSection, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct PackageSection { + pub name: String, + /// Minimum R package version this definition was written against — a + /// floor, never a closed range (`typR/registry.md` §7.2: `supports = + /// ["1.11.x"]` would depend on the resolving machine and rot unnoticed). + pub since: String, + /// Only set when a break is *known*, never speculative. + #[serde(default)] + pub until: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DefinitionSection { + /// semver of the definition itself, independent of the R package's own + /// version. + pub version: String, + /// Default tier (`T1`/`T2`/`T3`) for entries with no `#! tier:` of their + /// own. Kept as a free-form string rather than an enum, matching + /// `FunctionMeta::tier` — an unrecognized future tier degrades instead of + /// failing the whole manifest to parse. + pub tier: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ProviderType { + Official, + Community, + Generated, + Local, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ProviderSection { + #[serde(rename = "type")] + pub kind: ProviderType, + pub repository: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)] +pub struct CapabilitiesSection { + /// Ships executable R alongside the declarations (an `R/` shim + /// directory)? + #[serde(default)] + pub r_shims: bool, + /// Uses an `extern: (...) -> T r#"...R..."#` verbatim block anywhere? + #[serde(default)] + pub extern_raw: bool, +} + +/// Parse a `typr-def.toml` manifest. +/// +/// Checks `format_version` against a raw TOML value first, before attempting +/// to interpret the rest of the document against today's schema — so an +/// unsupported version is reported as exactly that, not as a confusing +/// "missing field" error from a future schema this build does not know about. +pub fn parse_manifest(source: &str) -> Result { + let raw: toml::Value = source.parse().map_err(|e| format!("not valid TOML: {e}"))?; + let found = raw + .get("format_version") + .ok_or_else(|| "missing required field `format_version`".to_string())? + .as_integer() + .ok_or_else(|| "`format_version` must be an integer".to_string())?; + if found != CURRENT_FORMAT_VERSION { + return Err(format!( + "unsupported format_version = {found} (this build of typr understands \ + format_version = {CURRENT_FORMAT_VERSION}); update typr, or ask this \ + definition's provider to publish one this build supports" + )); + } + toml::from_str(source) + .map_err(|e| format!("manifest does not match format_version {CURRENT_FORMAT_VERSION}: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHINY_MANIFEST: &str = r#" +format_version = 1 + +[package] +name = "shiny" +since = "1.11.0" + +[definition] +version = "0.3.0" +tier = "T2" + +[provider] +type = "community" +repository = "github:alice/typr-shiny" + +[capabilities] +r_shims = false +extern_raw = false +"#; + + #[test] + fn parses_the_rfc_example_manifest() { + let manifest = parse_manifest(SHINY_MANIFEST).unwrap(); + assert_eq!(manifest.format_version, 1); + assert_eq!(manifest.package.name, "shiny"); + assert_eq!(manifest.package.since, "1.11.0"); + assert_eq!(manifest.package.until, None); + assert_eq!(manifest.definition.version, "0.3.0"); + assert_eq!(manifest.definition.tier, "T2"); + assert_eq!(manifest.provider.kind, ProviderType::Community); + assert_eq!(manifest.provider.repository, "github:alice/typr-shiny"); + assert!(!manifest.capabilities.r_shims); + assert!(!manifest.capabilities.extern_raw); + } + + #[test] + fn until_is_optional() { + let manifest = parse_manifest(SHINY_MANIFEST).unwrap(); + assert!(manifest.package.until.is_none()); + + let with_until = SHINY_MANIFEST.replacen( + "since = \"1.11.0\"", + "since = \"1.11.0\"\nuntil = \"2.0.0\"", + 1, + ); + let manifest = parse_manifest(&with_until).unwrap(); + assert_eq!(manifest.package.until.as_deref(), Some("2.0.0")); + } + + #[test] + fn capabilities_default_to_false_when_section_is_absent() { + let without_capabilities = SHINY_MANIFEST + .lines() + .filter(|l| !l.contains("[capabilities]") && !l.contains("r_shims") && !l.contains("extern_raw")) + .collect::>() + .join("\n"); + let manifest = parse_manifest(&without_capabilities).unwrap(); + assert!(!manifest.capabilities.r_shims); + assert!(!manifest.capabilities.extern_raw); + } + + #[test] + fn missing_format_version_is_refused() { + let source = SHINY_MANIFEST.replacen("format_version = 1\n", "", 1); + let err = parse_manifest(&source).unwrap_err(); + assert!(err.contains("format_version"), "unexpected error: {err}"); + } + + #[test] + fn unknown_format_version_is_refused_not_misparsed() { + let source = SHINY_MANIFEST.replacen("format_version = 1", "format_version = 2", 1); + let err = parse_manifest(&source).unwrap_err(); + assert!(err.contains("unsupported format_version = 2"), "unexpected error: {err}"); + assert!(err.contains("format_version = 1"), "unexpected error: {err}"); + } + + #[test] + fn invalid_toml_is_refused() { + let err = parse_manifest("this is not { toml").unwrap_err(); + assert!(err.contains("not valid TOML"), "unexpected error: {err}"); + } + + #[test] + fn invalid_provider_type_is_refused() { + let source = SHINY_MANIFEST.replacen("type = \"community\"", "type = \"unofficial\"", 1); + assert!(parse_manifest(&source).is_err()); + } +} diff --git a/crates/typr-core/src/processes/spg/model.rs b/crates/typr-core/src/processes/spg/model.rs index caff81a..dc0b5bc 100644 --- a/crates/typr-core/src/processes/spg/model.rs +++ b/crates/typr-core/src/processes/spg/model.rs @@ -115,6 +115,14 @@ pub struct StdlibMeta { pub seealso: Vec, /// The R package of origin (e.g. "base", "stats"). pub pkg: Option, + /// Minimum R package version this entry was declared against (a floor, + /// never a closed range — `typR/registry.md` §7.2). External type + /// definitions only (rfcs/0031-external-type-definitions.md); unset for + /// the standard library. + pub since: Option, + /// Only set when a break is *known*, never speculative. Same scope as + /// `since`. + pub until: Option, } impl StdlibMeta { @@ -127,6 +135,8 @@ impl StdlibMeta { examples: Vec::new(), seealso: Vec::new(), pkg: None, + since: None, + until: None, } } @@ -139,6 +149,8 @@ impl StdlibMeta { || !self.examples.is_empty() || !self.seealso.is_empty() || self.pkg.is_some() + || self.since.is_some() + || self.until.is_some() } } diff --git a/crates/typr-core/src/processes/spg/stdlib_meta.rs b/crates/typr-core/src/processes/spg/stdlib_meta.rs index e6d23c9..017cb2f 100644 --- a/crates/typr-core/src/processes/spg/stdlib_meta.rs +++ b/crates/typr-core/src/processes/spg/stdlib_meta.rs @@ -14,6 +14,14 @@ pub struct FunctionMeta { pub coercion_notes: Option, pub examples: Vec, pub seealso: Vec, + /// Minimum R package version this entry was declared against — a floor, + /// never a closed range (`typR/registry.md` §7.2). Meaningful only in an + /// external type definition (`rfcs/0031-external-type-definitions.md`); + /// absent from the standard library. + pub since: Option, + /// Only set when a break is *known*, never speculative. Same scope as + /// `since`. + pub until: Option, } impl FunctionMeta { @@ -27,6 +35,8 @@ impl FunctionMeta { examples: self.examples, seealso: self.seealso, pkg: self.pkg, + since: self.since, + until: self.until, } } @@ -39,6 +49,8 @@ impl FunctionMeta { || !self.examples.is_empty() || !self.seealso.is_empty() || self.pkg.is_some() + || self.since.is_some() + || self.until.is_some() } } @@ -99,6 +111,14 @@ pub fn parse_meta_from_source(source: &str) -> HashMap { let target = pending_meta.get_or_insert_with(FunctionMeta::default); target.ret_doc = Some(strip_leading_colon(value)); } + "since" => { + let target = pending_meta.get_or_insert_with(FunctionMeta::default); + target.since = Some(strip_leading_colon(value)); + } + "until" => { + let target = pending_meta.get_or_insert_with(FunctionMeta::default); + target.until = Some(strip_leading_colon(value)); + } "coercion" | "note" => { let target = pending_meta.get_or_insert_with(FunctionMeta::default); target.coercion_notes = Some(strip_leading_colon(value)); @@ -282,6 +302,33 @@ let x: int <- 5;"; assert!(!map.contains_key("stats::rnorm")); } + #[test] + fn since_and_until_are_parsed() { + let src = "\ +#! pkg: dplyr +#! tier: T3 +#! since: 1.1.0 +#! until: 2.0.0 +@filter: (Any, Any) -> Any;"; + + let map = parse_meta_from_source(src); + let meta = map.get("filter").unwrap(); + assert_eq!(meta.since.as_deref(), Some("1.1.0")); + assert_eq!(meta.until.as_deref(), Some("2.0.0")); + } + + #[test] + fn since_without_until_leaves_until_none() { + let src = "\ +#! since: 1.11.0 +@fluidPage: (Any) -> Any;"; + + let map = parse_meta_from_source(src); + let meta = map.get("fluidPage").unwrap(); + assert_eq!(meta.since.as_deref(), Some("1.11.0")); + assert!(meta.until.is_none()); + } + #[test] fn empty_tier_is_none() { let src = "\