Skip to main content

ktrs_cli/ktfmt/
main.rs

1//! Port of ktfmt's `cli/Main.kt` (v0.64): formats files in place (in parallel) or stdin to stdout.
2//! Messages, exit codes and write decisions match ktfmt's (`tools/ktfmt-oracle/cli-diff.sh`).
3//! Deviation: each file's messages are printed in file order, not in completion order.
4
5use std::fs;
6use std::io::{self, Read, Write};
7use std::path::{Path, PathBuf};
8use std::sync::Mutex;
9use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10
11use ktrs_fmt::FormatError;
12use ktrs_syntax::caught_panic::catch_quietly;
13
14use super::editor_config_resolver;
15use super::parsed_args::{ParseResult, ParsedArgs, process_args};
16
17const EXIT_CODE_FAILURE: i32 = 1;
18const EXIT_CODE_SUCCESS: i32 = 0;
19const UTF8_BOM: &str = "\u{feff}";
20/// Java's `println` ends lines with the platform separator.
21const LINE_SEPARATOR: &str = if cfg!(windows) { "\r\n" } else { "\n" };
22
23const USAGE: &str = "\
24Usage:
25  ktfmt [OPTIONS] File1.kt File2.kt ...
26  ktfmt @ARGFILE
27
28For more details see `ktfmt --help`
29";
30
31pub struct Main<R, O, E> {
32    input: Mutex<R>,
33    out: Mutex<O>,
34    err: Mutex<E>,
35}
36
37/// One file's output, buffered so files report in order.
38#[derive(Default)]
39struct Report {
40    out: String,
41    err: String,
42}
43
44impl Report {
45    fn out(&mut self, line: &str) {
46        self.out.push_str(line);
47        self.out.push_str(LINE_SEPARATOR);
48    }
49
50    fn err(&mut self, line: &str) {
51        self.err.push_str(line);
52        self.err.push_str(LINE_SEPARATOR);
53    }
54}
55
56impl<R: Read + Send, O: Write + Send, E: Write + Send> Main<R, O, E> {
57    pub fn new(input: R, out: O, err: E) -> Self {
58        Main { input: Mutex::new(input), out: Mutex::new(out), err: Mutex::new(err) }
59    }
60
61    /// The captured streams, for tests.
62    pub fn into_streams(self) -> (O, E) {
63        (self.out.into_inner().unwrap(), self.err.into_inner().unwrap())
64    }
65
66    pub fn run(&self, input_args: &[String]) -> i32 {
67        let mut report = Report::default();
68        let code = match process_args(input_args) {
69            Ok(ParseResult::Ok(parsed_args)) => return self.run_parsed(&parsed_args),
70            Ok(ParseResult::ShowMessage(message)) => {
71                report.out(&message);
72                EXIT_CODE_SUCCESS
73            }
74            Ok(ParseResult::Error(error_message)) => {
75                report.err(&error_message);
76                EXIT_CODE_FAILURE
77            }
78            // Upstream's argfile read throws out of `main`: the JVM reports it and exits with 1.
79            Err(e) => {
80                let file = Path::new(&input_args[0][1..]);
81                report.err(&format!("Exception in thread \"main\" java.io.FileNotFoundException: {}", java_io_message(Some(file), &e)));
82                EXIT_CODE_FAILURE
83            }
84        };
85        self.flush(&report);
86        code
87    }
88
89    pub fn run_parsed(&self, parsed_args: &ParsedArgs) -> i32 {
90        if parsed_args.file_names.is_empty() {
91            self.flush(&Report { err: format!("{USAGE}{LINE_SEPARATOR}"), ..Report::default() });
92            return EXIT_CODE_FAILURE;
93        }
94
95        if parsed_args.file_names.len() == 1 && parsed_args.file_names[0] == "-" {
96            // Format code read from stdin
97            let mut report = Report::default();
98            let result = self.format(None, parsed_args, &mut report);
99            self.flush(&report);
100            return match result {
101                Ok(already_formatted) if !already_formatted && parsed_args.set_exit_if_changed => EXIT_CODE_FAILURE,
102                Ok(_) => EXIT_CODE_SUCCESS,
103                Err(()) => EXIT_CODE_FAILURE,
104            };
105        }
106
107        let files = expand_args_to_file_names(&parsed_args.file_names);
108        if files.is_empty() {
109            self.flush(&Report { err: format!("Error: no .kt files found{LINE_SEPARATOR}"), ..Report::default() });
110            return EXIT_CODE_FAILURE;
111        }
112
113        // `files.parallelStream().forEach`, one worker per core.
114        let failed = AtomicBool::new(false);
115        let next = AtomicUsize::new(0);
116        let done: Mutex<(usize, Vec<Option<Report>>)> = Mutex::new((0, files.iter().map(|_| None).collect()));
117        let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(files.len());
118        std::thread::scope(|s| {
119            for _ in 0..threads {
120                s.spawn(|| {
121                    loop {
122                        let i = next.fetch_add(1, Ordering::Relaxed);
123                        let Some(file) = files.get(i) else { break };
124                        let mut report = Report::default();
125                        match self.format(Some(file), parsed_args, &mut report) {
126                            Ok(false) if parsed_args.set_exit_if_changed => failed.store(true, Ordering::Relaxed),
127                            Ok(_) => {}
128                            Err(()) => failed.store(true, Ordering::Relaxed),
129                        }
130                        let mut done = done.lock().unwrap();
131                        let (next_to_print, reports) = &mut *done;
132                        reports[i] = Some(report);
133                        while let Some(report) = reports.get_mut(*next_to_print).and_then(Option::take) {
134                            self.flush(&report);
135                            *next_to_print += 1;
136                        }
137                    }
138                });
139            }
140        });
141        if failed.into_inner() { EXIT_CODE_FAILURE } else { EXIT_CODE_SUCCESS }
142    }
143
144    /// Formats `file`, or stdin when `None`; `Ok(true)` iff the input is valid and already formatted.
145    /// `Err` is upstream's rethrown exception: IO, parse and formatting errors are reported first.
146    fn format(&self, file: Option<&Path>, args: &ParsedArgs, report: &mut Report) -> Result<bool, ()> {
147        let file_name = match file {
148            Some(file) => java_file_name(file),
149            None => args.stdin_name.clone().unwrap_or_else(|| "<stdin>".to_owned()),
150        };
151        let mut io_error = |e: io::Error| {
152            report.err(&format!("Error formatting {file_name}: {}; skipping.", java_io_message(file, &e)));
153        };
154
155        let formatting_options = match file {
156            Some(file) if args.editor_config => {
157                editor_config_resolver::resolve_formatting_options(file, &args.formatting_options)
158            }
159            _ => args.formatting_options,
160        };
161        let bytes = match file {
162            Some(file) => fs::read(file).map_err(&mut io_error)?,
163            None => {
164                let mut bytes = Vec::new();
165                self.input.lock().unwrap().read_to_end(&mut bytes).map_err(&mut io_error)?;
166                bytes
167            }
168        };
169        let text = String::from_utf8_lossy(&bytes);
170        let code = text.strip_prefix(UTF8_BOM).unwrap_or(&text);
171        let formatted_code = match catch_quietly(|| ktrs_fmt::format(code, &formatting_options)) {
172            Ok(Ok(formatted_code)) => formatted_code,
173            Ok(Err(e)) => {
174                report_error(report, &file_name, &e);
175                return Err(());
176            }
177            // A formatter bug: like an unexpected JVM exception, it fails only this file.
178            Err(_) => return Err(()),
179        };
180        let already_formatted = code == formatted_code;
181
182        if args.dry_run {
183            if !already_formatted {
184                report.out(&file_name);
185            }
186        } else if let Some(file) = file {
187            if !already_formatted {
188                fs::write(file, &formatted_code).map_err(&mut io_error)?;
189            }
190            if !args.quiet {
191                report.err(&format!("Done formatting {file_name}"));
192            }
193        } else {
194            report.out.push_str(&formatted_code);
195        }
196        Ok(already_formatted)
197    }
198
199    fn flush(&self, report: &Report) {
200        for (stream, text) in [(&self.out as &dyn Flush, &report.out), (&self.err as &dyn Flush, &report.err)] {
201            if !text.is_empty() {
202                stream.write_flush(text.as_bytes());
203            }
204        }
205    }
206}
207
208trait Flush {
209    fn write_flush(&self, bytes: &[u8]);
210}
211
212impl<W: Write> Flush for Mutex<W> {
213    fn write_flush(&self, bytes: &[u8]) {
214        let mut stream = self.lock().unwrap();
215        let _ = stream.write_all(bytes).and_then(|()| stream.flush());
216    }
217}
218
219/// The `catch` blocks for `ParseError` and `FormattingError`; other exceptions pass silently.
220fn report_error(report: &mut Report, file_name: &str, error: &FormatError) {
221    match error {
222        FormatError::Parse(e) => report.err(&format!("{file_name}:{e}")),
223        FormatError::Formatting(e) => {
224            for diagnostic in e.diagnostics() {
225                report.err(&format!("{file_name}:{diagnostic}"));
226            }
227            // `e.printStackTrace(err)`: its first line.
228            report.err(&format!("com.google.googlejavaformat.FormattingError: {error}"));
229        }
230        FormatError::Formatter(_) | FormatError::Runtime(_) => {}
231        // Uncaught: the JVM ends the whole run; here it fails this file only (as for the jar's
232        // `FormattingError` abort, research/24 "Not findings").
233        FormatError::MissedTokens(e) => {
234            report.err(&e.log());
235            report.err(&format!("Exception in thread \"main\" {error}"));
236        }
237    }
238}
239
240/// `expandArgsToFileNames` expands `args` to a list of .kt files to format: a lone file as is,
241/// otherwise every .kt/.kts file under each argument, in directory order (sorted here).
242pub fn expand_args_to_file_names(args: &[String]) -> Vec<PathBuf> {
243    if args.len() == 1 && Path::new(&args[0]).is_file() {
244        return vec![PathBuf::from(&args[0])];
245    }
246    let mut result = Vec::new();
247    for arg in args {
248        walk_top_down(Path::new(arg), &mut result);
249    }
250    result
251}
252
253fn walk_top_down(path: &Path, result: &mut Vec<PathBuf>) {
254    let Ok(metadata) = fs::metadata(path) else { return };
255    if metadata.is_file() {
256        // Kotlin's `File.extension`: whatever follows the name's last dot.
257        let name = path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default();
258        if matches!(name.rsplit_once('.'), Some((_, "kt" | "kts"))) {
259            result.push(path.to_path_buf());
260        }
261    } else if metadata.is_dir() {
262        let Ok(entries) = fs::read_dir(path) else { return };
263        let mut children: Vec<PathBuf> = entries.filter_map(|e| Some(e.ok()?.path())).collect();
264        children.sort();
265        for child in children {
266            walk_top_down(&child, result);
267        }
268    }
269}
270
271/// `File.toString()`: Java normalizes separators to the platform's.
272fn java_file_name(file: &Path) -> String {
273    let name = file.to_string_lossy();
274    if cfg!(windows) { name.replace('/', "\\") } else { name.into_owned() }
275}
276
277/// A Java `IOException` message: `path (reason)` for a file, the OS reason alone otherwise.
278fn java_io_message(file: Option<&Path>, e: &io::Error) -> String {
279    let message = e.to_string();
280    let reason = message.split(" (os error").next().unwrap_or(&message).trim_end_matches('.');
281    match file {
282        Some(file) => format!("{} ({reason})", java_file_name(file)),
283        None => reason.to_owned(),
284    }
285}