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