Skip to main content

isoloom_spec/syntax/
problem.rs

1//! What is wrong with a spec, as an author reads it: a stable code, the field path, the line,
2//! the reason and the fix.
3//!
4//! ```text
5//! error[removed-field] machines.web.docker  (isoloom.yml:12:5)
6//!   a machine says what it runs, never how a platform runs it, so `docker:` is gone
7//!   fix: `app: { build: build/web }` for your Dockerfile, or `app: { image: <ref> }`
8//! ```
9
10use std::fmt;
11
12use serde::Serialize;
13
14/// The kind of problem: a stable, kebab-case code that tools and editors can key on.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
16#[serde(rename_all = "kebab-case")]
17#[non_exhaustive]
18pub enum Code {
19    /// A key the spec doesn't have (a typo, or a field in the wrong place).
20    UnknownField,
21    /// A key of the earlier format: never read, its replacement named in the fix.
22    RemovedField,
23    /// A field that must be there.
24    MissingField,
25    /// A value of the wrong type or outside what the field accepts.
26    InvalidValue,
27    /// Fields that can't go together.
28    Conflict,
29    /// A name that names nothing declared (a machine, a network, an input, a port).
30    UnknownReference,
31    /// A sensitive value where it would show (a secret or a sensitive input as an environment
32    /// variable).
33    SensitiveValue,
34    /// A target can't run the machine as written (not a spec error: another target may).
35    Refused,
36}
37
38impl Code {
39    /// The code as written in a problem: `removed-field`.
40    #[must_use]
41    pub fn id(self) -> &'static str {
42        match self {
43            Code::UnknownField => "unknown-field",
44            Code::RemovedField => "removed-field",
45            Code::MissingField => "missing-field",
46            Code::InvalidValue => "invalid-value",
47            Code::Conflict => "conflict",
48            Code::UnknownReference => "unknown-reference",
49            Code::SensitiveValue => "sensitive-value",
50            Code::Refused => "refused",
51        }
52    }
53}
54
55/// Where a field sits in the file: its line and column, both from 1.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
57pub struct Span {
58    /// The line, from 1.
59    pub line: usize,
60    /// The column, from 1.
61    pub column: usize,
62}
63
64/// A field's path in the spec: `machines.web.ports.site.port`, `checks[2].http`.
65#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)]
66#[serde(transparent)]
67pub struct FieldPath(String);
68
69impl FieldPath {
70    /// The path as written.
71    #[must_use]
72    pub fn new(path: impl Into<String>) -> FieldPath {
73        FieldPath(path.into())
74    }
75
76    /// The path of field `key` under this one.
77    #[must_use]
78    pub fn key(&self, key: &str) -> FieldPath {
79        if self.0.is_empty() {
80            FieldPath(key.to_owned())
81        } else {
82            FieldPath(format!("{}.{key}", self.0))
83        }
84    }
85
86    /// The path of item `i` of the list at this one.
87    #[must_use]
88    pub fn index(&self, i: usize) -> FieldPath {
89        FieldPath(format!("{}[{i}]", self.0))
90    }
91
92    /// The path as text.
93    #[must_use]
94    pub fn as_str(&self) -> &str {
95        &self.0
96    }
97
98    /// The enclosing field's path (`machines.web` for `machines.web.app`), when there is one.
99    #[must_use]
100    pub fn parent(&self) -> Option<FieldPath> {
101        let cut = self.0.rfind(['.', '['])?;
102        self.0.get(..cut).map(|parent| FieldPath(parent.to_owned()))
103    }
104
105    /// Whether this path is `other` or a field inside it.
106    #[must_use]
107    pub fn within(&self, other: &FieldPath) -> bool {
108        self.0 == other.0 || self.0.strip_prefix(other.0.as_str()).is_some_and(|rest| rest.starts_with(['.', '[']))
109    }
110}
111
112impl fmt::Display for FieldPath {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(&self.0)
115    }
116}
117
118/// One thing wrong with a spec.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
120pub struct Problem {
121    /// What kind of problem it is.
122    pub code: Code,
123    /// The field it is about.
124    pub path: FieldPath,
125    /// Where that field is in the file, when it is written there (a field a group gives a
126    /// machine has the machine's own place, or none).
127    #[serde(flatten, skip_serializing_if = "Option::is_none")]
128    pub span: Option<Span>,
129    /// Why it is wrong.
130    pub reason: String,
131    /// What to write instead, when there is one answer.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub fix: Option<String>,
134}
135
136impl Problem {
137    /// A problem with no fix to offer and no place yet (see [`crate::syntax::Spans::place`]).
138    #[must_use]
139    pub fn new(code: Code, path: FieldPath, reason: impl Into<String>) -> Problem {
140        Problem {
141            code,
142            path,
143            span: None,
144            reason: reason.into(),
145            fix: None,
146        }
147    }
148
149    /// The problem with its fix.
150    #[must_use]
151    pub fn fix(mut self, fix: impl Into<String>) -> Problem {
152        self.fix = Some(fix.into());
153        self
154    }
155
156    /// The three lines an author reads, naming `file` (`isoloom.yml`) on the first.
157    #[must_use]
158    pub fn render(&self, file: &str) -> String {
159        let head = format!("error[{}] {}", self.code.id(), self.path);
160        lines(&head, self.span, file, &self.reason, self.fix.as_deref())
161    }
162
163    /// The problem on one line, its field then its reason (`machines.web.app.build: a Dockerfile
164    /// builds a container only`): what a listing of targets shows.
165    #[must_use]
166    pub fn line(&self) -> String {
167        format!("{}: {}", self.path, self.reason)
168    }
169
170    /// The top-level section the problem is in: `machines` for `machines.web.app`.
171    #[must_use]
172    pub fn section(&self) -> &str {
173        self.path.as_str().split(['.', '[']).next().unwrap_or_default()
174    }
175}
176
177impl fmt::Display for Problem {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        f.write_str(&self.render("isoloom.yml"))
180    }
181}
182
183/// The problems of a spec, in file order.
184pub type Problems = Vec<Problem>;
185
186/// The known name closest to `word`, when it is close (a typo).
187#[must_use]
188pub fn closest<'a>(word: &str, known: &[&'a str]) -> Option<&'a str> {
189    known
190        .iter()
191        .map(|k| (distance(word, k), *k))
192        .filter(|(d, k)| *d <= 2.max(k.len() / 4))
193        .min_by_key(|(d, _)| *d)
194        .map(|(_, k)| k)
195}
196
197/// The edit distance between two words.
198fn distance(a: &str, b: &str) -> usize {
199    let b: Vec<char> = b.chars().collect();
200    // One row of the table at a time: `row[j]` is the distance between what of `a` was read and
201    // the first `j` letters of `b`.
202    let mut row: Vec<usize> = (0..=b.len()).collect();
203    for (i, ca) in a.chars().enumerate() {
204        let mut next = Vec::with_capacity(row.len());
205        next.push(i + 1);
206        for ((cb, diagonal), above) in b.iter().zip(&row).zip(row.iter().skip(1)) {
207            let left = next.last().copied().unwrap_or_default();
208            next.push((diagonal + usize::from(ca != *cb)).min(above + 1).min(left + 1));
209        }
210        row = next;
211    }
212    row.last().copied().unwrap_or_default()
213}
214
215/// The three lines an author reads: `head` and the field's place in `file`, the reason, then
216/// the fix when there is one.
217pub(crate) fn lines(head: &str, span: Option<Span>, file: &str, reason: &str, fix: Option<&str>) -> String {
218    let place = span.map(|s| format!("  ({file}:{}:{})", s.line, s.column)).unwrap_or_default();
219    let mut out = format!("{head}{place}\n  {reason}");
220    if let Some(fix) = fix {
221        out.push_str("\n  fix: ");
222        out.push_str(fix);
223    }
224    out
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn a_problem_reads_on_one_line_as_its_path_and_reason() {
233        let p = Problem::new(Code::Refused, FieldPath::new("machines.web.app.build"), "a Dockerfile builds a container only");
234        assert_eq!(p.line(), "machines.web.app.build: a Dockerfile builds a container only");
235        assert_eq!(p.to_string(), p.render("isoloom.yml"));
236        assert_eq!(p.to_string(), "error[refused] machines.web.app.build\n  a Dockerfile builds a container only");
237    }
238
239    #[test]
240    fn a_problem_reads_as_three_lines() {
241        let mut p = Problem::new(Code::RemovedField, FieldPath::new("machines.web.docker"), "`docker:` is gone").fix("`app: { image: <ref> }`");
242        assert_eq!(
243            p.render("isoloom.yml"),
244            "error[removed-field] machines.web.docker\n  `docker:` is gone\n  fix: `app: { image: <ref> }`"
245        );
246        p.span = Some(Span { line: 12, column: 5 });
247        assert!(
248            p.render("isoloom.yml")
249                .starts_with("error[removed-field] machines.web.docker  (isoloom.yml:12:5)\n")
250        );
251        let json = serde_json::to_value(&p).unwrap();
252        assert_eq!(json["code"], "removed-field");
253        assert_eq!((json["line"].as_u64(), json["column"].as_u64()), (Some(12), Some(5)));
254        assert_eq!(p.section(), "machines");
255    }
256
257    #[test]
258    fn a_typo_finds_the_name_it_meant() {
259        // A long name allows more edits: a quarter of its length.
260        assert_eq!(closest("nested-virtualisatio", &["nested_virtualization"]), Some("nested_virtualization"));
261        assert_eq!((distance("a", "b"), distance("on", "no"), distance("flaw", "lawn")), (1, 2, 2));
262        assert_eq!(closest("netwrks", &["networks", "ports"]), Some("networks"));
263        assert_eq!(closest("dc1", &["dc01", "ws01"]), Some("dc01"));
264        assert_eq!(closest("zzz", &["networks"]), None);
265        assert_eq!(distance("kitten", "sitting"), 3);
266        assert_eq!((distance("", "abc"), distance("abc", "")), (3, 3));
267    }
268
269    #[test]
270    fn paths_nest_and_contain() {
271        let web = FieldPath::new("machines").key("web");
272        let file = web.key("files").index(0);
273        assert_eq!(file.as_str(), "machines.web.files[0]");
274        assert!(file.within(&web) && !web.within(&file) && !FieldPath::new("machines.webs").within(&web));
275        assert_eq!(file.parent(), Some(web.key("files")));
276    }
277}