Skip to content

Commit f1326cc

Browse files
authored
Merge pull request #137 from BeaconBay/fix/cpp-edge-cases-136
fix(ck-chunk): C/C++ chunking edge cases (closes #136)
2 parents 2321510 + 017b76b commit f1326cc

2 files changed

Lines changed: 173 additions & 8 deletions

File tree

ck-chunk/src/lib.rs

Lines changed: 161 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -433,19 +433,23 @@ fn chunk_language(text: &str, language: ParseableLanguage) -> Result<Vec<Chunk>>
433433
chunks = merge_haskell_functions(chunks, text);
434434
}
435435

436-
// Suppress text chunks fully contained by class/method/function chunks for C/C++
437-
if matches!(language, ParseableLanguage::C | ParseableLanguage::Cpp) {
438-
chunks = suppress_contained_text_chunks(chunks);
439-
}
440-
441-
// Fill gaps between chunks with remainder content
436+
// Fill gaps between chunks with remainder content. This must happen
437+
// BEFORE suppress_contained_text_chunks so that gap-produced Text
438+
// chunks (e.g., the run of field declarations and access specifiers
439+
// inside a class body) are also subject to suppression — otherwise
440+
// they leak through as standalone Text chunks (issue #136).
442441
chunks = fill_gaps(chunks, text);
443442

444443
// Merge template-prefix gap chunks into the following C++ definition chunk
445444
if language == ParseableLanguage::Cpp {
446445
chunks = merge_cpp_template_prefix_chunks(chunks, text);
447446
}
448447

448+
// Suppress text chunks fully contained by class/method/function chunks for C/C++
449+
if matches!(language, ParseableLanguage::C | ParseableLanguage::Cpp) {
450+
chunks = suppress_contained_text_chunks(chunks);
451+
}
452+
449453
// Merge small chunks if Markdown
450454
if language == ParseableLanguage::Markdown {
451455
let (target_tokens, _) = get_model_chunk_config(None);
@@ -506,7 +510,16 @@ fn merge_cpp_template_prefix_chunks(chunks: Vec<Chunk>, text: &str) -> Vec<Chunk
506510
let template_chunk = &chunks[idx];
507511
let mut next_chunk = chunks[idx + 1].clone();
508512

509-
if template_chunk.span.byte_end == next_chunk.span.byte_start
513+
// Adjacency: byte_end may be <= next.byte_start, with only
514+
// whitespace in between. Previously required strict equality
515+
// which missed multiline templates and blank lines between
516+
// the template clause and the definition (issue #136).
517+
let gap_is_whitespace = template_chunk.span.byte_end <= next_chunk.span.byte_start
518+
&& text
519+
.get(template_chunk.span.byte_end..next_chunk.span.byte_start)
520+
.is_some_and(|gap| gap.chars().all(char::is_whitespace));
521+
522+
if gap_is_whitespace
510523
&& template_chunk.span.byte_start < next_chunk.span.byte_end
511524
&& next_chunk.span.byte_end <= text.len()
512525
{
@@ -3071,6 +3084,147 @@ Trailing paragraph.
30713084
);
30723085
}
30733086

