Skip to content

Commit 01f5d51

Browse files
authored
(Sources-3) oak_scan::set_package_sources() (#1288)
Progress towards #1234 Branched from #1287 This PR teaches `oak_scan` how to update an installed `Package`'s `files` and `scripts` from an _external on disk directory_. The idea is that some external source provider will return a flat `directory` of R files that correspond to the source files for a `Package`. The new `oak_scan::set_package_sources()` helper iterates over that `directory`, collecting the R files (but not reading them!), sorting and splitting them in accordance with `collate`, and stashes them in `files` and `scripts` of the `Package`. > [!CAUTION] > This PR has an important side effect. Since `package.files()` now live in a totally separate directory outside any `LiveRoot`, the `File::package` backpointer is now a load bearing feature. > > Consider `File::root()`. It switches on `self.package()`. If there is no package backpointer, it would go through either: > - `root_by_file()`, which looks inside `LiveRoot::Workspace` and `LiveRoot::Library` root paths for the file's `path`, neither of which would hold it > - `root_by_path()`, which is for orphaned files, and also uses paths contained within `WorkspaceRoots` > > So we need to instead detect `self.package()` and go through `root_by_package()`, which doesn't do a `path` check, it just detects that the `Package` backpointer is contained within a `LiveRoot` (by equality, not by `path` containment). We already do all this, it is just important to point it out now. > > `File::imports()` is similar
1 parent 9817b04 commit 01f5d51

4 files changed

Lines changed: 184 additions & 0 deletions

File tree

crates/oak_scan/src/inputs.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
1919
use std::collections::HashSet;
2020
use std::hash::Hash;
21+
use std::path::Path;
2122
use std::path::PathBuf;
2223

2324
use aether_path::FilePath;
@@ -30,6 +31,7 @@ use oak_db::Root;
3031
use salsa::Setter;
3132

3233
use crate::lookup::package_by_path;
34+
use crate::packages::read_package_sources;
3335
use crate::stale::remove_from_stale_files;
3436
use crate::stale::remove_from_stale_packages;
3537
use crate::stale::stale_file_by_path;
@@ -113,6 +115,14 @@ pub trait DbScan: Db + DbInputs {
113115
/// If the file is in a live workspace / library container, the call is a
114116
/// no-op.
115117
fn close_editor(&mut self, path: &FilePath);
118+
119+
/// Set `package`'s `files` / `scripts` to the `.R` files found directly
120+
/// under `directory`, respecting the package's `Collate` rules.
121+
///
122+
/// Used to ingest sources produced by an external tool into an already-registered
123+
/// library `Package` whose `files` start empty (the scanner registers installed
124+
/// packages without sources).
125+
fn set_package_sources(&mut self, package: Package, directory: &Path);
116126
}
117127

118128
impl<DB: Db + DbInputs> DbScan for DB {
@@ -165,6 +175,23 @@ impl<DB: Db + DbInputs> DbScan for DB {
165175
stale.set_files(self).to(stale_files);
166176
}
167177
}
178+
179+
fn set_package_sources(&mut self, package: Package, directory: &Path) {
180+
let (files, scripts) = read_package_sources(directory, package.collation(self).as_deref());
181+
182+
let files: Vec<File> = files
183+
.into_iter()
184+
.map(|entry| upsert_root_file(self, Some(package), entry))
185+
.collect();
186+
187+
let scripts: Vec<File> = scripts
188+
.into_iter()
189+
.map(|entry| upsert_root_file(self, Some(package), entry))
190+
.collect();
191+
192+
package.set_files(self).to(files);
193+
package.set_scripts(self).to(scripts);
194+
}
168195
}
169196

170197
/// Extension methods on [`Root`] for placement-aware updates.

crates/oak_scan/src/packages.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,46 @@ fn read_workspace_package(package_dir: &Path) -> Option<PackageEntry> {
210210
Some(package)
211211
}
212212

213+
/// Walk `directory`, collecting `.R` files
214+
///
215+
/// `.R` files are split into either `files` or `scripts` based on `collation`,
216+
/// matching [read_workspace_package()].
217+
///
218+
/// The `directory` is not walked recursively. We expect that this is a flat directory of
219+
/// `.R` files.
220+
pub(crate) fn read_package_sources(
221+
directory: &Path,
222+
collation: Option<&[String]>,
223+
) -> (Vec<FileEntry>, Vec<FileEntry>) {
224+
let Ok(entries) = fs::read_dir(directory) else {
225+
log::warn!("Cannot read sources directory: {}", directory.display());
226+
return (Vec::new(), Vec::new());
227+
};
228+
229+
let mut files: Vec<(PathBuf, FileEntry)> = Vec::new();
230+
231+
for entry in entries.flatten() {
232+
let path = entry.path();
233+
if !is_r_file(&path) {
234+
continue;
235+
}
236+
let Some(file_path) = FilePath::from_path_buf(path.clone()) else {
237+
log::warn!("Skipping R file, can't build a URL: {}", path.display());
238+
continue;
239+
};
240+
let revision = file_revision(&path);
241+
files.push((path, FileEntry {
242+
path: file_path,
243+
revision,
244+
}));
245+
}
246+
247+
match collation {
248+
Some(order) => order_by_collation(files, order),
249+
None => order_alphabetically(files),
250+
}
251+
}
252+
213253
/// Split the package's `R/*.R` files into `(loadable, leftover)` using the
214254
/// `Collate:` directive: `loadable` is the listed files in that order, and
215255
/// `leftover` is the R/ files not listed. Logs mismatches in either direction:

