Skip to content

Commit 8869d21

Browse files
dak2claude
andcommitted
Add EnvironmentLoader filling an Environment from its sources
The core root, then the libraries, then the plain directories are walked in that order, each .rbs file parsed once with the first registration winning, and one Source appended per file. It resolves nothing: add_library takes a directory that already holds the library's RBS files, and manifest.yaml dependencies are not expanded -- which is also why loading core does not implicitly add stringio. load returns what this call read, so a caller loading into a non-empty environment learns what it added, the one question env.sources() cannot answer. parse_one takes only the interners so the loop can later be parallelised per worker; SignatureNode is not Send and must not escape it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ce7631e commit 8869d21

4 files changed

Lines changed: 362 additions & 0 deletions

File tree

rust/ruby-rbs/src/environment/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod source;
33
pub use source::{Source, SourceKind};
44

55
use crate::interners::Interners;
6+
use crate::loader::{EnvironmentLoader, LoadError};
67

78
/// Owning the interners here gives a single `Environment` value the same
89
/// role as the Ruby implementation's global name pool: names interned while
@@ -34,6 +35,12 @@ impl Environment {
3435
pub(crate) fn add_source(&mut self, source: Source) {
3536
self.sources.push(source);
3637
}
38+
39+
pub fn from_loader(loader: &EnvironmentLoader) -> Result<Environment, LoadError> {
40+
let mut env = Environment::new();
41+
loader.load(&mut env)?;
42+
Ok(env)
43+
}
3744
}
3845

3946
#[cfg(test)]

rust/ruby-rbs/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ pub(crate) mod file_finder;
44
pub mod ids;
55
pub mod interner;
66
pub mod interners;
7+
pub mod loader;
78
pub mod node;
89
pub mod type_name;

rust/ruby-rbs/src/loader/mod.rs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
use std::collections::HashSet;
2+
use std::fmt;
3+
use std::io;
4+
use std::path::{Path, PathBuf};
5+
6+
use crate::ast::AstConverter;
7+
use crate::environment::{Environment, Source, SourceKind};
8+
use crate::file_finder;
9+
use crate::interners::Interners;
10+
use crate::node;
11+
12+
#[derive(Debug)]
13+
#[non_exhaustive]
14+
pub enum LoadError {
15+
Io { path: PathBuf, source: io::Error },
16+
Parse { path: PathBuf, message: String },
17+
}
18+
19+
impl fmt::Display for LoadError {
20+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21+
match self {
22+
LoadError::Io { path, source } => {
23+
write!(f, "IO error on {}: {}", path.display(), source)
24+
}
25+
LoadError::Parse { path, message } => {
26+
write!(f, "Syntax error in {}: {}", path.display(), message)
27+
}
28+
}
29+
}
30+
}
31+
32+
impl std::error::Error for LoadError {
33+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
34+
match self {
35+
LoadError::Io { source, .. } => Some(source),
36+
_ => None,
37+
}
38+
}
39+
}
40+
41+
/// Corresponds to one entry of the array `RBS::EnvironmentLoader#load`
42+
/// returns, at file rather than declaration granularity.
43+
#[derive(Debug, Clone, PartialEq, Eq)]
44+
pub struct LoadedFile {
45+
pub path: PathBuf,
46+
pub kind: SourceKind,
47+
}
48+
49+
/// Mirrors `RBS::EnvironmentLoader`.
50+
///
51+
/// Unlike the Ruby implementation, this does not resolve gem names or
52+
/// versions to directories, and does not expand `manifest.yaml`
53+
/// dependencies: callers (Ruby's `Repository#lookup` / `gem_sig_path`) pass
54+
/// already-resolved paths to [`EnvironmentLoader::add_library`]. Loading
55+
/// core does not implicitly add `stringio` either — that dependency is the
56+
/// caller's responsibility, same as any other library.
57+
pub struct EnvironmentLoader {
58+
core_root: Option<PathBuf>,
59+
libs: Vec<(String, PathBuf)>,
60+
dirs: Vec<PathBuf>,
61+
}
62+
63+
impl EnvironmentLoader {
64+
pub fn new(core_root: Option<PathBuf>) -> Self {
65+
EnvironmentLoader {
66+
core_root,
67+
libs: Vec::new(),
68+
dirs: Vec::new(),
69+
}
70+
}
71+
72+
pub fn add_library(mut self, name: &str, path: PathBuf) -> Self {
73+
self.libs.push((name.to_string(), path));
74+
self
75+
}
76+
77+
pub fn add_dir(mut self, path: PathBuf) -> Self {
78+
self.dirs.push(path);
79+
self
80+
}
81+
82+
/// Returns what this call read, in the order it read it, so a caller
83+
/// appending to a non-empty `env` still learns what it added. On `Err` the
84+
/// sources read before the failure are already in `env`, same as the Ruby
85+
/// implementation adding sources as it walks the directories.
86+
pub fn load(&self, env: &mut Environment) -> Result<Vec<LoadedFile>, LoadError> {
87+
let mut loaded = Vec::new();
88+
let mut seen_files: HashSet<PathBuf> = HashSet::new();
89+
90+
for (kind, dir) in self.each_dir() {
91+
let files = file_finder::each_file(dir, kind.skips_hidden()).map_err(|source| {
92+
LoadError::Io {
93+
path: dir.to_path_buf(),
94+
source,
95+
}
96+
})?;
97+
98+
for path in files {
99+
if !seen_files.insert(path.clone()) {
100+
continue;
101+
}
102+
let source = parse_one(&path, &kind, env.interners_mut())?;
103+
env.add_source(source);
104+
loaded.push(LoadedFile {
105+
path,
106+
kind: kind.clone(),
107+
});
108+
}
109+
}
110+
111+
Ok(loaded)
112+
}
113+
114+
fn each_dir(&self) -> impl Iterator<Item = (SourceKind, &Path)> {
115+
let core = self
116+
.core_root
117+
.iter()
118+
.map(|path| (SourceKind::Core, path.as_path()));
119+
let libs = self.libs.iter().map(|(name, path)| {
120+
let kind = SourceKind::Library {
121+
name: name.clone(),
122+
path: path.clone(),
123+
};
124+
(kind, path.as_path())
125+
});
126+
let dirs = self
127+
.dirs
128+
.iter()
129+
.map(|path| (SourceKind::Dir { path: path.clone() }, path.as_path()));
130+
131+
core.chain(libs).chain(dirs)
132+
}
133+
}
134+
135+
/// Deliberately a free function taking only the interners, so the load loop
136+
/// can later be parallelised by handing each worker its own [`Interners`].
137+
/// The parser's `SignatureNode` holds raw pointers and is not `Send`, so it
138+
/// must not escape this function — only the owned `Source` does.
139+
pub(crate) fn parse_one(
140+
path: &Path,
141+
kind: &SourceKind,
142+
interners: &mut Interners,
143+
) -> Result<Source, LoadError> {
144+
let content = std::fs::read_to_string(path).map_err(|source| LoadError::Io {
145+
path: path.to_path_buf(),
146+
source,
147+
})?;
148+
149+
let signature = node::parse(&content).map_err(|message| LoadError::Parse {
150+
path: path.to_path_buf(),
151+
message,
152+
})?;
153+
154+
let mut converter = AstConverter::new(&mut interners.strings, &mut interners.type_names);
155+
let directives = signature
156+
.directives()
157+
.iter()
158+
.map(|node| converter.convert_directive(&node))
159+
.collect();
160+
let declarations = signature
161+
.declarations()
162+
.iter()
163+
.map(|node| converter.convert_declaration(&node))
164+
.collect();
165+
166+
Ok(Source {
167+
path: path.to_path_buf(),
168+
directives,
169+
declarations,
170+
kind: kind.clone(),
171+
})
172+
}

