Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
1b1c3f3
init
etiennebacher May 10, 2026
2e658d4
better handling of exports
etiennebacher May 10, 2026
e261771
create new subcrate jarl-semantic
etiennebacher May 11, 2026
5643b8b
rename facts to info
etiennebacher May 11, 2026
67402ca
fix tests
etiennebacher May 11, 2026
faaa425
Merge branch 'main' into oak-unused-object
etiennebacher May 11, 2026
7c20568
handle source() calls that use symbols defined in original document
etiennebacher May 12, 2026
061df2e
more tests for sourced files
etiennebacher May 12, 2026
4e39420
simplify docs [skip ci]
etiennebacher May 12, 2026
dc29b29
update
etiennebacher May 22, 2026
bfe92f6
fix
etiennebacher May 22, 2026
1f3e4e8
Merge branch 'main' into oak-unused-object
etiennebacher May 22, 2026
9aa45fe
Merge branch 'main' into oak-unused-object
etiennebacher Jun 19, 2026
d4f6b50
bumt oka [skip ci]
etiennebacher Jun 19, 2026
7687480
use oak fork [skip ci]
etiennebacher Jun 19, 2026
757342b
use more of oak's api
etiennebacher Jun 19, 2026
e420b73
use Oak's DESCRIPTION parser
etiennebacher Jun 19, 2026
d943c95
init
etiennebacher Jun 19, 2026
cdee313
more
etiennebacher Jun 19, 2026
c300cd5
fix for .() in bquote()
etiennebacher Jun 20, 2026
bfcce81
more
etiennebacher Jun 20, 2026
9011e05
Merge branch 'oak-use-salsa-infra' into oak-unused-object
etiennebacher Jun 20, 2026
725bee3
Merge branch 'main' into oak-unused-object
etiennebacher Jun 20, 2026
d02388d
Merge branch 'main' into oak-unused-object
etiennebacher Jun 21, 2026
954b845
Merge branch 'main' into oak-unused-object
etiennebacher Jun 21, 2026
eafa226
Merge branch 'main' into oak-unused-object
etiennebacher Jun 21, 2026
c6afd40
fix object used in formula
etiennebacher Jun 21, 2026
c6af3b6
do not consider assignments in quoting functions
etiennebacher Jun 21, 2026
1541398
do not count assignment in quote-like functions are live bindings
etiennebacher Jun 21, 2026
0cbd609
ensure that arguments of quote-like functions are used
etiennebacher Jun 21, 2026
e154b24
gitignore
etiennebacher Jun 21, 2026
de8bca4
try improve perf
etiennebacher Jun 21, 2026
49ea96c
try more perf improvement
etiennebacher Jun 23, 2026
bd8b59c
more perf
etiennebacher Jun 23, 2026
2ad7e36
more
etiennebacher Jun 24, 2026
238469d
Merge branch 'main' into oak-unused-object
etiennebacher Jun 24, 2026
06fb4af
ignore assignment in alist()
etiennebacher Jun 24, 2026
baffa0e
extra test for alist
etiennebacher Jun 24, 2026
00a0231
Merge branch 'main' into oak-unused-object
etiennebacher Jun 26, 2026
4a88445
Merge branch 'main' into oak-unused-object
etiennebacher Jun 26, 2026
ce8e180
fix
etiennebacher Jun 26, 2026
517b22d
handle glue calls
etiennebacher Jun 26, 2026
800e708
handle cli calls
etiennebacher Jun 26, 2026
29b0c2c
do not report objects actually used in `where` arg of `bquote()`
etiennebacher Jun 26, 2026
7770960
trigger
etiennebacher Jun 26, 2026
6890d3d
Merge branch 'main' into oak-unused-object
etiennebacher Jul 1, 2026
0ac764e
handle infix operators
etiennebacher Jul 1, 2026
7f36e29
handle more glue and cli cases
etiennebacher Jul 1, 2026
b605dba
fix glue with if else
etiennebacher Jul 1, 2026
bcbe9e6
Merge branch 'main' into oak-unused-object
etiennebacher Jul 2, 2026
e8ba973
Merge branch 'main' into oak-unused-object
etiennebacher Jul 3, 2026
b14e0c8
Merge branch 'main' into oak-unused-object
etiennebacher Jul 3, 2026
2a9671f
handle loose scripts
etiennebacher Jul 3, 2026
0e05eee
Merge branch 'main' into oak-unused-object
etiennebacher Jul 3, 2026
5b4a567
Merge remote-tracking branch 'origin/main' into oak-unused-object
etiennebacher Jul 4, 2026
eeb6dd7
Merge branch 'main' into oak-unused-object
etiennebacher Jul 7, 2026
2bc503f
handle chained assignment of functions, see #540
etiennebacher Jul 7, 2026
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
958 changes: 565 additions & 393 deletions Cargo.lock

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ air_fs = { package = "fs", git = "https://github.com/etiennebacher/air_pratt" }
air_r_parser = { git = "https://github.com/etiennebacher/air_pratt" }
air_r_syntax = { git = "https://github.com/etiennebacher/air_pratt" }
air_workspace = { package = "workspace", git = "https://github.com/etiennebacher/air_pratt" }
aether_path = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" }
oak_core = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" }
oak_db = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" }
oak_ide = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" }
oak_index_vec = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" }
oak_package_metadata = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" }
oak_scan = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" }
oak_semantic = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" }
salsa = "0.26.2"
url = "2.5"
anyhow = "1.0.94"
biome_formatter = { git = "https://github.com/lionel-/biome", rev = "a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" }
biome_rowan = { git = "https://github.com/lionel-/biome", rev = "a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" }
Expand All @@ -25,6 +35,7 @@ insta = { version = "1.47.2", features = ["yaml", "filters"] }
etcetera = "0.11.0"
jarl-core = { path = "crates/jarl-core" }
jarl-lsp = { path = "crates/jarl-lsp" }
jarl-semantic = { path = "crates/jarl-semantic" }
path-absolutize = "3.1.1"
rayon = "1.12.0"
regex = { version = "1.11.1", default-features = false, features = ["std"] }
Expand All @@ -38,6 +49,13 @@ tempfile = "3.27.0"
toml = "0.8.23"
tracing = "0.1.41"

