fix(chunk): corrected namespace and module member addressability with recursion handlers

- Fixed namespace and module member addressability by adding dedicated recursion handlers for internal modules and namespace definitions.
- Fixed indent detection to scan source content as fallback when chunk metadata unavailable, improving robustness.
- Changed default indent character from tab to space for consistency with modern conventions.
- Added comprehensive test coverage for namespace member addressability, internal module recursion, and indent detection fallback logic.
This commit is contained in:
can1357
2026-04-07 02:23:38 +02:00
parent a6c78612e9
commit 4681186507
5 changed files with 191 additions and 22 deletions
+9 -3
View File
@@ -6,6 +6,10 @@ use super::{classify::LangClassifier, common::*, defaults::promote_assigned_expr
pub struct JsTsClassifier;
fn recurse_internal_module(node: Node<'_>) -> Option<RecurseSpec<'_>> {
recurse_into(node, ChunkContext::ClassBody, &["body"], &["statement_block"])
}
impl LangClassifier for JsTsClassifier {
fn classify_root<'t>(&self, node: Node<'t>, source: &str) -> Option<RawChunkCandidate<'t>> {
match node.kind() {
@@ -42,7 +46,9 @@ impl LangClassifier for JsTsClassifier {
Some(container_candidate(node, "iface", source, recurse_interface(node)))
},
"enum_declaration" => Some(container_candidate(node, "enum", source, recurse_enum(node))),
"internal_module" => Some(container_candidate(node, "mod", source, recurse_class(node))),
"internal_module" => {
Some(container_candidate(node, "mod", source, recurse_internal_module(node)))
},
// ── Types ──
"type_alias_declaration" => Some(named_candidate(node, "type", source, None)),
@@ -59,7 +65,7 @@ impl LangClassifier for JsTsClassifier {
.into_iter()
.find(|c| c.kind() == "internal_module");
if let Some(ns) = inner {
Some(container_candidate(ns, "mod", source, recurse_class(ns)))
Some(container_candidate(ns, "mod", source, recurse_internal_module(ns)))
} else {
Some(group_candidate(node, "stmts", source))
}
@@ -303,7 +309,7 @@ fn classify_export_statement<'t>(node: Node<'t>, source: &str) -> RawChunkCandid
}
},
"internal_module" => {
let recurse = recurse_class(child);
let recurse = recurse_internal_module(child);
if is_default {
make_container_chunk_from(node, child, "default_export".to_string(), source, recurse)
} else {
+14 -3
View File
@@ -16,6 +16,16 @@ impl LangClassifier for MiscClassifier {
recurse_body(node, ChunkContext::FunctionBody)
.or_else(|| recurse_into(node, ChunkContext::FunctionBody, &["body"], &["block"]))
};
let module_recurse = || {
recurse_class(node).or_else(|| {
recurse_into(node, ChunkContext::ClassBody, &["body"], &[
"compound_statement",
"statement_block",
"declaration_list",
"block",
])
})
};
Some(match node.kind() {
// ── Imports / package headers ──
"import_statement"
@@ -83,9 +93,10 @@ impl LangClassifier for MiscClassifier {
"contract_declaration" | "library_declaration" | "trait_declaration" => {
container_candidate(node, "contract", source, recurse_class(node))
},
"namespace_declaration" | "module_definition" | "extension_definition" => {
container_candidate(node, "mod", source, recurse_class(node))
},
"namespace_declaration"
| "namespace_definition"
| "module_definition"
| "extension_definition" => container_candidate(node, "mod", source, module_recurse()),
// ── Types / aliases ──
"type_alias_declaration" | "const_type_declaration" | "opaque_declaration" => {
+60 -10
View File
@@ -47,7 +47,7 @@ pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result<EditResult
let mut state =
rebuild_chunk_state(original_text.clone(), state.inner().language().to_string())?;
let file_indent_step = detect_file_indent_step(&state.tree) as usize;
let file_indent_char = detect_file_indent_char(&state.tree);
let file_indent_char = detect_file_indent_char(&state.source, &state.tree);
let initial_parse_errors = state.tree.parse_errors;
let mut touched_paths = Vec::new();
let mut warnings = Vec::new();
@@ -295,8 +295,12 @@ fn apply_replace(
detect_common_indent(&replaced_range).prefix
};
let content = operation.content.as_deref().unwrap_or_default();
let mut replacement =
normalize_inserted_content(content, &target_indent, Some(file_indent_step), file_indent_char);
let mut replacement = normalize_inserted_content(
content,
&target_indent,
Some(file_indent_step),
file_indent_char,
);
if !replacement.is_empty() && !replacement.ends_with('\n') && abs_end < state.tree.line_count
{
replacement.push('\n');
@@ -406,8 +410,12 @@ fn apply_insert(
)?;
let spacing = compute_insert_spacing(state, &anchor, pos);
let content = operation.content.as_deref().unwrap_or_default();
let mut replacement =
normalize_inserted_content(content, &insertion.indent, Some(file_indent_step), file_indent_char);
let mut replacement = normalize_inserted_content(
content,
&insertion.indent,
Some(file_indent_step),
file_indent_char,
);
replacement =
normalize_insertion_boundary_content(state, insertion.offset, &replacement, spacing);
@@ -1227,16 +1235,12 @@ fn generate_diff_hunks(before: &str, after: &str, context: usize) -> Vec<DiffHun
}
}
hunks.push(DiffHunk {
header,
lines: hunk_lines,
});
hunks.push(DiffHunk { header, lines: hunk_lines });
}
hunks
}
/// Render the response text for a changed file, combining the current chunked
/// tree view with a zero-context unified diff hunk summary.
fn render_changed_hunks(
@@ -1295,3 +1299,49 @@ fn render_unchanged_response(
tab_replacement: Some(" ".to_owned()),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::build_chunk_tree;
fn state_for(source: &str, language: &str) -> ChunkState {
let tree = build_chunk_tree(source, language).expect("tree should build");
ChunkState::from_inner(ChunkStateInner::new(source.to_owned(), language.to_owned(), tree))
}
#[test]
fn root_level_replace_preserves_space_indentation() {
let source = "fn main() {\n println!(\"old\");\n}\n";
let state = state_for(source, "rust");
let chunk = state.inner().chunk("fn_main").expect("fn_main");
let result = apply_edits(&state, &EditParams {
operations: vec![EditOperation {
op: ChunkEditOp::Replace,
sel: Some("fn_main".to_owned()),
crc: Some(chunk.checksum.clone()),
content: Some("fn main() {\n println!(\"new\");\n}".to_owned()),
line: None,
end_line: None,
}],
default_selector: None,
default_crc: None,
anchor_style: None,
cwd: ".".to_owned(),
file_path: "test.rs".to_owned(),
})
.expect("edit should apply");
assert!(
result.diff_after.contains("println!(\"new\");"),
"expected updated body text, got {:?}",
result.diff_after
);
assert!(
!result.diff_after.contains("\n\tprintln!(\"new\");\n"),
"expected no tab-indented body, got {:?}",
result.diff_after
);
}
}
+53 -6
View File
@@ -232,19 +232,33 @@ pub fn reindent_inserted_block(
indent_non_empty_lines(&dedented, &normalized_target_indent)
}
/// Detect the file's indent character by looking at non-root chunks.
/// Returns `'\t'` or `' '`, defaulting to `'\t'` if no chunks have indentation.
pub fn detect_file_indent_char(tree: &ChunkTree) -> char {
/// Detect the file's indent character.
/// Prefer chunk metadata, then fall back to scanning source lines.
/// Returns `' '` when the file provides no indentation signal.
pub fn detect_file_indent_char(source: &str, tree: &ChunkTree) -> char {
for chunk in &tree.chunks {
if chunk.indent > 0 && !chunk.indent_char.is_empty() {
return chunk.indent_char.chars().next().unwrap_or('\t');
return chunk.indent_char.chars().next().unwrap_or(' ');
}
}
'\t'
for line in source.split('\n') {
if line.trim().is_empty() {
continue;
}
if let Some(ch) = leading_whitespace(line).chars().next()
&& matches!(ch, ' ' | '\t')
{
return ch;
}
}
' '
}
/// Detect spaces-per-indent-level from parent→child indent differences.
/// Only meaningful for space-indented files; returns `DEFAULT_SPACE_INDENT_STEP` for tab files.
/// Only meaningful for space-indented files; returns
/// `DEFAULT_SPACE_INDENT_STEP` for tab files.
pub fn detect_file_indent_step(tree: &ChunkTree) -> u32 {
for chunk in &tree.chunks {
if chunk.children.is_empty() {
@@ -552,4 +566,37 @@ mod tests {
};
assert_eq!(detect_file_indent_step(&tree), 2);
}
#[test]
fn detect_file_indent_char_falls_back_to_source_lines() {
let tree = ChunkTree {
language: "rust".to_owned(),
checksum: "ABCD".to_owned(),
line_count: 3,
parse_errors: 0,
fallback: false,
root_path: String::new(),
root_children: vec!["fn_main".to_owned()],
chunks: vec![chunk("fn_main", Some(""), &[], 0, "")],
};
let source = "fn main() {\n println!(\"hi\");\n}\n";
assert_eq!(detect_file_indent_char(source, &tree), ' ');
}
#[test]
fn detect_file_indent_char_defaults_to_spaces_without_signal() {
let tree = ChunkTree {
language: "rust".to_owned(),
checksum: "ABCD".to_owned(),
line_count: 0,
parse_errors: 0,
fallback: false,
root_path: String::new(),
root_children: Vec::new(),
chunks: Vec::new(),
};
assert_eq!(detect_file_indent_char("", &tree), ' ');
}
}
+55
View File
@@ -1824,4 +1824,59 @@ end
assert!(!enum_chunk.leaf);
assert!(!enum_chunk.children.is_empty(), "mixed-size variants should stay addressable");
}
#[test]
fn typescript_namespace_members_stay_addressable() {
let source = r"namespace Foo {
export function bar() {
return 1;
}
}
";
let tree = build_chunk_tree(source, "typescript").expect("tree should build");
let module = tree
.chunks
.iter()
.find(|c| c.path == "mod_Foo")
.expect("mod_Foo");
assert!(!module.leaf);
assert!(
module
.children
.iter()
.any(|child| child == "mod_Foo.fn_bar"),
"expected fn_bar inside namespace, got {:?}",
module.children
);
}
#[test]
fn php_namespace_definition_keeps_inner_members_addressable() {
let source = "<?php\nnamespace App {\nclass User {}\nfunction boot() {}\n}\n";
let tree = build_chunk_tree(source, "php").expect("tree should build");
let module = tree
.chunks
.iter()
.find(|c| c.path == "mod_App")
.expect("mod_App");
assert!(!module.leaf);
assert!(
module
.children
.iter()
.any(|child| child == "mod_App.class_User"),
"expected class_User inside namespace, got {:?}",
module.children
);
assert!(
module
.children
.iter()
.any(|child| child == "mod_App.fn_boot"),
"expected fn_boot inside namespace, got {:?}",
module.children
);
}
}