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
87 changes: 67 additions & 20 deletions crates/ruff_charon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
//! | Charon | Meaning | Relation |
//! |---|---|---|
//! | `FnPtrKind::Fun(id)` | a resolved function | `CALLS_RESOLVED` |
//! | `FnPtrKind::Trait` with `TraitRefKind::TraitImpl` | a known impl | `CALLS_RESOLVED` |
//! | `FnPtrKind::Trait` with `TraitRefKind::TraitImpl` | a known impl; the row names the impl's method (or the trait's default) | `CALLS_RESOLVED` |
//! | `FnPtrKind::Trait`, any other trait-ref kind (`Clause`, `Dyn`, ...) | the impl is chosen later | `MAY_DISPATCH` |
//! | `FnOperand::Dynamic` | a function pointer or vtable slot | `UNRESOLVED_CALL` |
//!
Expand Down Expand Up @@ -95,9 +95,14 @@ pub enum Callee {
/// `FnPtrKind::Fun`: the function's id.
Fun(FunDeclId),
/// `FnPtrKind::Trait`: the trait reference's kind and the method's trait.
/// `impl_fun` is the function a `TraitImpl` reference resolves to: the
/// impl's own method, or the trait's default method when the impl does
/// not define it. `None` for every other kind, and when the impl or the
/// method is not in the LLBC.
Trait {
kind: String,
trait_id: Option<TraitDeclId>,
impl_fun: Option<FunDeclId>,
},
/// `FnOperand::Dynamic`.
Dynamic,
Expand Down Expand Up @@ -173,14 +178,33 @@ fn has_deref(place: &Place) -> bool {
}
}