3087+
// ----- Regression tests for issue #136 (C/C++ edge cases) -----
3088+
//
3089+
// These tests intentionally use the full `chunk_text` entry point so
3090+
// they exercise the post-processing pipeline (fill_gaps, suppression,
3091+
// template merge) — not just the raw query layer like the existing
3092+
// C/C++ tests in `query_chunker.rs`.
3093+
3094+
/// Issue #136 #1: gap-produced Text chunks inside a class body must
3095+
/// be suppressed too. Previously `suppress_contained_text_chunks`
3096+
/// ran *before* `fill_gaps`, so field declarations and access
3097+
/// specifiers between methods leaked through as a standalone Text
3098+
/// chunk. Now suppression runs after fill_gaps.
3099+
#[test]
3100+
fn cpp_class_body_gap_text_is_suppressed() {
3101+
let source = r"
3102+
class Counter {
3103+
public:
3104+
int value;
3105+
int spare;
3106+
3107+
void inc() { value++; }
3108+
void dec() { value--; }
3109+
};
3110+
";
3111+
let chunks = chunk_text(source, Some(ck_core::Language::Cpp)).expect("chunk cpp");
3112+
3113+
// The class itself + the two methods should be present.
3114+
assert!(
3115+
chunks
3116+
.iter()
3117+
.any(|c| c.chunk_type == ChunkType::Class && c.text.contains("class Counter")),
3118+
"expected Class chunk for Counter"
3119+
);
3120+
3121+
// Critical: no Text chunk should contain `int value` or
3122+
// `public:` — those bytes live inside the class span and would
3123+
// be a redundant duplicate of content already in the Class chunk.
3124+
let leaked_field = chunks.iter().any(|c| {
3125+
c.chunk_type == ChunkType::Text
3126+
&& (c.text.contains("int value") || c.text.contains("int spare"))
3127+
});
3128+
let leaked_access = chunks
3129+
.iter()
3130+
.any(|c| c.chunk_type == ChunkType::Text && c.text.trim() == "public:");
3131+
assert!(
3132+
!leaked_field,
3133+
"field declarations leaked as standalone Text chunks: {:?}",
3134+
chunks
3135+
.iter()
3136+
.filter(|c| c.chunk_type == ChunkType::Text)
3137+
.map(|c| &c.text)
3138+
.collect::<Vec<_>>()
3139+
);
3140+
assert!(!leaked_access, "access specifier leaked as Text chunk");
3141+
}
3142+
3143+
/// Issue #136 #3: `template<...>` clauses that aren't byte-adjacent
3144+
/// to their definition (multiline template clause, blank line
3145+
/// between) still merge into a single Cpp definition chunk. The
3146+
/// previous strict-adjacency check missed these.
3147+
#[test]
3148+
fn cpp_multiline_template_prefix_merges_with_definition() {
3149+
// Multiline template clause AND a blank line before the function.
3150+
let source = r"
3151+
template <
3152+
typename T,
3153+
typename U
3154+
>
3155+
3156+
T identity(T value, U /*ignored*/) {
3157+
return value;
3158+
}
3159+
";
3160+
let chunks = chunk_text(source, Some(ck_core::Language::Cpp)).expect("chunk cpp");
3161+
3162+
// After merge: one chunk that contains BOTH the template clause
3163+
// and the definition. There should not be a separate Text chunk
3164+
// that's just the template prefix.
3165+
let combined = chunks.iter().find(|c| {
3166+
matches!(c.chunk_type, ChunkType::Function | ChunkType::Method)
3167+
&& c.text.contains("template")
3168+
&& c.text.contains("T identity")
3169+
});
3170+
assert!(
3171+
combined.is_some(),
3172+
"expected one Function chunk combining the multiline template clause with its definition; got {:?}",
3173+
chunks
3174+
.iter()
3175+
.map(|c| (&c.chunk_type, c.text.lines().next().unwrap_or("")))
3176+
.collect::<Vec<_>>()
3177+
);
3178+
3179+
// And we shouldn't have a separate standalone Text chunk that's
3180+
// only the template clause without the body.
3181+
let orphan_template = chunks.iter().any(|c| {
3182+
c.chunk_type == ChunkType::Text
3183+
&& c.text.contains("template")
3184+
&& !c.text.contains("identity")
3185+
});
3186+
assert!(
3187+
!orphan_template,
3188+
"template clause was emitted as a standalone Text chunk instead of merging"
3189+
);
3190+
}
3191+
3192+
/// Coverage gap from issue #136: an `extern \"C\" { ... }` linkage
3193+
/// specification should not lose its inner functions. We don't
3194+
/// assert breadcrumb shape here — just that the functions are
3195+
/// captured at all and aren't suppressed by the linkage wrapper.
3196+
#[test]
3197+
fn cpp_extern_c_block_functions_are_captured() {
3198+
let source = r#"
3199+
extern "C" {
3200+
int add(int a, int b) {
3201+
return a + b;
3202+
}
3203+
int sub(int a, int b) {
3204+
return a - b;
3205+
}
3206+
}
3207+
"#;
3208+
let chunks = chunk_text(source, Some(ck_core::Language::Cpp)).expect("chunk cpp");
3209+
3210+
let has_add = chunks.iter().any(|c| {
3211+
matches!(c.chunk_type, ChunkType::Function | ChunkType::Method)
3212+
&& c.text.contains("int add")
3213+
});
3214+
let has_sub = chunks.iter().any(|c| {
3215+
matches!(c.chunk_type, ChunkType::Function | ChunkType::Method)
3216+
&& c.text.contains("int sub")
3217+
});
3218+
assert!(
3219+
has_add && has_sub,
3220+
"expected both `add` and `sub` to be captured inside extern \"C\"; got {:?}",
3221+
chunks
3222+
.iter()
3223+
.map(|c| (&c.chunk_type, c.text.lines().next().unwrap_or("")))
3224+
.collect::<Vec<_>>()
3225+
);
3226+
}
3227+
30743228
#[test]
30753229
fn test_csharp_query_matches_legacy() {
30763230
let source = r"

ck-chunk/src/query_chunker.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,18 @@ pub(crate) fn chunk_with_queries(
7979
continue;
8080
}
8181

82-
// Skip C/C++ function/method declarations without bodies (defaulted/deleted)
82+
// Drop C/C++ function/method nodes that have no body block.
83+
// tree-sitter-cpp emits `function_definition` for both real
84+
// bodied functions AND for special forms like
85+
// Foo() = default;
86+
// Foo(const Foo&) = delete;
87+
// void foo() = 0; // pure virtual
88+
// Distinguish via presence of a `compound_statement` child:
89+
// a real definition has one, the special forms don't.
90+
// (Note: regression test
91+
// `cpp_queries_skip_defaulted_deleted_ctors` proves this
92+
// filter is load-bearing — removing it lets the ctors leak
93+
// back in as Method chunks. Issue #136.)
8394
if matches!(chunk_type, ChunkType::Function | ChunkType::Method)
8495
&& capture.node.kind() == "function_definition"
8596
&& !has_compound_statement(capture.node)

0 commit comments

Comments
 (0)