Skip to content

Commit ca624d2

Browse files
authored
Merge pull request #126 from AdaWorldAPI/ccr-9f3b8075-74u47l-pgring-harvest
ruff_cpp_spo: harvest_pgring example (structure-only harvest of PG-Ring)
2 parents 6459b00 + 1d38081 commit ca624d2

6 files changed

Lines changed: 337 additions & 2 deletions

File tree

‎Cargo.toml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ license = "MIT"
2222

2323
[workspace.dependencies]
2424
char_str = { version = "0.0.4", default-features = false, features = ["std"] }
25-
ruff = { version = "0.17.0", path = "crates/ruff" }
25+
# Internal release train: keep published tarball dependency versions
26+
# identical to these local paths; ^0.17.0 allows 0.17.1 and mixes 0.0.18
27+
# ruff_* crates with this workspace's 0.0.17 ABI during cargo publish --dry-run.
28+
ruff = { version = "=0.17.0", path = "crates/ruff" }
2629
ruff_annotate_snippets = { version = "0.0.17", path = "crates/ruff_annotate_snippets" }
2730
ruff_cache = { version = "0.0.17", path = "crates/ruff_cache" }
2831
ruff_command_line = { version = "0.0.17", path = "crates/ruff_command_line" }
@@ -32,7 +35,7 @@ ruff_diagnostics = { version = "0.0.17", path = "crates/ruff_diagnostics" }
3235
ruff_formatter = { version = "0.0.17", path = "crates/ruff_formatter" }
3336
ruff_graph = { version = "0.0.17", path = "crates/ruff_graph" }
3437
ruff_index = { version = "0.0.17", path = "crates/ruff_index" }
35-
ruff_linter = { version = "0.17.0", path = "crates/ruff_linter" }
38+
ruff_linter = { version = "=0.17.0", path = "crates/ruff_linter" }
3639
ruff_macros = { version = "0.0.17", path = "crates/ruff_macros" }
3740
ruff_markdown = { version = "0.0.17", path = "crates/ruff_markdown" }
3841
ruff_memory_usage = { version = "0.0.17", path = "crates/ruff_memory_usage" }

‎_typos.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ CPY = "CPY" # it's a Ruff rule category
3434
# P-code / R2IL opcode names, not prose. INT_SCARRY is signed-carry; the
3535
# hook's "scary" correction silently corrupts an emitted wire tag.
3636
scarry = "scarry"
37+
# `anc` is an intentional MeTTa predicate for ancestor in a recursive
38+
# Datalog-style test. Replacing it with `and` changes the program's meaning.
39+
anc = "anc"
3740
# Tesseract really does ship `src/ccstruct/statistc.h` under that spelling.
3841
# "Correcting" it points the harvest docs at a file that does not exist.
3942
statistc = "statistc"

‎crates/ruff_cpp_spo/Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ required-features = ["libclang"]
5757
name = "harvest_ladybug"
5858
required-features = ["libclang"]
5959

