From 7aad93aaefbec5136229927e4193293eb5f4f1c7 Mon Sep 17 00:00:00 2001 From: Marc Jakobi Date: Wed, 23 Sep 2026 15:53:49 +0200 Subject: [PATCH 1/7] feat: parse wally.toml manifests --- lux-lib/Cargo.toml | 2 +- lux-lib/src/package/mod.rs | 1 + lux-lib/src/package/wally.rs | 371 +++++++++++++++++++++++++++++++++++ 3 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 lux-lib/src/package/wally.rs diff --git a/lux-lib/Cargo.toml b/lux-lib/Cargo.toml index cb73c7f38..b9d4549a2 100644 --- a/lux-lib/Cargo.toml +++ b/lux-lib/Cargo.toml @@ -70,7 +70,7 @@ tree-sitter-generate = { version = "0.27", default-features = false, features = "pathdiff", ] } tree-sitter-loader = "0.26" -url = "2.5" +url = { version = "2.5", features = ["serde"] } vfs = "0.13" darklua = { workspace = true } diff --git a/lux-lib/src/package/mod.rs b/lux-lib/src/package/mod.rs index b24901d8e..77d9b340d 100644 --- a/lux-lib/src/package/mod.rs +++ b/lux-lib/src/package/mod.rs @@ -6,6 +6,7 @@ use std::{cmp::Ordering, fmt::Display, str::FromStr}; use thiserror::Error; mod outdated; mod version; +pub(crate) mod wally; pub use outdated::*; pub use version::{ diff --git a/lux-lib/src/package/wally.rs b/lux-lib/src/package/wally.rs new file mode 100644 index 000000000..54dabd0d1 --- /dev/null +++ b/lux-lib/src/package/wally.rs @@ -0,0 +1,371 @@ +#![allow(dead_code)] + +use std::{ + collections::BTreeMap, + fmt::{self, Display}, + path::Path, + str::FromStr, +}; + +use semver::{Version, VersionReq}; +use serde::{de::Error as _, ser::Serializer, Deserialize, Deserializer, Serialize}; +use thiserror::Error; +use url::Url; + +pub(crate) const MANIFEST_FILE_NAME: &str = "wally.toml"; + +/// A package name, of the form `scope/name`. +/// +/// Both parts contain only lowercase letters, digits, and dashes (`-`), and +/// are at most 64 characters long. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) struct PackageName { + scope: PackageNamePart, + name: PackageNamePart, +} + +impl PackageName { + pub(crate) fn scope(&self) -> &str { + self.scope.as_str() + } + + pub(crate) fn name(&self) -> &str { + self.name.as_str() + } +} + +/// A part of a [`PackageName`]: lowercase letters, digits, and dashes, 1-64 chars. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) struct PackageNamePart(String); + +impl PackageNamePart { + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for PackageNamePart { + type Err = PackageNameError; + + fn from_str(s: &str) -> Result { + let valid = s + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); + if !valid || s.is_empty() || s.len() > 64 { + return Err(PackageNameError::InvalidPart(s.to_string())); + } + Ok(Self(s.to_string())) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub(crate) enum PackageNameError { + #[error("wally package name part '{0}' is invalid: it must contain only lowercase letters, digits and '-', and be 1-64 characters long")] + InvalidPart(String), + #[error("wally package name must be of the form SCOPE/NAME")] + InvalidFormat, +} + +impl Display for PackageName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.scope.as_str(), self.name.as_str()) + } +} + +impl FromStr for PackageName { + type Err = PackageNameError; + + fn from_str(s: &str) -> Result { + let (scope, name) = s.split_once('/').ok_or(PackageNameError::InvalidFormat)?; + if name.contains('/') { + return Err(PackageNameError::InvalidFormat); + } + Ok(Self { + scope: scope.parse()?, + name: name.parse()?, + }) + } +} + +impl Serialize for PackageName { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for PackageName { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer)? + .parse() + .map_err(D::Error::custom) + } +} + +/// A requirement on a package: a name plus a SemVer range. +/// +/// A bare version defaults to the `^` ("compatible") requirement. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct PackageReq { + name: PackageName, + version_req: VersionReq, +} + +impl PackageReq { + pub(crate) fn new(name: PackageName, version_req: VersionReq) -> Self { + Self { name, version_req } + } + + pub(crate) fn name(&self) -> &PackageName { + &self.name + } + + pub(crate) fn version_req(&self) -> &VersionReq { + &self.version_req + } + + pub(crate) fn matches(&self, name: &PackageName, version: &Version) -> bool { + self.name == *name && self.version_req.matches(version) + } +} + +impl Display for PackageReq { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}@{}", self.name, self.version_req) + } +} + +impl FromStr for PackageReq { + type Err = PackageReqError; + + fn from_str(s: &str) -> Result { + let (name, req) = s.split_once('@').ok_or(PackageReqError::InvalidFormat)?; + if req.is_empty() || req.chars().all(char::is_whitespace) { + return Err(PackageReqError::InvalidFormat); + } + let name: PackageName = name.parse()?; + let version_req = if Version::parse(req).is_ok() { + VersionReq::parse(&format!("^{req}"))? + } else { + VersionReq::parse(req)? + }; + Ok(Self::new(name, version_req)) + } +} + +#[derive(Debug, Error)] +pub(crate) enum PackageReqError { + #[error("wally package requirement must be of the form SCOPE/NAME@VERSION_REQ")] + InvalidFormat, + #[error(transparent)] + Name(#[from] PackageNameError), + #[error(transparent)] + Version(#[from] semver::Error), +} + +impl Serialize for PackageReq { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for PackageReq { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer)? + .parse() + .map_err(D::Error::custom) + } +} + +/// The realm a package can be used in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Realm { + /// May depend on any realm. + Server, + /// May depend only on [`Realm::Shared`]. + Shared, + /// Only valid as a root dependency. + Dev, +} + +impl Realm { + pub(crate) fn is_dependency_valid(dep_type: Self, dep_realm: Self) -> bool { + matches!( + (dep_type, dep_realm), + (Self::Server, _) | (Self::Shared, Self::Shared) | (Self::Dev, _) + ) + } +} + +/// The contents of a `wally.toml` file. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct Manifest { + pub(crate) package: Package, + + #[serde(default)] + pub(crate) place: PlaceInfo, + + #[serde(default)] + pub(crate) dependencies: BTreeMap, + + #[serde(default)] + pub(crate) server_dependencies: BTreeMap, + + #[serde(default)] + pub(crate) dev_dependencies: BTreeMap, +} + +impl Manifest { + pub(crate) fn load(dir: &Path) -> Result { + let path = dir.join(MANIFEST_FILE_NAME); + let content = std::fs::read_to_string(path).map_err(ManifestError::Io)?; + Self::parse(&content) + } + + pub(crate) fn parse(content: &str) -> Result { + Ok(toml::from_str(content)?) + } +} + +#[derive(Debug, Error)] +pub(crate) enum ManifestError { + #[error("failed to read {MANIFEST_FILE_NAME}")] + Io(#[source] std::io::Error), + #[error("failed to parse {MANIFEST_FILE_NAME}: {0}")] + Toml(#[from] toml::de::Error), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct Package { + /// The scope and name of the package, e.g. `jsdotperf/roact`. + pub(crate) name: PackageName, + + /// The current SemVer version of the package. + pub(crate) version: Version, + + /// The URL of the git index this package pulls its dependencies from. + pub(crate) registry: Url, + + /// The realm (`shared`, `server`, or `dev`) this package can be used in. + pub(crate) realm: Realm, + + /// A short description of the package. + pub(crate) description: Option, + + /// An SPDX license specifier for the package. + pub(crate) license: Option, + + /// The package's authors. + #[serde(default)] + pub(crate) authors: Vec, + + /// Glob patterns of paths to include in the published package. + #[serde(default)] + pub(crate) include: Vec, + + /// Glob patterns of paths to exclude from the published package. + #[serde(default)] + pub(crate) exclude: Vec, + + /// Whether the package can be published. + #[serde(default)] + pub(crate) private: bool, + + /// The package homepage. + pub(crate) homepage: Option, + + /// The package source repository. + pub(crate) repository: Option, +} + +/// Where shared and server packages are placed in the Roblox data model. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct PlaceInfo { + /// E.g. `game.ReplicatedStorage.Packages`. + #[serde(default)] + pub(crate) shared_packages: Option, + + /// E.g. `game.ServerScriptService.Packages`. + #[serde(default)] + pub(crate) server_packages: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + const MANIFEST: &str = r#" +[package] +name = "jsdotperf/roact" +version = "1.4.2" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +description = "A declarative UI library" +license = "MIT" +authors = ["Johnny Morgan "] +include = ["src", "*.lua"] +exclude = ["*.spec.lua"] +private = false +homepage = "https://github.com/jsdotperf/roact" +repository = "https://github.com/jsdotperf/roact" + +[place] +shared-packages = "game.ReplicatedStorage.Packages" +server-packages = "game.ServerScriptService.Packages" + +[dependencies] +promise = "evaera/promise@^3.1.0" +signal = "sleitnick/signal@2.0.0" + +[server-dependencies] +data-store = "campfire/data-store@1.0.0" + +[dev-dependencies] +testez = "roblox/testez@0.4.1" +"#; + + #[test] + fn test_parse_manifest() { + let manifest = Manifest::parse(MANIFEST).unwrap(); + assert_eq!(manifest.package.name.to_string(), "jsdotperf/roact"); + assert_eq!(manifest.package.version, Version::new(1, 4, 2)); + assert_eq!(manifest.package.realm, Realm::Shared); + assert_eq!(manifest.package.license.as_deref(), Some("MIT")); + assert_eq!( + manifest.place.shared_packages.as_deref(), + Some("game.ReplicatedStorage.Packages") + ); + assert_eq!(manifest.dependencies.len(), 2); + assert_eq!(manifest.server_dependencies.len(), 1); + assert_eq!(manifest.dev_dependencies.len(), 1); + } + + #[test] + fn bare_version_defaults_to_caret() { + let req: PackageReq = "sleitnick/signal@2.0.0".parse().unwrap(); + assert_eq!(req.version_req(), &VersionReq::parse("^2.0.0").unwrap()); + let name: PackageName = "sleitnick/signal".parse().unwrap(); + assert!(req.matches(&name, &Version::new(2, 0, 0))); + assert!(!req.matches(&name, &Version::new(3, 0, 0))); + } + + #[test] + fn realm_dependency_rules() { + assert!(Realm::is_dependency_valid(Realm::Server, Realm::Shared)); + assert!(Realm::is_dependency_valid(Realm::Server, Realm::Server)); + assert!(Realm::is_dependency_valid(Realm::Shared, Realm::Shared)); + assert!(Realm::is_dependency_valid(Realm::Dev, Realm::Shared)); + assert!(!Realm::is_dependency_valid(Realm::Shared, Realm::Server)); + assert!(!Realm::is_dependency_valid(Realm::Shared, Realm::Dev)); + } + + #[test] + fn test_parse_package_name() { + assert!("Upper-Skewer".parse::().is_err()); + assert!("snake_case".parse::().is_err()); + assert!("hello/world/foo".parse::().is_err()); + assert!("hello/world".parse::().is_ok()); + } +} From 69d8c68d2306307291e6ee50c121edf6ad5e0e5f Mon Sep 17 00:00:00 2001 From: Marc Jakobi Date: Wed, 23 Sep 2026 16:37:43 +0200 Subject: [PATCH 2/7] feat: add wally remote package source --- lux-lib/src/lockfile/mod.rs | 1 + lux-lib/src/operations/download.rs | 1 + lux-lib/src/operations/update.rs | 1 + lux-lib/src/remote_package_source/mod.rs | 14 +++++++++++++- lux-lua/src/lua_impls.rs | 3 +++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lux-lib/src/lockfile/mod.rs b/lux-lib/src/lockfile/mod.rs index 62e1c06ad..5ba409b5c 100644 --- a/lux-lib/src/lockfile/mod.rs +++ b/lux-lib/src/lockfile/mod.rs @@ -708,6 +708,7 @@ impl LocalPackageLock { RemotePackageSource::LuarocksRockspec(_) => filter_spec.rockspec, RemotePackageSource::LuarocksSrcRock(_) => filter_spec.src, RemotePackageSource::LuarocksBinaryRock(_) => filter_spec.binary, + RemotePackageSource::Wally(_) => true, RemotePackageSource::RockspecContent(_) => true, RemotePackageSource::Local => true, #[cfg(test)] diff --git a/lux-lib/src/operations/download.rs b/lux-lib/src/operations/download.rs index 740eb3d54..174792d71 100644 --- a/lux-lib/src/operations/download.rs +++ b/lux-lib/src/operations/download.rs @@ -336,6 +336,7 @@ async fn download_remote_rock( source_url: RemotePackageSourceUrl::Url { url }, }) } + RemotePackageSource::Wally(_) => unimplemented!("wally packages"), RemotePackageSource::Local => Err(SearchAndDownloadError::LocalSource), #[cfg(test)] RemotePackageSource::Test => unimplemented!(), diff --git a/lux-lib/src/operations/update.rs b/lux-lib/src/operations/update.rs index 276b0a118..3a51942c1 100644 --- a/lux-lib/src/operations/update.rs +++ b/lux-lib/src/operations/update.rs @@ -309,6 +309,7 @@ fn updatable_packages(lockfile: &Lockfile) -> Vec<(LocalPackage, Packa RemotePackageSource::LuarocksRockspec(_) => true, RemotePackageSource::LuarocksSrcRock(_) => true, RemotePackageSource::LuarocksBinaryRock(_) => true, + RemotePackageSource::Wally(_) => true, // We don't support updating git sources or local packages // Git sources can be updated with the --toml flag RemotePackageSource::RockspecContent(_) => false, diff --git a/lux-lib/src/remote_package_source/mod.rs b/lux-lib/src/remote_package_source/mod.rs index 7745f519d..526d63d87 100644 --- a/lux-lib/src/remote_package_source/mod.rs +++ b/lux-lib/src/remote_package_source/mod.rs @@ -16,6 +16,7 @@ pub(crate) enum RemotePackageSource { LuarocksRockspec(Url), LuarocksSrcRock(Url), LuarocksBinaryRock(Url), + Wally(Url), RockspecContent(String), Local, #[cfg(test)] @@ -27,7 +28,8 @@ impl RemotePackageSource { match self { Self::LuarocksRockspec(url) | Self::LuarocksSrcRock(url) - | Self::LuarocksBinaryRock(url) => Some(url), + | Self::LuarocksBinaryRock(url) + | Self::Wally(url) => Some(url), Self::RockspecContent(_) | Self::Local => None, #[cfg(test)] Self::Test => None, @@ -47,6 +49,7 @@ impl Display for RemotePackageSource { RemotePackageSource::LuarocksBinaryRock(url) => { format!("luarocks_rock{PLUS}{url}").fmt(f) } + RemotePackageSource::Wally(url) => format!("wally{PLUS}{url}").fmt(f), RemotePackageSource::RockspecContent(content) => { format!("rockspec{PLUS}{content}").fmt(f) } @@ -90,6 +93,7 @@ impl TryFrom for RemotePackageSource { "luarocks_rockspec" => Ok(Self::LuarocksRockspec(Url::parse(str)?)), "luarocks_src_rock" => Ok(Self::LuarocksSrcRock(Url::parse(str)?)), "luarocks_rock" => Ok(Self::LuarocksBinaryRock(Url::parse(str)?)), + "wally" => Ok(Self::Wally(Url::parse(str)?)), "rockspec" => Ok(Self::RockspecContent(str.into())), #[cfg(test)] "test" => Ok(Self::Test), @@ -146,6 +150,14 @@ source = { assert_eq!(source, roundtripped) } + #[test] + fn wally_source_roundtrip() { + let url = Url::parse("https://github.com/UpliftGames/wally-index").unwrap(); + let source = RemotePackageSource::Wally(url); + let roundtripped = RemotePackageSource::try_from(format!("{source}")).unwrap(); + assert_eq!(source, roundtripped); + } + #[test] fn rockspec_source_roundtrip() { let source = RemotePackageSource::RockspecContent(LUAROCKS_ROCKSPEC.into()); diff --git a/lux-lua/src/lua_impls.rs b/lux-lua/src/lua_impls.rs index 2a090886e..402933eec 100644 --- a/lux-lua/src/lua_impls.rs +++ b/lux-lua/src/lua_impls.rs @@ -596,6 +596,9 @@ impl IntoLua for RockSourceSpecLua { RockSourceSpec::Url(url) => { table.set("url", url.to_string())?; } + RockSourceSpec::Wally(package_req) => { + table.set("wally", package_req.to_string())?; + } }; Ok(LuaValue::Table(table)) } From 85a2de108be8de6b5d1c4f8146f4ed15881f77a5 Mon Sep 17 00:00:00 2001 From: Marc Jakobi Date: Wed, 23 Sep 2026 17:19:54 +0200 Subject: [PATCH 3/7] feat: read wally package index --- lux-lib/src/package/wally.rs | 121 +++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 5 deletions(-) diff --git a/lux-lib/src/package/wally.rs b/lux-lib/src/package/wally.rs index 54dabd0d1..be04c8c93 100644 --- a/lux-lib/src/package/wally.rs +++ b/lux-lib/src/package/wally.rs @@ -1,10 +1,7 @@ #![allow(dead_code)] use std::{ - collections::BTreeMap, - fmt::{self, Display}, - path::Path, - str::FromStr, + collections::BTreeMap, fmt::{self, Display}, io, path::{Path, PathBuf}, str::FromStr, }; use semver::{Version, VersionReq}; @@ -231,7 +228,7 @@ impl Manifest { #[derive(Debug, Error)] pub(crate) enum ManifestError { #[error("failed to read {MANIFEST_FILE_NAME}")] - Io(#[source] std::io::Error), + Io(#[source] io::Error), #[error("failed to parse {MANIFEST_FILE_NAME}: {0}")] Toml(#[from] toml::de::Error), } @@ -292,6 +289,67 @@ pub(crate) struct PlaceInfo { pub(crate) server_packages: Option, } +/// Configuration in a wally index's `config.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct WallyIndexConfig { + /// The HTTP registry that serves package contents. + pub(crate) api: Url, + + #[serde(default)] + pub(crate) fallback_registries: Vec, +} + +/// A local checkout of a wally package index. +#[derive(Debug, Clone)] +pub(crate) struct WallyIndex { + path: PathBuf, + config: WallyIndexConfig, +} + +impl WallyIndex { + pub(crate) fn new(path: PathBuf) -> Result { + let config = serde_json::from_str(&std::fs::read_to_string(path.join("config.json"))?)?; + Ok(Self { path, config }) + } + + pub(crate) fn config(&self) -> &WallyIndexConfig { + &self.config + } + + /// All published versions of a package, newest first. + pub(crate) fn versions(&self, name: &PackageName) -> Result, WallyIndexError> { + let path = self.path.join(name.scope()).join(name.name()); + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err.into()), + }; + let mut versions: Vec = content + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).map_err(WallyIndexError::from)) + .collect::>()?; + versions.sort_by(|a, b| b.package.version.cmp(&a.package.version)); + Ok(versions) + } + + /// The latest version of a package matching `req`. + pub(crate) fn find(&self, req: &PackageReq) -> Result, WallyIndexError> { + Ok(self + .versions(req.name())? + .into_iter() + .find(|manifest| req.matches(&manifest.package.name, &manifest.package.version))) + } +} + +#[derive(Debug, Error)] +pub(crate) enum WallyIndexError { + #[error("failed to read wally index")] + Io(#[from] io::Error), + #[error("failed to parse wally index")] + Json(#[from] serde_json::Error), +} + #[cfg(test)] mod tests { use super::*; @@ -368,4 +426,57 @@ testez = "roblox/testez@0.4.1" assert!("hello/world/foo".parse::().is_err()); assert!("hello/world".parse::().is_ok()); } + + fn package(version: Version) -> Manifest { + Manifest { + package: Package { + name: "evaera/promise".parse().unwrap(), + version, + registry: Url::parse("https://github.com/UpliftGames/wally-index").unwrap(), + realm: Realm::Shared, + description: None, + license: None, + authors: Vec::new(), + include: Vec::new(), + exclude: Vec::new(), + private: false, + homepage: None, + repository: None, + }, + place: PlaceInfo::default(), + dependencies: BTreeMap::new(), + server_dependencies: BTreeMap::new(), + dev_dependencies: BTreeMap::new(), + } + } + + #[test] + fn resolves_from_index() { + let dir = assert_fs::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("config.json"), + r#"{"api":"https://api.wally.run"}"#, + ) + .unwrap(); + let pkg_path = dir.path().join("evaera").join("promise"); + std::fs::create_dir_all(pkg_path.parent().unwrap()).unwrap(); + let jsonl = [Version::new(2, 4, 0), Version::new(3, 1, 0)] + .into_iter() + .map(|version| serde_json::to_string(&package(version)).unwrap()) + .collect::>() + .join("\n"); + std::fs::write(pkg_path, format!("{jsonl}\n")).unwrap(); + + let index = WallyIndex::new(dir.path().to_path_buf()).unwrap(); + assert_eq!( + index.config().api, + Url::parse("https://api.wally.run").unwrap() + ); + + let req: PackageReq = "evaera/promise@2".parse().unwrap(); + assert_eq!( + index.find(&req).unwrap().unwrap().package.version, + Version::new(2, 4, 0) + ); + } } From 6e9bf2d1aec2463bc6206376f5f15f61f3f5d2d1 Mon Sep 17 00:00:00 2001 From: Marc Jakobi Date: Wed, 23 Sep 2026 18:25:09 +0200 Subject: [PATCH 4/7] feat: parse wally dependencies in lux.toml --- lux-lib/src/lua_rockspec/rock_source.rs | 4 + lux-lib/src/operations/download.rs | 1 + lux-lib/src/operations/fetch.rs | 3 +- lux-lib/src/package/wally.rs | 6 +- lux-lib/src/project/project_toml.rs | 144 ++++++++++++++++++------ 5 files changed, 120 insertions(+), 38 deletions(-) diff --git a/lux-lib/src/lua_rockspec/rock_source.rs b/lux-lib/src/lua_rockspec/rock_source.rs index 7cdbc0038..7d955412b 100644 --- a/lux-lib/src/lua_rockspec/rock_source.rs +++ b/lux-lib/src/lua_rockspec/rock_source.rs @@ -4,6 +4,7 @@ use crate::{ GitSource, }, lua_rockspec::per_platform_from_intermediate, + package::wally::PackageReq as WallyPackageReq, }; pub use crate::git::GitRef; @@ -112,6 +113,7 @@ pub enum RockSourceSpec { Git(GitSource), File(PathBuf), Url(Url), + Wally(WallyPackageReq), } impl RockSourceSpec { @@ -130,6 +132,7 @@ impl Display for RockSourceSpec { RockSourceSpec::Git(git_source) => git_source.fmt(f), RockSourceSpec::File(path_buf) => path_buf.display().fmt(f), RockSourceSpec::Url(url) => url.fmt(f), + RockSourceSpec::Wally(req) => req.fmt(f), } } } @@ -172,6 +175,7 @@ impl DisplayAsLuaKV for RockSourceSpec { value: DisplayLuaValue::Table(source_tbl), } } + RockSourceSpec::Wally(_) => unimplemented!(), } } } diff --git a/lux-lib/src/operations/download.rs b/lux-lib/src/operations/download.rs index 174792d71..0673c76a9 100644 --- a/lux-lib/src/operations/download.rs +++ b/lux-lib/src/operations/download.rs @@ -183,6 +183,7 @@ impl RemoteRockDownload { } RockSourceSpec::File(path) => RemotePackageSourceUrl::File { path: path.clone() }, RockSourceSpec::Url(url) => RemotePackageSourceUrl::Url { url: url.clone() }, + RockSourceSpec::Wally(_) => unimplemented!(), }); let rockspec = RemoteLuaRockspec::from_package_and_source_spec(package_spec, source_spec); let rockspec_content = rockspec diff --git a/lux-lib/src/operations/fetch.rs b/lux-lib/src/operations/fetch.rs index 330759f9c..02edbde01 100644 --- a/lux-lib/src/operations/fetch.rs +++ b/lux-lib/src/operations/fetch.rs @@ -104,7 +104,7 @@ where } } } - RockSourceSpec::File(_) => Err(err), + RockSourceSpec::File(_) | RockSourceSpec::Wally(_) => Err(err), }, Ok(metadata) => Ok(metadata), } @@ -365,6 +365,7 @@ async fn fetch_src_impl( source_url: RemotePackageSourceUrl::Url { url: url.clone() }, } } + RockSourceSpec::Wally(_) => unimplemented!(), RockSourceSpec::File(path) => { tracing::debug!(message = format!("Copying {}", path.display()).as_str()); diff --git a/lux-lib/src/package/wally.rs b/lux-lib/src/package/wally.rs index be04c8c93..3eb821873 100644 --- a/lux-lib/src/package/wally.rs +++ b/lux-lib/src/package/wally.rs @@ -56,7 +56,7 @@ impl FromStr for PackageNamePart { } #[derive(Debug, Error, PartialEq, Eq)] -pub(crate) enum PackageNameError { +pub enum PackageNameError { #[error("wally package name part '{0}' is invalid: it must contain only lowercase letters, digits and '-', and be 1-64 characters long")] InvalidPart(String), #[error("wally package name must be of the form SCOPE/NAME")] @@ -102,7 +102,7 @@ impl<'de> Deserialize<'de> for PackageName { /// /// A bare version defaults to the `^` ("compatible") requirement. #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub(crate) struct PackageReq { +pub struct PackageReq { name: PackageName, version_req: VersionReq, } @@ -150,7 +150,7 @@ impl FromStr for PackageReq { } #[derive(Debug, Error)] -pub(crate) enum PackageReqError { +pub enum PackageReqError { #[error("wally package requirement must be of the form SCOPE/NAME@VERSION_REQ")] InvalidFormat, #[error(transparent)] diff --git a/lux-lib/src/project/project_toml.rs b/lux-lib/src/project/project_toml.rs index 520417710..d264a87d8 100644 --- a/lux-lib/src/project/project_toml.rs +++ b/lux-lib/src/project/project_toml.rs @@ -41,6 +41,8 @@ use crate::{ use itertools::Itertools; use miette::Diagnostic; use nonempty::NonEmpty; + +use crate::package::wally::PackageReq as WallyPackageReq; use serde::de; use serde::{Deserialize, Deserializer}; use ssri::Integrity; @@ -64,7 +66,8 @@ enum DependencyEntry { #[derive(Debug, Deserialize)] struct DependencyTableEntry { - version: PackageVersionReq, + #[serde(default)] + version: Option, #[serde(default)] opt: Option, #[serde(default)] @@ -75,6 +78,8 @@ struct DependencyTableEntry { path: Option, #[serde(default)] rev: Option, + #[serde(default)] + wally: Option, } fn parse_map_to_dependency_vec_opt<'de, D>( @@ -96,40 +101,68 @@ where Ok(PackageReq { name, version_req }.into()) } DependencyEntry::Detailed(entry) => { - let source = match (entry.git, entry.rev, entry.path) { - (None, None, None) => Ok(None), - (None, Some(_), None) => Err(de::Error::custom(format!( - "dependency {} specifies a 'rev', but missing a 'git' field", - name - ))), - (Some(git), Some(rev), None) => Ok(Some(RockSourceSpec::Git(GitSource { - url: git.into(), - git_ref: Some(GitRef::Tag(rev)), - }))), - (Some(git), None, None) => Ok(Some(RockSourceSpec::Git(GitSource { - url: git.into(), - git_ref: Some(GitRef::Tag( - entry - .version - .clone() - .to_string() - .trim_start_matches("=") - .to_string(), - )), - }))), - (None, None, Some(path)) => Ok(Some(RockSourceSpec::File(path))), - (_, _, Some(_)) => Err(de::Error::custom(format!( - "dependency '{}' specifies a 'path', which cannot be combined with 'git' or 'rev'", - name - ))), - }?; + let DependencyTableEntry { + version, + opt, + pin, + git, + path, + rev, + wally, + } = entry; + + let (version_req, source) = if let Some(wally) = wally { + if version.is_some() || git.is_some() || rev.is_some() || path.is_some() { + return Err(de::Error::custom(format!( + "dependency '{}' specifies 'wally', which cannot be combined with 'version', 'git', 'rev', or 'path'", + name + ))); + } + ( + PackageVersionReq::SemVer(wally.version_req().clone()), + Some(RockSourceSpec::Wally(wally)), + ) + } else { + let version = version.ok_or_else(|| { + de::Error::custom(format!( + "dependency '{name}' is missing a 'version' or 'wally' field" + )) + })?; + let source = match (git, rev, path) { + (None, None, None) => Ok(None), + (None, Some(_), None) => Err(de::Error::custom(format!( + "dependency {} specifies a 'rev', but missing a 'git' field", + name + ))), + (Some(git), Some(rev), None) => { + Ok(Some(RockSourceSpec::Git(GitSource { + url: git.into(), + git_ref: Some(GitRef::Tag(rev)), + }))) + } + (Some(git), None, None) => Ok(Some(RockSourceSpec::Git(GitSource { + url: git.into(), + git_ref: Some(GitRef::Tag( + version + .clone() + .to_string() + .trim_start_matches("=") + .to_string(), + )), + }))), + (None, None, Some(path)) => Ok(Some(RockSourceSpec::File(path))), + (_, _, Some(_)) => Err(de::Error::custom(format!( + "dependency '{}' specifies a 'path', which cannot be combined with 'git' or 'rev'", + name + ))), + }?; + (version, source) + }; + Ok(LuaDependencySpec { - package_req: PackageReq { - name, - version_req: entry.version, - }, - opt: OptState::from(entry.opt.unwrap_or(false)), - pin: PinnedState::from(entry.pin.unwrap_or(false)), + package_req: PackageReq { name, version_req }, + opt: OptState::from(opt.unwrap_or(false)), + pin: PinnedState::from(pin.unwrap_or(false)), source, }) } @@ -1617,6 +1650,49 @@ mod tests { assert!(merged.local.run().is_some()); } + #[test] + fn wally_dependency_parsing() { + let project_toml = r#" + package = "my-package" + version = "1.0.0" + lua = "5.1" + + [dependencies.roact] + wally = "jsdotperf/roact@^1.4.0" + + [build] + type = "builtin" + "#; + + let project = + PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap(); + let deps = project.dependencies.unwrap(); + assert_eq!(deps.len(), 1); + assert_eq!(deps[0].name().to_string(), "roact"); + assert!(matches!( + deps[0].source(), + Some(RockSourceSpec::Wally(req)) if req.name().to_string() == "jsdotperf/roact" + )); + } + + #[test] + fn wally_dependency_conflicts_with_version() { + let project_toml = r#" + package = "my-package" + version = "1.0.0" + lua = "5.1" + + [dependencies.roact] + wally = "jsdotperf/roact@^1.4.0" + version = "1.4.0" + + [build] + type = "builtin" + "#; + + PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap_err(); + } + #[test] fn project_toml_with_lua_in_dependencies() { let project_toml = r#" From 6550f9269fd88dba5b983497b2c9683032e58436 Mon Sep 17 00:00:00 2001 From: Marc Jakobi Date: Wed, 23 Sep 2026 18:50:19 +0200 Subject: [PATCH 5/7] feat: clone and cache wally package registries --- lux-lib/src/config/mod.rs | 24 ++++++++++++ lux-lib/src/package/wally.rs | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/lux-lib/src/config/mod.rs b/lux-lib/src/config/mod.rs index ba2904607..c647e903a 100644 --- a/lux-lib/src/config/mod.rs +++ b/lux-lib/src/config/mod.rs @@ -44,6 +44,7 @@ pub struct Config { enable_development_packages: bool, server: Url, extra_servers: Vec, + extra_wally_registries: Vec, namespace: Option, lua_dir: Option, lua_version: Option, @@ -148,6 +149,12 @@ impl Config { self.extra_servers.as_ref() } + /// Additional wally package registries (git-index URLs), in addition to the + /// official wally index. + pub fn extra_wally_registries(&self) -> &Vec { + self.extra_wally_registries.as_ref() + } + /// Enabled luarocks repository servers that provide dev/scm rocks pub fn enabled_dev_servers(&self) -> Result, ConfigError> { let mut enabled_dev_servers = Vec::new(); @@ -405,6 +412,12 @@ pub struct ConfigBuilder { serialize_with = "serialize_url_vec" )] extra_servers: Option>, + #[serde( + default, + deserialize_with = "deserialize_url_vec", + serialize_with = "serialize_url_vec" + )] + extra_wally_registries: Option>, namespace: Option, lua_version: Option, user_tree: Option, @@ -496,6 +509,14 @@ impl ConfigBuilder { } } + /// Additional wally package registries, in addition to the official wally index + pub fn extra_wally_registries(self, extra_wally_registries: Option>) -> Self { + Self { + extra_wally_registries: extra_wally_registries.or(self.extra_wally_registries), + ..self + } + } + /// The luarocks server namespace to use pub fn namespace(self, namespace: Option) -> Self { Self { @@ -763,6 +784,7 @@ impl ConfigBuilder { Self { server: other.server.or(self.server), extra_servers: other.extra_servers.or(self.extra_servers), + extra_wally_registries: other.extra_wally_registries.or(self.extra_wally_registries), namespace: other.namespace.or(self.namespace), lua_version: other.lua_version.or(self.lua_version), user_tree: other.user_tree.or(self.user_tree), @@ -812,6 +834,7 @@ impl ConfigBuilder { Url::parse("https://luarocks.org/").unwrap_unchecked() }), extra_servers: self.extra_servers.unwrap_or_default(), + extra_wally_registries: self.extra_wally_registries.unwrap_or_default(), namespace: self.namespace, lua_dir: self.lua_dir, lua_version, @@ -854,6 +877,7 @@ impl From for ConfigBuilder { enable_development_packages: Some(value.enable_development_packages), server: Some(value.server), extra_servers: Some(value.extra_servers), + extra_wally_registries: Some(value.extra_wally_registries), namespace: value.namespace, lua_dir: value.lua_dir, lua_version: value.lua_version, diff --git a/lux-lib/src/package/wally.rs b/lux-lib/src/package/wally.rs index 3eb821873..e40f9fabf 100644 --- a/lux-lib/src/package/wally.rs +++ b/lux-lib/src/package/wally.rs @@ -4,6 +4,7 @@ use std::{ collections::BTreeMap, fmt::{self, Display}, io, path::{Path, PathBuf}, str::FromStr, }; +use git2::Repository; use semver::{Version, VersionReq}; use serde::{de::Error as _, ser::Serializer, Deserialize, Deserializer, Serialize}; use thiserror::Error; @@ -11,6 +12,9 @@ use url::Url; pub(crate) const MANIFEST_FILE_NAME: &str = "wally.toml"; +/// The official wally package index. +pub(crate) const DEFAULT_INDEX_URL: &str = "https://github.com/UpliftGames/wally-index"; + /// A package name, of the form `scope/name`. /// /// Both parts contain only lowercase letters, digits, and dashes (`-`), and @@ -312,6 +316,36 @@ impl WallyIndex { Ok(Self { path, config }) } + /// Open a wally index, cloning it into `cache_dir` and updating it if needed. + pub(crate) fn open(url: &Url, cache_dir: &Path) -> Result { + let path = Self::cache_path(url, cache_dir); + if path.join(".git").is_dir() { + let repo = Repository::open(&path)?; + let mut remote = repo.find_remote("origin")?; + remote.fetch(&[] as &[&str], None, None)?; + let head = repo.refname_to_id("refs/remotes/origin/HEAD")?; + repo.reset(&repo.find_object(head, None)?, git2::ResetType::Hard, None)?; + } else { + if path.exists() { + std::fs::remove_dir_all(&path)?; + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + Repository::clone(url.as_str(), &path)?; + } + Self::new(path) + } + + fn cache_path(url: &Url, cache_dir: &Path) -> PathBuf { + let ident: String = url + .as_str() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + cache_dir.join("wally").join("index").join(ident) + } + pub(crate) fn config(&self) -> &WallyIndexConfig { &self.config } @@ -348,6 +382,8 @@ pub(crate) enum WallyIndexError { Io(#[from] io::Error), #[error("failed to parse wally index")] Json(#[from] serde_json::Error), + #[error("failed to fetch wally index")] + Git(#[from] git2::Error), } #[cfg(test)] @@ -479,4 +515,40 @@ testez = "roblox/testez@0.4.1" Version::new(2, 4, 0) ); } + + #[test] + fn opens_local_index() { + let remote = assert_fs::TempDir::new().unwrap(); + std::fs::write( + remote.path().join("config.json"), + r#"{"api":"https://api.wally.run"}"#, + ) + .unwrap(); + let pkg_path = remote.path().join("evaera").join("promise"); + std::fs::create_dir_all(pkg_path.parent().unwrap()).unwrap(); + let jsonl = serde_json::to_string(&package(Version::new(1, 0, 0))).unwrap(); + std::fs::write(&pkg_path, format!("{jsonl}\n")).unwrap(); + + let repo = git2::Repository::init(remote.path()).unwrap(); + { + let mut index = repo.index().unwrap(); + index + .add_all(["*"], git2::IndexAddOption::DEFAULT, None) + .unwrap(); + index.write().unwrap(); + let tree = repo.find_tree(index.write_tree().unwrap()).unwrap(); + let sig = git2::Signature::now("test", "test@example.com").unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .unwrap(); + } + + let cache = assert_fs::TempDir::new().unwrap(); + let url = Url::from_directory_path(remote.path()).unwrap(); + let index = WallyIndex::open(&url, cache.path()).unwrap(); + let req: PackageReq = "evaera/promise@1".parse().unwrap(); + assert_eq!( + index.find(&req).unwrap().unwrap().package.version, + Version::new(1, 0, 0) + ); + } } From d9daa133ef59c7f3dcbf976a08a303fce784093a Mon Sep 17 00:00:00 2001 From: Marc Jakobi Date: Wed, 23 Sep 2026 19:07:26 +0200 Subject: [PATCH 6/7] feat: resolve wally dependencies from registries --- lux-lib/src/lua_rockspec/mod.rs | 88 ++++++++++++++++++++- lux-lib/src/operations/download.rs | 94 ++++++++++++++++++++++- lux-lib/src/operations/fetch.rs | 18 +++-- lux-lib/src/operations/resolve.rs | 19 +++-- lux-lib/src/package/wally.rs | 119 ++++++++++++++++++++++++++++- 5 files changed, 318 insertions(+), 20 deletions(-) diff --git a/lux-lib/src/lua_rockspec/mod.rs b/lux-lib/src/lua_rockspec/mod.rs index 4bc9ad360..53a4297a5 100644 --- a/lux-lib/src/lua_rockspec/mod.rs +++ b/lux-lib/src/lua_rockspec/mod.rs @@ -444,6 +444,92 @@ impl RemoteLuaRockspec { pub fn from_package_and_source_spec( package_spec: PackageSpec, source_spec: RockSourceSpec, + ) -> Self { + Self::from_source_spec(package_spec, source_spec, Vec::new()) + } + + /// Build a rockspec for a wally package, whose contents are fetched from + /// `content_url`, whose dependencies come from the package's manifest + pub fn from_wally( + package_spec: PackageSpec, + content_url: Url, + dependencies: Vec, + modules: HashMap, + ) -> Self { + let version = package_spec.version().clone(); + let rockspec_format = RockspecFormat::default(); + let source_spec = RockSourceSpec::Url(content_url); + + let mut module_entries: Vec<_> = modules.iter().collect(); + module_entries.sort_by_key(|(module, _)| module.as_str()); + let modules_lua = module_entries + .into_iter() + .map(|(module, spec)| { + let path = match spec { + ModuleSpec::SourcePath(path) => path, + _ => unreachable!(), + }; + format!( + " [\"{module}\"] = \"{}\",", + path.to_string_lossy().replace('\\', "/") + ) + }) + .collect::>() + .join("\n"); + + let raw_content = format!( + r#" +rockspec_format = "{}" +package = "{}" +version = "{}" +{} +build = {{ + type = "builtin", + modules = {{ +{} + }} +}}"#, + rockspec_format, + package_spec.name(), + version, + source_spec.display_lua(), + modules_lua, + ); + + let source: RemoteRockSource = source_spec.into(); + + let local = LocalLuaRockspec { + rockspec_format: Some(rockspec_format), + package: package_spec.name().clone(), + version, + description: RockDescription::default(), + supported_platforms: PlatformSupport::default(), + lua: PackageVersionReq::Any, + dependencies: PerPlatform::new(dependencies), + build_dependencies: PerPlatform::default(), + external_dependencies: PerPlatform::default(), + test_dependencies: PerPlatform::default(), + build: PerPlatform::new(BuildSpec { + build_backend: Some(BuildBackendSpec::Builtin(BuiltinBuildSpec { modules })), + install: InstallSpec::default(), + copy_directories: Vec::new(), + patches: HashMap::new(), + }), + source: PerPlatform::new(source.clone()), + test: PerPlatform::default(), + deploy: PerPlatform::default(), + raw_content, + }; + Self { + local, + source: PerPlatform::new(source), + } + } + + fn from_source_spec( + package_spec: PackageSpec, + source_spec: RockSourceSpec, + dependencies: Vec, ) -> Self { let version = package_spec.version().clone(); let rockspec_format = RockspecFormat::default(); @@ -471,7 +557,7 @@ build = {{ description: RockDescription::default(), supported_platforms: PlatformSupport::default(), lua: PackageVersionReq::Any, - dependencies: PerPlatform::default(), + dependencies: PerPlatform::new(dependencies), build_dependencies: PerPlatform::default(), external_dependencies: PerPlatform::default(), test_dependencies: PerPlatform::default(), diff --git a/lux-lib/src/operations/download.rs b/lux-lib/src/operations/download.rs index 0673c76a9..cee2b4aec 100644 --- a/lux-lib/src/operations/download.rs +++ b/lux-lib/src/operations/download.rs @@ -14,17 +14,20 @@ use crate::{ config::Config, fs, git::{GitRef, GitSource}, - lockfile::RemotePackageSourceUrl, + lockfile::{OptState, PinnedState, RemotePackageSourceUrl}, lua_rockspec::{LuaRockspecError, RemoteLuaRockspec, RockSourceSpec}, luarocks, package::{ + wally::{ + self, PackageReq as WallyPackageReq, WallyIndex, WallyIndexError, WallyModulesError, + }, PackageName, PackageReq, PackageSpec, PackageSpecFromPackageReqError, PackageVersion, - RemotePackageTypeFilterSpec, + PackageVersionParseError, PackageVersionReq, RemotePackageTypeFilterSpec, }, remote_package_db::{RemotePackageDB, RemotePackageDBError, SearchError}, remote_package_source::RemotePackageSource, reqwest::{RequestBuilderExt, RequestError}, - rockspec::Rockspec, + rockspec::{lua_dependency::LuaDependencySpec, Rockspec}, }; /// Builder for a rock downloader. @@ -198,6 +201,83 @@ impl RemoteRockDownload { } } +/// Resolve and download a wally package from the configured registries. +pub(crate) async fn download_wally_rock( + package_req: &PackageReq, + wally_req: &WallyPackageReq, + config: &Config, +) -> Result { + let mut registries = vec![unsafe { Url::parse(wally::DEFAULT_INDEX_URL).unwrap_unchecked() }]; + registries.extend(config.extra_wally_registries().iter().cloned()); + download_wally_rock_from_registries(package_req, wally_req, ®istries, config).await +} + +pub(crate) async fn download_wally_rock_from_registries( + package_req: &PackageReq, + wally_req: &WallyPackageReq, + registries: &[Url], + config: &Config, +) -> Result { + for registry in registries { + let index = WallyIndex::open(registry, config.cache_dir())?; + let Some(manifest) = index.find(wally_req)? else { + continue; + }; + let version = PackageVersion::parse(&manifest.package.version.to_string())?; + let package_spec = PackageSpec::new(package_req.name().clone(), version); + let content_url = index + .config() + .api + .join(&format!( + "/v1/package-contents/{}/{}/{}", + wally_req.name().scope(), + wally_req.name().name(), + manifest.package.version + )) + .map_err(|source| SearchAndDownloadError::Parse { + source, + url: index.config().api.to_string(), + })?; + let contents = fetch_wally_contents(&content_url, config).await?; + let modules = wally::modules_from_zip(&contents)?; + let dependencies = manifest + .dependencies + .into_iter() + .map(|(alias, dep_req)| LuaDependencySpec { + package_req: PackageReq { + name: PackageName::new(alias), + version_req: PackageVersionReq::SemVer(dep_req.version_req().clone()), + }, + pin: PinnedState::default(), + opt: OptState::default(), + source: Some(RockSourceSpec::Wally(dep_req)), + }) + .collect(); + let rockspec = + RemoteLuaRockspec::from_wally(package_spec, content_url.clone(), dependencies, modules); + let rockspec_download = DownloadedRockspec { + rockspec, + source: RemotePackageSource::Wally(registry.clone()), + source_url: Some(RemotePackageSourceUrl::Url { url: content_url }), + }; + return Ok(RemoteRockDownload::RockspecOnly { rockspec_download }); + } + + Err(SearchAndDownloadError::WallyNotFound(wally_req.clone())) +} + +async fn fetch_wally_contents(url: &Url, config: &Config) -> Result { + let response = crate::reqwest::http_client(config)? + .get(url.clone()) + .header("Wally-Version", wally::WALLY_VERSION) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + Ok(response) +} + #[derive(Error, Debug, Diagnostic)] pub enum DownloadRockspecError { #[error("failed to download rockspec")] @@ -422,6 +502,14 @@ for local dependencies, use `path` in your lux.toml."# url("https://lux.lumen-labs.org/reference/lux-toml#local-dependencies") )] NonURLSource, + #[error("failed to resolve wally package")] + WallyIndex(#[from] WallyIndexError), + #[error("failed to inspect wally package contents")] + WallyModules(#[from] WallyModulesError), + #[error("failed to parse wally package version")] + WallyVersion(#[from] PackageVersionParseError), + #[error("wally package '{0}' was not found in any registry")] + WallyNotFound(WallyPackageReq), #[error("client error")] #[diagnostic(transparent)] Request(#[from] RequestError), diff --git a/lux-lib/src/operations/fetch.rs b/lux-lib/src/operations/fetch.rs index 02edbde01..ea1b509de 100644 --- a/lux-lib/src/operations/fetch.rs +++ b/lux-lib/src/operations/fetch.rs @@ -5,7 +5,7 @@ use crate::git::{GitRef, GitSource}; use crate::hash::HasIntegrity; use crate::lockfile::RemotePackageSourceUrl; use crate::lua_rockspec::{RemoteRockSource, RockSourceSpec}; -use crate::package::PackageSpec; +use crate::package::{wally, PackageSpec}; use crate::reqwest::{RequestBuilderExt, RequestError}; use crate::rockspec::Rockspec; use crate::{fs, operations}; @@ -330,14 +330,16 @@ async fn fetch_src_impl( // NOTE: We don't enforce HTTPS when fetching sources because some rockspecs // have HTTP URLs in `source.url`. - let response = crate::reqwest::http_client(config)? + let request = crate::reqwest::http_client(config)? .get(url.clone()) - .apply_access_token(config, url) - .send() - .await? - .error_for_status()? - .bytes() - .await?; + .apply_access_token(config, url); + let request = if url.path().starts_with("/v1/package-contents/") { + // HACK: The wally registry rejects requests without a sufficiently recent `Wally-Version` header. + request.header("Wally-Version", wally::WALLY_VERSION) + } else { + request + }; + let response = request.send().await?.error_for_status()?.bytes().await?; let hash = response.hash().await.map_err(FetchSrcError::Hash)?; let file_name = url .path_segments() diff --git a/lux-lib/src/operations/resolve.rs b/lux-lib/src/operations/resolve.rs index 3bcf3c5bb..d9b388521 100644 --- a/lux-lib/src/operations/resolve.rs +++ b/lux-lib/src/operations/resolve.rs @@ -15,7 +15,7 @@ use crate::{ lockfile::{ LocalPackageId, LocalPackageSpec, Lockfile, LockfilePermissions, OptState, PinnedState, }, - lua_rockspec::BuildBackendSpec, + lua_rockspec::{BuildBackendSpec, RockSourceSpec}, operations::{FetchVendored, FetchVendoredError}, package::{PackageName, PackageReq}, remote_package_db::RemotePackageDB, @@ -23,7 +23,9 @@ use crate::{ tree, }; -use super::{Download, PackageInstallSpec, RemoteRockDownload, SearchAndDownloadError}; +use super::{ + download_wally_rock, Download, PackageInstallSpec, RemoteRockDownload, SearchAndDownloadError, +}; #[derive(Error, Debug, Diagnostic)] #[non_exhaustive] @@ -189,10 +191,15 @@ where tokio::spawn( async move { let downloaded_rock = if let Some(source) = source { - RemoteRockDownload::from_package_req_and_source_spec( - package.clone(), - source, - )? + match &source { + RockSourceSpec::Wally(wally_req) => { + download_wally_rock(&package, wally_req, &config).await? + } + _ => RemoteRockDownload::from_package_req_and_source_spec( + package.clone(), + source, + )?, + } } else if let Some(vendor_dir) = config.vendor_dir() { FetchVendored::new() .vendor_dir(vendor_dir) diff --git a/lux-lib/src/package/wally.rs b/lux-lib/src/package/wally.rs index e40f9fabf..7c2677b56 100644 --- a/lux-lib/src/package/wally.rs +++ b/lux-lib/src/package/wally.rs @@ -1,7 +1,11 @@ #![allow(dead_code)] use std::{ - collections::BTreeMap, fmt::{self, Display}, io, path::{Path, PathBuf}, str::FromStr, + collections::{BTreeMap, HashMap}, + fmt::{self, Display}, + io, + path::{Path, PathBuf}, + str::FromStr, }; use git2::Repository; @@ -10,11 +14,17 @@ use serde::{de::Error as _, ser::Serializer, Deserialize, Deserializer, Serializ use thiserror::Error; use url::Url; +use crate::lua_rockspec::{LuaModule, ModuleSpec, ParseLuaModuleError}; + pub(crate) const MANIFEST_FILE_NAME: &str = "wally.toml"; /// The official wally package index. pub(crate) const DEFAULT_INDEX_URL: &str = "https://github.com/UpliftGames/wally-index"; +/// The wally client version advertised when downloading package contents. +/// The registry rejects requests without a sufficiently recent `Wally-Version` header. +pub(crate) const WALLY_VERSION: &str = "0.3.2"; + /// A package name, of the form `scope/name`. /// /// Both parts contain only lowercase letters, digits, and dashes (`-`), and @@ -377,7 +387,7 @@ impl WallyIndex { } #[derive(Debug, Error)] -pub(crate) enum WallyIndexError { +pub enum WallyIndexError { #[error("failed to read wally index")] Io(#[from] io::Error), #[error("failed to parse wally index")] @@ -386,9 +396,83 @@ pub(crate) enum WallyIndexError { Git(#[from] git2::Error), } +/// Compute the module map for a wally package's contents. +pub(crate) fn modules_from_zip( + bytes: &[u8], +) -> Result, WallyModulesError> { + let mut archive = zip::ZipArchive::new(io::Cursor::new(bytes))?; + let mut modules = HashMap::new(); + for index in 0..archive.len() { + let file = archive.by_index(index)?; + let name = file.name().to_string(); + if name.ends_with('/') { + continue; + } + let path = Path::new(&name); + if !is_lua_path(path) || is_test_file(path) { + continue; + } + modules.insert( + lua_module_from_entry(path)?, + ModuleSpec::SourcePath(path.to_path_buf()), + ); + } + Ok(modules) +} + +fn is_lua_path(path: &Path) -> bool { + matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("lua" | "luau") + ) +} + +fn is_test_file(path: &Path) -> bool { + path.file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| stem.ends_with(".test") || stem.ends_with(".spec")) +} + +fn lua_module_from_entry(path: &Path) -> Result { + let stripped: PathBuf = match path.components().next() { + Some(component) + if matches!(component.as_os_str().to_str(), Some("src" | "lua" | "lib")) => + { + path.components().skip(1).collect() + } + _ => path.to_path_buf(), + }; + if stripped + .parent() + .is_none_or(|parent| parent.as_os_str().is_empty()) + { + let mut file = stripped; + file.set_extension(""); + LuaModule::from_pathbuf(file) + } else { + let mut module = LuaModule::from_pathbuf(stripped.to_path_buf())?; + if matches!( + stripped.file_name().and_then(|name| name.to_str()), + Some("init.lua" | "init.luau") + ) { + module = module.join(unsafe { &LuaModule::from_str("init").unwrap_unchecked() }); + } + Ok(module) + } +} + +#[derive(Debug, Error)] +pub enum WallyModulesError { + #[error("failed to read package contents archive")] + Zip(#[from] zip::result::ZipError), + #[error("invalid module path in package contents")] + Module(#[from] ParseLuaModuleError), +} + #[cfg(test)] mod tests { use super::*; + use std::io::Write; const MANIFEST: &str = r#" [package] @@ -551,4 +635,35 @@ testez = "roblox/testez@0.4.1" Version::new(1, 0, 0) ); } + + #[test] + fn computes_modules_from_zip() { + let mut bytes = Vec::new(); + { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut bytes)); + let options = zip::write::SimpleFileOptions::default(); + for entry in [ + "init.luau", + "init.test.luau", + "src/foo.luau", + "src/bar/baz.luau", + "sub/init.lua", + "foo.spec.lua", + "wally.toml", + ] { + zip.start_file(entry, options).unwrap(); + zip.write_all(b"").unwrap(); + } + zip.finish().unwrap(); + } + + let modules = modules_from_zip(&bytes).unwrap(); + let mut names: Vec<_> = modules.keys().map(|module| module.to_string()).collect(); + names.sort(); + assert_eq!(names, vec!["bar.baz", "foo", "init", "sub.init"]); + assert_eq!( + modules.get(&LuaModule::from_str("foo").unwrap()), + Some(&ModuleSpec::SourcePath("src/foo.luau".into())) + ); + } } From 607ff302263f7a72064747d2fce297617ad06d88 Mon Sep 17 00:00:00 2001 From: Marc Jakobi Date: Wed, 23 Sep 2026 19:33:54 +0200 Subject: [PATCH 7/7] test(luau): end-to-end wally dependency build --- .../sample-projects/wally-signal/lux.toml | 9 ++++ .../wally-signal/src/main.luau | 5 +++ lux-lib/tests/wally.rs | 41 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 lux-lib/resources/test/sample-projects/wally-signal/lux.toml create mode 100644 lux-lib/resources/test/sample-projects/wally-signal/src/main.luau create mode 100644 lux-lib/tests/wally.rs diff --git a/lux-lib/resources/test/sample-projects/wally-signal/lux.toml b/lux-lib/resources/test/sample-projects/wally-signal/lux.toml new file mode 100644 index 000000000..8d74d7aea --- /dev/null +++ b/lux-lib/resources/test/sample-projects/wally-signal/lux.toml @@ -0,0 +1,9 @@ +package = "wally-signal" +version = "0.1.0" +lua = "luau" + +[dependencies.signal] +wally = "sleitnick/signal@=2.0.3" + +[run] +args = ["src/main.luau"] diff --git a/lux-lib/resources/test/sample-projects/wally-signal/src/main.luau b/lux-lib/resources/test/sample-projects/wally-signal/src/main.luau new file mode 100644 index 000000000..2b9829604 --- /dev/null +++ b/lux-lib/resources/test/sample-projects/wally-signal/src/main.luau @@ -0,0 +1,5 @@ +local signal = require("@signal") + +assert(type(signal) == "table", "signal package did not load") +local instance = signal.new() +assert(signal.Is(instance), "signal.new() returned an invalid signal") diff --git a/lux-lib/tests/wally.rs b/lux-lib/tests/wally.rs new file mode 100644 index 000000000..575f38d5d --- /dev/null +++ b/lux-lib/tests/wally.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; + +use assert_fs::{prelude::PathCopy, TempDir}; +use flaky_test::flaky_test; +use lux_lib::{ + config::ConfigBuilder, + lua_version::LuaVersion, + operations::{BuildWorkspace, Run}, + workspace::Workspace, +}; + +#[flaky_test(tokio, times = 5)] +async fn test_install_wally_dependency() { + let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources/test/sample-projects/wally-signal"); + let project = TempDir::new().unwrap(); + project.copy_from(&project_root, &["**/*"]).unwrap(); + + let config = ConfigBuilder::new() + .unwrap() + .cache_dir(Some(project.path().join(".cache"))) + .lua_version(Some(LuaVersion::Luau)) + .build() + .unwrap(); + + let workspace = Workspace::from_exact(project.path()).unwrap().unwrap(); + BuildWorkspace::new(&workspace, &config) + .no_lock(false) + .only_deps(false) + .build() + .await + .unwrap(); + + Run::new() + .workspace(&workspace) + .config(&config) + .args(&Vec::new()) + .run() + .await + .unwrap(); +}