ast_grep/
lib.rs

1mod completions;
2mod config;
3mod lang;
4mod lsp;
5mod new;
6mod print;
7mod run;
8mod scan;
9mod utils;
10mod verify;
11
12use anyhow::Result;
13use clap::{Parser, Subcommand};
14use std::path::PathBuf;
15
16use completions::{run_shell_completion, CompletionsArg};
17use config::ProjectConfig;
18use lsp::{run_language_server, LspArg};
19use new::{run_create_new, NewArg};
20use run::{run_with_pattern, RunArg};
21use scan::{run_with_config, ScanArg};
22use utils::exit_with_error;
23use verify::{run_test_rule, TestArg};
24
25const LOGO: &str = r#"
26Search and Rewrite code at large scale using AST pattern.
27                    __
28        ____ ______/ /_      ____ _________  ____
29       / __ `/ ___/ __/_____/ __ `/ ___/ _ \/ __ \
30      / /_/ (__  ) /_/_____/ /_/ / /  /  __/ /_/ /
31      \__,_/____/\__/      \__, /_/   \___/ .___/
32                          /____/         /_/
33"#;
34#[derive(Parser)]
35#[clap(author, version, about, long_about = LOGO)]
36/**
37 * TODO: add some description for ast-grep: sg
38 * Example:
39 * sg -p "$PATTERN.to($MATCH)" -l ts --rewrite "use($MATCH)"
40 */
41struct App {
42  #[clap(subcommand)]
43  command: Commands,
44  /// Path to ast-grep root config, default is sgconfig.yml.
45  #[clap(short, long, global = true, value_name = "CONFIG_FILE")]
46  config: Option<PathBuf>,
47}
48
49#[derive(Subcommand)]
50enum Commands {
51  /// Run one time search or rewrite in command line. (default command)
52  Run(RunArg),
53  /// Scan and rewrite code by configuration.
54  Scan(ScanArg),
55  /// Test ast-grep rules.
56  Test(TestArg),
57  /// Create new ast-grep project or items like rules/tests.
58  New(NewArg),
59  /// Start language server.
60  Lsp(LspArg),
61  /// Generate shell completion script.
62  Completions(CompletionsArg),
63  /// Generate rule docs for current configuration. (Not Implemented Yet)
64  #[cfg(debug_assertions)]
65  Docs,
66}
67
68pub fn execute_main() -> Result<()> {
69  match main_with_args(std::env::args()) {
70    Err(error) => exit_with_error(error),
71    ok => ok,
72  }
73}
74
75fn is_command(arg: &str, command: &str) -> bool {
76  let arg = arg.split('=').next().unwrap_or(arg);
77  if arg.starts_with("--") {
78    let arg = arg.trim_start_matches("--");
79    arg == command
80  } else if arg.starts_with('-') {
81    let arg = arg.trim_start_matches('-');
82    arg == &command[..1]
83  } else {
84    false
85  }
86}
87
88fn try_default_run(args: &[String]) -> Result<Option<RunArg>> {
89  // use `run` if there is at lease one pattern arg with no user provided command
90  let should_use_default_run_command =
91    args.iter().skip(1).any(|p| is_command(p, "pattern")) && args[1].starts_with('-');
92  if should_use_default_run_command {
93    // handle no subcommand
94    let arg = RunArg::try_parse_from(args)?;
95    Ok(Some(arg))
96  } else {
97    Ok(None)
98  }
99}
100
101/// finding project and setup custom language configuration
102fn setup_project_is_possible(args: &[String]) -> Result<Result<ProjectConfig>> {
103  let mut config = None;
104  for i in 0..args.len() {
105    let arg = &args[i];
106    if !is_command(arg, "config") {
107      continue;
108    }
109    // handle --config=config.yml, see ast-grep/ast-grep#1617
110    if arg.contains('=') {
111      let config_file = arg.split('=').nth(1).unwrap().into();
112      config = Some(config_file);
113      break;
114    }
115    // handle -c config.yml, arg value should be next
116    if i + 1 >= args.len() || args[i + 1].starts_with('-') {
117      return Err(anyhow::anyhow!("missing config file after -c"));
118    }
119    let config_file = (&args[i + 1]).into();
120    config = Some(config_file);
121  }
122  ProjectConfig::setup(config)
123}
124
125// this wrapper function is for testing
126pub fn main_with_args(args: impl Iterator<Item = String>) -> Result<()> {
127  let args: Vec<_> = args.collect();
128  // do not unwrap project before cmd parsing
129  // sg help does not need a valid sgconfig.yml
130  let project = setup_project_is_possible(&args);
131  if let Some(arg) = try_default_run(&args)? {
132    return run_with_pattern(arg, project?);
133  }
134  let app = App::try_parse_from(args)?;
135  let project = project?; // unwrap here to report invalid project
136  match app.command {
137    Commands::Run(arg) => run_with_pattern(arg, project),
138    Commands::Scan(arg) => run_with_config(arg, project),
139    Commands::Test(arg) => run_test_rule(arg, project),
140    Commands::New(arg) => run_create_new(arg, project),
141    Commands::Lsp(arg) => run_language_server(arg, project),
142    Commands::Completions(arg) => run_shell_completion::<App>(arg),
143    #[cfg(debug_assertions)]
144    Commands::Docs => todo!("todo, generate rule docs based on current config"),
145  }
146}
147
148#[cfg(test)]
149mod test_cli {
150  use super::*;
151
152  fn sg(args: &str) -> Result<App> {
153    let app = App::try_parse_from(
154      std::iter::once("sg".into()).chain(args.split(' ').map(|s| s.to_string())),
155    )?;
156    Ok(app)
157  }
158
159  fn ok(args: &str) -> App {
160    sg(args).expect("should parse")
161  }
162  fn error(args: &str) -> clap::Error {
163    let Err(err) = sg(args) else {
164      panic!("app parsing should fail!")
165    };
166    err
167      .downcast::<clap::Error>()
168      .expect("should have clap::Error")
169  }
170
171  #[test]
172  fn test_wrong_usage() {
173    error("");
174    error("Some($A) -l rs");
175    error("-l rs");
176  }
177
178  #[test]
179  fn test_version_and_help() {
180    let version = error("--version");
181    assert!(version.to_string().starts_with("ast-grep"));
182    let version = error("-V");
183    assert!(version.to_string().starts_with("ast-grep"));
184    let help = error("--help");
185    assert!(help.to_string().contains("Search and Rewrite code"));
186  }
187
188  fn default_run(args: &str) {
189    let args: Vec<_> = std::iter::once("sg".into())
190      .chain(args.split(' ').map(|s| s.to_string()))
191      .collect();
192    assert!(matches!(try_default_run(&args), Ok(Some(_))));
193  }
194  #[test]
195  fn test_no_arg_run() {
196    let ret = main_with_args(["sg".to_owned()].into_iter());
197    let err = ret.unwrap_err();
198    assert!(err.to_string().contains("sg [OPTIONS] <COMMAND>"));
199  }
200  #[test]
201  fn test_default_subcommand() {
202    default_run("-p Some($A) -l rs");
203    default_run("-p Some($A)");
204    default_run("-p Some($A) -l rs -r $A.unwrap()");
205  }
206
207  #[test]
208  fn test_run() {
209    ok("run -p test -i");
210    ok("run -p test --interactive dir");
211    ok("run -p test -r Test dir");
212    ok("run -p test -l rs --debug-query");
213    ok("run -p test -l rs --debug-query not");
214    ok("run -p test -l rs --debug-query=ast");
215    ok("run -p test -l rs --debug-query=cst");
216    ok("run -p test -l rs --color always");
217    ok("run -p test -l rs --heading always");
218    ok("run -p test dir1 dir2 dir3"); // multiple paths
219    ok("run -p testm -r restm -U"); // update all
220    ok("run -p testm -r restm --update-all"); // update all
221    ok("run -p test --json compact"); // argument after --json should not be parsed as JsonStyle
222    ok("run -p test --json=pretty dir");
223    ok("run -p test --json dir"); // arg after --json should not be parsed as JsonStyle
224    ok("run -p test --strictness ast");
225    ok("run -p test --strictness relaxed");
226    ok("run -p test --selector identifier"); // pattern + selector
227    ok("run -p test --selector identifier -l js");
228    ok("run -p test --follow");
229    ok("run -p test --globs '*.js'");
230    ok("run -p test --globs '*.{js, ts}'");
231    ok("run -p test --globs '*.js' --globs '*.ts'");
232    ok("run -p fubuki -j8");
233    ok("run -p test --threads 12");
234    ok("run -p test -l rs -c config.yml"); // global config arg
235    error("run test");
236    error("run --debug-query test"); // missing lang
237    error("run -r Test dir");
238    error("run -p test -i --json dir"); // conflict
239    error("run -p test -U");
240    error("run -p test --update-all");
241    error("run -p test --strictness not");
242    error("run -p test -l rs --debug-query=not");
243    error("run -p test --selector");
244    error("run -p test --threads");
245  }
246
247  #[test]
248  fn test_scan() {
249    ok("scan");
250    ok("scan dir");
251    ok("scan -r test-rule.yml dir");
252    ok("scan -c test-rule.yml dir");
253    ok("scan -c test-rule.yml");
254    ok("scan --report-style short"); // conflict
255    ok("scan dir1 dir2 dir3"); // multiple paths
256    ok("scan -r test.yml --format github");
257    ok("scan --format github");
258    ok("scan --interactive");
259    ok("scan --follow");
260    ok("scan --json --include-metadata");
261    ok("scan -r test.yml -c test.yml --json dir"); // allow registering custom lang
262    ok("scan --globs '*.js'");
263    ok("scan --globs '*.{js, ts}'");
264    ok("scan --globs '*.js' --globs '*.ts'");
265    ok("scan -j 12");
266    ok("scan --threads 12");
267    ok("scan -A 12");
268    ok("scan --after 12");
269    ok("scan --context 1");
270    error("scan -i --json dir"); // conflict
271    error("scan --report-style rich --json dir"); // conflict
272    error("scan -r test.yml --inline-rules '{}'"); // conflict
273    error("scan --format gitlab");
274    error("scan --format github -i");
275    error("scan --format local");
276    error("scan --json=dir"); // wrong json flag
277    error("scan --json= not-pretty"); // wrong json flag
278    error("scan -j");
279    error("scan --include-metadata"); // requires json
280    error("scan --threads");
281  }
282
283  #[test]
284  fn test_test() {
285    ok("test");
286    ok("test -c sgconfig.yml");
287    ok("test --skip-snapshot-tests");
288    ok("test -U");
289    ok("test --update-all");
290    error("test --update-all --skip-snapshot-tests");
291  }
292  #[test]
293  fn test_new() {
294    ok("new");
295    ok("new project");
296    ok("new -c sgconfig.yml rule");
297    ok("new rule -y");
298    ok("new test -y");
299    ok("new util -y");
300    ok("new rule -c sgconfig.yml");
301    error("new --base-dir");
302  }
303
304  #[test]
305  fn test_shell() {
306    ok("completions");
307    ok("completions zsh");
308    ok("completions fish");
309    error("completions not-shell");
310    error("completions --shell fish");
311  }
312}