crates/oak_scan/src/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod scheduler;
2+
mod sources;
23
mod stale;
34
mod watch;
45
mod workspace;
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
use std::fs;
2+
use std::path::Path;
3+
4+
use oak_db::Db;
5+
use oak_db::File;
6+
use oak_db::OakDatabase;
7+
8+
use crate::DbScan;
9+
10+
/// Write an installed package's `DESCRIPTION` under `lib`
11+
fn write_description(lib: &Path, name: &str, collate: Option<&[&str]>) {
12+
let directory = lib.join(name);
13+
fs::create_dir_all(&directory).unwrap();
14+
15+
let mut description = format!("Package: {name}\nVersion: 1.0.0\n");
16+
17+
if let Some(collate) = collate {
18+
description.push_str("Collate:");
19+
for basename in collate {
20+
description.push_str(&format!(" '{basename}'"));
21+
}
22+
description.push('\n');
23+
}
24+
25+
fs::write(directory.join("DESCRIPTION"), description).unwrap();
26+
}
27+
28+
/// Write `*.R` files directly under `directory`, the layout a source provider hands to
29+
/// `set_package_sources`.
30+
fn write_source_directory(directory: &Path, r_files: &[(&str, &str)]) {
31+
fs::create_dir_all(directory).unwrap();
32+
for (basename, contents) in r_files {
33+
fs::write(directory.join(basename), contents).unwrap();
34+
}
35+
}
36+
37+
fn basenames(db: &OakDatabase, files: &[File]) -> Vec<String> {
38+
files
39+
.iter()
40+
.map(|file| {
41+
file.path(db)
42+
.to_url()
43+
.path()
44+
.rsplit('/')
45+
.next()
46+
.unwrap()
47+
.to_string()
48+
})
49+
.collect()
50+
}
51+
52+
#[test]
53+
fn test_set_package_sources_alphabetical() {
54+
let lib = tempfile::tempdir().unwrap();
55+
write_description(lib.path(), "mypkg", None);
56+
57+
let mut db = OakDatabase::new();
58+
db.set_library_paths(&[lib.path().to_path_buf()]);
59+
let pkg = db.package_by_name("mypkg").unwrap();
60+
assert!(pkg.files(&db).is_empty());
61+
62+
let src = tempfile::tempdir().unwrap();
63+
write_source_directory(src.path(), &[("b.R", "b <- 1\n"), ("a.R", "a <- 1\n")]);
64+
db.set_package_sources(pkg, src.path());
65+
66+
let files = pkg.files(&db).clone();
67+
assert_eq!(basenames(&db, &files), ["a.R", "b.R"]);
68+
assert!(pkg.scripts(&db).is_empty());
69+
70+
// Each file carries the package backpointer (path-based containment can't
71+
// reach a cache dir outside the library root, so resolution relies on it).
72+
for file in &files {
73+
assert_eq!(file.package(&db), Some(pkg));
74+
}
75+
76+
// Source is read lazily from disk via the revision, not an editor override.
77+
assert_eq!(files[0].source_text(&db), "a <- 1\n");
78+
}
79+
80+
#[test]
81+
fn test_set_package_sources_collation_order() {
82+
let lib = tempfile::tempdir().unwrap();
83+
write_description(lib.path(), "mypkg", Some(&["b.R", "a.R"]));
84+
85+
let mut db = OakDatabase::new();
86+
db.set_library_paths(&[lib.path().to_path_buf()]);
87+
let pkg = db.package_by_name("mypkg").unwrap();
88+
assert!(pkg.files(&db).is_empty());
89+
90+
let src = tempfile::tempdir().unwrap();
91+
write_source_directory(src.path(), &[("a.R", "a <- 1\n"), ("b.R", "b <- 1\n")]);
92+
db.set_package_sources(pkg, src.path());
93+
94+
// Collation order preserved!
95+
assert_eq!(basenames(&db, &pkg.files(&db).clone()), ["b.R", "a.R"]);
96+
assert!(pkg.scripts(&db).is_empty());
97+
}
98+
99+
#[test]
100+
fn test_set_package_sources_leftover_becomes_scripts() {
101+
let lib = tempfile::tempdir().unwrap();
102+
write_description(lib.path(), "mypkg", Some(&["a.R"]));
103+
104+
let mut db = OakDatabase::new();
105+
db.set_library_paths(&[lib.path().to_path_buf()]);
106+
let pkg = db.package_by_name("mypkg").unwrap();
107+
assert!(pkg.files(&db).is_empty());
108+
109+
let src = tempfile::tempdir().unwrap();
110+
write_source_directory(src.path(), &[("a.R", "a <- 1\n"), ("c.R", "c <- 1\n")]);
111+
db.set_package_sources(pkg, src.path());
112+
113+
// `c.R` isn't in `Collate:`, so R won't load it. It ends up in `scripts`.
114+
assert_eq!(basenames(&db, &pkg.files(&db).clone()), ["a.R"]);
115+
assert_eq!(basenames(&db, &pkg.scripts(&db).clone()), ["c.R"]);
116+
}

0 commit comments

Comments
 (0)