Skip to main content

oapi_codegen/lower/
validate.rs

1//! Collection of semantic problems into one report.
2//!
3//! The generator stops on an ambiguity and does not guess. See the "Design
4//! differences" section of the README. A spec with several independent problems
5//! therefore costs one run for each problem. [`Diagnostics`] collects independent
6//! problems, so one run reports all of them.
7//!
8//! Two rules keep this report useful.
9//!
10//! * The collector holds independent problems only. Some checks produce a result
11//!   that the rest of the lowering pass needs. An unresolved `$ref` is one
12//!   example. Such a check must still return at once. A run that continues past
13//!   it reports later problems that are only effects of the first one.
14//! * One problem is reported as itself. [`Diagnostics::into_result`] returns the
15//!   single [`Error`] unchanged. It builds an [`Error::Validation`] for two or
16//!   more problems only. A caller that matches one variant still works, and the
17//!   message for one problem carries no count.
18
19use crate::error::Error;
20use crate::error::Result;
21
22/// A collector of independent semantic problems. One run reports every problem
23/// that it finds and does not stop at the first one.
24///
25/// Add a problem with [`Diagnostics::push`]. Add the result of a fallible check
26/// with [`Diagnostics::check`]. End with [`Diagnostics::into_result`].
27#[derive(Debug, Default)]
28pub struct Diagnostics {
29    problems: Vec<Error>,
30}
31
32impl Diagnostics {
33    /// A collector with no problems recorded.
34    pub fn new() -> Self {
35        return Self { problems: Vec::new() };
36    }
37
38    /// Record a problem and continue.
39    ///
40    /// A problem that is itself a report opens and adds its problems one by one.
41    /// A check can collect on its own and give a report back, and a caller that
42    /// collects again would otherwise nest one report inside another. The reader
43    /// then gets a count that hides most of the list. [`Error::Validation`]
44    /// therefore holds leaf problems only, at one level.
45    pub fn push(&mut self, problem: Error) {
46        match problem {
47            Error::Validation { problems } => self.problems.extend(problems),
48            leaf => self.problems.push(leaf),
49        }
50    }
51
52    /// Record the error from a failed check and continue.
53    ///
54    /// Use this method for a check that returns a `Result`. That check keeps one
55    /// signature. A caller can then run it alone, or as part of this collection
56    /// pass.
57    pub fn check(&mut self, outcome: Result<()>) {
58        if let Err(problem) = outcome {
59            self.push(problem);
60        }
61    }
62
63    /// Whether no problems have been recorded.
64    pub fn is_empty(&self) -> bool {
65        return self.problems.is_empty();
66    }
67
68    /// How many problems have been recorded.
69    pub fn len(&self) -> usize {
70        return self.problems.len();
71    }
72
73    /// Convert the collected problems into a result.
74    ///
75    /// No problems give `Ok(())`. One problem returns as itself, so the caller
76    /// sees the error that a stop-at-first check gives. Two or more problems go
77    /// into an [`Error::Validation`] in discovery order.
78    pub fn into_result(mut self) -> Result<()> {
79        // `pop` and not an index, because the workspace denies
80        // `indexing_slicing`. This path also makes no new allocation.
81        if self.problems.len() == 1 {
82            match self.problems.pop() {
83                Some(single) => return Err(single),
84                None => return Ok(()),
85            }
86        }
87        if self.problems.is_empty() {
88            return Ok(());
89        }
90        return Err(Error::Validation {
91            problems: self.problems,
92        });
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    fn problem(name: &str) -> Error {
101        return Error::TypeNameCollision {
102            name: name.to_owned(),
103            artifact: "response enum".to_owned(),
104            hint: "rename it".to_owned(),
105        };
106    }
107
108    #[test]
109    fn empty_collector_is_ok() {
110        let diagnostics = Diagnostics::new();
111        assert!(diagnostics.is_empty());
112        assert!(diagnostics.into_result().is_ok());
113    }
114
115    #[test]
116    fn single_problem_is_reported_as_itself() {
117        // One problem must stay unwrapped. A caller matches one variant, and the
118        // message must carry no count.
119        let mut diagnostics = Diagnostics::new();
120        diagnostics.push(problem("Widget"));
121        let err = diagnostics.into_result().expect_err("one problem must fail");
122        assert!(
123            matches!(&err, Error::TypeNameCollision { name, .. } if name == "Widget"),
124            "expected the original variant unwrapped, got: {err:?}",
125        );
126    }
127
128    #[test]
129    fn multiple_problems_aggregate_in_order() {
130        let mut diagnostics = Diagnostics::new();
131        diagnostics.push(problem("Widget"));
132        diagnostics.check(Err(problem("Gadget")));
133        diagnostics.check(Ok(()));
134        assert_eq!(diagnostics.len(), 2, "a passing check must not be recorded");
135        let err = diagnostics.into_result().expect_err("two problems must fail");
136        let Error::Validation { problems } = &err else {
137            panic!("expected Validation, got: {err:?}");
138        };
139        assert_eq!(problems.len(), 2);
140        let message = err.to_string();
141        let widget = message.find("Widget").expect("message should list the first problem");
142        let gadget = message.find("Gadget").expect("message should list the second problem");
143        assert!(
144            widget < gadget,
145            "problems should be listed in discovery order: {message}"
146        );
147        assert!(
148            message.contains('2'),
149            "message should say how many problems were found: {message}",
150        );
151    }
152
153    #[test]
154    fn a_report_pushed_into_a_report_does_not_nest() {
155        // A schema-level check collects on its own and gives a report back. The
156        // loop over schemas collects again. Without opening the inner report the
157        // reader sees "found 2 problems", and one of the two hides the rest.
158        let mut inner = Diagnostics::new();
159        inner.push(problem("Widget"));
160        inner.push(problem("Gadget"));
161        let report = inner.into_result().expect_err("two problems must fail");
162
163        let mut outer = Diagnostics::new();
164        outer.push(report);
165        outer.push(problem("Doohickey"));
166        assert_eq!(outer.len(), 3, "the inner problems should be counted one by one");
167
168        let err = outer.into_result().expect_err("three problems must fail");
169        let Error::Validation { problems } = &err else {
170            panic!("expected Validation, got: {err:?}");
171        };
172        assert!(
173            problems
174                .iter()
175                .all(|entry| return !matches!(*entry, Error::Validation { .. })),
176            "a report must hold leaf problems only, got: {problems:?}",
177        );
178    }
179}