diff --git a/Cargo.lock b/Cargo.lock index b21cc36..75107d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,12 @@ dependencies = [ [[package]] name = "cph-schema" version = "0.1.0" +dependencies = [ + "cph-diag", + "cph-model", + "serde_json", + "toml", +] [[package]] name = "cph-typst" @@ -56,6 +62,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "memchr" version = "2.8.2" @@ -110,6 +122,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -185,3 +210,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/cph-schema/Cargo.toml b/crates/cph-schema/Cargo.toml index b8c32aa..b374db4 100644 --- a/crates/cph-schema/Cargo.toml +++ b/crates/cph-schema/Cargo.toml @@ -5,3 +5,10 @@ edition.workspace = true license.workspace = true [dependencies] +cph-diag = { workspace = true } +cph-model = { workspace = true } +serde_json = "1" +toml = { workspace = true } + +[dev-dependencies] +toml = { workspace = true } diff --git a/crates/cph-schema/schemas/example.json b/crates/cph-schema/schemas/example.json new file mode 100644 index 0000000..01103d7 --- /dev/null +++ b/crates/cph-schema/schemas/example.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cph:kind/example", + "title": "example", + "type": "object", + "properties": { + "problem": { "type": "string", "x-cph-content": true }, + "solution": { "type": "string", "x-cph-content": true }, + "source": { "type": "string" } + }, + "required": ["problem", "solution"], + "additionalProperties": false +} diff --git a/crates/cph-schema/schemas/lemma.json b/crates/cph-schema/schemas/lemma.json new file mode 100644 index 0000000..e21433f --- /dev/null +++ b/crates/cph-schema/schemas/lemma.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cph:kind/lemma", + "title": "lemma", + "type": "object", + "properties": { + "stmt": { "type": "string", "x-cph-content": true }, + "proof": { "type": "string", "x-cph-content": true } + }, + "required": ["stmt"], + "additionalProperties": false +} diff --git a/crates/cph-schema/schemas/segment.json b/crates/cph-schema/schemas/segment.json new file mode 100644 index 0000000..91f8a37 --- /dev/null +++ b/crates/cph-schema/schemas/segment.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cph:kind/segment", + "title": "segment", + "type": "object", + "properties": { + "textbook": { "type": "string", "x-cph-content": true } + }, + "required": ["textbook"], + "additionalProperties": false +} diff --git a/crates/cph-schema/schemas/sop.json b/crates/cph-schema/schemas/sop.json new file mode 100644 index 0000000..db6d944 --- /dev/null +++ b/crates/cph-schema/schemas/sop.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cph:kind/sop", + "title": "sop", + "type": "object", + "properties": { + "sop": { "type": "string", "x-cph-content": true } + }, + "required": ["sop"], + "additionalProperties": false +} diff --git a/crates/cph-schema/src/lib.rs b/crates/cph-schema/src/lib.rs index c27fd1f..968db7b 100644 --- a/crates/cph-schema/src/lib.rs +++ b/crates/cph-schema/src/lib.rs @@ -4,6 +4,496 @@ //! Schema (built-in scalars plus the `content` extension type) and validates //! element instance data against it, including which `content` `.typ` files //! must exist. +//! +//! # The `content` extension convention +//! +//! ADR-0006 fixes that a kind's data schema is a declarative **JSON Schema** +//! (draft 2020-12 here) with one extension type — `content` — whose value is a +//! Typst source rather than inline data. JSON Schema has no native `content` +//! type, so this crate marks a content field with a **custom keyword**: +//! +//! ```json +//! { "type": "string", "x-cph-content": true } +//! ``` +//! +//! The `x-cph-content: true` marker is the single convention this crate honors. +//! A field carrying it is treated as a **content field**: it does NOT appear in +//! `element.toml`; instead the validator checks that the sibling file +//! `.typ` exists in the element's directory (ADR-0008). A required +//! content field whose `.typ` file is missing yields a +//! [`cph_diag::DiagCode::MissingContentFile`]; an *optional* content field +//! (one not in the schema's `required` list, e.g. a lemma's `proof`) may be +//! absent with no diagnostic. All other (non-`x-cph-content`) properties are +//! **scalar** fields that live in `element.toml` and are validated structurally. +//! +//! The four stdlib kind schemas live as real JSON artifacts under +//! `crates/cph-schema/schemas/{segment,example,lemma,sop}.json` and are +//! compiled in via `include_str!`, parsed once on first use. +//! +//! # Validation approach +//! +//! The MVP scalar surface is intentionally tiny (a single optional string on +//! `example`), so rather than pull in a full JSON Schema engine (and its +//! version churn), this crate uses `serde_json` to parse the schemas and a +//! small hand-rolled interpreter that understands exactly the keywords the +//! stdlib schemas use: `properties`, `required`, `type`, `enum`, +//! `additionalProperties`, plus the `x-cph-content` extension marker. This +//! keeps the schemas as declarative artifacts (ADR-0006 intent) while keeping +//! the validator dependency-light and fully under our control. -// WU-3: implement the 4 stdlib kind schemas (segment / example / lemma / sop) -// and the validation entry point. Empty for now so the workspace compiles. +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use cph_diag::{DiagCode, Diagnostic}; +use cph_model::ElementDescriptor; +use serde_json::Value as Json; + +/// The custom JSON Schema keyword marking a field as a `content` (Typst-source) +/// field rather than a scalar. See the crate docs for the convention. +const CONTENT_MARKER: &str = "x-cph-content"; + +/// The known stdlib kinds, in a stable order. +const KNOWN_KINDS: &[&str] = &["segment", "example", "lemma", "sop"]; + +/// Raw schema JSON for each kind, embedded at compile time. +const SCHEMA_SOURCES: &[(&str, &str)] = &[ + ("segment", include_str!("../schemas/segment.json")), + ("example", include_str!("../schemas/example.json")), + ("lemma", include_str!("../schemas/lemma.json")), + ("sop", include_str!("../schemas/sop.json")), +]; + +/// The scalar `type` a property declares (the subset the stdlib schemas use). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScalarType { + String, + Number, + Integer, + Boolean, +} + +impl ScalarType { + fn from_json(s: &str) -> Option { + match s { + "string" => Some(ScalarType::String), + "number" => Some(ScalarType::Number), + "integer" => Some(ScalarType::Integer), + "boolean" => Some(ScalarType::Boolean), + _ => None, + } + } + + fn label(self) -> &'static str { + match self { + ScalarType::String => "string", + ScalarType::Number => "number", + ScalarType::Integer => "integer", + ScalarType::Boolean => "boolean", + } + } + + /// Whether `value` (a scalar coming from `element.toml`, already converted + /// to JSON) satisfies this declared type. + fn matches(self, value: &Json) -> bool { + match self { + ScalarType::String => value.is_string(), + ScalarType::Number => value.is_number(), + ScalarType::Integer => value.is_i64() || value.is_u64(), + ScalarType::Boolean => value.is_boolean(), + } + } +} + +/// One scalar property of a kind (a field that lives in `element.toml`). +#[derive(Debug, Clone)] +struct ScalarField { + name: String, + /// Declared `type`, if any. `None` means "unconstrained" (we still accept + /// the field but apply no type check). + ty: Option, + /// Allowed values from a JSON Schema `enum`, as JSON values. + allowed: Option>, + required: bool, +} + +/// One content property of a kind (a field whose value is a sibling `.typ` +/// file, marked with `x-cph-content`). +#[derive(Debug, Clone)] +struct ContentField { + name: String, + required: bool, +} + +/// A parsed kind schema: the content fields (which drive `.typ` existence +/// checks) and the scalar fields (validated against `element.toml`). +#[derive(Debug, Clone)] +pub struct KindSchema { + kind: String, + content_fields: Vec, + scalar_fields: Vec, + /// Whether the schema forbids properties beyond those declared + /// (`additionalProperties: false`). + deny_additional: bool, +} + +impl KindSchema { + /// The kind name this schema describes (e.g. `"example"`). + pub fn kind(&self) -> &str { + &self.kind + } + + /// The names of the `content` fields (those requiring a `.typ` + /// file), in schema order. + pub fn content_field_names(&self) -> Vec<&str> { + self.content_fields + .iter() + .map(|f| f.name.as_str()) + .collect() + } + + /// The names of the scalar fields (those living in `element.toml`), in + /// schema order. + pub fn scalar_field_names(&self) -> Vec<&str> { + self.scalar_fields.iter().map(|f| f.name.as_str()).collect() + } + + /// Parse a kind schema from its raw JSON source. Panics on a malformed + /// schema — schemas are compile-time artifacts owned by this crate, so a + /// bad one is a build-time bug, not a runtime condition. + fn parse(kind: &str, src: &str) -> Self { + let root: Json = serde_json::from_str(src) + .unwrap_or_else(|e| panic!("kind schema for '{kind}' is not valid JSON: {e}")); + + let deny_additional = root + .get("additionalProperties") + .and_then(Json::as_bool) + .map(|b| !b) + .unwrap_or(false); + + let required: Vec = root + .get("required") + .and_then(Json::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let is_required = |name: &str| required.iter().any(|r| r == name); + + let mut content_fields = Vec::new(); + let mut scalar_fields = Vec::new(); + + let props = root + .get("properties") + .and_then(Json::as_object) + .unwrap_or_else(|| panic!("kind schema for '{kind}' has no `properties` object")); + + for (name, prop) in props { + let is_content = prop + .get(CONTENT_MARKER) + .and_then(Json::as_bool) + .unwrap_or(false); + + if is_content { + content_fields.push(ContentField { + name: name.clone(), + required: is_required(name), + }); + } else { + let ty = prop + .get("type") + .and_then(Json::as_str) + .and_then(ScalarType::from_json); + let allowed = prop + .get("enum") + .and_then(Json::as_array) + .map(|a| a.to_vec()); + scalar_fields.push(ScalarField { + name: name.clone(), + ty, + allowed, + required: is_required(name), + }); + } + } + + KindSchema { + kind: kind.to_string(), + content_fields, + scalar_fields, + deny_additional, + } + } +} + +/// The lazily-built registry mapping kind name → parsed schema. +fn registry() -> &'static BTreeMap<&'static str, KindSchema> { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| { + SCHEMA_SOURCES + .iter() + .map(|(kind, src)| (*kind, KindSchema::parse(kind, src))) + .collect() + }) +} + +/// Look up the parsed [`KindSchema`] for `kind`, or `None` if it is unknown. +pub fn schema_for(kind: &str) -> Option<&'static KindSchema> { + registry().get(kind) +} + +/// The known stdlib kind names (`["segment", "example", "lemma", "sop"]`), +/// for use by the orchestrator (`cph-check`). +pub fn known_kinds() -> &'static [&'static str] { + KNOWN_KINDS +} + +/// Validate an element instance against its kind's schema. +/// +/// Self-contained: looks up the schema by `desc.kind` and, if the kind is +/// unknown, returns a single [`cph_diag::DiagCode::UnknownKind`] diagnostic. +/// Otherwise it checks, in order: +/// +/// 1. **Scalar fields** (`desc.scalars`, a `toml::Table`) against the +/// non-content part of the schema — required-ness, declared `type`, and +/// `enum`. Violations are [`cph_diag::DiagCode::SchemaViolation`]. Under an +/// `additionalProperties: false` schema, an unexpected scalar key is also a +/// `SchemaViolation`. +/// 2. **Content fields**: for each required content field, that the sibling +/// file `desc.dir.join(".typ")` exists; if not, a +/// [`cph_diag::DiagCode::MissingContentFile`]. Optional content fields may be +/// absent with no diagnostic. +/// +/// Every diagnostic carries a fix `hint`. `MissingContentFile` names the +/// expected path in its message; `span` is left `None` (there is no line/col +/// for a file that does not exist). +pub fn validate(desc: &ElementDescriptor) -> Vec { + let Some(schema) = schema_for(&desc.kind) else { + return vec![Diagnostic::error( + DiagCode::UnknownKind, + format!("unknown element kind '{}'", desc.kind), + ) + .with_hint(format!("valid kinds are: {}", KNOWN_KINDS.join(", ")))]; + }; + + let mut diags = Vec::new(); + validate_scalars(schema, desc, &mut diags); + validate_content_files(schema, desc, &mut diags); + diags +} + +/// Validate the scalar fields in `desc.scalars` against the schema's scalar +/// part. +fn validate_scalars(schema: &KindSchema, desc: &ElementDescriptor, diags: &mut Vec) { + let scalars = &desc.scalars; + + // Required scalar fields must be present. + for field in &schema.scalar_fields { + if field.required && !scalars.contains_key(&field.name) { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "element of kind '{}' is missing required field '{}'", + schema.kind, field.name + ), + ) + .with_hint(format!("add `{} = ...` to element.toml", field.name)), + ); + } + } + + // Each present scalar: type / enum checks, and (if known) reject unexpected + // keys when additionalProperties is false. Content fields must never appear + // in element.toml. + let content_names: Vec<&str> = schema + .content_fields + .iter() + .map(|f| f.name.as_str()) + .collect(); + + for (key, value) in scalars { + if content_names.contains(&key.as_str()) { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "field '{}' of kind '{}' is a content field and must not appear in element.toml", + key, schema.kind + ), + ) + .with_hint(format!( + "remove '{key}' from element.toml; put its content in the sibling file '{key}.typ'" + )), + ); + continue; + } + + let Some(field) = schema.scalar_fields.iter().find(|f| f.name == *key) else { + if schema.deny_additional { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("unexpected field '{}' for kind '{}'", key, schema.kind), + ) + .with_hint(format!( + "remove '{key}' from element.toml (it is not part of the '{}' schema)", + schema.kind + )), + ); + } + continue; + }; + + let json = toml_to_json(value); + + if let Some(ty) = field.ty { + if !ty.matches(&json) { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "field '{}' of kind '{}' must be a {}, got {}", + key, + schema.kind, + ty.label(), + json_type_label(&json) + ), + ) + .with_hint(format!( + "set '{key}' to a {} value in element.toml", + ty.label() + )), + ); + continue; + } + } + + if let Some(allowed) = &field.allowed { + if !allowed.contains(&json) { + let choices: Vec = allowed.iter().map(render_json_choice).collect(); + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "field '{}' of kind '{}' has value {} which is not one of the allowed values", + key, + schema.kind, + render_json_choice(&json) + ), + ) + .with_hint(format!("'{key}' must be one of: {}", choices.join(", "))), + ); + } + } + } +} + +/// For each required content field, check that its `.typ` file exists in +/// the element directory. +fn validate_content_files( + schema: &KindSchema, + desc: &ElementDescriptor, + diags: &mut Vec, +) { + for field in &schema.content_fields { + if !field.required { + // Optional content (e.g. lemma's `proof`): absence is fine. + continue; + } + let file_name = format!("{}.typ", field.name); + let path = desc.dir.join(&file_name); + if !path.is_file() { + diags.push( + Diagnostic::error( + DiagCode::MissingContentFile, + format!( + "kind '{}' requires the content file '{}', which is missing", + schema.kind, + path.display() + ), + ) + .with_hint(format!( + "create '{file_name}' in the element folder with the typst source for '{}'", + field.name + )), + ); + } + } +} + +/// Convert a `toml::Value` scalar into the equivalent `serde_json::Value` for +/// type checking. Composite TOML values (arrays / tables) are converted +/// structurally too, though the MVP stdlib schemas only use scalars. +fn toml_to_json(value: &toml::Value) -> Json { + match value { + toml::Value::String(s) => Json::String(s.clone()), + toml::Value::Integer(i) => Json::from(*i), + toml::Value::Float(f) => Json::from(*f), + toml::Value::Boolean(b) => Json::Bool(*b), + toml::Value::Datetime(dt) => Json::String(dt.to_string()), + toml::Value::Array(arr) => Json::Array(arr.iter().map(toml_to_json).collect()), + toml::Value::Table(tbl) => Json::Object( + tbl.iter() + .map(|(k, v)| (k.clone(), toml_to_json(v))) + .collect(), + ), + } +} + +/// A short label for a JSON value's runtime type, for diagnostic messages. +fn json_type_label(value: &Json) -> &'static str { + match value { + Json::Null => "null", + Json::Bool(_) => "boolean", + Json::Number(n) => { + if n.is_i64() || n.is_u64() { + "integer" + } else { + "number" + } + } + Json::String(_) => "string", + Json::Array(_) => "array", + Json::Object(_) => "object", + } +} + +/// Render a JSON value for an enum-choice list (strings unquoted-ish, others as +/// JSON). +fn render_json_choice(value: &Json) -> String { + match value { + Json::String(s) => format!("\"{s}\""), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_has_all_known_kinds() { + for kind in known_kinds() { + assert!(schema_for(kind).is_some(), "missing schema for {kind}"); + } + assert!(schema_for("frob").is_none()); + } + + #[test] + fn example_field_split() { + let s = schema_for("example").unwrap(); + assert_eq!(s.content_field_names(), vec!["problem", "solution"]); + assert_eq!(s.scalar_field_names(), vec!["source"]); + } + + #[test] + fn lemma_proof_is_optional_content() { + let s = schema_for("lemma").unwrap(); + let proof = s.content_fields.iter().find(|f| f.name == "proof").unwrap(); + assert!(!proof.required); + let stmt = s.content_fields.iter().find(|f| f.name == "stmt").unwrap(); + assert!(stmt.required); + } +} diff --git a/crates/cph-schema/tests/fixtures/example_missing_solution/problem.typ b/crates/cph-schema/tests/fixtures/example_missing_solution/problem.typ new file mode 100644 index 0000000..3af134b --- /dev/null +++ b/crates/cph-schema/tests/fixtures/example_missing_solution/problem.typ @@ -0,0 +1,2 @@ += Problem +only the problem here diff --git a/crates/cph-schema/tests/fixtures/example_valid/problem.typ b/crates/cph-schema/tests/fixtures/example_valid/problem.typ new file mode 100644 index 0000000..200e0de --- /dev/null +++ b/crates/cph-schema/tests/fixtures/example_valid/problem.typ @@ -0,0 +1,2 @@ += Problem +What is 2+2? diff --git a/crates/cph-schema/tests/fixtures/example_valid/solution.typ b/crates/cph-schema/tests/fixtures/example_valid/solution.typ new file mode 100644 index 0000000..31152c0 --- /dev/null +++ b/crates/cph-schema/tests/fixtures/example_valid/solution.typ @@ -0,0 +1,2 @@ += Solution +4. diff --git a/crates/cph-schema/tests/fixtures/lemma_no_proof/stmt.typ b/crates/cph-schema/tests/fixtures/lemma_no_proof/stmt.typ new file mode 100644 index 0000000..1ae08b6 --- /dev/null +++ b/crates/cph-schema/tests/fixtures/lemma_no_proof/stmt.typ @@ -0,0 +1,2 @@ += Statement +A lemma. diff --git a/crates/cph-schema/tests/validate.rs b/crates/cph-schema/tests/validate.rs new file mode 100644 index 0000000..f742d2e --- /dev/null +++ b/crates/cph-schema/tests/validate.rs @@ -0,0 +1,76 @@ +//! Integration tests for `cph-schema::validate`, driven by tiny fixtures under +//! `tests/fixtures//` and in-code `ElementDescriptor`s. + +use std::path::{Path, PathBuf}; + +use cph_diag::DiagCode; +use cph_model::ElementDescriptor; +use cph_schema::validate; + +/// Absolute path to a fixture directory. +fn fixture(case: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(case) +} + +fn desc(kind: &str, dir: PathBuf, scalars: toml::Table) -> ElementDescriptor { + ElementDescriptor { + kind: kind.to_string(), + dir, + scalars, + } +} + +#[test] +fn valid_example_no_diagnostics() { + let mut scalars = toml::Table::new(); + scalars.insert("source".into(), toml::Value::String("Textbook §3".into())); + let d = desc("example", fixture("example_valid"), scalars); + let diags = validate(&d); + assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}"); +} + +#[test] +fn example_missing_solution_typ_is_missing_content_file() { + let d = desc( + "example", + fixture("example_missing_solution"), + toml::Table::new(), + ); + let diags = validate(&d); + assert_eq!(diags.len(), 1, "got {diags:?}"); + assert_eq!(diags[0].code, DiagCode::MissingContentFile); + assert!(diags[0].message.contains("solution.typ")); + assert!(diags[0].hint.is_some()); +} + +#[test] +fn lemma_without_proof_typ_is_ok() { + // stmt.typ exists, proof.typ does not — proof is optional, so no diagnostic. + let d = desc("lemma", fixture("lemma_no_proof"), toml::Table::new()); + let diags = validate(&d); + assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}"); +} + +#[test] +fn unknown_kind_is_reported() { + let d = desc("frob", fixture("example_valid"), toml::Table::new()); + let diags = validate(&d); + assert_eq!(diags.len(), 1, "got {diags:?}"); + assert_eq!(diags[0].code, DiagCode::UnknownKind); + assert!(diags[0].message.contains("frob")); +} + +#[test] +fn example_source_non_string_is_schema_violation() { + let mut scalars = toml::Table::new(); + scalars.insert("source".into(), toml::Value::Integer(42)); + let d = desc("example", fixture("example_valid"), scalars); + let diags = validate(&d); + assert_eq!(diags.len(), 1, "got {diags:?}"); + assert_eq!(diags[0].code, DiagCode::SchemaViolation); + assert!(diags[0].message.contains("source")); + assert!(diags[0].message.contains("string")); +}