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 {
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 NotFound(String),
36 Conflict {
38 by: &'static str,
39 message: String,
40 },
41 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 misses_a_recording(rs: &[Refusal]) -> bool {
81 rs.iter()
82 .any(|r| r.code == sva_ast::DiagCode::MissingRecording)
83}
84
85fn names_no_node(e: &EngineError) -> bool {
86 matches!(
87 e,
88 EngineError::UnknownNode(_) | EngineError::UnknownInstance(..)
89 )
90}
91
92impl CliError {
93 pub fn exit_code(&self) -> u8 {
94 match self {
95 CliError::Engine(e) if names_no_node(e) => 24,
96 CliError::Refusals(rs) if misses_a_recording(rs) => 24,
97 CliError::Usage { .. }
98 | CliError::Refusals(_)
99 | CliError::Engine(_)
100 | CliError::BadTempo(_)
101 | CliError::BadProbe(_)
102 | CliError::LintRefused(_) => 3,
103 CliError::Conflict { .. } => 4,
104 CliError::NotFound(_) => 24,
105 CliError::Io(_) => 1,
106 }
107 }
108
109 pub fn code(&self) -> &'static str {
110 match self {
111 CliError::Engine(e) if names_no_node(e) => "not_found",
112 CliError::Refusals(rs) if misses_a_recording(rs) => "not_found",
113 CliError::Usage { .. }
114 | CliError::Refusals(_)
115 | CliError::Engine(_)
116 | CliError::BadTempo(_)
117 | CliError::BadProbe(_)
118 | CliError::LintRefused(_) => "validation_error",
119 CliError::Conflict { .. } => "conflict",
120 CliError::NotFound(_) => "not_found",
121 CliError::Io(_) => "internal_error",
122 }
123 }
124
125 pub fn message(&self) -> String {
126 match self {
127 CliError::Usage { message, .. } => message.clone(),
128 CliError::Refusals(rs) => format!("{} refusal(s) parsing the composition", rs.len()),
129 CliError::Engine(e) => e.to_string(),
130 CliError::BadTempo(m) | CliError::BadProbe(m) | CliError::Io(m) => m.clone(),
131 CliError::NotFound(m) => m.clone(),
132 CliError::Conflict { message, .. } => message.clone(),
133 CliError::LintRefused(vs) => format!(
134 "{} lint violation(s)",
135 vs.iter().filter(|v| v.severity == Severity::Error).count()
136 ),
137 }
138 }
139
140 pub fn diagnostics(&self) -> Vec<Diagnostic> {
142 match self {
143 CliError::Refusals(rs) => rs
144 .iter()
145 .map(|r| {
146 Diagnostic::new(r.code.code_str(), r.reason.clone())
147 .at(
148 Some(r.at.path.clone()),
149 Some((r.at.span.start, r.at.span.end - r.at.span.start)),
150 )
151 .placed(r.at.start)
152 })
153 .collect(),
154 CliError::Engine(e) => vec![engine_diagnostic(e)],
155 CliError::BadTempo(m) => vec![
156 Diagnostic::new("tempo.invalid_bpm_meter", m.clone()).helped(
157 "state `variables/bpm` as a positive number and `variables/meter` as \
158 `<beats>/<note>`, e.g. `4/4`",
159 ),
160 ],
161 CliError::BadProbe(m) => vec![Diagnostic::new("probe.invalid_expression", m.clone())
162 .helped("name a node path that exists, or an expression in the same grammar a node file holds")],
163 CliError::Usage { code, message } => vec![Diagnostic::new(*code, message.clone())
164 .helped("`sva-cli --help` states every subcommand, its arguments and their defaults")],
165 CliError::Conflict { by, message } => vec![
166 Diagnostic::new(format!("{by}.already_exists"), message.clone())
167 .helped("pick a name nothing occupies, or remove what is there first"),
168 ],
169 CliError::NotFound(m) => vec![
170 Diagnostic::new("io.no_such_path", m.clone())
171 .helped("name a path that exists; `sva-cli lint` says what a composition holds"),
172 ],
173 CliError::LintRefused(vs) => vs
174 .iter()
175 .map(|v| lint_diagnostic(v.code, &v.subject, &v.message, v.severity, v.line))
176 .collect(),
177 CliError::Io(_) => Vec::new(),
178 }
179 }
180}
181
182pub fn warning_diagnostic(w: &sva_ast::song::Warning) -> Diagnostic {
184 Diagnostic::new(w.code, w.message.clone())
185 .with_severity(Severity::Warning)
186 .at(Some(w.file.clone()), Some((w.span.start, 0)))
187 .placed(Some(w.start))
188}
189
190fn engine_diagnostic(e: &EngineError) -> Diagnostic {
192 let held = match e {
193 EngineError::Refused(d) => {
194 Diagnostic::new(d.code.clone(), d.message.clone()).helped(d.help.clone())
195 }
196 other => Diagnostic::new(other.code(), other.to_string()),
197 };
198 held.at(engine_error_path(e), engine_error_span(e))
199}
200
201pub fn lint_diagnostic(
204 code: LintCode,
205 subject: &str,
206 message: &str,
207 severity: Severity,
208 line: Option<usize>,
209) -> Diagnostic {
210 Diagnostic::new(
211 format!("lint.{}", code.code_str()).replace('-', "_"),
212 message,
213 )
214 .with_severity(severity)
215 .at(Some(subject.to_string()), None)
216 .on_line(line)
217 .helped(code.help())
218}
219
220impl fmt::Display for CliError {
221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222 write!(f, "{}", self.message())
223 }
224}
225
226impl std::error::Error for CliError {}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
234 fn every_variant_answers_the_standards_own_code_and_exit_code() {
235 let cases: [(CliError, &str, u8); 7] = [
236 (CliError::NotFound("gone".to_string()), "not_found", 24),
237 (
238 CliError::Engine(EngineError::UnknownNode("gone".to_string())),
239 "not_found",
240 24,
241 ),
242 (
243 CliError::usage("cli.unknown_flag", "bad flag"),
244 "validation_error",
245 3,
246 ),
247 (
248 CliError::BadTempo("bad bpm".to_string()),
249 "validation_error",
250 3,
251 ),
252 (
253 CliError::Conflict {
254 by: "new",
255 message: "taken".to_string(),
256 },
257 "conflict",
258 4,
259 ),
260 (CliError::Io("disk".to_string()), "internal_error", 1),
261 (CliError::LintRefused(Vec::new()), "validation_error", 3),
262 ];
263 for (err, code, exit) in cases {
264 assert_eq!(err.code(), code, "{err:?}");
265 assert_eq!(err.exit_code(), exit, "{err:?}");
266 }
267 }
268
269 #[test]
271 fn an_argument_mistake_is_one_diagnostic_under_its_own_code() {
272 let refused = CliError::usage("query.unknown_argument", "`lines` takes no arguments");
273 assert_eq!(refused.message(), "`lines` takes no arguments");
274 let [d] = &refused.diagnostics()[..] else {
275 panic!("an argument mistake carries exactly one diagnostic");
276 };
277 assert_eq!(d.code, "query.unknown_argument");
278 assert_eq!(d.message, "`lines` takes no arguments");
279 assert!(d.help.is_some(), "and says where to look");
280 }
281}