# The oak crates (from the ark fork) declare their air dependencies against
# posit-dev/air; redirect them to the same Pratt-parser fork used by jarl so a
# single copy of the syntax/parser crates exists in the graph.
[patch."https://github.com/posit-dev/air"]
air_r_parser = { git = "https://github.com/etiennebacher/air_pratt" }
air_r_syntax = { git = "https://github.com/etiennebacher/air_pratt" }

[profile.release]
lto = true

Expand Down
15 changes: 15 additions & 0 deletions crates/jarl-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ air_fs.workspace = true
biome_rowan.workspace = true
biome_formatter.workspace = true

# Semantic analysis (per-file scopes, definitions, uses, use-def map)
aether_path.workspace = true
oak_core.workspace = true
oak_db.workspace = true
oak_ide.workspace = true
oak_index_vec.workspace = true
oak_package_metadata.workspace = true
oak_scan.workspace = true
oak_semantic.workspace = true
salsa.workspace = true
url.workspace = true

# Per-file semantic info (used by semantic-aware lints)
jarl-semantic.workspace = true

# Serialization and data structures
serde.workspace = true
toml.workspace = true
Expand Down
18 changes: 14 additions & 4 deletions crates/jarl-core/src/analyze/document.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use air_r_syntax::{RExpressionList, RSyntaxNode};
use biome_rowan::{AstNode, AstNodeList};
use oak_semantic::semantic_index::SemanticIndex;

use crate::checker::Checker;
use crate::diagnostic::*;
use crate::lints::base::empty_file::empty_file::empty_file;
use crate::lints::base::unreachable_code::unreachable_code::unreachable_code_top_level;
use crate::lints::base::unused_object::unused_object::unused_object;
use crate::lints::comments::blanket_suppression::blanket_suppression::blanket_suppression;
use crate::lints::comments::invalid_chunk_suppression::invalid_chunk_suppression::invalid_chunk_suppression;
use crate::lints::comments::misnamed_suppression::misnamed_suppression::misnamed_suppression;
Expand All @@ -15,14 +17,15 @@ use crate::lints::comments::unexplained_suppression::unexplained_suppression::un
use crate::lints::comments::unmatched_range_suppression::unmatched_range_suppression::{
unmatched_range_suppression_end, unmatched_range_suppression_start,
};
use crate::package::PackageFileAnalysis;
use crate::rule_set::Rule;

pub(crate) fn check_document(
expressions: &RExpressionList,
syntax: &RSyntaxNode,
checker: &mut Checker,
duplicate_assignments: &[(String, biome_rowan::TextRange, String)],
unused_functions: &[(String, biome_rowan::TextRange, String)],
package: &PackageFileAnalysis,
semantic: Option<&SemanticIndex>,
) -> anyhow::Result<()> {
// --- Document-level analysis ---

Expand All @@ -35,6 +38,13 @@ pub(crate) fn check_document(
}
}

// Check for unused local objects via the semantic index.
if checker.is_rule_enabled(Rule::UnusedObject)
&& let Some(semantic) = semantic
{
unused_object(&expressions, semantic, &package.cross_file_used, checker)?;
}

