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