Skip to main content

sva_core/
cli_error.rs

1// Concern: enumerates the pipeline error variants, exit codes, JSON codes and diagnostics | Non-concern: JSON envelope framing (output.rs), argv (sva-cli) | IO: none
2
3use std::fmt;
4
5use sva_ast::Refusal;
6use sva_engine::EngineError;
7
8use crate::lint_code::LintCode;
9use crate::output::{Diagnostic, Severity};
10
11/// One `lint` finding: its code, how hard it lands, and the node it is against.
12#[derive(Debug)]
13pub struct LintViolation {
14    pub code: LintCode,
15    pub severity: Severity,
16    pub subject: String,
17    pub message: String,
18    pub line: Option<usize>,
19}
20
21/// Every expected failure mode this crate can hit; none of them a panic.
22#[derive(Debug)]
23pub enum CliError {
24    /// An argument mistake: `code` names which, `message` quotes the text it refuses.
25    Usage {
26        code: &'static str,
27        message: String,
28    },
29    Refusals(Vec<Refusal>),
30    Engine(EngineError),
31    BadTempo(String),
32    BadProbe(String),
33    Io(String),
34    /// A path the caller named that nothing answers for; an `Io` is one that is there.
35    NotFound(String),
36    /// A name already taken, as opposed to an `Io` the filesystem raised.
37    Conflict {
38        by: &'static str,
39        message: String,
40    },
41    /// Every `LintViolation` the scan found, not just the first.
42    LintRefused(Vec<LintViolation>),
43    /// `error`, its diagnostics helped by `help` in place of their own.
44    Hinted {
45        error: Box<CliError>,
46        help: &'static str,
47    },
48    /// An argument mistake inside a file: its code, and where in the file, from 1.
49    Located {
50        code: &'static str,
51        message: String,
52        file: String,
53        byte: usize,
54        line: usize,
55        column: usize,
56    },
57}
58
59impl CliError {
60    pub fn usage(code: &'static str, message: impl Into<String>) -> CliError {
61        CliError::Usage {
62            code,
63            message: message.into(),
64        }
65    }
66}
67
68impl From<EngineError> for CliError {
69    fn from(e: EngineError) -> CliError {
70        CliError::Engine(e)
71    }
72}
73
74fn engine_error_path(e: &EngineError) -> Option<String> {
75    match e {
76        EngineError::Binding { node, .. } => Some(node.clone()),
77        EngineError::UnknownNode(p)
78        | EngineError::UnknownInstance(p, _)
79        | EngineError::AmbiguousNode(p, _)
80        | EngineError::RefAboveRoot(p, _) => Some(p.clone()),
81        other => other.at().map(|at| at.node.clone()),
82    }
83}
84
85fn engine_error_span(e: &EngineError) -> Option<(usize, usize)> {
86    let span = match e {
87        EngineError::Binding { span, .. } => *span,
88        other => other.at().and_then(|at| at.span),
89    }?;
90    Some((span.start, span.end - span.start))
91}
92
93/// A recording no file answers for is a path the caller named that nothing holds.
94fn misses_a_recording(rs: &[Refusal]) -> bool {
95    rs.iter()
96        .any(|r| r.code == sva_ast::DiagCode::MissingRecording)
97}
98
99fn names_no_node(e: &EngineError) -> bool {
100    matches!(
101        e,
102        EngineError::UnknownNode(_) | EngineError::UnknownInstance(..)
103    )
104}
105
106impl CliError {
107    pub fn exit_code(&self) -> u8 {
108        match self {
109            CliError::Hinted { error, .. } => error.exit_code(),
110            CliError::Engine(e) if names_no_node(e) => 24,
111            CliError::Refusals(rs) if misses_a_recording(rs) => 24,
112            CliError::Usage { .. }
113            | CliError::Refusals(_)
114            | CliError::Engine(_)
115            | CliError::BadTempo(_)
116            | CliError::BadProbe(_)
117            | CliError::LintRefused(_)
118            | CliError::Located { .. } => 3,
119            CliError::Conflict { .. } => 4,
120            CliError::NotFound(_) => 24,
121            CliError::Io(_) => 1,
122        }
123    }
124
125    pub fn code(&self) -> &'static str {
126        match self {
127            CliError::Hinted { error, .. } => error.code(),
128            CliError::Engine(e) if names_no_node(e) => "not_found",
129            CliError::Refusals(rs) if misses_a_recording(rs) => "not_found",
130            CliError::Usage { .. }
131            | CliError::Refusals(_)
132            | CliError::Engine(_)
133            | CliError::BadTempo(_)
134            | CliError::BadProbe(_)
135            | CliError::LintRefused(_)
136            | CliError::Located { .. } => "validation_error",
137            CliError::Conflict { .. } => "conflict",
138            CliError::NotFound(_) => "not_found",
139            CliError::Io(_) => "internal_error",
140        }
141    }
142
143    pub fn message(&self) -> String {
144        match self {
145            CliError::Hinted { error, .. } => error.message(),
146            CliError::Usage { message, .. } | CliError::Located { message, .. } => message.clone(),
147            CliError::Refusals(rs) => format!("{} refusal(s) parsing the composition", rs.len()),
148            CliError::Engine(e) => e.to_string(),
149            CliError::BadTempo(m) | CliError::BadProbe(m) | CliError::Io(m) => m.clone(),
150            CliError::NotFound(m) => m.clone(),
151            CliError::Conflict { message, .. } => message.clone(),
152            CliError::LintRefused(vs) => format!(
153                "{} lint violation(s)",
154                vs.iter().filter(|v| v.severity == Severity::Error).count()
155            ),
156        }
157    }
158
159    /// Per-finding detail; empty only where the failure has no subject to locate it against.
160    pub fn diagnostics(&self) -> Vec<Diagnostic> {
161        match self {
162            CliError::Hinted { error, help } => error
163                .diagnostics()
164                .into_iter()
165                .map(|d| d.helped(*help))
166                .collect(),
167            CliError::Refusals(rs) => rs
168                .iter()
169                .map(|r| {
170                    Diagnostic::new(r.code.code_str(), r.reason.clone())
171                        .at(
172                            Some(r.at.path.clone()),
173                            Some((r.at.span.start, r.at.span.end - r.at.span.start)),
174                        )
175                        .placed(r.at.start)
176                })
177                .collect(),
178            CliError::Engine(e) => vec![engine_diagnostic(e)],
179            CliError::BadTempo(m) => vec![
180                Diagnostic::new("tempo.invalid_bpm_meter", m.clone()).helped(
181                    "state `variables/bpm` as a positive number and `variables/meter` as \
182                     `<beats>/<note>`, e.g. `4/4`",
183                ),
184            ],
185            CliError::BadProbe(m) => vec![Diagnostic::new("probe.invalid_expression", m.clone())
186                .helped("name a node path that exists, or an expression in the same grammar a node file holds")],
187            CliError::Usage { code, message } => vec![Diagnostic::new(*code, message.clone())
188                .helped("`sva-cli --help` states every subcommand, its arguments and their defaults")],
189            CliError::Conflict { by, message } => vec![
190                Diagnostic::new(format!("{by}.already_exists"), message.clone())
191                    .helped("pick a name nothing occupies, or remove what is there first"),
192            ],
193            CliError::NotFound(m) => vec![
194                Diagnostic::new("io.no_such_path", m.clone())
195                    .helped("name a path that exists; `sva-cli lint` says what a composition holds"),
196            ],
197            CliError::LintRefused(vs) => vs
198                .iter()
199                .map(|v| lint_diagnostic(v.code, &v.subject, &v.message, v.severity, v.line))
200                .collect(),
201            CliError::Located {
202                code,
203                message,
204                file,
205                byte,
206                line,
207                column,
208            } => vec![
209                Diagnostic::new(*code, message.clone())
210                    .at(Some(file.clone()), Some((*byte, 0)))
211                    .placed(Some((*line, *column)))
212                    .helped("`sva-cli --help` states how a cost reads, under SOLVE"),
213            ],
214            CliError::Io(_) => Vec::new(),
215        }
216    }
217}
218
219/// A song's warning, located at its line and column as a refusal is.
220pub fn warning_diagnostic(w: &sva_ast::song::Warning) -> Diagnostic {
221    Diagnostic::new(w.code, w.message.clone())
222        .with_severity(Severity::Warning)
223        .at(Some(w.file.clone()), Some((w.span.start, 0)))
224        .placed(Some(w.start))
225}
226
227/// `Display` runs a refusal's three parts together; a diagnostic keeps each apart.
228fn engine_diagnostic(e: &EngineError) -> Diagnostic {
229    let held = match e {
230        EngineError::Refused(d) => {
231            Diagnostic::new(d.code.clone(), d.message.clone()).helped(d.help.clone())
232        }
233        other => Diagnostic::new(other.code(), other.to_string()),
234    };
235    let held = match solve_only(e) {
236        true => held.helped(crate::solve::SOLVE_ONLY),
237        false => held,
238    };
239    held.at(engine_error_path(e), engine_error_span(e))
240}
241
242/// A cost's leaf written where only a node's grammar reads: `repr` is no variable to it, and
243/// `load_json` no function.
244fn solve_only(e: &EngineError) -> bool {
245    match e {
246        EngineError::Binding {
247            fault: sva_engine::BindingFault::Unbound(_, name),
248            ..
249        } => name == "repr",
250        EngineError::UnknownBuiltin(name) => name == crate::solve::LOAD_JSON,
251        _ => false,
252    }
253}
254
255/// The one place a `lint` code becomes a diagnostic, so both of `lint`'s response paths
256/// namespace it, locate it and help against it identically.
257pub fn lint_diagnostic(
258    code: LintCode,
259    subject: &str,
260    message: &str,
261    severity: Severity,
262    line: Option<usize>,
263) -> Diagnostic {
264    Diagnostic::new(
265        format!("lint.{}", code.code_str()).replace('-', "_"),
266        message,
267    )
268    .with_severity(severity)
269    .at(Some(subject.to_string()), None)
270    .on_line(line)
271    .helped(code.help())
272}
273
274impl fmt::Display for CliError {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        write!(f, "{}", self.message())
277    }
278}
279
280impl std::error::Error for CliError {}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    /// The `cli` standard's table, and its rule that code and exit code always agree.
287    #[test]
288    fn every_variant_answers_the_standards_own_code_and_exit_code() {
289        let cases: [(CliError, &str, u8); 7] = [
290            (CliError::NotFound("gone".to_string()), "not_found", 24),
291            (
292                CliError::Engine(EngineError::UnknownNode("gone".to_string())),
293                "not_found",
294                24,
295            ),
296            (
297                CliError::usage("cli.unknown_flag", "bad flag"),
298                "validation_error",
299                3,
300            ),
301            (
302                CliError::BadTempo("bad bpm".to_string()),
303                "validation_error",
304                3,
305            ),
306            (
307                CliError::Conflict {
308                    by: "new",
309                    message: "taken".to_string(),
310                },
311                "conflict",
312                4,
313            ),
314            (CliError::Io("disk".to_string()), "internal_error", 1),
315            (CliError::LintRefused(Vec::new()), "validation_error", 3),
316        ];
317        for (err, code, exit) in cases {
318            assert_eq!(err.code(), code, "{err:?}");
319            assert_eq!(err.exit_code(), exit, "{err:?}");
320        }
321    }
322
323    /// Each argument mistake is its own code, its message the one line that names the text.
324    #[test]
325    fn an_argument_mistake_is_one_diagnostic_under_its_own_code() {
326        let refused = CliError::usage("query.unknown_argument", "`lines` takes no arguments");
327        assert_eq!(refused.message(), "`lines` takes no arguments");
328        let [d] = &refused.diagnostics()[..] else {
329            panic!("an argument mistake carries exactly one diagnostic");
330        };
331        assert_eq!(d.code, "query.unknown_argument");
332        assert_eq!(d.message, "`lines` takes no arguments");
333        assert!(d.help.is_some(), "and says where to look");
334    }
335}