From b07202a64b4fe1da0d5de75ef5537b3dc83c9f46 Mon Sep 17 00:00:00 2001 From: Filip Tibell Date: Sun, 14 Jan 2024 13:33:15 +0100 Subject: [PATCH] Implement support for path aliases in require --- .luaurc | 7 +- .vscode/settings.json | 4 +- CHANGELOG.md | 22 +++++ Cargo.lock | 7 ++ Cargo.toml | 1 + src/lune/builtins/process/mod.rs | 9 +- src/lune/globals/require/alias.rs | 69 ++++++++++++++-- src/lune/globals/require/context.rs | 23 ++---- src/lune/globals/require/mod.rs | 4 +- src/lune/globals/require/path.rs | 12 ++- src/lune/util/luaurc.rs | 123 ++++++++++++++++++++++++++++ src/lune/util/mod.rs | 2 + src/lune/util/paths.rs | 21 +++++ src/tests.rs | 1 + tests/require/tests/aliases.luau | 13 +++ 15 files changed, 287 insertions(+), 31 deletions(-) create mode 100644 src/lune/util/luaurc.rs create mode 100644 src/lune/util/paths.rs create mode 100644 tests/require/tests/aliases.luau diff --git a/.luaurc b/.luaurc index 9cc58679..2e9f18f6 100644 --- a/.luaurc +++ b/.luaurc @@ -7,5 +7,10 @@ "typeErrors": true, "globals": [ "warn" - ] + ], + "aliases": { + "lune": "./types/", + "tests": "./tests", + "require-tests": "./tests/require/tests" + } } diff --git a/.vscode/settings.json b/.vscode/settings.json index e7ff588e..95c90cf6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,9 @@ "luau-lsp.types.roblox": false, "luau-lsp.require.mode": "relativeToFile", "luau-lsp.require.directoryAliases": { - "@lune/": "./types/" + "@lune/": "./types/", + "@tests/": "./tests/", + "@require-tests/": "./tests/require/tests/" }, "luau-lsp.ignoreGlobs": [ "tests/roblox/rbx-test-files/**/*.lua", diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a55adc9..baf5e077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 To compile scripts that use `require` and reference multiple files, a bundler such as [darklua](https://github.com/seaofvoices/darklua) will need to be used first. This limitation will be lifted in the future and Lune will automatically bundle any referenced scripts. +- Added support for path aliases using `.luaurc` config files! + + For full documentation and reference, check out the [official Luau RFC](https://rfcs.luau-lang.org/require-by-string-aliases.html), but here's a quick example: + + ```jsonc + // .luaurc + { + "aliases": { + "modules": "./some/long/path/to/modules" + } + } + ``` + + ```lua + -- ./some/long/path/to/modules/foo.luau + return { World = "World!" } + + -- ./anywhere/you/want/my_script.luau + local mod = require("@modules/foo") + print("Hello, " .. mod.World) + ``` + - Added support for multiple values for a single query, and multiple values for a single header, in `net.request`. This is a part of the HTTP specification that is not widely used but that may be useful in certain cases. To clarify: - Single values remain unchanged and will work exactly the same as before.
diff --git a/Cargo.lock b/Cargo.lock index 1f8c6e2d..e3fbbfe2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1129,6 +1129,7 @@ dependencies = [ "once_cell", "os_str_bytes", "path-clean", + "pathdiff", "pin-project", "rand", "rbx_binary", @@ -1390,6 +1391,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + [[package]] name = "percent-encoding" version = "2.3.1" diff --git a/Cargo.toml b/Cargo.toml index e7531d63..2e75ce04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ dialoguer = "0.11" dunce = "1.0" lz4_flex = "0.11" path-clean = "1.0" +pathdiff = "0.2" pin-project = "1.0" urlencoding = "2.1" diff --git a/src/lune/builtins/process/mod.rs b/src/lune/builtins/process/mod.rs index 5443a016..15b18335 100644 --- a/src/lune/builtins/process/mod.rs +++ b/src/lune/builtins/process/mod.rs @@ -4,12 +4,14 @@ use std::{ process::Stdio, }; -use dunce::canonicalize; use mlua::prelude::*; use os_str_bytes::RawOsString; use tokio::io::AsyncWriteExt; -use crate::lune::{scheduler::Scheduler, util::TableBuilder}; +use crate::lune::{ + scheduler::Scheduler, + util::{paths::CWD, TableBuilder}, +}; mod tee_writer; @@ -26,8 +28,7 @@ yield() pub fn create(lua: &'static Lua) -> LuaResult { let cwd_str = { - let cwd = canonicalize(env::current_dir()?)?; - let cwd_str = cwd.to_string_lossy().to_string(); + let cwd_str = CWD.to_string_lossy().to_string(); if !cwd_str.ends_with(path::MAIN_SEPARATOR) { format!("{cwd_str}{}", path::MAIN_SEPARATOR) } else { diff --git a/src/lune/globals/require/alias.rs b/src/lune/globals/require/alias.rs index f8fd15e1..a4cf1990 100644 --- a/src/lune/globals/require/alias.rs +++ b/src/lune/globals/require/alias.rs @@ -1,16 +1,75 @@ +use console::style; use mlua::prelude::*; +use crate::lune::util::{ + luaurc::LuauRc, + paths::{make_absolute_and_clean, CWD}, +}; + use super::context::*; pub(super) async fn require<'lua, 'ctx>( - _ctx: &'ctx RequireContext<'lua>, + ctx: &'ctx RequireContext<'lua>, + source: &str, alias: &str, - name: &str, + path: &str, ) -> LuaResult> where 'lua: 'ctx, { - Err(LuaError::runtime(format!( - "TODO: Support require for built-in libraries (tried to require '{name}' with alias '{alias}')" - ))) + let alias = alias.to_ascii_lowercase(); + let path = path.to_ascii_lowercase(); + + let parent = make_absolute_and_clean(source) + .parent() + .expect("how did a root path end up here..") + .to_path_buf(); + + // Try to gather the first luaurc and / or error we + // encounter to display better error messages to users + let mut first_luaurc = None; + let mut first_error = None; + let predicate = |rc: &LuauRc| { + if first_luaurc.is_none() { + first_luaurc.replace(rc.clone()); + } + if let Err(e) = rc.validate() { + if first_error.is_none() { + first_error.replace(e); + } + false + } else { + rc.find_alias(&alias).is_some() + } + }; + + // Try to find a luaurc that contains the alias we're searching for + let luaurc = LuauRc::read_recursive(parent, predicate) + .await + .ok_or_else(|| { + if let Some(error) = first_error { + LuaError::runtime(format!("error while parsing .luaurc file: {error}")) + } else if let Some(luaurc) = first_luaurc { + LuaError::runtime(format!( + "failed to find alias '{alias}' - known aliases:\n{}", + luaurc + .aliases() + .iter() + .map(|(name, path)| format!(" {name} {} {path}", style(">").dim())) + .collect::>() + .join("\n") + )) + } else { + LuaError::runtime(format!("failed to find alias '{alias}' (no .luaurc)")) + } + })?; + + // We now have our aliased path, our path require function just needs it + // in a slightly different format with both absolute + relative to cwd + let abs_path = luaurc.find_alias(&alias).unwrap().join(path); + let rel_path = pathdiff::diff_paths(&abs_path, CWD.as_path()).ok_or_else(|| { + LuaError::runtime(format!("failed to find relative path for alias '{alias}'")) + })?; + + super::path::require_abs_rel(ctx, abs_path, rel_path).await } diff --git a/src/lune/globals/require/context.rs b/src/lune/globals/require/context.rs index 5f17f002..17522e16 100644 --- a/src/lune/globals/require/context.rs +++ b/src/lune/globals/require/context.rs @@ -1,6 +1,5 @@ use std::{ collections::HashMap, - env, path::{Path, PathBuf}, sync::Arc, }; @@ -17,6 +16,7 @@ use tokio::{ use crate::lune::{ builtins::LuneBuiltin, scheduler::{IntoLuaThread, Scheduler}, + util::paths::CWD, }; /** @@ -28,8 +28,6 @@ use crate::lune::{ #[derive(Debug, Clone)] pub(super) struct RequireContext<'lua> { lua: &'lua Lua, - use_cwd_relative_paths: bool, - working_directory: PathBuf, cache_builtins: Arc>>>, cache_results: Arc>>>, cache_pending: Arc>>>, @@ -44,13 +42,8 @@ impl<'lua> RequireContext<'lua> { than one context may lead to undefined require-behavior. */ pub fn new(lua: &'lua Lua) -> Self { - // FUTURE: We could load some kind of config or env var - // to check if we should be using cwd-relative paths - let cwd = env::current_dir().expect("Failed to get current working directory"); Self { lua, - use_cwd_relative_paths: false, - working_directory: cwd, cache_builtins: Arc::new(AsyncMutex::new(HashMap::new())), cache_results: Arc::new(AsyncMutex::new(HashMap::new())), cache_pending: Arc::new(AsyncMutex::new(HashMap::new())), @@ -70,20 +63,16 @@ impl<'lua> RequireContext<'lua> { source: impl AsRef, path: impl AsRef, ) -> LuaResult<(PathBuf, PathBuf)> { - let path = if self.use_cwd_relative_paths { - PathBuf::from(path.as_ref()) - } else { - PathBuf::from(source.as_ref()) - .parent() - .ok_or_else(|| LuaError::runtime("Failed to get parent path of source"))? - .join(path.as_ref()) - }; + let path = PathBuf::from(source.as_ref()) + .parent() + .ok_or_else(|| LuaError::runtime("Failed to get parent path of source"))? + .join(path.as_ref()); let rel_path = path_clean::clean(path); let abs_path = if rel_path.is_absolute() { rel_path.to_path_buf() } else { - self.working_directory.join(&rel_path) + CWD.join(&rel_path) }; Ok((rel_path, abs_path)) diff --git a/src/lune/globals/require/mod.rs b/src/lune/globals/require/mod.rs index f54ab219..3ba89a06 100644 --- a/src/lune/globals/require/mod.rs +++ b/src/lune/globals/require/mod.rs @@ -88,10 +88,10 @@ where { builtin::require(&context, &builtin_name).await } else if let Some(aliased_path) = path.strip_prefix('@') { - let (alias, name) = aliased_path.split_once('/').ok_or(LuaError::runtime( + let (alias, path) = aliased_path.split_once('/').ok_or(LuaError::runtime( "Require with custom alias must contain '/' delimiter", ))?; - alias::require(&context, alias, name).await + alias::require(&context, &source, alias, path).await } else { path::require(&context, &source, &path).await } diff --git a/src/lune/globals/require/path.rs b/src/lune/globals/require/path.rs index 0e443fdc..7333a5b1 100644 --- a/src/lune/globals/require/path.rs +++ b/src/lune/globals/require/path.rs @@ -13,7 +13,17 @@ where 'lua: 'ctx, { let (abs_path, rel_path) = ctx.resolve_paths(source, path)?; + require_abs_rel(ctx, abs_path, rel_path).await +} +pub(super) async fn require_abs_rel<'lua, 'ctx>( + ctx: &'ctx RequireContext<'lua>, + abs_path: PathBuf, // Absolute to filesystem + rel_path: PathBuf, // Relative to CWD (for displaying) +) -> LuaResult> +where + 'lua: 'ctx, +{ // 1. Try to require the exact path if let Ok(res) = require_inner(ctx, &abs_path, &rel_path).await { return Ok(res); @@ -62,7 +72,7 @@ where // Nothing left to try, throw an error Err(LuaError::runtime(format!( - "No file exist at the path '{}'", + "No file exists at the path '{}'", rel_path.display() ))) } diff --git a/src/lune/util/luaurc.rs b/src/lune/util/luaurc.rs new file mode 100644 index 00000000..69ac64a3 --- /dev/null +++ b/src/lune/util/luaurc.rs @@ -0,0 +1,123 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf, MAIN_SEPARATOR}, +}; + +use path_clean::PathClean; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use tokio::fs; + +use super::paths::make_absolute_and_clean; + +const LUAURC_FILE: &str = ".luaurc"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LuauLanguageMode { + NoCheck, + NonStrict, + Strict, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LuauRcConfig { + #[serde(skip_serializing_if = "Option::is_none")] + language_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + lint: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + lint_errors: Option, + #[serde(skip_serializing_if = "Option::is_none")] + type_errors: Option, + #[serde(skip_serializing_if = "Option::is_none")] + globals: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + paths: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + aliases: Option>, +} + +#[derive(Debug, Clone)] +pub struct LuauRc { + dir: PathBuf, + config: LuauRcConfig, +} + +impl LuauRc { + pub async fn read(dir: impl AsRef) -> Option { + let dir = make_absolute_and_clean(dir); + let path = dir.join(LUAURC_FILE); + let bytes = fs::read(&path).await.ok()?; + let config = serde_json::from_slice(&bytes).ok()?; + Some(Self { dir, config }) + } + + pub async fn read_recursive( + dir: impl AsRef, + mut predicate: impl FnMut(&Self) -> bool, + ) -> Option { + let mut current = make_absolute_and_clean(dir); + loop { + if let Some(rc) = Self::read(¤t).await { + if predicate(&rc) { + return Some(rc); + } + } + if let Some(parent) = current.parent() { + current = parent.to_path_buf(); + } else { + return None; + } + } + } + + pub fn validate(&self) -> Result<(), String> { + if let Some(aliases) = &self.config.aliases { + for alias in aliases.keys() { + if !is_valid_alias_key(alias) { + return Err(format!("invalid alias key: {}", alias)); + } + } + } + Ok(()) + } + + pub fn aliases(&self) -> HashMap { + self.config.aliases.clone().unwrap_or_default() + } + + pub fn find_alias(&self, name: &str) -> Option { + self.config.aliases.as_ref().and_then(|aliases| { + aliases.iter().find_map(|(alias, path)| { + if alias + .trim_end_matches(MAIN_SEPARATOR) + .eq_ignore_ascii_case(name) + && is_valid_alias_key(alias) + { + Some(self.dir.join(path).clean()) + } else { + None + } + }) + }) + } +} + +fn is_valid_alias_key(alias: impl AsRef) -> bool { + let alias = alias.as_ref(); + if alias.is_empty() + || alias.starts_with('.') + || alias.starts_with("..") + || alias.chars().any(|c| c == MAIN_SEPARATOR) + { + false // Paths are not valid alias keys + } else { + alias.chars().all(is_valid_alias_char) + } +} + +fn is_valid_alias_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' +} diff --git a/src/lune/util/mod.rs b/src/lune/util/mod.rs index 3c9457cc..45e75123 100644 --- a/src/lune/util/mod.rs +++ b/src/lune/util/mod.rs @@ -2,6 +2,8 @@ mod table_builder; pub mod formatting; pub mod futures; +pub mod luaurc; +pub mod paths; pub mod traits; pub use table_builder::TableBuilder; diff --git a/src/lune/util/paths.rs b/src/lune/util/paths.rs new file mode 100644 index 00000000..c439858c --- /dev/null +++ b/src/lune/util/paths.rs @@ -0,0 +1,21 @@ +use std::{ + env::current_dir, + path::{Path, PathBuf}, +}; + +use once_cell::sync::Lazy; +use path_clean::PathClean; + +pub static CWD: Lazy = Lazy::new(|| { + let cwd = current_dir().expect("failed to find current working directory"); + dunce::canonicalize(cwd).expect("failed to canonicalize current working directory") +}); + +pub fn make_absolute_and_clean(path: impl AsRef) -> PathBuf { + let path = path.as_ref(); + if path.is_relative() { + CWD.join(path).clean() + } else { + path.clean() + } +} diff --git a/src/tests.rs b/src/tests.rs index eb364fae..8f08cde6 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -82,6 +82,7 @@ create_tests! { process_spawn_stdin: "process/spawn/stdin", process_spawn_stdio: "process/spawn/stdio", + require_aliases: "require/tests/aliases", require_async: "require/tests/async", require_async_background: "require/tests/async_background", require_async_concurrent: "require/tests/async_concurrent", diff --git a/tests/require/tests/aliases.luau b/tests/require/tests/aliases.luau new file mode 100644 index 00000000..09491299 --- /dev/null +++ b/tests/require/tests/aliases.luau @@ -0,0 +1,13 @@ +local module = require("@tests/require/tests/module") + +assert(type(module) == "table", "Required module did not return a table") +assert(module.Foo == "Bar", "Required module did not contain correct values") +assert(module.Hello == "World", "Required module did not contain correct values") + +local module2 = require("@require-tests/module") + +assert(type(module2) == "table", "Required module did not return a table") +assert(module2.Foo == "Bar", "Required module did not contain correct values") +assert(module2.Hello == "World", "Required module did not contain correct values") + +assert(module == module2, "Require did not return the same table for the same module")