Fork of daniellemaywood.uk/gleam — Wasm codegen work
2

Configure Feed

Select the types of activity you want to include in your feed.

gleam / compiler-cli / src / new.rs
16 kB 539 lines
1// SPDX-License-Identifier: Apache-2.0 2// SPDX-FileCopyrightText: 2018 The Gleam contributors 3 4use camino::{Utf8Path, Utf8PathBuf}; 5use clap::ValueEnum; 6use gleam_core::{ 7 Result, erlang, error, 8 error::{Error, FileIoAction, FileKind, InvalidProjectNameReason}, 9 parse, 10}; 11use serde::{Deserialize, Serialize}; 12use std::fs::File; 13use std::{env, io::Write}; 14use strum::{Display, EnumIter, EnumString, IntoEnumIterator, VariantNames}; 15 16#[cfg(test)] 17mod tests; 18 19use crate::{NewOptions, fs::get_current_directory}; 20 21const GLEAM_STDLIB_REQUIREMENT: &str = ">= 1.0.0 and < 2.0.0"; 22const GLEEUNIT_REQUIREMENT: &str = ">= 1.0.0 and < 2.0.0"; 23const ERLANG_OTP_VERSION: &str = "29"; 24const REBAR3_VERSION: &str = "3"; 25const ELIXIR_VERSION: &str = "1"; 26 27#[derive( 28 Debug, Serialize, Deserialize, Display, EnumString, VariantNames, ValueEnum, Clone, Copy, 29)] 30#[strum(serialize_all = "lowercase")] 31#[clap(rename_all = "lower")] 32pub enum Template { 33 #[clap(skip)] 34 Lib, 35 Erlang, 36 JavaScript, 37} 38 39#[derive(Debug)] 40pub struct Creator { 41 root: Utf8PathBuf, 42 src: Utf8PathBuf, 43 test: Utf8PathBuf, 44 github: Utf8PathBuf, 45 workflows: Utf8PathBuf, 46 gleam_version: &'static str, 47 options: NewOptions, 48 project_name: String, 49} 50 51#[derive(EnumIter, PartialEq, Eq, Debug, Hash)] 52enum FileToCreate { 53 Readme, 54 Gitignore, 55 SrcModule, 56 TestModule, 57 GleamToml, 58 GithubCi, 59} 60 61impl FileToCreate { 62 pub fn location(&self, creator: &Creator) -> Utf8PathBuf { 63 let project_name = &creator.project_name; 64 65 match self { 66 Self::Readme => creator.root.join(Utf8PathBuf::from("README.md")), 67 Self::Gitignore => creator.root.join(Utf8PathBuf::from(".gitignore")), 68 Self::SrcModule => creator 69 .src 70 .join(Utf8PathBuf::from(format!("{project_name}.gleam"))), 71 Self::TestModule => creator 72 .test 73 .join(Utf8PathBuf::from(format!("{project_name}_test.gleam"))), 74 Self::GleamToml => creator.root.join(Utf8PathBuf::from("gleam.toml")), 75 Self::GithubCi => creator.workflows.join(Utf8PathBuf::from("test.yml")), 76 } 77 } 78 79 pub fn contents(&self, creator: &Creator) -> Option<String> { 80 let project_name = &creator.project_name; 81 let skip_git = creator.options.skip_git; 82 let skip_github = creator.options.skip_github; 83 let gleam_version = creator.gleam_version; 84 let target = match creator.options.template { 85 Template::JavaScript => "target = \"javascript\"\n", 86 Template::Lib | Template::Erlang => "", 87 }; 88 89 match self { 90 Self::Readme => Some(default_readme(project_name)), 91 Self::Gitignore if !skip_git => Some( 92 "*.beam 93*.ez 94/build 95erl_crash.dump 96" 97 .into(), 98 ), 99 100 Self::SrcModule => Some(format!( 101 r#"import gleam/io 102 103pub fn main() -> Nil {{ 104 io.println("Hello from {project_name}!") 105}} 106"#, 107 )), 108 109 Self::TestModule => Some( 110 r#"import gleeunit 111 112pub fn main() -> Nil { 113 gleeunit.main() 114} 115 116// gleeunit test functions end in `_test` 117pub fn hello_world_test() { 118 let name = "Joe" 119 let greeting = "Hello, " <> name <> "!" 120 121 assert greeting == "Hello, Joe!" 122} 123"# 124 .into(), 125 ), 126 127 Self::GleamToml => Some(format!( 128 r#"name = "{project_name}" 129version = "1.0.0" 130{target} 131# Fill out these fields if you intend to generate HTML documentation or publish 132# your project to the Hex package manager. 133# 134# description = "" 135# licences = ["Apache-2.0"] 136# repository = {{ type = "github", user = "", repo = "" }} 137# links = [{{ title = "Website", href = "" }}] 138# 139# For a full reference of all the available options, you can have a look at 140# https://gleam.run/writing-gleam/gleam-toml/. 141 142[dependencies] 143gleam_stdlib = "{GLEAM_STDLIB_REQUIREMENT}" 144 145[dev_dependencies] 146gleeunit = "{GLEEUNIT_REQUIREMENT}" 147"#, 148 )), 149 150 Self::GithubCi if !skip_git && !skip_github => Some(format!( 151 r#"name: test 152 153on: 154 push: 155 branches: 156 - master 157 - main 158 pull_request: 159 160jobs: 161 test: 162 runs-on: ubuntu-latest 163 steps: 164 - uses: actions/checkout@v7 165 - uses: erlef/setup-beam@v1 166 with: 167 otp-version: "{ERLANG_OTP_VERSION}" 168 gleam-version: "{gleam_version}" 169 rebar3-version: "{REBAR3_VERSION}" 170 # elixir-version: "{ELIXIR_VERSION}" 171 - run: gleam deps download 172 - run: gleam test 173 - run: gleam format --check src test 174"#, 175 )), 176 Self::GithubCi | Self::Gitignore => None, 177 } 178 } 179} 180 181pub fn default_readme(project_name: &str) -> String { 182 let project_name_with_dashes = project_name.replace('_', "-"); 183 184 format!( 185 r#"# {project_name} 186 187[![Package Version](https://img.shields.io/hexpm/v/{project_name})](https://hex.pm/packages/{project_name}) 188[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://{project_name_with_dashes}.hexdocs.pm/) 189 190```sh 191gleam add {project_name}@1 192``` 193```gleam 194import {project_name} 195 196pub fn main() -> Nil {{ 197 // TODO: An example of the project in use 198}} 199``` 200 201Further documentation can be found at <https://{project_name_with_dashes}.hexdocs.pm/>. 202 203## Development 204 205```sh 206gleam run # Run the project 207gleam test # Run the tests 208``` 209"#, 210 ) 211} 212 213impl Creator { 214 fn new(options: NewOptions, gleam_version: &'static str) -> Result<Self, Error> { 215 Self::new_with_confirmation(options, gleam_version, crate::cli::confirm) 216 } 217 218 fn new_with_confirmation( 219 mut options: NewOptions, 220 gleam_version: &'static str, 221 confirm: impl Fn(&str) -> Result<bool, Error>, 222 ) -> Result<Self, Error> { 223 let name = 224 get_valid_project_name(options.name.as_deref(), &options.project_root, &confirm)?; 225 options.project_root = name.project_root(&options.project_root); 226 227 let root = get_current_directory()?.join(&options.project_root); 228 let src = root.join("src"); 229 let test = root.join("test"); 230 let github = root.join(".github"); 231 let workflows = github.join("workflows"); 232 let me = Self { 233 root, 234 src, 235 test, 236 github, 237 workflows, 238 gleam_version, 239 options, 240 project_name: name.decided().to_string(), 241 }; 242 243 validate_root_folder(&me)?; 244 245 Ok(me) 246 } 247 248 fn run(&self) -> Result<()> { 249 crate::fs::mkdir(&self.root)?; 250 crate::fs::mkdir(&self.src)?; 251 crate::fs::mkdir(&self.test)?; 252 253 if !self.options.skip_git && !self.options.skip_github { 254 crate::fs::mkdir(&self.github)?; 255 crate::fs::mkdir(&self.workflows)?; 256 } 257 258 if !self.options.skip_git { 259 crate::fs::git_init(&self.root)?; 260 } 261 262 match self.options.template { 263 Template::Lib | Template::Erlang | Template::JavaScript => { 264 for file in FileToCreate::iter() { 265 let path = file.location(self); 266 if let Some(contents) = file.contents(self) { 267 write(path, &contents)?; 268 } 269 } 270 } 271 } 272 273 Ok(()) 274 } 275} 276 277pub fn create(options: NewOptions, version: &'static str) -> Result<()> { 278 let creator = Creator::new(options.clone(), version)?; 279 280 creator.run()?; 281 282 let cd_folder = if options.project_root == "." { 283 "".into() 284 } else { 285 format!("\tcd {}\n", creator.options.project_root) 286 }; 287 288 println!( 289 "Your Gleam project {} has been successfully created. 290The project can be compiled and tested by running these commands: 291 292{}\tgleam test 293", 294 creator.project_name, cd_folder, 295 ); 296 Ok(()) 297} 298 299fn write(path: Utf8PathBuf, contents: &str) -> Result<()> { 300 let mut f = File::create(&path).map_err(|err| Error::FileIo { 301 kind: FileKind::File, 302 path: path.clone(), 303 action: FileIoAction::Create, 304 err: Some(err.to_string()), 305 })?; 306 307 f.write_all(contents.as_bytes()) 308 .map_err(|err| Error::FileIo { 309 kind: FileKind::File, 310 path, 311 action: FileIoAction::WriteTo, 312 err: Some(err.to_string()), 313 })?; 314 Ok(()) 315} 316 317fn validate_root_folder(creator: &Creator) -> Result<(), Error> { 318 let mut duplicate_files: Vec<Utf8PathBuf> = Vec::new(); 319 320 for t in FileToCreate::iter() { 321 let full_path = t.location(creator); 322 let content = t.contents(creator); 323 if full_path.exists() && content.is_some() { 324 duplicate_files.push(full_path); 325 } 326 } 327 328 if !duplicate_files.is_empty() { 329 return Err(Error::OutputFilesAlreadyExist { 330 file_names: duplicate_files, 331 }); 332 } 333 334 Ok(()) 335} 336 337fn validate_name(name: &str) -> Result<(), Error> { 338 if name.starts_with("gleam_") { 339 Err(Error::InvalidProjectName { 340 name: name.to_string(), 341 reason: InvalidProjectNameReason::GleamPrefix, 342 }) 343 } else if erlang::is_erlang_reserved_word(name) { 344 Err(Error::InvalidProjectName { 345 name: name.to_string(), 346 reason: InvalidProjectNameReason::ErlangReservedWord, 347 }) 348 } else if erlang::is_erlang_standard_library_module(name) { 349 Err(Error::InvalidProjectName { 350 name: name.to_string(), 351 reason: InvalidProjectNameReason::ErlangStandardLibraryModule, 352 }) 353 } else if parse::lexer::string_to_keyword(name).is_some() { 354 Err(Error::InvalidProjectName { 355 name: name.to_string(), 356 reason: InvalidProjectNameReason::GleamReservedWord, 357 }) 358 } else if name == "gleam" { 359 Err(Error::InvalidProjectName { 360 name: name.to_string(), 361 reason: InvalidProjectNameReason::GleamReservedModule, 362 }) 363 } else if regex::Regex::new("^[a-z][a-z0-9_]*$") 364 .expect("failed regex to match valid name format") 365 .is_match(name) 366 { 367 Ok(()) 368 } else if regex::Regex::new("^[a-zA-Z][a-zA-Z0-9_]*$") 369 .expect("failed regex to match valid but non-lowercase name format") 370 .is_match(name) 371 { 372 Err(Error::InvalidProjectName { 373 name: name.to_string(), 374 reason: InvalidProjectNameReason::FormatNotLowercase, 375 }) 376 } else { 377 Err(Error::InvalidProjectName { 378 name: name.to_string(), 379 reason: InvalidProjectNameReason::Format, 380 }) 381 } 382} 383 384fn suggest_valid_name(invalid_name: &str, reason: &InvalidProjectNameReason) -> Option<String> { 385 match reason { 386 InvalidProjectNameReason::GleamPrefix => match invalid_name.strip_prefix("gleam_") { 387 Some(stripped) if invalid_name != "gleam_" => { 388 let suggestion = stripped.to_string(); 389 match validate_name(&suggestion) { 390 Ok(_) => Some(suggestion), 391 Err(_) => None, 392 } 393 } 394 _ => None, 395 }, 396 InvalidProjectNameReason::ErlangReservedWord => Some(format!("{invalid_name}_app")), 397 InvalidProjectNameReason::ErlangStandardLibraryModule => { 398 Some(format!("{invalid_name}_app")) 399 } 400 InvalidProjectNameReason::GleamReservedWord => Some(format!("{invalid_name}_app")), 401 InvalidProjectNameReason::GleamReservedModule => { 402 if invalid_name == "gleam" { 403 Some("app_gleam".into()) 404 } else { 405 Some(format!("{invalid_name}_app")) 406 } 407 } 408 InvalidProjectNameReason::FormatNotLowercase => Some(invalid_name.to_lowercase()), 409 InvalidProjectNameReason::Format => { 410 let suggestion = regex::Regex::new(r"[^a-z0-9]") 411 .expect("failed regex to match any non-lowercase and non-alphanumeric characters") 412 .replace_all(&invalid_name.to_lowercase(), "_") 413 .to_string(); 414 415 let suggestion = regex::Regex::new(r"_+") 416 .expect("failed regex to match consecutive underscores") 417 .replace_all(&suggestion, "_") 418 .to_string(); 419 420 match validate_name(&suggestion) { 421 Ok(_) => Some(suggestion), 422 Err(_) => None, 423 } 424 } 425 } 426} 427 428fn get_valid_project_name( 429 provided_name: Option<&str>, 430 project_root: &str, 431 confirm: impl Fn(&str) -> Result<bool, Error>, 432) -> Result<ProjectName, Error> { 433 let initial_name = match provided_name { 434 Some(name) => name.trim().to_string(), 435 None => get_foldername(project_root)?.trim().to_string(), 436 }; 437 438 let invalid_reason = match validate_name(&initial_name) { 439 Ok(_) => { 440 return Ok(match provided_name { 441 Some(_) => ProjectName::Provided { 442 decided: initial_name, 443 }, 444 None => ProjectName::Derived { 445 folder: initial_name.clone(), 446 decided: initial_name, 447 }, 448 }); 449 } 450 Err(Error::InvalidProjectName { reason, .. }) => reason, 451 Err(error) => return Err(error), 452 }; 453 454 let suggested_name = match suggest_valid_name(&initial_name, &invalid_reason) { 455 Some(suggested_name) => suggested_name, 456 None => { 457 return Err(Error::InvalidProjectName { 458 name: initial_name, 459 reason: invalid_reason, 460 }); 461 } 462 }; 463 464 let prompt_for_suggested_name = error::format_invalid_project_name_error( 465 &initial_name, 466 &invalid_reason, 467 &Some(suggested_name.clone()), 468 ); 469 470 if confirm(&prompt_for_suggested_name)? { 471 return Ok(match provided_name { 472 Some(_) => ProjectName::Provided { 473 decided: suggested_name, 474 }, 475 None => ProjectName::Derived { 476 folder: initial_name, 477 decided: suggested_name, 478 }, 479 }); 480 } 481 482 Err(Error::InvalidProjectName { 483 name: initial_name, 484 reason: invalid_reason, 485 }) 486} 487 488fn get_foldername(path: &str) -> Result<String, Error> { 489 match path { 490 "." => env::current_dir() 491 .expect("invalid folder") 492 .file_name() 493 .and_then(|x| x.to_str()) 494 .map(ToString::to_string) 495 .ok_or(Error::UnableToFindProjectRoot { 496 path: path.to_string(), 497 }), 498 _ => Utf8Path::new(path) 499 .file_name() 500 .map(ToString::to_string) 501 .ok_or(Error::UnableToFindProjectRoot { 502 path: path.to_string(), 503 }), 504 } 505} 506 507#[derive(Debug, Clone)] 508enum ProjectName { 509 Provided { decided: String }, 510 Derived { folder: String, decided: String }, 511} 512 513impl ProjectName { 514 fn decided(&self) -> &str { 515 match self { 516 Self::Provided { decided } | Self::Derived { decided, .. } => decided, 517 } 518 } 519 520 fn project_root(&self, current_root: &str) -> String { 521 match self { 522 Self::Provided { .. } => current_root.to_string(), 523 Self::Derived { folder, decided } => { 524 if current_root == "." || folder == decided { 525 return current_root.to_string(); 526 } 527 528 // If the name was invalid and generated suggestion was accepted, 529 // align the directory path with the new name. 530 let original_root = Utf8Path::new(current_root); 531 let new_root = match original_root.parent() { 532 Some(parent) if !parent.as_str().is_empty() => parent.join(decided), 533 Some(_) | None => Utf8PathBuf::from(decided), 534 }; 535 new_root.to_string() 536 } 537 } 538 } 539}