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:
@@ -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 {
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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), ' ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user