Fork of daniellemaywood.uk/gleam — Wasm codegen work
36 kB
1134 lines
1use camino::{Utf8Path, Utf8PathBuf};
2use ecow::EcoString;
3use flate2::{Compression, write::GzEncoder};
4use gleam_core::{
5 Error, Result,
6 analyse::TargetSupport,
7 ast::{CallArg, Statement, TypedExpr, TypedFunction},
8 build::{Codegen, Compile, Mode, Options, Package, Target},
9 config::{GleamVersion, PackageConfig, SpdxLicense},
10 docs::{Dependency, DependencyKind, DocContext},
11 error::{InvalidReadmeReason, SmallVersion, wrap},
12 hex,
13 manifest::ManifestPackageSource,
14 paths::{self, ProjectPaths},
15 requirement::Requirement,
16 type_,
17};
18use hexpm::version::{Range, Version};
19use itertools::Itertools;
20use sha2::Digest;
21use std::{
22 collections::HashMap,
23 io::Write,
24 process::{Command, Stdio},
25};
26
27use crate::{build, cli, docs, fs, http::HttpClient, new::default_readme};
28
29const CORE_TEAM_PUBLISH_PASSWORD: &str = "Trans rights are human rights";
30
31pub fn command(paths: &ProjectPaths, replace: bool, i_am_sure: bool) -> Result<()> {
32 let mut config = crate::config::root_config(paths)?;
33
34 let should_publish = check_for_gleam_prefix(&config)?
35 && check_for_version_zero(&config)?
36 && check_repo_url(&config, i_am_sure)?;
37
38 check_for_invalid_readme(&config, paths)?;
39
40 if !should_publish {
41 println!("Not publishing.");
42 return Ok(());
43 }
44
45 let Tarball {
46 mut compile_result,
47 cached_modules,
48 data: package_tarball,
49 src_files_added,
50 generated_files_added,
51 dependencies,
52 } = do_build_hex_tarball(paths, &mut config)?;
53
54 check_for_name_squatting(&compile_result)?;
55 check_for_multiple_top_level_modules(&compile_result, i_am_sure)?;
56 check_for_default_main(&compile_result)?;
57
58 // Build HTML documentation
59 let docs_tarball = fs::create_tar_archive(docs::build_documentation(
60 paths,
61 &config,
62 dependencies,
63 &mut compile_result,
64 DocContext::HexPublish,
65 &cached_modules,
66 )?)?;
67
68 // Ask user if this is correct
69 if !generated_files_added.is_empty() {
70 println!("\nGenerated files:");
71 for file in generated_files_added.iter().sorted() {
72 println!(" - {}", file.0);
73 }
74 }
75 println!("\nSource files:");
76 for file in src_files_added.iter().sorted() {
77 println!(" - {file}");
78 }
79 println!("\nName: {}", config.name);
80 println!("Version: {}", config.version);
81
82 let should_publish = i_am_sure || cli::confirm("\nDo you wish to publish this package?")?;
83 if !should_publish {
84 println!("Not publishing.");
85 return Ok(());
86 }
87
88 let runtime = tokio::runtime::Runtime::new().expect("Unable to start Tokio async runtime");
89 let http = HttpClient::new();
90 let hex_config = hexpm::Config::new();
91 let credentials = crate::hex::HexAuthentication::new(&runtime, &http, hex_config.clone())
92 .get_or_create_api_credentials()?;
93 let credentials = crate::hex::write_credentials(&credentials)?;
94 cli::print_publishing(&config.name, &config.version);
95
96 runtime.block_on(hex::publish_package(
97 package_tarball,
98 config.version.to_string(),
99 &config.name,
100 &credentials,
101 &hex_config,
102 replace,
103 &http,
104 ))?;
105
106 cli::print_publishing_documentation();
107 runtime.block_on(hex::publish_documentation(
108 &config.name,
109 &config.version,
110 docs_tarball,
111 &credentials,
112 &hex_config,
113 &http,
114 ))?;
115 cli::print_published("package and documentation");
116 println!(
117 "\nView your package at https://hex.pm/packages/{}",
118 &config.name
119 );
120
121 // Prompt the user to make a git tag if they have not.
122 let has_repo = config.repository.is_some();
123 let is_repository = Command::new("git")
124 .arg("rev-parse")
125 .arg("--is-inside-work-tree")
126 // We don't need Git's output, since it will exit with code 0 if we are
127 // inside work tree
128 .stdout(Stdio::null())
129 .stderr(Stdio::null())
130 .status()
131 .map(|s| s.success())
132 .unwrap_or(false);
133
134 if has_repo && is_repository {
135 let tag_name = config.tag_for_version(&config.version);
136 let tag_exists = Command::new("git")
137 .args(["rev-parse", "--verify", &tag_name])
138 // We don't need Git's output, since it will exit with code 0 if the
139 // tag exists
140 .stdout(Stdio::null())
141 .stderr(Stdio::null())
142 .status()
143 .map(|s| s.success())
144 .unwrap_or(false);
145
146 if !tag_exists {
147 println!(
148 "
149 Please push a git tag for this release so source code links in the
150 HTML documentation will work:
151
152 git tag {tag_name}
153 git push origin {tag_name}
154 "
155 )
156 }
157 }
158 Ok(())
159}
160
161fn check_for_invalid_readme(config: &PackageConfig, paths: &ProjectPaths) -> Result<(), Error> {
162 let normalise = |string: String| {
163 string
164 .trim()
165 .replace("\r\n", "")
166 .replace("\n", "")
167 .replace("\t", "")
168 .replace(" ", "")
169 };
170
171 let project_readme = match fs::read(paths.readme()) {
172 Err(Error::FileIo {
173 err: Some(message), ..
174 }) if message.contains("No such file or directory") => {
175 return Err(Error::CannotPublishWithInvalidReadme {
176 reason: InvalidReadmeReason::Missing,
177 });
178 }
179 Err(error) => return Err(error),
180 Ok(project_readme) => project_readme,
181 };
182
183 let normalised_project_readme = normalise(project_readme);
184 if normalised_project_readme.is_empty() {
185 return Err(Error::CannotPublishWithInvalidReadme {
186 reason: InvalidReadmeReason::Empty,
187 });
188 }
189
190 let default_readme = default_readme(config.name.as_str());
191 if normalised_project_readme == normalise(default_readme) {
192 return Err(Error::CannotPublishWithInvalidReadme {
193 reason: InvalidReadmeReason::Default,
194 });
195 }
196
197 Ok(())
198}
199
200fn check_for_name_squatting(package: &Package) -> Result<(), Error> {
201 if package.modules.len() > 1 {
202 return Ok(());
203 }
204
205 let Some(module) = package.modules.first() else {
206 return Err(Error::HexPackageSquatting);
207 };
208
209 if module.dependencies.len() > 1 {
210 return Ok(());
211 }
212
213 if module.ast.definitions_len() > 2 {
214 return Ok(());
215 }
216
217 let Some(main) = module
218 .ast
219 .definitions
220 .functions
221 .iter()
222 .find_map(|function| function.main_function())
223 else {
224 return Ok(());
225 };
226
227 if let Some(first) = &main.body.first()
228 && first.is_println()
229 {
230 return Err(Error::HexPackageSquatting);
231 }
232
233 Ok(())
234}
235
236/// Checks if publishing packages contain default main functions.
237/// Main functions with documentation are considered intentional and allowed.
238fn check_for_default_main(package: &Package) -> Result<(), Error> {
239 let package_name = &package.config.name;
240
241 let has_default_main = package
242 .modules
243 .iter()
244 .flat_map(|module| module.ast.definitions.functions.iter())
245 .filter_map(|function| function.main_function())
246 .any(|main| main.documentation.is_none() && is_default_main(main, package_name));
247
248 if has_default_main {
249 return Err(Error::CannotPublishWithDefaultMain {
250 package_name: package_name.clone(),
251 });
252 }
253
254 Ok(())
255}
256
257fn is_default_main(main: &TypedFunction, package_name: &EcoString) -> bool {
258 if main.body.len() != 1 {
259 return false;
260 }
261
262 let Some(Statement::Expression(expression)) = main.body.first() else {
263 return false;
264 };
265
266 if !expression.is_println() {
267 return false;
268 }
269
270 match expression {
271 TypedExpr::Call { arguments, .. } => {
272 if arguments.len() != 1 {
273 return false;
274 }
275
276 match arguments.first() {
277 Some(CallArg {
278 value: TypedExpr::String { value, .. },
279 ..
280 }) => {
281 let default_argument = format!("Hello from {}!", &package_name);
282 value == &default_argument
283 }
284 _ => false,
285 }
286 }
287 _ => false,
288 }
289}
290
291fn check_for_multiple_top_level_modules(package: &Package, i_am_sure: bool) -> Result<(), Error> {
292 // Collect top-level module names
293 let mut top_level_module_names = package
294 .modules
295 .iter()
296 .filter_map(|module| {
297 // Top-level modules are those that don't contain any path separators
298 if module.name.contains('/') {
299 None
300 } else {
301 Some(module.name.clone())
302 }
303 })
304 .collect::<Vec<_>>();
305
306 // Remove duplicates
307 top_level_module_names.sort_unstable();
308 top_level_module_names.dedup();
309
310 // If more than one top-level module name is found, prompt for confirmation
311 if top_level_module_names.len() > 1 {
312 let text = wrap(&format!(
313 "Your package defines multiple top-level modules: {}.
314
315Defining multiple top-level modules can lead to namespace pollution \
316and potential conflicts for consumers.
317
318To fix this, move all your modules under a single top-level module of your choice.
319
320For example:
321 src/{1}.gleam
322 src/{1}/module1.gleam
323 src/{1}/module2.gleam",
324 top_level_module_names.join(", "),
325 package.config.name
326 ));
327 println!("{text}\n");
328
329 let should_publish =
330 i_am_sure || cli::confirm("\nDo you wish to continue publishing this package?")?;
331 println!();
332
333 if !should_publish {
334 println!("Not publishing.");
335 std::process::exit(0);
336 }
337 }
338
339 Ok(())
340}
341
342fn check_repo_url(config: &PackageConfig, i_am_sure: bool) -> Result<bool, Error> {
343 let Some(repo) = config.repository.as_ref() else {
344 return Ok(true);
345 };
346 let url = repo.url();
347
348 let runtime = tokio::runtime::Runtime::new().expect("Unable to start Tokio async runtime");
349 let response = runtime.block_on(reqwest::get(&url)).map_err(Error::http)?;
350
351 if response.status().is_success() {
352 return Ok(true);
353 }
354
355 println!(
356 "The repository configuration in your `gleam.toml` file does not appear to be
357valid, {} returned status {}",
358 &url,
359 response.status()
360 );
361 let should_publish = i_am_sure || cli::confirm("\nDo you wish to continue?")?;
362 println!();
363 Ok(should_publish)
364}
365
366/// Ask for confirmation if the package name if a v0.x.x version
367fn check_for_version_zero(config: &PackageConfig) -> Result<bool, Error> {
368 if config.version.major != 0 {
369 return Ok(true);
370 }
371
372 println!(
373 "You are about to publish a release that is below version 1.0.0.
374
375Semantic versioning doesn't apply to version 0.x.x releases, so your
376users will not be protected from breaking changes. This can result
377in a poor user experience where packages can break unexpectedly with
378updates that would normally be safe.
379
380If your package is not ready to be used in production it should not
381be published.
382\n"
383 );
384 let should_publish = cli::confirm_with_text("I am not using semantic versioning")?;
385 println!();
386 Ok(should_publish)
387}
388
389/// Ask for confirmation if the package name if `gleam_*`
390fn check_for_gleam_prefix(config: &PackageConfig) -> Result<bool, Error> {
391 if !config.name.starts_with("gleam_") || config.name.starts_with("gleam_community_") {
392 return Ok(true);
393 }
394
395 println!(
396 "You are about to publish a package with a name that starts with
397the prefix `gleam_`, which is preferred for packages maintained by the
398Gleam core team.
399
400Security: do not assume the owner of a package from the name, always check
401the maintainers listed on https://hex.pm/.
402\n",
403 );
404 let password = cli::ask_password("Please enter the core team password to continue")?;
405 println!();
406 Ok(password == CORE_TEAM_PUBLISH_PASSWORD)
407}
408
409struct Tarball {
410 compile_result: Package,
411 cached_modules: im::HashMap<EcoString, type_::ModuleInterface>,
412 data: Vec<u8>,
413 src_files_added: Vec<Utf8PathBuf>,
414 generated_files_added: Vec<(Utf8PathBuf, String)>,
415 dependencies: HashMap<EcoString, Dependency>,
416}
417
418pub fn build_hex_tarball(paths: &ProjectPaths, config: &mut PackageConfig) -> Result<Vec<u8>> {
419 let Tarball { data, .. } = do_build_hex_tarball(paths, config)?;
420 Ok(data)
421}
422
423fn do_build_hex_tarball(paths: &ProjectPaths, config: &mut PackageConfig) -> Result<Tarball> {
424 let target = config.target;
425 check_config_for_publishing(config)?;
426
427 // Reset the build directory so we know the state of the project
428 fs::delete_directory(&paths.build_directory_for_target(Mode::Prod, target))?;
429
430 let manifest = build::download_dependencies(paths, cli::Reporter::new())?;
431 let dependencies = manifest
432 .packages
433 .iter()
434 .map(|package| {
435 (
436 package.name.clone(),
437 Dependency {
438 version: package.version.clone(),
439 kind: match &package.source {
440 ManifestPackageSource::Hex { .. } => DependencyKind::Hex,
441 ManifestPackageSource::Git { .. } => DependencyKind::Git,
442 ManifestPackageSource::Local { .. } => DependencyKind::Path,
443 },
444 },
445 )
446 })
447 .collect();
448
449 // Build a lookup from hex package name to OTP application name
450 let hex_to_otp_app = manifest
451 .packages
452 .iter()
453 .filter_map(|package| {
454 package
455 .otp_app
456 .as_ref()
457 .map(|otp_app_name| (package.name.clone(), otp_app_name.clone()))
458 })
459 .collect::<HashMap<_, _>>();
460
461 // Build the project to check that it is valid
462 let built = build::main(
463 paths,
464 Options {
465 root_target_support: TargetSupport::Enforced,
466 warnings_as_errors: false,
467 mode: Mode::Prod,
468 target: Some(target),
469 codegen: Codegen::All,
470 compile: Compile::All,
471 no_print_progress: false,
472 },
473 manifest,
474 )?;
475
476 let minimum_required_version = built.minimum_required_version();
477 match &config.gleam_version {
478 // If the package has no explicit `gleam` version in its `gleam.toml`
479 // then we want to add the automatically inferred one so we know it's
480 // correct and folks getting the package from Hex won't have unpleasant
481 // surprises if the author forgot to manualy write it down.
482 None => {
483 // If we're automatically adding the minimum required version
484 // constraint we want it to at least be `>= 1.0.0`, even if the
485 // inferred lower bound could be lower.
486 let minimum_required_version =
487 std::cmp::max(minimum_required_version, Version::new(1, 0, 0));
488 let inferred_version_range = pubgrub::Range::higher_than(minimum_required_version);
489 config.gleam_version = Some(GleamVersion::from_pubgrub(inferred_version_range));
490 }
491 // Otherwise we need to check that the annotated version range is
492 // correct and includes the minimum required version.
493 Some(gleam_version) => {
494 if let Some(lowest_allowed_version) = gleam_version.lowest_version()
495 && lowest_allowed_version < minimum_required_version
496 {
497 return Err(Error::CannotPublishWrongVersion {
498 minimum_required_version: SmallVersion::from_hexpm(minimum_required_version),
499 wrongfully_allowed_version: SmallVersion::from_hexpm(lowest_allowed_version),
500 });
501 }
502 }
503 }
504
505 // If any of the modules in the package contain a todo or an echo then
506 // refuse to publish as the package is not yet finished.
507 let mut modules_containing_todo = vec![];
508 let mut modules_containing_echo = vec![];
509
510 for module in built.root_package.modules.iter() {
511 if module.ast.type_info.contains_todo() {
512 modules_containing_todo.push(module.name.clone());
513 } else if module.ast.type_info.contains_echo {
514 modules_containing_echo.push(module.name.clone());
515 }
516 }
517
518 if !modules_containing_todo.is_empty() {
519 return Err(Error::CannotPublishTodo {
520 unfinished: modules_containing_todo,
521 });
522 }
523
524 if !modules_containing_echo.is_empty() {
525 return Err(Error::CannotPublishEcho {
526 unfinished: modules_containing_echo,
527 });
528 }
529
530 // empty_modules is a list of modules that do not export any values or types.
531 // We do not allow publishing packages that contain empty modules.
532 let empty_modules: Vec<_> = built
533 .root_package
534 .modules
535 .iter()
536 .filter(|module| {
537 built
538 .module_interfaces
539 .get(&module.name)
540 .map(|interface| {
541 // Check if the module exports any values or types
542 interface.values.is_empty() && interface.types.is_empty()
543 })
544 .unwrap_or(false)
545 })
546 .map(|module| module.name.clone())
547 .collect();
548
549 if !empty_modules.is_empty() {
550 return Err(Error::CannotPublishEmptyModules {
551 unfinished: empty_modules,
552 });
553 }
554
555 // TODO: If any of the modules in the package contain a leaked internal type then
556 // refuse to publish as the package is not yet finished.
557 // We need to move aliases in to the type system first.
558 // context: https://discord.com/channels/768594524158427167/768594524158427170/1227250677734969386
559
560 // Collect all the files we want to include in the tarball
561 let generated_files = match target {
562 Target::Erlang => generated_erlang_files(paths, &built.root_package)?,
563 Target::JavaScript => vec![],
564 };
565 let src_files = project_files(Utf8Path::new(""))?;
566 let contents_tar_gz = contents_tarball(&src_files, &generated_files)?;
567 let version = "3";
568 let metadata = metadata_config(
569 &built.root_package.config,
570 &hex_to_otp_app,
571 &src_files,
572 &generated_files,
573 )?;
574
575 // Calculate checksum
576 let mut hasher = sha2::Sha256::new();
577 hasher.update(version.as_bytes());
578 hasher.update(metadata.as_bytes());
579 hasher.update(contents_tar_gz.as_slice());
580 let checksum = base16::encode_upper(&hasher.finalize());
581 tracing::info!(checksum = %checksum, "Generated Hex package inner checksum");
582
583 // Build tarball
584 let mut tarball = Vec::new();
585 {
586 let mut tarball = tar::Builder::new(&mut tarball);
587 add_to_tar(&mut tarball, "VERSION", version.as_bytes())?;
588 add_to_tar(&mut tarball, "metadata.config", metadata.as_bytes())?;
589 add_to_tar(&mut tarball, "contents.tar.gz", contents_tar_gz.as_slice())?;
590 add_to_tar(&mut tarball, "CHECKSUM", checksum.as_bytes())?;
591 tarball.finish().map_err(Error::finish_tar)?;
592 }
593 tracing::info!("Generated package Hex release tarball");
594 Ok(Tarball {
595 compile_result: built.root_package,
596 cached_modules: built.module_interfaces,
597 data: tarball,
598 src_files_added: src_files,
599 generated_files_added: generated_files,
600 dependencies,
601 })
602}
603
604fn check_config_for_publishing(config: &PackageConfig) -> Result<()> {
605 // These fields are required to publish a Hex package. Hex will reject
606 // packages without them.
607 if config.description.is_empty() || config.licences.is_empty() {
608 Err(Error::MissingHexPublishFields {
609 description_missing: config.description.is_empty(),
610 licence_missing: config.licences.is_empty(),
611 })
612 } else {
613 Ok(())
614 }
615}
616
617fn metadata_config<'a>(
618 config: &'a PackageConfig,
619 hex_to_otp_app: &'a HashMap<EcoString, EcoString>,
620 source_files: &[Utf8PathBuf],
621 generated_files: &[(Utf8PathBuf, String)],
622) -> Result<String> {
623 let repo_url = http::Uri::try_from(
624 config
625 .repository
626 .as_ref()
627 .map(|r| r.url())
628 .unwrap_or_default(),
629 )
630 .ok();
631 let requirements: Result<Vec<ReleaseRequirement<'a>>> = config
632 .dependencies
633 .iter()
634 .map(|(name, requirement)| match requirement {
635 Requirement::Hex { version } => Ok(ReleaseRequirement {
636 name,
637 otp_app: hex_to_otp_app
638 .get(name)
639 .map(EcoString::as_str)
640 .unwrap_or(name.as_str()),
641 requirement: version,
642 }),
643 _ => Err(Error::PublishNonHexDependencies {
644 package: name.to_string(),
645 }),
646 })
647 .collect();
648 let metadata = ReleaseMetadata {
649 name: &config.name,
650 version: &config.version,
651 description: &config.description,
652 source_files,
653 generated_files,
654 licenses: &config.licences,
655 links: config
656 .links
657 .iter()
658 .map(|l| (l.title.as_str(), l.href.clone()))
659 .chain(repo_url.into_iter().map(|u| ("Repository", u)))
660 .collect(),
661 requirements: requirements?,
662 build_tools: vec!["gleam"],
663 }
664 .as_erlang();
665 tracing::info!(contents = ?metadata, "Generated Hex metadata.config");
666 Ok(metadata)
667}
668
669fn contents_tarball(
670 files: &[Utf8PathBuf],
671 data_files: &[(Utf8PathBuf, String)],
672) -> Result<Vec<u8>, Error> {
673 let mut contents_tar_gz = Vec::new();
674 {
675 let mut tarball =
676 tar::Builder::new(GzEncoder::new(&mut contents_tar_gz, Compression::default()));
677 for path in files {
678 add_path_to_tar(&mut tarball, path)?;
679 }
680 for (path, contents) in data_files {
681 add_to_tar(&mut tarball, path, contents.as_bytes())?;
682 }
683 tarball.finish().map_err(Error::finish_tar)?;
684 }
685 tracing::info!("Generated contents.tar.gz");
686 Ok(contents_tar_gz)
687}
688
689fn project_files(base_path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
690 let src = base_path.join(Utf8Path::new("src"));
691 let mut files: Vec<Utf8PathBuf> = fs::gleam_files(&src)
692 .chain(fs::native_files(&src))
693 .collect();
694 let private = base_path.join(Utf8Path::new("priv"));
695 let mut private_files: Vec<Utf8PathBuf> = fs::private_files(&private).collect();
696 files.append(&mut private_files);
697 let mut add = |path| {
698 let path = base_path.join(path);
699 if path.exists() {
700 files.push(path);
701 }
702 };
703 add("README");
704 add("README.md");
705 add("README.txt");
706 add("gleam.toml");
707 add("LICENSE");
708 add("LICENCE");
709 add("LICENSE.md");
710 add("LICENCE.md");
711 add("LICENSE.txt");
712 add("LICENCE.txt");
713 add("NOTICE");
714 add("NOTICE.md");
715 add("NOTICE.txt");
716 Ok(files)
717}
718
719// TODO: test
720fn generated_erlang_files(
721 paths: &ProjectPaths,
722 package: &Package,
723) -> Result<Vec<(Utf8PathBuf, String)>> {
724 let mut files = vec![];
725
726 let dir = paths.build_directory_for_package(Mode::Prod, Target::Erlang, &package.config.name);
727 let ebin = dir.join("ebin");
728 let build = dir.join(paths::ARTEFACT_DIRECTORY_NAME);
729 let include = dir.join("include");
730
731 let tar_src = Utf8Path::new("src");
732 let tar_include = Utf8Path::new("include");
733
734 // Erlang modules
735 for module in &package.modules {
736 // Do not publish test/ and dev/ code
737 if !module.origin.is_src() {
738 continue;
739 }
740
741 let name = module.compiled_erlang_path();
742 files.push((tar_src.join(&name), fs::read(build.join(name))?));
743 }
744
745 // Erlang headers
746 if include.is_dir() {
747 for file in fs::erlang_files(&include) {
748 let name = file.file_name().expect("generated_files include file name");
749 files.push((tar_include.join(name), fs::read(file)?));
750 }
751 }
752
753 // src/package.app.src file
754 let app = format!("{}.app", &package.config.name);
755 let appsrc = format!("{}.src", &app);
756 files.push((tar_src.join(appsrc), fs::read(ebin.join(app))?));
757
758 Ok(files)
759}
760
761fn add_to_tar<P, W>(tarball: &mut tar::Builder<W>, path: P, data: &[u8]) -> Result<()>
762where
763 P: AsRef<Utf8Path>,
764 W: Write,
765{
766 let path = path.as_ref();
767 tracing::info!(file=?path, "Adding file to tarball");
768 let mut header = tar::Header::new_gnu();
769 header.set_mode(0o600);
770 header.set_size(data.len() as u64);
771 header.set_cksum();
772 tarball
773 .append_data(&mut header, path, data)
774 .map_err(|e| Error::add_tar(path, e))
775}
776
777fn add_path_to_tar<P, W>(tarball: &mut tar::Builder<W>, path: P) -> Result<()>
778where
779 P: AsRef<Utf8Path>,
780 W: Write,
781{
782 let path = path.as_ref();
783 tracing::info!(file=?path, "Adding file to tarball");
784 tarball
785 .append_path(path)
786 .map_err(|e| Error::add_tar(path, e))
787}
788
789#[derive(Debug, Clone)]
790pub struct ReleaseMetadata<'a> {
791 name: &'a str,
792 version: &'a Version,
793 description: &'a str,
794 source_files: &'a [Utf8PathBuf],
795 generated_files: &'a [(Utf8PathBuf, String)],
796 licenses: &'a Vec<SpdxLicense>,
797 links: Vec<(&'a str, http::Uri)>,
798 requirements: Vec<ReleaseRequirement<'a>>,
799 build_tools: Vec<&'a str>,
800 // What should this be? I can't find it in the API anywhere.
801 // extra: (kvlist(string => kvlist(...))) (optional)
802}
803
804impl ReleaseMetadata<'_> {
805 pub fn as_erlang(&self) -> String {
806 fn link(link: &(&str, http::Uri)) -> String {
807 format!(
808 "\n {{<<\"{name}\"/utf8>>, <<\"{url}\"/utf8>>}}",
809 name = link.0,
810 url = link.1
811 )
812 }
813 fn file(name: impl AsRef<Utf8Path>) -> String {
814 format!("\n <<\"{name}\"/utf8>>", name = name.as_ref())
815 }
816
817 format!(
818 r#"{{<<"name">>, <<"{name}"/utf8>>}}.
819{{<<"app">>, <<"{name}"/utf8>>}}.
820{{<<"version">>, <<"{version}"/utf8>>}}.
821{{<<"description">>, <<"{description}"/utf8>>}}.
822{{<<"licenses">>, [{licenses}]}}.
823{{<<"build_tools">>, [{build_tools}]}}.
824{{<<"links">>, [{links}
825]}}.
826{{<<"requirements">>, [{requirements}
827]}}.
828{{<<"files">>, [{files}
829]}}.
830"#,
831 name = self.name,
832 version = self.version,
833 description = self.description,
834 files = self
835 .source_files
836 .iter()
837 .chain(self.generated_files.iter().map(|(p, _)| p))
838 .map(file)
839 .sorted()
840 .join(","),
841 links = self.links.iter().map(link).join(","),
842 licenses = self.licenses.iter().map(|l| quotes(l.as_ref())).join(", "),
843 build_tools = self.build_tools.iter().map(|l| quotes(l)).join(", "),
844 requirements = self
845 .requirements
846 .iter()
847 .map(ReleaseRequirement::as_erlang)
848 .join(",")
849 )
850 }
851}
852
853#[derive(Debug, Clone)]
854struct ReleaseRequirement<'a> {
855 name: &'a str,
856 // optional: bool,
857 requirement: &'a Range,
858 // Support alternate repositories at a later date.
859 // repository: String,
860 otp_app: &'a str,
861}
862impl ReleaseRequirement<'_> {
863 pub fn as_erlang(&self) -> String {
864 format!(
865 r#"
866 {{<<"{name}"/utf8>>, [
867 {{<<"app">>, <<"{otp_app}"/utf8>>}},
868 {{<<"optional">>, false}},
869 {{<<"requirement">>, <<"{requirement}"/utf8>>}}
870 ]}}"#,
871 name = self.name,
872 otp_app = self.otp_app,
873 requirement = self.requirement,
874 )
875 }
876}
877
878#[test]
879fn release_metadata_as_erlang() {
880 let licences = vec![
881 SpdxLicense {
882 licence: "MIT".into(),
883 },
884 SpdxLicense {
885 licence: "MPL-2.0".into(),
886 },
887 ];
888 let version = "1.2.3".try_into().unwrap();
889 let homepage = "https://gleam.run".parse().unwrap();
890 let github = "https://github.com/lpil/myapp".parse().unwrap();
891 let req1 = Range::new("~> 1.2.3 or >= 5.0.0".into()).unwrap();
892 let req2 = Range::new("~> 1.2".into()).unwrap();
893 let meta = ReleaseMetadata {
894 name: "myapp",
895 version: &version,
896 description: "description goes here 🌈",
897 source_files: &[
898 Utf8PathBuf::from("gleam.toml"),
899 Utf8PathBuf::from("src/thingy.gleam"),
900 Utf8PathBuf::from("src/whatever.gleam"),
901 ],
902 generated_files: &[
903 (Utf8PathBuf::from("src/myapp.app"), "".into()),
904 (Utf8PathBuf::from("src/thingy.erl"), "".into()),
905 (Utf8PathBuf::from("src/whatever.erl"), "".into()),
906 ],
907 licenses: &licences,
908 links: vec![("homepage", homepage), ("github", github)],
909 requirements: vec![
910 ReleaseRequirement {
911 name: "wibble",
912 otp_app: "wibble",
913 requirement: &req1,
914 },
915 ReleaseRequirement {
916 name: "wobble",
917 otp_app: "wobble",
918 requirement: &req2,
919 },
920 ReleaseRequirement {
921 name: "weeble_erl",
922 otp_app: "weeble",
923 requirement: &req2,
924 },
925 ],
926 build_tools: vec!["gleam", "rebar3"],
927 };
928 assert_eq!(
929 meta.as_erlang(),
930 r#"{<<"name">>, <<"myapp"/utf8>>}.
931{<<"app">>, <<"myapp"/utf8>>}.
932{<<"version">>, <<"1.2.3"/utf8>>}.
933{<<"description">>, <<"description goes here 🌈"/utf8>>}.
934{<<"licenses">>, [<<"MIT"/utf8>>, <<"MPL-2.0"/utf8>>]}.
935{<<"build_tools">>, [<<"gleam"/utf8>>, <<"rebar3"/utf8>>]}.
936{<<"links">>, [
937 {<<"homepage"/utf8>>, <<"https://gleam.run/"/utf8>>},
938 {<<"github"/utf8>>, <<"https://github.com/lpil/myapp"/utf8>>}
939]}.
940{<<"requirements">>, [
941 {<<"wibble"/utf8>>, [
942 {<<"app">>, <<"wibble"/utf8>>},
943 {<<"optional">>, false},
944 {<<"requirement">>, <<"~> 1.2.3 or >= 5.0.0"/utf8>>}
945 ]},
946 {<<"wobble"/utf8>>, [
947 {<<"app">>, <<"wobble"/utf8>>},
948 {<<"optional">>, false},
949 {<<"requirement">>, <<"~> 1.2"/utf8>>}
950 ]},
951 {<<"weeble_erl"/utf8>>, [
952 {<<"app">>, <<"weeble"/utf8>>},
953 {<<"optional">>, false},
954 {<<"requirement">>, <<"~> 1.2"/utf8>>}
955 ]}
956]}.
957{<<"files">>, [
958 <<"gleam.toml"/utf8>>,
959 <<"src/myapp.app"/utf8>>,
960 <<"src/thingy.erl"/utf8>>,
961 <<"src/thingy.gleam"/utf8>>,
962 <<"src/whatever.erl"/utf8>>,
963 <<"src/whatever.gleam"/utf8>>
964]}.
965"#
966 .to_string()
967 );
968}
969
970#[test]
971fn prevent_publish_local_dependency() {
972 let config = PackageConfig {
973 dependencies: [("provided".into(), Requirement::path("./path/to/package"))].into(),
974 ..Default::default()
975 };
976 assert_eq!(
977 metadata_config(&config, &HashMap::new(), &[], &[]),
978 Err(Error::PublishNonHexDependencies {
979 package: "provided".into()
980 })
981 );
982}
983
984#[test]
985fn prevent_publish_git_dependency() {
986 let config = PackageConfig {
987 dependencies: [(
988 "provided".into(),
989 Requirement::git("https://github.com/gleam-lang/gleam.git", "da6e917"),
990 )]
991 .into(),
992 ..Default::default()
993 };
994 assert_eq!(
995 metadata_config(&config, &HashMap::new(), &[], &[]),
996 Err(Error::PublishNonHexDependencies {
997 package: "provided".into()
998 })
999 );
1000}
1001
1002fn quotes(x: &str) -> String {
1003 format!(r#"<<"{x}"/utf8>>"#)
1004}
1005
1006#[test]
1007fn exported_project_files_test() {
1008 let tmp = tempfile::tempdir().unwrap();
1009 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path");
1010
1011 let exported_project_files = &[
1012 "LICENCE",
1013 "LICENCE.md",
1014 "LICENCE.txt",
1015 "LICENSE",
1016 "LICENSE.md",
1017 "LICENSE.txt",
1018 "NOTICE",
1019 "NOTICE.md",
1020 "NOTICE.txt",
1021 "README",
1022 "README.md",
1023 "README.txt",
1024 "gleam.toml",
1025 "priv/ignored",
1026 "priv/wibble",
1027 "priv/wobble.js",
1028 "src/.hidden/hidden_ffi.erl",
1029 "src/.hidden/hidden_ffi.mjs",
1030 "src/.hidden_ffi.erl",
1031 "src/.hidden_ffi.mjs",
1032 "src/exported.gleam",
1033 "src/exported_ffi.erl",
1034 "src/exported_ffi.ex",
1035 "src/exported_ffi.hrl",
1036 "src/exported_ffi.js",
1037 "src/exported_ffi.mjs",
1038 "src/exported_ffi.ts",
1039 "src/ignored.gleam",
1040 "src/ignored_ffi.erl",
1041 "src/ignored_ffi.mjs",
1042 "src/nested/exported.gleam",
1043 "src/nested/exported_ffi.erl",
1044 "src/nested/exported_ffi.ex",
1045 "src/nested/exported_ffi.hrl",
1046 "src/nested/exported_ffi.js",
1047 "src/nested/exported_ffi.mjs",
1048 "src/nested/exported_ffi.ts",
1049 "src/nested/ignored.gleam",
1050 "src/nested/ignored_ffi.erl",
1051 "src/nested/ignored_ffi.mjs",
1052 ];
1053
1054 let unexported_project_files = &[
1055 ".git/",
1056 ".github/workflows/test.yml",
1057 ".gitignore",
1058 "build/",
1059 "ignored.txt",
1060 "src/.hidden/hidden.gleam", // Not a valid Gleam module path
1061 "src/.hidden.gleam", // Not a valid Gleam module name
1062 "src/also-ignored.gleam", // Not a valid Gleam module name
1063 "test/exported_test.gleam",
1064 "test/exported_test_ffi.erl",
1065 "test/exported_test_ffi.ex",
1066 "test/exported_test_ffi.hrl",
1067 "test/exported_test_ffi.js",
1068 "test/exported_test_ffi.mjs",
1069 "test/exported_test_ffi.ts",
1070 "test/ignored_test.gleam",
1071 "test/ignored_test_ffi.erl",
1072 "test/ignored_test_ffi.mjs",
1073 "test/nested/exported_test.gleam",
1074 "test/nested/exported_test_ffi.erl",
1075 "test/nested/exported_test_ffi.ex",
1076 "test/nested/exported_test_ffi.hrl",
1077 "test/nested/exported_test_ffi.js",
1078 "test/nested/exported_test_ffi.mjs",
1079 "test/nested/exported_test_ffi.ts",
1080 "test/nested/ignored.gleam",
1081 "test/nested/ignored_test_ffi.erl",
1082 "test/nested/ignored_test_ffi.mjs",
1083 "dev/exported_test_ffi.erl",
1084 "dev/exported_test_ffi.ex",
1085 "dev/exported_test_ffi.hrl",
1086 "dev/exported_test_ffi.js",
1087 "dev/exported_test_ffi.mjs",
1088 "dev/exported_test_ffi.ts",
1089 "dev/ignored_test.gleam",
1090 "dev/ignored_test_ffi.erl",
1091 "dev/ignored_test_ffi.mjs",
1092 "dev/nested/exported_test.gleam",
1093 "dev/nested/exported_test_ffi.erl",
1094 "dev/nested/exported_test_ffi.ex",
1095 "dev/nested/exported_test_ffi.hrl",
1096 "dev/nested/exported_test_ffi.js",
1097 "dev/nested/exported_test_ffi.mjs",
1098 "dev/nested/exported_test_ffi.ts",
1099 "dev/nested/ignored.gleam",
1100 "dev/nested/ignored_test_ffi.erl",
1101 "dev/nested/ignored_test_ffi.mjs",
1102 "unrelated-file.txt",
1103 ];
1104
1105 let gitignore = "ignored*
1106src/also-ignored.gleam";
1107
1108 for &file in exported_project_files
1109 .iter()
1110 .chain(unexported_project_files)
1111 {
1112 if file.ends_with("/") {
1113 fs::mkdir(path.join(file)).unwrap();
1114 continue;
1115 }
1116
1117 let contents = match file {
1118 ".gitignore" => gitignore,
1119 _ => "",
1120 };
1121
1122 fs::write(&path.join(file), contents).unwrap();
1123 }
1124
1125 let mut chosen_exported_files = project_files(&path).unwrap();
1126 chosen_exported_files.sort_unstable();
1127
1128 let expected_exported_files = exported_project_files
1129 .iter()
1130 .map(|s| path.join(s))
1131 .collect_vec();
1132
1133 assert_eq!(expected_exported_files, chosen_exported_files);
1134}