feat(checker): cph-schema — 4 kind JSON Schemas + validation (WU-3)

Declarative JSON Schema per kind (segment/example/lemma/sop), embedded as real
.json artifacts (ADR-0006) and validated by a small hand-rolled interpreter
(properties/required/type/enum/additionalProperties + the `x-cph-content`
marker) — no jsonschema-crate churn for a one-optional-string scalar surface.
`x-cph-content: true` marks content fields (not in element.toml; require sibling
<field>.typ); everything else is an element.toml scalar. `validate(desc)` is
self-contained: UnknownKind for unknown kinds, MissingContentFile (path named in
message) for absent required .typ, SchemaViolation for bad scalars; lemma proof
optional ⇒ no diagnostic when absent. 8 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:33:40 +08:00
parent c17af60bb0
commit 752a5c661d
12 changed files with 661 additions and 2 deletions
Generated
+31
View File
@@ -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"
+7
View File
@@ -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 }
+13
View File
@@ -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
}
+12
View File
@@ -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
}
+11
View File
@@ -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
}
+11
View File
@@ -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
}
+492 -2
View File
@@ -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
//! `<field>.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<Self> {
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<ScalarType>,
/// Allowed values from a JSON Schema `enum`, as JSON values.
allowed: Option<Vec<Json>>,
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<ContentField>,
scalar_fields: Vec<ScalarField>,
/// 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 `<field>.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<String> = 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<BTreeMap<&'static str, KindSchema>> = 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("<field>.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<Diagnostic> {
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<Diagnostic>) {
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<String> = 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 `<field>.typ` file exists in
/// the element directory.
fn validate_content_files(
schema: &KindSchema,
desc: &ElementDescriptor,
diags: &mut Vec<Diagnostic>,
) {
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);
}
}
@@ -0,0 +1,2 @@
= Problem
only the problem here
@@ -0,0 +1,2 @@
= Problem
What is 2+2?
@@ -0,0 +1,2 @@
= Solution
4.
@@ -0,0 +1,2 @@
= Statement
A lemma.
+76
View File
@@ -0,0 +1,76 @@
//! Integration tests for `cph-schema::validate`, driven by tiny fixtures under
//! `tests/fixtures/<case>/` 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"));
}