//! `cph-diag` — the shared diagnostic vocabulary for the checker. //! //! Every other crate in the workspace depends on these types to report //! problems. The vocabulary is intentionally small and stable: a [`Severity`] //! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an //! optional [`SourceSpan`] pointing back at the offending source, and a //! [`Diagnostic`] tying them together with a human message and a fix hint. //! //! All public types derive `Serialize` (downstream prints diagnostics as JSON), //! plus `Debug`, `Clone`, `PartialEq`, `Eq`. use std::fmt; use std::ops::Range; use std::path::PathBuf; use serde::Serialize; /// Severity of a diagnostic. /// /// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic /// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is /// exactly: /// /// ```text /// inductive Severity where /// | warning /// | error /// ``` /// /// This two-valued shape is a **contract decision**, not an accident: the Lean /// module pins `Severity` to exactly `warning | error` and states the finer /// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore /// do **not** add an info/note level here. `error` blocks (the artifact is /// invalid); `warning` does not block (the artifact still exports, but with /// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning"). /// /// There is no CI gate enforcing this alignment (see the repo constitution); /// it is maintained by review, which is why this correspondence is documented /// here rather than only in the spec. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum Severity { /// Non-blocking: the artifact still exports, but is lossy / has an ignored /// element. Mirrors Lean `Severity.warning`. Warning, /// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`. Error, } /// A location in a source file within the engineering-file tree. /// /// `file` is relative to the engineering-file root so spans are portable and /// diffable. `line` / `col` are 1-based (as editors display them); `byte_range` /// is a 0-based byte offset range into the file. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct SourceSpan { /// Path to the file, relative to the engineering-file root. pub file: PathBuf, /// Byte offset range into the file (0-based). pub byte_range: Range, /// 1-based line number. pub line: u32, /// 1-based column number. pub col: u32, } /// Stable, machine-readable diagnostic codes. /// /// This set is **closed**: codes are part of the checker's public contract, so /// downstream tooling can match on the string form (see [`DiagCode::as_str`]). /// Do not invent codes outside this enum without a deliberate decision. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum DiagCode { /// A `[[parts]]` entry references a folder/path that does not exist. PartPathMissing, /// An element declares a `kind` that is not a known kind. UnknownKind, /// A required `content` `.typ` file (per the kind schema) is missing. MissingContentFile, /// Instance data does not conform to its kind's JSON Schema. SchemaViolation, /// The assembled typst source failed to compile. /// /// Includes **reference-resolution** failures — an unresolved cross-reference /// (`@ref`) or a relative `import`/`include` that escapes the import boundary /// or names a missing file. typst detects all of these at compile time, so /// they surface here rather than as a separate diagnostic (ADR-0012 folded /// the former `DanglingReference` class into this one). TypstCompile, /// An element is ignored under a render target (ADR-0005: warning). RenderIgnored, /// The engineering file's `.cph-version` is not compatible with the running /// CLI's version (ADR-0016). Decided at load time; `error` severity. CphVersionMismatch, } impl DiagCode { /// The stable string form of this code, e.g. `"E-PART-PATH"`. /// /// The `E-` / `W-` prefix reflects the *typical* severity of the code but /// is not authoritative — the actual severity lives on the [`Diagnostic`]. pub fn as_str(&self) -> &'static str { match self { DiagCode::PartPathMissing => "E-PART-PATH", DiagCode::UnknownKind => "E-UNKNOWN-KIND", DiagCode::MissingContentFile => "E-MISSING-CONTENT", DiagCode::SchemaViolation => "E-SCHEMA", DiagCode::TypstCompile => "E-TYPST-COMPILE", DiagCode::RenderIgnored => "W-RENDER-IGNORED", DiagCode::CphVersionMismatch => "E-CPH-VERSION", } } } impl fmt::Display for DiagCode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } /// A single diagnostic emitted by the checker. /// /// Construct via [`Diagnostic::error`] / [`Diagnostic::warning`] and attach an /// optional [`SourceSpan`] / hint with the builder methods /// [`Diagnostic::with_span`] / [`Diagnostic::with_hint`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Diagnostic { /// How serious this diagnostic is. pub severity: Severity, /// The stable machine code. pub code: DiagCode, /// Human-readable message. pub message: String, /// Optional location this diagnostic points at. pub span: Option, /// Optional fix hint — the product's core value. pub hint: Option, } impl Diagnostic { /// Create an `error`-severity diagnostic. pub fn error(code: DiagCode, message: impl Into) -> Self { Diagnostic { severity: Severity::Error, code, message: message.into(), span: None, hint: None, } } /// Create a `warning`-severity diagnostic. pub fn warning(code: DiagCode, message: impl Into) -> Self { Diagnostic { severity: Severity::Warning, code, message: message.into(), span: None, hint: None, } } /// Attach a source span (builder-style). pub fn with_span(mut self, span: SourceSpan) -> Self { self.span = Some(span); self } /// Attach a fix hint (builder-style). pub fn with_hint(mut self, hint: impl Into) -> Self { self.hint = Some(hint.into()); self } } impl fmt::Display for Severity { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Severity::Warning => "warning", Severity::Error => "error", }) } } impl fmt::Display for Diagnostic { /// Renders one line `error[E-PART-PATH]: `, plus an indented /// `--> file:line:col` line when a span is present, and a `hint: ...` line /// when a hint is present. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}[{}]: {}", self.severity, self.code, self.message)?; if let Some(span) = &self.span { write!( f, "\n --> {}:{}:{}", span.file.display(), span.line, span.col )?; } if let Some(hint) = &self.hint { write!(f, "\n hint: {hint}")?; } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn code_as_str_is_stable() { assert_eq!(DiagCode::PartPathMissing.as_str(), "E-PART-PATH"); assert_eq!(DiagCode::RenderIgnored.as_str(), "W-RENDER-IGNORED"); assert_eq!(DiagCode::SchemaViolation.as_str(), "E-SCHEMA"); // Display matches as_str. assert_eq!(DiagCode::UnknownKind.to_string(), "E-UNKNOWN-KIND"); } #[test] fn error_constructor() { let d = Diagnostic::error(DiagCode::UnknownKind, "no such kind 'frob'"); assert_eq!(d.severity, Severity::Error); assert_eq!(d.code, DiagCode::UnknownKind); assert_eq!(d.message, "no such kind 'frob'"); assert!(d.span.is_none()); assert!(d.hint.is_none()); } #[test] fn warning_constructor() { let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'"); assert_eq!(d.severity, Severity::Warning); assert_eq!(d.code, DiagCode::RenderIgnored); } #[test] fn builders_attach_span_and_hint() { let span = SourceSpan { file: PathBuf::from("segments/intro/textbook.typ"), byte_range: 10..20, line: 3, col: 5, }; let d = Diagnostic::error(DiagCode::TypstCompile, "unclosed bracket") .with_span(span.clone()) .with_hint("close the '[' opened on line 2"); assert_eq!(d.span, Some(span)); assert_eq!(d.hint.as_deref(), Some("close the '[' opened on line 2")); } #[test] fn display_bare() { let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found"); assert_eq!(d.to_string(), "error[E-PART-PATH]: folder not found"); } #[test] fn display_with_span_and_hint() { let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found") .with_span(SourceSpan { file: PathBuf::from("manifest.toml"), byte_range: 0..0, line: 12, col: 3, }) .with_hint("create the folder 'segments/intro' or fix the path"); assert_eq!( d.to_string(), "error[E-PART-PATH]: folder not found\n --> manifest.toml:12:3\n hint: create the folder 'segments/intro' or fix the path" ); } #[test] fn display_warning() { let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'"); assert_eq!( d.to_string(), "warning[W-RENDER-IGNORED]: ignored under 'teacher'" ); } }