diff --git a/CHANGELOG.md b/CHANGELOG.md index abc45ee3848..729e703dfae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ ### Build tool +- `gleam new` now has refined project name validation - rather than failing on + invalid project names, it suggests a valid alternative and prompts for + confirmation to use it. + ([Diemo Gebhardt](https://github.com/diemogebhardt)) + ### Language server - The language server can now fill in the labels of any function call, even when @@ -54,4 +59,4 @@ - Fixed a bug where a block expression containing a singular record update would produce invalid erlang. ([yoshi](https://github.com/joshi-monster)) - + diff --git a/compiler-cli/src/new.rs b/compiler-cli/src/new.rs index 9511fcceec5..d51cc0f5098 100644 --- a/compiler-cli/src/new.rs +++ b/compiler-cli/src/new.rs @@ -1,7 +1,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use clap::ValueEnum; use gleam_core::{ - erlang, + erlang, error, error::{Error, FileIoAction, FileKind, InvalidProjectNameReason}, parse, Result, }; @@ -203,16 +203,7 @@ jobs: impl Creator { fn new(options: NewOptions, gleam_version: &'static str) -> Result { - let project_name = if let Some(name) = options.name.clone() { - name - } else { - get_foldername(&options.project_root)? - } - .trim() - .to_string(); - - validate_name(&project_name)?; - + let project_name = get_valid_project_name(options.name.clone(), &options.project_root)?; let root = get_current_directory()?.join(&options.project_root); let src = root.join("src"); let test = root.join("test"); @@ -349,16 +340,99 @@ fn validate_name(name: &str) -> Result<(), Error> { name: name.to_string(), reason: InvalidProjectNameReason::GleamReservedModule, }) - } else if !regex::Regex::new("^[a-z][a-z0-9_]*$") - .expect("new name regex could not be compiled") + } else if regex::Regex::new("^[a-z][a-z0-9_]*$") + .expect("failed regex to match valid name format") + .is_match(name) + { + Ok(()) + } else if regex::Regex::new("^[a-zA-Z][a-zA-Z0-9_]*$") + .expect("failed regex to match valid but non-lowercase name format") .is_match(name) { Err(Error::InvalidProjectName { name: name.to_string(), - reason: InvalidProjectNameReason::Format, + reason: InvalidProjectNameReason::FormatNotLowercase, }) } else { - Ok(()) + Err(Error::InvalidProjectName { + name: name.to_string(), + reason: InvalidProjectNameReason::Format, + }) + } +} + +fn suggest_valid_name(invalid_name: &str, reason: &InvalidProjectNameReason) -> Option { + match reason { + InvalidProjectNameReason::GleamPrefix => match invalid_name.strip_prefix("gleam_") { + Some(stripped) if invalid_name != "gleam_" => { + let suggestion = stripped.to_string(); + match validate_name(&suggestion) { + Ok(_) => Some(suggestion), + Err(_) => None, + } + } + _ => None, + }, + InvalidProjectNameReason::ErlangReservedWord => Some(format!("{}_app", invalid_name)), + InvalidProjectNameReason::ErlangStandardLibraryModule => { + Some(format!("{}_app", invalid_name)) + } + InvalidProjectNameReason::GleamReservedWord => Some(format!("{}_app", invalid_name)), + InvalidProjectNameReason::GleamReservedModule => Some(format!("{}_app", invalid_name)), + InvalidProjectNameReason::FormatNotLowercase => Some(invalid_name.to_lowercase()), + InvalidProjectNameReason::Format => { + let suggestion = regex::Regex::new(r"[^a-z0-9]") + .expect("failed regex to match any non-lowercase and non-alphanumeric characters") + .replace_all(&invalid_name.to_lowercase(), "_") + .to_string(); + + let suggestion = regex::Regex::new(r"_+") + .expect("failed regex to match consecutive underscores") + .replace_all(&suggestion, "_") + .to_string(); + + match validate_name(&suggestion) { + Ok(_) => Some(suggestion), + Err(_) => None, + } + } + } +} + +fn get_valid_project_name(name: Option, project_root: &str) -> Result { + let initial_name = match name { + Some(name) => name, + None => get_foldername(project_root)?, + } + .trim() + .to_string(); + + let invalid_reason = match validate_name(&initial_name) { + Ok(_) => return Ok(initial_name), + Err(Error::InvalidProjectName { reason, .. }) => reason, + Err(error) => return Err(error), + }; + + let suggested_name = match suggest_valid_name(&initial_name, &invalid_reason) { + Some(suggested_name) => suggested_name, + None => { + return Err(Error::InvalidProjectName { + name: initial_name, + reason: invalid_reason, + }) + } + }; + let prompt_for_suggested_name = error::format_invalid_project_name_error( + &initial_name, + &invalid_reason, + &Some(suggested_name.clone()), + ); + match crate::cli::confirm(&prompt_for_suggested_name)? { + true => Ok(suggested_name), + false => Err(Error::InvalidProjectName { + name: initial_name, + reason: invalid_reason, + }), } } diff --git a/compiler-cli/src/new/tests.rs b/compiler-cli/src/new/tests.rs index f16547d268c..b4311c7ccd6 100644 --- a/compiler-cli/src/new/tests.rs +++ b/compiler-cli/src/new/tests.rs @@ -325,3 +325,114 @@ fn skip_existing_git_files_when_skip_git_is_true() { assert!(path.join("README.md").exists()); assert!(path.join(".gitignore").exists()); } + +#[test] +fn validate_name_format() { + assert!(crate::new::validate_name("project").is_ok()); + assert!(crate::new::validate_name("project_name").is_ok()); + assert!(crate::new::validate_name("project2").is_ok()); + + let invalid = ["Project", "PROJECT", "Project_Name"]; + for name in invalid { + assert!(matches!( + crate::new::validate_name(name), + Err(Error::InvalidProjectName { + name: _, + reason: crate::new::InvalidProjectNameReason::FormatNotLowercase + }) + )); + } + + let invalid = ["0project", "_project", "project-name"]; + for name in invalid { + assert!(matches!( + crate::new::validate_name(name), + Err(Error::InvalidProjectName { + name: _, + reason: crate::new::InvalidProjectNameReason::Format + }) + )); + } +} + +#[test] +fn suggest_valid_names() { + assert_eq!( + crate::new::suggest_valid_name( + "gleam_", + &crate::new::InvalidProjectNameReason::GleamPrefix + ), + None + ); + assert_eq!( + crate::new::suggest_valid_name( + "gleam_project", + &crate::new::InvalidProjectNameReason::GleamPrefix + ), + Some("project".to_string()) + ); + + assert_eq!( + crate::new::suggest_valid_name( + "try", + &crate::new::InvalidProjectNameReason::ErlangReservedWord + ), + Some("try_app".to_string()) + ); + + assert_eq!( + crate::new::suggest_valid_name( + "erl_eval", + &crate::new::InvalidProjectNameReason::ErlangStandardLibraryModule + ), + Some("erl_eval_app".to_string()) + ); + + assert_eq!( + crate::new::suggest_valid_name( + "assert", + &crate::new::InvalidProjectNameReason::GleamReservedWord + ), + Some("assert_app".to_string()) + ); + + assert_eq!( + crate::new::suggest_valid_name( + "gleam", + &crate::new::InvalidProjectNameReason::GleamReservedModule + ), + Some("gleam_app".to_string()) + ); + + assert_eq!( + crate::new::suggest_valid_name( + "Project_Name", + &crate::new::InvalidProjectNameReason::FormatNotLowercase + ), + Some("project_name".to_string()) + ); + + assert_eq!( + crate::new::suggest_valid_name( + "Pr0ject-n4me!", + &crate::new::InvalidProjectNameReason::Format + ), + Some("pr0ject_n4me_".to_string()) + ); + + assert_eq!( + crate::new::suggest_valid_name( + "Pr0ject--n4me!", + &crate::new::InvalidProjectNameReason::Format + ), + Some("pr0ject_n4me_".to_string()) + ); + + assert_eq!( + crate::new::suggest_valid_name( + "_pr0ject-name", + &crate::new::InvalidProjectNameReason::Format + ), + None + ); +} diff --git a/compiler-core/src/error.rs b/compiler-core/src/error.rs index b3f578cc143..cb1490b357c 100644 --- a/compiler-core/src/error.rs +++ b/compiler-core/src/error.rs @@ -524,6 +524,7 @@ impl From for Error { #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum InvalidProjectNameReason { Format, + FormatNotLowercase, GleamPrefix, ErlangReservedWord, ErlangStandardLibraryModule, @@ -531,6 +532,52 @@ pub enum InvalidProjectNameReason { GleamReservedModule, } +pub fn format_invalid_project_name_error( + name: &str, + reason: &InvalidProjectNameReason, + with_suggestion: &Option, +) -> String { + let reason_message = match reason { + InvalidProjectNameReason::ErlangReservedWord => "is a reserved word in Erlang.", + InvalidProjectNameReason::ErlangStandardLibraryModule => { + "is a standard library module in Erlang." + } + InvalidProjectNameReason::GleamReservedWord => "is a reserved word in Gleam.", + InvalidProjectNameReason::GleamReservedModule => "is a reserved module name in Gleam.", + InvalidProjectNameReason::FormatNotLowercase => { + "does not have the correct format. Project names \ +may only contain lowercase letters." + } + InvalidProjectNameReason::Format => { + "does not have the correct format. Project names \ +must start with a lowercase letter and may only contain lowercase letters, \ +numbers and underscores." + } + InvalidProjectNameReason::GleamPrefix => { + "has the reserved prefix `gleam_`. \ +This prefix is intended for official Gleam packages only." + } + }; + + match with_suggestion { + Some(suggested_name) => wrap_format!( + "We were not able to create your project as `{}` {} + +Would you like to name your project '{}' instead?", + name, + reason_message, + suggested_name + ), + None => wrap_format!( + "We were not able to create your project as `{}` {} + +Please try again with a different project name.", + name, + reason_message + ), + } +} + #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum StandardIoAction { Read, @@ -822,29 +869,7 @@ of the Gleam dependency modules." } Error::InvalidProjectName { name, reason } => { - let text = wrap_format!( - "We were not able to create your project as `{}` {} - -Please try again with a different project name.", - name, - match reason { - InvalidProjectNameReason::ErlangReservedWord => - "is a reserved word in Erlang.", - InvalidProjectNameReason::ErlangStandardLibraryModule => - "is a standard library module in Erlang.", - InvalidProjectNameReason::GleamReservedWord => - "is a reserved word in Gleam.", - InvalidProjectNameReason::GleamReservedModule => - "is a reserved module name in Gleam.", - InvalidProjectNameReason::Format => - "does not have the correct format. Project names \ -must start with a lowercase letter and may only contain lowercase letters, \ -numbers and underscores.", - InvalidProjectNameReason::GleamPrefix => - "has the reserved prefix `gleam_`. \ -This prefix is intended for official Gleam packages only.", - } - ); + let text = format_invalid_project_name_error(name, reason, &None); vec![Diagnostic { title: "Invalid project name".into(),