// --- Comment/suppression checks ---

// Report blanket suppression comments (file-level, done once)
Expand Down Expand Up @@ -104,7 +114,7 @@ pub(crate) fn check_document(
// Emit package-level diagnostics before suppression filtering so that
// # jarl-ignore and # jarl-ignore-file comments can suppress them.
if checker.is_rule_enabled(Rule::DuplicatedFunctionDefinition) {
for (name, range, help) in duplicate_assignments {
for (name, range, help) in &package.duplicate_assignments {
checker.report_diagnostic(Some(Diagnostic::new(
ViolationData::new(
"duplicated_function_definition".to_string(),
Expand All @@ -118,7 +128,7 @@ pub(crate) fn check_document(
}

if checker.is_rule_enabled(Rule::UnusedFunction) {
for (name, range, help) in unused_functions {
for (name, range, help) in &package.unused_functions {
checker.report_diagnostic(Some(Diagnostic::new(
ViolationData::new(
"unused_function".to_string(),
Expand Down
106 changes: 88 additions & 18 deletions crates/jarl-core/src/check.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
use crate::error::ParseError;
use crate::package::{
FilePackageInfo, FileScope, PackageAnalysis, PackageContext, make_package_analysis,
summarize_package_info,
FilePackageInfo, FileScope, PackageAnalysis, PackageContext, PackageFileAnalysis,
make_package_analysis, summarize_package_info,
};
use crate::roxygen::{extract_roxygen_examples, remap_roxygen_fix, remap_roxygen_range};
use crate::suppression::SuppressionManager;
use crate::vcs::check_version_control;
use air_fs::relativize_path;
use air_r_parser::RParserOptions;
use air_r_syntax::{RExpressionList, RSyntaxNode};
use air_r_syntax::RSyntaxNode;
use anyhow::{Context, Result};
use rayon::prelude::*;
use std::collections::HashMap;
Expand Down Expand Up @@ -126,6 +126,8 @@ pub fn lint_only(
&pkg,
&pkg_contexts,
&file_pkg_info,
// lint-only: on-disk contents match the cached index, so reuse it.
true,
)
.with_context(|| format!("Failed to get checks for file: {path}"))?;

Expand Down Expand Up @@ -164,6 +166,10 @@ pub fn lint_fix(
&pkg,
&pkg_contexts,
&file_pkg_info,
// Fix mode rewrites the file between iterations, so the on-disk
// contents (and the index the pre-pass cached from them) drift from
// the in-memory `contents`; rebuild the index rather than reuse it.
false,
)
.with_context(|| format!("Failed to get checks for file: {path}",))?;

Expand Down Expand Up @@ -200,6 +206,7 @@ pub fn get_checks(
pkg: &PackageAnalysis,
pkg_contexts: &HashMap<PathBuf, PackageContext>,
file_pkg_info: &HashMap<PathBuf, FilePackageInfo>,
use_cached_index: bool,
) -> Result<Vec<Diagnostic>> {
if crate::fs::has_rmd_extension(file) {
return get_checks_rmd(contents, file, config);
Expand All @@ -224,23 +231,48 @@ pub fn get_checks(
checker.rule_set = effective_rules_for_file(config, file);
checker.minimum_r_version = config.minimum_r_version;

// Build the semantic index for use-def-based rules. `source("path")`
// calls inject `DefinitionKind::Import` entries via JarlImportsResolver;
// the complementary "names read by sourced files" path is still handled
// inside `SemanticInfo`.
//
// When `unused_object` runs, the cross-file pre-pass already built this
// file's index (with the same resolver) and stored it in `pkg.file_indices`;
// reuse it rather than rebuilding. The pre-pass reads from disk, so the
// cache is only valid in lint-only mode — fix mode rewrites the file
// between passes, so it always rebuilds from the in-memory contents.
//
// Building (when not cached) happens here, in the parallel per-file pass,
// rather than via the shared `AnalysisDb`: oak's salsa database is `Send`
// but not `Sync`, so it can't be borrowed across rayon worker threads.
let owned_semantic;
let semantic: &oak_semantic::semantic_index::SemanticIndex = match use_cached_index
.then(|| pkg.file_indices.get(file))
.flatten()
{
Some(cached) => cached,
None => {
owned_semantic = oak_semantic::build_index(
&parsed.tree(),
jarl_semantic::JarlImportsResolver::new(file),
);
&owned_semantic
}
};
checker.file_path = file.to_path_buf();

// Wire up package context for package-specific rules.
get_package_info(
&mut checker,
file,
expressions,
semantic,
config,
pkg_contexts,
file_pkg_info,
);

// Look up per-file data from PackageAnalysis
let duplicate_assignments = pkg
.duplicate_assignments
.get(file)
.cloned()
.unwrap_or_default();
let unused_functions = pkg.unused_functions.get(file).cloned().unwrap_or_default();
let package_file = PackageFileAnalysis::for_file(pkg, file);

// We run checks at expression-level. This gathers all violations, no matter
// whether they are suppressed or not. They are filtered out in the next
Expand Down Expand Up @@ -274,8 +306,8 @@ pub fn get_checks(
expressions,
syntax,
&mut checker,
&duplicate_assignments,
&unused_functions,
&package_file,
Some(semantic),
)?;

// Some rules have a fix available in their implementation but do not have
Expand Down Expand Up @@ -335,11 +367,12 @@ pub fn get_checks(
/// Populate package context on the checker from pre-computed data.
///
/// For files inside an R package, copies the pre-computed `PackageContext`
/// fields. For scripts, scans for `library()`/`require()` calls.
/// fields. For scripts, harvests `library()`/`require()` calls from the
/// semantic index.
fn get_package_info(
checker: &mut Checker,
file: &Path,
expressions: &RExpressionList,
semantic: &oak_semantic::semantic_index::SemanticIndex,
config: &Config,
pkg_contexts: &HashMap<PathBuf, PackageContext>,
file_pkg_info: &HashMap<PathBuf, FilePackageInfo>,
Expand All @@ -357,13 +390,38 @@ fn get_package_info(
.iter()
.map(|s| s.to_string())
.collect();
packages.extend(crate::library_calls::extract_library_calls(expressions));
packages.extend(top_level_attached_packages(semantic));
checker.loaded_packages = packages;
}
}
checker.package_cache = config.package_cache.clone();
}

/// Collect package names from top-level `library()`/`require()` calls in
/// load order, deduplicated. Calls inside nested function bodies are
/// excluded because their attachment isn't statically guaranteed; calls
/// inside top-level `if`/loops are included because oak scopes them to the
/// file (R sequential execution makes their effect visible to subsequent
/// top-level code if the branch runs).
fn top_level_attached_packages(
semantic: &oak_semantic::semantic_index::SemanticIndex,
) -> Vec<String> {
use oak_semantic::semantic_index::SemanticCallKind;
let top_level = oak_semantic::ScopeId::from(0);
let mut out: Vec<String> = Vec::new();
for call in semantic.semantic_calls() {
if call.scope() != top_level {
continue;
}
if let SemanticCallKind::Attach { package } = call.kind()
&& !out.iter().any(|p| p == package)
{
out.push(package.clone());
}
}
out
}

/// Lint R code inside roxygen `@examples` and `@examplesIf` sections.
///
/// Each examples section is extracted, parsed as standalone R code, and linted.
Expand Down Expand Up @@ -403,7 +461,13 @@ fn get_checks_roxygen(
// otherwise unnecessary here (no package-level analysis, no
// suppression-related diagnostics to report).
if has_suppressions {
check_document(expressions, &syntax, &mut checker, &[], &[])?;
check_document(
expressions,
&syntax,
&mut checker,
&PackageFileAnalysis::default(),
None,
)?;
}

for mut d in checker.diagnostics {
Expand Down Expand Up @@ -453,8 +517,14 @@ fn get_checks_rmd(contents: &str, file: &Path, config: &Config) -> Result<Vec<Di
}
// check_document runs suppression filtering internally, so
// checker.diagnostics is the post-suppression list after this call.
// Rmd chunks don't participate in package-level analysis, so pass empty slices.
check_document(expressions, &syntax, &mut checker, &[], &[])?;
// Rmd chunks don't participate in package-level analysis.
check_document(
expressions,
&syntax,
&mut checker,
&PackageFileAnalysis::default(),
None,
)?;

// Remap ranges from virtual-string offsets to original Rmd file offsets.
let diagnostics: Vec<Diagnostic> = checker
Expand Down
5 changes: 5 additions & 0 deletions crates/jarl-core/src/checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ pub struct Checker {
// `S3method()`, etc.). Used to suppress false positives in rules
// like `unused_object` — exported names are "used" by definition.
pub namespace_exports: HashSet<String>,
// Path of the file being checked. Used by rules that need to resolve
// paths relative to the current file (e.g. `unused_object` resolving
// `source("...")` arguments).
pub file_path: std::path::PathBuf,
}

impl Checker {
Expand All @@ -91,6 +95,7 @@ impl Checker {
package_cache: None,
import_from: HashMap::new(),
namespace_exports: HashSet::new(),
file_path: std::path::PathBuf::new(),
}
}

Expand Down
Loading
Loading