diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d2d0d79f54..7b1becdd29 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -28,7 +28,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.104", ] [[package]] @@ -84,6 +84,33 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "glob" version = "0.3.2" @@ -117,15 +144,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -137,6 +164,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.27" @@ -165,6 +198,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "prettyplease" version = "0.2.35" @@ -172,7 +211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.104", ] [[package]] @@ -193,6 +232,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "regex" version = "1.11.1" @@ -229,6 +274,7 @@ dependencies = [ "ruby-rbs-sys", "serde", "serde_yaml", + "tempfile", "xxhash-rust", ] @@ -246,30 +292,53 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -302,6 +371,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -314,6 +407,21 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.53.2" diff --git a/rust/ruby-rbs/Cargo.toml b/rust/ruby-rbs/Cargo.toml index 0ba4dd4114..973162288b 100644 --- a/rust/ruby-rbs/Cargo.toml +++ b/rust/ruby-rbs/Cargo.toml @@ -21,3 +21,6 @@ xxhash-rust = { version = "0.8", features = ["xxh3"] } [build-dependencies] serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" + +[dev-dependencies] +tempfile = "3" diff --git a/rust/ruby-rbs/src/environment/mod.rs b/rust/ruby-rbs/src/environment/mod.rs new file mode 100644 index 0000000000..fa2cedf103 --- /dev/null +++ b/rust/ruby-rbs/src/environment/mod.rs @@ -0,0 +1,66 @@ +pub mod source; + +pub use source::{Source, SourceKind}; + +use crate::interners::Interners; +use crate::loader::{EnvironmentLoader, LoadError}; + +/// Owning the interners here gives a single `Environment` value the same +/// role as the Ruby implementation's global name pool: names interned while +/// loading stay resolvable and displayable for the environment's lifetime. +#[derive(Default)] +pub struct Environment { + interners: Interners, + sources: Vec, +} + +impl Environment { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn interners(&self) -> &Interners { + &self.interners + } + + pub fn sources(&self) -> &[Source] { + &self.sources + } + + pub(crate) fn interners_mut(&mut self) -> &mut Interners { + &mut self.interners + } + + pub(crate) fn add_source(&mut self, source: Source) { + self.sources.push(source); + } + + pub fn from_loader(loader: &EnvironmentLoader) -> Result { + let mut env = Environment::new(); + loader.load(&mut env)?; + Ok(env) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn environment_owns_interners() { + let mut env = Environment::new(); + + let interners = env.interners_mut(); + let symbol = interners.strings.intern("Foo"); + let root = interners.type_names.absolute_root(); + let name = interners.type_names.append(root, symbol); + + let interners = env.interners(); + assert_eq!( + interners.type_names.display(name, &interners.strings), + "::Foo" + ); + assert!(env.sources().is_empty()); + } +} diff --git a/rust/ruby-rbs/src/environment/source.rs b/rust/ruby-rbs/src/environment/source.rs new file mode 100644 index 0000000000..0ae87760c5 --- /dev/null +++ b/rust/ruby-rbs/src/environment/source.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +use crate::ast::{Declaration, Directive}; + +/// Corresponds to the `source` values yielded by +/// `RBS::EnvironmentLoader#each_dir`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SourceKind { + Core, + /// `path` is the library's version directory, already resolved by the + /// caller (Ruby's `Repository#lookup` / `gem_sig_path`), holding the + /// library's RBS files directly. Version alone could not recover where a + /// library's signatures came from; the path can. + Library { + name: String, + path: PathBuf, + }, + Dir { + path: PathBuf, + }, +} + +impl SourceKind { + pub(crate) fn skips_hidden(&self) -> bool { + !matches!(self, SourceKind::Dir { .. }) + } +} + +#[derive(Debug)] +pub struct Source { + pub path: PathBuf, + pub directives: Vec, + pub declarations: Vec, + pub kind: SourceKind, +} diff --git a/rust/ruby-rbs/src/file_finder.rs b/rust/ruby-rbs/src/file_finder.rs new file mode 100644 index 0000000000..a539459023 --- /dev/null +++ b/rust/ruby-rbs/src/file_finder.rs @@ -0,0 +1,230 @@ +use std::io; +use std::path::{Path, PathBuf}; + +/// Mirrors `RBS::FileFinder.each_file` and its `Dir.glob` semantics. +/// +/// Divergences: the sort compares the platform separator rather than +/// `/`-joined strings, and only `PermissionDenied`/`NotFound` are skipped +/// where Ruby skips every open/stat failure. +pub fn each_file(path: &Path, skip_hidden: bool) -> io::Result> { + let mut files = Vec::new(); + + if path.is_file() { + files.push(path.to_path_buf()); + } else if path.is_dir() { + collect(path, skip_hidden, &mut files)?; + files.sort_by(|a, b| a.as_os_str().cmp(b.as_os_str())); + } + + Ok(files) +} + +/// `None` for the failures Ruby swallows by letting `Dir.glob` skip the entry. +fn skippable(result: io::Result) -> io::Result> { + match result { + Ok(value) => Ok(Some(value)), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::PermissionDenied | io::ErrorKind::NotFound + ) => + { + Ok(None) + } + Err(error) => Err(error), + } +} + +fn collect(dir: &Path, skip_hidden: bool, files: &mut Vec) -> io::Result<()> { + let Some(entries) = skippable(std::fs::read_dir(dir))? else { + return Ok(()); + }; + + for entry in entries { + let Some(entry) = skippable(entry)? else { + continue; + }; + let path = entry.path(); + let Some(name) = path.file_name().map(|name| name.to_string_lossy()) else { + continue; + }; + + if name.starts_with('.') { + continue; + } + + if path.extension().is_some_and(|ext| ext == "rbs") { + files.push(path.clone()); + } + + // `file_type()` does not follow symlinks, which skips symlinked dirs. + let Some(file_type) = skippable(entry.file_type())? else { + continue; + }; + if file_type.is_dir() { + if skip_hidden && name.starts_with('_') { + continue; + } + collect(&path, skip_hidden, files)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn fixture(files: &[&str]) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for file in files { + let path = dir.path().join(file); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "").unwrap(); + } + dir + } + + fn relative(paths: Vec, root: &Path) -> Vec { + paths + .iter() + .map(|path| { + path.strip_prefix(root) + .unwrap() + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") + }) + .collect() + } + + #[test] + fn returns_single_file_as_is() { + let dir = fixture(&["a.txt"]); + let file = dir.path().join("a.txt"); + assert_eq!(each_file(&file, true).unwrap(), vec![file]); + } + + #[test] + fn collects_rbs_files_sorted() { + let dir = fixture(&["b.rbs", "a.rbs", "nested/c.rbs", "ignored.txt"]); + let found = each_file(dir.path(), false).unwrap(); + assert_eq!( + relative(found, dir.path()), + vec!["a.rbs", "b.rbs", "nested/c.rbs"] + ); + } + + #[test] + fn skip_hidden_skips_underscore_directories_only() { + let dir = fixture(&[ + "a.rbs", + "_private/b.rbs", + "nested/_private/c.rbs", + "_top.rbs", + ]); + + let found = each_file(dir.path(), true).unwrap(); + assert_eq!(relative(found, dir.path()), vec!["_top.rbs", "a.rbs"]); + + let found = each_file(dir.path(), false).unwrap(); + assert_eq!( + relative(found, dir.path()), + vec![ + "_private/b.rbs", + "_top.rbs", + "a.rbs", + "nested/_private/c.rbs" + ] + ); + } + + #[test] + fn missing_path_yields_nothing() { + let dir = fixture(&[]); + let found = each_file(&dir.path().join("no_such"), true).unwrap(); + assert!(found.is_empty()); + } + + #[cfg(unix)] + #[test] + fn symlinked_directories_are_not_traversed() { + let dir = fixture(&["a.rbs", "real/b.rbs"]); + std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("linked")).unwrap(); + + let found = each_file(dir.path(), false).unwrap(); + assert_eq!(relative(found, dir.path()), vec!["a.rbs", "real/b.rbs"]); + } + + #[test] + fn dot_entries_are_neither_listed_nor_traversed() { + let dir = fixture(&[".hidden.rbs", ".git/objects/x.rbs", "normal/ok.rbs"]); + + let found = each_file(dir.path(), false).unwrap(); + assert_eq!(relative(found, dir.path()), vec!["normal/ok.rbs"]); + } + + #[cfg(unix)] + #[test] + fn unreadable_directories_are_skipped() { + use std::os::unix::fs::PermissionsExt; + + let dir = fixture(&["a.rbs", "readable/b.rbs", "locked/c.rbs"]); + let locked = dir.path().join("locked"); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap(); + + // Permission bits do not deny root (common in CI containers). + if fs::read_dir(&locked).is_ok() { + fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap(); + return; + } + + let found = each_file(dir.path(), false); + + fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap(); + + assert_eq!( + relative(found.unwrap(), dir.path()), + vec!["a.rbs", "readable/b.rbs"] + ); + } + + #[cfg(unix)] + #[test] + fn unstattable_entries_are_skipped() { + use std::os::unix::fs::PermissionsExt; + + let dir = fixture(&["a.rbs", "listable/b.rbs", "listable/nested/c.rbs"]); + let listable = dir.path().join("listable"); + fs::set_permissions(&listable, fs::Permissions::from_mode(0o444)).unwrap(); + + // Permission bits do not deny root (common in CI containers). + if fs::metadata(listable.join("b.rbs")).is_ok() { + fs::set_permissions(&listable, fs::Permissions::from_mode(0o755)).unwrap(); + return; + } + + let found = each_file(dir.path(), false); + + fs::set_permissions(&listable, fs::Permissions::from_mode(0o755)).unwrap(); + + // `b.rbs` is listed by name; `nested` cannot be identified as a dir. + assert_eq!( + relative(found.unwrap(), dir.path()), + vec!["a.rbs", "listable/b.rbs"] + ); + } + + #[test] + fn entries_named_rbs_match_regardless_of_file_type() { + let dir = fixture(&["dir.rbs/inner.rbs"]); + + let found = each_file(dir.path(), false).unwrap(); + assert_eq!( + relative(found, dir.path()), + vec!["dir.rbs", "dir.rbs/inner.rbs"] + ); + } +} diff --git a/rust/ruby-rbs/src/interners.rs b/rust/ruby-rbs/src/interners.rs new file mode 100644 index 0000000000..dc69b46b06 --- /dev/null +++ b/rust/ruby-rbs/src/interners.rs @@ -0,0 +1,15 @@ +use crate::interner::StringInterner; +use crate::type_name::TypeNameInterner; + +#[derive(Default)] +pub struct Interners { + pub strings: StringInterner, + pub type_names: TypeNameInterner, +} + +impl Interners { + #[must_use] + pub fn new() -> Self { + Self::default() + } +} diff --git a/rust/ruby-rbs/src/lib.rs b/rust/ruby-rbs/src/lib.rs index 328898158b..1e4cb82f49 100644 --- a/rust/ruby-rbs/src/lib.rs +++ b/rust/ruby-rbs/src/lib.rs @@ -1,5 +1,9 @@ pub mod ast; +pub mod environment; +pub(crate) mod file_finder; pub mod ids; pub mod interner; +pub mod interners; +pub mod loader; pub mod node; pub mod type_name; diff --git a/rust/ruby-rbs/src/loader/mod.rs b/rust/ruby-rbs/src/loader/mod.rs new file mode 100644 index 0000000000..c436f8e5cd --- /dev/null +++ b/rust/ruby-rbs/src/loader/mod.rs @@ -0,0 +1,172 @@ +use std::collections::HashSet; +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::ast::AstConverter; +use crate::environment::{Environment, Source, SourceKind}; +use crate::file_finder; +use crate::interners::Interners; +use crate::node; + +#[derive(Debug)] +#[non_exhaustive] +pub enum LoadError { + Io { path: PathBuf, source: io::Error }, + Parse { path: PathBuf, message: String }, +} + +impl fmt::Display for LoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LoadError::Io { path, source } => { + write!(f, "IO error on {}: {}", path.display(), source) + } + LoadError::Parse { path, message } => { + write!(f, "Syntax error in {}: {}", path.display(), message) + } + } + } +} + +impl std::error::Error for LoadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + LoadError::Io { source, .. } => Some(source), + _ => None, + } + } +} + +/// Corresponds to one entry of the array `RBS::EnvironmentLoader#load` +/// returns, at file rather than declaration granularity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadedFile { + pub path: PathBuf, + pub kind: SourceKind, +} + +/// Mirrors `RBS::EnvironmentLoader`. +/// +/// Unlike the Ruby implementation, this does not resolve gem names or +/// versions to directories, and does not expand `manifest.yaml` +/// dependencies: callers (Ruby's `Repository#lookup` / `gem_sig_path`) pass +/// already-resolved paths to [`EnvironmentLoader::add_library`]. Loading +/// core does not implicitly add `stringio` either — that dependency is the +/// caller's responsibility, same as any other library. +pub struct EnvironmentLoader { + core_root: Option, + libs: Vec<(String, PathBuf)>, + dirs: Vec, +} + +impl EnvironmentLoader { + pub fn new(core_root: Option) -> Self { + EnvironmentLoader { + core_root, + libs: Vec::new(), + dirs: Vec::new(), + } + } + + pub fn add_library(mut self, name: &str, path: PathBuf) -> Self { + self.libs.push((name.to_string(), path)); + self + } + + pub fn add_dir(mut self, path: PathBuf) -> Self { + self.dirs.push(path); + self + } + + /// Returns what this call read, in the order it read it, so a caller + /// appending to a non-empty `env` still learns what it added. On `Err` the + /// sources read before the failure are already in `env`, same as the Ruby + /// implementation adding sources as it walks the directories. + pub fn load(&self, env: &mut Environment) -> Result, LoadError> { + let mut loaded = Vec::new(); + let mut seen_files: HashSet = HashSet::new(); + + for (kind, dir) in self.each_dir() { + let files = file_finder::each_file(dir, kind.skips_hidden()).map_err(|source| { + LoadError::Io { + path: dir.to_path_buf(), + source, + } + })?; + + for path in files { + if !seen_files.insert(path.clone()) { + continue; + } + let source = parse_one(&path, &kind, env.interners_mut())?; + env.add_source(source); + loaded.push(LoadedFile { + path, + kind: kind.clone(), + }); + } + } + + Ok(loaded) + } + + fn each_dir(&self) -> impl Iterator { + let core = self + .core_root + .iter() + .map(|path| (SourceKind::Core, path.as_path())); + let libs = self.libs.iter().map(|(name, path)| { + let kind = SourceKind::Library { + name: name.clone(), + path: path.clone(), + }; + (kind, path.as_path()) + }); + let dirs = self + .dirs + .iter() + .map(|path| (SourceKind::Dir { path: path.clone() }, path.as_path())); + + core.chain(libs).chain(dirs) + } +} + +/// Deliberately a free function taking only the interners, so the load loop +/// can later be parallelised by handing each worker its own [`Interners`]. +/// The parser's `SignatureNode` holds raw pointers and is not `Send`, so it +/// must not escape this function — only the owned `Source` does. +pub(crate) fn parse_one( + path: &Path, + kind: &SourceKind, + interners: &mut Interners, +) -> Result { + let content = std::fs::read_to_string(path).map_err(|source| LoadError::Io { + path: path.to_path_buf(), + source, + })?; + + let signature = node::parse(&content).map_err(|message| LoadError::Parse { + path: path.to_path_buf(), + message, + })?; + + let mut converter = AstConverter::new(&mut interners.strings, &mut interners.type_names); + let directives = signature + .directives() + .iter() + .map(|node| converter.convert_directive(&node)) + .collect(); + let declarations = signature + .declarations() + .iter() + .map(|node| converter.convert_declaration(&node)) + .collect(); + + Ok(Source { + path: path.to_path_buf(), + directives, + declarations, + kind: kind.clone(), + }) +} diff --git a/rust/ruby-rbs/tests/loader.rs b/rust/ruby-rbs/tests/loader.rs new file mode 100644 index 0000000000..3246dbec11 --- /dev/null +++ b/rust/ruby-rbs/tests/loader.rs @@ -0,0 +1,182 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use ruby_rbs::ast::{Declaration, Directive}; +use ruby_rbs::environment::{Environment, SourceKind}; +use ruby_rbs::loader::{EnvironmentLoader, LoadError}; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn stdlib_dir(name: &str) -> PathBuf { + repo_root().join("stdlib").join(name).join("0") +} + +fn lib(name: &str) -> SourceKind { + SourceKind::Library { + name: name.to_string(), + path: stdlib_dir(name), + } +} + +fn tree(files: &[(&str, &str)]) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for (path, content) in files { + let path = dir.path().join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + dir +} + +#[test] +fn loads_registered_sources_in_registration_order() { + let loader = EnvironmentLoader::new(Some(repo_root().join("core"))) + .add_library("bigdecimal-math", stdlib_dir("bigdecimal-math")) + .add_library("bigdecimal", stdlib_dir("bigdecimal")); + + let mut env = Environment::new(); + let loaded = loader.load(&mut env).unwrap(); + + assert_eq!(loaded.first().unwrap().kind, SourceKind::Core); + let first_math = loaded + .iter() + .position(|f| f.kind == lib("bigdecimal-math")) + .unwrap(); + let first_dep = loaded + .iter() + .position(|f| f.kind == lib("bigdecimal")) + .unwrap(); + assert!(first_math < first_dep); + assert_eq!(env.sources().len(), loaded.len()); + assert!(!env.interners().strings.is_empty()); +} + +#[test] +fn from_loader_is_the_primary_entry_point() { + let dir = tree(&[("a.rbs", "class Foo\nend\n")]); + + let loader = EnvironmentLoader::new(None).add_dir(dir.path().to_path_buf()); + let env = Environment::from_loader(&loader).unwrap(); + + assert_eq!(env.sources().len(), 1); +} + +#[test] +fn files_are_loaded_once_first_wins() { + let dir = tree(&[("a.rbs", "class Foo\nend\n")]); + + let loader = EnvironmentLoader::new(None) + .add_dir(dir.path().to_path_buf()) + .add_dir(dir.path().to_path_buf()); + + let mut env = Environment::new(); + let loaded = loader.load(&mut env).unwrap(); + + assert_eq!(loaded.len(), 1); + assert_eq!(env.sources().len(), 1); +} + +#[test] +fn load_reports_only_what_this_call_added() { + let first = tree(&[("a.rbs", "class Foo\nend\n")]); + let second = tree(&[("b.rbs", "class Bar\nend\n")]); + + let mut env = Environment::new(); + EnvironmentLoader::new(None) + .add_dir(first.path().to_path_buf()) + .load(&mut env) + .unwrap(); + let loaded = EnvironmentLoader::new(None) + .add_dir(second.path().to_path_buf()) + .load(&mut env) + .unwrap(); + + assert_eq!(env.sources().len(), 2); + let [only] = loaded.as_slice() else { + panic!("expected one loaded file, got {loaded:?}"); + }; + assert!(only.path.ends_with("b.rbs")); +} + +#[test] +fn explicit_dirs_do_not_skip_underscore_directories() { + let dir = tree(&[("_private/a.rbs", "class Foo\nend\n")]); + + let loader = EnvironmentLoader::new(None).add_dir(dir.path().to_path_buf()); + + let mut env = Environment::new(); + let loaded = loader.load(&mut env).unwrap(); + + assert_eq!(loaded.len(), 1); +} + +#[test] +fn parse_errors_carry_the_file_path() { + let dir = tree(&[("broken.rbs", "class\n")]); + + let loader = EnvironmentLoader::new(None).add_dir(dir.path().to_path_buf()); + + let mut env = Environment::new(); + let error = loader.load(&mut env).unwrap_err(); + + assert!(matches!( + error, + LoadError::Parse { ref path, .. } if path.ends_with("broken.rbs") + )); +} + +#[test] +fn loaded_sources_carry_converted_declarations_and_directives() { + let dir = tree(&[( + "person.rbs", + "use Foo::Bar\n\nclass Person\n def name: () -> String\nend\n", + )]); + + let loader = EnvironmentLoader::new(None).add_dir(dir.path().to_path_buf()); + let env = Environment::from_loader(&loader).unwrap(); + + let source = &env.sources()[0]; + assert!(source.path.ends_with("person.rbs")); + assert!(matches!(source.directives.as_slice(), [Directive::Use(_)])); + + let [Declaration::Class(class)] = source.declarations.as_slice() else { + panic!( + "expected one class declaration, got {:?}", + source.declarations + ); + }; + // Names stay as written; Ruby absolutises them in `insert_rbs_decl`. + let interners = env.interners(); + assert_eq!( + interners.type_names.display(class.name, &interners.strings), + "Person" + ); + assert_eq!(class.members.len(), 1); +} + +#[test] +fn library_dirs_skip_underscore_directories() { + let dir = tree(&[ + ("gem1/1.2.3/a.rbs", "class Person\nend\n"), + ("gem1/1.2.3/_private/b.rbs", "class Person::Internal\nend\n"), + ]); + + let loader = EnvironmentLoader::new(None).add_library("gem1", dir.path().join("gem1/1.2.3")); + + let mut env = Environment::new(); + let loaded = loader.load(&mut env).unwrap(); + + let [only] = loaded.as_slice() else { + panic!("expected one loaded file, got {loaded:?}"); + }; + assert!(only.path.ends_with("a.rbs")); + assert_eq!( + only.kind, + SourceKind::Library { + name: "gem1".to_string(), + path: dir.path().join("gem1/1.2.3"), + } + ); +}