LCOV - code coverage report
Current view: top level - dashql-cli/src - lint_files.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 94.2 % 240 226
Test Date: 2026-11-06 17:46:50

            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              : }
        

Generated by: LCOV version 2.0-1