From 14963d9c6bdc6018577f196cc70904ebf970fff7 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Tue, 25 Aug 2026 18:13:18 -0400 Subject: [PATCH] feat: discover Roblox service-shaped projects --- README.md | 20 +- src/lib.rs | 2 +- src/project/discovery.rs | 295 ++++++++++-------- src/project/execution_side.rs | 12 + src/project/manifest.rs | 26 +- src/project/mod.rs | 117 ++++++- src/project/problem.rs | 18 ++ .../{layout.rs => project_module_identity.rs} | 75 +++-- src/project/project_module_role.rs | 10 + src/project/roblox_service.rs | 68 ++++ tests/cli_project.rs | 8 +- 11 files changed, 461 insertions(+), 190 deletions(-) create mode 100644 src/project/execution_side.rs rename src/project/{layout.rs => project_module_identity.rs} (56%) create mode 100644 src/project/project_module_role.rs create mode 100644 src/project/roblox_service.rs diff --git a/README.md b/README.md index 4cdb9da..54005a4 100644 --- a/README.md +++ b/README.md @@ -84,19 +84,25 @@ source_root = "wasm" output_root = "build" ``` -Under `source_root`, modules use this convention: +Under `source_root`, project files use the Roblox DataModel layout directly: ```text -///.wasm +//.wasm ``` -For example, `wasm/server/entrypoint/game/main.wasm` becomes +For example, `wasm/ServerScriptService/game/main.wasm` becomes `ServerScriptService/game/main.server.luau`, while -`wasm/shared/library/math/core.wasm` becomes -`ReplicatedStorage/math/core.luau`. `check` performs discovery, decoding, and -translation without writing output. `compile` stages every generated file and +`wasm/ReplicatedStorage/math/core.wasm` becomes +`ReplicatedStorage/math/core.luau`. `ServerScriptService`, `Workspace`, +`ReplicatedFirst`, `StarterGui`, `StarterPlayer/StarterPlayerScripts`, and +`StarterPlayer/StarterCharacterScripts` own entrypoint scripts. `ServerStorage` +and `ReplicatedStorage` own libraries. Existing `.luau`, `.rbxm`, `.rbxmx`, +`.rbxl`, `.rbxlx`, and `*.model.json` files are retained at their DataModel +paths. `check` performs discovery, decoding, and translation without writing +output. `compile` stages every generated file and retained asset, then atomically replaces the previous `output_root` only after the complete tree is -ready. +ready. Unknown services, illegal `StarterPlayer` placement, duplicate output +paths, and overlapping source/output roots are rejected before generation. ## Current scope diff --git a/src/lib.rs b/src/lib.rs index 60bdf4e..5739155 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,7 @@ pub use project::{ ModuleExecutionSide, ProjectCompilationOutcome, ProjectCompilationProblem, ProjectCompilationRejection, ProjectCompilationRequest, ProjectDiscoveryProblem, ProjectManifest, ProjectManifestProblem, ProjectModuleIdentity, ProjectModuleRole, - ProjectModuleSource, ProjectOutputPath, + ProjectModuleSource, ProjectOutputPath, RobloxService, }; pub use translate::{ translate_module, GeneratedLuauText, MainInvocation, TranslateOptions, TranslateOutcome, diff --git a/src/project/discovery.rs b/src/project/discovery.rs index 7486a16..4d33ec0 100644 --- a/src/project/discovery.rs +++ b/src/project/discovery.rs @@ -1,19 +1,14 @@ -//! Convention-based recursive discovery of wasm project modules. +//! Deterministic discovery from a Roblox-shaped project tree. use super::{ - ProjectCompilationRequest, ProjectManifest, ProjectModuleIdentity, ProjectModuleRole, - ProjectModuleSource, + ProjectCompilationRequest, ProjectManifest, ProjectModuleIdentity, ProjectModuleSource, + ProjectSourceAsset, RobloxService, }; use std::path::{Path, PathBuf}; use thiserror::Error; use walkdir::WalkDir; -const MODULE_SIDE_COMPONENT_INDEX: usize = 0; -const MODULE_ROLE_COMPONENT_INDEX: usize = 1; -const MODULE_PATH_START_COMPONENT: usize = 2; -const MINIMUM_MODULE_PATH_COMPONENTS: usize = MODULE_PATH_START_COMPONENT + 1; - -/// Explains why a manifest source tree could not become a compilation request. +/// Explains why a Roblox-shaped source tree could not become a compilation request. #[derive(Debug, Error)] pub enum ProjectDiscoveryProblem { /// A directory entry could not be inspected while walking the source root. @@ -34,54 +29,55 @@ pub enum ProjectDiscoveryProblem { #[source] source: std::io::Error, }, - /// A wasm file did not follow the documented source-tree convention. - #[error("wasm module {path} must be below //; found {component_count} path components")] - InvalidModulePath { - /// The path relative to the configured source root. + /// A retained Luau or Roblox model asset could not be read. + #[error("could not read project asset {path}: {source}")] + ReadAsset { + /// The asset that was discovered. path: PathBuf, - /// The number of components that established the invalid layout. - component_count: usize, + /// The filesystem failure returned by the operating system. + #[source] + source: std::io::Error, }, - /// A source-tree directory used an unsupported execution side. - #[error( - "wasm module {path} uses an unknown execution side; expected server, client, or shared" - )] - UnknownExecutionSide { - /// The path relative to the configured source root. + /// A source path was not below one recognized Roblox service. + #[error("project path {path} uses unknown Roblox service {service:?}")] + UnknownRobloxService { + /// The offending source path. + path: PathBuf, + /// The unrecognized first path component. + service: String, + }, + /// A recognized service was used without its required child container. + #[error("project path {path} is not a legal path beneath a Roblox service")] + InvalidServicePath { + /// The offending source path. path: PathBuf, }, - /// A source-tree directory used an unsupported module role. - #[error("wasm module {path} uses an unknown module role; expected entrypoint or library")] - UnknownModuleRole { + /// A project file did not have enough path components to identify a module. + #[error("project path {path} must name a file beneath a recognized Roblox service")] + InvalidModulePath { /// The path relative to the configured source root. path: PathBuf, }, - /// No wasm input was found beneath the configured source root. - #[error("project source root {root} contains no .wasm modules (found {module_count} modules)")] + /// No supported source module or retained asset was found. + #[error("project source root {root} contains no supported Roblox project files")] EmptySourceRoot { /// The configured source root. root: PathBuf, - /// The discovered module count that established the empty result. - module_count: usize, }, } -/// Discovers convention-named wasm files in deterministic filesystem order. -/// -/// Every module must be below `//` beneath the manifest's source -/// root. The remaining path becomes the slash-separated project module path; -/// for example, `server/entrypoint/game/main.wasm` becomes the server -/// entrypoint `game/main`. +/// Discovers wasm modules and retained assets in deterministic Roblox service order. /// /// # Errors /// -/// Returns a typed discovery problem when traversal, input reading, or path -/// interpretation fails. +/// Returns a typed problem when a service path is unknown, a supported file +/// cannot be read, or the source root contains no supported project files. pub fn discover_project_request( manifest: &ProjectManifest, ) -> Result { let source_root = manifest.source_root(); let mut source_modules = Vec::new(); + let mut source_assets = Vec::new(); for entry_result in WalkDir::new(source_root).min_depth(1).sort_by_file_name() { let entry = match entry_result { Ok(entry) => entry, @@ -92,145 +88,186 @@ pub fn discover_project_request( }); } }; - if !entry.file_type().is_file() || !is_wasm_path(entry.path()) { - continue; - } let relative_path = match entry.path().strip_prefix(source_root) { Ok(relative_path) => relative_path.to_path_buf(), Err(_) => { return Err(ProjectDiscoveryProblem::InvalidModulePath { path: entry.path().to_path_buf(), - component_count: 0, - }); - } - }; - let (module_identity, module_role) = parse_module_layout(&relative_path)?; - let wasm_bytes = match fs_err::read(entry.path()) { - Ok(wasm_bytes) => wasm_bytes, - Err(source) => { - return Err(ProjectDiscoveryProblem::ReadModule { - path: entry.path().to_path_buf(), - source, }); } }; - source_modules.push(ProjectModuleSource::from_wasm_parts(( - module_identity, - module_role, - wasm_bytes, - ))); + if entry.depth() == 1 && entry.file_type().is_dir() { + validate_service_root(&relative_path)?; + continue; + } + if !entry.file_type().is_file() { + continue; + } + let (service, content_start) = parse_service_layout(&relative_path)?; + if is_wasm_path(entry.path()) { + let module_path = module_path_text(&relative_path, content_start)?; + let wasm_bytes = match fs_err::read(entry.path()) { + Ok(wasm_bytes) => wasm_bytes, + Err(source) => { + return Err(ProjectDiscoveryProblem::ReadModule { + path: entry.path().to_path_buf(), + source, + }); + } + }; + source_modules.push(ProjectModuleSource::from_wasm_parts(( + ProjectModuleIdentity::RobloxService { + service, + module_path, + }, + service.module_role(), + wasm_bytes, + ))); + } else if is_project_asset_path(entry.path()) { + let bytes = match fs_err::read(entry.path()) { + Ok(bytes) => bytes, + Err(source) => { + return Err(ProjectDiscoveryProblem::ReadAsset { + path: entry.path().to_path_buf(), + source, + }); + } + }; + source_assets.push(ProjectSourceAsset { + relative_path, + bytes, + }); + } else if content_start == 0 { + return Err(ProjectDiscoveryProblem::InvalidModulePath { + path: relative_path, + }); + } } - if source_modules.is_empty() { + if source_modules.is_empty() && source_assets.is_empty() { return Err(ProjectDiscoveryProblem::EmptySourceRoot { root: source_root.to_path_buf(), - module_count: source_modules.len(), }); } - Ok(ProjectCompilationRequest::from_source_modules( + Ok(ProjectCompilationRequest::from_discovered( source_modules, + source_assets, )) } -fn is_wasm_path(path: &Path) -> bool { - path.extension().and_then(|extension| extension.to_str()) == Some("wasm") +fn validate_service_root(relative_path: &Path) -> Result<(), ProjectDiscoveryProblem> { + let components = path_components(relative_path)?; + match components.first().map(String::as_str) { + Some( + "ServerScriptService" + | "ServerStorage" + | "ReplicatedStorage" + | "Workspace" + | "ReplicatedFirst" + | "StarterGui" + | "StarterPlayer", + ) => Ok(()), + Some(service) => Err(ProjectDiscoveryProblem::UnknownRobloxService { + path: relative_path.to_path_buf(), + service: service.to_owned(), + }), + None => Err(ProjectDiscoveryProblem::InvalidModulePath { + path: relative_path.to_path_buf(), + }), + } } -fn parse_module_layout( +fn parse_service_layout( relative_path: &Path, -) -> Result<(ProjectModuleIdentity, ProjectModuleRole), ProjectDiscoveryProblem> { - let path_text = relative_path.to_path_buf(); - let components = relative_path - .iter() +) -> Result<(RobloxService, usize), ProjectDiscoveryProblem> { + let components = path_components(relative_path)?; + let Some(root) = components.first().map(String::as_str) else { + return Err(ProjectDiscoveryProblem::InvalidModulePath { + path: relative_path.to_path_buf(), + }); + }; + match root { + "ServerScriptService" => Ok((RobloxService::ServerScriptService, 1)), + "ServerStorage" => Ok((RobloxService::ServerStorage, 1)), + "ReplicatedStorage" => Ok((RobloxService::ReplicatedStorage, 1)), + "Workspace" => Ok((RobloxService::Workspace, 1)), + "ReplicatedFirst" => Ok((RobloxService::ReplicatedFirst, 1)), + "StarterGui" => Ok((RobloxService::StarterGui, 1)), + "StarterPlayer" => match components.get(1).map(String::as_str) { + Some("StarterPlayerScripts") => Ok((RobloxService::StarterPlayerScripts, 2)), + Some("StarterCharacterScripts") => Ok((RobloxService::StarterCharacterScripts, 2)), + Some(_) | None => Err(ProjectDiscoveryProblem::InvalidServicePath { + path: relative_path.to_path_buf(), + }), + }, + service => Err(ProjectDiscoveryProblem::UnknownRobloxService { + path: relative_path.to_path_buf(), + service: service.to_owned(), + }), + } +} + +fn path_components(path: &Path) -> Result, ProjectDiscoveryProblem> { + path.iter() .map(|component| { component.to_str().map_or_else( || { Err(ProjectDiscoveryProblem::InvalidModulePath { - path: path_text.clone(), - component_count: relative_path.components().count(), + path: path.to_path_buf(), }) }, |component| Ok(component.to_owned()), ) }) - .collect::, _>>()?; - if components.len() < MINIMUM_MODULE_PATH_COMPONENTS { + .collect() +} + +fn module_path_text( + relative_path: &Path, + content_start: usize, +) -> Result { + let components = path_components(relative_path)?; + if components.len() <= content_start { return Err(ProjectDiscoveryProblem::InvalidModulePath { - path: path_text, - component_count: components.len(), + path: relative_path.to_path_buf(), }); } - - let side = match components - .get(MODULE_SIDE_COMPONENT_INDEX) - .map(String::as_str) - { - Some("server") => ProjectModuleIdentity::Server { - module_path: module_path_text(&components[MODULE_PATH_START_COMPONENT..])?, - }, - Some("client") => ProjectModuleIdentity::Client { - module_path: module_path_text(&components[MODULE_PATH_START_COMPONENT..])?, - }, - Some("shared") => ProjectModuleIdentity::Shared { - module_path: module_path_text(&components[MODULE_PATH_START_COMPONENT..])?, - }, - Some(_) => { - return Err(ProjectDiscoveryProblem::UnknownExecutionSide { path: path_text }); - } - None => { - return Err(ProjectDiscoveryProblem::InvalidModulePath { - path: path_text, - component_count: components.len(), - }) - } - }; - let role = match components - .get(MODULE_ROLE_COMPONENT_INDEX) - .map(String::as_str) - { - Some("entrypoint") => ProjectModuleRole::Entrypoint, - Some("library") => ProjectModuleRole::Library, - Some(_) => { - return Err(ProjectDiscoveryProblem::UnknownModuleRole { - path: relative_path.to_path_buf(), - }); - } - None => { - return Err(ProjectDiscoveryProblem::InvalidModulePath { - path: relative_path.to_path_buf(), - component_count: components.len(), - }); - } - }; - Ok((side, role)) -} - -fn module_path_text(components: &[String]) -> Result { let Some(last_component) = components.last() else { return Err(ProjectDiscoveryProblem::InvalidModulePath { - path: PathBuf::new(), - component_count: components.len(), + path: relative_path.to_path_buf(), }); }; let module_name = match Path::new(last_component).file_stem() { - Some(module_name) => match module_name.to_str() { - Some(module_name) if !module_name.is_empty() => module_name.to_owned(), - Some(_) | None => { + Some(module_name) if !module_name.is_empty() => match module_name.to_str() { + Some(module_name) => module_name.to_owned(), + None => { return Err(ProjectDiscoveryProblem::InvalidModulePath { - path: PathBuf::from(last_component), - component_count: components.len(), + path: relative_path.to_path_buf(), }); } }, - None => { + Some(_) | None => { return Err(ProjectDiscoveryProblem::InvalidModulePath { - path: PathBuf::from(last_component), - component_count: components.len(), + path: relative_path.to_path_buf(), }); } }; - let last_component_index = components.len() - 1; - let mut module_segments = components[..last_component_index].to_vec(); + let mut module_segments = components[content_start..components.len() - 1].to_vec(); module_segments.push(module_name); Ok(module_segments.join("/")) } + +fn is_wasm_path(path: &Path) -> bool { + path.extension().and_then(|extension| extension.to_str()) == Some("wasm") +} + +fn is_project_asset_path(path: &Path) -> bool { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map_or("", |file_name| file_name); + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| matches!(extension, "luau" | "rbxm" | "rbxmx" | "rbxl" | "rbxlx")) + || file_name.ends_with(".model.json") +} diff --git a/src/project/execution_side.rs b/src/project/execution_side.rs new file mode 100644 index 0000000..4cbb537 --- /dev/null +++ b/src/project/execution_side.rs @@ -0,0 +1,12 @@ +//! Runtime side derived from a Roblox service. + +/// Distinguishes which Roblox runtime can execute a discovered project module. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)] +pub enum ModuleExecutionSide { + /// Runs only in the authoritative server runtime. + Server, + /// Runs only in each player's client runtime. + Client, + /// Is available to both runtimes and initialized independently by each one. + Shared, +} diff --git a/src/project/manifest.rs b/src/project/manifest.rs index 1b8c60b..da72466 100644 --- a/src/project/manifest.rs +++ b/src/project/manifest.rs @@ -44,6 +44,14 @@ pub enum ProjectManifestProblem { /// The configured output-root value that failed validation. path: PathBuf, }, + /// The compiler cannot safely publish one project into the other project's source tree. + #[error("project source_root {source_root:?} overlaps output_root {output_root:?}")] + OverlappingRoots { + /// The configured source root. + source_root: PathBuf, + /// The configured output root. + output_root: PathBuf, + }, } impl ProjectManifest { @@ -87,17 +95,19 @@ impl ProjectManifest { Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(), Some(_) | None => PathBuf::from("."), }; + let source_root = resolve_manifest_path(&manifest_directory, &document.project.source_root); + let output_root = resolve_manifest_path(&manifest_directory, &document.project.output_root); + if source_root.starts_with(&output_root) || output_root.starts_with(&source_root) { + return Err(ProjectManifestProblem::OverlappingRoots { + source_root, + output_root, + }); + } Ok(Self { manifest_path: manifest_path.to_path_buf(), project: ManifestProject { - source_root: resolve_manifest_path( - &manifest_directory, - &document.project.source_root, - ), - output_root: resolve_manifest_path( - &manifest_directory, - &document.project.output_root, - ), + source_root, + output_root, }, }) } diff --git a/src/project/mod.rs b/src/project/mod.rs index 7eeb5e5..784fd2c 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -1,14 +1,20 @@ //! Compiling one or more wasm modules into a strict Luau Roblox project. mod discovery; -mod layout; +mod execution_side; mod manifest; mod problem; +mod project_module_identity; +mod project_module_role; +mod roblox_service; pub use discovery::{discover_project_request, ProjectDiscoveryProblem}; -pub use layout::{ModuleExecutionSide, ProjectModuleIdentity, ProjectModuleRole}; +pub use execution_side::ModuleExecutionSide; pub use manifest::{ProjectManifest, ProjectManifestProblem}; pub use problem::{ProjectCompilationProblem, ProjectCompilationRejection}; +pub use project_module_identity::ProjectModuleIdentity; +pub use project_module_role::ProjectModuleRole; +pub use roblox_service::RobloxService; use crate::translate::{GeneratedLuauText, MainInvocation, TranslateOptions, TranslateOutcome}; use crate::wasm::{decode_module, DecodeOutcome}; @@ -35,6 +41,12 @@ pub struct ProjectModuleSource { wasm_bytes: Vec, } +#[derive(Debug)] +pub(crate) struct ProjectSourceAsset { + relative_path: PathBuf, + bytes: Vec, +} + /// Keeps caller-owned wasm inputs immutable after the project compiler takes ownership. impl ProjectModuleSource { /// @why Keeps the module's location and initialization contract inseparable. @@ -73,6 +85,7 @@ impl ProjectModuleSource { #[derive(Debug)] pub struct ProjectCompilationRequest { source_modules: Vec, + source_assets: Vec, } /// Preserves the caller's complete source set so ordering cannot change the emitted layout. @@ -81,7 +94,20 @@ impl ProjectCompilationRequest { /// entrypoints reject the project before any artifact is accepted. #[must_use] pub const fn from_source_modules(source_modules: Vec) -> Self { - Self { source_modules } + Self { + source_modules, + source_assets: Vec::new(), + } + } + + pub(crate) const fn from_discovered( + source_modules: Vec, + source_assets: Vec, + ) -> Self { + Self { + source_modules, + source_assets, + } } /// @why Lets the compiler consume the source set after validation. @@ -89,6 +115,10 @@ impl ProjectCompilationRequest { pub fn into_source_modules(self) -> Vec { self.source_modules } + + pub(crate) fn into_parts(self) -> (Vec, Vec) { + (self.source_modules, self.source_assets) + } } /// The destination path of one generated artifact. @@ -139,6 +169,7 @@ impl GeneratedProjectModule { #[derive(Debug)] pub struct CompiledProject { generated_modules: Vec, + source_assets: Vec, } impl CompiledProject { @@ -168,7 +199,11 @@ impl CompiledProject { Ok(()) => {} Err(publish_error) => return Err(publish_error), } - let mut written_paths = Vec::with_capacity(self.generated_modules.len()); + let mut written_paths = + Vec::with_capacity(self.generated_modules.len() + self.source_assets.len()); + for source_asset in &self.source_assets { + written_paths.push(source_asset_output_path(project_root, source_asset)?); + } for generated_module in &self.generated_modules { written_paths.push(generated_output_path(project_root, generated_module)?); } @@ -176,6 +211,13 @@ impl CompiledProject { } fn write_to_staging_directory(&self, staging_directory: &Path) -> Result<(), std::io::Error> { + for source_asset in &self.source_assets { + let output_path = source_asset_output_path(staging_directory, source_asset)?; + if let Some(parent_directory) = output_path.parent() { + fs_err::create_dir_all(parent_directory)?; + } + fs_err::write(output_path, &source_asset.bytes)?; + } for generated_module in &self.generated_modules { let output_path = generated_output_path(staging_directory, generated_module)?; if let Some(parent_directory) = output_path.parent() { @@ -203,6 +245,31 @@ impl CompiledProject { } } +fn source_asset_output_path( + project_root: &Path, + source_asset: &ProjectSourceAsset, +) -> Result { + let relative_path = source_asset.relative_path.as_path(); + for component in relative_path.components() { + match component { + Component::Normal(_) => {} + Component::CurDir + | Component::ParentDir + | Component::RootDir + | Component::Prefix(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "source asset path is not relative and normalized: {}", + relative_path.display() + ), + )); + } + } + } + Ok(project_root.join(relative_path)) +} + const MAX_STAGING_DIRECTORY_ATTEMPTS: usize = 16; const INITIAL_STAGING_DIRECTORY_ATTEMPT: usize = 0; const FALLBACK_TIMESTAMP_NANOS: u128 = 0; @@ -366,11 +433,13 @@ fn output_sibling_path(project_root: &Path, purpose: &str, attempt: usize) -> Pa /// failure instead of accepting a partial project. #[must_use] pub fn compile_project(request: ProjectCompilationRequest) -> ProjectCompilationOutcome { - let mut source_modules = request.into_source_modules(); + let (mut source_modules, mut source_assets) = request.into_parts(); source_modules.sort_by(|left, right| left.module_identity().cmp(right.module_identity())); + source_assets.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); let mut previous_identity: Option = None; let mut has_entrypoint = false; + let mut output_paths = std::collections::BTreeSet::new(); for source_module in &source_modules { let module_identity = source_module.module_identity(); match &previous_identity { @@ -386,18 +455,37 @@ pub fn compile_project(request: ProjectCompilationRequest) -> ProjectCompilation ProjectModuleRole::Entrypoint => has_entrypoint = true, ProjectModuleRole::Library => {} } - if source_module.module_role() == ProjectModuleRole::Entrypoint - && source_module - .module_identity() - .output_path_text(source_module.module_role()) - .is_none() - { + let Some(output_path_text) = module_identity.output_path_text(source_module.module_role()) + else { + let problem = match module_identity { + ProjectModuleIdentity::RobloxService { .. } => { + ProjectCompilationProblem::IllegalServicePlacement { + identity: module_identity.clone(), + role: source_module.module_role(), + } + } + _ => ProjectCompilationProblem::SharedEntrypoint(module_identity.clone()), + }; + return ProjectCompilationOutcome::Rejected(problem.into()); + }; + if !output_paths.insert(output_path_text) { return ProjectCompilationOutcome::Rejected( - ProjectCompilationProblem::SharedEntrypoint(module_identity.clone()).into(), + ProjectCompilationProblem::DuplicateOutputPath( + module_identity.module_path().to_owned(), + ) + .into(), ); } previous_identity = Some(module_identity.clone()); } + for source_asset in &source_assets { + let asset_path = source_asset.relative_path.display().to_string(); + if !output_paths.insert(asset_path.clone()) { + return ProjectCompilationOutcome::Rejected( + ProjectCompilationProblem::SourceOutputOverlap(asset_path).into(), + ); + } + } if !has_entrypoint { return ProjectCompilationOutcome::Rejected( ProjectCompilationProblem::MissingEntrypointModule.into(), @@ -428,7 +516,10 @@ pub fn compile_project(request: ProjectCompilationRequest) -> ProjectCompilation }); } - ProjectCompilationOutcome::Compiled(CompiledProject { generated_modules }) + ProjectCompilationOutcome::Compiled(CompiledProject { + generated_modules, + source_assets, + }) } fn translate_wasm_module( diff --git a/src/project/problem.rs b/src/project/problem.rs index d26dbfb..33eb219 100644 --- a/src/project/problem.rs +++ b/src/project/problem.rs @@ -15,6 +15,23 @@ pub enum ProjectCompilationProblem { #[error("shared module {0} cannot be an entrypoint")] SharedEntrypoint(crate::project::ProjectModuleIdentity), + /// A service-derived module was assigned a role that its service cannot host. + #[error("module {identity} cannot be placed in its Roblox service as a {role:?}")] + IllegalServicePlacement { + /// The service-derived module identity that failed validation. + identity: crate::project::ProjectModuleIdentity, + /// The role that was requested for the service. + role: crate::project::ProjectModuleRole, + }, + + /// Two source modules would publish the same `DataModel` path. + #[error("multiple modules would publish to {0}")] + DuplicateOutputPath(String), + + /// A generated module would overwrite a source asset at the same `DataModel` path. + #[error("generated module overlaps source asset at {0}")] + SourceOutputOverlap(String), + /// The wasm bytes for a module failed to decode. #[error("module {0} failed to decode")] DecodeFailed(String), @@ -30,6 +47,7 @@ pub struct ProjectCompilationRejection { problem: ProjectCompilationProblem, } +/// Exposes the typed reason that stopped project compilation. impl ProjectCompilationRejection { /// @why Lets callers wrap a single problem into a rejection outcome. #[must_use] diff --git a/src/project/layout.rs b/src/project/project_module_identity.rs similarity index 56% rename from src/project/layout.rs rename to src/project/project_module_identity.rs index 415144b..e19f7c4 100644 --- a/src/project/layout.rs +++ b/src/project/project_module_identity.rs @@ -1,67 +1,56 @@ -//! Roblox project layout: identities, execution sides, and output paths. +//! Source identities and generated Roblox destinations. +use super::{ModuleExecutionSide, ProjectModuleRole, RobloxService}; use std::fmt; -/// Which Roblox runtime realm a module executes in. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ModuleExecutionSide { - /// Runs only in the authoritative server runtime. - Server, - /// Runs only in each player's client runtime. - Client, - /// Is available to both runtimes and initialized independently by each one. - Shared, -} - -/// Whether a module is an eager entrypoint or a lazily-initialized library. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProjectModuleRole { - /// Instantiates and invokes the module's `main` export eagerly. - Entrypoint, - /// Emits declarations only; a later import surface owns initialization. - Library, -} - /// Identifies one source module and its Roblox execution boundary. #[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd)] pub enum ProjectModuleIdentity { - /// Places the module beneath `ServerScriptService`. + /// Places a lower-level CLI module beneath `ServerScriptService`. Server { /// Is a slash-separated path relative to the server source root. module_path: String, }, - /// Places the module beneath `StarterPlayerScripts`. + /// Places a lower-level CLI module beneath `StarterPlayerScripts`. Client { /// Is a slash-separated path relative to the client source root. module_path: String, }, - /// Places the module beneath `ReplicatedStorage` for independent client - /// and server initialization. + /// Places a lower-level CLI module beneath `ReplicatedStorage`. Shared { /// Is a slash-separated path relative to the shared source root. module_path: String, }, + /// Places a discovered module under its owning Roblox service. + RobloxService { + /// The recognized service that owns the module. + service: RobloxService, + /// Is a slash-separated path beneath that service. + module_path: String, + }, } /// Keeps source identity decisions centralized before project layout derives target paths. impl ProjectModuleIdentity { - /// @why Lets compilation select Roblox placement without duplicating execution-side checks. + /// Lets compilation select Roblox placement without duplicating execution-side checks. #[must_use] pub const fn execution_side(&self) -> ModuleExecutionSide { match self { Self::Server { .. } => ModuleExecutionSide::Server, Self::Client { .. } => ModuleExecutionSide::Client, Self::Shared { .. } => ModuleExecutionSide::Shared, + Self::RobloxService { service, .. } => service.execution_side(), } } - /// @why Lets diagnostics name the offending source module without exposing storage. + /// Lets diagnostics name the offending source module without exposing storage. #[must_use] pub fn module_path(&self) -> &str { match self { Self::Server { module_path } | Self::Client { module_path } - | Self::Shared { module_path } => module_path, + | Self::Shared { module_path } + | Self::RobloxService { module_path, .. } => module_path, } } @@ -85,7 +74,34 @@ impl ProjectModuleIdentity { Some(format!("ReplicatedStorage/{module_path}.luau")) } (Self::Shared { .. }, ProjectModuleRole::Entrypoint) => None, + (Self::RobloxService { service, .. }, role) => { + service_output_path(*service, module_path, role) + } + } + } +} + +fn service_output_path( + service: RobloxService, + module_path: &str, + module_role: ProjectModuleRole, +) -> Option { + match service.module_role() { + expected_role if expected_role == module_role => { + let suffix = match module_role { + ProjectModuleRole::Entrypoint => match service.execution_side() { + ModuleExecutionSide::Server => ".server.luau", + ModuleExecutionSide::Client => ".client.luau", + ModuleExecutionSide::Shared => ".luau", + }, + ProjectModuleRole::Library => ".luau", + }; + Some(format!( + "{}/{module_path}{suffix}", + service.data_model_path() + )) } + _ => None, } } @@ -95,6 +111,9 @@ impl fmt::Display for ProjectModuleIdentity { Self::Server { .. } => "server", Self::Client { .. } => "client", Self::Shared { .. } => "shared", + Self::RobloxService { service, .. } => { + return write!(formatter, "{service:?}/{}", self.module_path()) + } }; write!(formatter, "{execution_side}:{}", self.module_path()) } diff --git a/src/project/project_module_role.rs b/src/project/project_module_role.rs new file mode 100644 index 0000000..8211257 --- /dev/null +++ b/src/project/project_module_role.rs @@ -0,0 +1,10 @@ +//! Initialization role derived from a Roblox service. + +/// Distinguishes eager Roblox scripts from lazily initialized `ModuleScripts`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)] +pub enum ProjectModuleRole { + /// Instantiates and invokes the module's `main` export eagerly. + Entrypoint, + /// Emits declarations only; a later import surface owns initialization. + Library, +} diff --git a/src/project/roblox_service.rs b/src/project/roblox_service.rs new file mode 100644 index 0000000..d0d9105 --- /dev/null +++ b/src/project/roblox_service.rs @@ -0,0 +1,68 @@ +//! Recognized Roblox service containers and their compiler-owned semantics. + +use super::{ModuleExecutionSide, ProjectModuleRole}; + +/// Names the Roblox service containers accepted at the root of a project tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)] +pub enum RobloxService { + /// Owns server entrypoint scripts. + ServerScriptService, + /// Owns server-only `ModuleScripts`. + ServerStorage, + /// Owns `ModuleScripts` replicated to both server and clients. + ReplicatedStorage, + /// Owns server scripts placed in the 3D world. + Workspace, + /// Owns client-first scripts. + ReplicatedFirst, + /// Owns client GUI scripts. + StarterGui, + /// Owns scripts that run for each player. + StarterPlayerScripts, + /// Owns scripts that run for each character. + StarterCharacterScripts, +} + +/// Adds the compiler's fixed path, role, and runtime mapping to each service. +impl RobloxService { + /// Returns the `DataModel` path used to publish this service's children. + pub(crate) const fn data_model_path(self) -> &'static str { + match self { + Self::ServerScriptService => "ServerScriptService", + Self::ServerStorage => "ServerStorage", + Self::ReplicatedStorage => "ReplicatedStorage", + Self::Workspace => "Workspace", + Self::ReplicatedFirst => "ReplicatedFirst", + Self::StarterGui => "StarterGui", + Self::StarterPlayerScripts => "StarterPlayer/StarterPlayerScripts", + Self::StarterCharacterScripts => "StarterPlayer/StarterCharacterScripts", + } + } + + /// Returns the role Roblox gives a wasm module under this service. + pub(crate) const fn module_role(self) -> ProjectModuleRole { + match self { + Self::ServerStorage | Self::ReplicatedStorage => ProjectModuleRole::Library, + Self::ServerScriptService + | Self::Workspace + | Self::ReplicatedFirst + | Self::StarterGui + | Self::StarterPlayerScripts + | Self::StarterCharacterScripts => ProjectModuleRole::Entrypoint, + } + } + + /// Returns the execution side implied by this service. + pub(crate) const fn execution_side(self) -> ModuleExecutionSide { + match self { + Self::ReplicatedStorage => ModuleExecutionSide::Shared, + Self::ReplicatedFirst + | Self::StarterGui + | Self::StarterPlayerScripts + | Self::StarterCharacterScripts => ModuleExecutionSide::Client, + Self::ServerScriptService | Self::ServerStorage | Self::Workspace => { + ModuleExecutionSide::Server + } + } + } +} diff --git a/tests/cli_project.rs b/tests/cli_project.rs index bd0cf55..e9b4e92 100644 --- a/tests/cli_project.rs +++ b/tests/cli_project.rs @@ -31,7 +31,7 @@ fn given_valid_project_when_checked_then_workspace_stays_unchanged() -> Result<( let temp_dir = tempfile::Builder::new() .prefix("luau-rs-project-bdd-check") .tempdir()?; - copy_fixture_module(temp_dir.path(), "wasm/server/entrypoint/main.wasm")?; + copy_fixture_module(temp_dir.path(), "wasm/ServerScriptService/main.wasm")?; let manifest = temp_dir.path().join("luau-rs.toml"); write_project_manifest(&manifest)?; @@ -66,8 +66,8 @@ fn given_nested_project_when_compiled_twice_then_modules_map_and_stale_file_disa let temp_dir = tempfile::Builder::new() .prefix("luau-rs-project-bdd-layout") .tempdir()?; - copy_fixture_module(temp_dir.path(), "wasm/shared/library/math/core.wasm")?; - copy_fixture_module(temp_dir.path(), "wasm/server/entrypoint/game/main.wasm")?; + copy_fixture_module(temp_dir.path(), "wasm/ReplicatedStorage/math/core.wasm")?; + copy_fixture_module(temp_dir.path(), "wasm/ServerScriptService/game/main.wasm")?; let manifest = temp_dir.path().join("luau-rs.toml"); write_project_manifest(&manifest)?; @@ -175,7 +175,7 @@ fn given_good_project_when_recompiled_with_corrupt_module_then_last_output_survi let temp_dir = tempfile::Builder::new() .prefix("luau-rs-project-bdd-preserve") .tempdir()?; - let wasm_path = copy_fixture_module(temp_dir.path(), "wasm/server/entrypoint/main.wasm")?; + let wasm_path = copy_fixture_module(temp_dir.path(), "wasm/ServerScriptService/main.wasm")?; let manifest = temp_dir.path().join("luau-rs.toml"); write_project_manifest(&manifest)?; let output_path = temp_dir