// SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2020 The Gleam contributors mod stale_package_remover; use crate::error::{FileIoAction, FileKind}; use crate::io::FileSystemReader; use crate::io::ordered_map; use crate::manifest::Manifest; use crate::requirement::Requirement; use crate::version::COMPILER_VERSION; use crate::{Error, Result}; use camino::{Utf8Path, Utf8PathBuf}; use ecow::EcoString; use globset::{Glob, GlobSetBuilder}; use hexpm::version::{self, LowestVersion, Version}; use http::Uri; use regex::Regex; use serde::ser::SerializeSeq; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt::{self}; use std::marker::PhantomData; use std::sync::OnceLock; #[cfg(test)] use crate::manifest::ManifestPackage; use crate::build::{Mode, Runtime, Target}; fn default_version() -> Version { Version::parse("0.1.0").expect("default version") } fn erlang_target() -> Target { Target::Erlang } fn default_javascript_runtime() -> Runtime { Runtime::NodeJs } #[derive(Clone, Debug, PartialEq, Eq)] pub struct SpdxLicense { pub licence: String, } impl<'de> Deserialize<'de> for SpdxLicense { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { deserializer.deserialize_str(SpdxLicenseVisitor) } } struct SpdxLicenseVisitor; impl<'de> serde::de::Visitor<'de> for SpdxLicenseVisitor { type Value = SpdxLicense; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("a SPDX License ID") } fn visit_str(self, value: &str) -> Result where E: serde::de::Error, { match spdx::license_id(value) { None => Err(serde::de::Error::custom(format!( "{value} is not a known SPDX License identifier" ))), Some(_) => Ok(SpdxLicense { licence: value.to_string(), }), } } } impl Serialize for SpdxLicense { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(&self.licence) } } impl AsRef for SpdxLicense { fn as_ref(&self) -> &str { self.licence.as_str() } } #[derive(Debug, PartialEq, Clone)] pub struct GleamVersion(version::Range); impl From for GleamVersion { fn from(range: version::Range) -> Self { Self(range) } } impl From for version::Range { fn from(gleam_version: GleamVersion) -> Self { gleam_version.0 } } impl From for pubgrub::Range { fn from(gleam_version: GleamVersion) -> Self { gleam_version.0.into() } } impl GleamVersion { pub fn from_pubgrub(range: pubgrub::Range) -> Self { let range: version::Range = range.into(); range.into() } pub fn as_pubgrub(&self) -> &pubgrub::Range { self.0.to_pubgrub() } pub fn new(spec: String) -> Result { let hex = version::Range::new(spec.to_string()).map_err(|e| Error::InvalidVersionFormat { input: spec, error: e.to_string(), })?; Ok(hex.into()) } pub fn lowest_version(&self) -> Option { self.as_pubgrub().lowest_version() } pub fn hex(&self) -> &version::Range { &self.0 } } #[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] pub struct PackageConfig { #[serde(deserialize_with = "package_name::deserialize")] pub name: EcoString, #[serde(default = "default_version")] pub version: Version, #[serde( default, rename = "gleam", deserialize_with = "deserialise_gleam_version", serialize_with = "serialise_gleam_version" )] pub gleam_version: Option, #[serde(default, alias = "licenses")] pub licences: Vec, #[serde(default)] pub description: EcoString, #[serde(default, alias = "docs")] pub documentation: Docs, #[serde( default, serialize_with = "ordered_map", deserialize_with = "map_with_package_name_keys::deserialize" )] pub dependencies: HashMap, #[serde( default, alias = "dev-dependencies", serialize_with = "ordered_map", deserialize_with = "map_with_package_name_keys::deserialize" )] pub dev_dependencies: HashMap, #[serde(default)] pub repository: Option, #[serde(default)] pub links: Vec, #[serde(default)] pub erlang: ErlangConfig, #[serde(default)] pub javascript: JavaScriptConfig, #[serde(default = "erlang_target")] pub target: Target, #[serde(default)] pub internal_modules: Option>, } pub fn serialise_gleam_version( gleam_gersion: &Option, serializer: S, ) -> Result where S: serde::Serializer, { match gleam_gersion { Some(version) => serializer.serialize_str(&version.hex().to_string()), None => serializer.serialize_none(), } } pub fn deserialise_gleam_version<'de, D>(deserialiser: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { match Deserialize::deserialize(deserialiser)? { Some(range_string) => { let hex = version::Range::new(range_string).map_err(serde::de::Error::custom)?; Ok(Some(hex.into())) } None => Ok(None), } } impl PackageConfig { pub fn dependencies_for(&self, mode: Mode) -> Result> { match mode { Mode::Dev | Mode::Lsp => self.all_direct_dependencies(), Mode::Prod => Ok(self.dependencies.clone()), } } // Return all the dependencies listed in the configuration, that is, all the // direct dependencies, both in the `dependencies` and `dev_dependencies`. pub fn all_direct_dependencies(&self) -> Result> { let mut deps = HashMap::with_capacity(self.dependencies.len() + self.dev_dependencies.len()); for (name, requirement) in self.dependencies.iter().chain(&self.dev_dependencies) { let already_inserted = deps.insert(name.clone(), requirement.clone()).is_some(); if already_inserted { return Err(Error::DuplicateDependency(name.clone())); } } Ok(deps) } pub fn read>( path: P, fs: &FS, ) -> Result { let toml = fs.read(path.as_ref())?; deserialise_config(path, toml) } /// Get the locked packages for the current config and a given (optional) /// manifest of previously locked packages. /// /// If a package is removed or the specified required version range for it /// changes then it is not considered locked. This also goes for any child /// packages of the package which have no other parents. /// /// This function should be used each time resolution is performed so that /// outdated deps are removed from the manifest and not locked to the /// previously selected versions. /// pub fn locked(&self, manifest: Option<&Manifest>) -> Result> { match manifest { None => Ok(HashMap::new()), Some(manifest) => { let requirements = self.all_direct_dependencies()?; let fresh_and_locked = stale_package_remover::StalePackageRemover::fresh_and_locked( &requirements, manifest, ); Ok(fresh_and_locked) } } } /// Determines whether the given module should be hidden in the docs or not /// /// The developer can specify a list of glob patterns in the gleam.toml file /// to determine modules that should not be shown in the package's documentation pub fn is_internal_module(&self, module: &str) -> bool { let package = &self.name; match &self.internal_modules { Some(globs) => { let mut builder = GlobSetBuilder::new(); for glob in globs { _ = builder.add(glob.clone()); } builder.build() } // If no patterns were specified in the config then we use a default value None => GlobSetBuilder::new() .add(Glob::new(&format!("{package}/internal")).expect("internal module glob")) .add(Glob::new(&format!("{package}/internal/*")).expect("internal module glob")) .build(), } .expect("internal module globs") .is_match(module) } // Checks to see if the gleam version specified in the config is compatible // with the current compiler version pub fn check_gleam_compatibility(&self) -> Result<(), Error> { if let Some(version) = &self.gleam_version { let range = version.as_pubgrub(); let compiler_version = Version::parse(COMPILER_VERSION).expect("Parse compiler semantic version"); // We ignore the pre-release and build metadata when checking compatibility let mut version_without_pre = compiler_version; version_without_pre.pre = vec![]; version_without_pre.build = None; if !range.contains(&version_without_pre) { return Err(Error::IncompatibleCompilerVersion { package: self.name.to_string(), required_version: range.to_string(), gleam_version: COMPILER_VERSION.to_string(), }); } } Ok(()) } pub fn tag_for_version(&self, version: &Version) -> String { let prefix = match self.repository.as_ref() { Some( Repository::GitHub { tag_prefix, .. } | Repository::GitLab { tag_prefix, .. } | Repository::BitBucket { tag_prefix, .. } | Repository::Codeberg { tag_prefix, .. } | Repository::SourceHut { tag_prefix, .. } | Repository::Gitea { tag_prefix, .. } | Repository::Forgejo { tag_prefix, .. } | Repository::Tangled { tag_prefix, .. }, ) => tag_prefix.as_ref(), Some(Repository::Custom { .. }) | None => None, }; match prefix { Some(prefix) => format!("{prefix}v{version}"), None => format!("v{version}"), } } } fn deserialise_config>( path: P, toml: String, ) -> std::result::Result { let config: PackageConfig = toml::from_str(&toml).map_err(|e| Error::FileIo { action: FileIoAction::Parse, kind: FileKind::File, path: path.as_ref().to_path_buf(), err: Some(e.to_string()), })?; Ok(config) } // https://github.com/gleam-lang/gleam/issues/4867 #[test] fn deny_extra_deps_properties() { let toml = r#" name = "wibble" version = "1.0.0" [dependencies] aide_generator = { git = "git@github.com:crowdhailer/aide.git", ref = "f559c5bc", extra = "idk what this is" } "#; let error = deserialise_config("gleam.toml", toml.into()) .expect_err("should fail to deserialise because of additional path"); insta::assert_snapshot!(insta::internals::AutoName, error.pretty_string()); } #[test] fn invalid_dependency_name() { let toml = r#" name = "wibble" version = "1.0.0" [dependencies] Hello = "> 1.0.0" "#; let error = deserialise_config("gleam.toml", toml.into()) .expect_err("should fail to deserialise because invalid name"); insta::assert_snapshot!(insta::internals::AutoName, error.pretty_string()); } #[test] fn invalid_dev_dependency_name() { let toml = r#" name = "wibble" version = "1.0.0" [dev_dependencies] "yes." = "> 1.0.0" "#; let error = deserialise_config("gleam.toml", toml.into()) .expect_err("should fail to deserialise because invalid name"); insta::assert_snapshot!(insta::internals::AutoName, error.pretty_string()); } #[test] fn locked_no_manifest() { let mut config = PackageConfig::default(); config.dependencies = [ ("prod1".into(), Requirement::hex("~> 1.0").unwrap()), ("prod2".into(), Requirement::hex("~> 2.0").unwrap()), ] .into(); config.dev_dependencies = [ ("dev1".into(), Requirement::hex("~> 1.0").unwrap()), ("dev2".into(), Requirement::hex("~> 2.0").unwrap()), ] .into(); assert_eq!(config.locked(None).unwrap(), [].into()); } #[test] fn locked_no_changes() { let mut config = PackageConfig::default(); config.dependencies = [ ("prod1".into(), Requirement::hex("~> 1.0").unwrap()), ("prod2".into(), Requirement::hex("~> 2.0").unwrap()), ] .into(); config.dev_dependencies = [ ("dev1".into(), Requirement::hex("~> 1.0").unwrap()), ("dev2".into(), Requirement::hex("~> 2.0").unwrap()), ] .into(); let manifest = Manifest { requirements: config.all_direct_dependencies().unwrap(), packages: vec![ manifest_package("prod1", "1.1.0", &[]), manifest_package("prod2", "1.2.0", &[]), manifest_package("dev1", "1.1.0", &[]), manifest_package("dev2", "1.2.0", &[]), ], }; assert_eq!( config.locked(Some(&manifest)).unwrap(), [ locked_version("prod1", "1.1.0"), locked_version("prod2", "1.2.0"), locked_version("dev1", "1.1.0"), locked_version("dev2", "1.2.0"), ] .into() ); } #[test] fn locked_some_removed() { let mut config = PackageConfig::default(); config.dependencies = [("prod1".into(), Requirement::hex("~> 1.0").unwrap())].into(); config.dev_dependencies = [("dev2".into(), Requirement::hex("~> 2.0").unwrap())].into(); let manifest = Manifest { requirements: config.all_direct_dependencies().unwrap(), packages: vec![ manifest_package("prod1", "1.1.0", &[]), manifest_package("prod2", "1.2.0", &[]), // Not in config manifest_package("dev1", "1.1.0", &[]), // Not in config manifest_package("dev2", "1.2.0", &[]), ], }; assert_eq!( config.locked(Some(&manifest)).unwrap(), [ // prod2 removed // dev1 removed locked_version("prod1", "1.1.0"), locked_version("dev2", "1.2.0"), ] .into() ); } #[test] fn locked_some_changed() { let mut config = PackageConfig::default(); config.dependencies = [ ("prod1".into(), Requirement::hex("~> 3.0").unwrap()), // Does not match manifest ("prod2".into(), Requirement::hex("~> 2.0").unwrap()), ] .into(); config.dev_dependencies = [ ("dev1".into(), Requirement::hex("~> 3.0").unwrap()), // Does not match manifest ("dev2".into(), Requirement::hex("~> 2.0").unwrap()), ] .into(); let manifest = Manifest { requirements: [ ("prod1".into(), Requirement::hex("~> 1.0").unwrap()), ("prod2".into(), Requirement::hex("~> 2.0").unwrap()), ("dev1".into(), Requirement::hex("~> 1.0").unwrap()), ("dev2".into(), Requirement::hex("~> 2.0").unwrap()), ] .into(), packages: vec![ manifest_package("prod1", "1.1.0", &[]), manifest_package("prod2", "1.2.0", &[]), manifest_package("dev1", "1.1.0", &[]), manifest_package("dev2", "1.2.0", &[]), ], }; assert_eq!( config.locked(Some(&manifest)).unwrap(), [ // prod1 removed // dev1 removed locked_version("prod2", "1.2.0"), locked_version("dev2", "1.2.0"), ] .into() ); } #[test] fn locked_nested_are_removed_too() { let mut config = PackageConfig::default(); config.dependencies = [ ("1".into(), Requirement::hex("~> 2.0").unwrap()), // Does not match manifest ("2".into(), Requirement::hex("~> 1.0").unwrap()), ] .into(); config.dev_dependencies = [].into(); let manifest = Manifest { requirements: [ ("1".into(), Requirement::hex("~> 1.0").unwrap()), ("2".into(), Requirement::hex("~> 1.0").unwrap()), ] .into(), packages: vec![ manifest_package("1", "1.1.0", &["1.1", "1.2"]), manifest_package("1.1", "1.1.0", &["1.1.1", "1.1.2"]), manifest_package("1.1.1", "1.1.0", &["shared"]), manifest_package("1.1.2", "1.1.0", &[]), manifest_package("1.2", "1.1.0", &["1.2.1", "1.2.2"]), manifest_package("1.2.1", "1.1.0", &[]), manifest_package("1.2.2", "1.1.0", &[]), manifest_package("2", "2.1.0", &["2.1", "2.2"]), manifest_package("2.1", "2.1.0", &["2.1.1", "2.1.2"]), manifest_package("2.1.1", "2.1.0", &[]), manifest_package("2.1.2", "2.1.0", &[]), manifest_package("2.2", "2.1.0", &["2.2.1", "2.2.2", "shared"]), manifest_package("2.2.1", "2.1.0", &[]), manifest_package("2.2.2", "2.1.0", &[]), manifest_package("shared", "2.1.0", &[]), ], }; assert_eq!( config.locked(Some(&manifest)).unwrap(), [ // 1* removed locked_version("2", "2.1.0"), locked_version("2.1", "2.1.0"), locked_version("2.1.1", "2.1.0"), locked_version("2.1.2", "2.1.0"), locked_version("2.2", "2.1.0"), locked_version("2.2.1", "2.1.0"), locked_version("2.2.2", "2.1.0"), locked_version("shared", "2.1.0"), ] .into() ); } // https://github.com/gleam-lang/gleam/issues/1754 #[test] fn locked_unlock_new() { let mut config = PackageConfig::default(); config.dependencies = [ ("1".into(), Requirement::hex("~> 1.0").unwrap()), ("2".into(), Requirement::hex("~> 1.0").unwrap()), ("3".into(), Requirement::hex("~> 3.0").unwrap()), // Does not match manifest ] .into(); config.dev_dependencies = [].into(); let manifest = Manifest { requirements: [ ("1".into(), Requirement::hex("~> 1.0").unwrap()), ("2".into(), Requirement::hex("~> 1.0").unwrap()), ] .into(), packages: vec![ manifest_package("1", "1.1.0", &["3"]), manifest_package("2", "1.1.0", &["3"]), manifest_package("3", "1.1.0", &[]), ], }; assert_eq!( config.locked(Some(&manifest)).unwrap(), [locked_version("1", "1.1.0"), locked_version("2", "1.1.0"),].into() ) } #[test] fn default_internal_modules() { // When no internal modules are specified then we default to // `["$package/internal", "$package/internal/*"]` let mut config = PackageConfig::default(); config.name = "my_package".into(); config.internal_modules = None; assert!(config.is_internal_module("my_package/internal")); assert!(config.is_internal_module("my_package/internal/wibble")); assert!(config.is_internal_module("my_package/internal/wibble/wobble")); assert!(!config.is_internal_module("my_package/internallll")); assert!(!config.is_internal_module("my_package/other")); assert!(!config.is_internal_module("my_package/other/wibble")); assert!(!config.is_internal_module("other/internal")); } #[test] fn no_internal_modules() { // When no internal modules are specified then we default to // `["$package/internal", "$package/internal/*"]` let mut config = PackageConfig::default(); config.name = "my_package".into(); config.internal_modules = Some(vec![]); assert!(!config.is_internal_module("my_package/internal")); assert!(!config.is_internal_module("my_package/internal/wibble")); assert!(!config.is_internal_module("my_package/internal/wibble/wobble")); assert!(!config.is_internal_module("my_package/internallll")); assert!(!config.is_internal_module("my_package/other")); assert!(!config.is_internal_module("my_package/other/wibble")); assert!(!config.is_internal_module("other/internal")); } #[test] fn hidden_a_directory_from_docs() { let mut config = PackageConfig::default(); config.internal_modules = Some(vec![Glob::new("package/internal/*").expect("")]); let mod1 = "package/internal"; let mod2 = "package/internal/module"; assert_eq!(config.is_internal_module(mod1), false); assert_eq!(config.is_internal_module(mod2), true); } #[test] fn hidden_two_directories_from_docs() { let mut config = PackageConfig::default(); config.internal_modules = Some(vec![ Glob::new("package/internal1/*").expect(""), Glob::new("package/internal2/*").expect(""), ]); let mod1 = "package/internal1"; let mod2 = "package/internal1/module"; let mod3 = "package/internal2"; let mod4 = "package/internal2/module"; assert_eq!(config.is_internal_module(mod1), false); assert_eq!(config.is_internal_module(mod2), true); assert_eq!(config.is_internal_module(mod3), false); assert_eq!(config.is_internal_module(mod4), true); } #[test] fn hidden_a_directory_and_a_file_from_docs() { let mut config = PackageConfig::default(); config.internal_modules = Some(vec![ Glob::new("package/internal1/*").expect(""), Glob::new("package/module").expect(""), ]); let mod1 = "package/internal1"; let mod2 = "package/internal1/module"; let mod3 = "package/module"; let mod4 = "package/module/inner"; assert_eq!(config.is_internal_module(mod1), false); assert_eq!(config.is_internal_module(mod2), true); assert_eq!(config.is_internal_module(mod3), true); assert_eq!(config.is_internal_module(mod4), false); } #[test] fn hidden_a_file_in_all_directories_from_docs() { let mut config = PackageConfig::default(); config.internal_modules = Some(vec![Glob::new("package/*/module1").expect("")]); let mod1 = "package/internal1/module1"; let mod2 = "package/internal2/module1"; let mod3 = "package/internal2/module2"; let mod4 = "package/module"; assert_eq!(config.is_internal_module(mod1), true); assert_eq!(config.is_internal_module(mod2), true); assert_eq!(config.is_internal_module(mod3), false); assert_eq!(config.is_internal_module(mod4), false); } #[cfg(test)] fn manifest_package( name: &'static str, version: &'static str, requirements: &'static [&'static str], ) -> ManifestPackage { use crate::manifest::Base16Checksum; ManifestPackage { name: name.into(), version: Version::parse(version).unwrap(), build_tools: vec![], otp_app: None, requirements: requirements .iter() .map(|requirement| (*requirement).into()) .collect(), source: crate::manifest::ManifestPackageSource::Hex { outer_checksum: Base16Checksum(vec![]), }, } } #[cfg(test)] fn locked_version(name: &'static str, version: &'static str) -> (EcoString, Version) { (name.into(), Version::parse(version).unwrap()) } impl Default for PackageConfig { fn default() -> Self { Self { name: EcoString::new(), version: default_version(), gleam_version: None, description: EcoString::new(), documentation: Docs::default(), dependencies: HashMap::new(), erlang: ErlangConfig::default(), javascript: JavaScriptConfig::default(), repository: None, dev_dependencies: HashMap::new(), licences: vec![], links: vec![], internal_modules: None, target: Target::Erlang, } } } #[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Default, Clone)] pub struct ErlangConfig { /// An module that can be set in the `.app` file as the entrypoint for a stateful application /// that defines a singleton supervision tree. /// Erlang syntax. #[serde(default)] pub application_start_module: Option, /// The argument for the start module start function. If not set then `[]` is used as the /// default argument. /// Erlang syntax. #[serde(default)] pub application_start_argument: Option, #[serde(default)] pub extra_applications: Vec, } #[derive(Deserialize, Serialize, Debug, PartialEq, Default, Clone)] pub struct JavaScriptConfig { #[serde(default)] pub typescript_declarations: bool, #[serde(default)] pub source_maps: bool, #[serde(default = "default_javascript_runtime")] pub runtime: Runtime, #[serde(default, rename = "deno")] pub deno: DenoConfig, } #[derive(Deserialize, Debug, PartialEq, Eq, Clone)] pub enum DenoFlag { AllowAll, Allow(Vec), } impl Default for DenoFlag { fn default() -> Self { Self::Allow(Vec::new()) } } impl Serialize for DenoFlag { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { match self { DenoFlag::AllowAll => serializer.serialize_bool(true), DenoFlag::Allow(items) => { let mut seq = serializer.serialize_seq(Some(items.len()))?; for e in items { seq.serialize_element(e)?; } seq.end() } } } } fn bool_or_seq_string_to_deno_flag<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { struct StringOrVec(PhantomData>); impl<'de> serde::de::Visitor<'de> for StringOrVec { type Value = DenoFlag; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("bool or list of strings") } fn visit_bool(self, value: bool) -> Result where E: serde::de::Error, { if value { Ok(DenoFlag::AllowAll) } else { Ok(DenoFlag::default()) } } fn visit_seq(self, visitor: S) -> Result where S: serde::de::SeqAccess<'de>, { let allow: Vec = Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(visitor)) .unwrap_or_default(); Ok(DenoFlag::Allow(allow)) } } deserializer.deserialize_any(StringOrVec(PhantomData)) } #[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Default, Clone)] pub struct DenoConfig { #[serde(default, deserialize_with = "bool_or_seq_string_to_deno_flag")] pub allow_env: DenoFlag, #[serde(default)] pub allow_sys: bool, #[serde(default)] pub allow_hrtime: bool, #[serde(default, deserialize_with = "bool_or_seq_string_to_deno_flag")] pub allow_net: DenoFlag, #[serde(default)] pub allow_ffi: bool, #[serde(default, deserialize_with = "bool_or_seq_string_to_deno_flag")] pub allow_read: DenoFlag, #[serde(default, deserialize_with = "bool_or_seq_string_to_deno_flag")] pub allow_run: DenoFlag, #[serde(default, deserialize_with = "bool_or_seq_string_to_deno_flag")] pub allow_write: DenoFlag, #[serde(default)] pub allow_all: bool, #[serde(default)] pub unstable: bool, #[serde( default, serialize_with = "uri_serde::serialize_option", deserialize_with = "uri_serde::deserialize_option" )] pub location: Option, } #[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)] #[serde(tag = "type")] pub enum Repository { #[serde(rename = "github")] GitHub { user: String, repo: String, path: Option, #[serde(alias = "tag-prefix")] tag_prefix: Option, }, #[serde(rename = "gitlab")] GitLab { user: String, repo: String, path: Option, #[serde(alias = "tag-prefix")] tag_prefix: Option, }, #[serde(rename = "bitbucket")] BitBucket { user: String, repo: String, path: Option, #[serde(alias = "tag-prefix")] tag_prefix: Option, }, #[serde(rename = "codeberg")] Codeberg { user: String, repo: String, path: Option, #[serde(alias = "tag-prefix")] tag_prefix: Option, }, #[serde(rename = "gitea")] Gitea { user: String, repo: String, path: Option, #[serde(alias = "tag-prefix")] tag_prefix: Option, #[serde( serialize_with = "uri_serde::serialize", deserialize_with = "uri_serde_default_https::deserialize" )] host: Uri, }, #[serde(rename = "forgejo")] Forgejo { user: String, repo: String, path: Option, #[serde(alias = "tag-prefix")] tag_prefix: Option, #[serde( serialize_with = "uri_serde::serialize", deserialize_with = "uri_serde_default_https::deserialize" )] host: Uri, }, #[serde(rename = "sourcehut")] SourceHut { user: String, repo: String, path: Option, #[serde(alias = "tag-prefix")] tag_prefix: Option, }, #[serde(rename = "tangled")] Tangled { user: String, repo: String, path: Option, #[serde(alias = "tag-prefix")] tag_prefix: Option, }, #[serde(rename = "custom")] Custom { url: String, #[serde(alias = "tag-prefix")] tag_prefix: Option, }, } impl Repository { pub fn url(&self) -> String { match self { Repository::GitHub { repo, user, .. } => { format!("https://github.com/{user}/{repo}") } Repository::GitLab { repo, user, .. } => { format!("https://gitlab.com/{user}/{repo}") } Repository::BitBucket { repo, user, .. } => { format!("https://bitbucket.com/{user}/{repo}") } Repository::Codeberg { repo, user, .. } => { format!("https://codeberg.org/{user}/{repo}") } Repository::SourceHut { repo, user, .. } => { format!("https://git.sr.ht/~{user}/{repo}") } Repository::Tangled { repo, user, .. } => { format!("https://tangled.sh/{user}/{repo}") } Repository::Gitea { repo, user, host, .. } | Repository::Forgejo { repo, user, host, .. } => { let string_host = host.to_string(); let cleaned_host = string_host.trim_end_matches('/'); format!("{cleaned_host}/{user}/{repo}") } Repository::Custom { url, .. } => url.clone(), } } pub fn path(&self) -> Option<&String> { match self { Repository::GitHub { path, .. } | Repository::GitLab { path, .. } | Repository::BitBucket { path, .. } | Repository::Codeberg { path, .. } | Repository::SourceHut { path, .. } | Repository::Tangled { path, .. } | Repository::Gitea { path, .. } | Repository::Forgejo { path, .. } => path.as_ref(), Repository::Custom { .. } => None, } } } #[derive(Deserialize, Serialize, Default, Debug, PartialEq, Eq, Clone)] pub struct Docs { #[serde(default)] pub pages: Vec, } #[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)] pub struct DocsPage { pub title: String, #[serde(default, deserialize_with = "package_scoped_path::deserialize")] pub path: Utf8PathBuf, #[serde(default, deserialize_with = "package_scoped_path::deserialize")] pub source: Utf8PathBuf, } pub(crate) mod package_scoped_path { use camino::Utf8PathBuf; use serde::{Deserialize, Deserializer, de::Error as _}; pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let path = Utf8PathBuf::deserialize(deserializer)?; crate::io::validate_safe_relative_path(&path).map_err(D::Error::custom)?; Ok(path) } pub fn optional_deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let path = Option::::deserialize(deserializer)?; if let Some(path) = &path { crate::io::validate_safe_relative_path(path).map_err(D::Error::custom)?; } Ok(path) } } #[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)] pub struct Link { pub title: String, #[serde(with = "uri_serde")] pub href: Uri, } // Note we don't use http-serde since we also want to validate the scheme and host is set. mod uri_serde { use http::uri::InvalidUri; use serde::{Deserialize, Deserializer, de::Error as _}; pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let string = String::deserialize(deserializer)?; let uri: http::Uri = string .parse() .map_err(|err: InvalidUri| D::Error::custom(err.to_string()))?; if uri.scheme().is_none() || uri.host().is_none() { return Err(D::Error::custom("uri without scheme")); } Ok(uri) } pub fn deserialize_option<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let string: Option = Option::deserialize(deserializer)?; match string { Some(s) => { let deserializer = serde::de::value::StringDeserializer::new(s); deserialize(deserializer).map(Some) } None => Ok(None), } } pub fn serialize_option(uri: &Option, serializer: S) -> Result where S: serde::Serializer, { match uri { Some(uri) => serialize(uri, serializer), None => serializer.serialize_unit(), } } pub fn serialize(uri: &http::Uri, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(&uri.to_string()) } } // This prefixes https as a default in the event no scheme was provided mod uri_serde_default_https { use http::uri::InvalidUri; use serde::{Deserialize, Deserializer, de::Error as _}; pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let string = String::deserialize(deserializer)?; let uri: http::Uri = string .parse() .map_err(|err: InvalidUri| D::Error::custom(err.to_string()))?; if uri.host().is_none() { return Err(D::Error::custom("uri without host")); } match uri.scheme().is_none() { true => format!("https://{string}") .parse() .map_err(|err: InvalidUri| D::Error::custom(err.to_string())), false => Ok(uri), } } } pub(crate) mod package_name { use ecow::EcoString; use serde::Deserializer; use std::fmt; pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { deserializer.deserialize_str(NameVisitor) } struct NameVisitor; impl<'de> serde::de::Visitor<'de> for NameVisitor { type Value = EcoString; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("a package name") } fn visit_str(self, value: &str) -> Result where E: serde::de::Error, { if super::is_valid_package_name(value) { Ok(value.into()) } else { let error = "Package names may only contain lowercase letters, numbers, and underscores"; Err(serde::de::Error::custom(error)) } } } pub fn optional_deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { deserializer.deserialize_option(OptionVisitor) } struct OptionVisitor; impl<'de> serde::de::Visitor<'de> for OptionVisitor { type Value = Option; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("an optional package name") } fn visit_none(self) -> Result where E: serde::de::Error, { Ok(None) } fn visit_some(self, deserializer: D) -> Result where D: Deserializer<'de>, { deserialize(deserializer).map(Some) } } } pub mod map_with_package_name_keys { use ecow::EcoString; use serde::{Deserialize, Deserializer, de}; use std::collections::HashMap; pub fn deserialize<'de, D, V>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, V: Deserialize<'de>, { struct DependenciesVisitor(std::marker::PhantomData); impl<'de, V: Deserialize<'de>> de::Visitor<'de> for DependenciesVisitor { type Value = HashMap; fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "a map with valid package name keys") } fn visit_map>(self, mut map: A) -> Result { let mut dependencies = HashMap::new(); while let Some(key) = map.next_key::()? { if !super::is_valid_package_name(&key) { return Err(de::Error::invalid_value( de::Unexpected::Str(&key), &"a package name containing only lowercase letter, numbers, and underscores", )); } let _: Option<_> = dependencies.insert(key, map.next_value()?); } Ok(dependencies) } } deserializer.deserialize_map(DependenciesVisitor(std::marker::PhantomData)) } } static PACKAGE_NAME_PATTERN: OnceLock = OnceLock::new(); fn is_valid_package_name(name: &str) -> bool { PACKAGE_NAME_PATTERN .get_or_init(|| Regex::new("^[a-z][a-z0-9_]*$").expect("Package name regex")) .is_match(name) } #[test] fn name_with_dash() { let input = r#" name = "one-two" "#; insta::assert_snapshot!( insta::internals::AutoName, toml::from_str::(input) .unwrap_err() .to_string() ); } #[test] fn name_with_number_start() { let input = r#" name = "1" "#; insta::assert_snapshot!( insta::internals::AutoName, toml::from_str::(input) .unwrap_err() .to_string(), ) } #[test] fn docs_dot_dot_string_path() { let input = r#" name = "one_two" [documentation] pages = [{ title = "My Page", path = "../secrets.txt", source = "./path/to/my-page.md" }] "#; insta::assert_snapshot!( insta::internals::AutoName, toml::from_str::(input) .unwrap_err() .to_string() ); } #[test] fn docs_prefix_dot_dot_string_path() { let input = r#" name = "one_two" [documentation] pages = [{ title = "My Page", path = "something/../../secrets.txt", source = "./path/to/my-page.md" }] "#; insta::assert_snapshot!( insta::internals::AutoName, toml::from_str::(input) .unwrap_err() .to_string() ); } #[cfg(windows)] #[test] fn docs_windows_dot_dot_string_path() { let input = r#" name = "one_two" [documentation] pages = [{ title = "My Page", path = "..\\secrets.txt", source = "./path/to/my-page.md" }] "#; insta::assert_snapshot!( insta::internals::AutoName, toml::from_str::(input) .unwrap_err() .to_string() ); } #[cfg(windows)] #[test] fn docs_prefix_windows_dot_dot_string_path() { let input = r#" name = "one_two" [documentation] pages = [{ title = "My Page", path = "something\\..\\..\\secrets.txt", source = "./path/to/my-page.md" }] "#; insta::assert_snapshot!( insta::internals::AutoName, toml::from_str::(input) .unwrap_err() .to_string() ); } #[test] fn docs_dot_dot_path_buf() { let input = r#" name = "one_two" [documentation] pages = [{ title = "My Page", path = "stuff.html", source = "../secrets.txt" }] "#; insta::assert_snapshot!( insta::internals::AutoName, toml::from_str::(input) .unwrap_err() .to_string() ); } #[test] fn docs_prefix_dot_dot_path_buf() { let input = r#" name = "one_two" [documentation] pages = [{ title = "My Page", path = "stuff.html", source = "something/../../secrets.txt" }] "#; insta::assert_snapshot!( insta::internals::AutoName, toml::from_str::(input) .unwrap_err() .to_string() ); } #[cfg(windows)] #[test] fn docs_windows_dot_dot_path_buf() { let input = r#" name = "one_two" [documentation] pages = [{ title = "My Page", path = "stuff.html", source = "..\\secrets.txt" }] "#; assert!(toml::from_str::(input).is_err()) } #[cfg(windows)] #[test] fn docs_prefix_windows_dot_dot_path_buf() { let input = r#" name = "one_two" [documentation] pages = [{ title = "My Page", path = "stuff.html", source = "something\\..\\..\\secrets.txt" }] "#; assert!(toml::from_str::(input).is_err()) } #[cfg(windows)] #[test] fn docs_absolute_source_windows() { let input = r#" name = "one_two" [documentation] pages = [{ title = "My Page", path = "stuff.html", source = "C:\\etc\\passwd" }] "#; assert!(toml::from_str::(input).is_err()) } #[test] fn docs_absolute_source() { let input = r#" name = "one_two" [documentation] pages = [{ title = "My Page", path = "stuff.html", source = "/etc/passwd" }] "#; insta::assert_snapshot!( insta::internals::AutoName, toml::from_str::(input) .unwrap_err() .to_string() ); } #[test] fn package_config_to_json() { let input = r#" name = "my_project" version = "1.0.0" licences = ["Apache-2.0", "MIT"] description = "Pretty complex config" target = "erlang" repository = { type = "github", user = "example", repo = "my_dep" } links = [{ title = "Home page", href = "https://example.com" }] internal_modules = ["my_app/internal"] gleam = ">= 0.30.0" [dependencies] gleam_stdlib = ">= 0.18.0 and < 2.0.0" my_other_project = { path = "../my_other_project" } [dev_dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" [documentation] pages = [{ title = "My Page", path = "my-page.html", source = "./path/to/my-page.md" }] [erlang] application_start_module = "my_app/application" extra_applications = ["inets", "ssl"] [javascript] typescript_declarations = true source_maps = true runtime = "node" [javascript.deno] allow_all = false allow_ffi = true allow_env = ["DATABASE_URL"] allow_net = ["example.com:443"] allow_read = ["./database.sqlite"] "#; let config = toml::from_str::(input).unwrap(); let json = serde_json::to_string_pretty(&config).unwrap(); let output = format!("--- GLEAM.TOML\n{input}\n\n--- EXPORTED JSON\n\n{json}"); insta::assert_snapshot!(output); let roundtrip = serde_json::from_str::(&json).unwrap(); assert_eq!(config, roundtrip); } #[test] fn barebones_package_config_to_json() { let input = r#" name = "my_project" version = "1.0.0" "#; let config = toml::from_str::(input).unwrap(); let json = serde_json::to_string_pretty(&config).unwrap(); let output = format!("--- GLEAM.TOML\n{input}\n\n--- EXPORTED JSON\n\n{json}"); insta::assert_snapshot!(output); } #[test] fn dev_deps_field_name() { let toml = r#" name = "wibble" version = "1.0.0" [dev_dependencies] wibble = ">= 1.0.0 and < 2.0.0" "#; let hyphen_alternative = deserialise_config("gleam.toml", toml.into()).expect("valid config"); let toml = r#" name = "wibble" version = "1.0.0" [dev_dependencies] wibble = ">= 1.0.0 and < 2.0.0" "#; let canonical = deserialise_config("gleam.toml", toml.into()).expect("valid config"); assert_eq!(canonical, hyphen_alternative) }