rust/ruby-rbs/tests/loader.rs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
use std::fs;
2+
use std::path::{Path, PathBuf};
3+
4+
use ruby_rbs::ast::{Declaration, Directive};
5+
use ruby_rbs::environment::{Environment, SourceKind};
6+
use ruby_rbs::loader::{EnvironmentLoader, LoadError};
7+
8+
fn repo_root() -> PathBuf {
9+
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
10+
}
11+
12+
fn stdlib_dir(name: &str) -> PathBuf {
13+
repo_root().join("stdlib").join(name).join("0")
14+
}
15+
16+
fn lib(name: &str) -> SourceKind {
17+
SourceKind::Library {
18+
name: name.to_string(),
19+
path: stdlib_dir(name),
20+
}
21+
}
22+
23+
fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
24+
let dir = tempfile::tempdir().unwrap();
25+
for (path, content) in files {
26+
let path = dir.path().join(path);
27+
fs::create_dir_all(path.parent().unwrap()).unwrap();
28+
fs::write(path, content).unwrap();
29+
}
30+
dir
31+
}
32+
33+
#[test]
34+
fn loads_registered_sources_in_registration_order() {
35+
let loader = EnvironmentLoader::new(Some(repo_root().join("core")))
36+
.add_library("bigdecimal-math", stdlib_dir("bigdecimal-math"))
37+
.add_library("bigdecimal", stdlib_dir("bigdecimal"));
38+
39+
let mut env = Environment::new();
40+
let loaded = loader.load(&mut env).unwrap();
41+
42+
assert_eq!(loaded.first().unwrap().kind, SourceKind::Core);
43+
let first_math = loaded
44+
.iter()
45+
.position(|f| f.kind == lib("bigdecimal-math"))
46+
.unwrap();
47+
let first_dep = loaded
48+
.iter()
49+
.position(|f| f.kind == lib("bigdecimal"))
50+
.unwrap();
51+
assert!(first_math < first_dep);
52+
assert_eq!(env.sources().len(), loaded.len());
53+
assert!(!env.interners().strings.is_empty());
54+
}
55+
56+
#[test]
57+
fn from_loader_is_the_primary_entry_point() {
58+
let dir = tree(&[("a.rbs", "class Foo\nend\n")]);
59+
60+
let loader = EnvironmentLoader::new(None).add_dir(dir.path().to_path_buf());
61+
let env = Environment::from_loader(&loader).unwrap();
62+
63+
assert_eq!(env.sources().len(), 1);
64+
}
65+
66+
#[test]
67+
fn files_are_loaded_once_first_wins() {
68+
let dir = tree(&[("a.rbs", "class Foo\nend\n")]);
69+
70+
let loader = EnvironmentLoader::new(None)
71+
.add_dir(dir.path().to_path_buf())
72+
.add_dir(dir.path().to_path_buf());
73+
74+
let mut env = Environment::new();
75+
let loaded = loader.load(&mut env).unwrap();
76+
77+
assert_eq!(loaded.len(), 1);
78+
assert_eq!(env.sources().len(), 1);
79+
}
80+
81+
#[test]
82+
fn load_reports_only_what_this_call_added() {
83+
let first = tree(&[("a.rbs", "class Foo\nend\n")]);
84+
let second = tree(&[("b.rbs", "class Bar\nend\n")]);
85+
86+
let mut env = Environment::new();
87+
EnvironmentLoader::new(None)
88+
.add_dir(first.path().to_path_buf())
89+
.load(&mut env)
90+
.unwrap();
91+
let loaded = EnvironmentLoader::new(None)
92+
.add_dir(second.path().to_path_buf())
93+
.load(&mut env)
94+
.unwrap();
95+
96+
assert_eq!(env.sources().len(), 2);
97+
let [only] = loaded.as_slice() else {
98+
panic!("expected one loaded file, got {loaded:?}");
99+
};
100+
assert!(only.path.ends_with("b.rbs"));
101+
}
102+
103+
#[test]
104+
fn explicit_dirs_do_not_skip_underscore_directories() {
105+
let dir = tree(&[("_private/a.rbs", "class Foo\nend\n")]);
106+
107+
let loader = EnvironmentLoader::new(None).add_dir(dir.path().to_path_buf());
108+
109+
let mut env = Environment::new();
110+
let loaded = loader.load(&mut env).unwrap();
111+
112+
assert_eq!(loaded.len(), 1);
113+
}
114+
115+
#[test]
116+
fn parse_errors_carry_the_file_path() {
117+
let dir = tree(&[("broken.rbs", "class\n")]);
118+
119+
let loader = EnvironmentLoader::new(None).add_dir(dir.path().to_path_buf());
120+
121+
let mut env = Environment::new();
122+
let error = loader.load(&mut env).unwrap_err();
123+
124+
assert!(matches!(
125+
error,
126+
LoadError::Parse { ref path, .. } if path.ends_with("broken.rbs")
127+
));
128+
}
129+
130+
#[test]
131+
fn loaded_sources_carry_converted_declarations_and_directives() {
132+
let dir = tree(&[(
133+
"person.rbs",
134+
"use Foo::Bar\n\nclass Person\n def name: () -> String\nend\n",
135+
)]);
136+
137+
let loader = EnvironmentLoader::new(None).add_dir(dir.path().to_path_buf());
138+
let env = Environment::from_loader(&loader).unwrap();
139+
140+
let source = &env.sources()[0];
141+
assert!(source.path.ends_with("person.rbs"));
142+
assert!(matches!(source.directives.as_slice(), [Directive::Use(_)]));
143+
144+
let [Declaration::Class(class)] = source.declarations.as_slice() else {
145+
panic!(
146+
"expected one class declaration, got {:?}",
147+
source.declarations
148+
);
149+
};
150+
// Names stay as written; Ruby absolutises them in `insert_rbs_decl`.
151+
let interners = env.interners();
152+
assert_eq!(
153+
interners.type_names.display(class.name, &interners.strings),
154+
"Person"
155+
);
156+
assert_eq!(class.members.len(), 1);
157+
}
158+
159+
#[test]
160+
fn library_dirs_skip_underscore_directories() {
161+
let dir = tree(&[
162+
("gem1/1.2.3/a.rbs", "class Person\nend\n"),
163+
("gem1/1.2.3/_private/b.rbs", "class Person::Internal\nend\n"),
164+
]);
165+
166+
let loader = EnvironmentLoader::new(None).add_library("gem1", dir.path().join("gem1/1.2.3"));
167+
168+
let mut env = Environment::new();
169+
let loaded = loader.load(&mut env).unwrap();
170+
171+
let [only] = loaded.as_slice() else {
172+
panic!("expected one loaded file, got {loaded:?}");
173+
};
174+
assert!(only.path.ends_with("a.rbs"));
175+
assert_eq!(
176+
only.kind,
177+
SourceKind::Library {
178+
name: "gem1".to_string(),
179+
path: dir.path().join("gem1/1.2.3"),
180+
}
181+
);
182+
}

0 commit comments

Comments
 (0)