60+
[[example]]
61+
name = "harvest_pgring"
62+
required-features = ["libclang"]
63+
6064
[dev-dependencies]
6165
# End-to-end pipeline test only (harvest -> reassemble -> ruff_cpp_codegen emit).
6266
# The harvester does NOT depend on the codegen at runtime — this is the test
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
//! Harvest the PG-Ring graph query engine (Gómez-Brandón, Hogan, Navarro,
2+
//! ISWC 2026, arXiv 2608.03840) into a structural manifest: classes, their
3+
//! fields and method signatures, and the call graph of every free function
4+
//! and out-of-line method definition.
5+
//!
6+
//! Methods defined inside a class body (inline, the common case in a
7+
//! header-only library) appear only as signatures: the function walker does
8+
//! not descend into class bodies, so their callees are not in the call graph.
9+
//!
10+
//! PG-Ring is GPL-3.0. This example reads the source only to extract the
11+
//! STRUCTURE (which iterators exist, which methods they offer, who calls whom)
12+
//! that a clean-room reimplementation is designed against. No code is copied,
13+
//! and the corpus is never vendored here.
14+
//!
15+
//! Run:
16+
//! ```sh
17+
//! PGRING_SRC=/home/user/adaworldapi/pg-ring \
18+
//! SDSL_INCLUDE=/home/user/adaworldapi/sdsl-lite/include \
19+
//! LIBCLANG_PATH=/usr/lib/llvm-18/lib \
20+
//! cargo run -p ruff_cpp_spo --features libclang --example harvest_pgring
21+
//! ```
22+
//!
23+
//! Outputs (directory `OUT_DIR`, default `/tmp/pgring_harvest`):
24+
//! - `manifest.ndjson`: the SPO triples of every class (shared IR).
25+
//! - `<file>.txt`: one readable summary per source file: classes with bases,
26+
//! fields and method signatures, then every function defined in that file
27+
//! with its callees.
28+
//! - `_diagnostics.txt`: translation units with parse errors. A class is taken
29+
//! from such a unit only when no unit without errors declares it, and the
30+
//! classes taken that way are listed.
31+
32+
#![expect(
33+
clippy::print_stderr,
34+
reason = "manifest-emission CLI example (mirrors harvest_textord)"
35+
)]
36+
37+
use std::collections::BTreeSet;
38+
use std::fmt::Write as _;
39+
use std::path::{Path, PathBuf};
40+
41+
use ruff_cpp_spo::{
42+
CppClass, CppFunction, Declaration, NAMESPACE, model_from_class,
43+
walk_free_functions_with_diagnostics, walk_tu_with_diagnostics,
44+
};
45+
use ruff_spo_triplet::{ModelGraph, expand, to_ndjson};
46+
47+
/// Every `.hpp`, `.h`, `.cpp` and `.c` under `dir`, recursively, sorted.
48+
fn sources(dir: &Path, out: &mut Vec<PathBuf>) {
49+
let Ok(entries) = std::fs::read_dir(dir) else {
50+
return;
51+
};
52+
let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
53+
paths.sort();
54+
for p in paths {
55+
if p.is_dir() {
56+
sources(&p, out);
57+
} else if p
58+
.extension()
59+
.is_some_and(|e| e == "hpp" || e == "h" || e == "cpp" || e == "c")
60+
{
61+
out.push(p);
62+
}
63+
}
64+
}
65+
66+
fn summarize_class(c: &CppClass, s: &mut String) {
67+
let _ = writeln!(s, "class {}", c.qualified_name());
68+
for d in &c.declarations {
69+
match d {
70+
Declaration::Base(b) => {
71+
let _ = writeln!(s, " base {}", b.name);
72+
}
73+
Declaration::Field(f) => {
74+
let _ = writeln!(s, " field {}: {}", f.name, f.type_name);
75+
}
76+
Declaration::Method(m) => {
77+
let ret = m.return_type.as_deref().unwrap_or("void");
78+
let flags = if m.is_static { " static" } else { "" };
79+
let _ = writeln!(
80+
s,
81+
" method {}({}) -> {ret}{flags}",
82+
m.name,
83+
m.param_types.join(", ")
84+
);
85+
}
86+
Declaration::Template(t) => {
87+
let _ = writeln!(s, " template {:?} {}", t.kind, t.name);
88+
}
89+
_ => {}
90+
}
91+
}
92+
}
93+
94+
/// Library namespaces pulled in through `#include` that are not PG-Ring's own
95+
/// code. Their declarations reach every translation unit; keeping them would
96+
/// bury the engine under SDSL and the standard library.
97+
const FOREIGN: [&str; 3] = ["sdsl", "std", "__gnu_cxx"];
98+
99+
fn is_foreign(qualified: &str) -> bool {
100+
qualified.starts_with("__")
101+
|| FOREIGN
102+
.iter()
103+
.any(|ns| qualified == *ns || qualified.starts_with(&format!("{ns}::")))
104+
}
105+
106+
/// The source file that DEFINES `name` (a `class name` / `struct name` not
107+
/// followed by `;`), found by text scan. libclang reports a class in every
108+
/// translation unit that includes it, so the walk alone cannot attribute it.
109+
fn defining_file<'a>(name: &str, texts: &'a [(String, String)]) -> Option<&'a str> {
110+
texts.iter().find_map(|(rel, text)| {
111+
["class ", "struct "].iter().find_map(|kw| {
112+
let needle = format!("{kw}{name}");
113+
text.match_indices(&needle).find_map(|(i, _)| {
114+
let rest = &text[i + needle.len()..];
115+
let next = rest.chars().next()?;
116+
if next.is_alphanumeric() || next == '_' {
117+
return None;
118+
}
119+
let decl_end = rest.find(['{', ';'])?;
120+
(rest.as_bytes()[decl_end] == b'{').then_some(rel.as_str())
121+
})
122+
})
123+
})
124+
}
125+
126+
/// The identity of a harvested function. An out-of-line method is named
127+
/// `Class::method` by the walker and also carries `Class` as its last scope
128+
/// component; joining the two would repeat the class (`Class::Class::method`).
129+
fn function_identity(func: &CppFunction) -> String {
130+
match func.namespace.split_last() {
131+
Some((owner, outer)) if func.name.starts_with(&format!("{owner}::")) => {
132+
if outer.is_empty() {
133+
func.name.clone()
134+
} else {
135+
format!("{}::{}", outer.join("::"), func.name)
136+
}
137+
}
138+
_ => func.qualified_name(),
139+
}
140+
}
141+
142+
fn main() -> Result<(), Box<dyn std::error::Error>> {
143+
let root = PathBuf::from(
144+
std::env::var("PGRING_SRC").unwrap_or_else(|_| "/home/user/adaworldapi/pg-ring".into()),
145+
);
146+
let sdsl = std::env::var("SDSL_INCLUDE")
147+
.unwrap_or_else(|_| "/home/user/adaworldapi/sdsl-lite/include".into());
148+
let out_dir =
149+
PathBuf::from(std::env::var("OUT_DIR").unwrap_or_else(|_| "/tmp/pgring_harvest".into()));
150+
std::fs::create_dir_all(&out_dir)?;
151+
152+
let mut files = Vec::new();
153+
sources(&root.join("include"), &mut files);
154+
sources(&root.join("src"), &mut files);
155+
if files.is_empty() {
156+
return Err(format!("no sources under {}; set PGRING_SRC", root.display()).into());
157+
}
158+
159+
let args = [
160+
"-std=c++17".to_string(),
161+
"-x".to_string(),
162+
"c++".to_string(),
163+
format!("-I{}", root.join("include").display()),
164+
format!("-I{}", root.join("libCSD").display()),
165+
format!("-I{sdsl}"),
166+
];
167+
168+
let texts: Vec<(String, String)> = files
169+
.iter()
170+
.filter_map(|f| {
171+
let rel = f.strip_prefix(&root).unwrap_or(f).display().to_string();
172+
std::fs::read_to_string(f).ok().map(|t| (rel, t))
173+
})
174+
.collect();
175+
176+
let mut all: Vec<CppClass> = Vec::new();
177+
let mut seen: BTreeSet<String> = BTreeSet::new();
178+
// Classes seen only in translation units with parse errors: a partial AST,
179+
// used only for a class that no clean unit declares.
180+
let mut from_errored: Vec<CppClass> = Vec::new();
181+
let mut functions: Vec<(String, String)> = Vec::new();
182+
let mut seen_fn: BTreeSet<String> = BTreeSet::new();
183+
let mut diag_report = String::new();
184+
for f in &files {
185+
let rel = f.strip_prefix(&root).unwrap_or(f).display().to_string();
186+
match walk_tu_with_diagnostics(f, &args) {
187+
Ok((classes, diags)) => {
188+
if !diags.is_empty() {
189+
let _ = writeln!(diag_report, "{rel}: {} error(s)", diags.len());
190+
for d in diags.iter().take(3) {
191+
let _ = writeln!(diag_report, " {d}");
192+
}
193+
}
194+
for c in classes {
195+
if is_foreign(&c.qualified_name()) {
196+
continue;
197+
}
198+
if !diags.is_empty() {
199+
from_errored.push(c);
200+
} else if seen.insert(c.qualified_name()) {
201+
all.push(c);
202+
}
203+
}
204+
}
205+
Err(e) => {
206+
let _ = writeln!(diag_report, "{rel}: class walk failed: {e}");
207+
}
208+
}
209+
match walk_free_functions_with_diagnostics(f, &args) {
210+
Ok((fns, _)) => {
211+
for func in fns {
212+
let q = function_identity(&func);
213+
let sig = format!("{q}({})", func.param_types.join(", "));
214+
if is_foreign(&q) || !seen_fn.insert(sig.clone()) {
215+
continue;
216+
}
217+
let ret = func.return_type.as_deref().unwrap_or("void");
218+
// Callees of a method are spelled `Class::method` (no
219+
// namespace); print that spelling too so a definition
220+
// joins its call edges.
221+
let key = if func.name == q {
222+
String::new()
223+
} else {
224+
format!(" callee name: {}\n", func.name)
225+
};
226+
let line = format!(
227+
"fn {sig} -> {ret}\n{key} calls: {}\n",
228+
func.calls.join(", ")
229+
);
230+
// The definition's own location attributes it; the walk
231+
// sees an inline header function in every including TU.
232+
let home = func
233+
.file
234+
.as_deref()
235+
.map(|p| p.strip_prefix(&root).unwrap_or(p).display().to_string())
236+
.unwrap_or_else(|| rel.clone());
237+
functions.push((home, line));
238+
}
239+
}
240+
Err(e) => {
241+
let _ = writeln!(diag_report, "{rel}: function walk failed: {e}");
242+
}
243+
}
244+
}
245+
246+
let mut recovered: Vec<String> = Vec::new();
247+
for c in from_errored {
248+
if seen.insert(c.qualified_name()) {
249+
recovered.push(c.qualified_name());
250+
all.push(c);
251+
}
252+
}
253+
let _ = writeln!(
254+
diag_report,
255+
"\nclasses taken from units with parse errors (partial AST): {recovered:?}"
256+
);
257+
258+
for (rel, _) in &texts {
259+
let mut s = format!("# {rel}\n");
260+
for c in &all {
261+
if defining_file(&c.name, &texts) == Some(rel.as_str()) {
262+
summarize_class(c, &mut s);
263+
}
264+
}
265+
for (home, line) in &functions {
266+
if home == rel {
267+
s.push_str(line);
268+
}
269+
}
270+
std::fs::write(out_dir.join(format!("{}.txt", rel.replace('/', "__"))), &s)?;
271+
}
272+
let unattributed: Vec<&str> = all
273+
.iter()
274+
.filter(|c| defining_file(&c.name, &texts).is_none())
275+
.map(|c| c.name.as_str())
276+
.collect();
277+
let _ = writeln!(
278+
diag_report,
279+
"\nclasses with no defining file found: {unattributed:?}"
280+
);
281+
std::fs::write(out_dir.join("_diagnostics.txt"), &diag_report)?;
282+
let n_functions = functions.len();
283+
let warned = diag_report
284+
.lines()
285+
.filter(|l| l.contains(" error(s)"))
286+
.count();
287+
288+
let mut graph = ModelGraph::new(NAMESPACE);
289+
for c in &all {
290+
graph.models.push(model_from_class(c));
291+
}
292+
let triples = expand(&graph);
293+
std::fs::write(out_dir.join("manifest.ndjson"), to_ndjson(&triples))?;
294+
eprintln!(
295+
"[harvest] {} files, {} unique classes, {} function definitions, {} files with parse errors, {} triples -> {}",
296+
files.len(),
297+
all.len(),
298+
n_functions,
299+
warned,
300+
triples.len(),
301+
out_dir.display()
302+
);
303+
Ok(())
304+
}

‎crates/ruff_cpp_spo/src/clang_walker.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,13 +320,18 @@ fn collect_functions(entity: &Entity, out: &mut Vec<CppFunction>) {
320320
// member question and is false for a free function, so the
321321
// storage class is the right source here.
322322
let is_static = child.get_storage_class() == Some(StorageClass::Static);
323+
let file = child
324+
.get_location()
325+
.and_then(|l| l.get_file_location().file)
326+
.map(|f| f.get_path());
323327
out.push(CppFunction {
324328
namespace: enclosing_scopes(&child),
325329
name,
326330
calls,
327331
return_type,
328332
param_types,
329333
is_static,
334+
file,
330335
});
331336
}
332337
}
@@ -2378,6 +2383,14 @@ void Widget::dispatch() {
23782383
.unwrap_or_else(|| panic!("Widget::dispatch missing; got {funcs:?}"));
23792384
assert_eq!(dispatch.namespace, vec!["Widget".to_string()]);
23802385

2386+
// The definition's own file is recorded, so a caller can attribute it
2387+
// without searching text for the name.
2388+
assert_eq!(
2389+
dispatch.file.as_deref().and_then(|f| f.file_name()),
2390+
path.file_name(),
2391+
"the definition must carry the file it is defined in"
2392+
);
2393+
23812394
// Fix 1 + codex P2 (#57): in-TU dispatch between two out-of-line
23822395
// methods resolves CLASS-QUALIFIED, so same-named methods of
23832396
// different classes can never collapse in the call graph.

‎crates/ruff_cpp_spo/src/lib.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,14 @@ pub struct CppFunction {
184184
/// obtain by overloading. See `examples/harvest_openggs.rs` for the mapping
185185
/// that omits it, and why omission is a truer encoding than either value.
186186
pub is_static: bool,
187+
/// The file that holds this DEFINITION, from the cursor's own location.
188+
///
189+
/// The walker reports a function in every translation unit that includes
190+
/// its definition (an inline header function reaches every `.cpp`), so the
191+
/// TU being walked is not where the function lives, and a text search for
192+
/// its name finds declarations and call sites first. `None` only when
193+
/// libclang reports no file for the cursor.
194+
pub file: Option<std::path::PathBuf>,
187195
}
188196

189197
#[cfg(feature = "libclang")]

0 commit comments

Comments
 (0)