diff --git a/.vscode/launch.json b/.vscode/launch.json index 787b44f..aaf0d86 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,5 +3,20 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", - "configurations": [] + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Cargo launch", + "cargo": { + "args": [ + "build" + ] + }, + "args": [ + "--languages", + "Node" + ] + } + ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 5cb3bdc..a64cc9a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -9,6 +9,18 @@ "command": "cargo run", "problemMatcher": [] }, + { + "label": "run program (-languages)", + "type": "shell", + "args": [ + "run", + "--", + "--languages", + "Node,Rust" + ], + "command": "cargo", + "problemMatcher": [] + }, { "label": "run tests", "type": "shell", diff --git a/Cargo.lock b/Cargo.lock index bc2e63b..d8c3cde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "args" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b7432c65177b8d5c032d56e020dd8d407e939468479fc8c300e2d93e6d970b" +dependencies = [ + "getopts", + "log", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -75,10 +85,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + [[package]] name = "gi_gen" version = "0.4.0" dependencies = [ + "args", + "getopts", "globset", "home", "tempfile", @@ -211,6 +232,12 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 50ac9a0..f69cb62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,8 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +args = "2.2.0" +getopts = "0.2.21" globset = "0.4.10" home = "0.5.4" tempfile = "3.4.0" diff --git a/README.md b/README.md index 3a2a4e0..cd6ba17 100644 --- a/README.md +++ b/README.md @@ -61,18 +61,18 @@ $ gi_gen You may pass additional flags to `gi_gen`. These are the currently available flags: -| Usage | Description | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `-languages` \| `-l` | List the languages you want to use as templates.
To add multiple templates, use commas as separators, e.g.: `-languages Node,Python` | -| `-auto-discover` \| `-d` | Use auto-discovery for project, detecting the project type and using the result as the pre-selected template list. | -| `-clean-output` \| `-c` | Perform cleanup on the output .gitignore file, removing any unused patterns | -| `-keep-output` \| `-k` | Do not perform cleanup on the output .gitignore file, keep all the original contents | -| `-append` \| `-a` | Append to .gitignore file if it already exists | -| `-overwrite` \| `-w` | Overwrite .gitignore file if it already exists | -| `-detect-languages` | Outputs the automatically-detected languages, separated by newlines, and exits. Useful for outside tools detection. | -| `-all-languages` | Outputs all the available languages, separated by newlines, and exits. Useful for outside tools detection. | -| `-clear-cache` | Clear the .gitignore cache directory, for troubleshooting or for removing trace files of this program.
Exits after running, so other flags will be ignored. | -| `-help` \| `-h` | Display help message | +| Usage | Description | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--languages` \| `-l` | List the languages you want to use as templates.
To add multiple templates, use commas as separators, e.g.: `-languages Node,Python` | +| `--auto-discover` \| `-d` | Use auto-discovery for project, detecting the project type and using the result as the pre-selected template list. | +| `--clean-output` \| `-c` | Perform cleanup on the output .gitignore file, removing any unused patterns | +| `--keep-output` \| `-k` | Do not perform cleanup on the output .gitignore file, keep all the original contents | +| `--append` \| `-a` | Append to .gitignore file if it already exists | +| `--overwrite` \| `-w` | Overwrite .gitignore file if it already exists | +| `--detect-languages` | Outputs the automatically-detected languages, separated by newlines, and exits. Useful for outside tools detection. | +| `--all-languages` | Outputs all the available languages, separated by newlines, and exits. Useful for outside tools detection. | +| `--clear-cache` | Clear the .gitignore cache directory, for troubleshooting or for removing trace files of this program.
Exits after running, so other flags will be ignored. | +| `--help` \| `-h` | Display help message | ### Examples @@ -85,28 +85,28 @@ You may pass additional flags to `gi_gen`. These are the currently available fla - Pre-select languages (skip prompt): ```shell - gi_gen -languages Node # One language - gi_gen -languages Node,Python # Multiple languages + gi_gen --languages Node # One language + gi_gen --languages Node,Python # Multiple languages ``` - Perform clean up (skip prompt): ```shell - gi_gen -clean-output # clean up - gi_gen -keep-output # skip clean up + gi_gen --clean-output # clean up + gi_gen --keep-output # skip clean up ``` - Use auto-discovery (skip prompt): ```shell - gi_gen -auto-discover + gi_gen --auto-discover ``` - Existing file handlers (skip prompt): ```shell - gi_gen -append # if file exists, add to end of it - gi_gen -overwrite # if file exists, replace the existing content + gi_gen --append # if file exists, add to end of it + gi_gen --overwrite # if file exists, replace the existing content ``` - Combined (skip all prompts): @@ -119,13 +119,13 @@ You may pass additional flags to `gi_gen`. These are the currently available fla - Clean cache directory and exit: ```shell - gi_gen -clear-cache + gi_gen --clear-cache ``` - Detect languages and output the results, then exit: ```shell - gi_gen -detect-languages + gi_gen --detect-languages ``` ## Contribute @@ -134,7 +134,7 @@ Credits to [open-source-ideas][osi] for the idea for the tool. Please feel free to open PRs or issues with bug fixes/reports, or feature requests. -This project was built using Go, and should run easily with the normal Go tools with no further +This project was built using Rust, and should run easily with the normal Rust tools with no further configuration. Tested on all major platforms, but feel free to report any issues on your platform if you have any, diff --git a/src/cache.rs b/src/cache.rs index 20981a1..19fa07c 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -60,13 +60,40 @@ pub fn prepare_cache() -> Result<(), Error> { Ok(()) } -pub fn get_language_file(language: String) -> Result { +pub struct LanguageFile { + pub language: String, + pub content: String, + pub file_path: PathBuf, +} + +pub fn get_language_file(language: String) -> Result { let cache_dir = get_cache_dir()?; - let language_file = cache_dir.join(format!("{}.gitignore", language)); + // TODO make this a case-insensitive file search + let language_file = &cache_dir.join(format!("{}.gitignore", language)); let content = match std::fs::read_to_string(language_file) { Ok(content) => content, Err(_) => panic!("Could not read file"), }; - Ok(content) + Ok(LanguageFile { + language, + content, + file_path: language_file.into(), + }) +} + +pub fn get_all_languages_contents(languages: Vec) -> String { + let mut output = String::new(); + for chosen in languages { + let language_file = match get_language_file(chosen.to_string()) { + Ok(info) => info, + Err(e) => panic!("Error: {}", e), + }; + let content = language_file.content; + let sep = "#========================================================================\n"; + output.push_str(format!("\n{sep}# {}\n{sep}\n", language_file.language).as_str()); + output.push_str(&content); + } + output = format!("{}\n", output.trim_end()); + output } diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..f2b6c15 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,175 @@ +use std::{ + error::Error, + fmt::{self, Formatter}, +}; + +use args::Args; +use getopts::Occur; + +#[derive(Debug)] +pub struct Config { + pub languages: Vec, + pub auto_discover: bool, + pub clean_output: bool, + pub keep_output: bool, + pub overwrite_file: bool, + pub append_file: bool, +} + +impl fmt::Display for Config { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!( + f, + "Config{{languages: {:?}, auto_discover: {}, clean_output: {}, keep_output: {}, overwrite_file: {}, append_file: {}}}", + self.languages, self.auto_discover, self.clean_output, self.keep_output, self.overwrite_file, self.append_file + ) + } +} + +#[derive(Debug, Clone)] +pub enum ConfigErrorType { + Parse, + InvalidValue, + Required, +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!( + f, + "ConfigError{{option: {}, value: {}, message: {:?}, error_type: {:?}}}", + self.option, self.value, self.message, self.error_type + ) + } +} + +#[derive(Debug, Clone)] +pub struct ConfigError { + option: String, + value: String, + message: Option, + error_type: ConfigErrorType, +} + +impl ConfigError { + pub fn new( + option: &str, + value: Option<&str>, + error_type: ConfigErrorType, + message: Option<&str>, + ) -> ConfigError { + ConfigError { + option: option.to_string(), + value: value.unwrap_or_default().to_string(), + error_type, + message: message.map(|s| s.to_string()), + } + } +} + +pub fn parse(args: Vec) -> Result { + let mut parser = create_parser(); + match parser.parse(args) { + Ok(_) => (), + Err(err) => { + return Err(ConfigError::new( + err.to_string().as_str(), + None, + ConfigErrorType::Parse, + None, + )) + } + }; + let languages = match parser.values_of::("languages") { + Ok(languages) => languages, + Err(_) => Vec::new(), + }; + let auto_discover = if parser.has_value("auto-discover") { + parser.value_of("auto-discover").unwrap_or(true) + } else { + true + }; + let clean_output = if parser.has_value("clean-output") { + parser.value_of("clean-output").unwrap_or(true) + } else { + false + }; + let keep_output = if parser.has_value("keep-output") { + parser.value_of("keep-output").unwrap_or(true) + } else { + false + }; + let overwrite_file = if parser.has_value("overwrite") { + parser.value_of("overwrite").unwrap_or(true) + } else { + false + }; + let append_file = if parser.has_value("append") { + parser.value_of("append").unwrap_or(true) + } else { + false + }; + let config = Config { + languages, + auto_discover, + clean_output, + keep_output, + overwrite_file, + append_file, + }; + Ok(config) +} + +fn create_parser() -> Args { + let mut parser = Args::new( + "gi_gen", + "Generate .gitignore files automatically for any project", + ); + parser.option( + "l", + "languages", + "Comma-separated list of languages to generate .gitignore for", + "Node,Python,...", + Occur::Optional, + None, + ); + parser.flag( + "a", + "auto-discover", + "Automatically discover languages from project files", + ); + parser.flag( + "c", + "clean-output", + "Perform cleanup on the output .gitignore file, removing any unused patterns", + ); + parser.flag( + "k", + "keep-output", + "Do not perform cleanup on the output .gitignore file, keep all the original contents", + ); + parser.flag( + "o", + "overwrite", + "Overwrite the output .gitignore file if it already exists", + ); + parser.flag( + "a", + "append", + "Append to the output .gitignore file if it already exists", + ); + parser.flag( + "", + "clear-cache", + "Clear the local cache of .gitignore files", + ); + parser.flag("", "all-languages", "List all supported languages"); + parser.flag( + "", + "detect-languages", + "List the automatically-detected languages for the current project", + ); + parser.flag("h", "help", "Print this help message"); + + parser +} diff --git a/src/emitter.rs b/src/emitter.rs index c63ec3d..ae95635 100644 --- a/src/emitter.rs +++ b/src/emitter.rs @@ -12,13 +12,6 @@ pub fn emit_file(path: &PathBuf, content: String, strategy: EmitStrategy) -> Res "Could not convert path to string", ))?, }; - let mut file = match File::create(path_str) { - Ok(file) => file, - Err(_) => Err(Error::new( - std::io::ErrorKind::Other, - "Could not create file", - ))?, - }; match strategy { EmitStrategy::Append => { let original_content = match std::fs::read_to_string(path_str) { @@ -26,10 +19,12 @@ pub fn emit_file(path: &PathBuf, content: String, strategy: EmitStrategy) -> Res Err(_) => Err(Error::new(std::io::ErrorKind::Other, "Could not read file"))?, }; let final_content = append_lines(original_content, content); + let mut file = File::create(path_str)?; // TODO smart merge - remove duplicates file.write_all(final_content.as_bytes())?; } EmitStrategy::Overwrite => { + let mut file = File::create(path_str)?; file.write_all(content.as_bytes())?; } EmitStrategy::Skip => (), @@ -46,7 +41,7 @@ fn append_lines(target: String, source: String) -> String { // dedupe for line in content_lines.into_iter() { - if !original_lines.contains(&line) { + if line.trim() == "" || line.starts_with("#") || !original_lines.contains(&line) { filtered_lines.push_str(&line); filtered_lines.push_str("\n"); } @@ -54,7 +49,8 @@ fn append_lines(target: String, source: String) -> String { // append deduped lines to final output let mut final_content = target.clone(); - if !final_content.ends_with("\n") { + final_content = final_content.trim().to_string(); + if final_content.len() > 0 { final_content.push_str("\n"); } final_content.push_str(&filtered_lines); @@ -79,4 +75,24 @@ mod tests { let result = super::append_lines(original_content, content); assert_eq!(result, expected) } + + #[test] + fn test_no_leading_newline() { + let original_content = String::from("a\nb\nc"); + let content = String::from("a\nb\nc"); + + let expected = String::from("a\nb\nc\n"); + let result = super::append_lines(original_content, content); + assert_eq!(result, expected) + } + + #[test] + fn test_preserve_comments() { + let original_content = String::from("# ===\n# test\na\n# ===\nb\nc"); + let content = String::from("# ===\n# test 2\n# ===\na\nb\nc\nd"); + + let expected = String::from("# ===\n# test\na\n# ===\nb\nc\n# ===\n# test 2\n# ===\nd\n"); + let result = super::append_lines(original_content, content); + assert_eq!(result, expected) + } } diff --git a/src/main.rs b/src/main.rs index f603714..b803cd4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,19 @@ use analyzer::get_language_candidates; -use cache::get_language_file; +use cache::{get_all_languages_contents, prepare_cache}; use emitter::emit_file; -use crate::cache::prepare_cache; - mod analyzer; mod cache; +mod cli; mod emitter; fn main() { + let args = std::env::args().collect::>()[1..].to_vec(); + let config = match cli::parse(args) { + Ok(config) => config, + Err(e) => panic!("Error: {}", e), + }; + println!("{}", config); let wd = match std::env::current_dir() { Ok(path) => path, Err(e) => panic!("Could not get current directory: {}", e), @@ -17,20 +22,16 @@ fn main() { Ok(_) => println!("Cache prepared"), Err(e) => panic!("Error: {}", e), } - let languages = match get_language_candidates(&wd) { - Ok(result) => result, - Err(e) => panic!("Error: {}", e), - }; - let chosen = match languages.get(0) { - Some(language) => language, - None => panic!("Could not find language"), - }; - let content = match get_language_file(chosen.to_string()) { - Ok(content) => content, - Err(e) => panic!("Error: {}", e), + let languages = match config.languages.len() { + 0 => match get_language_candidates(&wd) { + Ok(result) => result, + Err(e) => panic!("Error: {}", e), + }, + _ => config.languages, }; + let output = get_all_languages_contents(languages); let file = wd.join(".gitignore"); - match emit_file(&file, content, emitter::EmitStrategy::Append) { + match emit_file(&file, output, emitter::EmitStrategy::Append) { Ok(_) => println!("File emitted: {}", file.display()), Err(e) => panic!("Error: {}", e), };