fn callee(call: &Call) -> Callee {
fn callee(krate: &TranslatedCrate, call: &Call) -> Callee {
match &call.func {
FnOperand::Regular(ptr) => match ptr.kind.as_ref() {
FnPtrKind::Fun(id) => Callee::Fun(*id),
FnPtrKind::Trait(tr, _) => Callee::Trait {
kind: variant(format!("{:?}", tr.kind)),
trait_id: Some(tr.trait_decl_ref.id),
},
FnPtrKind::Trait(tr, method) => {
let impl_fun = match &tr.kind {
TraitRefKind::TraitImpl(r) => krate
.trait_impls
.get(r.id)
.and_then(|i| i.methods.get(*method))
.map(|b| b.skip_binder.id)
.or_else(|| {
krate
.trait_decls
.get(tr.trait_decl_ref.id)
.and_then(|t| t.methods.get(*method))
.and_then(|m| m.skip_binder.default.as_ref())
.map(|d| d.id)
}),
_ => None,
};
Callee::Trait {
kind: variant(format!("{:?}", tr.kind)),
trait_id: Some(tr.trait_decl_ref.id),
impl_fun,
}
}
},
FnOperand::Dynamic(..) => Callee::Dynamic,
}
Expand Down Expand Up @@ -211,7 +235,7 @@ pub fn fun_facts(krate: &TranslatedCrate, crate_name: &str) -> Vec<FunFacts> {
let mut calls = Vec::new();
body.body.dyn_visit(|st: &Statement| match &st.kind {
StatementKind::Call { call, .. } => calls.push(CallSite {
callee: callee(call),
callee: callee(krate, call),
site: site(krate, st.span),
}),
StatementKind::Assert { .. } => facts.asserts += 1,
Expand Down Expand Up @@ -320,6 +344,10 @@ pub fn relations(
site: f.site.clone(),
});
let Some(calls) = f.calls else {
// The body is an item of its own: harvested here, represented by
// nothing, so it is the residual. The definition above stays
// represented by its DEFINES row.
receipt.harvested += 1;
residual.push(Residual {
reason: "BODY_NOT_STRUCTURED",
subject: f.qualified.clone(),
Expand All @@ -342,17 +370,36 @@ pub fn relations(
}
},
},
Callee::Trait { kind, trait_id } => {
let tr = trait_id
.and_then(|t| krate.item_names.get(&ItemId::TraitDecl(t)))
.map(|n| qualified_name(krate, n))
.unwrap_or_else(|| "?".to_owned());
let p = if kind == "TraitImpl" {
"CALLS_RESOLVED"
Callee::Trait {
kind,
trait_id,
impl_fun,
} => {
if kind == "TraitImpl" {
// A known impl: the confirmed callee is the function
// it resolves to, never the trait.
let named = impl_fun.and_then(|id| match krate.fun_decls.get(id) {
Some(t) => Some(qualified_name(krate, &t.item_meta.name)),
None => krate
.item_names
.get(&ItemId::Fun(id))
.map(|n| qualified_name(krate, n)),
});
let Some(name) = named else {
residual.push(Residual {
reason: "TRAIT_IMPL_METHOD_NOT_FOUND",
subject: f.qualified.clone(),
});
continue;
};
("CALLS_RESOLVED", name)
} else {
"MAY_DISPATCH"
};
(p, format!("{tr} via {kind}"))
let tr = trait_id
.and_then(|t| krate.item_names.get(&ItemId::TraitDecl(t)))
.map(|n| qualified_name(krate, n))
.unwrap_or_else(|| "?".to_owned());
("MAY_DISPATCH", format!("{tr} via {kind}"))
}
}
Callee::Dynamic => ("UNRESOLVED_CALL", "dynamic".to_owned()),
};
Expand Down Expand Up @@ -490,9 +537,9 @@ mod tests {
#[test]
fn base64_matches_the_standard_alphabet() {
assert_eq!(b64(b""), "");
assert_eq!(b64(b"f"), "Zg==");
assert_eq!(b64(b"fo"), "Zm8=");
assert_eq!(b64(b"foo"), "Zm9v");
assert_eq!(b64(b"a"), "YQ==");
assert_eq!(b64(b"ab"), "YWI=");
assert_eq!(b64(b"abc"), "YWJj");
assert_eq!(b64(b"function"), "ZnVuY3Rpb24=");
}

Expand Down
54 changes: 44 additions & 10 deletions crates/ruff_charon/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

use crate::{GRADE, Llbc, relations};

Expand Down Expand Up @@ -164,15 +165,46 @@ impl Store {

/// Write `path` through a temporary file and a rename, so a reader never
/// sees half an artifact.
#[expect(
clippy::disallowed_methods,
reason = "ruff/clippy.toml's list is directory-scoped and reaches this \
workspace-excluded crate; there is no ty System here, plain std::fs is correct"
)]
fn replace(path: &Path, body: &[u8]) -> io::Result<()> {
let tmp = path.with_extension("tmp");
fs::write(&tmp, body)?;
fs::rename(&tmp, path)
// A temporary file of its own per call: two imports of one crate must
// not write into each other's file, or one of them publishes the
// other's half-written bytes (or fails when it was renamed away).
static NEXT: AtomicU64 = AtomicU64::new(0);
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("artifact");
// A leftover from an interrupted import can hold the name this call
// picks (a restarted process may get the same id and its counter
// starts again at zero). Skip taken names; creation stays exclusive,
// so a concurrent writer is never written into.
let (tmp, mut file) = loop {
let tmp = path.with_file_name(format!(
".{name}.{}.{}.tmp",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
{
Ok(file) => break (tmp, file),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
};
let written = file
.write_all(body)
.and_then(|()| file.sync_all())
.and_then(|()| {
drop(file);
fs::rename(&tmp, path)
});
if written.is_err() {
let _ = fs::remove_file(&tmp);
}
written
}

/// Import (or refresh) one crate from its LLBC: replaces its rows,
Expand Down Expand Up @@ -326,7 +358,9 @@ impl Store {
}

/// Call rows whose callee is a function of a stored crate that no stored
/// crate defines: edges left behind by an incomplete refresh.
/// crate defines: edges left behind by an incomplete refresh. A call
/// resolved through a known trait impl names the trait (`<trait> via
/// TraitImpl`), not a function, so it is not checked here.
///
/// # Errors
/// I/O or format errors.
Expand All @@ -340,7 +374,7 @@ impl Store {
.collect();
Ok(all
.iter()
.filter(|r| r.predicate == "CALLS_RESOLVED")
.filter(|r| r.predicate == "CALLS_RESOLVED" && !r.object.contains(" via "))
.filter(|r| {
let owner = r.object.split("::").next().unwrap_or("");
crates.iter().any(|c| c == owner) && !defined.contains(r.object.as_str())
Expand Down
19 changes: 9 additions & 10 deletions crates/ruff_charon/tests/data/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
# ruff_charon test data

- `cg_lib.llbc`, `cg_app.llbc`: Charon LLBC of the two crates of lance-graph
`crates/sag-ra-probe/fixtures/codegraph/rev1`, produced with the AdaWorldAPI Charon fork
(`c69e231`, toolchain `nightly-2026-09-17`, built `--no-default-features --features rustc`):
- `cg_lib.llbc`, `cg_app.llbc`: Charon LLBC of the two crates of lance-graph `crates/sag-ra-probe/fixtures/codegraph/rev1`.
- `cg_lib_rev2.llbc`, `cg_app_rev2.llbc`: the same for `rev2`, which deletes `triple` and `area_dyn` from `cg_lib` and drops the `area_dyn` call from `cg_app` (the store tests).
- `trprobe.llbc`: Charon LLBC of `trprobe.rs` (a one-file crate with a trait, a default method and one impl), used to build calls through a known impl, which Charon does not emit for any of these sources.
- `*.llbc_calls.txt`: the reference output, written by lance-graph `crates/sag-ra-probe/tools/llbc_calls.py <file> <crate>`, never by this crate.

cd rev1/cg_lib && charon cargo --no-dedup-serialized-ast --dest-file cg_lib.llbc
cd rev1/cg_app && charon cargo --no-dedup-serialized-ast --dest-file cg_app.llbc
The LLBC was produced with the AdaWorldAPI Charon fork (`c69e231`, toolchain `nightly-2026-09-17`, built `--no-default-features --features rustc`), from the fixture revision's directory:

- `cg_lib_rev2.llbc`, `cg_app_rev2.llbc`: the same for `rev2`, which deletes `triple` and
`area_dyn` from `cg_lib` and drops the `area_dyn` call from `cg_app` (the store tests).

- `*.llbc_calls.txt`: the reference output, written by lance-graph
`crates/sag-ra-probe/tools/llbc_calls.py <file> <crate>`, never by this crate.
```sh
cd cg_lib && charon cargo --no-dedup-serialized-ast --dest-file cg_lib.llbc
cd cg_app && charon cargo --no-dedup-serialized-ast --dest-file cg_app.llbc
```
1 change: 1 addition & 0 deletions crates/ruff_charon/tests/data/trprobe.llbc

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions crates/ruff_charon/tests/data/trprobe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
pub trait Shape {
fn area(&self) -> u32;
fn twice(&self) -> u32 {
self.area() * 2
}
}
pub struct Sq(pub u32);
impl Shape for Sq {
fn area(&self) -> u32 {
self.0
}
}
pub fn method_call() -> u32 {
Sq(1).area()
}
pub fn qualified_call() -> u32 {
<Sq as Shape>::area(&Sq(2))
}
pub fn default_method() -> u32 {
Sq(3).twice()
}
Loading
Loading