Skip to main content

zenith_core/ast/
policy.rs

1//! Document-level diagnostic-policy AST types.
2//!
3//! A `.zen` document may carry a root `diagnostics { … }` block that adjusts how
4//! specific diagnostic codes are *reported*. This is a lint-level model in the
5//! spirit of rustc lint levels:
6//!
7//! ```text
8//! diagnostics {
9//!     allow "layout.off_canvas"     // suppress this advisory
10//!     allow "layout.off_canvas" "bg.glow" "bg.rim" // suppress only these nodes
11//!     deny  "font.local"            // elevate to a blocking Error (CI gate)
12//!     warn  "node.unknown_property" // force to Warning
13//! }
14//! ```
15//!
16//! The policy affects ONLY which diagnostics are surfaced by validation — it is
17//! consulted in [`crate::validate()`] and nowhere else. The scene compiler and the
18//! render path never see it, so a policy can never change rendered output. A
19//! document with no `diagnostics` block parses to an empty [`DiagnosticPolicy`],
20//! which is an identity pass (no entries → no effect), so the default-off path is
21//! byte-identical to before this feature existed.
22//!
23//! Bright lines (see [`crate::validate()`] for the application logic):
24//! - Policy applies to **Warning**- and **Advisory**-severity diagnostics only.
25//!   **Error** severity is IMMUTABLE: an `allow` never drops an Error and a
26//!   `warn` never weakens an Error.
27//! - **Last-wins** for duplicate codes: a later entry for the same code overrides
28//!   any earlier one (exactly like rustc lint levels on the command line).
29
30use super::Span;
31
32/// The verb of a single [`PolicyEntry`] — how a diagnostic code's reporting is
33/// adjusted.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PolicyVerb {
36    /// Suppress the diagnostic when its severity is Warning or Advisory. An
37    /// Error-severity diagnostic is left unchanged (Errors are immutable).
38    Allow,
39    /// Elevate the diagnostic to Error severity (turning a Warning/Advisory into
40    /// a blocking Error). An already-Error diagnostic stays Error.
41    Deny,
42    /// Force the diagnostic to Warning severity when it is currently Warning or
43    /// Advisory. An Error-severity diagnostic is left unchanged.
44    Warn,
45}
46
47/// A single entry inside a document's `diagnostics { … }` block: one verb
48/// applied to one diagnostic code, optionally scoped to diagnostic subject ids.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct PolicyEntry {
51    /// What this entry does to the named code.
52    pub verb: PolicyVerb,
53    /// The diagnostic code this entry governs, e.g. `"layout.off_canvas"`.
54    pub code: String,
55    /// Optional diagnostic subject ids this entry governs, e.g. `"bg.glow"`.
56    ///
57    /// When empty, the entry governs every diagnostic with the matching code.
58    /// When non-empty, the entry governs only diagnostics whose `subject_id`
59    /// exactly matches one of these values.
60    pub subjects: Vec<String>,
61    /// Source declaration span, when available.
62    pub source_span: Option<Span>,
63}
64
65/// The complete document-level diagnostic policy: an ordered list of
66/// [`PolicyEntry`] records as written in the `diagnostics { … }` block.
67///
68/// The default value is empty, which is an identity pass: with no entries the
69/// policy has no effect on validation output. Resolution is **last-wins** — see
70/// [`DiagnosticPolicy::verb_for`].
71#[derive(Debug, Clone, PartialEq, Eq, Default)]
72pub struct DiagnosticPolicy {
73    /// Policy entries in source order. Declaration order is preserved so the
74    /// formatter can round-trip the block verbatim; resolution applies last-wins.
75    pub entries: Vec<PolicyEntry>,
76}
77
78impl DiagnosticPolicy {
79    /// The effective verb for `code` and `subject_id`, or `None` if no entry
80    /// governs that diagnostic.
81    ///
82    /// Resolution is **last-wins** among entries that match both code and
83    /// subjects. A code-only entry matches every subject for that code; a scoped
84    /// entry matches only the listed subject ids.
85    pub fn verb_for(&self, code: &str, subject_id: Option<&str>) -> Option<&PolicyVerb> {
86        self.entries
87            .iter()
88            .rev()
89            .find(|e| e.matches(code, subject_id))
90            .map(|e| &e.verb)
91    }
92}
93
94impl PolicyEntry {
95    fn matches(&self, code: &str, subject_id: Option<&str>) -> bool {
96        if self.code != code {
97            return false;
98        }
99        if self.subjects.is_empty() {
100            return true;
101        }
102        match subject_id {
103            Some(actual) => self.subjects.iter().any(|expected| expected == actual),
104            None => false,
105        }
106    }
107}
108
109// `Eq` is derivable on `DiagnosticPolicy`/`PolicyEntry` only because `PolicyVerb`
110// is `Eq` and `Span`/`String` are `Eq`; if a future field breaks `Eq`, drop it
111// from the derive rather than suppressing.
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn entry(verb: PolicyVerb, code: &str) -> PolicyEntry {
118        PolicyEntry {
119            verb,
120            code: code.to_owned(),
121            subjects: Vec::new(),
122            source_span: None,
123        }
124    }
125
126    #[test]
127    fn default_policy_is_empty_and_inert() {
128        let p = DiagnosticPolicy::default();
129        assert!(p.entries.is_empty());
130        assert_eq!(p.verb_for("anything", None), None);
131    }
132
133    #[test]
134    fn verb_for_returns_the_governing_verb() {
135        let p = DiagnosticPolicy {
136            entries: vec![entry(PolicyVerb::Allow, "layout.off_canvas")],
137        };
138        assert_eq!(
139            p.verb_for("layout.off_canvas", Some("r.off")),
140            Some(&PolicyVerb::Allow)
141        );
142        assert_eq!(p.verb_for("token.unused", None), None);
143    }
144
145    #[test]
146    fn verb_for_is_last_wins() {
147        let p = DiagnosticPolicy {
148            entries: vec![
149                entry(PolicyVerb::Deny, "node.unknown_property"),
150                entry(PolicyVerb::Warn, "node.unknown_property"),
151            ],
152        };
153        // The later `warn` overrides the earlier `deny`.
154        assert_eq!(
155            p.verb_for("node.unknown_property", None),
156            Some(&PolicyVerb::Warn)
157        );
158    }
159
160    #[test]
161    fn scoped_entry_matches_only_its_subjects() {
162        let p = DiagnosticPolicy {
163            entries: vec![PolicyEntry {
164                verb: PolicyVerb::Allow,
165                code: "layout.off_canvas".to_owned(),
166                subjects: vec!["bg.glow".to_owned(), "bg.rim".to_owned()],
167                source_span: None,
168            }],
169        };
170        assert_eq!(
171            p.verb_for("layout.off_canvas", Some("bg.glow")),
172            Some(&PolicyVerb::Allow)
173        );
174        assert_eq!(
175            p.verb_for("layout.off_canvas", Some("bg.rim")),
176            Some(&PolicyVerb::Allow)
177        );
178        assert_eq!(p.verb_for("layout.off_canvas", Some("shape.1")), None);
179        assert_eq!(p.verb_for("layout.off_canvas", None), None);
180    }
181}