Skip to main content

knf/interp/
error.rs

1//! What interpolation reports, and how it reads.
2//!
3//! Key paths and nothing else. Not a rule to enforce here so much as one that
4//! cannot be broken: this pass runs after the merge, and no layer outlives the
5//! merge, so the filename a reference was written in is genuinely unavailable.
6//! No flag names either — `crates/knf/src/explain.rs` adds the `help:` line that
7//! knows what the flags are called.
8
9use std::fmt;
10
11use super::scan::Syntax;
12use crate::{Seg, render_path};
13
14/// Why interpolation failed.
15///
16/// Two shapes because the two failures differ in kind. Everything a document
17/// gets *wrong* is collected and reported together — references are written all
18/// over a config, and rediscovering them one run at a time is the experience
19/// this avoids. A cycle is the exception: resolution cannot continue past it, so
20/// it is an early return and arrives alone.
21#[derive(Debug)]
22pub enum InterpError {
23    Problems(Vec<Problem>),
24    Cycle(Cycle),
25}
26
27impl std::error::Error for InterpError {}
28
29impl fmt::Display for InterpError {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::Cycle(cycle) => write!(f, "{cycle}"),
33            Self::Problems(problems) => {
34                // Grouped by kind rather than listed in document order: one
35                // header per kind keeps a mixed report as readable as a pure
36                // one, and the group order is fixed so the message never
37                // depends on where in the document the first mistake happened.
38                //
39                // No trailing newline — the caller appends its own `help:`
40                // lines, exactly as `NullInToml` does.
41                let mut lines: Vec<String> = Vec::new();
42                for group in Group::ALL {
43                    let members = problems.iter().filter(|p| p.group() == group);
44                    let mut any = false;
45                    for problem in members {
46                        if !any {
47                            lines.push(group.header().to_string());
48                            any = true;
49                        }
50                        lines.push(format!(
51                            "  --> {}: {}",
52                            render_path(problem.path()),
53                            problem.detail()
54                        ));
55                    }
56                }
57                f.write_str(&lines.join("\n"))
58            }
59        }
60    }
61}
62
63/// One thing wrong with one reference.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum Problem {
66    /// A reference that is not spelled like one.
67    Syntax { path: Vec<Seg>, error: Syntax },
68    /// A reference that names nothing: a key the merged document does not have,
69    /// or a variable the environment does not set.
70    ///
71    /// An error rather than a pass-through. Leaving `${db.hostname}` in the
72    /// output would ship a typo as a literal, and the document is already the
73    /// authority on what exists.
74    Unresolved { path: Vec<Seg>, reference: String },
75    /// A container or a null in embedded position — `url = "http://${db}/"`.
76    ///
77    /// Legal in *whole-string* position, where it aliases the subtree. Embedded
78    /// it has no format-independent rendering, so it is rejected in v1.
79    NotStringifiable {
80        path: Vec<Seg>,
81        reference: String,
82        kind: &'static str,
83    },
84}
85
86impl Problem {
87    /// Where in the merged document the offending string lives.
88    pub fn path(&self) -> &[Seg] {
89        match self {
90            Self::Syntax { path, .. }
91            | Self::Unresolved { path, .. }
92            | Self::NotStringifiable { path, .. } => path,
93        }
94    }
95
96    fn group(&self) -> Group {
97        match self {
98            Self::Syntax { .. } => Group::Syntax,
99            Self::Unresolved { .. } => Group::Unresolved,
100            Self::NotStringifiable { .. } => Group::NotStringifiable,
101        }
102    }
103
104    fn detail(&self) -> String {
105        match self {
106            Self::Syntax { error, .. } => error.to_string(),
107            Self::Unresolved { reference, .. } => format!("`{reference}`"),
108            Self::NotStringifiable {
109                reference, kind, ..
110            } => format!("`{reference}` is {} {kind}", article(kind)),
111        }
112    }
113}
114
115/// `object` and `array` take `an`; `null` takes `a`. Three possible inputs, so
116/// the vowel test is exact rather than a heuristic that will meet `hour`.
117fn article(kind: &str) -> &'static str {
118    if kind.starts_with(['a', 'e', 'i', 'o', 'u']) {
119        "an"
120    } else {
121        "a"
122    }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126enum Group {
127    Syntax,
128    Unresolved,
129    NotStringifiable,
130}
131
132impl Group {
133    /// Fixed order, most fundamental first: a malformed reference was never
134    /// going to resolve, so saying so before listing what is missing reads in
135    /// the order the user will fix things.
136    const ALL: [Self; 3] = [Self::Syntax, Self::Unresolved, Self::NotStringifiable];
137
138    fn header(self) -> &'static str {
139        match self {
140            Self::Syntax => "invalid reference",
141            Self::Unresolved => "unresolved reference",
142            Self::NotStringifiable => "reference cannot be rendered into a string",
143        }
144    }
145}
146
147/// A reference that resolves, directly or indirectly, to itself.
148///
149/// Reported as the whole chain rather than the one path it closed at: a two-hop
150/// cycle is obvious from either end, but a five-hop one is not.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct Cycle {
153    chain: Vec<Vec<Seg>>,
154}
155
156impl Cycle {
157    pub(crate) fn new(chain: Vec<Vec<Seg>>) -> Self {
158        Self { chain }
159    }
160}
161
162impl fmt::Display for Cycle {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        let hops: Vec<String> = self
165            .chain
166            .iter()
167            .map(|p| format!("`{}`", render_path(p)))
168            .collect();
169        write!(f, "reference cycle: {}", hops.join(" -> "))
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    fn key(dotted: &str) -> Vec<Seg> {
178        dotted.split('.').map(|s| Seg::Key(s.to_string())).collect()
179    }
180
181    #[test]
182    fn one_kind_renders_as_a_header_and_a_list() {
183        let err = InterpError::Problems(vec![
184            Problem::Unresolved {
185                path: key("server.url"),
186                reference: "db.hostname".into(),
187            },
188            Problem::Unresolved {
189                path: vec![Seg::Key("tags".into()), Seg::Index(0)],
190                reference: "env:REGION".into(),
191            },
192        ]);
193        assert_eq!(
194            err.to_string(),
195            "unresolved reference\n\
196             \x20 --> server.url: `db.hostname`\n\
197             \x20 --> tags[0]: `env:REGION`"
198        );
199    }
200
201    /// Mixed kinds group, in a fixed order that does not depend on where in the
202    /// document each mistake was found.
203    #[test]
204    fn kinds_group_in_a_fixed_order() {
205        let err = InterpError::Problems(vec![
206            Problem::NotStringifiable {
207                path: key("url"),
208                reference: "db".into(),
209                kind: "object",
210            },
211            Problem::Unresolved {
212                path: key("a"),
213                reference: "nope".into(),
214            },
215            Problem::Syntax {
216                path: key("b"),
217                error: Syntax::EmptyRef,
218            },
219        ]);
220        assert_eq!(
221            err.to_string(),
222            "invalid reference\n\
223             \x20 --> b: empty reference `${}`\n\
224             unresolved reference\n\
225             \x20 --> a: `nope`\n\
226             reference cannot be rendered into a string\n\
227             \x20 --> url: `db` is an object"
228        );
229    }
230
231    #[test]
232    fn a_cycle_reads_as_a_chain() {
233        let err = InterpError::Cycle(Cycle::new(vec![key("a"), key("b"), key("a")]));
234        assert_eq!(err.to_string(), "reference cycle: `a` -> `b` -> `a`");
235    }
236
237    #[test]
238    fn articles_match_the_three_possible_kinds() {
239        assert_eq!(article("object"), "an");
240        assert_eq!(article("array"), "an");
241        assert_eq!(article("null"), "a");
242    }
243}