Skip to main content

skif_cli/
lib.rs

1//! The `skif` command line as a library: a thin consumer of the `skif` library.
2//!
3//! It is the only code that reads the environment, prints, prompts, writes files, spawns a process and opens a network
4//! connection (`skif update` only); [`run`] returns an exit code and no function below calls `std::process::exit`,
5//! except the panic hook that [`install_panic_hook`] puts in place for the `skif` binary. The npm addon calls [`run`];
6//! the binary calls [`run_to_exit`], which also changes the current directory under `bazel run` and leaves the memory
7//! of a run to the operating system.
8
9use std::cell::Cell;
10use std::ffi::OsString;
11use std::io::Write;
12use std::sync::Once;
13
14use clap::Parser;
15use serde_json::Map;
16
17mod args;
18mod baseline;
19mod commands;
20mod config;
21mod config_toml;
22mod env;
23mod error;
24mod explain;
25mod filter;
26mod fix;
27mod format;
28mod install;
29mod instances;
30mod lint;
31mod lsp;
32mod mcp;
33mod migrate;
34mod module_cache;
35mod npmrc;
36mod output;
37mod registry;
38mod roots;
39mod trace;
40mod update;
41mod watch;
42mod write;
43
44use args::{Cli, Command, Format};
45use env::Env;
46pub use error::{CliError, Kind};
47use lint::Teardown;
48pub use lint::lint_to_json;
49use output::{emit_error, emit_error_with};
50
51/// Runs the CLI with `args` (the first item is the program name, as in `std::env::args_os`) and returns the exit
52/// code. The npm addon calls this; the `skif` binary calls [`run_to_exit`], which differs only in what happens to
53/// memory and in entering the `bazel run` directory; everything else is shared, so their behaviour cannot drift.
54///
55/// It never changes the process's current directory, even under `bazel run`: in a host such as Node.js, which caches
56/// its own current directory, a change made here would leave the host out of step. A host that runs skif under
57/// `bazel run` enters that directory itself first, as the npm package's `skif` bin does (ADR 0031).
58///
59/// A panic in skif is caught and exits 2, as the binary's [`install_panic_hook`] makes it do; the panic hook prints
60/// the message and its location to stderr, and with `--format json` stdout also receives the `error` document (kind
61/// `internal`) naming both.
62///
63/// To learn the location, the first call chains a hook in front of the process's panic hook (`std::panic::set_hook`),
64/// once per process; it records the panic for the thread that raised it and then calls the hook it replaced, so the
65/// host's own handling still runs. A host that sets its own hook after that call drops the recording hook: a panic
66/// still exits 2, but the `error` document then names only the panic's payload, without the location.
67pub fn run(args: impl IntoIterator<Item = OsString>) -> u8 {
68    let raw: Vec<OsString> = args.into_iter().collect();
69    exit_two_on_panic(&raw, &mut std::io::stdout(), || run_with(raw.clone(), Teardown::Free))
70}
71
72thread_local! {
73    /// The last panic of this thread seen by the hook [`record_panics`] chains in front of the host's: its message
74    /// and location, which the payload that `catch_unwind` returns does not carry. Per thread, since `catch_unwind`
75    /// runs on the thread that panicked and a panic elsewhere in the process must not be reported as this one.
76    static LAST_PANIC: Cell<Option<String>> = const { Cell::new(None) };
77}
78
79/// Chains a hook in front of the current panic hook, once per process, that records each panic in [`LAST_PANIC`]
80/// and then calls the hook it replaced, so the host's panic handling is unchanged.
81fn record_panics() {
82    static ONCE: Once = Once::new();
83    ONCE.call_once(|| {
84        let previous = std::panic::take_hook();
85        std::panic::set_hook(Box::new(move |info| {
86            // A panic while the thread's locals are torn down goes unrecorded; the payload then describes it.
87            let _ = LAST_PANIC.try_with(|last| last.set(Some(info.to_string())));
88            previous(info);
89        }));
90    });
91}
92
93/// `body`'s exit code, or 2 when it panics, after the `internal` error document on `stdout` when `raw` asks for JSON.
94/// The panic hook has already printed the message and location to stderr. The document names the location the hook
95/// recorded on this thread, or only the payload when the hook did not run (a host replaced it).
96fn exit_two_on_panic(raw: &[OsString], stdout: &mut dyn Write, body: impl FnOnce() -> u8) -> u8 {
97    record_panics();
98    LAST_PANIC.with(|last| last.set(None));
99    std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)).unwrap_or_else(|payload| {
100        let recorded = LAST_PANIC.with(Cell::take);
101        if wants_json(raw) {
102            let report = recorded.unwrap_or_else(|| {
103                payload
104                    .downcast_ref::<&str>()
105                    .copied()
106                    .or_else(|| payload.downcast_ref::<String>().map(String::as_str))
107                    .unwrap_or("panic")
108                    .to_owned()
109            });
110            emit_error_with(&internal_error(&report), Format::Json, Map::new(), stdout);
111        }
112        2
113    })
114}
115
116/// The error a panic becomes; `report` describes it: the panic hook's `panicked at <location>:` and the message, or
117/// the payload alone.
118fn internal_error(report: &str) -> CliError {
119    CliError::new(Kind::Internal, format!("internal error: {report}"))
120}
121
122/// Like [`run`], for a process that exits as soon as this returns: the lint command leaves its diagnostics, workspace
123/// model and file caches to the operating system instead of freeing them one allocation at a time, a measurable part
124/// of a large run. Never call it from a long-lived host such as the npm addon: that memory would leak on every call.
125///
126/// Under `bazel run` (`BUILD_WORKSPACE_DIRECTORY` is set) it first changes the process's current directory to
127/// `BUILD_WORKING_DIRECTORY`, else to the workspace root, so paths on the command line resolve as the user typed them
128/// rather than in the runfiles tree (ADR 0031); a directory it cannot enter exits 2.
129pub fn run_to_exit(args: impl IntoIterator<Item = OsString>) -> u8 {
130    let raw: Vec<OsString> = args.into_iter().collect();
131    if let Err(error) = env::enter_bazel_run_directory() {
132        emit_error(&error, if wants_json(&raw) { Format::Json } else { Format::Human });
133        return 2;
134    }
135    run_with(raw, Teardown::Leak)
136}
137
138fn run_with(args: impl IntoIterator<Item = OsString>, teardown: Teardown) -> u8 {
139    let raw: Vec<OsString> = args.into_iter().collect();
140    env::enable_timings();
141    let cli = match Cli::try_parse_from(&raw) {
142        Ok(cli) => cli,
143        Err(error) => return usage(&raw, &error),
144    };
145    match cli.command {
146        None if cli.lint.watch => watch::run(&cli.lint, Env::detect()),
147        None => lint::run(&cli.lint, Env::detect(), teardown),
148        Some(Command::Rules(args)) => commands::rules(&args),
149        Some(Command::Schema) => commands::schema(),
150        Some(Command::Init(args)) => commands::init(&args),
151        Some(Command::Config(args)) => config::run(&args),
152        Some(Command::ExplainUsage(args)) => explain::run(&args),
153        Some(Command::Migrate(args)) => migrate::run(&args),
154        Some(Command::Baseline(args)) => baseline::run(&args),
155        Some(Command::Update(args)) => update::run(&args, Env::detect()),
156        Some(Command::List(args)) => instances::list(&args),
157        Some(Command::Json(args)) => instances::json(&args),
158        Some(Command::Format(args)) => format::run(&args, Env::detect(), teardown),
159        Some(Command::Mcp) => mcp::run(),
160        Some(Command::Lsp) => lsp::run(),
161    }
162}
163
164/// Makes a panic in skif exit 2 (could not run) in every format, after the default hook has printed its message and
165/// location to stderr; in `--format json` mode stdout also receives exactly one `error` document (kind `internal`).
166/// The release binary is built with `panic = "abort"`, and the hook runs before the abort, so the process exits 2
167/// instead of being killed by `SIGABRT`.
168///
169/// For the `skif` binary only; an embedding host owns its own panic handling.
170pub fn install_panic_hook(raw: &[OsString]) {
171    let json = wants_json(raw);
172    let default = std::panic::take_hook();
173    std::panic::set_hook(Box::new(move |info| {
174        default(info);
175        if json {
176            emit_error(&internal_error(&info.to_string()), Format::Json);
177        }
178        std::process::exit(2);
179    }));
180}
181
182/// Help and version go to stdout with exit 0; usage errors exit 2, as a JSON `error` document when `--format json`
183/// is on the command line.
184fn usage(raw: &[OsString], error: &clap::Error) -> u8 {
185    if !error.use_stderr() {
186        let _ = error.print();
187        return 0;
188    }
189    if wants_json(raw) {
190        let message = error.to_string();
191        emit_error(&CliError::new(Kind::Usage, message.trim_end()), Format::Json);
192    } else {
193        let _ = error.print();
194    }
195    2
196}
197
198/// Whether the command line asks for JSON-mode errors: the format that goes to stdout is `json`, or, when every
199/// `--format` names a file, the first one is (ADR 0022). Read from the raw arguments, before clap has parsed them.
200fn wants_json(raw: &[OsString]) -> bool {
201    let args: Vec<&str> = raw.iter().filter_map(|arg| arg.to_str()).collect();
202    let mut values = Vec::new();
203    for (index, arg) in args.iter().enumerate() {
204        if let Some(value) = arg.strip_prefix("--format=") {
205            values.push(value);
206        } else if *arg == "--format" {
207            values.extend(args.get(index + 1));
208        }
209    }
210    values
211        .iter()
212        .find(|value| !value.contains('='))
213        .or_else(|| values.first())
214        .is_some_and(|value| value.split('=').next() == Some("json"))
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn args(list: &[&str]) -> Vec<OsString> {
222        list.iter().map(OsString::from).collect()
223    }
224
225    #[test]
226    fn a_panic_exits_two_in_every_format() {
227        for (raw, json) in [(args(&["skif"]), false), (args(&["skif", "--format", "json"]), true)] {
228            let mut stdout = Vec::new();
229            assert_eq!(exit_two_on_panic(&raw, &mut stdout, || panic!("boom")), 2);
230            if !json {
231                assert!(stdout.is_empty(), "{}", String::from_utf8_lossy(&stdout));
232                continue;
233            }
234            let document: serde_json::Value = serde_json::from_slice(&stdout).unwrap();
235            assert_eq!(document["error"]["kind"], "internal", "{document:#}");
236            let message = document["error"]["message"].as_str().unwrap();
237            assert!(
238                message.starts_with("internal error: panicked at ")
239                    && message.contains("lib.rs:")
240                    && message.ends_with(":\nboom"),
241                "the message names the location and the panic: {message}"
242            );
243        }
244    }
245
246    /// The JSON `error` message of a run of `body` that exits 2.
247    fn panic_message(body: impl FnOnce() -> u8) -> String {
248        let mut stdout = Vec::new();
249        assert_eq!(
250            exit_two_on_panic(&args(&["skif", "--format", "json"]), &mut stdout, body),
251            2
252        );
253        let document: serde_json::Value = serde_json::from_slice(&stdout).unwrap();
254        document["error"]["message"].as_str().unwrap().to_owned()
255    }
256
257    #[test]
258    fn a_panic_on_another_thread_is_not_reported_as_this_one() {
259        // `resume_unwind` skips the panic hook, so only the payload describes this panic; the other thread's panic
260        // goes through the hook while this run is in progress.
261        let message = panic_message(|| {
262            let other = std::thread::spawn(|| std::panic::panic_any("another thread's panic"));
263            assert!(other.join().is_err(), "the other thread panicked");
264            std::panic::resume_unwind(Box::new("boom"))
265        });
266        assert_eq!(message, "internal error: boom");
267    }
268
269    #[test]
270    fn a_panic_caught_earlier_on_this_thread_is_not_reported_again() {
271        record_panics();
272        let earlier = std::panic::catch_unwind(|| std::panic::panic_any("an earlier panic"));
273        assert!(earlier.is_err(), "the earlier body panicked");
274        let message = panic_message(|| std::panic::resume_unwind(Box::new(String::from("boom"))));
275        assert_eq!(message, "internal error: boom");
276    }
277
278    #[test]
279    fn without_a_panic_the_exit_code_is_the_body_s() {
280        let mut stdout = Vec::new();
281        assert_eq!(
282            exit_two_on_panic(&args(&["skif", "--format", "json"]), &mut stdout, || 1),
283            1
284        );
285        assert_eq!(stdout, b"");
286    }
287}