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