Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/typr-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/typr-cli/src/md_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
224 changes: 224 additions & 0 deletions crates/typr-cli/src/type_definition.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

#[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<DefinitionManifest, String> {
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::<Vec<_>>()
.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());
}
}
12 changes: 12 additions & 0 deletions crates/typr-core/src/processes/spg/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ pub struct StdlibMeta {
pub seealso: Vec<String>,
/// The R package of origin (e.g. "base", "stats").
pub pkg: Option<String>,
/// 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<String>,
/// Only set when a break is *known*, never speculative. Same scope as
/// `since`.
pub until: Option<String>,
}

impl StdlibMeta {
Expand All @@ -127,6 +135,8 @@ impl StdlibMeta {
examples: Vec::new(),
seealso: Vec::new(),
pkg: None,
since: None,
until: None,
}
}

Expand All @@ -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()
}
}

Expand Down
47 changes: 47 additions & 0 deletions crates/typr-core/src/processes/spg/stdlib_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ pub struct FunctionMeta {
pub coercion_notes: Option<String>,
pub examples: Vec<String>,
pub seealso: Vec<String>,
/// 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<String>,
/// Only set when a break is *known*, never speculative. Same scope as
/// `since`.
pub until: Option<String>,
}

impl FunctionMeta {
Expand All @@ -27,6 +35,8 @@ impl FunctionMeta {
examples: self.examples,
seealso: self.seealso,
pkg: self.pkg,
since: self.since,
until: self.until,
}
}

Expand All @@ -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()
}
}

Expand Down Expand Up @@ -99,6 +111,14 @@ pub fn parse_meta_from_source(source: &str) -> HashMap<String, FunctionMeta> {
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));
Expand Down Expand Up @@ -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 = "\
Expand Down
Loading