Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ oak_ide = { path = "crates/oak_ide" }
oak_index_vec = { path = "crates/oak_index_vec" }
oak_package_metadata = { path = "crates/oak_package_metadata" }
oak_r_process = { path = "crates/oak_r_process" }
oak_scan = { path = "crates/oak_scan" }
oak_semantic = { path = "crates/oak_semantic" }
oak_sources = { path = "crates/oak_sources" }
once_cell = "1.21.4"
Expand Down
215 changes: 199 additions & 16 deletions crates/oak_db/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ use rustc_hash::FxHashMap;

use crate::File;
use crate::LibraryRoots;
use crate::LiveRoot;
use crate::OrphanRoot;
use crate::Package;
use crate::Root;
use crate::StaleRoot;
use crate::WorkspaceRoots;

/// Concrete-input surface of the salsa database. Each impl
Expand All @@ -26,6 +28,10 @@ pub trait DbInputs: salsa::Database {

/// Files not yet anchored to any workspace or library root.
fn orphan_root(&self) -> OrphanRoot;

/// Files and packages from roots that have been removed. Holding
/// pen for entity reuse on re-add (see [`StaleRoot`]).
fn stale_root(&self) -> StaleRoot;
}

/// Salsa database trait used throughout `oak_db`. Tracked queries take `&dyn
Expand Down Expand Up @@ -53,6 +59,57 @@ pub trait Db: DbInputs {
/// - Installed packages in an earlier root shadow later ones
/// (mirroring `.libPaths()`).
fn package_by_name(&self, name: &str) -> Option<Package>;

/// Look up a `Package` by its `DESCRIPTION` URL.
///
/// Walks workspace packages, then library packages, then falls back
/// to [`StaleRoot`]. Stale matches are intentional: scanner upserts
/// use this to find a `Package` entity whose live container was
/// dropped on a previous `set_*_paths` call, so the entity gets
/// reused on re-add. Analysis paths should not call this β€” they use
/// [`Db::package_by_name`] which is stale-blind.
fn package_by_url(&self, url: &UrlId) -> Option<Package>;

/// Resolve the live `Root` that contains `pkg`, if any.
///
/// Returns `None` when the package is only in [`StaleRoot`] (its live
/// container was previously evicted).
///
/// **Nested roots.** Two roots can claim the same package when one is
/// nested inside the other, e.g. the frontend opens both `/proj` and
/// `/proj/sub-pkg` as workspace folders and both scans walk into
/// `sub-pkg/DESCRIPTION`. Both scans hand the same `Package` entity to
/// their respective root's `packages` vec; the longest-path root wins
/// the ownership query here. The shorter root's vec still transiently
/// lists the package, but it self-heals on its next scan since
/// `set_packages` replaces the vec wholesale.
fn root_by_package(&self, pkg: Package) -> Option<Root>;

/// All live roots in lookup-precedence order: workspace folders first, then
/// library paths (mirroring R's `.libPaths()`), then the orphan bucket.
/// Stale roots are not included. Salsa-cached and invalidates when one of
/// `workspace_roots` / `library_roots` / `orphan_root` changes.
fn live_roots(&self) -> &[LiveRoot];
}

#[salsa::tracked(returns(ref))]
pub fn live_roots_query(db: &dyn Db) -> Vec<LiveRoot> {
let mut roots: Vec<LiveRoot> = db
.workspace_roots()
.roots(db)
.iter()
.map(|&r| LiveRoot::Workspace(r))
.collect();

roots.extend(
db.library_roots()
.roots(db)
.iter()
.map(|&r| LiveRoot::Library(r)),
);

roots.push(LiveRoot::Orphan(db.orphan_root()));
roots
}

/// Implementation of [`Db::file_by_url`]. Walks the per-root indices.
Expand All @@ -62,35 +119,104 @@ pub trait Db: DbInputs {
/// cached map, so adding a file to one root invalidates only that
/// root's index.
pub fn file_by_url_query(db: &dyn Db, url: &UrlId) -> Option<File> {
for root in db.workspace_roots().roots(db) {
if let Some(&file) = root_url_index(db, *root).get(url) {
return Some(file);
for &root in db.live_roots() {
let hit = match root {
LiveRoot::Workspace(r) | LiveRoot::Library(r) => {
root_url_index(db, r).get(url).copied()
},
LiveRoot::Orphan(_) => orphan_url_index(db).get(url).copied(),
};
if hit.is_some() {
return hit;
}
}
for root in db.library_roots().roots(db) {
if let Some(&file) = root_url_index(db, *root).get(url) {
return Some(file);
}
}
orphan_url_index(db).get(url).copied()
None
}

/// Implementation of [`Db::package_by_name`]. Same shape as
/// [`file_by_url_query`].
/// [`file_by_url_query`]; orphan has no packages, so it contributes
/// nothing to the walk.
pub fn package_by_name_query(db: &dyn Db, name: &str) -> Option<Package> {
for root in db.workspace_roots().roots(db) {
if let Some(&pkg) = root_package_index(db, *root).get(name) {
return Some(pkg);
for &root in db.live_roots() {
if let LiveRoot::Workspace(r) | LiveRoot::Library(r) = root {
if let Some(&pkg) = root_package_index(db, r).get(name) {
return Some(pkg);
}
}
}
for root in db.library_roots().roots(db) {
if let Some(&pkg) = root_package_index(db, *root).get(name) {
return Some(pkg);
None
}

/// Implementation of [`Db::package_by_url`]. Walks live roots' packages
/// by `description_url`, then falls back to the stale bucket.
pub fn package_by_url_query(db: &dyn Db, url: &UrlId) -> Option<Package> {
for &root in db.live_roots() {
if let LiveRoot::Workspace(r) | LiveRoot::Library(r) = root {
if let Some(&pkg) = root_package_url_index(db, r).get(url) {
return Some(pkg);
}
}
}
stale_package_url_index(db).get(url).copied()
}

/// Implementation of [`Db::root_by_package`]. Walks all live roots looking for
/// `pkg` in their `packages` vec, picking the longest-path root on ties.
pub fn root_by_package_query(db: &dyn Db, pkg: Package) -> Option<Root> {
let mut best: Option<(Root, usize)> = None;
for &root in db.live_roots() {
let (LiveRoot::Workspace(r) | LiveRoot::Library(r)) = root else {
continue;
};
if r.packages(db).contains(&pkg) {
let depth = root_depth(db, r);
if best.is_none_or(|(_, d)| depth > d) {
best = Some((r, depth));
}
}
}
best.map(|(root, _)| root)
}

/// Resolve the [`Package`] that owns `file`, if any.
///
/// Walks live roots' `File -> Package` indexes in workspace-then-library
/// order and returns the first hit. A file belongs to at most one package
/// *entity* (in the nested-root case both roots' `packages` hold the same
/// entity), so first-hit is unambiguous. The orphan bucket has no packages
/// and contributes nothing; stale entities are invisible by design, which
/// is what makes an evicted file's package association clear to `None`.
///
/// Backs [`crate::File::package`], which is the derived replacement for the
/// old `File.package` back-pointer field: the container vecs are now the
/// single source of truth for file ownership.
pub fn package_by_file_query(db: &dyn Db, file: File) -> Option<Package> {
for &root in db.live_roots() {
if let LiveRoot::Workspace(r) | LiveRoot::Library(r) = root {
if let Some(&pkg) = root_file_package_index(db, r).get(&file) {
return Some(pkg);
}
}
}
None
}

/// Number of path segments in a root's URL. Used as the tiebreaker by
/// [`root_by_package_query`] when nested roots both claim the same package.
///
/// Counts URL segments directly rather than going through `to_file_path()`.
/// `to_file_path()` errors on Windows for non-OS-style URLs (no drive
/// letter), which would silently collapse all depths to zero and degrade
/// the tiebreaker into "first found wins". Depth is a structural property
/// of the URL hierarchy, so the URL itself is the right source.
fn root_depth(db: &dyn Db, root: Root) -> usize {
root.path(db)
.as_url()
.path_segments()
.map(|s| s.filter(|seg| !seg.is_empty()).count())
.unwrap_or(0)
}

/// Per-root URL -> File index. Salsa caches one map per `Root`;
/// reads only `root.scripts`, `root.packages`, and each
/// `pkg.files` reachable from this root. Adding or removing a file
Expand All @@ -109,6 +235,21 @@ fn root_url_index(db: &dyn Db, root: Root) -> FxHashMap<UrlId, File> {
map
}

/// Per-root File -> owning Package index. Built from `root.packages` and
/// each package's `files`. Same per-root granularity as [`root_url_index`]:
/// adding or removing a file in this root invalidates only this entry.
/// Backs [`package_by_file_query`].
#[salsa::tracked(returns(ref))]
fn root_file_package_index(db: &dyn Db, root: Root) -> FxHashMap<File, Package> {
let mut map = FxHashMap::default();
for &pkg in root.packages(db) {
for &file in pkg.files(db) {
map.insert(file, pkg);
}
}
map
}

/// Orphan URL -> File index. Reads only `orphan_root().files`.
#[salsa::tracked(returns(ref))]
fn orphan_url_index(db: &dyn Db) -> FxHashMap<UrlId, File> {
Expand All @@ -129,3 +270,45 @@ fn root_package_index(db: &dyn Db, root: Root) -> FxHashMap<String, Package> {
}
map
}

/// Per-root DESCRIPTION URL -> Package index. Used by
/// [`package_by_url_query`] for entity-reuse lookups across rescans;
/// salsa cache invalidates only when this root's packages change.
#[salsa::tracked(returns(ref))]
fn root_package_url_index(db: &dyn Db, root: Root) -> FxHashMap<UrlId, Package> {
let mut map = FxHashMap::default();
for &pkg in root.packages(db) {
map.insert(pkg.description_url(db).clone(), pkg);
}
map
}

/// Stale file URL -> File index. Reads only `stale_root().files`. Not
/// consulted by [`file_by_url_query`] β€” analysis is stale-blind by
/// design. Scanner upserts use [`stale_file_by_url`] when re-adding a
/// path.
#[salsa::tracked(returns(ref))]
fn stale_url_index(db: &dyn Db) -> FxHashMap<UrlId, File> {
let mut map = FxHashMap::default();
for &file in db.stale_root().files(db) {
map.insert(file.url(db).clone(), file);
}
map
}

/// Look up a stale `File` by URL. Public so scanner upsert helpers in
/// `oak_scan` can fall back to stale after [`Db::file_by_url`] misses.
pub fn stale_file_by_url(db: &dyn Db, url: &UrlId) -> Option<File> {
stale_url_index(db).get(url).copied()
}

/// Stale DESCRIPTION URL -> Package index. Same role as
/// [`stale_url_index`] for packages.
#[salsa::tracked(returns(ref))]
fn stale_package_url_index(db: &dyn Db) -> FxHashMap<UrlId, Package> {
let mut map = FxHashMap::default();
for &pkg in db.stale_root().packages(db) {
map.insert(pkg.description_url(db).clone(), pkg);
}
map
}
57 changes: 30 additions & 27 deletions crates/oak_db/src/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,34 +23,18 @@ use crate::Root;
/// The `url` field is a [`UrlId`], so the type system enforces "everything
/// inside Salsa is a canonical URL".
///
/// `package` is a back-pointer to the [`Package`] this file belongs to, or
/// `None` for standalone scripts. Inverse of `Package.files`, so queries
/// answering "what package owns this file?" don't walk the forward edge.
/// Files with `package == None` are either standalone scripts under a
/// workspace root or orphan files not registered anywhere.
///
/// # Placement invariant
///
/// `File.package` and the file's physical location in a `Vec<File>` are
/// expected to agree. A file with `package == Some(pkg)` should live in
/// `pkg.files`. A file with `package == None` should live in either some
/// `root.scripts` or `orphan_root().files`. The salsa setters (`set_url`,
/// `set_contents`, `set_package`) are `pub` because field visibility couples to
/// setter visibility in salsa but calling `set_package` directly leaves the
/// file in its old bucket and silently breaks this invariant.
///
/// The scanner crate (`oak_scan`) wraps these setters in helpers that
/// maintain placement (move the file between `pkg.files`,
/// `root.scripts`, and `orphan_root().files` as `package` changes).
/// Callers that go around the helpers and use the salsa setters
/// directly must maintain placement themselves.
/// File ownership has a single source of truth: the forward edge. A file
/// belongs to whichever container currently holds it -- `pkg.files`,
/// `root.scripts`, or `orphan_root().files`. There is no stored
/// back-pointer to keep in sync. "What package owns this file?" is
/// answered by the derived [`File::package`] query, which walks the
/// per-root `File -> Package` indexes; "what root?" by [`File::root`].
#[salsa::input(debug)]
pub struct File {
#[returns(ref)]
pub url: UrlId,
#[returns(ref)]
pub contents: String,
pub package: Option<Package>,
}

#[salsa::tracked]
Expand Down Expand Up @@ -142,19 +126,38 @@ impl File {
.collect()
}

/// The [`Package`] that owns this file, or `None` for standalone
/// scripts and orphan / stale files.
///
/// Derived from live-graph containment via
/// [`package_by_file_query`](crate::db::package_by_file_query): the
/// file belongs to whichever live `pkg.files` currently holds it.
/// This replaces the old `File.package` back-pointer field, so file
/// ownership has a single source of truth (the forward edge) and no
/// invariant to maintain by hand.
#[salsa::tracked]
pub fn package(self, db: &dyn Db) -> Option<Package> {
crate::db::package_by_file_query(db, self)
}

/// The root containing this file, if any.
///
/// If the file has a registered [`Package`], dispatches through
/// `Package.root`. Otherwise falls back to a URL-prefix lookup
/// against [`WorkspaceRoots`] (orphan files live under a workspace
/// root or nowhere; library files always have a package).
/// If the file has an owning [`Package`], asks the db which live
/// root holds it via [`Db::root_by_package`]. Otherwise falls back to a
/// URL-prefix lookup against [`WorkspaceRoots`] (orphan files live
/// under a workspace root or nowhere). Library files normally have
/// a package; the `root_by_package` branch covers them too.
///
/// Returns `None` if the file's package was evicted to
/// [`StaleRoot`] (no live root contains it), or if the file is in
/// orphan and the URL falls outside every workspace folder.
///
/// Callers that need to distinguish workspace from library roots
/// inspect `root.kind(db)`.
#[salsa::tracked]
pub fn root(self, db: &dyn Db) -> Option<Root> {
if let Some(pkg) = self.package(db) {
return Some(pkg.root(db));
return db.root_by_package(pkg);
}
root_by_url(db, self.url(db))
}
Expand Down
5 changes: 3 additions & 2 deletions crates/oak_db/src/file_imports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,11 @@ fn narrow_script_top_level(file: File, db: &dyn Db, offset: TextSize) -> Vec<Imp
fn narrow_package_top_level(file: File, db: &dyn Db, package: Package) -> Vec<ImportLayer> {
let files = package.files(db);
let Some(file_pos) = files.iter().position(|f| *f == file) else {
// File claims membership but isn't in the package's `files`.
// `package` was derived from `package.files` containing this file
// (via `File::package`), so the position is always found.
// Shouldn't happen.
log::warn!(
"File {file} has package back-pointer to {package} but is not in its files",
"File {file} resolved to package {package} but is not in its files",
file = file.url(db),
package = package.name(db),
);
Expand Down
Loading
Loading