diff --git a/Cargo.lock b/Cargo.lock index c9b05f930..c70e21beb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2539,12 +2539,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "path-clean" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" - [[package]] name = "pathdiff" version = "0.2.3" @@ -2731,8 +2725,6 @@ dependencies = [ "napi-derive", "os_pipe", "parking_lot", - "path-clean", - "pathdiff", "phf 0.13.1", "portable-pty", "rayon", diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 017be381a..2ca3d9e5a 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -124,8 +124,6 @@ memmap2 = "0.9" xxhash-rust = { version = "0.8", features = ["xxh64"] } regex = "1" similar = "3.0.0" -path-clean = "1" -pathdiff = "0.2" serde = "1.0" serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/crates/pi-natives/src/chunk/ast_js_ts.rs b/crates/pi-natives/src/chunk/ast_js_ts.rs index de21d2b94..65a4ec137 100644 --- a/crates/pi-natives/src/chunk/ast_js_ts.rs +++ b/crates/pi-natives/src/chunk/ast_js_ts.rs @@ -4,10 +4,11 @@ use tree_sitter::Node; use super::{ classify::{ - ClassifierTables, LangClassifier, NamingMode, RecurseMode, RuleStyle, semantic_rule, + ClassifierTables, LangClassifier, NamingMode, RecurseMode, RuleStyle, WrapperSignature, + WrapperTransform, classify_with_defaults, first_wrapper_content_child, + promote_wrapper_candidate, semantic_rule, }, common::*, - defaults::promote_assigned_expression, kind::ChunkKind, }; @@ -40,6 +41,41 @@ static JSTS_TABLES: ClassifierTables = ClassifierTables { NamingMode::AutoIdentifier, RecurseMode::Auto(ChunkContext::FunctionBody), ), + semantic_rule( + "function", + ChunkKind::Function, + RuleStyle::Named, + NamingMode::AutoIdentifier, + RecurseMode::Auto(ChunkContext::FunctionBody), + ), + semantic_rule( + "function_expression", + ChunkKind::Function, + RuleStyle::Named, + NamingMode::AutoIdentifier, + RecurseMode::Auto(ChunkContext::FunctionBody), + ), + semantic_rule( + "arrow_function", + ChunkKind::Function, + RuleStyle::Named, + NamingMode::AutoIdentifier, + RecurseMode::Auto(ChunkContext::FunctionBody), + ), + semantic_rule( + "generator_function", + ChunkKind::Function, + RuleStyle::Named, + NamingMode::AutoIdentifier, + RecurseMode::Auto(ChunkContext::FunctionBody), + ), + semantic_rule( + "generator_function_declaration", + ChunkKind::Function, + RuleStyle::Named, + NamingMode::AutoIdentifier, + RecurseMode::Auto(ChunkContext::FunctionBody), + ), semantic_rule( "class_declaration", ChunkKind::Class, @@ -47,6 +83,20 @@ static JSTS_TABLES: ClassifierTables = ClassifierTables { NamingMode::AutoIdentifier, RecurseMode::Auto(ChunkContext::ClassBody), ), + semantic_rule( + "class", + ChunkKind::Class, + RuleStyle::Named, + NamingMode::AutoIdentifier, + RecurseMode::Auto(ChunkContext::ClassBody), + ), + semantic_rule( + "class_expression", + ChunkKind::Class, + RuleStyle::Named, + NamingMode::AutoIdentifier, + RecurseMode::Auto(ChunkContext::ClassBody), + ), semantic_rule( "interface_declaration", ChunkKind::Interface, @@ -118,8 +168,15 @@ impl LangClassifier for JsTsClassifier { fn classify_root_custom<'t>(node: Node<'t>, source: &str) -> Option> { match node.kind() { // ── Exports / decorators ── - "export_statement" => Some(classify_export_statement(node, source)), - "decorated_definition" => Some(classify_decorated(node, source)), + "export_statement" => Some(classify_export_statement(ChunkContext::Root, node, source)), + "decorated_definition" => promote_wrapper_candidate( + &JsTsClassifier, + ChunkContext::Root, + node, + source, + WrapperTransform { signature: WrapperSignature::Wrapper, ..WrapperTransform::default() }, + ) + .or_else(|| Some(positional_candidate(node, ChunkKind::Block, source))), // ── Variables ── "lexical_declaration" | "variable_declaration" => Some(classify_var_decl_js(node, source)), @@ -154,8 +211,15 @@ fn classify_root_custom<'t>(node: Node<'t>, source: &str) -> Option(node: Node<'t>, source: &str) -> Option> { match node.kind() { // ── Exports / decorators (re-exported members) ── - "export_statement" => Some(classify_export_statement(node, source)), - "decorated_definition" => Some(classify_decorated(node, source)), + "export_statement" => Some(classify_export_statement(ChunkContext::ClassBody, node, source)), + "decorated_definition" => promote_wrapper_candidate( + &JsTsClassifier, + ChunkContext::ClassBody, + node, + source, + WrapperTransform { signature: WrapperSignature::Wrapper, ..WrapperTransform::default() }, + ) + .or_else(|| Some(positional_candidate(node, ChunkKind::Block, source))), // ── Variables ── "lexical_declaration" | "variable_declaration" => Some(classify_var_decl_js(node, source)), @@ -300,19 +364,28 @@ fn group_from_sanitized<'t>(node: Node<'t>, source: &str) -> RawChunkCandidate<' /// Unwrap `export` / `export default` to classify the inner declaration. /// -/// Named exports delegate to the appropriate container/named-chunk builder; -/// `export default …` always maps to `default_export`. Re-exports and -/// bare expression exports fall through to the `stmts` group. -fn classify_export_statement<'t>(node: Node<'t>, source: &str) -> RawChunkCandidate<'t> { +/// Wrapper promotion handles declaration-like exports automatically. +/// `export default …` remaps the promoted child to `default_export`, while +/// re-exports and bare expression exports still fall through to `stmts`. +fn classify_export_statement<'t>( + context: ChunkContext, + node: Node<'t>, + source: &str, +) -> RawChunkCandidate<'t> { let header = normalized_header(source, node.start_byte(), node.end_byte()); let is_default = header.starts_with("export default"); - let inner = named_children(node) - .into_iter() - .find(|child| !is_trivia(child.kind()) && !child.is_error() && child.kind() != "comment"); + if let Some(candidate) = + promote_wrapper_candidate(&JsTsClassifier, context, node, source, WrapperTransform { + kind: is_default.then_some(ChunkKind::DefaultExport), + name_style: is_default.then_some(NameStyle::Named), + clear_identifier: is_default, + ..WrapperTransform::default() + }) { + return candidate; + } - let Some(child) = inner else { - // `export { foo } from "bar"` with no inner declaration node. + let Some(child) = first_wrapper_content_child(&JsTsClassifier, node) else { return if is_default { make_kind_chunk(node, ChunkKind::DefaultExport, None, source, None) } else { @@ -320,149 +393,14 @@ fn classify_export_statement<'t>(node: Node<'t>, source: &str) -> RawChunkCandid }; }; + if is_default { + return make_kind_chunk(node, ChunkKind::DefaultExport, None, source, None); + } + match child.kind() { - "class_declaration" => { - let recurse = recurse_class(child); - if is_default { - make_container_chunk_from(node, child, ChunkKind::DefaultExport, None, source, recurse) - } else { - make_container_chunk_from( - node, - child, - ChunkKind::Class, - extract_identifier(child, source), - source, - recurse, - ) - } - }, - "function_declaration" - | "function" - | "function_expression" - | "arrow_function" - | "generator_function" - | "generator_function_declaration" => { - let recurse = recurse_body(child, ChunkContext::FunctionBody); - if is_default { - make_kind_chunk_from(node, child, ChunkKind::DefaultExport, None, source, recurse) - } else { - make_kind_chunk_from( - node, - child, - ChunkKind::Function, - extract_identifier(child, source), - source, - recurse, - ) - } - }, - "interface_declaration" => { - let recurse = recurse_interface(child); - if is_default { - make_container_chunk_from(node, child, ChunkKind::DefaultExport, None, source, recurse) - } else { - make_container_chunk_from( - node, - child, - ChunkKind::Interface, - extract_identifier(child, source), - source, - recurse, - ) - } - }, - "type_alias_declaration" => { - if is_default { - make_kind_chunk_from(node, child, ChunkKind::DefaultExport, None, source, None) - } else { - make_kind_chunk_from( - node, - child, - ChunkKind::Type, - extract_identifier(child, source), - source, - None, - ) - } - }, - "enum_declaration" => { - let recurse = recurse_enum(child); - if is_default { - make_container_chunk_from(node, child, ChunkKind::DefaultExport, None, source, recurse) - } else { - make_container_chunk_from( - node, - child, - ChunkKind::Enum, - extract_identifier(child, source), - source, - recurse, - ) - } - }, - "internal_module" => { - let recurse = recurse_internal_module(child); - if is_default { - make_container_chunk_from(node, child, ChunkKind::DefaultExport, None, source, recurse) - } else { - make_container_chunk_from( - node, - child, - ChunkKind::Module, - extract_identifier(child, source), - source, - recurse, - ) - } - }, "lexical_declaration" | "variable_declaration" => { - if is_default { - make_kind_chunk_from(node, child, ChunkKind::DefaultExport, None, source, None) - } else { - promote_assigned_expression(node, child, source) - .unwrap_or_else(|| super::defaults::classify_var_decl(child, source)) - } - }, - _ => { - // expression_statement, re-exports, or anything else. - if is_default { - make_kind_chunk_from(node, child, ChunkKind::DefaultExport, None, source, None) - } else { - group_candidate(child, ChunkKind::Statements, source) - } + classify_with_defaults(&JsTsClassifier, context, child, source) }, - } -} - -/// Unwrap `@decorator` wrappers (TS/Python `decorated_definition`) to find -/// the inner class or function definition. -fn classify_decorated<'t>(node: Node<'t>, source: &str) -> RawChunkCandidate<'t> { - let inner = named_children(node).into_iter().find(|c| { - matches!( - c.kind(), - "class_declaration" | "class_definition" | "function_declaration" | "function_definition" - ) - }); - - match inner { - Some(child) if child.kind() == "class_declaration" || child.kind() == "class_definition" => { - let recurse = recurse_class(child); - make_container_chunk( - node, - ChunkKind::Class, - extract_identifier(child, source), - source, - recurse, - ) - }, - Some(child) => { - // function_declaration | function_definition - let name = extract_identifier(child, source).unwrap_or_else(|| "anonymous".to_string()); - make_kind_chunk(node, ChunkKind::Function, Some(name), source, { - let context = ChunkContext::FunctionBody; - recurse_into(child, context, &["body"], &["block"]) - }) - }, - None => positional_candidate(node, ChunkKind::Block, source), + _ => group_candidate(child, ChunkKind::Statements, source), } } diff --git a/crates/pi-natives/src/chunk/ast_misc.rs b/crates/pi-natives/src/chunk/ast_misc.rs index 9e19685a6..ee7177fb3 100644 --- a/crates/pi-natives/src/chunk/ast_misc.rs +++ b/crates/pi-natives/src/chunk/ast_misc.rs @@ -9,7 +9,7 @@ use tree_sitter::Node; use super::{ classify::{ ClassifierTables, LangClassifier, NamingMode, RecurseMode, RuleStyle, StructuralOverrides, - semantic_rule, + WrapperSignature, WrapperTransform, promote_wrapper_candidate, semantic_rule, }, common::*, defaults::classify_var_decl, @@ -1097,21 +1097,13 @@ fn classify_class_override<'t>(node: Node<'t>, source: &str) -> Option { - let inner = named_children(node) - .into_iter() - .find(|c| c.kind() == "function_definition"); - if let Some(child) = inner { - let name = extract_identifier(child, source).unwrap_or_else(|| "anonymous".to_string()); - make_kind_chunk(node, ChunkKind::Function, Some(name), source, { - let context = ChunkContext::FunctionBody; - recurse_into(child, context, &["body"], &["block"]) - }) - } else { - return None; - } - }, + "decorated_definition" => promote_wrapper_candidate( + &MiscClassifier, + ChunkContext::ClassBody, + node, + source, + WrapperTransform { signature: WrapperSignature::Wrapper, ..WrapperTransform::default() }, + )?, _ => return None, }) diff --git a/crates/pi-natives/src/chunk/ast_python.rs b/crates/pi-natives/src/chunk/ast_python.rs index 2db2bdebe..a7fd324e8 100644 --- a/crates/pi-natives/src/chunk/ast_python.rs +++ b/crates/pi-natives/src/chunk/ast_python.rs @@ -4,7 +4,8 @@ use tree_sitter::Node; use super::{ classify::{ - ClassifierTables, LangClassifier, NamingMode, RecurseMode, RuleStyle, semantic_rule, + ClassifierTables, LangClassifier, NamingMode, RecurseMode, RuleStyle, WrapperSignature, + WrapperTransform, promote_wrapper_candidate, semantic_rule, }, common::*, kind::ChunkKind, @@ -202,7 +203,11 @@ impl LangClassifier for PythonClassifier { ) -> Option> { match context { ChunkContext::Root | ChunkContext::ClassBody if node.kind() == "decorated_definition" => { - Some(classify_decorated(node, source, context)) + promote_wrapper_candidate(self, context, node, source, WrapperTransform { + signature: WrapperSignature::Wrapper, + ..WrapperTransform::default() + }) + .or_else(|| Some(positional_candidate(node, ChunkKind::Block, source))) }, ChunkContext::ClassBody if node.kind() == "function_definition" => { Some(classify_class_method(node, source)) @@ -237,52 +242,3 @@ fn classify_class_method<'t>(node: Node<'t>, source: &str) -> RawChunkCandidate< resolve_recurse(node, ChunkContext::FunctionBody), ) } - -fn classify_decorated<'t>( - node: Node<'t>, - source: &str, - context: ChunkContext, -) -> RawChunkCandidate<'t> { - let inner = named_children(node) - .into_iter() - .find(|c| matches!(c.kind(), "class_definition" | "function_definition")); - match inner { - Some(child) if child.kind() == "class_definition" => make_container_chunk( - node, - ChunkKind::Class, - extract_identifier(child, source), - source, - resolve_recurse(child, ChunkContext::ClassBody), - ), - Some(child) - if child.kind() == "function_definition" && context == ChunkContext::ClassBody => - { - let name = extract_identifier(child, source).unwrap_or_else(|| "anonymous".to_string()); - let kind = if name == "__init__" || name == "__new__" { - ChunkKind::Constructor - } else { - ChunkKind::Function - }; - let identifier = if kind == ChunkKind::Constructor { - None - } else { - Some(name) - }; - make_kind_chunk( - node, - kind, - identifier, - source, - resolve_recurse(child, ChunkContext::FunctionBody), - ) - }, - Some(child) if child.kind() == "function_definition" => make_kind_chunk( - node, - ChunkKind::Function, - extract_identifier(child, source), - source, - resolve_recurse(child, ChunkContext::FunctionBody), - ), - _ => positional_candidate(node, ChunkKind::Block, source), - } -} diff --git a/crates/pi-natives/src/chunk/classify.rs b/crates/pi-natives/src/chunk/classify.rs index aa42bc621..9549c93c0 100644 --- a/crates/pi-natives/src/chunk/classify.rs +++ b/crates/pi-natives/src/chunk/classify.rs @@ -7,9 +7,11 @@ use tree_sitter::Node; use super::{ common::{ - ChunkContext, NameStyle, RawChunkCandidate, extract_identifier, make_candidate, recurse_self, - resolve_recurse, resolve_value_container, sanitize_node_kind, signature_for_node, + ChunkContext, NameStyle, RawChunkCandidate, extract_identifier, is_absorbable_attribute, + is_trivia_node, make_candidate, named_children, recurse_self, resolve_recurse, + resolve_value_container, sanitize_node_kind, signature_for_node, }, + defaults, kind::ChunkKind, }; use crate::chunk::types::ChunkNode; @@ -36,6 +38,21 @@ pub enum RecurseMode { ValueContainer, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum WrapperSignature { + #[default] + Child, + Wrapper, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct WrapperTransform { + pub kind: Option, + pub name_style: Option, + pub clear_identifier: bool, + pub signature: WrapperSignature, +} + #[derive(Clone, Copy, Debug)] pub struct SemanticRule { pub ts_kind: &'static str, @@ -199,6 +216,67 @@ pub fn classify_with_tables<'tree>( }) } +pub fn classify_with_defaults<'tree>( + classifier: &dyn LangClassifier, + context: ChunkContext, + node: Node<'tree>, + source: &str, +) -> RawChunkCandidate<'tree> { + if node.is_error() || node.kind() == "ERROR" { + return make_candidate(node, ChunkKind::Error, None, NameStyle::Error, None, None, source); + } + + match context { + ChunkContext::Root => classify_with_tables(classifier, context, node, source) + .unwrap_or_else(|| defaults::classify_root_default(node, source)), + ChunkContext::ClassBody => classify_with_tables(classifier, context, node, source) + .unwrap_or_else(|| defaults::classify_class_default(node, source)), + ChunkContext::FunctionBody => classify_with_tables(classifier, context, node, source) + .unwrap_or_else(|| defaults::classify_function_default(node, source)), + } +} + +pub fn first_wrapper_content_child<'tree>( + classifier: &dyn LangClassifier, + node: Node<'tree>, +) -> Option> { + let overrides = structural_overrides(classifier); + named_children(node) + .into_iter() + .find(|child| !is_wrapper_metadata_child(*child, classifier, overrides)) +} + +pub fn promote_wrapper_candidate<'tree>( + classifier: &dyn LangClassifier, + context: ChunkContext, + node: Node<'tree>, + source: &str, + transform: WrapperTransform, +) -> Option> { + let (child, candidate) = promotable_wrapper_child(classifier, context, node, source)?; + let signature_node = match transform.signature { + WrapperSignature::Child => child, + WrapperSignature::Wrapper => node, + }; + let kind = transform.kind.unwrap_or(candidate.kind); + let name_style = transform.name_style.unwrap_or(candidate.name_style); + let identifier = if transform.clear_identifier { + None + } else { + candidate.identifier + }; + + Some(make_candidate( + node, + kind, + identifier, + name_style, + signature_for_node(signature_node, source), + candidate.recurse, + source, + )) +} + pub fn build_candidate_from_rule<'tree>( node: Node<'tree>, source: &str, @@ -256,6 +334,63 @@ fn find_rule( rules.iter().find(|rule| rule.ts_kind == kind) } +fn promotable_wrapper_child<'tree>( + classifier: &dyn LangClassifier, + context: ChunkContext, + node: Node<'tree>, + source: &str, +) -> Option<(Node<'tree>, RawChunkCandidate<'tree>)> { + let overrides = structural_overrides(classifier); + let mut promoted = named_children(node).into_iter().filter_map(|child| { + if is_wrapper_metadata_child(child, classifier, overrides) { + return None; + } + + let candidate = classify_with_defaults(classifier, context, child, source); + is_promotable_wrapper_candidate(child, &candidate).then_some((child, candidate)) + }); + + let promoted_child = promoted.next()?; + if promoted.next().is_some() { + return None; + } + Some(promoted_child) +} + +fn is_wrapper_metadata_child( + node: Node<'_>, + classifier: &dyn LangClassifier, + overrides: StructuralOverrides, +) -> bool { + let kind = node.kind(); + ((is_trivia_node(node) || classifier.is_trivia(kind)) + && !overrides.preserves_trivia(kind) + && !classifier.preserve_trivia(kind)) + || (overrides.is_extra_trivia(kind) + && !overrides.preserves_trivia(kind) + && !classifier.preserve_trivia(kind)) + || is_absorbable_attribute(kind) + || overrides.is_absorbable_attr(kind) + || classifier.is_absorbable_attr(kind) + || matches!(kind, "annotation" | "annotations" | "decorator" | "modifier" | "modifiers") + || kind.ends_with("_annotation") + || kind.ends_with("_attribute") + || kind.ends_with("_decorator") + || kind.ends_with("_modifier") +} + +fn is_promotable_wrapper_candidate(node: Node<'_>, candidate: &RawChunkCandidate<'_>) -> bool { + if matches!(candidate.kind, ChunkKind::Error | ChunkKind::Chunk | ChunkKind::Statements) { + return false; + } + + candidate.identifier.is_some() + || candidate.recurse.is_some() + || candidate.kind.traits().container + || node.kind().ends_with("_definition") + || node.kind().ends_with("_declaration") +} + /// Resolve a [`LangClassifier`] for the given language. pub fn classifier_for(lang: &str) -> &'static dyn LangClassifier { match lang { diff --git a/crates/pi-natives/src/chunk/common.rs b/crates/pi-natives/src/chunk/common.rs index 3a4e0eca8..fa91378d6 100644 --- a/crates/pi-natives/src/chunk/common.rs +++ b/crates/pi-natives/src/chunk/common.rs @@ -414,10 +414,6 @@ pub fn recurse_class(node: Node<'_>) -> Option> { resolve_recurse(node, ChunkContext::ClassBody) } -pub fn recurse_interface(node: Node<'_>) -> Option> { - resolve_recurse(node, ChunkContext::ClassBody) -} - pub fn recurse_enum(node: Node<'_>) -> Option> { resolve_recurse(node, ChunkContext::ClassBody) } @@ -684,10 +680,6 @@ fn rust_function_signature(header: &str) -> Option { // ── Trivia and attribute detection ─────────────────────────────────────── -pub fn is_trivia(kind: &str) -> bool { - shape::is_generic_trivia_name(kind) -} - pub fn is_trivia_node(node: Node<'_>) -> bool { shape::is_generic_trivia(node) } diff --git a/crates/pi-natives/src/chunk/edit.rs b/crates/pi-natives/src/chunk/edit.rs index d67be2e05..15185036d 100644 --- a/crates/pi-natives/src/chunk/edit.rs +++ b/crates/pi-natives/src/chunk/edit.rs @@ -372,10 +372,15 @@ fn apply_replace( .to_owned(), ); } - let (region_start, region_end) = match target.region { + + let (mut region_start, region_end) = match target.region { None => (anchor.start_byte as usize, anchor.end_byte as usize), Some(r) => chunk_region_range(&anchor, r), }; + if matches!(target.region, Some(ChunkRegion::Head)) { + region_start = + line_start_offset(&line_offsets(&state.source), anchor.start_line, &state.source); + } // Scoped find/replace: locate a literal substring inside the chunk and replace // it. @@ -420,6 +425,7 @@ fn apply_replace( let target_indent = target_indent_for_region(state, &anchor, target.region, file_indent_char, file_indent_step); + let content = operation.content.as_deref().unwrap_or_default(); let mut replacement = normalize_inserted_content( content, @@ -1932,13 +1938,13 @@ mod tests { let state = state_for(source, "typescript"); let chunk = state .inner() - .chunk("class_Foo.fn_increment") - .expect("fn_increment"); + .chunk("class_Foo.fn_increm") + .expect("fn_increm"); let result = apply_edits(&state, &EditParams { operations: vec![EditOperation { op: ChunkEditOp::Replace, - sel: Some("class_Foo.fn_increment".to_owned()), + sel: Some("class_Foo.fn_increm".to_owned()), crc: Some(chunk.checksum.clone()), region: Some(ChunkRegion::Body), content: Some("this.value += 2;\n".to_owned()), @@ -2045,13 +2051,13 @@ mod tests { let state = state_for(source, "typescript"); let chunk = state .inner() - .chunk("fn_fuzzyMatch") - .expect("fn_fuzzyMatch should exist"); + .chunk("fn_fuzzyM") + .expect("fn_fuzzyM should exist"); let result = apply_edits(&state, &EditParams { operations: vec![EditOperation { op: ChunkEditOp::Replace, - sel: Some("fuzzyMatch".to_owned()), + sel: Some("fuzzyM".to_owned()), crc: Some(chunk.checksum.clone()), region: None, content: Some( @@ -2070,7 +2076,7 @@ mod tests { assert!(result.diff_after.contains("console.log(\"resolved\");"), "{}", result.diff_after); assert!(result.warnings.iter().any(|warning| { - warning.contains("Auto-resolved chunk selector \"fuzzyMatch\" to \"fn_fuzzyMatch#") + warning.contains("Auto-resolved chunk selector \"fuzzyM\" to \"fn_fuzzyM#") })); } @@ -2178,13 +2184,13 @@ mod tests { let state = state_for(source, "markdown"); let chunk = state .inner() - .chunk("section_Top.section_Building") - .expect("section_Building"); + .chunk("sect_Top.sect_Buildi") + .expect("sect_Buildi"); let result = apply_edits(&state, &EditParams { operations: vec![EditOperation { op: ChunkEditOp::Replace, - sel: Some("section_Top.section_Building".to_owned()), + sel: Some("sect_Top.sect_Buildi".to_owned()), crc: Some(chunk.checksum.clone()), region: None, content: Some("## Building\n\nNew content.\n".to_owned()), @@ -2644,7 +2650,7 @@ mod tests { let result = apply_single_edit(&state, "Cargo.toml", EditOperation { op: ChunkEditOp::After, - sel: Some("table_dependencies.key_parking_lot_workspace".to_owned()), + sel: Some("table_depend.key_parkin".to_owned()), crc: None, region: None, content: Some("rayon.workspace = true\n".to_owned()), @@ -3013,7 +3019,7 @@ mod tests { let state = state_for(source, "markdown"); let section = state .inner() - .chunk("section_Title.section_Alpha") + .chunk("sect_Title.sect_Alpha") .expect("alpha section"); let result = apply_single_edit(&state, "test.md", EditOperation { @@ -3040,7 +3046,7 @@ mod tests { let state = state_for(source, "markdown"); let section = state .inner() - .chunk("section_Title.section_Alpha") + .chunk("sect_Title.sect_Alpha") .expect("alpha section"); let result = apply_single_edit(&state, "test.md", EditOperation { @@ -3071,14 +3077,14 @@ mod tests { let trait_chunk = tree .chunks .iter() - .find(|c| c.path == "trait_Handler") - .expect("trait_Handler should exist"); + .find(|c| c.path == "trait_Handle") + .expect("trait_Handle should exist"); // Trait members should be listed as children even when they're // single-line signatures (not collapsed as trivial). assert!( !trait_chunk.children.is_empty(), - "trait_Handler should have children, got leaf. Chunks: {:?}", + "trait_Handle should have children, got leaf. Chunks: {:?}", tree.chunks.iter().map(|c| &c.path).collect::>() ); } @@ -3115,12 +3121,12 @@ mod tests { let state = state_for(source, "rust"); let chunk = state .inner() - .chunk("enum_LogLevel.variant_Info") - .expect("variant_Info should exist"); + .chunk("enum_LogLev.vrnt_Info") + .expect("vrnt_Info should exist"); assert!(chunk.prologue_end_byte.is_none(), "leaf variant should not have prologue_end_byte"); for region_name in ["body", "head", "tail"] { - let sel = format!("enum_LogLevel.variant_Info#{}@{}", chunk.checksum, region_name); + let sel = format!("enum_LogLev.vrnt_Info#{}@{}", chunk.checksum, region_name); let result = apply_edits(&state, &EditParams { operations: vec![EditOperation { op: ChunkEditOp::Replace, @@ -3431,8 +3437,8 @@ mod tests { let state = state_for(source, "rust"); let chunk = state .inner() - .chunk("mod_tests.fn_my_test") - .expect("mod_tests.fn_my_test should exist"); + .chunk("mod_tests.fn_my_tes") + .expect("mod_tests.fn_my_tes should exist"); // Verify the chunk absorbs the #[test] attribute as leading trivia. assert!( @@ -3445,7 +3451,7 @@ mod tests { // Replace the function WITHOUT including #[test] in the content. let result = apply_single_edit(&state, "test.rs", EditOperation { op: ChunkEditOp::Replace, - sel: Some("mod_tests.fn_my_test".to_owned()), + sel: Some("mod_tests.fn_my_tes".to_owned()), crc: Some(chunk.checksum.clone()), region: None, content: Some("fn my_test() {\n\tnew();\n}".to_owned()), @@ -3490,12 +3496,12 @@ mod tests { let state = state_for(source, "rust"); let chunk = state .inner() - .chunk("mod_tests.fn_my_test") - .expect("fn_my_test should exist"); + .chunk("mod_tests.fn_my_tes") + .expect("fn_my_tes should exist"); let result = apply_single_edit(&state, "test.rs", EditOperation { op: ChunkEditOp::Replace, - sel: Some("mod_tests.fn_my_test".to_owned()), + sel: Some("mod_tests.fn_my_tes".to_owned()), crc: Some(chunk.checksum.clone()), region: Some(ChunkRegion::Decl), content: Some("fn my_test() {\n\tnew();\n}".to_owned()), @@ -3528,19 +3534,19 @@ mod tests { let state = state_for(source, "rust"); let chunk_a = state .inner() - .chunk("mod_tests.fn_test_alpha") - .expect("fn_test_alpha should exist"); + .chunk("mod_tests.fn_test_a") + .expect("fn_test_a should exist"); let chunk_b = state .inner() - .chunk("mod_tests.fn_test_beta") - .expect("fn_test_beta should exist"); + .chunk("mod_tests.fn_test_b") + .expect("fn_test_b should exist"); // Batch replace: add #[test] to both functions. let result = apply_edits(&state, &EditParams { operations: vec![ EditOperation { op: ChunkEditOp::Replace, - sel: Some("mod_tests.fn_test_alpha".to_owned()), + sel: Some("mod_tests.fn_test_a".to_owned()), crc: Some(chunk_a.checksum.clone()), region: None, content: Some("#[test]\nfn test_alpha() {\n\tnew_alpha();\n}".to_owned()), @@ -3548,7 +3554,7 @@ mod tests { }, EditOperation { op: ChunkEditOp::Replace, - sel: Some("mod_tests.fn_test_beta".to_owned()), + sel: Some("mod_tests.fn_test_b".to_owned()), crc: Some(chunk_b.checksum.clone()), region: None, content: Some("#[test]\nfn test_beta() {\n\tnew_beta();\n}".to_owned()), @@ -3600,20 +3606,20 @@ mod tests { // Verify the functions have children (sub-chunks). let chunk_a = state .inner() - .chunk("mod_tests.fn_test_alpha") - .expect("fn_test_alpha should exist"); + .chunk("mod_tests.fn_test_a") + .expect("fn_test_a should exist"); assert!( !chunk_a.children.is_empty(), - "fn_test_alpha should have children (sub-chunks), got: {:?}", + "fn_test_a should have children (sub-chunks), got: {:?}", chunk_a.children ); let chunk_b = state .inner() - .chunk("mod_tests.fn_test_beta") - .expect("fn_test_beta should exist"); + .chunk("mod_tests.fn_test_b") + .expect("fn_test_b should exist"); assert!( !chunk_b.children.is_empty(), - "fn_test_beta should have children (sub-chunks), got: {:?}", + "fn_test_b should have children (sub-chunks), got: {:?}", chunk_b.children ); @@ -3622,7 +3628,7 @@ mod tests { operations: vec![ EditOperation { op: ChunkEditOp::Replace, - sel: Some("mod_tests.fn_test_alpha".to_owned()), + sel: Some("mod_tests.fn_test_a".to_owned()), crc: Some(chunk_a.checksum.clone()), region: None, content: Some( @@ -3637,7 +3643,7 @@ mod tests { }, EditOperation { op: ChunkEditOp::Replace, - sel: Some("mod_tests.fn_test_beta".to_owned()), + sel: Some("mod_tests.fn_test_b".to_owned()), crc: Some(chunk_b.checksum.clone()), region: None, content: Some( @@ -3853,4 +3859,100 @@ function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>> assert!(result.diff_after.contains("return bar(1);")); assert!(!result.diff_after.contains("<<<<<<<")); } + + #[test] + fn exported_decorated_class_is_addressable() { + let source = concat!( + "function sealed(target: any) {}\n", + "\n", + "@sealed\n", + "export class Server {\n", + " start(): void {\n", + " console.log(\"starting\");\n", + " }\n", + " stop(): void {\n", + " console.log(\"stopping\");\n", + " }\n", + "}\n", + "\n", + "function formatLog(msg: string): string {\n", + " return `[LOG] ${msg}`;\n", + "}\n", + ); + let state = state_for(source, "typescript"); + let tree = state.inner().tree(); + + let class_chunk = tree + .chunks + .iter() + .find(|c| c.path == "class_Server") + .unwrap_or_else(|| { + panic!( + "class_Server should be in the chunk tree. Available chunks: {:?}", + tree.chunks.iter().map(|c| &c.path).collect::>() + ) + }); + assert!(!class_chunk.children.is_empty(), "class_Server should have child methods"); + + let start = state.inner().chunk("class_Server.fn_start"); + assert!(start.is_some(), "class_Server.fn_start should exist"); + let stop = state.inner().chunk("class_Server.fn_stop"); + assert!(stop.is_some(), "class_Server.fn_stop should exist"); + } + + #[test] + fn head_replace_on_nested_rust_fn_uniform_indent() { + let source = concat!( + "pub struct Server {\n", + "\thost: String,\n", + "\tport: u16,\n", + "}\n", + "\n", + "impl Server {\n", + "\tpub fn address(&self) -> String {\n", + "\t\tformat!(\"{}:{}\", self.host, self.port)\n", + "\t}\n", + "}\n", + ); + let state = state_for(source, "rust"); + let chunk = state + .inner() + .chunk("impl_Server.fn_addres") + .expect("impl_Server.fn_addres should exist"); + + let result = apply_single_edit(&state, "test.rs", EditOperation { + op: ChunkEditOp::Replace, + sel: Some(format!("impl_Server.fn_addres#{}@head", chunk.checksum)), + crc: None, + region: None, + content: Some( + "/// Returns the server address.\n#[must_use]\npub fn address(&self) -> String {\n" + .to_owned(), + ), + find: None, + }); + + assert!( + result + .diff_after + .contains("\t/// Returns the server address."), + "doc comment should be at 1-tab indent, got:\n{}", + result.diff_after + ); + assert!( + result.diff_after.contains("\t#[must_use]"), + "attribute should be at 1-tab indent, got:\n{}", + result.diff_after + ); + assert!( + result.diff_after.contains("\tpub fn address"), + "signature should be at 1-tab indent, got:\n{}", + result.diff_after + ); + assert!( + !result.diff_after.contains("\t\t///"), + "doc comment must NOT be double-indented, got:\n{}", + result.diff_after + ); + } } diff --git a/crates/pi-natives/src/chunk/kind.rs b/crates/pi-natives/src/chunk/kind.rs index 379718821..e1590b8d9 100644 --- a/crates/pi-natives/src/chunk/kind.rs +++ b/crates/pi-natives/src/chunk/kind.rs @@ -222,11 +222,11 @@ impl ChunkKind { Self::Array => "array", Self::At => "at", Self::Attr => "attr", - Self::AttrExpr => "attr_expr", + Self::AttrExpr => "aexpr", Self::Attrs => "attrs", Self::Block => "block", - Self::BlockIf => "block_if", - Self::BlockLocals => "block_locals", + Self::BlockIf => "blkif", + Self::BlockLocals => "blkloc", Self::Body => "body", Self::Case => "case", Self::Cases => "cases", @@ -237,15 +237,15 @@ impl ChunkKind { Self::Cmd => "cmd", Self::Code => "code", Self::Cond => "cond", - Self::Constructor => "constructor", - Self::Contract => "contract", + Self::Constructor => "ctor", + Self::Contract => "ctrct", Self::Copy => "copy", Self::Custom => "custom", Self::Declarations => "decls", Self::Decl => "decl", - Self::DefaultExport => "default_export", + Self::DefaultExport => "defexp", Self::Define => "define", - Self::Directive => "directive", + Self::Directive => "dir", Self::Either => "either", Self::Elif => "elif", Self::Else => "else", @@ -253,7 +253,7 @@ impl ChunkKind { Self::Env => "env", Self::Error => "error", Self::Except => "except", - Self::Exports => "exports", + Self::Exports => "exp", Self::Expose => "expose", Self::Expression => "expr", Self::Field => "field", @@ -262,107 +262,107 @@ impl ChunkKind { Self::Frame => "frame", Self::Function => "fn", Self::For => "for", - Self::ForIn => "for_in", - Self::ForOf => "for_of", + Self::ForIn => "forin", + Self::ForOf => "forof", Self::Form => "form", - Self::Frontmatter => "frontmatter", + Self::Frontmatter => "front", Self::Group => "group", - Self::GroupBy => "group_by", - Self::Headers => "headers", - Self::Healthcheck => "healthcheck", + Self::GroupBy => "grpby", + Self::Headers => "hdrs", + Self::Healthcheck => "hlthck", Self::Html => "html", Self::Hunks => "hunks", Self::Hunk => "hunk", Self::If => "if", - Self::Iface => "iface", + Self::Iface => "ifc", Self::Impl => "impl", - Self::Imports => "imports", - Self::Includes => "includes", - Self::InlineFragment => "inline_fragment", - Self::Install => "install", - Self::Interface => "interface", - Self::Interpolation => "interpolation", + Self::Imports => "imp", + Self::Includes => "incl", + Self::InlineFragment => "infrag", + Self::Install => "inst", + Self::Interface => "intf", + Self::Interpolation => "intrp", Self::Item => "item", Self::Join => "join", Self::Key => "key", - Self::KeyScripts => "key_scripts", + Self::KeyScripts => "keyscr", Self::Label => "label", Self::Let => "let", Self::List => "list", Self::Loop => "loop", Self::Macro => "macro", Self::Map => "map", - Self::Markdown => "markdown", + Self::Markdown => "md", Self::Match => "match", Self::Method => "meth", - Self::Methods => "methods", + Self::Methods => "meths", Self::Module => "mod", - Self::Mustache => "mustache", + Self::Mustache => "mst", Self::Object => "object", - Self::Operation => "operation", - Self::Operator => "operator", + Self::Operation => "op", + Self::Operator => "oper", Self::Option => "option", - Self::Options => "options", - Self::OrderBy => "order_by", + Self::Options => "opts", + Self::OrderBy => "ordby", Self::Parameters => "params", - Self::Preamble => "preamble", + Self::Preamble => "pre", Self::Proc => "proc", - Self::Process => "process", - Self::Project => "project", + Self::Process => "prcs", + Self::Project => "proj", Self::Proto => "proto", Self::Py => "py", Self::Python => "python", Self::Query => "query", - Self::Receive => "receive", + Self::Receive => "recv", Self::Recipe => "recipe", - Self::Relations => "relations", + Self::Relations => "rels", Self::Render => "render", Self::Return => "ret", Self::Root => "root", Self::Rule => "rule", Self::Schema => "schema", Self::Script => "script", - Self::ScriptModule => "script_module", - Self::ScriptSetup => "script_setup", - Self::Section => "section", + Self::ScriptModule => "scrmod", + Self::ScriptSetup => "scrset", + Self::Section => "sect", Self::Select => "select", - Self::Setting => "setting", - Self::Shebang => "shebang", + Self::Setting => "setng", + Self::Shebang => "shbng", Self::Shell => "shell", Self::Slot => "slot", - Self::Snippet => "snippet", + Self::Snippet => "snip", Self::Source => "source", Self::Stage => "stage", - Self::StaticInit => "static_init", + Self::StaticInit => "sinit", Self::Statements => "stmts", Self::Struct => "struct", Self::Style => "style", - Self::StyleScoped => "style_scoped", + Self::StyleScoped => "scoped", Self::Switch => "switch", Self::Table => "table", Self::Tag => "tag", Self::Target => "target", - Self::Template => "template", + Self::Template => "tmpl", Self::Text => "text", Self::Trait => "trait", - Self::Translation => "translation", + Self::Translation => "trans", Self::Try => "try", Self::Ts => "ts", Self::Type => "type", - Self::Typescript => "typescript", + Self::Typescript => "tysc", Self::Union => "union", Self::User => "user", Self::Val => "val", Self::Variable => "var", - Self::Variant => "variant", - Self::Variants => "variants", - Self::VersionGate => "version_gate", + Self::Variant => "vrnt", + Self::Variants => "vrnts", + Self::VersionGate => "vergt", Self::When => "when", Self::Where => "where", Self::While => "while", Self::With => "with", - Self::Workdir => "workdir", - Self::Conflict => "conflict", + Self::Workdir => "wkdir", + Self::Conflict => "cflct", Self::Ours => "ours", Self::Theirs => "theirs", Self::Chunk => "chunk", diff --git a/crates/pi-natives/src/chunk/mod.rs b/crates/pi-natives/src/chunk/mod.rs index d4e1e6541..655f6af35 100644 --- a/crates/pi-natives/src/chunk/mod.rs +++ b/crates/pi-natives/src/chunk/mod.rs @@ -69,7 +69,7 @@ use tree_sitter::{Node, Parser, Tree}; use xxhash_rust::xxh64::xxh64; use self::{ - classify::{LangClassifier, classifier_for, classify_with_tables, structural_overrides}, + classify::{LangClassifier, classifier_for, classify_with_defaults, structural_overrides}, common::*, kind::ChunkKind, }; @@ -486,19 +486,7 @@ fn classify_node<'tree>( source: &str, classifier: &dyn LangClassifier, ) -> RawChunkCandidate<'tree> { - if node.is_error() || node.kind() == "ERROR" { - return make_candidate(node, ChunkKind::Error, None, NameStyle::Error, None, None, source); - } - - // Try language-specific classifier first, then fall back to defaults. - match context { - ChunkContext::Root => classify_with_tables(classifier, context, node, source) - .unwrap_or_else(|| defaults::classify_root_default(node, source)), - ChunkContext::ClassBody => classify_with_tables(classifier, context, node, source) - .unwrap_or_else(|| defaults::classify_class_default(node, source)), - ChunkContext::FunctionBody => classify_with_tables(classifier, context, node, source) - .unwrap_or_else(|| defaults::classify_function_default(node, source)), - } + classify_with_defaults(classifier, context, node, source) } fn attach_leading_trivia<'tree>( @@ -589,7 +577,30 @@ fn group_candidates(candidates: Vec>) -> Vec String { + const MAX_IDENT_CHARS: usize = 6; + if name.len() <= MAX_IDENT_CHARS { + return name.to_string(); + } + let end = name + .char_indices() + .nth(MAX_IDENT_CHARS) + .map_or(name.len(), |(idx, _)| idx); + name[..end].trim_end_matches('_').to_string() +} + fn assign_unique_names(mut candidates: Vec>) -> Vec> { + // Truncate identifiers for path brevity before grouping. + for candidate in &mut candidates { + candidate.identifier = candidate + .identifier + .take() + .map(|id| truncate_path_name(&id)); + } + let mut totals = HashMap::::new(); for candidate in &candidates { let key = candidate.kind.path_segment(candidate.identifier.as_deref()); @@ -975,7 +986,7 @@ mod tests { module .children .iter() - .any(|child| child == "mod_Spec.operator_Init"), + .any(|child| child == "mod_Spec.oper_Init"), "expected Init operator child, got {:?}", module.children ); @@ -991,7 +1002,7 @@ mod tests { tree .chunks .iter() - .all(|chunk| !chunk.path.ends_with("operator_Next")), + .all(|chunk| !chunk.path.ends_with("oper_Next")), "translation-generated operator should be hidden: {:?}", tree .chunks @@ -1006,8 +1017,8 @@ mod tests { let json = build_chunk_tree("{\"scripts\":{\"start\":\"bun\"}}\n", "json") .expect("json tree should build"); assert!( - json.root_children.contains(&"key_scripts".to_string()), - "expected key_scripts, got {:?}", + json.root_children.contains(&"key_script".to_string()), + "expected key_script, got {:?}", json.root_children ); @@ -1028,25 +1039,25 @@ mod tests { let tree = build_chunk_tree(source, "yaml").expect("yaml tree should build"); assert!( - tree.root_children.contains(&"key_database".to_string()), - "expected key_database, got {:?}", + tree.root_children.contains(&"key_databa".to_string()), + "expected key_databa, got {:?}", tree.root_children ); let db = tree .chunks .iter() - .find(|c| c.path == "key_database") - .expect("key_database"); - assert!(!db.leaf, "key_database should have children: {:?}", db.children); + .find(|c| c.path == "key_databa") + .expect("key_databa"); + assert!(!db.leaf, "key_databa should have children: {:?}", db.children); assert!( db.children.iter().any(|c| c.contains("key_host")), "expected key_host child, got {:?}", db.children ); assert!( - db.children.iter().any(|c| c.contains("key_credentials")), - "expected key_credentials child, got {:?}", + db.children.iter().any(|c| c.contains("key_creden")), + "expected key_creden child, got {:?}", db.children ); @@ -1054,12 +1065,12 @@ mod tests { let creds = tree .chunks .iter() - .find(|c| c.path == "key_database.key_credentials") - .expect("key_credentials"); - assert!(!creds.leaf, "key_credentials should have children: {:?}", creds.children); + .find(|c| c.path == "key_databa.key_creden") + .expect("key_creden"); + assert!(!creds.leaf, "key_creden should have children: {:?}", creds.children); assert!( - creds.children.iter().any(|c| c.contains("key_username")), - "expected key_username child of credentials, got {:?}", + creds.children.iter().any(|c| c.contains("key_userna")), + "expected key_userna child of credentials, got {:?}", creds.children ); } @@ -1115,8 +1126,8 @@ mod tests { app.children ); assert!( - app.children.iter().any(|c| c.contains("key_features")), - "expected key_features child, got {:?}", + app.children.iter().any(|c| c.contains("key_featur")), + "expected key_featur child, got {:?}", app.children ); } @@ -1182,7 +1193,7 @@ function main(): void {{ .iter() .map(std::string::String::as_str) .collect::>(); - assert_eq!(child_names, vec!["imports", "class_Bla", "fn_main"]); + assert_eq!(child_names, vec!["imp", "class_Bla", "fn_main"]); let class_chunk = tree .chunks @@ -1194,17 +1205,17 @@ function main(): void {{ class_chunk .children .iter() - .any(|child| child == "class_Bla.constructor") + .any(|child| child == "class_Bla.ctor") ); assert!( class_chunk .children .iter() - .any(|child| child == "class_Bla.fn_onEvent") + .any(|child| child == "class_Bla.fn_onEven") ); let line_path = line_to_chunk_path(&tree, 15).expect("line should resolve"); - assert!(line_path.starts_with("class_Bla.fn_onEvent")); + assert!(line_path.starts_with("class_Bla.fn_onEven")); } #[test] @@ -1290,7 +1301,7 @@ function main(): void {{ return ev; };"; let tree = build_chunk_tree(source, "typescript").expect("tree should build"); - assert!(tree.chunks.iter().any(|c| c.path == "fn_handler"), "expected fn_handler chunk"); + assert!(tree.chunks.iter().any(|c| c.path == "fn_handle"), "expected fn_handler chunk"); assert!( !tree.root_children.contains(&"decls".to_string()), "arrow fn should not be grouped as decls" @@ -1319,8 +1330,8 @@ function main(): void {{ let chunk = tree .chunks .iter() - .find(|c| c.path == "fn_handler") - .expect("fn_handler"); + .find(|c| c.path == "fn_handle") + .expect("fn_handle"); assert!(chunk.leaf); assert_eq!(chunk.start_line, 1); assert_eq!(chunk.end_line, 3); @@ -1346,6 +1357,25 @@ function main(): void {{ assert_eq!(chunk.end_line, 3); } + #[test] + fn promotes_export_default_class_to_default_export_chunk() { + let source = r"export default class Foo { + method() { return 42; } +}"; + let tree = build_chunk_tree(source, "typescript").expect("tree should build"); + let chunk = tree + .chunks + .iter() + .find(|c| c.path == "defexp") + .expect("defexp"); + assert_eq!(chunk.start_line, 1); + assert_eq!(chunk.end_line, 3); + assert!( + !tree.root_children.contains(&"class_Foo".to_string()), + "default export should be remapped to defexp" + ); + } + #[test] fn small_interfaces_keep_children() { let source = r"interface Config { @@ -1356,8 +1386,8 @@ function main(): void {{ let iface = tree .chunks .iter() - .find(|c| c.path == "interface_Config") - .expect("interface_Config"); + .find(|c| c.path == "intf_Config") + .expect("intf_Config"); assert!(!iface.children.is_empty(), "interface members should be addressable as children"); } @@ -1389,7 +1419,7 @@ def main(): .to_string(); let tree = build_chunk_tree(source.as_str(), "python").expect("tree should build"); let names: Vec<&str> = tree.root_children.iter().map(String::as_str).collect(); - assert!(names.contains(&"imports"), "expected imports, got {names:?}"); + assert!(names.contains(&"imp"), "expected imports, got {names:?}"); assert!(names.contains(&"class_Server"), "expected class_Server, got {names:?}"); assert!(names.contains(&"fn_main"), "expected fn_main, got {names:?}"); let cls = tree @@ -1466,7 +1496,7 @@ fn main() { }"#; let tree = build_chunk_tree(source, "rust").expect("tree should build"); let names: Vec<&str> = tree.root_children.iter().map(String::as_str).collect(); - assert!(names.contains(&"imports"), "expected imports, got {names:?}"); + assert!(names.contains(&"imp"), "expected imports, got {names:?}"); assert!(names.contains(&"struct_Config"), "expected struct_Config, got {names:?}"); assert!(names.contains(&"impl_Config"), "expected impl_Config, got {names:?}"); assert!(names.contains(&"fn_main"), "expected fn_main, got {names:?}"); @@ -1513,10 +1543,7 @@ impl Config { }"#; let tree = build_chunk_tree(source, "rust").expect("tree should build"); let names: Vec<&str> = tree.root_children.iter().map(String::as_str).collect(); - assert!( - names.contains(&"impl_Display_for_Config"), - "expected impl_Display_for_Config, got {names:?}" - ); + assert!(names.contains(&"impl_Displa"), "expected impl_Displa, got {names:?}"); assert!(names.contains(&"impl_Config"), "expected impl_Config, got {names:?}"); } @@ -1535,8 +1562,8 @@ impl Config { server .children .iter() - .any(|c| c == "struct_Server.field_field_0"), - "expected field_field_0 in children: {:?}", + .any(|c| c == "struct_Server.field_field_1"), + "expected field_field_1 in children: {:?}", server.children ); } @@ -1560,7 +1587,7 @@ impl Config { }"#; let tree = build_chunk_tree(source, "go").expect("tree should build"); let names: Vec<&str> = tree.root_children.iter().map(String::as_str).collect(); - assert!(names.contains(&"imports"), "expected imports, got {names:?}"); + assert!(names.contains(&"imp"), "expected imports, got {names:?}"); assert!(names.contains(&"type_Config"), "expected type_Config, got {names:?}"); assert!(names.contains(&"type_Reader"), "expected type_Reader, got {names:?}"); assert!(names.contains(&"fn_main"), "expected fn_main, got {names:?}"); @@ -1674,20 +1701,20 @@ impl Config { let enum_chunk = tree .chunks .iter() - .find(|c| c.path == "enum_LogLevel") - .expect("enum_LogLevel"); + .find(|c| c.path == "enum_LogLev") + .expect("enum_LogLev"); assert!(!enum_chunk.leaf); assert!( enum_chunk .children .iter() - .any(|child| child == "enum_LogLevel.variant_Debug") + .any(|child| child == "enum_LogLev.vrnt_Debug") ); assert!( enum_chunk .children .iter() - .any(|child| child == "enum_LogLevel.variant_Error") + .any(|child| child == "enum_LogLev.vrnt_Error") ); } @@ -1700,8 +1727,8 @@ impl Config { let trait_chunk = tree .chunks .iter() - .find(|c| c.path == "trait_Handler") - .expect("trait_Handler"); + .find(|c| c.path == "trait_Handle") + .expect("trait_Handle"); assert!(!trait_chunk.children.is_empty(), "trait members should be addressable as children"); } @@ -1716,8 +1743,8 @@ impl Config { let iface = tree .chunks .iter() - .find(|c| c.path == "type_Handler") - .expect("type_Handler"); + .find(|c| c.path == "type_Handle") + .expect("type_Handle"); assert!(iface.leaf); assert!(iface.children.is_empty(), "single-line interface methods should render inline"); } @@ -1730,11 +1757,8 @@ impl Config { "; let tree = build_chunk_tree(source, "typescript").expect("tree should build"); assert!( - tree - .chunks - .iter() - .any(|chunk| chunk.path == "interface_Settings"), - "expected interface_Settings in {:?}", + tree.chunks.iter().any(|chunk| chunk.path == "intf_Settin"), + "expected intf_Settin in {:?}", tree .chunks .iter() @@ -1742,11 +1766,8 @@ impl Config { .collect::>() ); assert!( - !tree - .chunks - .iter() - .any(|chunk| chunk.path == "iface_Settings"), - "legacy iface_ prefix should not remain addressable" + !tree.chunks.iter().any(|chunk| chunk.path == "ifc_Settin"), + "legacy ifc_ prefix should not remain addressable" ); } @@ -1764,11 +1785,11 @@ impl Config { let chunk = state .chunks() .into_iter() - .find(|candidate| candidate.path == "fn_handleTerraform.try") + .find(|candidate| candidate.path == "fn_handle.try") .expect("try chunk path should exist"); let selectors = vec![ - format!("sample.ts:{}", "fn_handleTerraform.try"), - format!("sample.ts:{}", "handleTerraform.try"), + format!("sample.ts:{}", "fn_handle.try"), + format!("sample.ts:{}", "handle.try"), format!("sample.ts:{}", "try"), format!("sample.ts:try#{}", chunk.checksum), format!("sample.ts:#{}", chunk.checksum), @@ -1790,7 +1811,7 @@ impl Config { let resolved = result .chunk .expect("selector read should resolve a chunk target"); - assert_eq!(resolved.selector, format!("fn_handleTerraform.try#{}", chunk.checksum)); + assert_eq!(resolved.selector, format!("fn_handle.try#{}", chunk.checksum)); } } @@ -1856,7 +1877,7 @@ impl Config { let state = ChunkState::parse(source, "typescript".to_string()).expect("state should parse"); let result = state .render_read(ReadRenderParams { - read_path: "sample.ts:fn_loadSkills.try_2".to_string(), + read_path: "sample.ts:fn_loadSk.try_2".to_string(), display_path: "sample.ts".to_string(), language_tag: Some("ts".to_string()), omit_checksum: false, @@ -1871,9 +1892,9 @@ impl Config { assert_eq!(chunk.status, super::types::ChunkReadStatus::NotFound); let text = &result.text; - assert!(text.contains("Chunk path not found: \"fn_loadSkills.try_2\""), "{text}"); - assert!(text.contains("Direct children of \"fn_loadSkills\""), "{text}"); - assert!(text.contains("fn_loadSkills.try"), "{text}"); + assert!(text.contains("Chunk path not found: \"fn_loadSk.try_2\""), "{text}"); + assert!(text.contains("Direct children of \"fn_loadSk\""), "{text}"); + assert!(text.contains("fn_loadSk.try"), "{text}"); } #[test] @@ -1944,7 +1965,7 @@ impl Config { ChunkState::parse(source.to_string(), "python".to_string()).expect("state should parse"); let result = state .render_read(ReadRenderParams { - read_path: "test.py:class_Server.fn_address@head".to_string(), + read_path: "test.py:class_Server.fn_addres@head".to_string(), display_path: "test.py".to_string(), language_tag: Some("py".to_string()), omit_checksum: false, @@ -2041,13 +2062,13 @@ func (s *Server) Start() string { enum_chunk .children .iter() - .any(|child| child == "enum_Status.variant_Idle") + .any(|child| child == "enum_Status.vrnt_Idle") ); assert!( enum_chunk .children .iter() - .any(|child| child == "enum_Status.variant_Busy") + .any(|child| child == "enum_Status.vrnt_Busy") ); } @@ -2128,23 +2149,20 @@ struct Config { let enum_chunk = tree .chunks .iter() - .find(|c| c.path == "enum_Message") - .expect("enum_Message"); + .find(|c| c.path == "enum_Messag") + .expect("enum_Messag"); assert!(!enum_chunk.children.is_empty(), "non-trivial enum should have children"); assert!( - tree - .chunks - .iter() - .any(|c| c.path == "enum_Message.variant_Ok"), - "expected variant_Ok, got children: {:?}", + tree.chunks.iter().any(|c| c.path == "enum_Messag.vrnt_Ok"), + "expected vrnt_Ok, got children: {:?}", enum_chunk.children ); assert!( tree .chunks .iter() - .any(|c| c.path == "enum_Message.variant_Error"), - "expected variant_Error, got children: {:?}", + .any(|c| c.path == "enum_Messag.vrnt_Error"), + "expected vrnt_Error, got children: {:?}", enum_chunk.children ); } @@ -2179,32 +2197,32 @@ struct Config { end "#; let tree = build_chunk_tree(source, "ruby").expect("tree should build"); - assert_eq!(tree.root_children, vec!["mod_PaymentProcessing"]); + assert_eq!(tree.root_children, vec!["mod_Paymen"]); let module = tree .chunks .iter() - .find(|c| c.path == "mod_PaymentProcessing") - .expect("mod_PaymentProcessing"); + .find(|c| c.path == "mod_Paymen") + .expect("mod_Paymen"); assert!(!module.leaf); assert!( module .children .iter() - .any(|c| c == "mod_PaymentProcessing.class_Money"), + .any(|c| c == "mod_Paymen.class_Money"), "expected class_Money inside module, got {:?}", module.children ); let class = tree .chunks .iter() - .find(|c| c.path == "mod_PaymentProcessing.class_Money") + .find(|c| c.path == "mod_Paymen.class_Money") .expect("class_Money"); assert!(!class.leaf); assert!( class .children .iter() - .any(|c| c == "mod_PaymentProcessing.class_Money.constructor"), + .any(|c| c == "mod_Paymen.class_Money.ctor"), "expected constructor in class children: {:?}", class.children ); @@ -2212,7 +2230,7 @@ end class .children .iter() - .any(|c| c == "mod_PaymentProcessing.class_Money.fn_zero"), + .any(|c| c == "mod_Paymen.class_Money.fn_zero"), "expected fn_zero in class children: {:?}", class.children ); @@ -2220,7 +2238,7 @@ end class .children .iter() - .any(|c| c == "mod_PaymentProcessing.class_Money.fn_to_s"), + .any(|c| c == "mod_Paymen.class_Money.fn_to_s"), "expected fn_to_s in class children: {:?}", class.children ); @@ -2228,7 +2246,7 @@ end class .children .iter() - .any(|c| c == "mod_PaymentProcessing.class_Money.fn_validate"), + .any(|c| c == "mod_Paymen.class_Money.fn_valida"), "expected fn_validate in class children: {:?}", class.children ); @@ -2246,8 +2264,8 @@ end let enum_chunk = tree .chunks .iter() - .find(|c| c.path == "enum_Message") - .expect("enum_Message"); + .find(|c| c.path == "enum_Messag") + .expect("enum_Messag"); assert!(!enum_chunk.leaf); assert!(!enum_chunk.children.is_empty(), "mixed-size variants should stay addressable"); } @@ -2315,22 +2333,22 @@ end let a = tree .chunks .iter() - .find(|c| c.path == "section_Top.section_A") - .expect("section_A"); + .find(|c| c.path == "sect_Top.sect_A") + .expect("sect_A"); let b = tree .chunks .iter() - .find(|c| c.path == "section_Top.section_B") - .expect("section_B"); + .find(|c| c.path == "sect_Top.sect_B") + .expect("sect_B"); let c = tree .chunks .iter() - .find(|c| c.path == "section_Top.section_C") - .expect("section_C"); + .find(|c| c.path == "sect_Top.sect_C") + .expect("sect_C"); assert!( a.end_line < b.start_line, - "section_A ({}-{}) must not overlap section_B ({}-{})", + "sect_A ({}-{}) must not overlap section_B ({}-{})", a.start_line, a.end_line, b.start_line, @@ -2338,7 +2356,7 @@ end ); assert!( b.end_line < c.start_line, - "section_B ({}-{}) must not overlap section_C ({}-{})", + "sect_B ({}-{}) must not overlap section_C ({}-{})", b.start_line, b.end_line, c.start_line, @@ -2354,8 +2372,8 @@ end let package = tree .chunks .iter() - .find(|c| c.path == "table_package") - .expect("table_package"); + .find(|c| c.path == "table_packag") + .expect("table_packag"); let deps = tree .chunks .iter() @@ -2369,7 +2387,7 @@ end assert!( package.end_line < deps.start_line, - "table_package ({}-{}) must not overlap table_deps ({}-{})", + "table_packag ({}-{}) must not overlap table_deps ({}-{})", package.start_line, package.end_line, deps.start_line, @@ -2420,8 +2438,8 @@ end let fn_chunk = tree .chunks .iter() - .find(|c| c.path == "class_Server.fn_address") - .expect("fn_address should exist"); + .find(|c| c.path == "class_Server.fn_addres") + .expect("fn_addres should exist"); let orphan_ret = tree.chunks.iter().find(|c| c.path.contains("ret")); assert!( @@ -2508,8 +2526,8 @@ end let fn_chunk = tree .chunks .iter() - .find(|c| c.path == "class_Server.fn_address") - .expect("fn_address"); + .find(|c| c.path == "class_Server.fn_addres") + .expect("fn_addres"); let (head_s, head_e) = chunk_region_range(fn_chunk, ChunkRegion::Head); let head = &source[head_s..head_e]; @@ -2633,13 +2651,13 @@ end 100644\n--- a/src/bar.ts\n+++ b/src/bar.ts\n@@ -5,2 +5,3 @@\n x\n+y\n z\n"; let tree = build_chunk_tree(source, "diff").expect("diff tree should build"); assert!( - tree.root_children.contains(&"file_src_foo_ts".to_string()), - "expected file_src_foo_ts, got {:?}", + tree.root_children.contains(&"file_src_fo".to_string()), + "expected file_src_fo, got {:?}", tree.root_children ); assert!( - tree.root_children.contains(&"file_src_bar_ts".to_string()), - "expected file_src_bar_ts, got {:?}", + tree.root_children.contains(&"file_src_ba".to_string()), + "expected file_src_ba, got {:?}", tree.root_children ); } @@ -2681,8 +2699,8 @@ end @@\n-line1\n-line2\n"; let tree = build_chunk_tree(source, "diff").expect("diff tree should build"); assert!( - tree.root_children.contains(&"file_old_txt".to_string()), - "expected file_old_txt for deleted file, got {:?}", + tree.root_children.contains(&"file_old_tx".to_string()), + "expected file_old_tx for deleted file, got {:?}", tree.root_children ); } diff --git a/crates/pi-natives/src/chunk/shape.rs b/crates/pi-natives/src/chunk/shape.rs index 1e9463272..0d5c88d34 100644 --- a/crates/pi-natives/src/chunk/shape.rs +++ b/crates/pi-natives/src/chunk/shape.rs @@ -84,10 +84,6 @@ pub fn is_generic_trivia(node: Node<'_>) -> bool { node.is_extra() || kind_looks_like_comment(node.kind()) } -pub fn is_generic_trivia_name(kind: &str) -> bool { - kind_looks_like_comment(kind) -} - pub fn is_generic_absorbable_attr(kind: &str) -> bool { matches!(kind, "attribute_item" | "inner_attribute_item") } diff --git a/crates/pi-natives/src/indent.rs b/crates/pi-natives/src/indent.rs deleted file mode 100644 index cc1f09e39..000000000 --- a/crates/pi-natives/src/indent.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! Tab width resolution from `.editorconfig` and process defaults. - -use std::{ - collections::HashMap, - path::{Path, PathBuf}, - sync::LazyLock, -}; - -use dashmap::DashMap; -use globset::GlobSet; -use napi::bindgen_prelude::*; -use napi_derive::napi; -use path_clean::PathClean; - -use crate::{glob_util::compile_glob, text}; - -const MIN_TAB_WIDTH: u32 = 1; -const MAX_TAB_WIDTH: u32 = 16; -const EDITORCONFIG_NAME: &str = ".editorconfig"; - -static EDITOR_CONFIG_CACHE: LazyLock> = - LazyLock::new(DashMap::new); -static EDITOR_CONFIG_CHAIN_CACHE: LazyLock>> = - LazyLock::new(DashMap::new); -static INDENTATION_CACHE: LazyLock> = LazyLock::new(DashMap::new); - -#[derive(Clone)] -struct EditorConfigSection { - pattern: String, - properties: HashMap, -} - -#[derive(Clone)] -struct ParsedEditorConfig { - root: bool, - sections: Vec, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum IndentStyle { - Space, - Tab, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum IndentSize { - Spaces(u32), - Tab, -} - -struct EditorConfigMatch { - indent_style: Option, - indent_size: Option, - tab_width: Option, -} - -fn clamp_tab_width(value: u32) -> u32 { - value.clamp(MIN_TAB_WIDTH, MAX_TAB_WIDTH) -} - -fn parse_positive_integer(value: Option<&str>) -> Option { - let value = value?; - if !value.chars().all(|c| c.is_ascii_digit()) { - return None; - } - let parsed: u32 = value.parse().ok()?; - if parsed == 0 { - return None; - } - Some(clamp_tab_width(parsed)) -} - -fn parse_editor_config_file(content: &str) -> ParsedEditorConfig { - let mut parsed = ParsedEditorConfig { root: false, sections: Vec::new() }; - let mut current_section_idx: Option = None; - - for raw_line in content.lines() { - let line = raw_line.trim(); - if line.is_empty() { - continue; - } - if line.starts_with('#') || line.starts_with(';') { - continue; - } - - if line.starts_with('[') && line.ends_with(']') && line.len() >= 2 { - let pattern = line[1..line.len() - 1].trim(); - if pattern.is_empty() { - current_section_idx = None; - continue; - } - parsed.sections.push(EditorConfigSection { - pattern: pattern.to_string(), - properties: HashMap::new(), - }); - current_section_idx = Some(parsed.sections.len() - 1); - continue; - } - - let Some((key, value)) = line.split_once('=') else { - continue; - }; - let key = key.trim().to_lowercase(); - let value = value.trim().to_lowercase(); - if key.is_empty() { - continue; - } - - if let Some(idx) = current_section_idx { - parsed.sections[idx].properties.insert(key, value); - } else if key == "root" { - parsed.root = value == "true"; - } - } - - parsed -} - -fn parse_cached_editor_config(config_path: &Path) -> Option { - let key = config_path.to_string_lossy().into_owned(); - if let Some(cached) = EDITOR_CONFIG_CACHE.get(&key) { - return Some(cached.clone()); - } - - let content = std::fs::read_to_string(config_path).ok()?; - let parsed = parse_editor_config_file(&content); - EDITOR_CONFIG_CACHE.insert(key, parsed.clone()); - Some(parsed) -} - -fn matches_editor_config_pattern(pattern: &str, relative_path: &str) -> bool { - let normalized = pattern.trim_start_matches('/'); - if normalized.is_empty() { - return false; - } - - let candidates: Vec> = if normalized.contains('/') { - vec![compile_glob(normalized, false)] - } else { - vec![compile_glob(normalized, false), compile_glob(normalized, true)] - }; - - for gs in candidates { - if let Ok(set) = gs - && set.is_match(relative_path) - { - return true; - } - } - - false -} - -fn resolve_file_path(project_dir: &Path, file: &str) -> PathBuf { - let p = Path::new(file); - if p.is_absolute() { - p.to_path_buf().clean() - } else { - project_dir.join(file).clean() - } -} - -fn collect_editor_config_chain(start_dir: &Path) -> Vec<(PathBuf, ParsedEditorConfig)> { - let key = start_dir.to_string_lossy().into_owned(); - if let Some(cached) = EDITOR_CONFIG_CHAIN_CACHE.get(&key) { - return cached.clone(); - } - - let mut chain = Vec::new(); - let mut cursor = start_dir.to_path_buf(); - loop { - let config_path = cursor.join(EDITORCONFIG_NAME); - if let Some(parsed) = parse_cached_editor_config(&config_path) { - let stop = parsed.root; - chain.push((cursor.clone(), parsed)); - if stop { - break; - } - } - - let Some(parent) = cursor.parent() else { - break; - }; - if parent == cursor { - break; - } - cursor = parent.to_path_buf(); - } - - chain.reverse(); - EDITOR_CONFIG_CHAIN_CACHE.insert(key, chain.clone()); - chain -} - -fn relative_path_unified(base: &Path, file: &Path) -> String { - pathdiff::diff_paths(file, base) - .unwrap_or_else(|| PathBuf::from(".")) - .to_string_lossy() - .replace('\\', "/") -} - -fn resolve_editor_config_match(absolute_file: &Path) -> Option { - let file_dir = absolute_file.parent()?; - let chain = collect_editor_config_chain(file_dir); - if chain.is_empty() { - return None; - } - - let mut match_ = - EditorConfigMatch { indent_style: None, indent_size: None, tab_width: None }; - - for (dir, parsed) in chain { - let relative_path = relative_path_unified(&dir, absolute_file); - for section in &parsed.sections { - if !matches_editor_config_pattern(§ion.pattern, &relative_path) { - continue; - } - - if let Some(style) = section.properties.get("indent_style") { - match style.as_str() { - "space" => match_.indent_style = Some(IndentStyle::Space), - "tab" => match_.indent_style = Some(IndentStyle::Tab), - _ => {}, - } - } - - if let Some(raw) = section.properties.get("indent_size") { - if raw == "tab" { - match_.indent_size = Some(IndentSize::Tab); - } else if let Some(n) = parse_positive_integer(Some(raw.as_str())) { - match_.indent_size = Some(IndentSize::Spaces(n)); - } - } - - if let Some(tw) = - parse_positive_integer(section.properties.get("tab_width").map(|s| s.as_str())) - { - match_.tab_width = Some(tw); - } - } - } - - if match_.indent_style.is_some() || match_.indent_size.is_some() || match_.tab_width.is_some() { - Some(match_) - } else { - None - } -} - -fn resolve_editor_config_tab_width( - match_: Option<&EditorConfigMatch>, - fallback: u32, -) -> Option { - let m = match_?; - - if let Some(IndentSize::Spaces(n)) = m.indent_size { - return Some(n); - } - - if m.indent_size == Some(IndentSize::Tab) { - if let Some(tw) = m.tab_width { - return Some(tw); - } - return Some(fallback); - } - - if let Some(tw) = m.tab_width { - return Some(tw); - } - - if m.indent_style == Some(IndentStyle::Tab) { - return Some(fallback); - } - - None -} - -/// Returns a string of spaces used to replace one tab, using `.editorconfig` -/// when present. -pub fn get_indentation(project_dir: &Path, file: Option, fallback: u32) -> u32 { - let Some(file) = file else { - return fallback; - }; - - let absolute_file = resolve_file_path(project_dir, &file); - let abs_key = absolute_file.to_string_lossy().into_owned(); - if let Some(cached) = INDENTATION_CACHE.get(&abs_key) { - return *cached; - } - - let editor_match = resolve_editor_config_match(&absolute_file); - let resolved_width = - resolve_editor_config_tab_width(editor_match.as_ref(), fallback).unwrap_or(fallback); - let clamped = clamp_tab_width(resolved_width); - INDENTATION_CACHE.insert(abs_key, clamped); - clamped -} - -/// Get the indentation for a file. -#[napi(js_name = "getIndentation")] -pub fn get_indentation_napi( - env: &napi::Env, - file: Option, - project_dir: Option, -) -> u32 { - let fallback = text::operation_tab_width(env, None); - let project_dir: &Path = match &project_dir { - Some(p) => Path::new(p), - None => &std::env::current_dir().unwrap(), - }; - - get_indentation(project_dir, file, fallback as u32) -} diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 72cb5c9b8..cf0790afb 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -38,7 +38,6 @@ pub mod grep; pub mod highlight; pub mod html; pub mod image; -pub mod indent; pub mod keys; pub mod language; diff --git a/crates/pi-natives/src/text.rs b/crates/pi-natives/src/text.rs index a28c03e0a..a00a6badc 100644 --- a/crates/pi-natives/src/text.rs +++ b/crates/pi-natives/src/text.rs @@ -8,10 +8,7 @@ //! - Ellipsis decoded lazily //! - truncateToWidth returns the original `JsString` when possible -use std::{ - cell::{Cell, RefCell}, - sync::atomic::{AtomicU32, Ordering}, -}; +use std::cell::RefCell; use napi::{JsString, bindgen_prelude::*}; use napi_derive::napi; @@ -24,47 +21,9 @@ const MAX_TAB_WIDTH: u32 = 16; pub const DEFAULT_TAB_WIDTH: usize = 3; const ESC: u16 = 0x1b; -static PROCESS_TAB_WIDTH: AtomicU32 = AtomicU32::new(DEFAULT_TAB_WIDTH as u32); - -thread_local! { - static THREAD_TAB_WIDTH: Cell = const { Cell::new(0) }; -} - -struct EnvTabWidth { - width: u32, -} - -/// Set the default tab width for the process. -#[napi] -pub fn set_default_tab_width(env: &napi::Env, width: u32) { - // #1. env-wide tab width - _ = env.set_instance_data(EnvTabWidth { width }, 0, |_| {}); - // #2. thread-local tab width - THREAD_TAB_WIDTH.set(width); - // #3. process-wide tab width - PROCESS_TAB_WIDTH.store(width, Ordering::Relaxed); -} - -/// Get the default tab width for the process. -#[napi] -pub fn get_default_tab_width(env: &napi::Env) -> u32 { - // #1. env-wide tab width - if let Ok(Some(data)) = env.get_instance_data::() { - return data.width; - } - // #2. thread-local tab width - let tls = THREAD_TAB_WIDTH.get(); - if tls > 0 { - return tls; - } - // #3. process-wide tab width - PROCESS_TAB_WIDTH.load(Ordering::Relaxed) -} - -pub fn operation_tab_width(env: &napi::Env, explicit: Option) -> usize { - explicit - .unwrap_or_else(|| get_default_tab_width(env)) - .clamp(MIN_TAB_WIDTH, MAX_TAB_WIDTH) as usize +#[inline] +fn clamp_tab_width_for_ops(width: u32) -> usize { + width.clamp(MIN_TAB_WIDTH, MAX_TAB_WIDTH) as usize } /// Ellipsis strategy for [`truncate_to_width`]. @@ -831,14 +790,9 @@ fn wrap_text_with_ansi_impl( /// /// Returns UTF-16 lines with active SGR codes carried across line boundaries. #[napi] -pub fn wrap_text_with_ansi( - env: &napi::Env, - text: JsString, - width: u32, - tab_width: Option, -) -> Result> { +pub fn wrap_text_with_ansi(text: JsString, width: u32, tab_width: u32) -> Result> { let text_u16 = text.into_utf16()?; - let tab_width = operation_tab_width(env, tab_width); + let tab_width = clamp_tab_width_for_ops(tab_width); let lines = wrap_text_with_ansi_impl(text_u16.as_slice(), width as usize, tab_width); Ok(lines.into_iter().map(build_utf16_string).collect()) } @@ -851,18 +805,17 @@ pub fn wrap_text_with_ansi( /// /// Pads with spaces when requested. #[napi] -pub fn truncate_to_width<'a>( - env: &napi::Env, - text: JsString<'a>, +pub fn truncate_to_width( + text: JsString<'_>, max_width: u32, ellipsis_kind: Option, pad: Option, - tab_width: Option, -) -> Result, Utf16String>> { + tab_width: u32, +) -> Result, Utf16String>> { let max_width = max_width as usize; let ellipsis_kind = ellipsis_kind.unwrap_or(Ellipsis::Unicode); let pad = pad.unwrap_or(false); - let tab_width = operation_tab_width(env, tab_width); + let tab_width = clamp_tab_width_for_ops(tab_width); // Keep original handle so we can return it without allocating. let original = text; @@ -1117,12 +1070,11 @@ fn slice_with_width_impl( /// width. #[napi] pub fn slice_with_width( - env: &napi::Env, line: JsString, start_col: u32, length: u32, strict: Option, - tab_width: Option, + tab_width: u32, ) -> Result { let line_u16 = line.into_utf16()?; let line = line_u16.as_slice(); @@ -1132,7 +1084,7 @@ pub fn slice_with_width( return Ok(SliceResult { text: build_utf16_string(vec![]), width: 0 }); } - let tab_width = operation_tab_width(env, tab_width); + let tab_width = clamp_tab_width_for_ops(tab_width); let (out, w) = slice_with_width_impl(line, start_col as usize, length as usize, strict, tab_width); @@ -1283,18 +1235,17 @@ fn extract_segments_impl( /// truncation. #[napi] pub fn extract_segments( - env: &napi::Env, line: JsString, before_end: u32, after_start: u32, after_len: u32, strict_after: bool, - tab_width: Option, + tab_width: u32, ) -> Result { let line_u16 = line.into_utf16()?; let line = line_u16.as_slice(); - let tab_width = operation_tab_width(env, tab_width); + let tab_width = clamp_tab_width_for_ops(tab_width); let (before, bw, after, aw) = extract_segments_impl( line, before_end as usize, @@ -1403,9 +1354,9 @@ pub fn sanitize_text(text: JsString<'_>) -> Result, Utf16Str /// /// Tabs count as a fixed-width cell. #[napi] -pub fn visible_width(env: &napi::Env, text: JsString, tab_width: Option) -> Result { +pub fn visible_width(text: JsString, tab_width: u32) -> Result { let text_u16 = text.into_utf16()?; - let tab_width = operation_tab_width(env, tab_width); + let tab_width = clamp_tab_width_for_ops(tab_width); Ok(crate::utils::clamp_u32(visible_width_u16(text_u16.as_slice(), tab_width) as u64)) } diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 7f60e90fd..28c8aad89 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -13,8 +13,15 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { setDefaultTabWidth } from "@oh-my-pi/pi-natives"; -import { getAgentDbPath, getAgentDir, getProjectDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils"; +import { + getAgentDbPath, + getAgentDir, + getProjectDir, + isEnoent, + logger, + procmgr, + setDefaultTabWidth, +} from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings"; import type { ModelRole } from "../config/model-registry"; diff --git a/packages/coding-agent/src/debug/log-formatting.ts b/packages/coding-agent/src/debug/log-formatting.ts index 4f0b6d4bb..155610569 100644 --- a/packages/coding-agent/src/debug/log-formatting.ts +++ b/packages/coding-agent/src/debug/log-formatting.ts @@ -1,5 +1,5 @@ -import { sanitizeText, wrapTextWithAnsi } from "@oh-my-pi/pi-natives"; -import { replaceTabs, truncateToWidth } from "../tools/render-utils"; +import { sanitizeText } from "@oh-my-pi/pi-natives"; +import { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "../tools/render-utils"; export function formatDebugLogLine(line: string, maxWidth: number): string { const sanitized = sanitizeText(line); diff --git a/packages/coding-agent/src/modes/components/diff.ts b/packages/coding-agent/src/modes/components/diff.ts index badfe3fd6..375933b5a 100644 --- a/packages/coding-agent/src/modes/components/diff.ts +++ b/packages/coding-agent/src/modes/components/diff.ts @@ -1,4 +1,4 @@ -import { getIndentation } from "@oh-my-pi/pi-natives"; +import { getIndentation } from "@oh-my-pi/pi-utils"; import * as Diff from "diff"; import { theme } from "../../modes/theme/theme"; import { replaceTabs } from "../../tools/render-utils"; diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index 6fae05e7b..f0a736c68 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -14,7 +14,7 @@ import type { Theme } from "../modes/theme/theme"; import { formatDimensionNote, type ResizedImage } from "../utils/image-resize"; export { Ellipsis } from "@oh-my-pi/pi-natives"; -export { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui"; +export { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui"; // ============================================================================= // Standardized Display Constants diff --git a/packages/coding-agent/test/config-spacing.test.ts b/packages/coding-agent/test/config-spacing.test.ts index 0519698f5..18e3d0de6 100644 --- a/packages/coding-agent/test/config-spacing.test.ts +++ b/packages/coding-agent/test/config-spacing.test.ts @@ -3,8 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { getDefaultTabWidth, getIndentation, setDefaultTabWidth } from "@oh-my-pi/pi-natives"; -import { Snowflake } from "@oh-my-pi/pi-utils"; +import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils"; describe("indentation resolver", () => { let tempDir = ""; diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index d54f81616..7ee74214b 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Made `tabWidth` parameter required (no longer optional) for `visibleWidth`, `truncateToWidth`, `wrapTextWithAnsi`, `sliceWithWidth`, and `extractSegments` +- Removed `getIndentation`, `getDefaultTabWidth`, and `setDefaultTabWidth` (moved to `@oh-my-pi/pi-utils`) +- `visibleWidth`, `truncateToWidth`, `wrapTextWithAnsi`, `sliceWithWidth`, and `extractSegments` now require an explicit `tabWidth` argument ## [14.0.4] - 2026-04-10 diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 52067a172..7bb4039c4 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -598,7 +598,7 @@ export declare function executeShell(options: ShellExecuteOptions, onChunk?: ((e * Preserves ANSI state so the `after` segment renders correctly after * truncation. */ -export declare function extractSegments(line: string, beforeEnd: number, afterStart: number, afterLen: number, strictAfter: boolean, tabWidth?: number | undefined | null): ExtractSegmentsResult +export declare function extractSegments(line: string, beforeEnd: number, afterStart: number, afterLen: number, strictAfter: boolean, tabWidth: number): ExtractSegmentsResult /** Before/after UTF-16 segments around an overlay region, with measured widths. */ export interface ExtractSegmentsResult { @@ -683,12 +683,6 @@ export interface FuzzyFindResult { totalMatches: number } -/** Get the default tab width for the process. */ -export declare function getDefaultTabWidth(): number - -/** Get the indentation for a file. */ -export declare function getIndentation(file?: string | undefined | null, projectDir?: string | undefined | null): number - /** Get list of supported languages. */ export declare function getSupportedLanguages(): Array @@ -1266,9 +1260,6 @@ export interface SearchResult { error?: string } -/** Set the default tab width for the process. */ -export declare function setDefaultTabWidth(width: number): void - /** Options for executing a shell command via brush-core. */ export interface ShellExecuteOptions { /** Command string to execute in the shell. */ @@ -1346,7 +1337,7 @@ export interface SliceResult { * Counts terminal cells, skipping ANSI escapes, and optionally enforces strict * width. */ -export declare function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | undefined | null, tabWidth?: number | undefined | null): SliceResult +export declare function sliceWithWidth(line: string, startCol: number, length: number, strict: boolean | undefined | null, tabWidth: number): SliceResult /** * Check if a language is supported for highlighting. @@ -1360,7 +1351,7 @@ export declare function supportsLanguage(lang: string): boolean * * Pads with spaces when requested. */ -export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind?: Ellipsis | undefined | null, pad?: boolean | undefined | null, tabWidth?: number | undefined | null): string +export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind: Ellipsis | undefined | null, pad: boolean | undefined | null, tabWidth: number): string /** * Inclusive 1-based line range within a source file (used for scoped chunk @@ -1378,7 +1369,7 @@ export interface VisibleLineRange { * * Tabs count as a fixed-width cell. */ -export declare function visibleWidth(text: string, tabWidth?: number | undefined | null): number +export declare function visibleWidth(text: string, tabWidth: number): number /** Profiling results returned to JavaScript. */ export interface WorkProfile { @@ -1400,4 +1391,4 @@ export interface WorkProfile { * * Returns UTF-16 lines with active SGR codes carried across line boundaries. */ -export declare function wrapTextWithAnsi(text: string, width: number, tabWidth?: number | undefined | null): Array +export declare function wrapTextWithAnsi(text: string, width: number, tabWidth: number): Array diff --git a/packages/natives/test/native.test.ts b/packages/natives/test/native.test.ts index 05e3aed40..3f6c7bcda 100644 --- a/packages/natives/test/native.test.ts +++ b/packages/natives/test/native.test.ts @@ -338,7 +338,7 @@ describe("pi-natives", () => { describe("text tab width", () => { it("uses default tab width and supports explicit overrides", () => { - expect(visibleWidth("a\tb")).toBe(5); + expect(visibleWidth("a\tb", 3)).toBe(5); expect(visibleWidth("a\tb", 4)).toBe(6); expect(visibleWidth("a\tb", 2)).toBe(4); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 7b6a5a336..c61b94660 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### Changed + +- Wrapped native text utility functions (`sliceWithWidth`, `truncateToWidth`, `wrapTextWithAnsi`, `extractSegments`) to automatically pass the current default tab width, simplifying the API for consumers +- Added `getIndentationNoescape` wrapper that uses `process.cwd()` as the project root for relative file paths +- Re-export `getDefaultTabWidth`, `getIndentation`, and `setDefaultTabWidth` from `@oh-my-pi/pi-utils`; native text helpers still receive tab width via wrappers that read the JS default ## [13.16.1] - 2026-03-27 diff --git a/packages/tui/bench/width.ts b/packages/tui/bench/width.ts index 3ed0ebfa2..9f8820b38 100644 --- a/packages/tui/bench/width.ts +++ b/packages/tui/bench/width.ts @@ -4,6 +4,7 @@ * Run: bun packages/tui/bench/visible-width.ts */ import { visibleWidth as nativeVisibleWidth } from "@oh-my-pi/pi-natives"; +import { getDefaultTabWidth } from "@oh-my-pi/pi-utils"; import { visibleWidthRaw as hybridVisibleWidth, replaceTabs } from "../src/utils"; const ITERATIONS = 10_000; @@ -99,9 +100,10 @@ for (const [sampleName, sample] of Object.entries(samples)) { const results: BenchResult[] = []; + const tabW = getDefaultTabWidth(); results.push( bench("native", () => { - nativeVisibleWidth(sample); + nativeVisibleWidth(sample, tabW); }), ); @@ -126,7 +128,7 @@ for (const [sampleName, sample] of Object.entries(samples)) { } // Verify correctness - const nativeResult = nativeVisibleWidth(sample); + const nativeResult = nativeVisibleWidth(sample, tabW); const bunResult = bunStringWidth(sample); const hybridResult = hybridVisibleWidth(sample); @@ -153,13 +155,15 @@ const categories = { mixed: ["mixed_short", "mixed_medium", "mixed_long"], }; +const benchTabW = getDefaultTabWidth(); + for (const [category, sampleNames] of Object.entries(categories)) { const categoryResults = { native: 0, bun: 0, hybrid: 0 }; for (const name of sampleNames) { const sample = samples[name as keyof typeof samples]; - const nativeTime = bench("", () => nativeVisibleWidth(sample)).perOpUs; + const nativeTime = bench("", () => nativeVisibleWidth(sample, benchTabW)).perOpUs; const bunTime = bench("", () => bunStringWidth(sample)).perOpUs; const hybridTime = bench("", () => hybridVisibleWidth(sample)).perOpUs; diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 9dfc5f06e..1a4a0a680 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -1,10 +1,53 @@ -import { getDefaultTabWidth, getIndentation, sliceWithWidth } from "@oh-my-pi/pi-natives"; +import type { Ellipsis, ExtractSegmentsResult, SliceResult } from "@oh-my-pi/pi-natives"; +import { + extractSegments as nativeExtractSegments, + sliceWithWidth as nativeSliceWithWidth, + truncateToWidth as nativeTruncateToWidth, + wrapTextWithAnsi as nativeWrapTextWithAnsi, +} from "@oh-my-pi/pi-natives"; +import { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils"; -export { Ellipsis, extractSegments, sliceWithWidth, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-natives"; +export { Ellipsis } from "@oh-my-pi/pi-natives"; + +export { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils"; + +export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult { + return nativeSliceWithWidth(line, startCol, length, strict, getDefaultTabWidth()); +} + +export function truncateToWidth( + text: string, + maxWidth: number, + ellipsisKind?: Ellipsis | null, + pad?: boolean | null, +): string { + return nativeTruncateToWidth(text, maxWidth, ellipsisKind, pad, getDefaultTabWidth()); +} + +export function wrapTextWithAnsi(text: string, width: number): string[] { + return nativeWrapTextWithAnsi(text, width, getDefaultTabWidth()); +} + +export function extractSegments( + line: string, + beforeEnd: number, + afterStart: number, + afterLen: number, + strictAfter: boolean, +): ExtractSegmentsResult { + return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, getDefaultTabWidth()); +} // Pre-allocated space buffer for padding const SPACE_BUFFER = " ".repeat(512); +/** + * Tab width in columns for `file`, using `process.cwd()` as the project root for relative paths. + */ +export function getIndentationNoescape(file?: string): number { + return getIndentation(file, process.cwd()); +} + /* * Replace tabs with configured spacing for consistent rendering. */ @@ -31,19 +74,6 @@ export function getSegmenter(): Intl.Segmenter { return segmenter; } -/** - * Calculate the visible width of a string in terminal columns. - */ -function _isPrintableAscii(str: string): boolean { - for (let i = 0; i < str.length; i++) { - const code = str.charCodeAt(i); - if (code < 0x20 || code > 0x7e) { - return false; - } - } - return true; -} - export function visibleWidthRaw(str: string): number { if (!str) { return 0; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index b336eba65..b7c81f3dc 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it } from "bun:test"; import { stripVTControlCharacters } from "node:util"; -import { setDefaultTabWidth } from "@oh-my-pi/pi-natives"; import { CURSOR_MARKER } from "@oh-my-pi/pi-tui"; import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete"; import { Editor } from "@oh-my-pi/pi-tui/components/editor"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; +import { setDefaultTabWidth } from "@oh-my-pi/pi-utils"; import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings"; import { defaultEditorTheme } from "./test-themes"; diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index b723d2411..597cfb663 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { getIndentation } from "@oh-my-pi/pi-natives"; import { CURSOR_MARKER } from "@oh-my-pi/pi-tui"; import { Input } from "@oh-my-pi/pi-tui/components/input"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; +import { getIndentation } from "@oh-my-pi/pi-utils"; function renderedWidth(input: Input, width: number): number { const [line] = input.render(width); diff --git a/packages/utils/src/env.ts b/packages/utils/src/env.ts index 4a87ef2ea..bb77b04df 100644 --- a/packages/utils/src/env.ts +++ b/packages/utils/src/env.ts @@ -100,9 +100,9 @@ export function isBunTestRuntime(): boolean { return Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test"; } -const TRUTHY: Dict = { "1": true, TRUE: true, YES: true, ON: true }; +const TRUTHY: Dict = { "1": true, Y: true, TRUE: true, YES: true, ON: true }; export function $flag(name: string, def: boolean = false): boolean { const value = $env[name]; if (!value) return def; - return !!TRUTHY[value]; + return TRUTHY[value] === true; } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 56d4903ef..c7350da78 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -21,6 +21,7 @@ export * as ptree from "./ptree"; export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree"; export * from "./snowflake"; export * from "./stream"; +export * from "./tab-spacing"; export * from "./temp"; export * from "./type-guards"; export * from "./which"; diff --git a/packages/utils/src/tab-spacing.ts b/packages/utils/src/tab-spacing.ts new file mode 100644 index 000000000..1acbc5f90 --- /dev/null +++ b/packages/utils/src/tab-spacing.ts @@ -0,0 +1,312 @@ +/** + * Default tab width (display / tab expansion) and per-file width from `.editorconfig`. + * Mirrors former `pi-natives` `indent` + `text` default-tab-width behavior (no N-API). + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { isEnoent } from "./fs-error"; + +export const MIN_TAB_WIDTH = 1; +export const MAX_TAB_WIDTH = 16; +export const DEFAULT_TAB_WIDTH = 3; + +const EDITORCONFIG_NAME = ".editorconfig"; + +let defaultTabWidth = DEFAULT_TAB_WIDTH; + +const editorConfigCache = new Map(); +const editorConfigChainCache = new Map(); +const indentationCache = new Map(); + +interface EditorConfigSection { + pattern: string; + properties: Map; +} + +interface ParsedEditorConfig { + root: boolean; + sections: EditorConfigSection[]; +} + +interface ChainEntry { + dir: string; + parsed: ParsedEditorConfig; +} + +const enum IndentStyle { + Space, + Tab, +} + +type IndentSize = { kind: "spaces"; n: number } | { kind: "tab" }; + +interface EditorConfigMatch { + indentStyle?: IndentStyle; + indentSize?: IndentSize; + tabWidth?: number; +} + +function clampTabWidth(value: number): number { + return Math.min(MAX_TAB_WIDTH, Math.max(MIN_TAB_WIDTH, Math.trunc(value))); +} + +function parsePositiveInteger(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + if (!/^\d+$/.test(raw)) return undefined; + const parsed = Number.parseInt(raw, 10); + if (parsed === 0) return undefined; + return clampTabWidth(parsed); +} + +function fixUnclosedBraces(pattern: string): string { + const opens = [...pattern].filter(c => c === "{").length; + const closes = [...pattern].filter(c => c === "}").length; + if (opens > closes) { + return pattern + "}".repeat(opens - closes); + } + return pattern; +} + +/** Match `crates/pi-natives/src/glob_util.rs` `build_glob_pattern`. */ +function buildGlobPattern(globStr: string, recursive: boolean): string { + const normalized = globStr.replace(/\\/g, "/"); + const pattern = + !recursive || normalized.includes("/") || normalized.startsWith("**") ? normalized : `**/${normalized}`; + return fixUnclosedBraces(pattern); +} + +function globMatches(pattern: string, relativePath: string): boolean { + try { + const g = new Bun.Glob(pattern); + return g.match(relativePath); + } catch { + return false; + } +} + +function matchesEditorConfigPattern(pattern: string, relativePath: string): boolean { + const normalized = pattern.replace(/^\/+/, ""); + if (!normalized) { + return false; + } + + const candidates = normalized.includes("/") + ? [buildGlobPattern(normalized, false)] + : [buildGlobPattern(normalized, false), buildGlobPattern(normalized, true)]; + + for (const p of candidates) { + if (globMatches(p, relativePath)) { + return true; + } + } + return false; +} + +function parseEditorConfigFile(content: string): ParsedEditorConfig { + const parsed: ParsedEditorConfig = { root: false, sections: [] }; + let currentSectionIdx: number | undefined; + + for (const rawLine of content.split(/\n/)) { + const line = rawLine.trim(); + if (line === "") continue; + if (line.startsWith("#") || line.startsWith(";")) continue; + + if (line.startsWith("[") && line.endsWith("]") && line.length >= 2) { + const secPattern = line.slice(1, -1).trim(); + if (secPattern === "") { + currentSectionIdx = undefined; + continue; + } + parsed.sections.push({ pattern: secPattern, properties: new Map() }); + currentSectionIdx = parsed.sections.length - 1; + continue; + } + + const eq = line.indexOf("="); + if (eq === -1) continue; + const key = line.slice(0, eq).trim().toLowerCase(); + const value = line + .slice(eq + 1) + .trim() + .toLowerCase(); + if (key === "") continue; + + if (currentSectionIdx !== undefined) { + parsed.sections[currentSectionIdx]!.properties.set(key, value); + } else if (key === "root") { + parsed.root = value === "true"; + } + } + + return parsed; +} + +function parseCachedEditorConfig(configPath: string): ParsedEditorConfig | undefined { + const key = path.resolve(configPath); + const hit = editorConfigCache.get(key); + if (hit !== undefined) { + return hit; + } + + let content: string; + try { + content = fs.readFileSync(key, "utf8"); + } catch (err) { + if (isEnoent(err)) return undefined; + throw err; + } + const parsed = parseEditorConfigFile(content); + editorConfigCache.set(key, parsed); + return parsed; +} + +function resolveFilePath(projectDir: string, file: string): string { + if (path.isAbsolute(file)) { + return path.normalize(path.resolve(file)); + } + return path.normalize(path.resolve(projectDir, file)); +} + +/** Like `pathdiff::diff_paths` + forward slashes (see `indent.rs`). */ +function relativePathUnified(baseDir: string, absoluteFile: string): string { + const base = path.resolve(baseDir); + const file = path.resolve(absoluteFile); + const rel = path.relative(base, file); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + return "."; + } + return rel.replace(/\\/g, "/"); +} + +function collectEditorConfigChain(startDir: string): ChainEntry[] { + const key = path.resolve(startDir); + const cached = editorConfigChainCache.get(key); + if (cached !== undefined) { + return cached; + } + + const chain: ChainEntry[] = []; + let cursor = key; + for (;;) { + const configPath = path.join(cursor, EDITORCONFIG_NAME); + const parsed = parseCachedEditorConfig(configPath); + if (parsed !== undefined) { + chain.push({ dir: cursor, parsed }); + if (parsed.root) { + break; + } + } + + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + + chain.reverse(); + editorConfigChainCache.set(key, chain); + return chain; +} + +function resolveEditorConfigMatch(absoluteFile: string): EditorConfigMatch | undefined { + const fileDir = path.dirname(absoluteFile); + const chain = collectEditorConfigChain(fileDir); + if (chain.length === 0) { + return undefined; + } + + const match: EditorConfigMatch = {}; + for (const { dir, parsed } of chain) { + const relativePath = relativePathUnified(dir, absoluteFile); + for (const section of parsed.sections) { + if (!matchesEditorConfigPattern(section.pattern, relativePath)) { + continue; + } + + const style = section.properties.get("indent_style"); + if (style === "space") { + match.indentStyle = IndentStyle.Space; + } else if (style === "tab") { + match.indentStyle = IndentStyle.Tab; + } + + const rawSize = section.properties.get("indent_size"); + if (rawSize === "tab") { + match.indentSize = { kind: "tab" }; + } else if (rawSize !== undefined) { + const n = parsePositiveInteger(rawSize); + if (n !== undefined) { + match.indentSize = { kind: "spaces", n }; + } + } + + const tw = parsePositiveInteger(section.properties.get("tab_width")); + if (tw !== undefined) { + match.tabWidth = tw; + } + } + } + + if (match.indentStyle === undefined && match.indentSize === undefined && match.tabWidth === undefined) { + return undefined; + } + return match; +} + +function resolveEditorConfigTabWidth(match: EditorConfigMatch | undefined, fallback: number): number | undefined { + if (match === undefined) return undefined; + + if (match.indentSize?.kind === "spaces") { + return match.indentSize.n; + } + + if (match.indentSize?.kind === "tab") { + if (match.tabWidth !== undefined) { + return match.tabWidth; + } + return fallback; + } + + if (match.tabWidth !== undefined) { + return match.tabWidth; + } + + if (match.indentStyle === IndentStyle.Tab) { + return fallback; + } + + return undefined; +} + +export function getDefaultTabWidth(): number { + return defaultTabWidth; +} + +export function setDefaultTabWidth(width: number): void { + defaultTabWidth = clampTabWidth(width); +} + +/** + * Visible tab width in columns for `file` (from `.editorconfig` + default), or the default when `file` is omitted. + */ +export function getIndentation(file?: string | null, projectDir?: string | null): number { + const fallback = defaultTabWidth; + if (file === undefined || file === null || file === "") { + return fallback; + } + + const cwd = projectDir ?? process.cwd(); + const absoluteFile = resolveFilePath(cwd, file); + const absKey = absoluteFile; + const cached = indentationCache.get(absKey); + if (cached !== undefined) { + return cached; + } + + const editorMatch = resolveEditorConfigMatch(absoluteFile); + const resolved = resolveEditorConfigTabWidth(editorMatch, fallback) ?? fallback; + const clamped = clampTabWidth(resolved); + indentationCache.set(absKey, clamped); + return clamped; +} diff --git a/packages/utils/test/spacing.test.ts b/packages/utils/test/spacing.test.ts index 21423ec93..2635d4d48 100644 --- a/packages/utils/test/spacing.test.ts +++ b/packages/utils/test/spacing.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { getDefaultTabWidth, getIndentation, setDefaultTabWidth } from "@oh-my-pi/pi-natives"; import { getProjectDir, setProjectDir } from "../src/dirs"; import { Snowflake } from "../src/snowflake"; +import { getDefaultTabWidth, getIndentation, setDefaultTabWidth } from "../src/tab-spacing"; describe("spacing", () => { let tempDir = "";