refactor: migrated tab width resolution to pi-utils with EditorConfig caching

- Extracted tab width resolution from pi-natives to pi-utils with EditorConfig caching support.
- Made tabWidth parameter required in native text functions (visibleWidth, truncateToWidth, wrapTextWithAnsi, sliceWithWidth, extractSegments).
- Removed process-wide tab width state management from pi-natives text module.
- Consolidated wrapper node promotion logic in chunk classification with abbreviated string representations.
- Removed path-clean and pathdiff dependencies from pi-natives.
- Updated all consuming packages to import tab width functions from pi-utils and pass explicit tabWidth parameters.
This commit is contained in:
can1357
2026-04-10 14:18:36 +02:00
parent 0e5dfd542b
commit b60c0cc3ce
31 changed files with 993 additions and 883 deletions
Generated
-8
View File
@@ -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",
-2
View File
@@ -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"] }
+94 -156
View File
@@ -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<RawChunkCandidate<'t>> {
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<RawChunkCand
fn classify_class_custom<'t>(node: Node<'t>, source: &str) -> Option<RawChunkCandidate<'t>> {
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),
}
}
+8 -16
View File
@@ -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<RawChunkC
}
},
// Custom child-node logic (recurse_into on inner function_definition)
"decorated_definition" => {
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,
})
+7 -51
View File
@@ -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<RawChunkCandidate<'t>> {
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),
}
}
+137 -2
View File
@@ -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<ChunkKind>,
pub name_style: Option<NameStyle>,
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<Node<'tree>> {
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<RawChunkCandidate<'tree>> {
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 {
-8
View File
@@ -414,10 +414,6 @@ pub fn recurse_class(node: Node<'_>) -> Option<RecurseSpec<'_>> {
resolve_recurse(node, ChunkContext::ClassBody)
}
pub fn recurse_interface(node: Node<'_>) -> Option<RecurseSpec<'_>> {
resolve_recurse(node, ChunkContext::ClassBody)
}
pub fn recurse_enum(node: Node<'_>) -> Option<RecurseSpec<'_>> {
resolve_recurse(node, ChunkContext::ClassBody)
}
@@ -684,10 +680,6 @@ fn rust_function_signature(header: &str) -> Option<String> {
// ── 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)
}
+142 -40
View File
@@ -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::<Vec<_>>()
);
}
@@ -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::<Vec<_>>()
)
});
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
);
}
}
+50 -50
View File
@@ -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",
+138 -120
View File
@@ -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<RawChunkCandidate<'_>>) -> Vec<RawChunkCandi
assign_unique_names(grouped)
}
/// Truncate a chunk identifier to at most `MAX_IDENT_CHARS` characters for
/// compact path segments. Trailing underscores left by mid-word truncation
/// are stripped.
fn truncate_path_name(name: &str) -> 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<RawChunkCandidate<'_>>) -> Vec<RawChunkCandidate<'_>> {
// 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::<String, usize>::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::<Vec<_>>();
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::<Vec<_>>()
);
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
);
}
-4
View File
@@ -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")
}
-313
View File
@@ -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<DashMap<String, ParsedEditorConfig>> =
LazyLock::new(DashMap::new);
static EDITOR_CONFIG_CHAIN_CACHE: LazyLock<DashMap<String, Vec<(PathBuf, ParsedEditorConfig)>>> =
LazyLock::new(DashMap::new);
static INDENTATION_CACHE: LazyLock<DashMap<String, u32>> = LazyLock::new(DashMap::new);
#[derive(Clone)]
struct EditorConfigSection {
pattern: String,
properties: HashMap<String, String>,
}
#[derive(Clone)]
struct ParsedEditorConfig {
root: bool,
sections: Vec<EditorConfigSection>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum IndentStyle {
Space,
Tab,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum IndentSize {
Spaces(u32),
Tab,
}
struct EditorConfigMatch {
indent_style: Option<IndentStyle>,
indent_size: Option<IndentSize>,
tab_width: Option<u32>,
}
fn clamp_tab_width(value: u32) -> u32 {
value.clamp(MIN_TAB_WIDTH, MAX_TAB_WIDTH)
}
fn parse_positive_integer(value: Option<&str>) -> Option<u32> {
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<usize> = 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<ParsedEditorConfig> {
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<Result<GlobSet, _>> = 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<EditorConfigMatch> {
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(&section.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<u32> {
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<String>, 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<String>,
project_dir: Option<String>,
) -> 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)
}
-1
View File
@@ -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;
+17 -66
View File
@@ -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<u32> = 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::<EnvTabWidth>() {
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<u32>) -> 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<u32>,
) -> Result<Vec<Utf16String>> {
pub fn wrap_text_with_ansi(text: JsString, width: u32, tab_width: u32) -> Result<Vec<Utf16String>> {
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<Ellipsis>,
pad: Option<bool>,
tab_width: Option<u32>,
) -> Result<Either<JsString<'a>, Utf16String>> {
tab_width: u32,
) -> Result<Either<JsString<'_>, 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<bool>,
tab_width: Option<u32>,
tab_width: u32,
) -> Result<SliceResult> {
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<u32>,
tab_width: u32,
) -> Result<ExtractSegmentsResult> {
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<Either<JsString<'_>, Utf16Str
///
/// Tabs count as a fixed-width cell.
#[napi]
pub fn visible_width(env: &napi::Env, text: JsString, tab_width: Option<u32>) -> Result<u32> {
pub fn visible_width(text: JsString, tab_width: u32) -> Result<u32> {
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))
}
+9 -2
View File
@@ -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";
@@ -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);
@@ -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";
@@ -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
@@ -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 = "";
+5
View File
@@ -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
+5 -14
View File
@@ -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<string>
@@ -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<string>
export declare function wrapTextWithAnsi(text: string, width: number, tabWidth: number): Array<string>
+1 -1
View File
@@ -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);
});
+5
View File
@@ -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
+7 -3
View File
@@ -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;
+45 -15
View File
@@ -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;
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -100,9 +100,9 @@ export function isBunTestRuntime(): boolean {
return Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
}
const TRUTHY: Dict<boolean> = { "1": true, TRUE: true, YES: true, ON: true };
const TRUTHY: Dict<boolean> = { "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;
}
+1
View File
@@ -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";
+312
View File
@@ -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<string, ParsedEditorConfig>();
const editorConfigChainCache = new Map<string, ChainEntry[]>();
const indentationCache = new Map<string, number>();
interface EditorConfigSection {
pattern: string;
properties: Map<string, string>;
}
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;
}
+1 -1
View File
@@ -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 = "";