LCOV - code coverage report
Current view: top level - dashql-cli/src - format_files.rs Coverage Total Hit
Test: DashQL d805a4b Lines: 93.0 % 199 185
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : //! `dashql format`: pretty prints SQL files on disk with the native dashql-core
       2              : 
       3              : use crate::inputs;
       4              : use dashql_buffers::formatting::{FormattingConfigT, FormattingDialect, FormattingMode, KeywordCase};
       5              : use dashql_core::formatter::Formatter;
       6              : use dashql_parser::parser::parse;
       7              : use dashql_parser::scanner::scan;
       8              : use std::io::{Read, Write};
       9              : use std::path::PathBuf;
      10              : use std::process::ExitCode;
      11              : use std::sync::Arc;
      12              : 
      13              : const USAGE: &str = "Usage:
      14              :   dashql format [options] <file.sql|dir|->...
      15              :       Pretty prints the scripts with dashql-core, to stdout by default.
      16              :       --write                  Rewrite the files in place
      17              :       --check                  Print the files that are not formatted, change nothing
      18              :       --dialect <name>         The dialect of the scripts: hyper (default) or trino
      19              : 
      20              : Directories are searched recursively for *.sql files, - reads a script from stdin.
      21              : A script with errors, or with a statement the formatter doesn't know, is reported and stays as it is.
      22              : Exits with 0 if all scripts could be formatted (with --check: are formatted), 1 otherwise and 2 on usage or I/O errors.";
      23              : 
      24              : /// The line width of the formatted scripts
      25              : const MAX_WIDTH: u32 = 120;
      26              : /// The indentation of the formatted scripts, the one of the app
      27              : const INDENTATION_WIDTH: u32 = 4;
      28              : 
      29              : /// What happens to the formatted scripts
      30              : #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
      31              : enum Output {
      32              :     #[default]
      33              :     Print,
      34              :     Write,
      35              :     Check,
      36              : }
      37              : 
      38              : /// The arguments of `format`
      39              : #[derive(Debug, PartialEq, Eq)]
      40              : struct Args {
      41              :     /// The files, directories or `-` to format
      42              :     paths: Vec<String>,
      43              :     /// What happens to the formatted scripts
      44              :     output: Output,
      45              :     /// The dialect of the scripts
      46              :     dialect: FormattingDialect,
      47              :     /// Print the usage with `-h` or `--help`
      48              :     help: bool,
      49              : }
      50              : 
      51            3 : fn parse_dialect(name: &str) -> Result<FormattingDialect, String> {
      52            3 :     match name {
      53            3 :         "hyper" => Ok(FormattingDialect::HYPER),
      54            3 :         "trino" => Ok(FormattingDialect::TRINO),
      55            1 :         _ => Err(format!("unknown dialect {name}")),
      56              :     }
      57            3 : }
      58              : 
      59           18 : fn parse_args(args: &[String]) -> Result<Args, String> {
      60           18 :     let mut out = Args { paths: Vec::new(), output: Output::Print, dialect: FormattingDialect::HYPER, help: false };
      61           18 :     let mut args = args.iter();
      62           18 :     let mut output = None;
      63           41 :     while let Some(arg) = args.next() {
      64           27 :         let selected = match arg.as_str() {
      65           27 :             "--write" => Output::Write,
      66           22 :             "--check" => Output::Check,
      67           19 :             "-h" | "--help" => {
      68            1 :                 out.help = true;
      69            1 :                 continue;
      70              :             }
      71           18 :             "--dialect" => {
      72            3 :                 out.dialect = parse_dialect(args.next().ok_or("option --dialect needs a name")?)?;
      73            1 :                 continue;
      74              :             }
      75           15 :             a if a.starts_with("--dialect=") => {
      76            1 :                 out.dialect = parse_dialect(&a["--dialect=".len()..])?;
      77            1 :                 continue;
      78              :             }
      79           14 :             "--" => {
      80            0 :                 out.paths.extend(args.by_ref().cloned());
      81            0 :                 continue;
      82              :             }
      83           14 :             a if a.starts_with('-') && a != inputs::STDIN => return Err(format!("unknown option {a}")),
      84           13 :             a => {
      85           13 :                 out.paths.push(a.to_string());
      86           13 :                 continue;
      87              :             }
      88              :         };
      89            8 :         if output.is_some_and(|o| o != selected) {
      90            1 :             return Err("options --write and --check exclude each other".into());
      91            7 :         }
      92            7 :         output = Some(selected);
      93              :     }
      94           14 :     out.output = output.unwrap_or_default();
      95           14 :     Ok(out)
      96           18 : }
      97              : 
      98              : /// Format a script, with a line break at its end. A script with errors isn't formatted, and neither is one that the
      99              : /// formatter can't print completely or that doesn't parse afterwards: the output would replace the script.
     100           13 : fn format_script(text: &str, dialect: FormattingDialect) -> Result<String, String> {
     101           13 :     let parsed = parse(Arc::new(scan(text, 0, 0)));
     102           13 :     let error = parsed.scanned.errors.first().map(|e| &e.message).or(parsed.errors.first().map(|e| &e.message));
     103           13 :     if let Some(message) = error {
     104            1 :         return Err(message.clone());
     105           12 :     }
     106           12 :     let mut config = FormattingConfigT::default();
     107           12 :     config.dialect = dialect;
     108           12 :     config.mode = FormattingMode::PRETTY;
     109           12 :     config.max_width = MAX_WIDTH;
     110           12 :     config.indentation_width = INDENTATION_WIDTH;
     111           12 :     config.keyword_case = KeywordCase::LOWER;
     112           12 :     let mut formatter = Formatter::new(&parsed);
     113           12 :     let mut formatted = formatter.format(&config);
     114           12 :     if !formatter.is_fully_formatted() {
     115            1 :         return Err("the formatter doesn't know all of the script".into());
     116           11 :     }
     117           11 :     let reparsed = parse(Arc::new(scan(&formatted, 0, 0)));
     118           11 :     if !reparsed.errors.is_empty() || !reparsed.scanned.errors.is_empty() {
     119            0 :         return Err("the formatted script doesn't parse".into());
     120           11 :     }
     121           11 :     formatted.truncate(formatted.trim_end().len());
     122           11 :     formatted.push('\n');
     123           11 :     Ok(formatted)
     124           13 : }
     125              : 
     126              : /// What `format` takes from the process
     127              : struct Process<'a> {
     128              :     /// The invoking directory, see `inputs::working_directory`
     129              :     cwd: std::io::Result<PathBuf>,
     130              :     /// Read for the path `-`
     131              :     stdin: &'a mut dyn Read,
     132              :     /// The scripts, the files that changed and the summary
     133              :     stdout: &'a mut dyn Write,
     134              :     /// The usage and the errors of the run
     135              :     stderr: &'a mut dyn Write,
     136              : }
     137              : 
     138           11 : fn format_files(args: Args, process: &mut Process) -> Result<u8, String> {
     139           11 :     let cwd = process.cwd.as_ref().map_err(|e| format!("working directory: {e}"))?;
     140           11 :     let inputs = inputs::read_inputs(&args.paths, cwd, process.stdin)?;
     141           10 :     let stdout = &mut *process.stdout;
     142           13 :     let mut write = |text: &str| write!(stdout, "{text}").map_err(|e| format!("stdout: {e}"));
     143           10 :     let (mut failed, mut changed) = (0, 0);
     144           13 :     for input in &inputs {
     145           13 :         let formatted = match format_script(&input.text, args.dialect) {
     146           11 :             Ok(formatted) => formatted,
     147            2 :             Err(e) => {
     148            2 :                 failed += 1;
     149            2 :                 writeln!(process.stderr, "{}: {e}", input.id).map_err(|e| format!("stderr: {e}"))?;
     150            2 :                 continue;
     151              :             }
     152              :         };
     153            3 :         match args.output {
     154            5 :             Output::Print => write(&formatted)?,
     155            6 :             _ if formatted == input.text => {}
     156              :             Output::Write => {
     157              :                 // Stdin has no file to write to
     158            2 :                 if input.id != "<stdin>" {
     159            2 :                     let path = cwd.join(&input.id);
     160            2 :                     std::fs::write(&path, &formatted).map_err(|e| format!("{}: {e}", path.display()))?;
     161            0 :                 }
     162            2 :                 changed += 1;
     163            2 :                 write(&format!("{}\n", input.id))?;
     164              :             }
     165              :             Output::Check => {
     166            1 :                 changed += 1;
     167            1 :                 write(&format!("{}\n", input.id))?;
     168              :             }
     169              :         }
     170              :     }
     171           10 :     let files = if inputs.len() == 1 { "file" } else { "files" };
     172           10 :     match args.output {
     173            5 :         Output::Print => {}
     174            3 :         Output::Write => write(&format!("{} {files}, {changed} rewritten, {failed} failed\n", inputs.len()))?,
     175            2 :         Output::Check => write(&format!("{} {files}, {changed} not formatted, {failed} failed\n", inputs.len()))?,
     176              :     }
     177           10 :     let unformatted = args.output == Output::Check && changed > 0;
     178           10 :     Ok(if failed > 0 || unformatted { 1 } else { 0 })
     179           11 : }
     180              : 
     181              : /// Run `format` and return the exit code
     182           18 : fn run(args: &[String], process: &mut Process) -> u8 {
     183              :     // A closed stderr or stdout leaves nothing to report the failure on
     184           18 :     let args = match parse_args(args) {
     185            1 :         Ok(args) if args.help => {
     186            1 :             let _ = writeln!(process.stdout, "{USAGE}");
     187            1 :             return 0;
     188              :         }
     189           13 :         Ok(args) if args.paths.is_empty() => {
     190            1 :             let _ = writeln!(process.stderr, "{USAGE}");
     191            1 :             return 2;
     192              :         }
     193           12 :         Ok(args) if args.output == Output::Write && args.paths.iter().any(|p| p == inputs::STDIN) => {
     194            1 :             let _ = writeln!(process.stderr, "error: --write needs files, not stdin\n\n{USAGE}");
     195            1 :             return 2;
     196              :         }
     197           11 :         Ok(args) => args,
     198            4 :         Err(e) => {
     199            4 :             let _ = writeln!(process.stderr, "error: {e}\n\n{USAGE}");
     200            4 :             return 2;
     201              :         }
     202              :     };
     203           11 :     format_files(args, process).unwrap_or_else(|e| {
     204            1 :         let _ = writeln!(process.stderr, "error: {e}");
     205            1 :         2
     206            1 :     })
     207           18 : }
     208              : 
     209              : /// Run `dashql format` with the arguments after `format`
     210            0 : pub fn main(args: &[String]) -> ExitCode {
     211            0 :     ExitCode::from(run(
     212            0 :         args,
     213            0 :         &mut Process {
     214            0 :             cwd: inputs::working_directory(),
     215            0 :             stdin: &mut std::io::stdin(),
     216            0 :             stdout: &mut std::io::stdout().lock(),
     217            0 :             stderr: &mut std::io::stderr().lock(),
     218            0 :         },
     219              :     ))
     220            0 : }
     221              : 
     222              : #[cfg(test)]
     223              : mod tests {
     224              :     use super::*;
     225              :     use crate::agent::config::temp_dir;
     226              :     use std::path::Path;
     227              : 
     228              :     /// The result of a run
     229              :     struct Result {
     230              :         code: u8,
     231              :         stdout: String,
     232              :         stderr: String,
     233              :     }
     234              : 
     235              :     /// A directory with the given files
     236            6 :     fn files(files: &[(&str, &str)]) -> PathBuf {
     237            6 :         let root = temp_dir("dashql-cli-format");
     238            8 :         for (name, text) in files {
     239            8 :             std::fs::write(root.join(name), text).unwrap();
     240            8 :         }
     241            6 :         root
     242            6 :     }
     243              : 
     244           18 :     fn run_with(cwd: &Path, args: &[&str], stdin: &str) -> Result {
     245           18 :         let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
     246           32 :         let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
     247           18 :         let code = run(
     248           18 :             &args,
     249           18 :             &mut Process {
     250           18 :                 cwd: Ok(cwd.to_path_buf()),
     251           18 :                 stdin: &mut stdin.as_bytes(),
     252           18 :                 stdout: &mut stdout,
     253           18 :                 stderr: &mut stderr,
     254           18 :             },
     255              :         );
     256           18 :         Result { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() }
     257           18 :     }
     258              : 
     259              :     #[test]
     260            1 :     fn prints_a_script_and_leaves_the_file() {
     261            1 :         let root = files(&[("a.sql", "SELECT  1")]);
     262            1 :         let output = run_with(&root, &["a.sql"], "");
     263            1 :         assert_eq!((output.code, output.stdout.as_str(), output.stderr.as_str()), (0, "select 1;\n", ""));
     264            1 :         assert_eq!(std::fs::read_to_string(root.join("a.sql")).unwrap(), "SELECT  1");
     265            1 :     }
     266              : 
     267              :     #[test]
     268            1 :     fn formats_a_script_from_stdin() {
     269            1 :         let output = run_with(Path::new("/"), &["-"], "CALL t.f(a=>1)");
     270            1 :         assert_eq!((output.code, output.stdout.as_str()), (0, "call t.f (a => 1);\n"));
     271            1 :     }
     272              : 
     273              :     #[test]
     274            1 :     fn formats_in_the_dialect_of_the_scripts() {
     275            1 :         let script = "select cast(a as array(integer));\n";
     276            1 :         let hyper = run_with(Path::new("/"), &["-"], script);
     277            1 :         assert_eq!((hyper.code, hyper.stdout.as_str()), (0, "select cast(a as integer[]);\n"));
     278            1 :         let trino = run_with(Path::new("/"), &["--dialect", "trino", "-"], script);
     279            1 :         assert_eq!((trino.code, trino.stdout.as_str()), (0, "select cast(a as array(integer));\n"));
     280            1 :         assert_eq!(run_with(Path::new("/"), &["--dialect=trino", "-"], script).stdout, trino.stdout);
     281            1 :     }
     282              : 
     283              :     #[test]
     284            1 :     fn writes_only_the_files_that_change() {
     285            1 :         let root = files(&[("a.sql", "SELECT  1"), ("b.sql", "select 1;\n")]);
     286            1 :         let output = run_with(&root, &["--write", "."], "");
     287            1 :         assert_eq!((output.code, output.stdout.as_str()), (0, "a.sql\n2 files, 1 rewritten, 0 failed\n"));
     288            1 :         assert_eq!(std::fs::read_to_string(root.join("a.sql")).unwrap(), "select 1;\n");
     289            1 :     }
     290              : 
     291              :     #[test]
     292            1 :     fn checks_without_writing() {
     293            1 :         let root = files(&[("a.sql", "SELECT  1"), ("b.sql", "select 1;\n")]);
     294            1 :         let output = run_with(&root, &["--check", "."], "");
     295            1 :         assert_eq!((output.code, output.stdout.as_str()), (1, "a.sql\n2 files, 1 not formatted, 0 failed\n"));
     296            1 :         assert_eq!(std::fs::read_to_string(root.join("a.sql")).unwrap(), "SELECT  1");
     297            1 :         assert_eq!(run_with(&root, &["--check", "b.sql"], "").code, 0);
     298            1 :     }
     299              : 
     300              :     #[test]
     301            1 :     fn keeps_a_script_with_errors() {
     302            1 :         let root = files(&[("a.sql", "select 1 +;\n"), ("b.sql", "SELECT  1")]);
     303            1 :         let output = run_with(&root, &["--write", "."], "");
     304            1 :         assert_eq!(output.code, 1);
     305            1 :         assert!(output.stderr.starts_with("a.sql: syntax error"), "{}", output.stderr);
     306            1 :         assert_eq!(std::fs::read_to_string(root.join("a.sql")).unwrap(), "select 1 +;\n");
     307              :         // The other scripts are still formatted
     308            1 :         assert_eq!(std::fs::read_to_string(root.join("b.sql")).unwrap(), "select 1;\n");
     309            1 :     }
     310              : 
     311              :     #[test]
     312            1 :     fn keeps_a_script_the_formatter_cannot_print() {
     313            1 :         let root = files(&[("a.sql", "select somefunc(default);\n")]);
     314            1 :         let output = run_with(&root, &["--write", "a.sql"], "");
     315            1 :         assert_eq!(output.code, 1);
     316            1 :         assert_eq!(output.stderr, "a.sql: the formatter doesn't know all of the script\n");
     317            1 :         assert_eq!(std::fs::read_to_string(root.join("a.sql")).unwrap(), "select somefunc(default);\n");
     318            1 :     }
     319              : 
     320              :     #[test]
     321            1 :     fn rejects_bad_command_lines() {
     322            1 :         let root = files(&[]);
     323            6 :         for args in [&[][..], &["--bogus", "a.sql"][..], &["--write", "--check", "a.sql"][..], &["--write", "-"][..], &["--dialect", "x", "a.sql"][..], &["a.sql", "--dialect"][..]] {
     324            6 :             let output = run_with(&root, args, "");
     325            6 :             assert_eq!(output.code, 2, "{args:?}");
     326            6 :             assert!(output.stderr.ends_with(&format!("{USAGE}\n")), "{args:?}: {}", output.stderr);
     327              :         }
     328            1 :         assert_eq!(run_with(&root, &["gone.sql"], "").code, 2);
     329            1 :         assert_eq!(run_with(&root, &["--help"], "").stdout, format!("{USAGE}\n"));
     330            1 :     }
     331              : }
        

Generated by: LCOV version 2.0-1