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
7 changes: 5 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ license = "MIT"

[workspace.dependencies]
char_str = { version = "0.0.4", default-features = false, features = ["std"] }
ruff = { version = "0.17.0", path = "crates/ruff" }
# Internal release train: keep published tarball dependency versions
# identical to these local paths; ^0.17.0 allows 0.17.1 and mixes 0.0.18
# ruff_* crates with this workspace's 0.0.17 ABI during cargo publish --dry-run.
ruff = { version = "=0.17.0", path = "crates/ruff" }
ruff_annotate_snippets = { version = "0.0.17", path = "crates/ruff_annotate_snippets" }
ruff_cache = { version = "0.0.17", path = "crates/ruff_cache" }
ruff_command_line = { version = "0.0.17", path = "crates/ruff_command_line" }
Expand All @@ -32,7 +35,7 @@ ruff_diagnostics = { version = "0.0.17", path = "crates/ruff_diagnostics" }
ruff_formatter = { version = "0.0.17", path = "crates/ruff_formatter" }
ruff_graph = { version = "0.0.17", path = "crates/ruff_graph" }
ruff_index = { version = "0.0.17", path = "crates/ruff_index" }
ruff_linter = { version = "0.17.0", path = "crates/ruff_linter" }
ruff_linter = { version = "=0.17.0", path = "crates/ruff_linter" }
ruff_macros = { version = "0.0.17", path = "crates/ruff_macros" }
ruff_markdown = { version = "0.0.17", path = "crates/ruff_markdown" }
ruff_memory_usage = { version = "0.0.17", path = "crates/ruff_memory_usage" }
Expand Down
3 changes: 3 additions & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ CPY = "CPY" # it's a Ruff rule category
# P-code / R2IL opcode names, not prose. INT_SCARRY is signed-carry; the
# hook's "scary" correction silently corrupts an emitted wire tag.
scarry = "scarry"
# `anc` is an intentional MeTTa predicate for ancestor in a recursive
# Datalog-style test. Replacing it with `and` changes the program's meaning.
anc = "anc"
# Tesseract really does ship `src/ccstruct/statistc.h` under that spelling.
# "Correcting" it points the harvest docs at a file that does not exist.
statistc = "statistc"
Expand Down
4 changes: 4 additions & 0 deletions crates/ruff_cpp_spo/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ required-features = ["libclang"]
name = "harvest_ladybug"
required-features = ["libclang"]

[[example]]
name = "harvest_pgring"
required-features = ["libclang"]

[dev-dependencies]
# End-to-end pipeline test only (harvest -> reassemble -> ruff_cpp_codegen emit).
# The harvester does NOT depend on the codegen at runtime — this is the test
Expand Down
304 changes: 304 additions & 0 deletions crates/ruff_cpp_spo/examples/harvest_pgring.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
//! Harvest the PG-Ring graph query engine (Gómez-Brandón, Hogan, Navarro,
//! ISWC 2026, arXiv 2608.03840) into a structural manifest: classes, their
//! fields and method signatures, and the call graph of every free function
//! and out-of-line method definition.
//!
//! Methods defined inside a class body (inline, the common case in a
//! header-only library) appear only as signatures: the function walker does
//! not descend into class bodies, so their callees are not in the call graph.
//!
//! PG-Ring is GPL-3.0. This example reads the source only to extract the
//! STRUCTURE (which iterators exist, which methods they offer, who calls whom)
//! that a clean-room reimplementation is designed against. No code is copied,
//! and the corpus is never vendored here.
//!
//! Run:
//! ```sh
//! PGRING_SRC=/home/user/adaworldapi/pg-ring \
//! SDSL_INCLUDE=/home/user/adaworldapi/sdsl-lite/include \
//! LIBCLANG_PATH=/usr/lib/llvm-18/lib \
//! cargo run -p ruff_cpp_spo --features libclang --example harvest_pgring
//! ```
//!
//! Outputs (directory `OUT_DIR`, default `/tmp/pgring_harvest`):
//! - `manifest.ndjson`: the SPO triples of every class (shared IR).
//! - `<file>.txt`: one readable summary per source file: classes with bases,
//! fields and method signatures, then every function defined in that file
//! with its callees.
//! - `_diagnostics.txt`: translation units with parse errors. A class is taken
//! from such a unit only when no unit without errors declares it, and the
//! classes taken that way are listed.

#![expect(
clippy::print_stderr,
reason = "manifest-emission CLI example (mirrors harvest_textord)"
)]

use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};

use ruff_cpp_spo::{
CppClass, CppFunction, Declaration, NAMESPACE, model_from_class,
walk_free_functions_with_diagnostics, walk_tu_with_diagnostics,
};
use ruff_spo_triplet::{ModelGraph, expand, to_ndjson};

/// Every `.hpp`, `.h`, `.cpp` and `.c` under `dir`, recursively, sorted.
fn sources(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
paths.sort();
for p in paths {
if p.is_dir() {
sources(&p, out);
} else if p
.extension()
.is_some_and(|e| e == "hpp" || e == "h" || e == "cpp" || e == "c")
{
out.push(p);
}
}
}

