Skip to main content

chainsaw/
repl.rs

1//! REPL command parser and interactive loop.
2//!
3//! The command parser is deliberately simple: one word command, optional
4//! argument string. No shell-style quoting or flags — the REPL is an
5//! interactive explorer, not a second CLI.
6
7use std::path::{Path, PathBuf};
8
9use rustyline::Helper;
10use rustyline::completion::{Completer, Pair};
11use rustyline::highlight::Highlighter;
12use rustyline::hint::Hinter;
13use rustyline::validate::Validator;
14
15use crate::error::Error;
16use crate::graph::EdgeKind;
17use crate::query::{self, ChainTarget};
18use crate::report::{self, StderrColor};
19use crate::session::Session;
20
21/// A parsed REPL command.
22pub enum Command {
23    /// Trace transitive weight (optionally from a different file).
24    Trace(Option<String>),
25    /// Switch the session entry point.
26    Entry(String),
27    /// Show import chains to a target.
28    Chain(String),
29    /// Show optimal cut points for a target.
30    Cut(String),
31    /// Diff against another entry point.
32    Diff(String),
33    /// List all third-party packages.
34    Packages,
35    /// List direct imports of a file.
36    Imports(String),
37    /// List files that import a given file.
38    Importers(String),
39    /// Show package info by name.
40    Info(String),
41    /// Print help text.
42    Help,
43    /// Exit the REPL.
44    Quit,
45    /// Unrecognised input or missing argument.
46    Unknown(String),
47}
48
49impl Command {
50    /// Parse a single line of user input into a command.
51    pub fn parse(line: &str) -> Self {
52        /// Extract a non-empty argument or return an error message.
53        fn require(arg: Option<&str>, msg: &str) -> Result<String, String> {
54            match arg {
55                Some(a) if !a.is_empty() => Ok(a.to_string()),
56                _ => Err(msg.to_string()),
57            }
58        }
59
60        let line = line.trim();
61        if line.is_empty() {
62            return Self::Help;
63        }
64        let (cmd, arg) = line
65            .split_once(' ')
66            .map_or((line, None), |(c, a)| (c, Some(a.trim())));
67
68        match cmd {
69            "trace" => Self::Trace(arg.map(String::from)),
70            "entry" => match require(arg, "entry requires a file argument") {
71                Ok(a) => Self::Entry(a),
72                Err(e) => Self::Unknown(e),
73            },
74            "chain" => match require(arg, "chain requires a target argument") {
75                Ok(a) => Self::Chain(a),
76                Err(e) => Self::Unknown(e),
77            },
78            "cut" => match require(arg, "cut requires a target argument") {
79                Ok(a) => Self::Cut(a),
80                Err(e) => Self::Unknown(e),
81            },
82            "diff" => match require(arg, "diff requires a file argument") {
83                Ok(a) => Self::Diff(a),
84                Err(e) => Self::Unknown(e),
85            },
86            "imports" => match require(arg, "imports requires a file argument") {
87                Ok(a) => Self::Imports(a),
88                Err(e) => Self::Unknown(e),
89            },
90            "importers" => match require(arg, "importers requires a file argument") {
91                Ok(a) => Self::Importers(a),
92                Err(e) => Self::Unknown(e),
93            },
94            "info" => match require(arg, "info requires a package name") {
95                Ok(a) => Self::Info(a),
96                Err(e) => Self::Unknown(e),
97            },
98            "packages" => Self::Packages,
99            "help" | "?" => Self::Help,
100            "quit" | "exit" => Self::Quit,
101            _ => Self::Unknown(format!("unknown command: {cmd}")),
102        }
103    }
104}
105
106/// All command names, for tab completion.
107pub const COMMAND_NAMES: &[&str] = &[
108    "trace",
109    "entry",
110    "chain",
111    "cut",
112    "diff",
113    "packages",
114    "imports",
115    "importers",
116    "info",
117    "help",
118    "quit",
119    "exit",
120];
121
122// ---------------------------------------------------------------------------
123// Tab completion
124// ---------------------------------------------------------------------------
125
126const MAX_COMPLETIONS: usize = 20;
127
128struct ChainsawHelper {
129    file_paths: Vec<String>,
130    package_names: Vec<String>,
131}
132
133impl ChainsawHelper {
134    fn new() -> Self {
135        Self {
136            file_paths: Vec::new(),
137            package_names: Vec::new(),
138        }
139    }
140
141    fn update_from_session(&mut self, session: &Session) {
142        self.file_paths = session
143            .graph()
144            .modules
145            .iter()
146            .map(|m| report::relative_path(&m.path, session.root()))
147            .collect();
148        self.package_names = session.graph().package_map.keys().cloned().collect();
149    }
150}
151
152impl Completer for ChainsawHelper {
153    type Candidate = Pair;
154
155    fn complete(
156        &self,
157        line: &str,
158        pos: usize,
159        _ctx: &rustyline::Context<'_>,
160    ) -> rustyline::Result<(usize, Vec<Pair>)> {
161        let line = &line[..pos];
162
163        // Complete command names at start of line.
164        if !line.contains(' ') {
165            let matches: Vec<Pair> = COMMAND_NAMES
166                .iter()
167                .filter(|c| c.starts_with(line))
168                .map(|&c| Pair {
169                    display: c.to_string(),
170                    replacement: c.to_string(),
171                })
172                .collect();
173            return Ok((0, matches));
174        }
175
176        // Complete arguments based on command.
177        let (cmd, partial) = line.split_once(' ').unwrap_or((line, ""));
178        let partial = partial.trim_start();
179        let start = pos - partial.len();
180
181        let matches: Vec<Pair> = match cmd {
182            "chain" | "cut" => self
183                .package_names
184                .iter()
185                .chain(self.file_paths.iter())
186                .filter(|c| c.starts_with(partial))
187                .take(MAX_COMPLETIONS)
188                .map(|c| Pair {
189                    display: c.clone(),
190                    replacement: c.clone(),
191                })
192                .collect(),
193            "info" => self
194                .package_names
195                .iter()
196                .filter(|c| c.starts_with(partial))
197                .take(MAX_COMPLETIONS)
198                .map(|c| Pair {
199                    display: c.clone(),
200                    replacement: c.clone(),
201                })
202                .collect(),
203            "trace" | "entry" | "imports" | "importers" | "diff" => self
204                .file_paths
205                .iter()
206                .filter(|c| c.starts_with(partial))
207                .take(MAX_COMPLETIONS)
208                .map(|c| Pair {
209                    display: c.clone(),
210                    replacement: c.clone(),
211                })
212                .collect(),
213            _ => return Ok((start, vec![])),
214        };
215
216        Ok((start, matches))
217    }
218}
219
220impl Hinter for ChainsawHelper {
221    type Hint = String;
222}
223impl Highlighter for ChainsawHelper {}
224impl Validator for ChainsawHelper {}
225impl Helper for ChainsawHelper {}
226
227// ---------------------------------------------------------------------------
228// REPL loop
229// ---------------------------------------------------------------------------
230
231/// Run the interactive REPL loop.
232pub fn run(entry: &Path, no_color: bool, sc: StderrColor) -> Result<(), Error> {
233    let start = std::time::Instant::now();
234    let mut session = Session::open(entry, false)?;
235
236    report::print_load_status(
237        session.from_cache(),
238        session.graph().module_count(),
239        start.elapsed().as_secs_f64() * 1000.0,
240        session.file_warnings(),
241        session.unresolvable_dynamic_count(),
242        session.unresolvable_dynamic_files(),
243        session.root(),
244        sc,
245    );
246    eprintln!("Type 'help' for commands, 'quit' to exit.\n");
247
248    let mut helper = ChainsawHelper::new();
249    helper.update_from_session(&session);
250
251    let mut rl =
252        rustyline::Editor::new().map_err(|e| Error::Readline(format!("init failed: {e}")))?;
253    rl.set_helper(Some(helper));
254
255    let history_path = history_file();
256    if let Some(ref path) = history_path {
257        let _ = rl.load_history(path);
258    }
259
260    let prompt = format!("{}> ", sc.status("chainsaw"));
261
262    loop {
263        // Check for file changes before each command.
264        match session.refresh() {
265            Ok(true) => {
266                eprintln!(
267                    "{} graph refreshed ({} modules)",
268                    sc.status("Reloaded:"),
269                    session.graph().module_count()
270                );
271                if let Some(h) = rl.helper_mut() {
272                    h.update_from_session(&session);
273                }
274            }
275            Ok(false) => {}
276            Err(e) => eprintln!("{} refresh failed: {e}", sc.warning("warning:")),
277        }
278
279        let line = match rl.readline(&prompt) {
280            Ok(line) => line,
281            Err(rustyline::error::ReadlineError::Interrupted) => continue,
282            Err(rustyline::error::ReadlineError::Eof) => break,
283            Err(e) => {
284                eprintln!("{} {e}", sc.error("error:"));
285                break;
286            }
287        };
288
289        let trimmed = line.trim();
290        if trimmed.is_empty() {
291            continue;
292        }
293        rl.add_history_entry(trimmed).ok();
294
295        match Command::parse(trimmed) {
296            Command::Trace(file) => dispatch_trace(&session, file.as_deref(), no_color, sc),
297            Command::Entry(path) => dispatch_entry(&mut session, &path, sc),
298            Command::Chain(target) => dispatch_chain(&session, &target, no_color, sc),
299            Command::Cut(target) => dispatch_cut(&session, &target, no_color, sc),
300            Command::Diff(path) => dispatch_diff(&session, &path, no_color, sc),
301            Command::Packages => dispatch_packages(&session, no_color),
302            Command::Imports(path) => dispatch_imports(&session, &path, sc),
303            Command::Importers(path) => dispatch_importers(&session, &path, sc),
304            Command::Info(name) => dispatch_info(&session, &name, sc),
305            Command::Help => print_help(),
306            Command::Quit => break,
307            Command::Unknown(msg) => eprintln!("{} {msg}", sc.error("error:")),
308        }
309    }
310
311    if let Some(ref path) = history_path {
312        let _ = rl.save_history(path);
313    }
314    Ok(())
315}
316
317fn history_file() -> Option<PathBuf> {
318    let dir = std::env::var_os("XDG_DATA_HOME")
319        .map(PathBuf::from)
320        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
321    let dir = dir.join("chainsaw");
322    std::fs::create_dir_all(&dir).ok()?;
323    Some(dir.join("history"))
324}
325
326// ---------------------------------------------------------------------------
327// Command dispatch
328// ---------------------------------------------------------------------------
329
330fn dispatch_trace(session: &Session, file: Option<&str>, no_color: bool, sc: StderrColor) {
331    let opts = query::TraceOptions::default();
332    let (result, entry_path) = if let Some(f) = file {
333        match session.trace_from(Path::new(f), &opts) {
334            Ok(r) => r,
335            Err(e) => {
336                eprintln!("{} {e}", sc.error("error:"));
337                return;
338            }
339        }
340    } else {
341        (session.trace(&opts), session.entry().to_path_buf())
342    };
343
344    let display_opts = report::DisplayOpts {
345        top: report::DEFAULT_TOP,
346        top_modules: report::DEFAULT_TOP_MODULES,
347        include_dynamic: false,
348        no_color,
349        max_weight: None,
350    };
351    report::print_trace(
352        session.graph(),
353        &result,
354        &entry_path,
355        session.root(),
356        &display_opts,
357    );
358}
359
360fn dispatch_entry(session: &mut Session, path: &str, sc: StderrColor) {
361    if let Err(e) = session.set_entry(Path::new(path)) {
362        eprintln!("{} {e}", sc.error("error:"));
363        return;
364    }
365    let rel = report::relative_path(session.entry(), session.root());
366    eprintln!("{} entry point is now {rel}", sc.status("Switched:"));
367}
368
369fn dispatch_chain(session: &Session, target: &str, no_color: bool, sc: StderrColor) {
370    let (resolved, chains) = session.chain(target, false);
371    if resolved.target == ChainTarget::Module(session.entry_id()) {
372        eprintln!("{} target is the entry point itself", sc.error("error:"));
373        return;
374    }
375    if !resolved.exists {
376        eprintln!(
377            "{} '{}' not found in graph",
378            sc.warning("warning:"),
379            resolved.label
380        );
381    }
382    report::print_chains(
383        session.graph(),
384        &chains,
385        &resolved.label,
386        session.root(),
387        resolved.exists,
388        no_color,
389    );
390}
391
392fn dispatch_cut(session: &Session, target: &str, no_color: bool, sc: StderrColor) {
393    let (resolved, chains, cuts) = session.cut(target, report::DEFAULT_TOP, false);
394    if resolved.target == ChainTarget::Module(session.entry_id()) {
395        eprintln!("{} target is the entry point itself", sc.error("error:"));
396        return;
397    }
398    if !resolved.exists {
399        eprintln!(
400            "{} '{}' not found in graph",
401            sc.warning("warning:"),
402            resolved.label
403        );
404    }
405    report::print_cut(
406        session.graph(),
407        &cuts,
408        &chains,
409        &resolved.label,
410        session.root(),
411        resolved.exists,
412        no_color,
413    );
414}
415
416fn dispatch_diff(session: &Session, path: &str, no_color: bool, sc: StderrColor) {
417    let opts = query::TraceOptions::default();
418    match session.diff_entry(Path::new(path), &opts) {
419        Ok((diff, other_canon)) => {
420            let entry_rel = session.entry_label();
421            let other_rel = session.entry_label_for(&other_canon);
422            report::print_diff(&diff, &entry_rel, &other_rel, report::DEFAULT_TOP, no_color);
423        }
424        Err(e) => eprintln!("{} {e}", sc.error("error:")),
425    }
426}
427
428fn dispatch_packages(session: &Session, no_color: bool) {
429    report::print_packages(session.graph(), report::DEFAULT_TOP, no_color);
430}
431
432fn dispatch_imports(session: &Session, path: &str, sc: StderrColor) {
433    match session.imports(Path::new(path)) {
434        Ok(imports) => {
435            if imports.is_empty() {
436                println!("  (no imports)");
437                return;
438            }
439            for (p, kind) in &imports {
440                let rel = report::relative_path(p, session.root());
441                let suffix = match kind {
442                    EdgeKind::Static => "",
443                    EdgeKind::Dynamic => " (dynamic)",
444                    EdgeKind::TypeOnly => " (type-only)",
445                };
446                println!("  {rel}{suffix}");
447            }
448        }
449        Err(e) => eprintln!("{} {e}", sc.error("error:")),
450    }
451}
452
453fn dispatch_importers(session: &Session, path: &str, sc: StderrColor) {
454    match session.importers(Path::new(path)) {
455        Ok(importers) => {
456            if importers.is_empty() {
457                println!("  (no importers)");
458                return;
459            }
460            for (p, kind) in &importers {
461                let rel = report::relative_path(p, session.root());
462                let suffix = match kind {
463                    EdgeKind::Static => "",
464                    EdgeKind::Dynamic => " (dynamic)",
465                    EdgeKind::TypeOnly => " (type-only)",
466                };
467                println!("  {rel}{suffix}");
468            }
469        }
470        Err(e) => eprintln!("{} {e}", sc.error("error:")),
471    }
472}
473
474fn dispatch_info(session: &Session, name: &str, sc: StderrColor) {
475    match session.info(name) {
476        Some(info) => {
477            println!(
478                "  {} ({} files, {})",
479                info.name,
480                info.total_reachable_files,
481                report::format_size(info.total_reachable_size)
482            );
483        }
484        None => eprintln!("{} package '{name}' not found", sc.error("error:")),
485    }
486}
487
488fn print_help() {
489    println!("Commands:");
490    println!("  trace [file]       Trace from entry point (or specified file)");
491    println!("  entry <file>       Switch the default entry point");
492    println!("  chain <target>     Show import chains to a package or file");
493    println!("  cut <target>       Show where to cut to sever chains");
494    println!("  diff <file>        Compare weight against another entry");
495    println!("  packages           List third-party packages");
496    println!("  imports <file>     Show what a file imports");
497    println!("  importers <file>   Show what imports a file");
498    println!("  info <package>     Show package details");
499    println!("  help               Show this help");
500    println!("  quit               Exit");
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn parse_trace_no_arg() {
509        assert!(matches!(Command::parse("trace"), Command::Trace(None)));
510    }
511
512    #[test]
513    fn parse_trace_with_file() {
514        assert!(
515            matches!(Command::parse("trace src/index.ts"), Command::Trace(Some(ref f)) if f == "src/index.ts")
516        );
517    }
518
519    #[test]
520    fn parse_chain() {
521        assert!(matches!(Command::parse("chain zod"), Command::Chain(ref t) if t == "zod"));
522    }
523
524    #[test]
525    fn parse_entry() {
526        assert!(
527            matches!(Command::parse("entry src/other.ts"), Command::Entry(ref f) if f == "src/other.ts")
528        );
529    }
530
531    #[test]
532    fn parse_packages() {
533        assert!(matches!(Command::parse("packages"), Command::Packages));
534    }
535
536    #[test]
537    fn parse_imports() {
538        assert!(
539            matches!(Command::parse("imports src/foo.ts"), Command::Imports(ref f) if f == "src/foo.ts")
540        );
541    }
542
543    #[test]
544    fn parse_importers() {
545        assert!(
546            matches!(Command::parse("importers lib/bar.py"), Command::Importers(ref f) if f == "lib/bar.py")
547        );
548    }
549
550    #[test]
551    fn parse_info() {
552        assert!(matches!(Command::parse("info zod"), Command::Info(ref p) if p == "zod"));
553    }
554
555    #[test]
556    fn parse_empty_is_help() {
557        assert!(matches!(Command::parse(""), Command::Help));
558    }
559
560    #[test]
561    fn parse_question_mark_is_help() {
562        assert!(matches!(Command::parse("?"), Command::Help));
563    }
564
565    #[test]
566    fn parse_quit() {
567        assert!(matches!(Command::parse("quit"), Command::Quit));
568        assert!(matches!(Command::parse("exit"), Command::Quit));
569    }
570
571    #[test]
572    fn parse_unknown() {
573        assert!(matches!(Command::parse("blah"), Command::Unknown(_)));
574    }
575
576    #[test]
577    fn parse_missing_arg() {
578        assert!(matches!(Command::parse("chain"), Command::Unknown(_)));
579        assert!(matches!(Command::parse("entry"), Command::Unknown(_)));
580        assert!(matches!(Command::parse("cut"), Command::Unknown(_)));
581        assert!(matches!(Command::parse("diff"), Command::Unknown(_)));
582        assert!(matches!(Command::parse("imports"), Command::Unknown(_)));
583        assert!(matches!(Command::parse("importers"), Command::Unknown(_)));
584        assert!(matches!(Command::parse("info"), Command::Unknown(_)));
585    }
586
587    #[test]
588    fn parse_preserves_arg_with_spaces() {
589        assert!(
590            matches!(Command::parse("chain @scope/pkg"), Command::Chain(ref t) if t == "@scope/pkg")
591        );
592    }
593
594    #[test]
595    fn parse_trims_whitespace() {
596        assert!(matches!(Command::parse("  quit  "), Command::Quit));
597    }
598}