Line data Source code
1 : //! `dashql lint`: lints SQL files on disk with the native dashql-core
2 :
3 : use crate::inputs;
4 : use crate::lint::{Linter, Severity};
5 : use crate::report::{format_file, format_summary, format_timings, Style};
6 : use std::io::{Read, Write};
7 : use std::path::PathBuf;
8 : use std::process::ExitCode;
9 : use std::time::Instant;
10 :
11 : const USAGE: &str = "Usage:
12 : dashql lint [options] <file.sql|dir|->...
13 : Scans, parses and analyzes the scripts with dashql-core and prints the scripts with diagnostics.
14 : --schema <file.sql> Schema script (CREATE TABLE ...) to resolve names against, can be repeated
15 : --debug Show where the time goes
16 :
17 : Directories are searched recursively for *.sql files, - reads a script from stdin.
18 : Exits with 0 without errors (warnings are fine), 1 with errors and 2 on usage or I/O errors.";
19 :
20 : /// The arguments of `lint`
21 : #[derive(Debug, Default, PartialEq, Eq)]
22 : struct Args {
23 : /// The schema scripts passed with `--schema`
24 : schemas: Vec<String>,
25 : /// The files, directories or `-` to lint
26 : paths: Vec<String>,
27 : /// Print timings with `--debug`
28 : debug: bool,
29 : /// Print the usage with `-h` or `--help`
30 : help: bool,
31 : }
32 :
33 27 : fn parse_args(args: &[String]) -> Result<Args, String> {
34 27 : let mut out = Args::default();
35 27 : let mut args = args.iter();
36 64 : while let Some(arg) = args.next() {
37 40 : match arg.as_str() {
38 40 : "--schema" => out.schemas.push(args.next().ok_or("option --schema needs a file")?.clone()),
39 34 : "--debug" => out.debug = true,
40 31 : "-h" | "--help" => out.help = true,
41 27 : "--" => out.paths.extend(args.by_ref().cloned()),
42 26 : a if a.starts_with("--schema=") => out.schemas.push(a["--schema=".len()..].to_string()),
43 24 : a if a.starts_with('-') && a != inputs::STDIN => return Err(format!("unknown option {a}")),
44 22 : a => out.paths.push(a.to_string()),
45 : }
46 : }
47 24 : Ok(out)
48 27 : }
49 :
50 : /// What `lint` takes from the process
51 : struct Process<'a> {
52 : /// The invoking directory, see `inputs::working_directory`
53 : cwd: std::io::Result<PathBuf>,
54 : /// Read for the path `-`
55 : stdin: &'a mut dyn Read,
56 : /// The diagnostics and the summary
57 : stdout: &'a mut dyn Write,
58 : /// The usage and the errors of the run
59 : stderr: &'a mut dyn Write,
60 : /// The style of the diagnostics
61 : style: Style,
62 : }
63 :
64 17 : fn lint(args: Args, process: &mut Process) -> Result<u8, String> {
65 17 : let style = process.style;
66 17 : let cwd = process.cwd.as_ref().map_err(|e| format!("working directory: {e}"))?;
67 16 : let schemas = inputs::read_inputs(&args.schemas, cwd, process.stdin)?;
68 15 : let inputs = inputs::read_inputs(&args.paths, cwd, process.stdin)?;
69 13 : let stdout = &mut *process.stdout;
70 22 : let mut write = |text: String| writeln!(stdout, "{text}").map_err(|e| format!("stdout: {e}"));
71 :
72 13 : let start = Instant::now();
73 13 : let mut linter = Linter::new();
74 : // Schemas come first, so that the scripts find their tables
75 13 : let schema_diagnostics = schemas.iter().map(|schema| linter.add_schema(schema)).collect::<Vec<_>>();
76 14 : let diagnostics = schema_diagnostics.into_iter().chain(inputs.iter().map(|input| linter.lint(input)));
77 13 : let (mut errors, mut warnings) = (0, 0);
78 18 : for (input, diagnostics) in schemas.iter().chain(&inputs).zip(diagnostics.collect::<Vec<_>>()) {
79 18 : if diagnostics.is_empty() {
80 10 : continue;
81 8 : }
82 8 : for d in &diagnostics {
83 8 : match d.severity {
84 7 : Severity::Error => errors += 1,
85 1 : Severity::Warning => warnings += 1,
86 : }
87 : }
88 8 : write(format!("{}\n", format_file(&input.id, &input.text, &diagnostics, style)))?;
89 : }
90 13 : if args.debug {
91 1 : write(format!("{}\n", format_timings(start.elapsed(), style)))?;
92 12 : }
93 13 : write(format_summary(schemas.len() + inputs.len(), errors, warnings, style))?;
94 : // Warnings alone do not fail
95 12 : Ok(if errors > 0 { 1 } else { 0 })
96 17 : }
97 :
98 : /// Run `lint` and return the exit code
99 21 : fn run(args: &[String], process: &mut Process) -> u8 {
100 : // A closed stderr or stdout leaves nothing to report the failure on
101 21 : let args = match parse_args(args) {
102 2 : Ok(args) if args.help => {
103 2 : let _ = writeln!(process.stdout, "{USAGE}");
104 2 : return 0;
105 : }
106 18 : Ok(args) if args.paths.is_empty() => {
107 1 : let _ = writeln!(process.stderr, "{USAGE}");
108 1 : return 2;
109 : }
110 17 : Ok(args) => args,
111 1 : Err(e) => {
112 1 : let _ = writeln!(process.stderr, "error: {e}\n\n{USAGE}");
113 1 : return 2;
114 : }
115 : };
116 17 : lint(args, process).unwrap_or_else(|e| {
117 5 : let _ = writeln!(process.stderr, "error: {e}");
118 5 : 2
119 5 : })
120 21 : }
121 :
122 : /// Run `dashql lint` with the arguments after `lint`
123 0 : pub fn main(args: &[String]) -> ExitCode {
124 0 : ExitCode::from(run(
125 0 : args,
126 0 : &mut Process {
127 0 : cwd: inputs::working_directory(),
128 0 : stdin: &mut std::io::stdin(),
129 0 : stdout: &mut std::io::stdout().lock(),
130 0 : stderr: &mut std::io::stderr().lock(),
131 0 : style: Style::for_stdout(),
132 0 : },
133 : ))
134 0 : }
135 :
136 : #[cfg(test)]
137 : mod tests {
138 : use super::*;
139 : use crate::agent::config::temp_dir;
140 : use std::path::Path;
141 :
142 6 : fn parse(args: &[&str]) -> Result<Args, String> {
143 13 : parse_args(&args.iter().map(|a| a.to_string()).collect::<Vec<_>>())
144 6 : }
145 :
146 : #[test]
147 1 : fn parses_options_and_paths() {
148 1 : assert_eq!(
149 1 : parse(&["--schema", "s.sql", "a.sql", "--schema=t.sql", "-", "--debug", "--", "--odd.sql"]),
150 1 : Ok(Args {
151 1 : schemas: vec!["s.sql".into(), "t.sql".into()],
152 1 : paths: vec!["a.sql".into(), "-".into(), "--odd.sql".into()],
153 1 : debug: true,
154 1 : help: false,
155 1 : })
156 : );
157 1 : assert_eq!(parse(&["--schema"]), Err("option --schema needs a file".into()));
158 1 : assert_eq!(parse(&["--bogus"]), Err("unknown option --bogus".into()));
159 1 : }
160 :
161 : #[test]
162 1 : fn asks_for_help_with_both_spellings() {
163 1 : assert!(parse(&["-h"]).unwrap().help);
164 1 : assert!(parse(&["--help"]).unwrap().help);
165 1 : assert!(!parse(&["a.sql"]).unwrap().help);
166 1 : }
167 :
168 : const SCHEMA: &str = "create table orders (id integer);\n";
169 : const QUERY: &str = "select id from orders, items;\n";
170 :
171 : /// The result of a run
172 : struct Output {
173 : code: u8,
174 : stdout: String,
175 : stderr: String,
176 : }
177 :
178 : /// A directory with the given files
179 12 : fn files(files: &[(&str, &str)]) -> PathBuf {
180 12 : let root = temp_dir("dashql-cli-lint");
181 17 : for (name, text) in files {
182 17 : let path = root.join(name);
183 17 : std::fs::create_dir_all(path.parent().unwrap()).unwrap();
184 17 : std::fs::write(path, text).unwrap();
185 17 : }
186 12 : root
187 12 : }
188 :
189 20 : fn run_with(cwd: std::io::Result<PathBuf>, args: &[&str], stdin: &str) -> Output {
190 20 : let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
191 33 : let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
192 20 : let code = run(
193 20 : &args,
194 20 : &mut Process {
195 20 : cwd,
196 20 : stdin: &mut stdin.as_bytes(),
197 20 : stdout: &mut stdout,
198 20 : stderr: &mut stderr,
199 20 : style: Style { colors: false },
200 20 : },
201 : );
202 20 : Output { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() }
203 20 : }
204 :
205 17 : fn run_in(cwd: &Path, args: &[&str]) -> Output {
206 17 : run_with(Ok(cwd.to_path_buf()), args, "")
207 17 : }
208 :
209 : #[test]
210 1 : fn passes_a_clean_script_and_prints_only_the_summary() {
211 1 : let root = files(&[("a.sql", "select 1;\n")]);
212 1 : let output = run_in(&root, &["a.sql"]);
213 1 : assert_eq!(output.code, 0);
214 1 : assert_eq!(output.stdout, "1 file, 0 errors, 0 warnings\n");
215 1 : assert_eq!(output.stderr, "");
216 1 : }
217 :
218 : #[test]
219 1 : fn fails_with_exit_code_1_and_shows_the_error_in_its_line() {
220 1 : let root = files(&[("a.sql", "select 1;\nselect 1 +;\n")]);
221 1 : let output = run_in(&root, &["a.sql"]);
222 1 : assert_eq!(output.code, 1);
223 1 : assert_eq!(
224 : output.stdout,
225 : "a.sql\n 2:11 error parse syntax error, unexpected SEMICOLON\n │ select 1 +;\n │ ^\n\n1 file, 1 error, 0 warnings\n"
226 : );
227 1 : assert_eq!(output.stderr, "");
228 1 : }
229 :
230 : #[test]
231 1 : fn passes_with_warnings_alone_and_shows_them() {
232 1 : let root = files(&[("a.sql", "/* @vis vegalite */\nselect 1 as value;\n")]);
233 1 : let output = run_in(&root, &["a.sql"]);
234 1 : assert_eq!(output.code, 0, "{}", output.stdout);
235 1 : assert!(output.stdout.starts_with("a.sql\n"), "{}", output.stdout);
236 1 : assert!(output.stdout.contains("warning analyze @vis vegalite needs a Vega-Lite spec"), "{}", output.stdout);
237 1 : assert!(output.stdout.ends_with("1 file, 0 errors, 1 warning\n"), "{}", output.stdout);
238 1 : }
239 :
240 : #[test]
241 1 : fn prints_only_the_scripts_with_diagnostics_and_counts_all_of_them() {
242 1 : let root = files(&[("bad1.sql", "select 1 +;\n"), ("good.sql", "select 1;\n"), ("bad2.sql", "select 2 +;\n")]);
243 1 : let output = run_in(&root, &["."]);
244 1 : assert_eq!(output.code, 1);
245 1 : assert!(output.stdout.contains("bad1.sql\n"), "{}", output.stdout);
246 1 : assert!(output.stdout.contains("bad2.sql\n"), "{}", output.stdout);
247 1 : assert!(!output.stdout.contains("good.sql"), "{}", output.stdout);
248 1 : assert!(output.stdout.ends_with("3 files, 2 errors, 0 warnings\n"), "{}", output.stdout);
249 1 : }
250 :
251 : #[test]
252 1 : fn passes_a_directory_without_scripts() {
253 1 : let root = files(&[("notes.txt", "")]);
254 1 : let output = run_in(&root, &["."]);
255 1 : assert_eq!(output.code, 0);
256 1 : assert_eq!(output.stdout, "0 files, 0 errors, 0 warnings\n");
257 1 : }
258 :
259 : #[test]
260 1 : fn reads_a_script_from_stdin() {
261 1 : let root = files(&[]);
262 1 : let output = run_with(Ok(root.clone()), &["-"], "select 1 +;\n");
263 1 : assert_eq!(output.code, 1);
264 1 : assert!(output.stdout.starts_with("<stdin>\n"), "{}", output.stdout);
265 1 : assert!(output.stdout.contains("select 1 +;"), "{}", output.stdout);
266 1 : }
267 :
268 : #[test]
269 1 : fn resolves_names_only_against_the_schemas() {
270 1 : let schema_of_both = "create table orders (id integer);\ncreate table items (id integer);\n";
271 1 : let root = files(&[("schema.sql", schema_of_both), ("q.sql", QUERY)]);
272 : // id is ambiguous once both tables are known
273 1 : let output = run_in(&root, &["--schema", "schema.sql", "q.sql"]);
274 1 : assert_eq!(output.code, 1, "{}", output.stdout);
275 1 : assert!(output.stdout.starts_with("q.sql\n"), "{}", output.stdout);
276 1 : assert!(output.stdout.ends_with("2 files, 1 error, 0 warnings\n"), "{}", output.stdout);
277 : // Without them the query has no ambiguous column
278 1 : assert_eq!(run_in(&root, &["q.sql"]).stdout, "1 file, 0 errors, 0 warnings\n");
279 1 : }
280 :
281 : #[test]
282 1 : fn loads_the_schemas_before_the_scripts_wherever_they_are_named() {
283 1 : let root = files(&[("orders.sql", SCHEMA), ("items.sql", "create table items (id integer);\n"), ("q.sql", QUERY)]);
284 1 : let output = run_in(&root, &["q.sql", "--schema=orders.sql", "--schema", "items.sql"]);
285 1 : assert_eq!(output.code, 1, "{}", output.stdout);
286 1 : assert!(output.stdout.ends_with("3 files, 1 error, 0 warnings\n"), "{}", output.stdout);
287 1 : }
288 :
289 : #[test]
290 1 : fn reports_the_errors_of_a_schema_and_does_not_load_it() {
291 1 : let root = files(&[("schema.sql", "create table orders (id integer"), ("q.sql", QUERY)]);
292 1 : let output = run_in(&root, &["--schema", "schema.sql", "q.sql"]);
293 1 : assert_eq!(output.code, 1, "{}", output.stdout);
294 1 : assert!(output.stdout.starts_with("schema.sql\n"), "{}", output.stdout);
295 1 : assert!(!output.stdout.contains("q.sql"), "{}", output.stdout);
296 1 : }
297 :
298 : #[test]
299 1 : fn prints_timings_with_debug_only() {
300 1 : let root = files(&[("a.sql", "select 1;\n")]);
301 1 : let timings = "dashql-core scan, parse and analyze ";
302 1 : assert!(run_in(&root, &["--debug", "a.sql"]).stdout.starts_with(timings));
303 1 : assert!(!run_in(&root, &["a.sql"]).stdout.contains(timings));
304 1 : }
305 :
306 : #[test]
307 1 : fn prints_the_usage_to_stdout_when_asked() {
308 1 : let output = run_in(Path::new("/"), &["--help", "ignored.sql"]);
309 1 : assert_eq!(output.code, 0);
310 1 : assert_eq!(output.stdout, format!("{USAGE}\n"));
311 1 : assert_eq!(output.stderr, "");
312 1 : }
313 :
314 : #[test]
315 1 : fn prints_the_usage_to_stderr_without_paths() {
316 1 : let output = run_in(Path::new("/"), &["--debug"]);
317 1 : assert_eq!(output.code, 2);
318 1 : assert_eq!(output.stdout, "");
319 1 : assert_eq!(output.stderr, format!("{USAGE}\n"));
320 1 : }
321 :
322 : #[test]
323 1 : fn prints_the_usage_after_an_unknown_option() {
324 1 : let output = run_in(Path::new("/"), &["--bogus", "a.sql"]);
325 1 : assert_eq!(output.code, 2);
326 1 : assert_eq!(output.stdout, "");
327 1 : assert_eq!(output.stderr, format!("error: unknown option --bogus\n\n{USAGE}\n"));
328 1 : }
329 :
330 : #[test]
331 1 : fn fails_with_exit_code_2_on_a_missing_script_or_schema() {
332 1 : let root = files(&[("a.sql", "select 1;\n")]);
333 3 : for args in [&["missing.sql"][..], &["a.sql", "missing.sql"][..], &["--schema", "missing.sql", "a.sql"][..]] {
334 3 : let output = run_in(&root, args);
335 3 : assert_eq!(output.code, 2, "{args:?}");
336 : // Nothing was linted, there is no summary that could pass for a clean run
337 3 : assert_eq!(output.stdout, "", "{args:?}");
338 3 : assert!(output.stderr.starts_with(&format!("error: {}: ", root.join("missing.sql").display())), "{}", output.stderr);
339 : }
340 1 : }
341 :
342 : #[test]
343 1 : fn fails_with_exit_code_2_without_a_working_directory() {
344 1 : let output = run_with(Err(std::io::Error::other("gone")), &["a.sql"], "");
345 1 : assert_eq!(output.code, 2);
346 1 : assert_eq!(output.stderr, "error: working directory: gone\n");
347 : // Asking for the usage does not need one
348 1 : assert_eq!(run_with(Err(std::io::Error::other("gone")), &["--help"], "").code, 0);
349 1 : }
350 :
351 : #[test]
352 1 : fn fails_with_exit_code_2_when_stdout_is_closed() {
353 : struct Closed;
354 : impl Write for Closed {
355 1 : fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
356 1 : Err(std::io::Error::other("closed"))
357 1 : }
358 0 : fn flush(&mut self) -> std::io::Result<()> {
359 0 : Ok(())
360 0 : }
361 : }
362 1 : let root = files(&[("a.sql", "select 1;\n")]);
363 1 : let mut stderr = Vec::new();
364 1 : let code = run(
365 1 : &["a.sql".to_string()],
366 1 : &mut Process {
367 1 : cwd: Ok(root),
368 1 : stdin: &mut &b""[..],
369 1 : stdout: &mut Closed,
370 1 : stderr: &mut stderr,
371 1 : style: Style { colors: false },
372 1 : },
373 : );
374 1 : assert_eq!(code, 2);
375 1 : assert_eq!(String::from_utf8(stderr).unwrap(), "error: stdout: closed\n");
376 1 : }
377 : }
|