diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c8cee777..4188717f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,21 @@ jobs: run: cargo test --all-features - name: Run test suite with all optimizations run: cargo test --release + # Do the new project test last, because it adds the crate in the current source + # folder, and that shouldn't influence other tests. + - name: Generate, build, and run new language project + run: | + cargo run --bin tree-sitter-stack-graphs --features cli -- init \ + --language-name InitTest \ + --language-id init_test \ + --language-file-extension it \ + --grammar-crate-name tree-sitter-python \ + --grammar-crate-version 0.20.0 \ + --internal \ + --non-interactive + cargo check -p tree-sitter-stack-graphs-init_test --all-features + cargo test -p tree-sitter-stack-graphs-init_test + cargo run -p tree-sitter-stack-graphs-init_test --features cli -- help list-languages: runs-on: ubuntu-latest diff --git a/languages/tree-sitter-stack-graphs-java/Cargo.toml b/languages/tree-sitter-stack-graphs-java/Cargo.toml index ee01dbef8..38c7ee51a 100644 --- a/languages/tree-sitter-stack-graphs-java/Cargo.toml +++ b/languages/tree-sitter-stack-graphs-java/Cargo.toml @@ -35,6 +35,6 @@ harness = false # need to provide own main function to handle running tests [dependencies] anyhow = "1.0" -clap = "3" +clap = { version = "4", features = ["derive"] } tree-sitter-stack-graphs = { version = "~0.6.0", path = "../../tree-sitter-stack-graphs", features=["cli"] } tree-sitter-java = { version = "~0.20.0" } diff --git a/languages/tree-sitter-stack-graphs-typescript/Cargo.toml b/languages/tree-sitter-stack-graphs-typescript/Cargo.toml index 000d6eab5..e78a9f03a 100644 --- a/languages/tree-sitter-stack-graphs-typescript/Cargo.toml +++ b/languages/tree-sitter-stack-graphs-typescript/Cargo.toml @@ -21,17 +21,14 @@ test = false name = "test" path = "rust/test.rs" harness = false -required-features = ["test"] # should be a forced feature, but Cargo does not support those [features] -default = ["test"] # test is enabled by default because we cannot specify it as a forced featured for [[test]] above cli = ["anyhow", "clap", "tree-sitter-stack-graphs/cli"] lsp = ["tree-sitter-stack-graphs/lsp"] -test = ["anyhow", "tree-sitter-stack-graphs/cli"] [dependencies] anyhow = { version = "1.0", optional = true } -clap = { version = "3", optional = true } +clap = { version = "4", optional = true } glob = "0.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -39,3 +36,7 @@ stack-graphs = { version = "0.10", path = "../../stack-graphs" } tree-sitter-stack-graphs = { version = "0.6", path = "../../tree-sitter-stack-graphs" } tree-sitter-typescript = "0.20.2" tsconfig = "0.1.0" + +[dev-dependencies] +anyhow = { version = "1.0" } +tree-sitter-stack-graphs = { version = "0.6", path = "../../tree-sitter-stack-graphs", features = ["cli"] } diff --git a/tree-sitter-stack-graphs/Cargo.toml b/tree-sitter-stack-graphs/Cargo.toml index 922cb996b..1f2ed81db 100644 --- a/tree-sitter-stack-graphs/Cargo.toml +++ b/tree-sitter-stack-graphs/Cargo.toml @@ -52,7 +52,7 @@ lsp = [ anyhow = "1.0" base64 = { version = "0.21", optional = true } capture-it = { version = "0.3", optional = true } -clap = { version = "3", optional = true, features=["derive"] } +clap = { version = "4", optional = true, features=["derive"] } colored = { version = "2.0", optional = true } controlled-option = ">=0.4" crossbeam-channel = { version = "0.5", optional = true } diff --git a/tree-sitter-stack-graphs/src/cli/clean.rs b/tree-sitter-stack-graphs/src/cli/clean.rs index 2a02a6d40..0efacd040 100644 --- a/tree-sitter-stack-graphs/src/cli/clean.rs +++ b/tree-sitter-stack-graphs/src/cli/clean.rs @@ -25,7 +25,6 @@ pub struct CleanArgs { #[clap( value_name = "SOURCE_PATH", value_hint = ValueHint::AnyPath, - parse(from_os_str), )] pub source_paths: Vec, diff --git a/tree-sitter-stack-graphs/src/cli/database.rs b/tree-sitter-stack-graphs/src/cli/database.rs index ecc63b3ac..e573f1c6b 100644 --- a/tree-sitter-stack-graphs/src/cli/database.rs +++ b/tree-sitter-stack-graphs/src/cli/database.rs @@ -18,7 +18,6 @@ pub struct DatabaseArgs { short = 'D', value_name = "DATABASE_PATH", value_hint = ValueHint::AnyPath, - parse(from_os_str), )] pub database: Option, } diff --git a/tree-sitter-stack-graphs/src/cli/index.rs b/tree-sitter-stack-graphs/src/cli/index.rs index 10f5175e0..5647a891f 100644 --- a/tree-sitter-stack-graphs/src/cli/index.rs +++ b/tree-sitter-stack-graphs/src/cli/index.rs @@ -27,10 +27,10 @@ use crate::NoCancellation; use super::util::duration_from_seconds_str; use super::util::iter_files_and_directories; -use super::util::path_exists; use super::util::sha1; use super::util::wait_for_input; use super::util::ConsoleLogger; +use super::util::ExistingPathBufValueParser; use super::util::FileLogger; use super::util::Logger; @@ -42,8 +42,7 @@ pub struct IndexArgs { value_name = "SOURCE_PATH", required = true, value_hint = ValueHint::AnyPath, - parse(from_os_str), - validator_os = path_exists, + value_parser = ExistingPathBufValueParser, )] pub source_paths: Vec, @@ -52,8 +51,7 @@ pub struct IndexArgs { long, value_name = "SOURCE_PATH", value_hint = ValueHint::AnyPath, - parse(from_os_str), - validator_os = path_exists, + value_parser = ExistingPathBufValueParser, )] pub continue_from: Option, @@ -72,7 +70,7 @@ pub struct IndexArgs { #[clap( long, value_name = "SECONDS", - parse(try_from_str = duration_from_seconds_str), + value_parser = duration_from_seconds_str, )] pub max_file_time: Option, diff --git a/tree-sitter-stack-graphs/src/cli/init.rs b/tree-sitter-stack-graphs/src/cli/init.rs index 6dbde3aa7..03b0d3890 100644 --- a/tree-sitter-stack-graphs/src/cli/init.rs +++ b/tree-sitter-stack-graphs/src/cli/init.rs @@ -6,6 +6,11 @@ // ------------------------------------------------------------------------------------------------ use anyhow::anyhow; +use clap::builder::StringValueParser; +use clap::builder::TypedValueParser; +use clap::error::ContextKind; +use clap::error::ContextValue; +use clap::error::ErrorKind; use clap::Args; use clap::ValueHint; use dialoguer::Input; @@ -22,10 +27,7 @@ use std::path::Path; use std::path::PathBuf; use time::OffsetDateTime; -use self::license::lookup_license; -use self::license::DEFAULT_LICENSES; -use self::license::NO_LICENSE; -use self::license::OTHER_LICENSE; +use self::license::*; mod license; @@ -42,26 +44,149 @@ static VALID_DEPENDENCY_VERSION: Lazy = #[derive(Args)] pub struct InitArgs { /// Project directory path. - #[clap(value_name = "PROJECT_PATH", required = false, default_value = ".", value_hint = ValueHint::AnyPath, parse(from_os_str))] + #[clap(value_name = "PROJECT_PATH", required = false, default_value = ".", value_hint = ValueHint::AnyPath)] pub project_path: PathBuf, + + /// Disable console interaction. All input values must be provided through the appropriate options. + #[clap( + long, + requires("language_name"), + requires("language_id"), + requires("language_file_extension"), + // crate_name is optional + // crate_version is optional + // author is optional + // license is optional + // grammar_crate_name + requires("grammar_crate_version") + )] + pub non_interactive: bool, + + /// Name of the target language. + #[clap(long)] + pub language_name: Option, + + /// Identifier for the target language. + #[clap(long, value_parser = RegexValidator(&VALID_CRATE_NAME))] + pub language_id: Option, + + /// File extension for files written in the target language. + #[clap(long, value_parser = RegexValidator(&VALID_CRATE_NAME))] + pub language_file_extension: Option, + + /// Name for the generated crate. Default: tree-sitter-stack-graphs-LANGUAGE_ID + #[clap(long, value_parser = RegexValidator(&VALID_CRATE_NAME))] + pub crate_name: Option, + + /// Version for the generated crate. Default: 0.1.0 + #[clap(long, value_parser = RegexValidator(&VALID_CRATE_VERSION))] + pub crate_version: Option, + + /// Author of the generated crate, in NAME format. + #[clap(long)] + pub author: Option, + + /// SPDX identifier for the license of the generated crate. Examples: MIT, Apache-2.0 + #[clap(long)] + pub license: Option, + + /// The crate name of the Tree-sitter grammar for the target language. + #[clap(long, value_parser = RegexValidator(&VALID_CRATE_NAME))] + pub grammar_crate_name: Option, + + /// The crate version of the Tree-sitter grammar for the target language. + #[clap(long, value_parser = RegexValidator(&VALID_DEPENDENCY_VERSION))] + pub grammar_crate_version: Option, + + /// Generate a project that is meant to be part of the official stack-graphs repository. + /// Instead of the project path, the repository root must be specified. The project path, + /// license, and dependencies will follow the repository conventions. + #[clap(long, conflicts_with("crate_name"), conflicts_with("license"))] + pub internal: bool, } impl InitArgs { - pub fn new(project_path: PathBuf) -> Self { - Self { project_path } + pub fn run(self) -> anyhow::Result<()> { + if self.internal { + Self::check_repo_dir(&self.project_path)?; + } else { + Self::check_project_dir(&self.project_path)?; + } + let license = if self.internal { + Some(INTERNAL_LICENSE) + } else { + self.license.map(|spdx| { + DEFAULT_LICENSES + .iter() + .find(|l| l.0 == spdx) + .cloned() + .unwrap_or_else(|| new_license(spdx.into())) + }) + }; + let mut config = ProjectSettings { + language_name: self.language_name.unwrap_or_default(), + language_id: self.language_id.unwrap_or_default(), + language_file_extension: self.language_file_extension.unwrap_or_default(), + crate_name: self.crate_name, + crate_version: self.crate_version, + author: self.author, + license, + grammar_crate_name: self.grammar_crate_name, + grammar_crate_version: self.grammar_crate_version.unwrap_or_default(), + internal: self.internal, + }; + if !self.non_interactive && !Self::interactive(&self.project_path, &mut config)? { + return Ok(()); + } + let project_path = Self::effective_project_path(&self.project_path, &config); + Self::check_project_dir(&project_path)?; + config.generate_files_into(&project_path)?; + Ok(()) } - pub fn run(self) -> anyhow::Result<()> { - self.check_project_dir()?; - let mut config = ProjectSettings::default(); + fn check_project_dir(project_path: &Path) -> anyhow::Result<()> { + if !project_path.exists() { + return Ok(()); + } + if !project_path.is_dir() { + return Err(anyhow!("Project path exists but is not a directory")); + } + if fs::read_dir(&project_path)?.next().is_some() { + return Err(anyhow!("Project directory exists but is not empty")); + } + Ok(()) + } + + fn check_repo_dir(project_path: &Path) -> anyhow::Result<()> { + if !project_path.exists() { + return Ok(()); + } + if !project_path.is_dir() { + return Err(anyhow!("Repository path exists but is not a directory")); + } + if !project_path.join("Cargo.toml").exists() { + return Err(anyhow!( + "Repository directory exists but is missing Cargo.toml" + )); + } + Ok(()) + } + + fn effective_project_path(project_path: &Path, config: &ProjectSettings) -> PathBuf { + if config.internal { + project_path.join("languages").join(config.crate_name()) + } else { + project_path.to_path_buf() + } + } + + fn interactive(project_path: &Path, config: &mut ProjectSettings) -> anyhow::Result { loop { - config.read_from_console()?; + Self::read_from_console(config)?; + let project_path = Self::effective_project_path(project_path, config); println!(); println!("=== Review project settings ==="); - println!( - "Project directory : {}", - self.project_path.display() - ); + println!("Project directory : {}", project_path.display()); println!("{}", config); let action = Select::new() .items(&["Generate", "Edit", "Cancel"]) @@ -69,64 +194,34 @@ impl InitArgs { .interact()?; match action { 0 => { - config.generate_files_into(&self.project_path)?; println!( "Project created. See {} to get started!", - self.project_path.join("README.md").display(), + project_path.join("README.md").display(), ); - break; + return Ok(true); } 1 => { continue; } 2 => { println!("No project created."); - break; + return Ok(false); } _ => unreachable!(), } } - Ok(()) } - fn check_project_dir(&self) -> anyhow::Result<()> { - if !self.project_path.exists() { - return Ok(()); - } - if !self.project_path.is_dir() { - return Err(anyhow!("Project path exists but is not a directory")); - } - if fs::read_dir(&self.project_path)?.next().is_some() { - return Err(anyhow!("Project directory exists but is not empty")); - } - Ok(()) - } -} - -#[derive(Default)] -struct ProjectSettings { - language_name: String, - language_id: String, - language_file_extension: String, - crate_name: String, - crate_version: String, - author: String, - license: String, - grammar_crate_name: String, - grammar_crate_version: String, -} - -impl ProjectSettings { - fn read_from_console(&mut self) -> anyhow::Result<()> { + fn read_from_console(config: &mut ProjectSettings) -> anyhow::Result<()> { printdoc! {r#" Give the name of the programming language the stack graphs definitions in this project will target. This name will appear in the project description and comments. "# }; - self.language_name = Input::new() + config.language_name = Input::new() .with_prompt("Language name") - .with_initial_text(&self.language_name) + .with_initial_text(&config.language_name) .interact_text()?; printdoc! {r#" @@ -135,17 +230,17 @@ impl ProjectSettings { name and suggested dependencies. May only contain letters, numbers, dashes, and underscores. "#, - self.language_name, + config.language_name, }; - let default_language_id = self.language_name.to_lowercase(); - self.language_id = Input::new() + let default_language_id = config.language_name.to_lowercase(); + config.language_id = Input::new() .with_prompt("Language identifier") - .with_initial_text(if self.language_id.is_empty() { + .with_initial_text(if config.language_id.is_empty() { &default_language_id } else { - &self.language_id + &config.language_id }) - .validate_with(regex_validator(&VALID_CRATE_NAME)) + .validate_with(RegexValidator(&VALID_CRATE_NAME)) .interact_text()?; printdoc! {r#" @@ -153,92 +248,104 @@ impl ProjectSettings { Give the file extension for {}. This file extension will be used for stub files in the project. May only contain letters, numbers, dashes, and underscores. "#, - self.language_name, + config.language_name, }; - let default_language_file_extension = if self.language_file_extension.is_empty() { - &self.language_id + let default_language_file_extension = if config.language_file_extension.is_empty() { + &config.language_id } else { - &self.language_file_extension + &config.language_file_extension }; - self.language_file_extension = Input::new() + config.language_file_extension = Input::new() .with_prompt("Language file extension") .with_initial_text(default_language_file_extension) - .validate_with(regex_validator(&VALID_CRATE_NAME)) + .validate_with(RegexValidator(&VALID_CRATE_NAME)) .interact_text()?; printdoc! {r#" Give the crate name for this project. May only contain letters, numbers, dashes, and underscores. - "# + "# }; - let default_crate_name = "tree-sitter-stack-graphs-".to_string() + &self.language_id; - self.crate_name = Input::new() - .with_prompt("Package name") - .with_initial_text(if self.crate_name.is_empty() { - &default_crate_name - } else { - &self.crate_name - }) - .validate_with(regex_validator(&VALID_CRATE_NAME)) - .interact_text()?; + config.crate_name = Some( + Input::new() + .with_prompt("Crate name") + .with_initial_text(config.crate_name()) + .validate_with(RegexValidator(&VALID_CRATE_NAME)) + .interact_text()?, + ); printdoc! {r#" Give the crate version for this project. Must be in MAJOR.MINOR.PATCH format. "# }; - self.crate_version = Input::new() - .with_prompt("Package version") - .with_initial_text(if self.crate_version.is_empty() { - "0.1.0" - } else { - &self.crate_version - }) - .validate_with(regex_validator(&VALID_CRATE_VERSION)) - .interact_text()?; + config.crate_version = Some( + Input::new() + .with_prompt("Crate version") + .with_initial_text(config.crate_version()) + .validate_with(RegexValidator(&VALID_CRATE_VERSION)) + .interact_text()?, + ); printdoc! {r#" Give the project author in the format NAME . Leave empty to omit. "# }; - self.author = Input::new() + let author: String = Input::new() .with_prompt("Author") - .with_initial_text(&self.author) + .with_initial_text(config.author.clone().unwrap_or_default()) .allow_empty(true) .interact_text()?; - - printdoc! {r#" - - Give the project license as an SPDX expression. Choose "Other" to input - manually. Press ESC to deselect. See https://spdx.org/licenses/ for possible - license identifiers. - "# - }; - let selected = lookup_license(&self.license); - let (other, other_default) = if selected == OTHER_LICENSE { - (format!("Other ({})", self.license), self.license.as_ref()) + config.author = if author.is_empty() { + None } else { - ("Other".to_string(), "") + Some(author) }; - let selected = Select::new() - .with_prompt("License") - .items(&DEFAULT_LICENSES.iter().map(|l| l.0).collect::>()) - .item(&other) - .item("None") - .default(selected) - .interact()?; - self.license = if selected == NO_LICENSE { - "".to_string() - } else if selected == OTHER_LICENSE { - Input::new() - .with_prompt("Other license") - .with_initial_text(other_default) - .allow_empty(true) - .interact_text()? + + config.license = if config.internal { + Some(INTERNAL_LICENSE) } else { - DEFAULT_LICENSES[selected].0.to_string() + printdoc! {r#" + + Give the project license as an SPDX expression. Choose "Other" to input + manually. Press ESC to deselect. See https://spdx.org/licenses/ for possible + license identifiers. + "# + }; + let (selected, other, other_default) = if let Some(license) = &config.license { + if let Some(selected) = DEFAULT_LICENSES.iter().position(|l| l.0 == license.0) { + (selected, "Other".to_string(), "") + } else { + ( + OTHER_LICENSE, + format!("Other ({})", license.0), + license.0.as_ref(), + ) + } + } else { + (NO_LICENSE, "Other".to_string(), "") + }; + let selected = Select::new() + .with_prompt("License") + .items(&DEFAULT_LICENSES.iter().map(|l| &l.0).collect::>()) + .item(&other) + .item("None") + .default(selected) + .interact()?; + if selected == NO_LICENSE { + None + } else if selected == OTHER_LICENSE { + let spdx: String = Input::new() + .with_prompt("Other license") + .with_initial_text(other_default) + .allow_empty(true) + .interact_text()?; + Some(new_license(spdx.into())) + } else { + Some(DEFAULT_LICENSES[selected].clone()) + } }; printdoc! {r#" @@ -247,15 +354,12 @@ impl ProjectSettings { parsing. May only contain letters, numbers, dashes, and underscores. "# }; - let default_grammar_crate_name = "tree-sitter-".to_string() + &self.language_id; - self.grammar_crate_name = Input::new() - .with_prompt("Grammar crate name") - .with_initial_text(if self.grammar_crate_name.is_empty() { - &default_grammar_crate_name - } else { - &self.grammar_crate_name - }) - .interact_text()?; + config.grammar_crate_name = Some( + Input::new() + .with_prompt("Grammar crate name") + .with_initial_text(config.grammar_crate_name()) + .interact_text()?, + ); printdoc! {r##" @@ -263,31 +367,65 @@ impl ProjectSettings { dependency version. For example, 1.2, ^0.4.1, or ~3.2.4. See https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html. "##, - self.grammar_crate_name, + config.grammar_crate_name(), }; - self.grammar_crate_version = Input::new() + config.grammar_crate_version = Input::new() .with_prompt("Grammar crate version") - .with_initial_text(&self.grammar_crate_version) - .validate_with(regex_validator(&VALID_DEPENDENCY_VERSION)) + .with_initial_text(&config.grammar_crate_version) + .validate_with(RegexValidator(&VALID_DEPENDENCY_VERSION)) .interact_text()?; Ok(()) } +} + +#[derive(Default)] +struct ProjectSettings<'a> { + language_name: String, + language_id: String, + language_file_extension: String, + crate_name: Option, + crate_version: Option, + author: Option, + license: Option>, + grammar_crate_name: Option, + grammar_crate_version: String, + internal: bool, +} + +impl<'a> ProjectSettings<'a> {} + +impl ProjectSettings<'_> { + fn crate_name(&self) -> String { + self.crate_name + .clone() + .unwrap_or_else(|| format!("tree-sitter-stack-graphs-{}", self.language_id)) + } + + fn crate_version(&self) -> String { + self.crate_version + .clone() + .unwrap_or_else(|| "0.1.0".to_string()) + } fn package_name(&self) -> String { - self.crate_name.replace("-", "_") + self.crate_name().replace("-", "_") + } + + fn grammar_crate_name(&self) -> String { + self.grammar_crate_name + .clone() + .unwrap_or_else(|| format!("tree-sitter-{}", self.language_id)) } fn grammar_package_name(&self) -> String { - self.grammar_crate_name.replace("-", "_") + self.grammar_crate_name().replace("-", "_") } fn license_author(&self) -> String { - if self.author.is_empty() { - format!("the {} authors", self.crate_name) - } else { - self.author.clone() - } + self.author + .clone() + .unwrap_or_else(|| format!("the {} authors", self.crate_name())) } fn generate_files_into(&self, project_path: &Path) -> anyhow::Result<()> { @@ -413,13 +551,13 @@ impl ProjectSettings { Go to https://crates.io/crates/tree-sitter-stack-graphs for links to examples and documentation. "####, self.language_name, - self.language_name, self.grammar_crate_name, - self.grammar_crate_name, self.grammar_crate_name, - self.crate_name, self.crate_version, - self.crate_name, - self.crate_name, - self.crate_name, - self.crate_name, + self.language_name, self.grammar_crate_name(), + self.grammar_crate_name(), self.grammar_crate_name(), + self.crate_name(), self.crate_version(), + self.crate_name(), + self.crate_name(), + self.crate_name(), + self.crate_name(), self.language_file_extension, }?; Ok(()) @@ -435,33 +573,27 @@ impl ProjectSettings { The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). "####, - self.crate_name, + self.crate_name(), }?; Ok(()) } fn generate_license(&self, project_path: &Path) -> std::io::Result<()> { - match lookup_license(&self.license) { - NO_LICENSE | OTHER_LICENSE => {} - selected => { - let mut file = File::create(project_path.join("LICENSE"))?; - let year = OffsetDateTime::now_utc().year(); - let author = self.license_author(); - (DEFAULT_LICENSES[selected].2)(&mut file, year, &author)?; - } - }; + if let Some(license) = &self.license { + let mut file = File::create(project_path.join("LICENSE"))?; + let year = OffsetDateTime::now_utc().year(); + let author = self.license_author(); + (license.2)(&mut file, year, &author)?; + } Ok(()) } fn write_license_header(&self, file: &mut File, prefix: &str) -> std::io::Result<()> { - match lookup_license(&self.license) { - NO_LICENSE | OTHER_LICENSE => {} - selected => { - let year = OffsetDateTime::now_utc().year(); - let author = self.license_author(); - (DEFAULT_LICENSES[selected].1)(file, year, &author, prefix)?; - } - }; + if let Some(license) = &self.license { + let year = OffsetDateTime::now_utc().year(); + let author = self.license_author(); + (license.1)(file, year, &author, prefix)?; + } Ok(()) } @@ -475,17 +607,35 @@ impl ProjectSettings { readme = "README.md" keywords = ["tree-sitter", "stack-graphs", "{}"] "#, - self.crate_name, - self.crate_version, - self.language_name, self.grammar_crate_name, + self.crate_name(), + self.crate_version(), + self.language_name, self.grammar_crate_name(), self.language_id }?; - if !self.author.is_empty() { - writeln!(file, r#"authors = ["{}"]"#, self.author)?; + if self.internal || self.author.is_some() { + writeln!(file, r#"authors = ["#)?; + if self.internal { + writeln!( + file, + r#" "GitHub ","# + )?; + } + if let Some(author) = &self.author { + writeln!(file, r#" "{}","#, author)?; + } + writeln!(file, r#"]"#)?; } - if !self.license.is_empty() { - writeln!(file, r#"license = "{}""#, self.license)?; + if let Some(license) = &self.license { + writeln!(file, r#"license = "{}""#, license.0)?; } + let tssg_dep_fields = if self.internal { + format!( + r#"version = "{}", path = "../../tree-sitter-stack-graphs""#, + TSSG_VERSION + ) + } else { + format!(r#"version = "{}""#, TSSG_VERSION) + }; writedoc! {file, r#" edition = "2018" @@ -502,22 +652,24 @@ impl ProjectSettings { name = "test" path = "rust/test.rs" harness = false - required-features = ["test"] # should be a forced feature, but Cargo does not support those [features] - default = ["test"] # test is enabled by default because we cannot specify it as a forced featured for [[test]] above cli = ["anyhow", "clap", "tree-sitter-stack-graphs/cli"] - test = ["anyhow", "tree-sitter-stack-graphs/cli"] [dependencies] anyhow = {{ version = "1.0", optional = true }} - clap = {{ version = "3", optional = true }} - tree-sitter-stack-graphs = "{}" + clap = {{ version = "4", optional = true, features = ["derive"] }} + tree-sitter-stack-graphs = {{ {} }} {} = "{}" + + [dev-dependencies] + anyhow = "1.0" + tree-sitter-stack-graphs = {{ {}, features = ["cli"] }} "#, - self.crate_name, - TSSG_VERSION, - self.grammar_crate_name, self.grammar_crate_version, + self.crate_name(), + tssg_dep_fields, + self.grammar_crate_name(), self.grammar_crate_version, + tssg_dep_fields, }?; Ok(()) } @@ -528,6 +680,7 @@ impl ProjectSettings { writedoc! {file, r#" use anyhow::anyhow; use clap::Parser; + use tree_sitter_stack_graphs::cli::database::default_user_database_path_for_crate; use tree_sitter_stack_graphs::cli::provided_languages::Subcommands; use tree_sitter_stack_graphs::NoCancellation; @@ -541,7 +694,8 @@ impl ProjectSettings { }} }}; let cli = Cli::parse(); - cli.subcommand.run(vec![lc]) + let default_db_path = default_user_database_path_for_crate(env!("CARGO_PKG_NAME"))?; + cli.subcommand.run(default_db_path, vec![lc]) }} #[derive(Parser)] @@ -718,7 +872,7 @@ impl ProjectSettings { } } -impl std::fmt::Display for ProjectSettings { +impl std::fmt::Display for ProjectSettings<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { writedoc! {f, r##" Language name : {} @@ -735,26 +889,57 @@ impl std::fmt::Display for ProjectSettings { self.language_name, self.language_id, self.language_file_extension, - self.crate_name, - self.crate_version, - self.author, - self.license, - self.grammar_crate_name, + self.crate_name(), + self.crate_version(), + self.author.clone().unwrap_or_default(), + self.license.as_ref().map_or("", |l| &l.0), + self.grammar_crate_name(), self.grammar_crate_version, } } } -fn regex_validator<'a>(regex: &'a Regex) -> impl Validator + 'a { - struct RegexValidator<'a>(&'a Regex); - impl Validator for RegexValidator<'_> { - type Err = String; - fn validate(&mut self, input: &String) -> Result<(), Self::Err> { - if !self.0.is_match(input) { - return Err(format!("Invalid input value. Must match {}", self.0)); - } - Ok(()) +#[derive(Clone)] +struct RegexValidator<'a>(&'a Regex); + +impl TypedValueParser for RegexValidator<'static> { + type Value = String; + fn parse_ref( + &self, + cmd: &clap::Command, + arg: Option<&clap::Arg>, + value: &std::ffi::OsStr, + ) -> Result { + let inner = StringValueParser::new(); + let value = inner.parse_ref(cmd, arg, value)?; + + if self.0.is_match(&value) { + return Ok(value); + } + + let mut err = clap::Error::new(ErrorKind::ValueValidation); + if let Some(arg) = arg { + err.insert( + ContextKind::InvalidArg, + ContextValue::String(arg.to_string()), + ); } + err.insert(ContextKind::InvalidValue, ContextValue::String(value)); + err.insert( + ContextKind::Custom, + ContextValue::String(format!("value must match {}", self.0)), + ); + + Err(err) + } +} + +impl Validator for RegexValidator<'_> { + type Err = String; + fn validate(&mut self, input: &String) -> Result<(), Self::Err> { + if !self.0.is_match(input) { + return Err(format!("Invalid input value. Must match {}", self.0)); + } + Ok(()) } - RegexValidator(regex) } diff --git a/tree-sitter-stack-graphs/src/cli/init/license.rs b/tree-sitter-stack-graphs/src/cli/init/license.rs index a4aa82441..654af4d53 100644 --- a/tree-sitter-stack-graphs/src/cli/init/license.rs +++ b/tree-sitter-stack-graphs/src/cli/init/license.rs @@ -6,34 +6,44 @@ // ------------------------------------------------------------------------------------------------ use indoc::writedoc; +use std::borrow::Cow; use std::fs::File; use std::io::Write; +pub type License<'a> = (Cow<'a, str>, WriteLicenseHeader, WriteLicenseText); pub type WriteLicenseHeader = fn(&mut File, i32, &str, &str) -> std::io::Result<()>; pub type WriteLicenseText = fn(&mut File, i32, &str) -> std::io::Result<()>; -pub const DEFAULT_LICENSES: &[(&str, WriteLicenseHeader, WriteLicenseText)] = &[ - ("APACHE-2.0", write_apache2_header, write_apache2_text), - ("BSD-2-Clause", write_empty_header, write_bsd2_text), - ("BSD-3-Clause", write_empty_header, write_bsd3_text), - ("ISC", write_empty_header, write_isc_text), - ("MIT", write_empty_header, write_mit_text), +pub const DEFAULT_LICENSES: &[License] = &[ + ( + Cow::Borrowed("Apache-2.0"), + write_apache2_header, + write_apache2_text, + ), + ( + Cow::Borrowed("BSD-2-Clause"), + write_empty_header, + write_bsd2_text, + ), + ( + Cow::Borrowed("BSD-3-Clause"), + write_empty_header, + write_bsd3_text, + ), + (Cow::Borrowed("ISC"), write_empty_header, write_isc_text), + (Cow::Borrowed("MIT"), write_empty_header, write_mit_text), ]; pub const OTHER_LICENSE: usize = DEFAULT_LICENSES.len(); pub const NO_LICENSE: usize = OTHER_LICENSE + 1; -// Return an index into DEFAULT_LICENSES, OTHER_LICENSE, or NO_LICENSE. -pub fn lookup_license(name: &str) -> usize { - DEFAULT_LICENSES - .iter() - .position(|l| l.0 == name) - .unwrap_or_else(|| { - if name.is_empty() { - NO_LICENSE - } else { - OTHER_LICENSE - } - }) +pub const INTERNAL_LICENSE: License = ( + Cow::Borrowed("MIT OR Apache-2.0"), + write_internal_header, + write_empty_text, +); + +pub fn new_license<'a>(spdx: Cow<'a, str>) -> License<'a> { + (spdx.into(), write_empty_header, write_empty_text) } fn write_empty_header( @@ -45,6 +55,31 @@ fn write_empty_header( Ok(()) } +fn write_empty_text(_file: &mut File, _year: i32, _prefix: &str) -> std::io::Result<()> { + Ok(()) +} + +pub fn write_internal_header( + file: &mut File, + year: i32, + _author: &str, + prefix: &str, +) -> std::io::Result<()> { + writedoc! {file, r####" + {}-*- coding: utf-8 -*- + {}------------------------------------------------------------------------------------------------ + {}Copyright © {}, stack-graphs authors. + {}Licensed under either of Apache License, Version 2.0, or MIT license, at your option. + {}Please see the LICENSE-APACHE or LICENSE-MIT files in this distribution for license details. + {}------------------------------------------------------------------------------------------------ + + "####, + prefix, prefix, + prefix, year, + prefix, prefix, prefix, + } +} + fn write_apache2_header( file: &mut File, year: i32, diff --git a/tree-sitter-stack-graphs/src/cli/lsp.rs b/tree-sitter-stack-graphs/src/cli/lsp.rs index f663bbde2..3b80ff420 100644 --- a/tree-sitter-stack-graphs/src/cli/lsp.rs +++ b/tree-sitter-stack-graphs/src/cli/lsp.rs @@ -49,7 +49,7 @@ pub struct LspArgs { #[clap( long, value_name = "SECONDS", - parse(try_from_str = duration_from_seconds_str), + value_parser = duration_from_seconds_str, )] pub max_folder_index_time: Option, @@ -57,7 +57,7 @@ pub struct LspArgs { #[clap( long, value_name = "SECONDS", - parse(try_from_str = duration_from_seconds_str), + value_parser = duration_from_seconds_str, )] pub max_file_index_time: Option, @@ -65,7 +65,7 @@ pub struct LspArgs { #[clap( long, value_name = "MILLISECONDS", - parse(try_from_str = duration_from_milliseconds_str), + value_parser = duration_from_milliseconds_str, )] pub max_query_time: Option, } diff --git a/tree-sitter-stack-graphs/src/cli/parse.rs b/tree-sitter-stack-graphs/src/cli/parse.rs index 216324ca9..bad6eca28 100644 --- a/tree-sitter-stack-graphs/src/cli/parse.rs +++ b/tree-sitter-stack-graphs/src/cli/parse.rs @@ -13,17 +13,23 @@ use std::path::PathBuf; use tree_sitter::Parser; use tree_sitter_graph::parse_error::ParseError; -use crate::cli::util::path_exists; use crate::loader::FileReader; use crate::loader::Loader; use crate::util::DisplayParseErrorsPretty; use crate::BuildError; +use super::util::ExistingPathBufValueParser; + /// Parse file #[derive(Args)] pub struct ParseArgs { /// Input file path. - #[clap(value_name = "FILE_PATH", required = true, value_hint = ValueHint::AnyPath, parse(from_os_str), validator_os = path_exists)] + #[clap( + value_name = "FILE_PATH", + required = true, + value_hint = ValueHint::AnyPath, + value_parser = ExistingPathBufValueParser, + )] pub file_path: PathBuf, } diff --git a/tree-sitter-stack-graphs/src/cli/query.rs b/tree-sitter-stack-graphs/src/cli/query.rs index 4945d975e..84f5f45cb 100644 --- a/tree-sitter-stack-graphs/src/cli/query.rs +++ b/tree-sitter-stack-graphs/src/cli/query.rs @@ -70,7 +70,7 @@ pub struct Definition { value_name = "SOURCE_POSITION", required = true, value_hint = ValueHint::AnyPath, - parse(try_from_str), + value_parser, )] pub references: Vec, } diff --git a/tree-sitter-stack-graphs/src/cli/status.rs b/tree-sitter-stack-graphs/src/cli/status.rs index 33685274c..2a52c797d 100644 --- a/tree-sitter-stack-graphs/src/cli/status.rs +++ b/tree-sitter-stack-graphs/src/cli/status.rs @@ -30,7 +30,6 @@ pub struct StatusArgs { #[clap( value_name = "SOURCE_PATH", value_hint = ValueHint::AnyPath, - parse(from_os_str), )] pub source_paths: Vec, diff --git a/tree-sitter-stack-graphs/src/cli/test.rs b/tree-sitter-stack-graphs/src/cli/test.rs index 656452f18..4947a9f94 100644 --- a/tree-sitter-stack-graphs/src/cli/test.rs +++ b/tree-sitter-stack-graphs/src/cli/test.rs @@ -6,8 +6,8 @@ // ------------------------------------------------------------------------------------------------ use anyhow::anyhow; -use clap::ArgEnum; use clap::Args; +use clap::ValueEnum; use clap::ValueHint; use itertools::Itertools; use stack_graphs::arena::Handle; @@ -21,7 +21,7 @@ use std::path::Path; use std::path::PathBuf; use tree_sitter_graph::Variables; -use crate::cli::util::path_exists; +use crate::cli::util::ExistingPathBufValueParser; use crate::cli::util::PathSpec; use crate::loader::FileReader; use crate::loader::LanguageConfiguration; @@ -61,8 +61,7 @@ pub struct TestArgs { value_name = "TEST_PATH", required = true, value_hint = ValueHint::AnyPath, - parse(from_os_str), - validator_os = path_exists + value_parser = ExistingPathBufValueParser, )] pub test_paths: Vec, @@ -85,8 +84,7 @@ pub struct TestArgs { long, short = 'G', value_name = "PATH_SPEC", - min_values = 0, - max_values = 1, + num_args = 0..1, require_equals = true, default_missing_value = "%n.graph.json" )] @@ -99,8 +97,7 @@ pub struct TestArgs { long, short = 'P', value_name = "PATH_SPEC", - min_values = 0, - max_values = 1, + num_args = 0..1, require_equals = true, default_missing_value = "%n.paths.json" )] @@ -113,8 +110,7 @@ pub struct TestArgs { long, short = 'V', value_name = "PATH_SPEC", - min_values = 0, - max_values = 1, + num_args = 0..1, require_equals = true, default_missing_value = "%n.html" )] @@ -123,7 +119,7 @@ pub struct TestArgs { /// Controls when graphs, paths, or visualization are saved. #[clap( long, - arg_enum, + value_enum, default_value_t = OutputMode::OnFailure, )] pub output_mode: OutputMode, @@ -134,7 +130,7 @@ pub struct TestArgs { } /// Flag to control output -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ArgEnum)] +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] pub enum OutputMode { Always, OnFailure, diff --git a/tree-sitter-stack-graphs/src/cli/util.rs b/tree-sitter-stack-graphs/src/cli/util.rs index be9ce8ada..32d24c066 100644 --- a/tree-sitter-stack-graphs/src/cli/util.rs +++ b/tree-sitter-stack-graphs/src/cli/util.rs @@ -7,6 +7,11 @@ use anyhow::anyhow; use base64::Engine; +use clap::builder::PathBufValueParser; +use clap::builder::TypedValueParser; +use clap::error::ContextKind; +use clap::error::ContextValue; +use clap::error::ErrorKind; use colored::Colorize; use lsp_positions::Span; use sha1::Digest; @@ -25,16 +30,48 @@ use std::time::Duration; use std::time::Instant; use walkdir::WalkDir; -pub fn path_exists(path: &OsStr) -> anyhow::Result { - let path = PathBuf::from(path); - if !path.exists() { - return Err(anyhow!("path does not exist")); +#[derive(Clone)] +pub(crate) struct ExistingPathBufValueParser; + +impl TypedValueParser for ExistingPathBufValueParser { + type Value = PathBuf; + + fn parse_ref( + &self, + cmd: &clap::Command, + arg: Option<&clap::Arg>, + value: &std::ffi::OsStr, + ) -> Result { + let inner = PathBufValueParser::new(); + let value = inner.parse_ref(cmd, arg, value)?; + + if value.exists() { + return Ok(value); + } + + let mut err = clap::Error::new(ErrorKind::ValueValidation); + if let Some(arg) = arg { + err.insert( + ContextKind::InvalidArg, + ContextValue::String(arg.to_string()), + ); + } + err.insert( + ContextKind::InvalidValue, + ContextValue::String(value.to_string_lossy().to_string()), + ); + err.insert( + ContextKind::Custom, + ContextValue::String("path does not exist".to_string()), + ); + + Err(err) } - Ok(path) } /// A path specification that can be formatted into a path based on a root and path /// contained in that root. +#[derive(Clone)] pub struct PathSpec { spec: String, }