fn summarize_class(c: &CppClass, s: &mut String) {
let _ = writeln!(s, "class {}", c.qualified_name());
for d in &c.declarations {
match d {
Declaration::Base(b) => {
let _ = writeln!(s, " base {}", b.name);
}
Declaration::Field(f) => {
let _ = writeln!(s, " field {}: {}", f.name, f.type_name);
}
Declaration::Method(m) => {
let ret = m.return_type.as_deref().unwrap_or("void");
let flags = if m.is_static { " static" } else { "" };
let _ = writeln!(
s,
" method {}({}) -> {ret}{flags}",
m.name,
m.param_types.join(", ")
);
}
Declaration::Template(t) => {
let _ = writeln!(s, " template {:?} {}", t.kind, t.name);
}
_ => {}
}
}
}

/// Library namespaces pulled in through `#include` that are not PG-Ring's own
/// code. Their declarations reach every translation unit; keeping them would
/// bury the engine under SDSL and the standard library.
const FOREIGN: [&str; 3] = ["sdsl", "std", "__gnu_cxx"];

fn is_foreign(qualified: &str) -> bool {
qualified.starts_with("__")
|| FOREIGN
.iter()
.any(|ns| qualified == *ns || qualified.starts_with(&format!("{ns}::")))
}

/// The source file that DEFINES `name` (a `class name` / `struct name` not
/// followed by `;`), found by text scan. libclang reports a class in every
/// translation unit that includes it, so the walk alone cannot attribute it.
fn defining_file<'a>(name: &str, texts: &'a [(String, String)]) -> Option<&'a str> {
texts.iter().find_map(|(rel, text)| {
["class ", "struct "].iter().find_map(|kw| {
let needle = format!("{kw}{name}");
text.match_indices(&needle).find_map(|(i, _)| {
let rest = &text[i + needle.len()..];
let next = rest.chars().next()?;
if next.is_alphanumeric() || next == '_' {
return None;
}
let decl_end = rest.find(['{', ';'])?;
(rest.as_bytes()[decl_end] == b'{').then_some(rel.as_str())
})
})
})
}

