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 Hinted {
45 error: Box<CliError>,
46 help: &'static str,
47 },
48 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
93fn 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 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
219pub 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
227fn 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
242fn 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
255pub 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 #[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 #[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}