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
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 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 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 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
151fn 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
162fn 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
177pub 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 #[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 #[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 #[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}