use std::{ collections::HashSet, fs::{create_dir_all, read_to_string, write}, path::{Component, Path, PathBuf}, }; use full_moon::{ ast::types::ExportedTypeDeclaration, parse, visitors::{Visit, Visitor}, }; use log::debug; use semver::Version; use thiserror::Error; use crate::{ dependencies::resolution::{packages_folder, ResolvedPackage, RootLockfileNode}, manifest::{Manifest, ManifestReadError, PathStyle, Realm}, package_name::PackageName, project::Project, }; struct TypeVisitor { pub(crate) types: Vec, } impl Visitor for TypeVisitor { fn visit_exported_type_declaration(&mut self, node: &ExportedTypeDeclaration) { let name = node.type_declaration().type_name().to_string(); let (declaration_generics, generics) = if let Some(declaration) = node.type_declaration().generics() { let mut declaration_generics = vec![]; let mut generics = vec![]; for generic in declaration.generics().iter() { declaration_generics.push(generic.to_string()); if generic.default_type().is_some() { generics.push(generic.parameter().to_string()) } else { generics.push(generic.to_string()) } } ( format!("<{}>", declaration_generics.join(", ")), format!("<{}>", generics.join(", ")), ) } else { ("".to_string(), "".to_string()) }; self.types.push(format!( "export type {name}{declaration_generics} = module.{name}{generics}\n" )); } } /// Generates the contents of a linking file, given the require path, and the contents of the target file /// The contents will be scanned for type exports, and the linking file will be generated accordingly pub fn linking_file(content: &str, path: &str) -> Result { let mut linker = format!("local module = require({path})\n"); let mut visitor = TypeVisitor { types: vec![] }; parse(content)?.nodes().visit(&mut visitor); for ty in visitor.types { linker.push_str(&ty); } linker.push_str("return module"); Ok(linker) } #[derive(Debug, Error)] /// An error that occurred while linking dependencies pub enum LinkingError { #[error("error interacting with the file system")] /// An error that occurred while interacting with the file system Io(#[from] std::io::Error), #[error("failed getting file name from {0}")] /// An error that occurred while getting a file name FileNameFail(PathBuf), #[error("failed converting file name to string")] /// An error that occurred while converting a file name to a string FileNameToStringFail, #[error("failed getting relative path from {0} to {1}")] /// An error that occurred while getting a relative path RelativePathFail(PathBuf, PathBuf), #[error("failed getting path parent of {0}")] /// An error that occurred while getting a path parent ParentFail(PathBuf), #[error("failed to convert path component to string")] /// An error that occurred while converting a path component to a string ComponentToStringFail, #[error("failed to get path string")] /// An error that occurred while getting a path string PathToStringFail, #[error("error encoding utf-8 string")] /// An error that occurred while converting a byte slice to a string Utf8(#[from] std::str::Utf8Error), #[error("error reading manifest")] /// An error that occurred while reading the manifest of a package ManifestRead(#[from] ManifestReadError), #[error("missing realm {0} in-game path")] /// An error that occurred while getting the in-game path for a realm MissingRealmInGamePath(Realm), #[error("library source is not valid Luau")] /// An error that occurred because the library source is not valid Luau InvalidLuau(#[from] full_moon::Error), } pub(crate) fn link, Q: AsRef>( project: &Project, resolved_pkg: &ResolvedPackage, lockfile: &RootLockfileNode, destination_dir: P, parent_dependency_packages_dir: Q, desired_name: &str, as_root: bool, ) -> Result<(), LinkingError> { let (_, source_dir) = resolved_pkg.directory(project.path()); let file = Manifest::from_path(&source_dir)?; let Some(relative_lib_export) = file.exports.lib else { return Ok(()); }; let lib_export = relative_lib_export.to_path(&source_dir); let path_style = &project.manifest().path_style; let PathStyle::Roblox { place } = &path_style; debug!("linking {resolved_pkg} using `{}` path style", path_style); let pkg_name = resolved_pkg.pkg_ref.name(); let name = pkg_name.name(); let destination_dir = match lockfile .specifiers .get(&pkg_name) .and_then(|v| v.get(resolved_pkg.pkg_ref.version())) { Some((specifier, _)) if as_root => project.path().join(packages_folder( specifier.realm().copied().unwrap_or_default(), )), _ => destination_dir.as_ref().to_path_buf(), }; create_dir_all(&destination_dir)?; let destination_file = destination_dir.join(desired_name.to_string() + ".lua"); let realm_folder = project.path().join(resolved_pkg.packages_folder()); let in_different_folders = realm_folder != parent_dependency_packages_dir.as_ref(); let mut path = if in_different_folders { pathdiff::diff_paths(&source_dir, &realm_folder) .ok_or_else(|| LinkingError::RelativePathFail(source_dir.clone(), realm_folder))? } else { pathdiff::diff_paths(&source_dir, &destination_dir).ok_or_else(|| { LinkingError::RelativePathFail(source_dir.clone(), destination_dir.to_path_buf()) })? }; path.set_extension(""); let beginning = if in_different_folders { place .get(&resolved_pkg.realm) .ok_or_else(|| LinkingError::MissingRealmInGamePath(resolved_pkg.realm))? .clone() } else if name == "init" { "script".to_string() } else { "script.Parent".to_string() }; let mut components = path .components() .map(|component| { Ok(match component { Component::ParentDir => ".Parent".to_string(), Component::Normal(part) => format!( "[{:?}]", part.to_str().ok_or(LinkingError::ComponentToStringFail)? ), _ => unreachable!("invalid path component"), }) }) .collect::, LinkingError>>()?; components.pop(); let path = beginning + &components.join("") + &format!("[{name:?}]"); debug!( "writing linking file for {} with import `{path}` to {}", source_dir.display(), destination_file.display() ); let file_contents = match relative_lib_export.as_str() { "true" => "".to_string(), _ => read_to_string(lib_export)?, }; let linking_file_contents = linking_file(&file_contents, &path)?; write(&destination_file, linking_file_contents)?; Ok(()) } #[derive(Debug, Error)] #[error("error linking {1}@{2} to {3}@{4}")] /// An error that occurred while linking the dependencies pub struct LinkingDependenciesError( #[source] LinkingError, PackageName, Version, PackageName, Version, ); impl Project { /// Links the dependencies of the project pub fn link_dependencies( &self, lockfile: &RootLockfileNode, ) -> Result<(), LinkingDependenciesError> { let root_deps = lockfile .specifiers .iter() .flat_map(|(name, versions)| versions.keys().map(|version| (name.clone(), version))) .collect::>(); for (name, versions) in &lockfile.children { for (version, resolved_pkg) in versions { let (container_dir, _) = resolved_pkg.directory(self.path()); debug!( "linking package {name}@{version}'s dependencies to directory {}", container_dir.display() ); for (dep_name, (dep_version, desired_name)) in &resolved_pkg.dependencies { let dep = lockfile .children .get(dep_name) .and_then(|versions| versions.get(dep_version)) .unwrap(); link( self, dep, lockfile, &container_dir, &self.path().join(resolved_pkg.packages_folder()), desired_name, false, ) .map_err(|e| { LinkingDependenciesError( e, dep_name.clone(), dep_version.clone(), name.clone(), version.clone(), ) })?; } if root_deps.contains(&(name.clone(), version)) { let (specifier, desired_name) = lockfile.root_specifier(resolved_pkg).unwrap(); let linking_dir = &self.path().join(packages_folder( specifier.realm().copied().unwrap_or_default(), )); debug!( "linking root package {name}@{version} to directory {}", linking_dir.display() ); link( self, resolved_pkg, lockfile, linking_dir, self.path().join(resolved_pkg.packages_folder()), desired_name, true, ) .map_err(|e| { LinkingDependenciesError( e, name.clone(), version.clone(), name.clone(), version.clone(), ) })?; } } } Ok(()) } }