Fork of daniellemaywood.uk/gleam — Wasm codegen work
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[](https://hex.pm/packages/{project_name})
188[](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}