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    Usage(String),
25    Refusals(Vec<Refusal>),
26    Engine(EngineError),
27    BadTempo(String),
28    BadProbe(String),
29    Io(String),
30    /// A path the caller named that nothing answers for; an `Io` is one that is there.
31    NotFound(String),
32    /// A name already taken, as opposed to an `Io` the filesystem raised.
33    Conflict {
34        by: &'static str,
35        message: String,
36    },
37    /// Every `LintViolation` the scan found, not just the first.
38    LintRefused(Vec<LintViolation>),
39}
40
41fn engine_error_path(e: &EngineError) -> Option<String> {
42    match e {
43        EngineError::Binding { node, .. } => Some(node.clone()),
44        EngineError::UnknownNode(p)
45        | EngineError::AmbiguousNode(p, _)
46        | EngineError::RefAboveRoot(p, _) => Some(p.clone()),
47        other => other.at().map(|at| at.node.clone()),
48    }
49}
50
51fn engine_error_span(e: &EngineError) -> Option<(usize, usize)> {
52    let span = match e {
53        EngineError::Binding { span, .. } => *span,
54        other => other.at().and_then(|at| at.span),
55    }?;
56    Some((span.start, span.end - span.start))
57}
58
59fn names_no_node(e: &EngineError) -> bool {
60    matches!(e, EngineError::UnknownNode(_))
61}
62
63impl CliError {
64    /// This repo's `cli` standard exit-code table, by failure category.
65    pub fn exit_code(&self) -> u8 {
66        match self {
67            CliError::Engine(e) if names_no_node(e) => 24,
68            CliError::Usage(_)
69            | CliError::Refusals(_)
70            | CliError::Engine(_)
71            | CliError::BadTempo(_)
72            | CliError::BadProbe(_)
73            | CliError::LintRefused(_) => 3,
74            CliError::Conflict { .. } => 4,
75            CliError::NotFound(_) => 24,
76            CliError::Io(_) => 1,
77        }
78    }
79
80    /// The top-level `error.code`, per the standard's error-code table.
81    pub fn code(&self) -> &'static str {
82        match self {
83            CliError::Engine(e) if names_no_node(e) => "not_found",
84            CliError::Usage(_)
85            | CliError::Refusals(_)
86            | CliError::Engine(_)
87            | CliError::BadTempo(_)
88            | CliError::BadProbe(_)
89            | CliError::LintRefused(_) => "validation_error",
90            CliError::Conflict { .. } => "conflict",
91            CliError::NotFound(_) => "not_found",
92            CliError::Io(_) => "internal_error",
93        }
94    }
95
96    pub fn message(&self) -> String {
97        match self {
98            CliError::Usage(m) => m.clone(),
99            CliError::Refusals(rs) => format!("{} refusal(s) parsing the composition", rs.len()),
100            CliError::Engine(e) => e.to_string(),
101            CliError::BadTempo(m) | CliError::BadProbe(m) | CliError::Io(m) => m.clone(),
102            CliError::NotFound(m) => m.clone(),
103            CliError::Conflict { message, .. } => message.clone(),
104            CliError::LintRefused(vs) => format!(
105                "{} lint violation(s)",
106                vs.iter().filter(|v| v.severity == Severity::Error).count()
107            ),
108        }
109    }
110
111    /// Per-finding detail; empty only where the failure has no subject to locate it against.
112    pub fn diagnostics(&self) -> Vec<Diagnostic> {
113        match self {
114            CliError::Refusals(rs) => rs
115                .iter()
116                .map(|r| {
117                    Diagnostic::new(r.code.code_str(), r.reason.clone()).at(
118                        Some(r.at.path.clone()),
119                        Some((r.at.span.start, r.at.span.end - r.at.span.start)),
120                    )
121                })
122                .collect(),
123            CliError::Engine(e) => vec![engine_diagnostic(e)],
124            CliError::BadTempo(m) => vec![
125                Diagnostic::new("tempo.invalid_bpm_meter", m.clone()).helped(
126                    "state `variables/bpm` as a positive number and `variables/meter` as \
127                     `<beats>/<note>`, e.g. `4/4`",
128                ),
129            ],
130            CliError::BadProbe(m) => vec![Diagnostic::new("probe.invalid_expression", m.clone())
131                .helped("name a node path that exists, or an expression in the same grammar a node file holds")],
132            CliError::Usage(m) => vec![Diagnostic::new("cli.invalid_argument", without_usage(m))
133                .helped("`sva-cli --help` states every subcommand, its arguments and their defaults")],
134            CliError::Conflict { by, message } => vec![
135                Diagnostic::new(format!("{by}.already_exists"), message.clone())
136                    .helped("pick a name nothing occupies, or remove what is there first"),
137            ],
138            CliError::NotFound(m) => vec![
139                Diagnostic::new("io.no_such_path", m.clone())
140                    .helped("name a path that exists; `sva-cli lint` says what a composition holds"),
141            ],
142            CliError::LintRefused(vs) => vs
143                .iter()
144                .map(|v| lint_diagnostic(v.code, &v.subject, &v.message, v.severity, v.line))
145                .collect(),
146            CliError::Io(_) => Vec::new(),
147        }
148    }
149}
150
151/// `Display` runs a refusal's three parts together; a diagnostic keeps each apart.
152fn engine_diagnostic(e: &EngineError) -> Diagnostic {
153    let held = match e {
154        EngineError::Refused(d) => {
155            Diagnostic::new(d.code.clone(), d.message.clone()).helped(d.help.clone())
156        }
157        other => Diagnostic::new(other.code(), other.to_string()),
158    };
159    held.at(engine_error_path(e), engine_error_span(e))
160}
161
162/// `error.message` already carries the banner; repeating it here doubles a response. Every
163/// `Usage` states its reason first, so a bare banner is a call site that forgot to.
164fn without_usage(message: &str) -> &str {
165    match message.split_once("\nusage:") {
166        Some((reason, _)) => reason,
167        None => {
168            debug_assert!(
169                !message.starts_with("usage:"),
170                "a usage refusal states its reason before the banner"
171            );
172            message
173        }
174    }
175}
176
177/// The one place a `lint` code becomes a diagnostic, so both of `lint`'s response paths
178/// namespace it, locate it and help against it identically.
179pub fn lint_diagnostic(
180    code: LintCode,
181    subject: &str,
182    message: &str,
183    severity: Severity,
184    line: Option<usize>,
185) -> Diagnostic {
186    Diagnostic::new(
187        format!("lint.{}", code.code_str()).replace('-', "_"),
188        message,
189    )
190    .with_severity(severity)
191    .at(Some(subject.to_string()), None)
192    .on_line(line)
193    .helped(code.help())
194}
195
196impl fmt::Display for CliError {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        write!(f, "{}", self.message())
199    }
200}
201
202impl std::error::Error for CliError {}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    /// The `cli` standard's table, and its rule that code and exit code always agree.
209    #[test]
210    fn every_variant_answers_the_standards_own_code_and_exit_code() {
211        let cases: [(CliError, &str, u8); 7] = [
212            (CliError::NotFound("gone".to_string()), "not_found", 24),
213            (
214                CliError::Engine(EngineError::UnknownNode("gone".to_string())),
215                "not_found",
216                24,
217            ),
218            (
219                CliError::Usage("bad flag".to_string()),
220                "validation_error",
221                3,
222            ),
223            (
224                CliError::BadTempo("bad bpm".to_string()),
225                "validation_error",
226                3,
227            ),
228            (
229                CliError::Conflict {
230                    by: "new",
231                    message: "taken".to_string(),
232                },
233                "conflict",
234                4,
235            ),
236            (CliError::Io("disk".to_string()), "internal_error", 1),
237            (CliError::LintRefused(Vec::new()), "validation_error", 3),
238        ];
239        for (err, code, exit) in cases {
240            assert_eq!(err.code(), code, "{err:?}");
241            assert_eq!(err.exit_code(), exit, "{err:?}");
242        }
243    }
244
245    /// A diagnostic's `message` is a one-line summary, so the banner stays out of it.
246    #[test]
247    fn an_argument_diagnostic_carries_the_reason_without_the_usage_banner() {
248        for message in [
249            "lint takes only [<node|expression>], not `--path`",
250            "no subcommand given",
251        ] {
252            let full = format!("{message}\nusage: sva-cli render [...]\nquery options: [...]");
253            let [d] = &CliError::Usage(full).diagnostics()[..] else {
254                panic!("a usage refusal carries exactly one diagnostic");
255            };
256            assert_eq!(d.code, "cli.invalid_argument");
257            assert_eq!(d.message, message);
258            assert!(d.help.is_some(), "and says where to look");
259        }
260    }
261
262    /// Both of `lint`'s response paths name a code the same way, advised or refused.
263    #[test]
264    fn a_lint_code_reaches_a_diagnostic_the_same_way_from_either_response_path() {
265        let refused = CliError::LintRefused(vec![LintViolation {
266            code: LintCode::MissingComment,
267            severity: Severity::Error,
268            subject: "kick".to_string(),
269            message: "no `;`-comment".to_string(),
270            line: Some(4),
271        }]);
272        let [from_refusal] = &refused.diagnostics()[..] else {
273            panic!("one violation, one diagnostic");
274        };
275        let advised = lint_diagnostic(
276            LintCode::MissingComment,
277            "kick",
278            "no `;`-comment",
279            Severity::Advice,
280            Some(4),
281        );
282        assert_eq!(from_refusal.code, advised.code);
283        assert_eq!(from_refusal.code, "lint.missing_comment");
284        assert_eq!(from_refusal.severity, Severity::Error);
285        assert_eq!(advised.severity, Severity::Advice);
286        assert_eq!(from_refusal.help, advised.help);
287        assert_eq!(from_refusal.line, advised.line);
288        assert!(advised.help.is_some(), "a lint code says how to fix it");
289    }
290}