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:
Generated
-8
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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(§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<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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+5
-14
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,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,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user