Skip to main content

corvus_json_schema/
results.rs

1//! Results collection: a port of Corvus.Text.Json's `JsonSchemaResultsCollector` and `JsonSchemaAnnotationProducer`.
2//!
3//! The evaluator opens a context per subschema application and writes keyword rows into the open context. Closing a
4//! context either commits it (a summary row, then its own rows newest first, after its committed descendants) or
5//! pops it (everything it and its descendants wrote is discarded). Levels decide which rows exist and which carry
6//! message text: Basic has failures without text, Detailed adds text to failures, Verbose keeps every row with text,
7//! annotations included.
8
9use std::collections::BTreeMap;
10
11use serde_json::Value;
12
13/// How much a results collector records.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
15pub enum ResultsLevel {
16    /// Failures only, without message text (the lowest overhead).
17    Basic,
18    /// Failures only, with message text.
19    Detailed,
20    /// Every evaluation, passing and failing, with message text, including annotations.
21    Verbose,
22}
23
24/// One result row.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct SchemaResult {
27    pub is_match: bool,
28    /// The message, or `""` when the level records none or the keyword has none. Annotation rows carry raw JSON.
29    pub message: String,
30    /// The path of keywords from the root schema (e.g. `/properties/name/type`).
31    pub evaluation_location: String,
32    /// The JSON pointer of the evaluated schema (or keyword) within its document (e.g. `/properties/name/type`).
33    pub schema_evaluation_location: String,
34    /// The JSON pointer of the instance location (e.g. `/name`).
35    pub document_evaluation_location: String,
36}
37
38/// A message for a result row, produced only when the level records message text.
39pub(crate) enum Message<'a> {
40    None,
41    Static(&'static str),
42    Lazy(&'a dyn Fn() -> String),
43}
44
45struct Frame {
46    eval_len: usize,
47    schema_path: String,
48    doc_len: usize,
49    commit_index: usize,
50    rows_start: usize,
51}
52
53/// Collects the results of an evaluation (`JsonSchemaResultsCollector`).
54pub struct JsonSchemaResultsCollector {
55    level: ResultsLevel,
56    committed: Vec<SchemaResult>,
57    frames: Vec<Frame>,
58    /// The rows written into open frames (each frame owns the tail from its `rows_start`).
59    pending: Vec<SchemaResult>,
60    eval_path: String,
61    schema_path: String,
62    doc_path: String,
63}
64
65/// Encodes a JSON pointer segment (`~` as `~0`, `/` as `~1`).
66pub fn encode_pointer_segment(segment: &str) -> std::borrow::Cow<'_, str> {
67    crate::uri::escape_pointer_token(segment)
68}
69
70impl JsonSchemaResultsCollector {
71    /// Creates a collector at the given level.
72    pub fn new(level: ResultsLevel) -> Self {
73        JsonSchemaResultsCollector {
74            level,
75            committed: Vec::new(),
76            frames: Vec::new(),
77            pending: Vec::new(),
78            eval_path: String::new(),
79            schema_path: String::new(),
80            doc_path: String::new(),
81        }
82    }
83
84    pub fn level(&self) -> ResultsLevel {
85        self.level
86    }
87
88    /// The results, in commit order.
89    pub fn results(&self) -> &[SchemaResult] {
90        &self.committed
91    }
92
93    /// Removes and returns the results.
94    pub fn take_results(&mut self) -> Vec<SchemaResult> {
95        std::mem::take(&mut self.committed)
96    }
97
98    // ------------------------------------------------------------------------------------------------------------
99    // The evaluator's side
100
101    /// Opens a child context. The evaluation path is extended by `eval_segment` (verbatim), the schema path is
102    /// replaced by `schema_location`, and the document path is extended by `doc_segment` (already pointer-encoded).
103    pub(crate) fn begin_child_context(
104        &mut self,
105        eval_segment: Option<&str>,
106        schema_location: Option<&str>,
107        doc_segment: Option<&str>,
108    ) {
109        self.frames.push(Frame {
110            eval_len: self.eval_path.len(),
111            schema_path: self.schema_path.clone(),
112            doc_len: self.doc_path.len(),
113            commit_index: self.committed.len(),
114            rows_start: self.pending.len(),
115        });
116        if let Some(s) = eval_segment {
117            self.eval_path.push('/');
118            self.eval_path.push_str(s);
119        }
120        if let Some(s) = schema_location {
121            self.schema_path.clear();
122            self.schema_path.push_str(s);
123        }
124        if let Some(s) = doc_segment {
125            self.doc_path.push('/');
126            self.doc_path.push_str(s);
127        }
128    }
129
130    /// Closes a child context. When the parent does not need the child's results (`parent_is_match`) they are
131    /// discarded below Verbose; otherwise the context's summary row is written and its rows are committed.
132    pub(crate) fn commit_child_context(&mut self, parent_is_match: bool, child_is_match: bool, message: Message<'_>) {
133        if parent_is_match && self.level != ResultsLevel::Verbose {
134            self.pop_child_context();
135            return;
136        }
137        let row =
138            self.row(child_is_match, message, self.eval_path.clone(), self.schema_path.clone(), self.doc_path.clone());
139        self.pending.push(row);
140        let frame = self.frames.pop().unwrap();
141        let rows = self.pending.split_off(frame.rows_start);
142        self.committed.extend(rows.into_iter().rev());
143        self.restore(frame);
144    }
145
146    /// Closes a child context and discards everything it and its descendants wrote.
147    pub(crate) fn pop_child_context(&mut self) {
148        let frame = self.frames.pop().unwrap();
149        self.committed.truncate(frame.commit_index);
150        self.pending.truncate(frame.rows_start);
151        self.restore(frame);
152    }
153
154    pub(crate) fn evaluated_keyword(&mut self, is_match: bool, message: Message<'_>, keyword: &str) {
155        if !is_match || self.level == ResultsLevel::Verbose {
156            let k = encode_pointer_segment(keyword);
157            let row = self.row(
158                is_match,
159                message,
160                format!("{}/{k}", self.eval_path),
161                format!("{}/{k}", self.schema_path),
162                self.doc_path.clone(),
163            );
164            self.pending.push(row);
165        }
166    }
167
168    pub(crate) fn evaluated_keyword_for_property(
169        &mut self,
170        is_match: bool,
171        message: Message<'_>,
172        property_name: &str,
173        keyword: &str,
174    ) {
175        if !is_match || self.level == ResultsLevel::Verbose {
176            let k = encode_pointer_segment(keyword);
177            let row = self.row(
178                is_match,
179                message,
180                format!("{}/{k}", self.eval_path),
181                format!("{}/{k}", self.schema_path),
182                format!("{}/{}", self.doc_path, encode_pointer_segment(property_name)),
183            );
184            self.pending.push(row);
185        }
186    }
187
188    /// An annotation: Verbose only; the keyword extends the evaluation path but not the schema path.
189    pub(crate) fn ignored_keyword(&mut self, message: Message<'_>, keyword: &str) {
190        if self.level == ResultsLevel::Verbose {
191            let row = self.row(
192                true,
193                message,
194                format!("{}/{}", self.eval_path, encode_pointer_segment(keyword)),
195                self.schema_path.clone(),
196                self.doc_path.clone(),
197            );
198            self.pending.push(row);
199        }
200    }
201
202    pub(crate) fn evaluated_boolean_schema(&mut self, is_match: bool) {
203        if !is_match || self.level == ResultsLevel::Verbose {
204            let row = self.row(
205                is_match,
206                Message::None,
207                self.eval_path.clone(),
208                self.schema_path.clone(),
209                self.doc_path.clone(),
210            );
211            self.pending.push(row);
212        }
213    }
214
215    fn row(
216        &self,
217        is_match: bool,
218        message: Message<'_>,
219        evaluation_location: String,
220        schema_evaluation_location: String,
221        document_evaluation_location: String,
222    ) -> SchemaResult {
223        let with_text = self.level == ResultsLevel::Verbose || (!is_match && self.level >= ResultsLevel::Detailed);
224        let message = if with_text {
225            match message {
226                Message::None => String::new(),
227                Message::Static(s) => s.to_string(),
228                Message::Lazy(f) => f(),
229            }
230        } else {
231            String::new()
232        };
233        SchemaResult {
234            is_match,
235            message,
236            evaluation_location,
237            schema_evaluation_location,
238            document_evaluation_location,
239        }
240    }
241
242    fn restore(&mut self, frame: Frame) {
243        self.eval_path.truncate(frame.eval_len);
244        self.schema_path = frame.schema_path;
245        self.doc_path.truncate(frame.doc_len);
246    }
247}
248
249/// An annotation extracted from verbose results.
250#[derive(Clone, Debug, PartialEq, Eq)]
251pub struct Annotation {
252    /// The instance location (JSON pointer).
253    pub instance_location: String,
254    pub keyword: String,
255    /// The JSON pointer of the schema object that holds the keyword.
256    pub schema_location: String,
257    /// The annotation value as JSON text.
258    pub value: String,
259}
260
261/// The annotations in a verbose collector's results (`JsonSchemaAnnotationProducer.EnumerateAnnotations`).
262pub fn enumerate_annotations(collector: &JsonSchemaResultsCollector) -> impl Iterator<Item = Annotation> + '_ {
263    collector.results().iter().filter_map(|r| {
264        if !r.is_match || r.message.is_empty() {
265            return None;
266        }
267        let slash = r.evaluation_location.rfind('/')?;
268        if r.evaluation_location == r.schema_evaluation_location {
269            return None;
270        }
271        let keyword = &r.evaluation_location[slash + 1..];
272        let first = r.message.as_bytes()[0];
273        if keyword.is_empty()
274            || !(matches!(first, b'"' | b'{' | b'[' | b't' | b'f' | b'n' | b'-') || first.is_ascii_digit())
275        {
276            return None;
277        }
278        Some(Annotation {
279            instance_location: r.document_evaluation_location.clone(),
280            keyword: keyword.to_string(),
281            schema_location: r.schema_evaluation_location.clone(),
282            value: r.message.clone(),
283        })
284    })
285}
286
287/// `#` followed by the schema location, percent-encoded as a URI fragment (upper-case hex, UTF-8).
288pub fn schema_location_fragment(schema_location: &str) -> String {
289    let mut out = String::from("#");
290    for &byte in schema_location.as_bytes() {
291        let c = byte as char;
292        if byte < 128 && (c.is_ascii_alphanumeric() || "-._~!$&'()*+,;=:@/?".contains(c)) {
293            out.push(c);
294        } else {
295            out.push_str(&format!("%{byte:02X}"));
296        }
297    }
298    out
299}
300
301/// Annotations grouped by instance location, then keyword, then schema location fragment, with parsed values
302/// (`JsonSchemaAnnotationProducer.WriteAnnotationsTo`): `{ "/name": { "title": { "#/properties/name": "Name" } } }`.
303pub fn collect_annotations(
304    collector: &JsonSchemaResultsCollector,
305) -> BTreeMap<String, BTreeMap<String, BTreeMap<String, Value>>> {
306    let mut out: BTreeMap<String, BTreeMap<String, BTreeMap<String, Value>>> = BTreeMap::new();
307    for a in enumerate_annotations(collector) {
308        let value = serde_json::from_str(&a.value).unwrap_or(Value::String(a.value.clone()));
309        out.entry(a.instance_location)
310            .or_default()
311            .entry(a.keyword)
312            .or_default()
313            .insert(schema_location_fragment(&a.schema_location), value);
314    }
315    out
316}