Repository: ast-grep/ast-grep (language: rust)

Issue:
[refactor] unify configuration discovery for different subcommands
probably by a new global arg --config


Discussion / hints:

Fix patch:
diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs
index 95f9496a9e..48a15bf7c6 100644
--- a/crates/cli/src/config.rs
+++ b/crates/cli/src/config.rs
@@ -90,9 +90,9 @@ impl ProjectConfig {
     read_directory_yaml(self, global_rules, rule_overwrite)
   }
   // do not report error if no sgconfig.yml is found
-  pub fn setup(config_path: Option<PathBuf>) -> Result<Option<Self>> {
+  pub fn setup(config_path: Option<PathBuf>) -> Result<Result<Self>> {
     let Some((project_dir, mut sg_config)) = Self::discover_project(config_path)? else {
-      return Ok(None);
+      return Ok(Err(anyhow::anyhow!(EC::ProjectNotExist)));
     };
     let config = ProjectConfig {
       project_dir,
@@ -102,7 +102,7 @@ impl ProjectConfig {
     };
     // sg_config will not use rule dirs and test configs anymore
     register_custom_language(&config.project_dir, sg_config)?;
-    Ok(Some(config))
+    Ok(Ok(config))
   }
 }
 
diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs
index db38967718..cfbdfe3dc1 100644
--- a/crates/cli/src/lib.rs
+++ b/crates/cli/src/lib.rs
@@ -11,11 +11,13 @@ mod verify;
 
 use anyhow::Result;
 use clap::{Parser, Subcommand};
+use std::path::PathBuf;
 
 use completions::{run_shell_completion, CompletionsArg};
+use config::ProjectConfig;
 use lsp::{run_language_server, LspArg};
 use new::{run_create_new, NewArg};
-use run::{register_custom_language_if_is_run, run_with_pattern, RunArg};
+use run::{run_with_pattern, RunArg};
 use scan::{run_with_config, ScanArg};
 use utils::exit_with_error;
 use verify::{run_test_rule, TestArg};
@@ -39,6 +41,9 @@ Search and Rewrite code at large scale using AST pattern.
 struct App {
   #[clap(subcommand)]
   command: Commands,
+  /// Path to ast-grep root config, default is sgconfig.yml.
+  #[clap(short, long, global = true, value_name = "CONFIG_FILE")]
+  config: Option<PathBuf>,
 }
 
 #[derive(Subcommand)]
@@ -79,10 +84,27 @@ fn try_default_run(args: &[String]) -> Result<Option<RunArg>> {
   }
 }
 
