Skip to content
Merged
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
19 changes: 6 additions & 13 deletions crates/ark/src/lsp/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -995,7 +995,7 @@ fn handle_package_attach_call(node: Node, context: &mut DiagnosticContext) -> an
let package_name = package_node.get_identifier_or_string_text(context.contents())?;
let attach_pos = node.end_position();

insert_package_exports(package_name, attach_pos, context)?;
insert_package_exports(package_name, attach_pos, context);

// Also attach packages from `Depends` field, if any
if let Some(package_names) = context
Expand All @@ -1004,7 +1004,7 @@ fn handle_package_attach_call(node: Node, context: &mut DiagnosticContext) -> an
.and_then(|package| package.depends(context.db).as_ref())
{
for package_name in package_names.iter() {
insert_package_exports(package_name, attach_pos, context)?;
insert_package_exports(package_name, attach_pos, context);
}
}

Expand Down Expand Up @@ -1050,21 +1050,16 @@ fn handle_package_attach_call(node: Node, context: &mut DiagnosticContext) -> an
_ => vec![],
};
for package_name in attach_field {
insert_package_exports(package_name, attach_pos, context)?;
insert_package_exports(package_name, attach_pos, context);
}

Ok(())
}

fn insert_package_exports(
package_name: &str,
attach_pos: Point,
context: &mut DiagnosticContext,
) -> anyhow::Result<()> {
fn insert_package_exports(package_name: &str, attach_pos: Point, context: &mut DiagnosticContext) {
let Some(package) = context.db.package_by_name(package_name) else {
return Err(anyhow::anyhow!(
"Can't get exports from package {package_name} because it is not installed."
));
// Package is not installed. This is linted via another path.
return;
};

// Start from explicit `NAMESPACE` exports
Expand All @@ -1083,8 +1078,6 @@ fn insert_package_exports(
.entry(attach_pos)
.or_default()
.extend(exports);

Ok(())
}

fn recurse_subset_or_subset2(
Expand Down
11 changes: 11 additions & 0 deletions crates/oak_db/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,17 @@ pub fn workspace_files(db: &dyn Db) -> Vec<File> {
files
}

/// The scripts held directly by workspace roots, in root order.
/// Like [`workspace_files`] but without package files.
#[salsa::tracked(returns(ref))]
pub(crate) fn workspace_scripts(db: &dyn Db) -> Vec<File> {
db.workspace_roots()
.roots(db)
.iter()
.flat_map(|root| root.scripts(db).iter().copied())
.collect()
}

fn collect_root_files(db: &dyn Db, files: &mut Vec<File>, r: Root) {
let owned = |f: File| root_by_file(db, f) == Some(r);
files.extend(r.scripts(db).iter().copied().filter(|&f| owned(f)));
Expand Down
107 changes: 83 additions & 24 deletions crates/oak_db/src/file_imports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use oak_semantic::semantic_index::SemanticCall;
use oak_semantic::semantic_index::SemanticCallKind;
use oak_semantic::semantic_index::SemanticIndex;

use crate::db::workspace_scripts;
use crate::Db;
use crate::File;
use crate::Package;
Expand Down Expand Up @@ -449,35 +450,18 @@ impl File {
package.files(db).contains(&self)
}

/// The collation members of `self`'s own `R/` directory, in load order:
/// sorted by basename, ASCII case-insensitively, the same order a
/// package `R/` with no `Collate:` gets from
/// `oak_scan::packages::order_alphabetically`.
/// The collation members of `self`'s own `R/` directory, in load order.
///
/// Path-based only. The scan-time resolver
/// ([`SalsaImportsResolver`](crate::imports::SalsaImportsResolver)) calls
/// `cross_file_layers` while `self`'s own semantic index is still being
/// built. The query can't recurse into the index.
///
/// Gathers candidates from workspace roots only (`root.scripts(db)` for
/// each). `OrphanRoot`, library roots, and `StaleRoot` don't contribute
/// collation siblings.
#[salsa::tracked(returns(ref))]
pub(crate) fn collation_siblings(self, db: &dyn Db) -> Vec<File> {
let Some(dir) = self.path(db).as_path().and_then(Utf8Path::parent) else {
return Vec::new();
};

let mut siblings: Vec<File> = db
.workspace_roots()
.roots(db)
.iter()
.flat_map(|root| root.scripts(db).iter().copied())
.filter(|file| file.path(db).as_path().and_then(Utf8Path::parent) == Some(dir))
.collect();

siblings.sort_by_cached_key(|file| collation_basename_key(*file, db));
siblings
files_in_directory(db, dir)
}
}

Expand Down Expand Up @@ -528,7 +512,18 @@ fn build_inherited_layers(
None => ImportLayer::File(source_site),
};

let mut above = vec![source_layer];
// One Source effect can load several files (`sourceDir()`, `tar_source()`).
// Keep track of already sourced files, since their eager top-level context
// can see what's already been sourced.
let mut above: Vec<ImportLayer> = match offsets.as_deref() {
Some(offsets) => loaded_before(db, source_site, file, offsets)
.into_iter()
.map(ImportLayer::File)
.collect(),
None => Vec::new(),
};

above.push(source_layer);
above.extend(own_cross.above.iter().cloned());
above.extend(
grandparents
Expand Down Expand Up @@ -576,6 +571,36 @@ fn source_offsets(db: &dyn Db, sourcing_file: File, file: File) -> Option<Vec<Te
}
}

/// The files the calls at `offsets` loaded before `file`, latest first.
///
/// One Source effect can expand to several targets (`sourceDir()`), which share
/// the call's offset. Only the targets ahead of `file` in its own group have run
/// by the time `file` does.
fn loaded_before(db: &dyn Db, source_file: File, file: File, offsets: &[TextSize]) -> Vec<File> {
let sites = source_file.source_sites(db);
let mut loaded: Vec<File> = Vec::new();

// Descending, so the latest call's targets rank first.
for &offset in offsets.iter().rev() {
let targets: Vec<File> = sites
.iter()
.filter(|site| site.offset() == offset)
.filter_map(|site| site.target())
.collect();
let Some(own) = targets.iter().position(|target| *target == file) else {
continue;
};

for &target in targets[..own].iter().rev() {
if !loaded.contains(&target) {
loaded.push(target);
}
}
}

loaded
}

fn package_load_layers(
file: File,
db: &dyn Db,
Expand Down Expand Up @@ -811,10 +836,44 @@ fn testthat_support_key(file: File, db: &dyn Db) -> Cow<'_, str> {
file.path(db).file_name().unwrap_or_default()
}

/// Case-insensitive basename sort key for `collation_siblings`, matching
/// `oak_scan::packages::order_alphabetically`'s `basename_key` so a
/// non-package `R/` collates the same way as a package `R/` with no
/// `Collate:`.
/// Returns workspace scripts directly under `dir`, in `list.files()` load order.
///
/// `sourceDir()`, Shiny's `loadSupport()`, and non-package `R/` collation use
/// this order. [`collation_basename_key()`] mirrors their session-locale sort.
pub(crate) fn files_in_directory(db: &dyn Db, dir: &Utf8Path) -> Vec<File> {
let mut files: Vec<File> = workspace_scripts(db)
.iter()
.copied()
.filter(|file| file.path(db).as_path().and_then(Utf8Path::parent) == Some(dir))
.collect();

files.sort_by_cached_key(|file| collation_basename_key(*file, db));
files
}

/// Returns workspace scripts below `dir` in `targets::tar_source()` load order.
///
/// `list.files(recursive = TRUE)` sorts nested scripts by relative path. Case
/// folding matches [`collation_basename_key()`].
pub(crate) fn files_in_directory_recursive(db: &dyn Db, dir: &Utf8Path) -> Vec<File> {
let mut keyed: Vec<(String, File)> = workspace_scripts(db)
.iter()
.copied()
.filter_map(|file| {
let relative = file.path(db).as_path()?.strip_prefix(dir).ok()?;
Some((relative.as_str().to_ascii_lowercase(), file))
})
.collect();

keyed.sort_by(|(left, _), (right, _)| left.cmp(right));
keyed.into_iter().map(|(_, file)| file).collect()
}

/// ASCII-case-folded basename key approximating `list.files()` session-locale
/// ordering.
///
/// Package installation instead forces `LC_COLLATE=C`, where raw byte order
/// determines collation.
fn collation_basename_key(file: File, db: &dyn Db) -> Option<String> {
file.path(db)
.file_name()
Expand Down
84 changes: 68 additions & 16 deletions crates/oak_db/src/imports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ use camino::Utf8Component;
use camino::Utf8Path;
use camino::Utf8PathBuf;
use oak_semantic::effects;
use oak_semantic::effects::DirWalk;
use oak_semantic::EffectsHandlers;
use oak_semantic::ImportsResolver;
use oak_semantic::SourceResolution;
use rustc_hash::FxHashMap;
use url::Url;

use crate::file_imports::files_in_directory;
use crate::file_imports::files_in_directory_recursive;
use crate::file_imports::CollationView;
use crate::file_imports::ImportLayer;
use crate::Db;
Expand Down Expand Up @@ -56,6 +59,62 @@ impl<'db> SalsaImportsResolver<'db> {
cache: EffectsCache::default(),
}
}

/// What sourcing `file` brings in. The two reads this makes are the ones
/// described on [`SalsaImportsResolver`].
fn source_resolution(&self, file: File) -> SourceResolution {
let names: Vec<String> = file
.exports(self.db)
.iter()
.map(|(name, _)| name.to_string())
.collect();

let packages: Vec<String> = file
.attached_packages(self.db)
.iter()
.map(|name| name.text(self.db).to_string())
.collect();

SourceResolution {
url: file.path(self.db).to_url(),
names,
packages,
}
}
}

/// Returns scripts from the workspace directory named by `path`, in load order.
/// `walk` determines whether nested directories are included. Returns no files
/// when `path` cannot resolve to a directory.
///
/// Tracked to give the directory listing a backdating point, the role
/// [`File::collation_siblings`] plays for the `R/` convention. The listing
/// filters every root's scripts, so without a memo here a file appearing
/// anywhere in the workspace would re-run the whole `semantic_index` of every
/// file holding a directory source, `_targets.R` being the one that hurts.
///
/// Reads only inputs, so it's safe to call while `file`'s own index is being
/// built.
#[salsa::tracked(returns(ref))]
pub(crate) fn source_dir_scripts(
db: &dyn Db,
file: File,
path: String,
walk: DirWalk,
) -> Vec<File> {
let Some(anchor) = anchor_dir(db, file) else {
return Vec::new();
};
let Some(target_path) = resolve_relative_to(&anchor, &path) else {
return Vec::new();
};
let Some(dir) = target_path.as_path() else {
return Vec::new();
};
match walk {
DirWalk::Shallow => files_in_directory(db, dir),
DirWalk::Recursive => files_in_directory_recursive(db, dir),
}
}

/// Per-build memo for `resolve_effects`, keyed on `(name, attached)`.
Expand Down Expand Up @@ -97,24 +156,17 @@ impl<'db> ImportsResolver for SalsaImportsResolver<'db> {
// TODO(diagnostics): Until we support out-of-workspace sourced files,
// should we at least lint so user knows that we can't analyse the file?
let file = self.db.file_by_path(&target_path)?;
Some(self.source_resolution(file))
}

let names: Vec<String> = file
.exports(self.db)
.iter()
.map(|(name, _)| name.to_string())
.collect();

let packages: Vec<String> = file
.attached_packages(self.db)
fn resolve_source_dir(&mut self, path: &str, walk: DirWalk) -> Vec<SourceResolution> {
source_dir_scripts(self.db, self.file, path.to_string(), walk)
.iter()
.map(|name| name.text(self.db).to_string())
.collect();

Some(SourceResolution {
url: target_path.to_url(),
names,
packages,
})
.copied()
// Exclude sourcing file
.filter(|file| *file != self.file)
.map(|file| self.source_resolution(file))
.collect()
}

fn resolve_effects(&mut self, name: &str, attached: &[String]) -> Option<EffectsHandlers> {
Expand Down
14 changes: 7 additions & 7 deletions crates/oak_db/src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,13 @@ pub struct Package {
/// editing a package's `NAMESPACE` live.
#[returns(ref)]
pub namespace_override: Option<Namespace>,
/// R source files belonging to this package (the `R/*.R` files), in
/// R's load order. When DESCRIPTION's `Collate:` directive is
/// present, this is exactly the files it lists, in that order;
/// files in `R/` not listed are excluded (matching R's loader,
/// Writing R Extensions §1.1.1). When `Collate:` is absent, files
/// are in case-insensitive alphabetical order. TODO(diagnostics):
/// Lint files missing from collation.
/// R source files belonging to this package (the `R/*.R` files), in R's
/// load order. When DESCRIPTION's `Collate:` directive is present, this is
/// exactly the files it lists, in that order; files in `R/` not listed are
/// excluded (matching R's loader, Writing R Extensions §1.1.1). When
/// `Collate:` is absent, files are in raw basename byte order, matching R's
/// `LC_COLLATE=C` installation collation.
/// TODO(diagnostics): Lint files missing from collation.
///
/// Per-package granularity: adding or removing a file in one
/// package doesn't invalidate tracked queries reading another
Expand Down
15 changes: 4 additions & 11 deletions crates/oak_db/src/tests/file_diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,17 +208,10 @@ f <- function() reactive({ x <- 1 })
}

#[test]
fn test_diagnostic_gap_named_arg_before_block() {
// R matches named arguments first, so `desc = "d"` binds to the `desc`
// formal, and the unnamed block then fills the remaining `code` formal,
// which is formal position 1, even though the block sits at call
// position 0. `match_positional()` in `crates/oak_semantic/src/effects.rs`
// only matches a positional argument to a formal declared at that exact
// call position, so it never finds `code` here and no scope gets pushed
// for `x <- 1`. Confirmed by direct comparison: this source yields one
// scope, versus two for the same call with `code` in its normal
// position, so `x` resolves at file scope instead of inside
// `test_that()`.
fn test_diagnostic_named_arg_before_block_scopes_correctly() {
// After `desc` binds by name, the block fills `code` despite appearing first.
// Its `test_that()` resolution is conditionally shadowed and must still gain
// the `code` scope.
let mut db = TestDb::new();
install_packages(&mut db, &["testthat"]);
let source = "\
Expand Down
Loading
Loading