/// The identity of a harvested function. An out-of-line method is named
/// `Class::method` by the walker and also carries `Class` as its last scope
/// component; joining the two would repeat the class (`Class::Class::method`).
fn function_identity(func: &CppFunction) -> String {
match func.namespace.split_last() {
Some((owner, outer)) if func.name.starts_with(&format!("{owner}::")) => {
if outer.is_empty() {
func.name.clone()
} else {
format!("{}::{}", outer.join("::"), func.name)
}
}
_ => func.qualified_name(),
}
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
let root = PathBuf::from(
std::env::var("PGRING_SRC").unwrap_or_else(|_| "/home/user/adaworldapi/pg-ring".into()),
);
let sdsl = std::env::var("SDSL_INCLUDE")
.unwrap_or_else(|_| "/home/user/adaworldapi/sdsl-lite/include".into());
let out_dir =
PathBuf::from(std::env::var("OUT_DIR").unwrap_or_else(|_| "/tmp/pgring_harvest".into()));
std::fs::create_dir_all(&out_dir)?;

let mut files = Vec::new();
sources(&root.join("include"), &mut files);
sources(&root.join("src"), &mut files);
if files.is_empty() {
return Err(format!("no sources under {}; set PGRING_SRC", root.display()).into());
}

let args = [
"-std=c++17".to_string(),
"-x".to_string(),
"c++".to_string(),
format!("-I{}", root.join("include").display()),
format!("-I{}", root.join("libCSD").display()),
format!("-I{sdsl}"),
];

let texts: Vec<(String, String)> = files
.iter()
.filter_map(|f| {
let rel = f.strip_prefix(&root).unwrap_or(f).display().to_string();
std::fs::read_to_string(f).ok().map(|t| (rel, t))
})
.collect();

let mut all: Vec<CppClass> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
// Classes seen only in translation units with parse errors: a partial AST,
// used only for a class that no clean unit declares.
let mut from_errored: Vec<CppClass> = Vec::new();
let mut functions: Vec<(String, String)> = Vec::new();
let mut seen_fn: BTreeSet<String> = BTreeSet::new();
let mut diag_report = String::new();
for f in &files {
let rel = f.strip_prefix(&root).unwrap_or(f).display().to_string();
match walk_tu_with_diagnostics(f, &args) {
Ok((classes, diags)) => {
if !diags.is_empty() {
let _ = writeln!(diag_report, "{rel}: {} error(s)", diags.len());
for d in diags.iter().take(3) {
let _ = writeln!(diag_report, " {d}");
}
}
for c in classes {
if is_foreign(&c.qualified_name()) {
continue;
}
if !diags.is_empty() {
from_errored.push(c);
} else if seen.insert(c.qualified_name()) {
all.push(c);
Comment thread
AdaWorldAPI marked this conversation as resolved.
}
}
}
Err(e) => {
let _ = writeln!(diag_report, "{rel}: class walk failed: {e}");
}
}
match walk_free_functions_with_diagnostics(f, &args) {
Ok((fns, _)) => {
Comment thread
AdaWorldAPI marked this conversation as resolved.
for func in fns {
let q = function_identity(&func);
let sig = format!("{q}({})", func.param_types.join(", "));
if is_foreign(&q) || !seen_fn.insert(sig.clone()) {
continue;
}
let ret = func.return_type.as_deref().unwrap_or("void");
// Callees of a method are spelled `Class::method` (no
// namespace); print that spelling too so a definition
// joins its call edges.
let key = if func.name == q {
String::new()
} else {
format!(" callee name: {}\n", func.name)
};
let line = format!(
"fn {sig} -> {ret}\n{key} calls: {}\n",
func.calls.join(", ")
);
// The definition's own location attributes it; the walk
// sees an inline header function in every including TU.
let home = func
.file
.as_deref()
.map(|p| p.strip_prefix(&root).unwrap_or(p).display().to_string())
.unwrap_or_else(|| rel.clone());
functions.push((home, line));
}
}
Err(e) => {
let _ = writeln!(diag_report, "{rel}: function walk failed: {e}");
}
}
}

let mut recovered: Vec<String> = Vec::new();
for c in from_errored {
if seen.insert(c.qualified_name()) {
recovered.push(c.qualified_name());
all.push(c);
}
}
let _ = writeln!(
diag_report,
"\nclasses taken from units with parse errors (partial AST): {recovered:?}"
);

for (rel, _) in &texts {
let mut s = format!("# {rel}\n");
for c in &all {
if defining_file(&c.name, &texts) == Some(rel.as_str()) {
summarize_class(c, &mut s);
}
}
for (home, line) in &functions {
if home == rel {
s.push_str(line);
}
}
std::fs::write(out_dir.join(format!("{}.txt", rel.replace('/', "__"))), &s)?;
}
let unattributed: Vec<&str> = all
.iter()
.filter(|c| defining_file(&c.name, &texts).is_none())
.map(|c| c.name.as_str())
.collect();
let _ = writeln!(
diag_report,
"\nclasses with no defining file found: {unattributed:?}"
);
std::fs::write(out_dir.join("_diagnostics.txt"), &diag_report)?;
let n_functions = functions.len();
let warned = diag_report
.lines()
.filter(|l| l.contains(" error(s)"))
.count();

let mut graph = ModelGraph::new(NAMESPACE);
for c in &all {
graph.models.push(model_from_class(c));
}
let triples = expand(&graph);
std::fs::write(out_dir.join("manifest.ndjson"), to_ndjson(&triples))?;
eprintln!(
"[harvest] {} files, {} unique classes, {} function definitions, {} files with parse errors, {} triples -> {}",
files.len(),
all.len(),
n_functions,
warned,
triples.len(),
out_dir.display()
);
Ok(())
}
13 changes: 13 additions & 0 deletions crates/ruff_cpp_spo/src/clang_walker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,13 +320,18 @@ fn collect_functions(entity: &Entity, out: &mut Vec<CppFunction>) {
// member question and is false for a free function, so the
// storage class is the right source here.
let is_static = child.get_storage_class() == Some(StorageClass::Static);
let file = child
.get_location()
.and_then(|l| l.get_file_location().file)
.map(|f| f.get_path());
out.push(CppFunction {
namespace: enclosing_scopes(&child),
name,
calls,
return_type,
param_types,
is_static,
file,
});
}
}
Expand Down Expand Up @@ -2378,6 +2383,14 @@ void Widget::dispatch() {
.unwrap_or_else(|| panic!("Widget::dispatch missing; got {funcs:?}"));
assert_eq!(dispatch.namespace, vec!["Widget".to_string()]);

// The definition's own file is recorded, so a caller can attribute it
// without searching text for the name.
assert_eq!(
dispatch.file.as_deref().and_then(|f| f.file_name()),
path.file_name(),
"the definition must carry the file it is defined in"
);

// Fix 1 + codex P2 (#57): in-TU dispatch between two out-of-line
// methods resolves CLASS-QUALIFIED, so same-named methods of
// different classes can never collapse in the call graph.
Expand Down
8 changes: 8 additions & 0 deletions crates/ruff_cpp_spo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ pub struct CppFunction {
/// obtain by overloading. See `examples/harvest_openggs.rs` for the mapping
/// that omits it, and why omission is a truer encoding than either value.
pub is_static: bool,
/// The file that holds this DEFINITION, from the cursor's own location.
///
/// The walker reports a function in every translation unit that includes
/// its definition (an inline header function reaches every `.cpp`), so the
/// TU being walked is not where the function lives, and a text search for
/// its name finds declarations and call sites first. `None` only when
/// libclang reports no file for the cursor.
pub file: Option<std::path::PathBuf>,
}

#[cfg(feature = "libclang")]
Expand Down
Loading