+/// finding project and setup custom language configuration
+fn setup_project_is_possible(args: &[String]) -> Result<ProjectConfig> {
+  let mut config = None;
+  for i in 0..args.len() {
+    if args[i] != "-c" && args[i] != "--config" {
+      continue;
+    }
+    if i + 1 >= args.len() || args[i + 1].starts_with('-') {
+      return Err(anyhow::anyhow!("missing config file after -c"));
+    }
+    let config_file = (&args[i + 1]).into();
+    config = Some(config_file);
+  }
+  ProjectConfig::setup(config)?
+}
+
 // this wrapper function is for testing
 pub fn main_with_args(args: impl Iterator<Item = String>) -> Result<()> {
   let args: Vec<_> = args.collect();
-  register_custom_language_if_is_run(&args)?;
+  let project = setup_project_is_possible(&args);
+  // register_custom_language_if_is_run(&args)?;
   if let Some(arg) = try_default_run(&args)? {
     return run_with_pattern(arg);
   }
@@ -90,10 +112,10 @@ pub fn main_with_args(args: impl Iterator<Item = String>) -> Result<()> {
   let app = App::try_parse_from(args)?;
   match app.command {
     Commands::Run(arg) => run_with_pattern(arg),
-    Commands::Scan(arg) => run_with_config(arg),
-    Commands::Test(arg) => run_test_rule(arg),
-    Commands::New(arg) => run_create_new(arg),
-    Commands::Lsp(arg) => run_language_server(arg),
+    Commands::Scan(arg) => run_with_config(arg, project),
+    Commands::Test(arg) => run_test_rule(arg, project),
+    Commands::New(arg) => run_create_new(arg, project),
+    Commands::Lsp(arg) => run_language_server(arg, project),
     Commands::Completions(arg) => run_shell_completion::<App>(arg),
     Commands::Docs => todo!("todo, generate rule docs based on current config"),
   }
@@ -149,7 +171,7 @@ mod test_cli {
   fn test_no_arg_run() {
     let ret = main_with_args(["sg".to_owned()].into_iter());
     let err = ret.unwrap_err();
-    assert!(err.to_string().contains("sg <COMMAND>"));
+    assert!(err.to_string().contains("sg [OPTIONS] <COMMAND>"));
   }
   #[test]
   fn test_default_subcommand() {
@@ -185,11 +207,11 @@ mod test_cli {
     ok("run -p test --globs '*.js' --globs '*.ts'");
     ok("run -p fubuki -j8");
     ok("run -p test --threads 12");
+    ok("run -p test -l rs -c config.yml"); // global config arg
     error("run test");
     error("run --debug-query test"); // missing lang
     error("run -r Test dir");
     error("run -p test -i --json dir"); // conflict
-    error("run -p test -l rs -c always"); // no color shortcut
     error("run -p test -U");
     error("run -p test --update-all");
     error("run -p test --strictness not");
diff --git a/crates/cli/src/lsp.rs b/crates/cli/src/lsp.rs
index d851628c3a..5d23fb7ecc 100644
--- a/crates/cli/src/lsp.rs
+++ b/crates/cli/src/lsp.rs
@@ -3,21 +3,14 @@ use crate::utils::ErrorContext as EC;
 use anyhow::{Context, Result};
 use ast_grep_lsp::{Backend, LspService, Server};
 use clap::Args;
-use std::path::PathBuf;
 
 #[derive(Args)]
-pub struct LspArg {
-  /// Path to ast-grep root config, default is sgconfig.yml.
-  #[clap(short, long, value_name = "CONFIG_FILE")]
-  config: Option<PathBuf>,
-}
+pub struct LspArg {}
 
-async fn run_language_server_impl(arg: LspArg) -> Result<()> {
+async fn run_language_server_impl(_arg: LspArg, project: Result<ProjectConfig>) -> Result<()> {
   // env_logger::init();
   // TODO: move this error to client
-  let Some(project_config) = ProjectConfig::setup(arg.config.clone())? else {
-    return Err(anyhow::anyhow!(EC::ProjectNotExist));
-  };
+  let project_config = project?;
   let stdin = tokio::io::stdin();
   let stdout = tokio::io::stdout();
   let config_result = project_config.find_rules(Default::default());
@@ -37,12 +30,12 @@ async fn run_language_server_impl(arg: LspArg) -> Result<()> {
   Ok(())
 }
 
-pub fn run_language_server(arg: LspArg) -> Result<()> {
+pub fn run_language_server(arg: LspArg, project: Result<ProjectConfig>) -> Result<()> {
   tokio::runtime::Builder::new_multi_thread()
     .enable_all()
     .build()
     .context(EC::StartLanguageServer)?
-    .block_on(async { run_language_server_impl(arg).await })
+    .block_on(async { run_language_server_impl(arg, project).await })
 }
 
 #[cfg(test)]
@@ -52,7 +45,7 @@ mod test {
   #[test]
   #[ignore = "test lsp later"]
   fn test_lsp_start() {
-    let arg = LspArg { config: None };
-    assert!(run_language_server(arg).is_err())
+    let arg = LspArg {};
+    assert!(run_language_server(arg, Err(anyhow::anyhow!("error"))).is_err())
   }
 }
diff --git a/crates/cli/src/new.rs b/crates/cli/src/new.rs
index 9cb6ef7659..ef906f24d1 100644
--- a/crates/cli/src/new.rs
+++ b/crates/cli/src/new.rs
@@ -29,10 +29,6 @@ pub struct NewArg {
   /// Please see the command description for the what arguments are required.
   #[arg(short, long, global = true)]
   yes: bool,
-
-  /// Path to ast-grep root config, default is sgconfig.yml.
-  #[clap(short, long, global = true, value_name = "CONFIG_FILE")]
-  config: Option<PathBuf>,
 }
 
 fn create_dir(project_dir: &Path, dir: &str) -> Result<PathBuf> {
@@ -148,18 +144,17 @@ impl Display for Entity {
   }
 }
 
-pub fn run_create_new(mut arg: NewArg) -> Result<()> {
-  let project_config = ProjectConfig::setup(arg.config.clone())?;
+pub fn run_create_new(mut arg: NewArg, project: Result<ProjectConfig>) -> Result<()> {
   if let Some(entity) = arg.entity.take() {
-    run_create_entity(entity, arg, project_config)
+    run_create_entity(entity, arg, project)
   } else {
-    ask_entity_type(arg, project_config)
+    ask_entity_type(arg, project)
   }
 }
 
-fn run_create_entity(entity: Entity, arg: NewArg, project: Option<ProjectConfig>) -> Result<()> {
+fn run_create_entity(entity: Entity, arg: NewArg, project: Result<ProjectConfig>) -> Result<()> {
   // check if we are under a project dir
-  if let Some(found) = project {
+  if let Ok(found) = project {
     return do_create_entity(entity, found, arg);
   }
   // check if we creating a project
@@ -181,9 +176,9 @@ fn do_create_entity(entity: Entity, found: ProjectConfig, arg: NewArg) -> Result
   }
 }
 
-fn ask_entity_type(arg: NewArg, project: Option<ProjectConfig>) -> Result<()> {
+fn ask_entity_type(arg: NewArg, project: Result<ProjectConfig>) -> Result<()> {
   // 1. check if we are under a sgconfig.yml
-  if let Some(found) = project {
+  if let Ok(found) = project {
     // 2. ask users what to create if yes
     let entity = arg.ask_entity_type()?;
     do_create_entity(entity, found, arg)
@@ -369,7 +364,6 @@ mod test {
       name: None,
       lang: None,
       yes: true,
-      config: None,
     };
     create_new_project(arg, tempdir)?;
     assert!(tempdir.join("sgconfig.yml").exists());
@@ -377,27 +371,27 @@ mod test {
   }
 
   fn create_rule(temp: &Path) -> Result<()> {
+    let project = ProjectConfig::setup(Some(temp.join("sgconfig.yml")))?;
     let arg = NewArg {
       entity: Some(Entity::Rule),
       name: Some("test-rule".into()),
       lang: Some(SupportLang::Rust.into()),
       yes: true,
-      config: Some(temp.join("sgconfig.yml")),
     };
-    run_create_new(arg)?;
+    run_create_new(arg, project)?;
     assert!(temp.join("rules/test-rule.yml").exists());
     Ok(())
   }
 
   fn create_util(temp: &Path) -> Result<()> {
+    let project = ProjectConfig::setup(Some(temp.join("sgconfig.yml")))?;
     let arg = NewArg {
       entity: Some(Entity::Util),
       name: Some("test-utils".into()),
       lang: Some(SupportLang::Rust.into()),
       yes: true,
-      config: Some(temp.join("sgconfig.yml")),
     };
-    run_create_new(arg)?;
+    run_create_new(arg, project)?;
     assert!(temp.join("utils/test-utils.yml").exists());
     Ok(())
   }
diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs
index b5c3220685..e5d17d41bb 100644
--- a/crates/cli/src/run.rs
+++ b/crates/cli/src/run.rs
@@ -7,7 +7,6 @@ use ast_grep_language::Language;
 use clap::{builder::PossibleValue, Parser, ValueEnum};
 use ignore::WalkParallel;
 
-use crate::config::ProjectConfig;
 use crate::lang::SgLang;
 use crate::print::{ColoredPrinter, Diff, Heading, InteractivePrinter, JSONPrinter, Printer};
 use crate::utils::ErrorContext as EC;
@@ -15,18 +14,6 @@ use crate::utils::{filter_file_pattern, ContextArgs, InputArgs, MatchUnit, Outpu
 use crate::utils::{DebugFormat, FileTrace, RunTrace};
 use crate::utils::{Items, PathWorker, StdInWorker, Worker};
 
-// NOTE: have to register custom lang before clap read arg
-// RunArg has a field of SgLang
-pub fn register_custom_language_if_is_run(args: &[String]) -> Result<()> {
-  let Some(arg) = args.get(1) else {
-    return Ok(());
-  };
-  if arg.starts_with('-') || arg == "run" {
-    ProjectConfig::setup(None)?;
-  }
-  Ok(())
-}
-
 fn lang_help() -> String {
   format!(
     "The language of the pattern. Supported languages are:\n{:?}",
diff --git a/crates/cli/src/scan.rs b/crates/cli/src/scan.rs
index b1a2d79c29..8275fdde23 100644
--- a/crates/cli/src/scan.rs
+++ b/crates/cli/src/scan.rs
@@ -25,10 +25,6 @@ type AstGrep = ast_grep_core::AstGrep<StrDoc<SgLang>>;
 
 #[derive(Args)]
 pub struct ScanArg {
-  /// Path to ast-grep root config, default is sgconfig.yml.
-  #[clap(short, long, value_name = "CONFIG_FILE")]
-  config: Option<PathBuf>,
-
   /// Scan the codebase with the single rule located at the path RULE_FILE.
   ///
   /// It is useful to run single rule without project setup or sgconfig.yml.
@@ -67,15 +63,15 @@ pub struct ScanArg {
   context: ContextArgs,
 }
 
-pub fn run_with_config(arg: ScanArg) -> Result<()> {
+pub fn run_with_config(arg: ScanArg, project: Result<ProjectConfig>) -> Result<()> {
   let context = arg.context.get();
   if let Some(_format) = &arg.format {
     let printer = CloudPrinter::stdout();
-    return run_scan(arg, printer);
+    return run_scan(arg, printer, project);
   }
   if let Some(json) = arg.output.json {
     let printer = JSONPrinter::stdout(json);
-    return run_scan(arg, printer);
+    return run_scan(arg, printer, project);
   }
   let printer = ColoredPrinter::stdout(arg.output.color)
     .style(arg.report_style)
@@ -84,20 +80,23 @@ pub fn run_with_config(arg: ScanArg) -> Result<()> {
   if interactive {
     let from_stdin = arg.input.stdin;
     let printer = InteractivePrinter::new(printer, arg.output.update_all, from_stdin)?;
-    run_scan(arg, printer)
+    run_scan(arg, printer, project)
   } else {
-    run_scan(arg, printer)
+    run_scan(arg, printer, project)
   }
 }
 
-fn run_scan<P: Printer + 'static>(arg: ScanArg, printer: P) -> Result<()> {
-  let project_config = ProjectConfig::setup(arg.config.clone())?;
+fn run_scan<P: Printer + 'static>(
+  arg: ScanArg,
+  printer: P,
+  project: Result<ProjectConfig>,
+) -> Result<()> {
   if arg.input.stdin {
     let worker = ScanWithRule::try_new(arg, printer)?;
     // TODO: report a soft error if rules have different languages
     worker.run_std_in()
   } else {
-    let worker = ScanWithConfig::try_new(arg, printer, project_config)?;
+    let worker = ScanWithConfig::try_new(arg, printer, project)?;
     worker.run_path()
   }
 }
@@ -109,7 +108,7 @@ struct ScanWithConfig<Printer> {
   trace: ScanTrace,
 }
 impl<P: Printer> ScanWithConfig<P> {
-  fn try_new(arg: ScanArg, printer: P, project: Option<ProjectConfig>) -> Result<Self> {
+  fn try_new(arg: ScanArg, printer: P, project: Result<ProjectConfig>) -> Result<Self> {
     let mut rule_trace = RuleTrace::default();
     let configs = if let Some(path) = &arg.rule {
       let rules = read_rule_file(path, None)?;
@@ -119,7 +118,8 @@ impl<P: Printer> ScanWithConfig<P> {
         .with_context(|| EC::ParseRule("INLINE_RULES".into()))?;
       RuleCollection::try_new(rules).context(EC::GlobPattern)?
     } else {
-      let project_config = project.ok_or_else(|| anyhow::anyhow!(EC::ProjectNotExist))?;
+      // NOTE: only query project here since -r does not need project
+      let project_config = project?;
       let overwrite = RuleOverwrite::new(&arg.overwrite)?;
       let (configs, r_stats) = project_config.find_rules(overwrite)?;
       rule_trace = r_stats;
@@ -356,7 +356,6 @@ rule:
 
   fn default_scan_arg() -> ScanArg {
     ScanArg {
-      config: None,
       rule: None,
       inline_rules: None,
       report_style: ReportStyle::Rich,
@@ -403,11 +402,9 @@ rule:
       .write_all("fn test() { Some(123) }".as_bytes())
       .unwrap();
     file.sync_all().unwrap();
-    let arg = ScanArg {
-      config: Some(dir.path().join("sgconfig.yml")),
-      ..default_scan_arg()
-    };
-    assert!(run_with_config(arg).is_ok());
+    let project_config = ProjectConfig::setup(Some(dir.path().join("sgconfig.yml"))).unwrap();
+    let arg = default_scan_arg();
+    assert!(run_with_config(arg, project_config).is_ok());
   }
 
   #[test]
@@ -417,7 +414,7 @@ rule:
       inline_rules: Some(inline_rules),
       ..default_scan_arg()
     };
-    assert!(run_with_config(arg).is_ok());
+    assert!(run_with_config(arg, Err(anyhow::anyhow!("not found"))).is_ok());
   }
 
   #[test]
@@ -428,7 +425,7 @@ rule:
       inline_rules: Some(inline_rules),
       ..default_scan_arg()
     };
-    assert!(run_with_config(arg).is_ok());
+    assert!(run_with_config(arg, Err(anyhow::anyhow!("not found"))).is_ok());
   }
 
   // baseline test for coverage
@@ -439,7 +436,7 @@ rule:
       inline_rules: Some(inline_rules),
       ..default_scan_arg()
     };
-    let err = run_with_config(arg).expect_err("should error");
+    let err = run_with_config(arg, Err(anyhow::anyhow!("not found"))).expect_err("should error");
     assert!(err.is::<EC>());
     assert_eq!(err.to_string(), "Cannot parse rule INLINE_RULES");
   }
diff --git a/crates/cli/src/verify.rs b/crates/cli/src/verify.rs
index 269c475d99..653a0fdf70 100644
--- a/crates/cli/src/verify.rs
+++ b/crates/cli/src/verify.rs
@@ -160,9 +160,6 @@ fn verify_test_case_simple<'a>(
 
 #[derive(Args)]
 pub struct TestArg {
-  /// Path to the root ast-grep config YAML
-  #[clap(short, long)]
-  config: Option<PathBuf>,
   /// the directories to search test YAML files
   #[clap(short, long)]
   test_dir: Option<PathBuf>,
@@ -186,9 +183,8 @@ pub struct TestArg {
   filter: Option<Regex>,
 }
 
-pub fn run_test_rule(arg: TestArg) -> Result<()> {
-  let project = ProjectConfig::setup(arg.config.clone())?
-    .ok_or_else(|| anyhow!(ErrorContext::ProjectNotExist))?;
+pub fn run_test_rule(arg: TestArg, project: Result<ProjectConfig>) -> Result<()> {
+  let project = project?;
   if arg.interactive {
     let reporter = InteractiveReporter {
       output: std::io::stdout(),
@@ -308,7 +304,6 @@ rule:
   #[test]
   fn test_run_verify_error() {
     let arg = TestArg {
-      config: None,
       interactive: false,
       skip_snapshot_tests: true,
       snapshot_dir: None,
@@ -316,7 +311,7 @@ rule:
       update_all: false,
       filter: None,
     };
-    assert!(run_test_rule(arg).is_err());
+    assert!(run_test_rule(arg, Err(anyhow!("error"))).is_err());
   }
   const TRANSFORM_TEXT: &str = "
 transform:


Test patch: