1
0
Fork 0
mirror of https://github.com/imjasonh/rustvulncheck synced 2026-07-08 04:08:07 +00:00

Delete regex-based symbol extraction entirely

Remove extract_symbols_regex and all its helpers (parse_impl_type,
qualify_fn_name, is_std_trait, skip_balanced_angles, find_top_level_keyword)
plus all 17 regex-specific tests. The AST path is the only extraction
method now — files that fail to parse are skipped with a warning rather
than falling back to brittle regex matching.

-853 lines of regex code that was the source of every symbol extraction
bug (for leaks, where leaks, trait-as-type, duplicates, <method>
placeholders).

https://claude.ai/code/session_01P1LKP6aqGt68rQAXrF6kSE
This commit is contained in:
Claude 2026-03-25 15:54:21 +00:00
parent 62e9fcd8ff
commit d64faab2c9
No known key found for this signature in database
3 changed files with 61 additions and 854 deletions

View file

@ -13,7 +13,7 @@ use crate::diff_analyzer::{ChangeType, VulnerableSymbol};
/// A function symbol extracted from a parsed AST.
#[derive(Debug, Clone)]
struct FnSymbol {
pub struct FnSymbol {
/// Fully qualified name: `module::Type::method` or `module::free_fn`
qualified_name: String,
/// Hash of the function body tokens for detecting modifications

View file

@ -1,6 +1,9 @@
//! Analyze unified diffs to extract modified Rust function signatures.
use regex::Regex;
//! Extract modified Rust function symbols from GitHub commit diffs.
//!
//! Uses AST parsing via `syn` to precisely identify Added, Modified, and
//! Deleted function symbols. For each changed `.rs` file, fetches the full
//! before/after contents from GitHub, parses both with `syn::parse_file`,
//! and diffs the function sets.
use crate::github::PatchDiff;
@ -22,18 +25,35 @@ pub enum ChangeType {
Deleted,
}
/// Extract function signatures using AST parsing where possible, with regex fallback.
/// Extract function signatures by fetching full file contents and AST-diffing.
///
/// For each changed `.rs` file, fetches the full before/after contents from GitHub,
/// parses them with `syn`, and diffs the function sets to find Added/Modified/Deleted
/// symbols. Falls back to regex-based extraction per-file when AST parsing fails
/// (syntax errors, macro-heavy files) or when file contents can't be fetched.
/// For each changed `.rs` file in the diff, fetches the before (parent) and
/// after (commit) versions from GitHub, parses both with `syn`, and diffs
/// the function sets. Files that fail to parse are skipped with a warning.
pub fn extract_symbols(
diff: &PatchDiff,
gh: &crate::github::GithubClient,
) -> Vec<VulnerableSymbol> {
let mut all_symbols = Vec::new();
let has_metadata = !diff.owner.is_empty() && !diff.repo.is_empty();
let parent_sha = match &diff.parent_sha {
Some(sha) => sha,
None => {
eprintln!(
"Warning: no parent SHA for commit {}, cannot diff files",
&diff.commit_sha[..7.min(diff.commit_sha.len())]
);
return all_symbols;
}
};
if diff.owner.is_empty() || diff.repo.is_empty() {
eprintln!(
"Warning: missing owner/repo metadata for commit {}, cannot fetch files",
&diff.commit_sha[..7.min(diff.commit_sha.len())]
);
return all_symbols;
}
for file_patch in &diff.files {
if is_test_file(&file_patch.filename) {
@ -42,406 +62,48 @@ pub fn extract_symbols(
let module_path = file_path_to_module(&file_patch.filename);
// Try AST-based extraction if we have the metadata to fetch files
if has_metadata {
if let Some(parent) = &diff.parent_sha {
let before = gh
.fetch_file_contents(
&diff.owner,
&diff.repo,
&file_patch.filename,
parent,
)
.ok()
.flatten();
let before = gh
.fetch_file_contents(&diff.owner, &diff.repo, &file_patch.filename, parent_sha)
.ok()
.flatten();
let after = gh
.fetch_file_contents(
&diff.owner,
&diff.repo,
&file_patch.filename,
&diff.commit_sha,
)
.ok()
.flatten();
let after = gh
.fetch_file_contents(
&diff.owner,
&diff.repo,
&file_patch.filename,
&diff.commit_sha,
)
.ok()
.flatten();
if let Some(ast_symbols) = crate::ast_differ::ast_diff_symbols(
before.as_deref(),
after.as_deref(),
&module_path,
&file_patch.filename,
) {
if !ast_symbols.is_empty() || before.is_some() || after.is_some() {
// AST succeeded — use its results (even if empty means no changes)
all_symbols.extend(ast_symbols);
continue;
}
}
match crate::ast_differ::ast_diff_symbols(
before.as_deref(),
after.as_deref(),
&module_path,
&file_patch.filename,
) {
Some(symbols) => {
all_symbols.extend(symbols);
}
None => {
eprintln!(
" Warning: AST parsing failed for {}, skipping",
file_patch.filename
);
}
}
// Fallback: regex-based extraction for this file
let file_diff = PatchDiff {
commit_sha: diff.commit_sha.clone(),
owner: String::new(),
repo: String::new(),
parent_sha: None,
files: vec![file_patch.clone()],
};
all_symbols.extend(extract_symbols_regex(&file_diff));
}
all_symbols
}
/// Regex-based fallback for extracting function signatures from unified diffs.
///
/// Used when AST parsing fails or file contents can't be fetched.
pub(crate) fn extract_symbols_regex(diff: &PatchDiff) -> Vec<VulnerableSymbol> {
let mut symbols: Vec<VulnerableSymbol> = Vec::new();
let fn_decl_re = Regex::new(
r"(?:pub\s+(?:\(crate\)\s+)?)?(?:unsafe\s+)?(?:async\s+)?fn\s+([a-zA-Z_][a-zA-Z0-9_]*)",
)
.unwrap();
let hunk_header_re = Regex::new(r"^@@.*@@\s*(.*)$").unwrap();
for file_patch in &diff.files {
// Skip test files — they aren't callable library code
if is_test_file(&file_patch.filename) {
continue;
}
let module_path = file_path_to_module(&file_patch.filename);
let mut current_context_fn: Option<String> = None;
let mut current_impl_type: Option<String> = None;
let mut seen_test_attr = false;
for line in file_patch.patch.lines() {
// Track hunk headers - they often show the enclosing function
if let Some(caps) = hunk_header_re.captures(line) {
let context = &caps[1];
let has_fn = fn_decl_re.captures(context);
let has_impl = parse_impl_type(context);
if let Some(impl_type) = has_impl {
current_impl_type = Some(impl_type);
}
if let Some(fcaps) = has_fn {
// Hunk header explicitly names a fn — use it
current_context_fn = Some(fcaps[1].to_string());
}
// If the hunk header shows only `impl` (no fn), do NOT clear
// current_context_fn. Git picks the nearest enclosing scope
// line for hunk headers, and consecutive hunks in the same
// long method can alternate between showing `fn foo` and
// `impl Foo`. Clearing would silently drop body-only changes.
// current_context_fn will be properly reset when we encounter
// an actual fn declaration in context/changed lines.
continue;
}
// Track impl blocks in context lines
if line.starts_with(' ') || line.starts_with('+') || line.starts_with('-') {
let content = &line[1..];
if let Some(impl_type) = parse_impl_type(content) {
if !content.trim_start().starts_with("//") {
current_impl_type = Some(impl_type);
// In a context/changed line showing impl, we're entering
// a new impl block — clear the fn context.
current_context_fn = None;
}
}
// Track #[test] and #[cfg(test)] attributes
let trimmed = content.trim();
if trimmed == "#[test]" || trimmed.starts_with("#[cfg(test)]") {
seen_test_attr = true;
}
}
// Look for fn declarations in changed lines
let is_added = line.starts_with('+') && !line.starts_with("+++");
let is_removed = line.starts_with('-') && !line.starts_with("---");
if (is_added || is_removed) && line.contains("fn ") {
let content = &line[1..];
// Skip comments
if content.trim_start().starts_with("//") || content.trim_start().starts_with("*")
{
continue;
}
if let Some(caps) = fn_decl_re.captures(content) {
let fn_name = &caps[1];
// Skip test functions (marked with #[test] or named test_*)
if seen_test_attr || fn_name.starts_with("test_") {
seen_test_attr = false;
continue;
}
let qualified = qualify_fn_name(&module_path, &current_impl_type, fn_name);
// Dedup: if the same fn is both deleted (-) and added (+), it's Modified
if let Some(existing) = symbols.iter_mut().find(|s| s.function == qualified) {
// Upgrade to Modified when we see both + and - for the same fn
existing.change_type = ChangeType::Modified;
} else {
let change_type = if is_added {
ChangeType::Added
} else {
ChangeType::Deleted
};
symbols.push(VulnerableSymbol {
file: file_patch.filename.clone(),
function: qualified,
change_type,
});
}
}
} else if is_added || is_removed {
// Changed line inside a function body
let content = &line[1..];
if content.trim().is_empty() || content.trim_start().starts_with("//") {
continue;
}
if let Some(ref ctx_fn) = current_context_fn {
let qualified =
qualify_fn_name(&module_path, &current_impl_type, ctx_fn);
if !symbols.iter().any(|s| s.function == qualified) {
symbols.push(VulnerableSymbol {
file: file_patch.filename.clone(),
function: qualified,
change_type: ChangeType::Modified,
});
}
} else if current_impl_type.is_some() {
// We have code changes inside an impl block but the fn
// declaration is too far above for git to include it in
// the hunk header or context. Still record the change at
// the impl-type level rather than silently dropping it.
let qualified =
qualify_fn_name(&module_path, &current_impl_type, "<method>");
if !symbols.iter().any(|s| s.function == qualified) {
symbols.push(VulnerableSymbol {
file: file_patch.filename.clone(),
function: qualified,
change_type: ChangeType::Modified,
});
}
}
}
// Update current function context from context/added lines
if !line.starts_with('-') {
let content = if line.starts_with('+') || line.starts_with(' ') {
&line[1..]
} else {
line
};
if content.contains("fn ") && !content.trim_start().starts_with("//") {
if let Some(caps) = fn_decl_re.captures(content) {
let fn_name = &caps[1];
if seen_test_attr || fn_name.starts_with("test_") {
// Don't track test functions as context
seen_test_attr = false;
current_context_fn = None;
} else {
current_context_fn = Some(fn_name.to_string());
}
}
}
}
}
}
symbols
}
/// Parse an `impl` line and extract the implementing type name.
///
/// Handles nested generics correctly by counting `<>` brackets instead of
/// using a greedy regex. This avoids the `for` and `where` keyword leaks
/// that occur when `<.*>` over-matches nested generics like
/// `impl<T> From<Py<T>> for PyObject where T: AsRef<PyAny>`.
fn parse_impl_type(s: &str) -> Option<String> {
// Find the `impl` keyword (must be word-bounded)
let impl_pos = s.find("impl")?;
// Make sure `impl` is at a word boundary (not part of "implement" etc.)
if impl_pos > 0 {
let prev = s.as_bytes()[impl_pos - 1];
if prev.is_ascii_alphanumeric() || prev == b'_' {
return None;
}
}
let after_impl = &s[impl_pos + 4..];
// Check trailing boundary: next char must be <, whitespace, or end of string
if let Some(next) = after_impl.as_bytes().first() {
if next.is_ascii_alphanumeric() || *next == b'_' {
return None;
}
}
// Skip impl's own generic params (balanced <>)
let rest = skip_balanced_angles(after_impl);
let rest = rest.trim_start();
if rest.is_empty() {
return None;
}
// Now rest is either "Trait<...> for Type<...> ..." or "Type<...> ..."
// Find " for " at the top level (not inside <>) to split trait from type
let has_for = find_top_level_keyword(rest, " for ");
let type_str = if let Some(pos) = has_for {
rest[pos + 5..].trim_start()
} else {
rest
};
// Stop at `where` clause (top-level) or `{`
let type_str = if let Some(pos) = find_top_level_keyword(type_str, " where ") {
&type_str[..pos]
} else {
type_str
};
let type_str = type_str.split('{').next().unwrap_or(type_str);
let type_str = type_str.trim();
if type_str.is_empty() {
return None;
}
// When there's no `for` keyword, we might be seeing a trait impl where
// `for Type` was cut off by the hunk header (e.g. `impl From<X>` without
// the ` for MyType` part). If the captured "type" is a well-known trait
// name, it's almost certainly wrong — return None rather than recording
// the trait name as the implementing type.
if has_for.is_none() {
let base_name = type_str.split('<').next().unwrap_or(type_str).trim();
if is_std_trait(base_name) {
return None;
}
}
Some(type_str.to_string())
}
/// Returns true if the name is a well-known standard library trait that
/// would never be an implementing type in `impl Trait { ... }`.
/// Used to detect truncated hunk headers like `impl From<X>` where
/// `for ActualType` was cut off.
fn is_std_trait(name: &str) -> bool {
matches!(
name,
"From"
| "Into"
| "TryFrom"
| "TryInto"
| "AsRef"
| "AsMut"
| "Borrow"
| "BorrowMut"
| "Clone"
| "Copy"
| "Debug"
| "Default"
| "Deref"
| "DerefMut"
| "Display"
| "Drop"
| "Eq"
| "Fn"
| "FnMut"
| "FnOnce"
| "Hash"
| "Index"
| "IndexMut"
| "IntoIterator"
| "Iterator"
| "Ord"
| "PartialEq"
| "PartialOrd"
| "Read"
| "Write"
| "Seek"
| "ToString"
| "Add"
| "AddAssign"
| "Sub"
| "SubAssign"
| "Mul"
| "MulAssign"
| "Div"
| "DivAssign"
| "Rem"
| "RemAssign"
| "Neg"
| "Not"
| "BitAnd"
| "BitOr"
| "BitXor"
| "Shl"
| "Shr"
| "Serialize"
| "Deserialize"
| "Future"
| "Stream"
| "Sink"
| "Unpin"
| "Send"
| "Sync"
)
}
/// Skip past balanced `<...>` generics at the start of a string.
/// Returns the remainder after the closing `>`, or the original string
/// if it doesn't start with `<`.
fn skip_balanced_angles(s: &str) -> &str {
if !s.starts_with('<') {
return s;
}
let mut depth = 0;
for (i, c) in s.char_indices() {
match c {
'<' => depth += 1,
'>' => {
depth -= 1;
if depth == 0 {
return &s[i + 1..];
}
}
_ => {}
}
}
s // unbalanced — return as-is
}
/// Find a keyword (like " for " or " where ") at the top level of a string,
/// i.e. not nested inside `<>` brackets.
fn find_top_level_keyword(s: &str, keyword: &str) -> Option<usize> {
let mut depth: i32 = 0;
let bytes = s.as_bytes();
for i in 0..bytes.len() {
match bytes[i] {
b'<' => depth += 1,
b'>' => {
if depth > 0 {
depth -= 1;
}
}
_ if depth == 0 && s[i..].starts_with(keyword) => {
return Some(i);
}
_ => {}
}
}
None
}
/// Convert a file path like `src/http/request.rs` to a module path like `http::request`.
///
/// Handles workspace layouts like `crates/foo/src/bar.rs` → `foo::bar`
/// and top-level `src/bar.rs` → `bar`. Replaces hyphens with underscores
/// to match Rust crate naming conventions.
fn file_path_to_module(path: &str) -> String {
pub(crate) fn file_path_to_module(path: &str) -> String {
// Split into components
let parts: Vec<&str> = path.split('/').collect();
@ -483,7 +145,7 @@ fn file_path_to_module(path: &str) -> String {
/// Returns true if the file path looks like a non-library file
/// (tests, examples, benchmarks, build scripts) that downstream
/// code wouldn't call.
fn is_test_file(path: &str) -> bool {
pub(crate) fn is_test_file(path: &str) -> bool {
let parts: Vec<&str> = path.split('/').collect();
// Any path component named tests, test, examples, benches, or fuzz
if parts.iter().any(|&p| {
@ -504,23 +166,6 @@ fn is_test_file(path: &str) -> bool {
false
}
/// Build a qualified function name from module path, optional impl type, and fn name.
fn qualify_fn_name(module: &str, impl_type: &Option<String>, fn_name: &str) -> String {
let type_part = impl_type.as_ref().map(|ty| {
// Clean up generic parameters for the type name
ty.split('<').next().unwrap_or(ty).trim().to_string()
});
// Build path parts, skipping empty segments to avoid leading `::`
let parts: Vec<&str> = [Some(module), type_part.as_deref(), Some(fn_name)]
.into_iter()
.flatten()
.filter(|s| !s.is_empty())
.collect();
parts.join("::")
}
#[cfg(test)]
mod tests {
use super::*;
@ -530,33 +175,17 @@ mod tests {
assert_eq!(file_path_to_module("src/http/request.rs"), "http::request");
assert_eq!(file_path_to_module("src/lib.rs"), "");
assert_eq!(file_path_to_module("src/net/tcp/mod.rs"), "net::tcp");
// Workspace crate paths
assert_eq!(
file_path_to_module("crates/algorithms/sha3/src/simd/avx2.rs"),
"sha3::simd::avx2"
);
// Hyphens become underscores
assert_eq!(
file_path_to_module("pyo3-ffi/src/object.rs"),
"pyo3_ffi::object"
);
// src/lib.rs should produce empty module path
assert_eq!(file_path_to_module("src/lib.rs"), "");
}
#[test]
fn test_qualify_fn_name_empty_module() {
// When module is empty (src/lib.rs), no leading ::
let empty = "".to_string();
let impl_ty = Some("Context".to_string());
assert_eq!(qualify_fn_name(&empty, &impl_ty, "seal"), "Context::seal");
assert_eq!(qualify_fn_name(&empty, &None, "main"), "main");
assert_eq!(
qualify_fn_name("foo::bar", &impl_ty, "baz"),
"foo::bar::Context::baz"
);
}
#[test]
fn test_is_test_file() {
assert!(is_test_file("tests/integration.rs"));
@ -570,414 +199,4 @@ mod tests {
assert!(!is_test_file("src/lib.rs"));
assert!(!is_test_file("src/http/request.rs"));
}
#[test]
fn test_consecutive_hunks_preserve_fn_context() {
// When consecutive hunks are in the same function and hunk 1 header
// shows `fn foo`, hunk 2 (with `impl` header) should preserve context.
let diff = PatchDiff::for_test("abc123",
vec![crate::github::FilePatch {
filename: "src/keccak/xof.rs".to_string(),
patch: concat!(
"@@ -100,6 +100,8 @@ pub(crate) fn squeeze(out: &mut [u8]) {\n",
" let x = 1;\n",
"+ let y = 2;\n",
" }\n",
"@@ -200,10 +202,12 @@ impl<const RATE: usize> KeccakXofState<RATE> {\n",
" let blocks = out_len / RATE;\n",
"- for i in 0..blocks {\n",
"+ for i in 1..blocks {\n",
" }\n",
)
.to_string(),
}],
);
let symbols = extract_symbols_regex(&diff);
assert!(
symbols.iter().any(|s| s.function.contains("squeeze")),
"Expected squeeze to be found, got: {:?}",
symbols
);
}
#[test]
fn test_impl_body_changes_without_fn_context() {
// Reproduces the real xof.rs scenario: BOTH hunk headers show `impl`,
// fn declaration is too far above for git to include. Changes should
// still be captured at the impl-type level.
let diff = PatchDiff::for_test("6bbe15ec",
vec![crate::github::FilePatch {
filename: "crates/algorithms/sha3/src/generic_keccak/xof.rs".to_string(),
patch: concat!(
"@@ -290,6 +290,12 @@ impl<const PARALLEL_LANES: usize, const RATE: usize> KeccakState<PARALLEL_LANES>\n",
" /// Squeeze\n",
"+/// Note that calling squeeze multiple times will only give correct\n",
"+/// output if all sqeezed chunks are RATE bytes long.\n",
" #[hax_lib::attributes]\n",
"@@ -316,42 +322,38 @@ impl<const RATE: usize, STATE: KeccakItem<1>> KeccakXofState<1, RATE, STATE> {\n",
" self.inner.keccakf1600();\n",
" }\n",
" \n",
"- if out_len <= RATE {\n",
"- self.inner.squeeze::<RATE>(out, 0, out_len);\n",
"+ if out_len > 0 {\n",
"+ let blocks = out_len / RATE;\n",
"+ self.inner.squeeze::<RATE>(out, 0, RATE);\n",
"+ for i in 1..blocks {\n",
" }\n",
)
.to_string(),
}],
);
let symbols = extract_symbols_regex(&diff);
assert!(
!symbols.is_empty(),
"Expected symbols from impl body changes, got none"
);
assert!(
symbols.iter().any(|s| s.function.contains("KeccakXofState")),
"Expected KeccakXofState in symbol, got: {:?}",
symbols
);
}
#[test]
fn test_extract_fn_from_diff() {
let diff = PatchDiff::for_test("abc123",
vec![crate::github::FilePatch {
filename: "src/http/request.rs".to_string(),
patch: r#"@@ -10,6 +10,8 @@ impl Request {
pub fn parse(buf: &[u8]) -> Result<Self> {
- let old_code = true;
+ let new_code = true;
+ let extra = false;
}
"#
.to_string(),
}],
);
let symbols = extract_symbols_regex(&diff);
assert!(!symbols.is_empty());
assert!(symbols.iter().any(|s| s.function.contains("parse")));
}
#[test]
fn test_parse_impl_type_simple() {
assert_eq!(parse_impl_type("impl Request {"), Some("Request".into()));
assert_eq!(
parse_impl_type("impl<T> Vec<T> {"),
Some("Vec<T>".into())
);
}
#[test]
fn test_parse_impl_type_trait_for() {
// This was the core bug: greedy <.*> consumed across nested generics
assert_eq!(
parse_impl_type("impl<T> std::convert::From<Py<T>> for PyObject"),
Some("PyObject".into())
);
assert_eq!(
parse_impl_type("impl<T> From<T> for NotNan<T>"),
Some("NotNan<T>".into())
);
assert_eq!(
parse_impl_type("impl AddAssign for NotNan<f64>"),
Some("NotNan<f64>".into())
);
}
#[test]
fn test_parse_impl_type_where_clause() {
// where clause should be stripped
assert_eq!(
parse_impl_type("impl<T> From<Py<T>> for PyObject where T: AsRef<PyAny> {"),
Some("PyObject".into())
);
assert_eq!(
parse_impl_type("impl<T: HasAfEnum> Array<T> where T: Clone {"),
Some("Array<T>".into())
);
}
#[test]
fn test_parse_impl_type_not_in_word() {
// "implement" shouldn't match
assert_eq!(parse_impl_type("fn implement_thing()"), None);
}
#[test]
fn test_for_keyword_no_longer_leaks_into_symbol() {
// Reproduces the pyo3 RUSTSEC-2020-0074 bug
let diff = PatchDiff::for_test("abc123",
vec![crate::github::FilePatch {
filename: "src/instance.rs".to_string(),
patch: concat!(
"@@ -495,7 +495,9 @@ impl<T> std::convert::From<Py<T>> for PyObject\n",
" fn from(ob: Py<T>) -> Self {\n",
"- let old = ob.0;\n",
"+ unsafe { ob.into_non_null() }\n",
" }\n",
)
.to_string(),
}],
);
let symbols = extract_symbols_regex(&diff);
for s in &symbols {
assert!(
!s.function.contains("for "),
"Symbol should not contain 'for ': got '{}'",
s.function
);
}
// Should find the `from` function under `PyObject`, not `for PyObject`
assert!(
symbols.iter().any(|s| s.function.contains("PyObject::from")),
"Expected PyObject::from, got: {:?}",
symbols
);
}
#[test]
fn test_where_clause_no_longer_leaks_into_symbol() {
// Reproduces the lock_api RUSTSEC-2020-0070 bug
let diff = PatchDiff::for_test("abc123",
vec![crate::github::FilePatch {
filename: "lock_api/src/mutex.rs".to_string(),
patch: concat!(
"@@ -100,6 +100,8 @@ impl<R: RawMutex, T: ?Sized> Mutex<R, T> where R: Send {\n",
"- let old = self.0;\n",
"+ let new = self.0;\n",
)
.to_string(),
}],
);
let symbols = extract_symbols_regex(&diff);
for s in &symbols {
assert!(
!s.function.contains("where"),
"Symbol should not contain 'where': got '{}'",
s.function
);
}
assert!(
symbols
.iter()
.any(|s| s.function.contains("Mutex") && !s.function.contains("where")),
"Expected clean Mutex symbol, got: {:?}",
symbols
);
}
#[test]
fn test_test_functions_filtered_by_attribute() {
// #[test] functions in library source should be skipped
let diff = PatchDiff::for_test("abc123",
vec![crate::github::FilePatch {
filename: "src/pycell/impl_.rs".to_string(),
patch: concat!(
"@@ -100,6 +100,12 @@ impl PyCell {\n",
" pub fn new() -> Self {\n",
"- let old = 1;\n",
"+ let new = 2;\n",
" }\n",
"+ #[test]\n",
"+ fn test_inherited_size() {\n",
"+ assert_eq!(1, 1);\n",
"+ }\n",
)
.to_string(),
}],
);
let symbols = extract_symbols_regex(&diff);
assert!(
!symbols.iter().any(|s| s.function.contains("test_inherited")),
"test_ functions should be filtered out, got: {:?}",
symbols
);
// The real function should still be present
assert!(
symbols.iter().any(|s| s.function.contains("new")),
"Expected new() to be found, got: {:?}",
symbols
);
}
#[test]
fn test_test_functions_filtered_by_name_prefix() {
// test_ prefix functions should be filtered even without #[test] attribute
let diff = PatchDiff::for_test("abc123",
vec![crate::github::FilePatch {
filename: "src/lib.rs".to_string(),
patch: concat!(
"@@ -10,6 +10,8 @@\n",
"+fn test_combine_uuids() {\n",
"+ assert!(true);\n",
"+}\n",
"+pub fn real_function() {\n",
"+ do_work();\n",
"+}\n",
)
.to_string(),
}],
);
let symbols = extract_symbols_regex(&diff);
assert!(
!symbols.iter().any(|s| s.function.contains("test_combine")),
"test_ prefix functions should be filtered, got: {:?}",
symbols
);
assert!(
symbols.iter().any(|s| s.function.contains("real_function")),
"Non-test functions should be kept, got: {:?}",
symbols
);
}
#[test]
fn test_cfg_test_functions_filtered() {
// Functions after #[cfg(test)] should be filtered
let diff = PatchDiff::for_test("abc123",
vec![crate::github::FilePatch {
filename: "src/core.rs".to_string(),
patch: concat!(
"@@ -200,6 +200,10 @@\n",
" #[cfg(test)]\n",
"+fn helper_for_tests() {\n",
"+ setup();\n",
"+}\n",
)
.to_string(),
}],
);
let symbols = extract_symbols_regex(&diff);
assert!(
!symbols.iter().any(|s| s.function.contains("helper_for_tests")),
"#[cfg(test)] functions should be filtered, got: {:?}",
symbols
);
}
#[test]
fn test_duplicate_fn_deduped_as_modified() {
// When a fn is both removed (-) and added (+), it should appear once as Modified
let diff = PatchDiff::for_test("abc123",
vec![crate::github::FilePatch {
filename: "src/reader.rs".to_string(),
patch: concat!(
"@@ -10,6 +10,8 @@ impl Reader {\n",
"- pub fn open_mmap(path: &str) -> Self {\n",
"- let old = 1;\n",
"+ pub fn open_mmap(path: &Path) -> Result<Self> {\n",
"+ let new = 2;\n",
" }\n",
)
.to_string(),
}],
);
let symbols = extract_symbols_regex(&diff);
let open_mmap_syms: Vec<_> = symbols
.iter()
.filter(|s| s.function.contains("open_mmap"))
.collect();
assert_eq!(
open_mmap_syms.len(),
1,
"Expected exactly 1 open_mmap symbol (deduped), got {}: {:?}",
open_mmap_syms.len(),
open_mmap_syms
);
assert!(
matches!(open_mmap_syms[0].change_type, ChangeType::Modified),
"Expected Modified when fn is both deleted and added, got {:?}",
open_mmap_syms[0].change_type
);
}
#[test]
fn test_trait_name_not_captured_as_type() {
// When hunk header shows `impl From<X>` without `for Type`,
// the trait name should NOT be used as the implementing type
assert_eq!(
parse_impl_type("impl From<Error> {"),
None,
"From is a trait, not a type"
);
assert_eq!(
parse_impl_type("impl<T> Into<Vec<T>> {"),
None,
"Into is a trait, not a type"
);
assert_eq!(
parse_impl_type("impl TryFrom<String> {"),
None,
"TryFrom is a trait, not a type"
);
assert_eq!(
parse_impl_type("impl Default {"),
None,
"Default is a trait, not a type"
);
// But with `for`, the trait should be skipped and the type extracted
assert_eq!(
parse_impl_type("impl From<Error> for MyError {"),
Some("MyError".into())
);
// Regular types should still work
assert_eq!(
parse_impl_type("impl MyStruct {"),
Some("MyStruct".into())
);
}
#[test]
fn test_trait_as_type_not_in_symbols() {
// End-to-end: hunk header with trait impl truncated should not
// produce symbols with trait name as type
let diff = PatchDiff::for_test("abc123",
vec![crate::github::FilePatch {
filename: "src/util.rs".to_string(),
patch: concat!(
"@@ -10,6 +10,8 @@ impl From<Error>\n",
"- fn from(e: Error) -> Self {\n",
"+ fn from(e: Error) -> Self {\n",
"+ Self::new(e)\n",
" }\n",
)
.to_string(),
}],
);
let symbols = extract_symbols_regex(&diff);
for s in &symbols {
assert!(
!s.function.contains("From::"),
"Trait name should not appear as type in symbol: '{}'",
s.function
);
}
}
#[test]
fn test_is_std_trait() {
assert!(is_std_trait("From"));
assert!(is_std_trait("Into"));
assert!(is_std_trait("TryFrom"));
assert!(is_std_trait("Default"));
assert!(is_std_trait("Clone"));
assert!(is_std_trait("Iterator"));
assert!(!is_std_trait("MyStruct"));
assert!(!is_std_trait("Request"));
assert!(!is_std_trait("Vec")); // Vec is a type, not a trait
assert!(!is_std_trait("HashMap"));
}
}

View file

@ -44,23 +44,11 @@ pub struct PatchDiff {
#[derive(Debug, Clone)]
pub struct FilePatch {
pub filename: String,
/// The unified diff patch text from the GitHub API.
#[allow(dead_code)]
pub patch: String,
}
impl PatchDiff {
/// Create a PatchDiff for testing (no owner/repo/parent metadata).
#[cfg(test)]
pub fn for_test(commit_sha: &str, files: Vec<FilePatch>) -> Self {
Self {
commit_sha: commit_sha.to_string(),
owner: String::new(),
repo: String::new(),
parent_sha: None,
files,
}
}
}
impl GithubClient {
pub fn new(token: Option<String>) -> Self {
Self {