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}