Skip to main content

moire_source_context/
lib.rs

1use arborium::HtmlFormat;
2use arborium::advanced::{Span, spans_to_html};
3use arborium::tree_sitter;
4use moire_types::LineRange;
5use moire_types::{ContextCodeLine, ContextSeparator, SourceContextLine};
6
7pub struct CutResult {
8    /// The scope excerpt with cuts applied. Only contains lines from scope_range.
9    /// Within that range, cut regions have their first line replaced with `/* ... */`
10    /// and remaining lines empty. Line count = scope_range.end - scope_range.start + 1.
11    pub cut_source: String,
12    /// 1-based inclusive line range of the displayed scope excerpt in the original file.
13    /// For function scopes, this starts at the function body (signature omitted).
14    /// Line 1 of cut_source = line scope_range.start in the original.
15    pub scope_range: LineRange,
16}
17
18/// Scope-level node kinds we recognize in Rust's tree-sitter grammar.
19const SCOPE_KINDS: &[&str] = &[
20    "function_item",
21    "closure_expression",
22    "impl_item",
23    "source_file",
24];
25
26/// The body child name for each scope kind.
27fn body_kind_for_scope(scope_kind: &str) -> &'static str {
28    match scope_kind {
29        "function_item" | "closure_expression" => "block",
30        "impl_item" => "declaration_list",
31        _ => "source_file", // sentinel — we use all children
32    }
33}
34
35fn display_scope_rows(
36    scope: tree_sitter::Node<'_>,
37    body: Option<tree_sitter::Node<'_>>,
38    body_children: &[tree_sitter::Node<'_>],
39) -> (usize, usize) {
40    if scope.kind() != "function_item" {
41        return (scope.start_position().row, scope.end_position().row);
42    }
43
44    // For function items, avoid duplicating the signature by using body rows.
45    if let (Some(first), Some(last)) = (body_children.first(), body_children.last()) {
46        return (first.start_position().row, last.end_position().row);
47    }
48
49    // Empty-body fallback: keep a stable single line.
50    let mut row = body
51        .map(|body_node| body_node.start_position().row.saturating_add(1))
52        .unwrap_or_else(|| scope.start_position().row);
53    let scope_end_row = scope.end_position().row;
54    if row > scope_end_row {
55        row = scope_end_row;
56    }
57    (row, row)
58}
59
60/// Number of sibling statements to keep on each side of the target for
61/// regular (expanded) context rendering.
62const NEIGHBOR_COUNT: usize = 1;
63
64/// Number of sibling statements to keep on each side of the target for
65/// compact/collapsed rendering.
66const COMPACT_NEIGHBOR_COUNT: usize = 0;
67
68/// Given source content, a language name, and a target position, find the
69/// containing scope, classify its children into keep/cut, and return the
70/// modified source with `/* ... */` placeholders for cut regions.
71///
72/// The returned `cut_source` preserves the same number of lines as the
73/// displayed scope excerpt in the original file
74/// (scope_range.end - scope_range.start + 1), making line numbers stable
75/// for gutter rendering.
76pub fn cut_source(
77    content: &str,
78    lang_name: &str,
79    target_line: u32,
80    target_col: Option<u32>,
81) -> Option<CutResult> {
82    cut_source_with_neighbor_count(content, lang_name, target_line, target_col, NEIGHBOR_COUNT)
83}
84
85/// Aggressive context cutter for compact/collapsed displays.
86pub fn cut_source_compact(
87    content: &str,
88    lang_name: &str,
89    target_line: u32,
90    target_col: Option<u32>,
91) -> Option<CutResult> {
92    cut_source_with_neighbor_count(
93        content,
94        lang_name,
95        target_line,
96        target_col,
97        COMPACT_NEIGHBOR_COUNT,
98    )
99}
100
101fn cut_source_with_neighbor_count(
102    content: &str,
103    lang_name: &str,
104    target_line: u32,
105    target_col: Option<u32>,
106    neighbor_count: usize,
107) -> Option<CutResult> {
108    let ts_lang = arborium::get_language(lang_name)?;
109    let mut parser = tree_sitter::Parser::new();
110    parser.set_language(&ts_lang).ok()?;
111    let tree = parser.parse(content.as_bytes(), None)?;
112
113    let row = (target_line - 1) as usize;
114    let col = target_col.unwrap_or(0) as usize;
115    let point = tree_sitter::Point::new(row, col);
116
117    // Find deepest named node at target
118    let node = tree
119        .root_node()
120        .named_descendant_for_point_range(point, point)?;
121
122    // Walk up to nearest scope node
123    let scope = find_scope(node, point)?;
124
125    let source_lines: Vec<&str> = content.lines().collect();
126
127    // Find the body child
128    let body_kind = body_kind_for_scope(scope.kind());
129    let is_source_file = scope.kind() == "source_file";
130
131    // Collect body children (the statements/items inside the scope)
132    let (body_node, body_children): (Option<tree_sitter::Node>, Vec<tree_sitter::Node>) =
133        if is_source_file {
134            // For source_file, all named children are body children
135            (
136                None,
137                (0..scope.named_child_count() as u32)
138                    .filter_map(|i| scope.named_child(i))
139                    .collect(),
140            )
141        } else {
142            // Find the body node (block or declaration_list)
143            let body = (0..scope.child_count() as u32)
144                .filter_map(|i| scope.child(i))
145                .find(|c| c.kind() == body_kind)?;
146            let children = (0..body.named_child_count() as u32)
147                .filter_map(|i| body.named_child(i))
148                .collect();
149            (Some(body), children)
150        };
151    let is_function_scope = scope.kind() == "function_item";
152    let (mut scope_start_row, mut scope_end_row) =
153        display_scope_rows(scope, body_node, &body_children);
154
155    // If few enough children, no cutting needed — just return the scope as-is.
156    // Skip in compact mode: collect_compact_block_elision_ranges may still
157    // need to elide block interiors within the single kept child.
158    if body_children.len() <= (neighbor_count * 2 + 1) && neighbor_count != COMPACT_NEIGHBOR_COUNT {
159        let scope_start = scope_start_row as u32 + 1;
160        let scope_end = scope_end_row as u32 + 1;
161        let cut = source_lines[(scope_start - 1) as usize..scope_end as usize].join("\n");
162        return Some(CutResult {
163            cut_source: cut,
164            scope_range: LineRange {
165                start: scope_start,
166                end: scope_end,
167            },
168        });
169    }
170
171    // Find the target child index
172    let target_idx = body_children
173        .iter()
174        .position(|child| contains_point(child, point))
175        .unwrap_or_else(|| {
176            // Fallback: find closest child by line
177            body_children
178                .iter()
179                .enumerate()
180                .min_by_key(|(_, c)| {
181                    let c_start = c.start_position().row;
182                    let c_end = c.end_position().row;
183                    if row >= c_start && row <= c_end {
184                        0usize
185                    } else if row < c_start {
186                        c_start - row
187                    } else {
188                        row - c_end
189                    }
190                })
191                .map(|(i, _)| i)
192                .unwrap_or(0)
193        });
194
195    // Classify children: keep neighbors around target, cut the rest
196    let keep_start = target_idx.saturating_sub(neighbor_count);
197    let keep_end = (target_idx + neighbor_count + 1).min(body_children.len());
198
199    if is_function_scope && !body_children.is_empty() {
200        scope_start_row = body_children[keep_start].start_position().row;
201        scope_end_row = body_children[keep_end - 1].end_position().row;
202    }
203
204    // Build cut_source by processing lines
205    let mut result_lines: Vec<String> = Vec::with_capacity(scope_end_row - scope_start_row + 1);
206
207    // Determine which line ranges to cut (0-based rows)
208    let mut cut_ranges: Vec<(usize, usize)> = Vec::new(); // inclusive start/end rows
209
210    // Lines before first body child but after scope header: keep (scope header)
211    // Lines of cut children: replace
212    // Lines of kept children: keep
213    // Lines after last body child but before scope end: keep (closing brace)
214
215    if !body_children.is_empty() {
216        // Before the keep range: cut children [0..keep_start)
217        if keep_start > 0 && !is_function_scope {
218            let cut_start = body_children[0].start_position().row;
219            let cut_end = if keep_start < body_children.len() {
220                body_children[keep_start]
221                    .start_position()
222                    .row
223                    .saturating_sub(1)
224            } else {
225                body_children[body_children.len() - 1].end_position().row
226            };
227            if cut_end >= cut_start {
228                cut_ranges.push((cut_start, cut_end));
229            }
230        }
231
232        // After the keep range: cut children [keep_end..len)
233        if keep_end < body_children.len() && !is_function_scope {
234            let cut_start = body_children[keep_end].start_position().row;
235            let cut_end = body_children[body_children.len() - 1].end_position().row;
236            if cut_end >= cut_start {
237                cut_ranges.push((cut_start, cut_end));
238            }
239        }
240
241        // Compact mode: also elide interiors of kept blocks (e.g. async move bodies)
242        // while preserving outer lines and stable file-based line numbers.
243        if neighbor_count == COMPACT_NEIGHBOR_COUNT {
244            for child in &body_children[keep_start..keep_end] {
245                collect_compact_block_elision_ranges(*child, &mut cut_ranges);
246            }
247        }
248    }
249
250    cut_ranges = merge_line_ranges(cut_ranges);
251
252    for row_idx in scope_start_row..=scope_end_row {
253        let in_cut = cut_ranges
254            .iter()
255            .find(|(s, e)| row_idx >= *s && row_idx <= *e);
256        if let Some(&(cut_start, _cut_end)) = in_cut {
257            if row_idx == cut_start {
258                // First line of cut region: insert /* ... */ with indentation
259                let indent = if row_idx < source_lines.len() {
260                    let line = source_lines[row_idx];
261                    let trimmed = line.trim_start();
262                    &line[..line.len() - trimmed.len()]
263                } else {
264                    ""
265                };
266                result_lines.push(format!("{indent}/* ... */"));
267            } else {
268                // Remaining lines in cut region: empty
269                result_lines.push(String::new());
270            }
271        } else if row_idx < source_lines.len() {
272            result_lines.push(source_lines[row_idx].to_string());
273        } else {
274            result_lines.push(String::new());
275        }
276    }
277
278    Some(CutResult {
279        cut_source: result_lines.join("\n"),
280        scope_range: LineRange {
281            start: scope_start_row as u32 + 1,
282            end: scope_end_row as u32 + 1,
283        },
284    })
285}
286
287fn collect_compact_block_elision_ranges(
288    node: tree_sitter::Node<'_>,
289    ranges: &mut Vec<(usize, usize)>,
290) {
291    if is_block_like_statement_node(node.kind()) {
292        let start_row = node.start_position().row;
293        let end_row = node.end_position().row;
294        if end_row > start_row + 1 {
295            ranges.push((start_row + 1, end_row - 1));
296        }
297    }
298
299    // Elide long parameter lists: keep only the first parameter.
300    if node.kind() == "parameters" && node.parent().is_some_and(|p| p.kind() == "function_item") {
301        let start_row = node.start_position().row;
302        let end_row = node.end_position().row;
303        if end_row > start_row + MAX_PARAM_LIST_ROWS {
304            let first_param_end_row = (0..node.named_child_count() as u32)
305                .filter_map(|i| node.named_child(i))
306                .next()
307                .map(|p| p.end_position().row)
308                .unwrap_or(start_row);
309            let elide_start = first_param_end_row + 1;
310            let elide_end = end_row.saturating_sub(1);
311            if elide_end >= elide_start {
312                ranges.push((elide_start, elide_end));
313            }
314        }
315    }
316
317    for i in 0..node.child_count() as u32 {
318        if let Some(child) = node.child(i) {
319            collect_compact_block_elision_ranges(child, ranges);
320        }
321    }
322}
323
324/// Walk up the tree to find the nearest scope node.
325///
326/// Closures with ≤1 statement walk up to find richer outer context.
327/// `function_item` and `impl_item` are always terminal — their body is the
328/// context; the signature is shown separately as the frame header.
329/// `source_file` is also always terminal.
330fn find_scope<'a>(
331    node: tree_sitter::Node<'a>,
332    target: tree_sitter::Point,
333) -> Option<tree_sitter::Node<'a>> {
334    let mut current = node;
335    let mut found_scope: Option<tree_sitter::Node<'a>> = None;
336
337    loop {
338        if SCOPE_KINDS.contains(&current.kind()) {
339            let child_count = count_body_children(&current);
340            // If the target is on the closing brace row, don't walk up —
341            // this scope is the right one regardless of child count.
342            let on_closing_brace = target_is_on_closing_brace(&current, target);
343            let is_terminal = matches!(
344                current.kind(),
345                "source_file" | "function_item" | "impl_item"
346            );
347            if child_count <= 1 && !is_terminal && !on_closing_brace {
348                // Too few statements in a closure — keep looking for outer scope
349                found_scope = Some(current);
350            } else {
351                return Some(current);
352            }
353        }
354        match current.parent() {
355            Some(parent) => current = parent,
356            None => {
357                // Reached root — check if root is source_file
358                if current.kind() == "source_file" && contains_point(&current, target) {
359                    return Some(current);
360                }
361                return found_scope;
362            }
363        }
364    }
365}
366
367/// Returns true if `target` is on the closing brace row of `scope`'s body
368/// (i.e. target.row >= body.end_position().row).  This means the target is
369/// past all statements and we should NOT walk up to a richer outer scope.
370fn target_is_on_closing_brace(scope: &tree_sitter::Node, target: tree_sitter::Point) -> bool {
371    if scope.kind() == "source_file" {
372        return false;
373    }
374    let body_kind = body_kind_for_scope(scope.kind());
375    let body = (0..scope.child_count() as u32)
376        .filter_map(|i| scope.child(i))
377        .find(|c| c.kind() == body_kind);
378    match body {
379        Some(b) => target.row >= b.end_position().row,
380        None => false,
381    }
382}
383
384fn count_body_children(scope: &tree_sitter::Node) -> usize {
385    let body_kind = body_kind_for_scope(scope.kind());
386    if scope.kind() == "source_file" {
387        return scope.named_child_count();
388    }
389    let body = (0..scope.child_count() as u32)
390        .filter_map(|i| scope.child(i))
391        .find(|c| c.kind() == body_kind);
392    match body {
393        Some(b) => b.named_child_count(),
394        None => 0,
395    }
396}
397
398fn contains_point(node: &tree_sitter::Node, point: tree_sitter::Point) -> bool {
399    let start = node.start_position();
400    let end = node.end_position();
401    (point.row > start.row || (point.row == start.row && point.column >= start.column))
402        && (point.row < end.row || (point.row == end.row && point.column <= end.column))
403}
404
405/// Statement-level node kinds we walk up to when extracting the target statement.
406const STATEMENT_KINDS: &[&str] = &[
407    "let_declaration",
408    "const_item",
409    "static_item",
410    "expression_statement",
411    "macro_invocation",
412    "function_item",
413    "if_expression",
414    "match_expression",
415    "for_expression",
416    "while_expression",
417    "loop_expression",
418    "return_expression",
419];
420
421/// Maximum number of interior lines (`{ ... }` body only) before we elide a block.
422const STATEMENT_BLOCK_INTERIOR_MAX_LINES: usize = 4;
423
424/// If a function's parameter list spans more than this many rows, elide all
425/// parameters after the first one, leaving a single `/* ... */` placeholder.
426const MAX_PARAM_LIST_ROWS: usize = 3;
427
428/// Extract a compact, context-aware statement snippet around the target position.
429///
430/// - Preserves formatting/newlines (not whitespace-collapsed).
431/// - Strips leading Rust attributes on the statement.
432/// - Aggressively elides long block interiors with a placeholder to keep cards compact.
433pub fn extract_target_statement(
434    content: &str,
435    lang_name: &str,
436    target_line: u32,
437    target_col: Option<u32>,
438) -> Option<String> {
439    let ts_lang = arborium::get_language(lang_name)?;
440    let mut parser = tree_sitter::Parser::new();
441    parser.set_language(&ts_lang).ok()?;
442    let tree = parser.parse(content.as_bytes(), None)?;
443
444    let row = (target_line - 1) as usize;
445    let col = target_col.unwrap_or(0) as usize;
446    let point = tree_sitter::Point::new(row, col);
447
448    let node = tree
449        .root_node()
450        .named_descendant_for_point_range(point, point)?;
451
452    // Walk up to nearest statement node, but stop before scope nodes
453    let mut current = node;
454    loop {
455        if STATEMENT_KINDS.contains(&current.kind()) {
456            break;
457        }
458        if SCOPE_KINDS.contains(&current.kind()) {
459            break;
460        }
461        match current.parent() {
462            Some(parent) => {
463                if SCOPE_KINDS.contains(&parent.kind()) {
464                    // If we're on an intermediate node (not a block/decl_list
465                    // that we can search children of) and the parent is also a
466                    // statement kind (e.g. function_item), step up to it — we
467                    // want the full signature, not just a sub-node like
468                    // `parameters` or `async`.
469                    let is_body_container =
470                        current.kind() == "block" || current.kind() == "declaration_list";
471                    if !is_body_container && STATEMENT_KINDS.contains(&parent.kind()) {
472                        current = parent;
473                    }
474                    break;
475                }
476                current = parent;
477            }
478            None => break,
479        }
480    }
481
482    // If we stopped at a block/declaration_list, find the child whose row range covers target
483    let statement = if current.kind() == "block" || current.kind() == "declaration_list" {
484        (0..current.named_child_count() as u32)
485            .filter_map(|i| current.named_child(i))
486            .find(|c| {
487                let s = c.start_position().row;
488                let e = c.end_position().row;
489                point.row >= s && point.row <= e
490            })
491            .unwrap_or(current)
492    } else {
493        current
494    };
495
496    // Prefer the outermost statement before we hit a scope boundary.
497    // Example: if target is inside `match` in `let x = match ...`, show the whole `let`.
498    let statement = {
499        let mut outer = statement;
500        while let Some(parent) = outer.parent() {
501            if SCOPE_KINDS.contains(&parent.kind()) {
502                break;
503            }
504            if STATEMENT_KINDS.contains(&parent.kind()) {
505                outer = parent;
506                continue;
507            }
508            break;
509        }
510        outer
511    };
512
513    // Skip leading attribute children so compact view doesn't show
514    // `#[moire::instrument] pub async fn foo()` — just `pub async fn foo()`.
515    let (text_start, text_start_row) = {
516        let mut start = statement.start_byte();
517        let mut row = statement.start_position().row;
518        for i in 0..statement.child_count() as u32 {
519            if let Some(child) = statement.child(i) {
520                if child.kind() == "attribute_item"
521                    || child.kind() == "attribute"
522                    || child.kind() == "attributes"
523                {
524                    continue;
525                }
526                start = child.start_byte();
527                row = child.start_position().row;
528                break;
529            }
530        }
531        (start, row)
532    };
533
534    let text = &content[text_start..statement.end_byte()];
535    let snippet = compact_statement_text(statement, text, text_start_row);
536    if snippet.is_empty() {
537        return None;
538    }
539
540    Some(snippet)
541}
542
543/// Extract the collapsed enclosing-function context for compact display.
544///
545/// Walks up the syntax tree from the target location to find the nearest
546/// enclosing `function_item` (named function or method, not closure).
547///
548/// Returns a compact single-line signature context that includes:
549/// - enclosing module path (if any)
550/// - enclosing impl type (if any)
551/// - full function signature (with parameter + return types)
552///
553/// Currently only implemented for Rust; returns `None` for other languages.
554pub fn extract_enclosing_fn(
555    content: &str,
556    lang_name: &str,
557    target_line: u32,
558    target_col: Option<u32>,
559) -> Option<String> {
560    if lang_name != "rust" {
561        return None;
562    }
563    let ts_lang = arborium::get_language(lang_name)?;
564    let mut parser = tree_sitter::Parser::new();
565    parser.set_language(&ts_lang).ok()?;
566    let tree = parser.parse(content.as_bytes(), None)?;
567
568    let row = (target_line - 1) as usize;
569    let col = target_col.unwrap_or(0) as usize;
570    let point = tree_sitter::Point::new(row, col);
571
572    let node = tree
573        .root_node()
574        .named_descendant_for_point_range(point, point)?;
575
576    let bytes = content.as_bytes();
577
578    // Walk up to find the nearest enclosing function_item.
579    // Closures (closure_expression) are skipped naturally since they
580    // are not "function_item" nodes.
581    let fn_node = {
582        let mut current = node;
583        loop {
584            if current.kind() == "function_item" {
585                break Some(current);
586            }
587            match current.parent() {
588                Some(parent) => current = parent,
589                None => break None,
590            }
591        }
592    }?;
593
594    let (modifiers, sig_without_modifiers) = extract_function_signature_text(content, &fn_node)?;
595    let mut qualifiers = collect_module_qualifiers(&fn_node, bytes);
596    if let Some(impl_type) = find_enclosing_impl_type_name(&fn_node, bytes) {
597        qualifiers.push(impl_type);
598    }
599
600    let qualified = if qualifiers.is_empty() {
601        sig_without_modifiers
602    } else {
603        format!("{}::{}", qualifiers.join("::"), sig_without_modifiers)
604    };
605
606    if modifiers.is_empty() {
607        Some(qualified)
608    } else {
609        Some(format!("{modifiers} {qualified}"))
610    }
611}
612
613fn collapse_ws_inline(text: &str) -> String {
614    text.split_whitespace().collect::<Vec<_>>().join(" ")
615}
616
617/// Returns `(modifiers, sig_without_modifiers)`.
618/// `modifiers` is e.g. `"async"` or `"async unsafe"` or `""`.
619/// `sig_without_modifiers` is e.g. `"push(&mut self, value)"`.
620fn extract_function_signature_text(
621    content: &str,
622    fn_node: &tree_sitter::Node<'_>,
623) -> Option<(String, String)> {
624    let bytes = content.as_bytes();
625
626    // Function modifiers before the fn keyword (async, const, unsafe, extern).
627    // Tree-sitter groups them under a "function_modifiers" node.
628    let mut modifiers = String::new();
629    for i in 0..fn_node.child_count() as u32 {
630        let child = fn_node.child(i)?;
631        match child.kind() {
632            "attribute_item" | "visibility_modifier" => continue,
633            "function_modifiers" => {
634                modifiers = child.utf8_text(bytes).ok()?.to_string();
635            }
636            "fn" => break,
637            _ => {}
638        }
639    }
640
641    let name = fn_node.child_by_field_name("name")?.utf8_text(bytes).ok()?;
642
643    let params_node = fn_node.child_by_field_name("parameters")?;
644
645    // Collect params in two forms: full (name: type) and slim (name only).
646    let mut params_full: Vec<String> = Vec::new();
647    let mut params_slim: Vec<String> = Vec::new();
648    for i in 0..params_node.child_count() as u32 {
649        let child = params_node.child(i)?;
650        match child.kind() {
651            "parameter" => {
652                let full = collapse_ws_inline(child.utf8_text(bytes).ok()?);
653                params_full.push(full);
654                if let Some(pat) = child.child_by_field_name("pattern") {
655                    params_slim.push(pat.utf8_text(bytes).ok()?.to_string());
656                }
657            }
658            "self_parameter" | "shorthand_self" => {
659                let self_text = collapse_ws_inline(child.utf8_text(bytes).ok()?);
660                params_full.push(self_text.clone());
661                params_slim.push(self_text);
662            }
663            _ => {}
664        }
665    }
666
667    // Return type (optional)
668    let ret = fn_node
669        .child_by_field_name("return_type")
670        .and_then(|rt| rt.utf8_text(bytes).ok().map(collapse_ws_inline))
671        .map(|t| format!(" -> {t}"))
672        .unwrap_or_default();
673
674    // Use full params if short enough, otherwise fall back to names only.
675    // The length budget is checked against the full qualified form, but we only
676    // have the local sig here — use a generous threshold.
677    const MAX_LEN: usize = 80;
678    let params_str = params_full.join(", ");
679    let candidate = format!("{name}({params_str}){ret}");
680    let sig = if candidate.len() <= MAX_LEN {
681        candidate
682    } else {
683        format!("{name}({}){ret}", params_slim.join(", "))
684    };
685
686    Some((modifiers, sig))
687}
688
689fn find_enclosing_impl_type_name(fn_node: &tree_sitter::Node<'_>, bytes: &[u8]) -> Option<String> {
690    let mut current = *fn_node;
691    while let Some(parent) = current.parent() {
692        if parent.kind() == "impl_item" {
693            let type_node = parent.child_by_field_name("type")?;
694            let raw = type_node.utf8_text(bytes).ok()?;
695            let collapsed = collapse_ws_inline(raw);
696            if collapsed.is_empty() {
697                return None;
698            }
699            return Some(collapsed);
700        }
701        current = parent;
702    }
703    None
704}
705
706fn collect_module_qualifiers(fn_node: &tree_sitter::Node<'_>, bytes: &[u8]) -> Vec<String> {
707    let mut rev_modules: Vec<String> = Vec::new();
708    let mut current = *fn_node;
709    while let Some(parent) = current.parent() {
710        if parent.kind() == "mod_item"
711            && let Some(name_node) = parent.child_by_field_name("name")
712            && let Ok(name) = name_node.utf8_text(bytes)
713        {
714            let collapsed = collapse_ws_inline(name);
715            if !collapsed.is_empty() {
716                rev_modules.push(collapsed);
717            }
718        }
719        current = parent;
720    }
721    rev_modules.reverse();
722    rev_modules
723}
724
725fn is_block_like_statement_node(kind: &str) -> bool {
726    kind == "block" || kind == "declaration_list" || kind == "match_block"
727}
728
729fn collect_statement_elision_ranges(node: tree_sitter::Node<'_>, ranges: &mut Vec<(usize, usize)>) {
730    if is_block_like_statement_node(node.kind()) {
731        let start_row = node.start_position().row;
732        let end_row = node.end_position().row;
733        if end_row > start_row + 1 {
734            let interior_start = start_row + 1;
735            let interior_end = end_row - 1;
736            let interior_len = interior_end - interior_start + 1;
737            let should_elide = node
738                .parent()
739                .is_some_and(|parent| parent.kind() == "function_item")
740                || interior_len > STATEMENT_BLOCK_INTERIOR_MAX_LINES;
741            if should_elide {
742                ranges.push((interior_start, interior_end));
743            }
744        }
745    }
746
747    // Elide long parameter lists: keep only the first parameter.
748    if node.kind() == "parameters" && node.parent().is_some_and(|p| p.kind() == "function_item") {
749        let start_row = node.start_position().row;
750        let end_row = node.end_position().row;
751        if end_row > start_row + MAX_PARAM_LIST_ROWS {
752            let first_param_end_row = (0..node.named_child_count() as u32)
753                .filter_map(|i| node.named_child(i))
754                .next()
755                .map(|p| p.end_position().row)
756                .unwrap_or(start_row);
757            let elide_start = first_param_end_row + 1;
758            let elide_end = end_row.saturating_sub(1);
759            if elide_end >= elide_start {
760                ranges.push((elide_start, elide_end));
761            }
762        }
763    }
764
765    for i in 0..node.child_count() as u32 {
766        if let Some(child) = node.child(i) {
767            collect_statement_elision_ranges(child, ranges);
768        }
769    }
770}
771
772fn merge_line_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
773    if ranges.len() <= 1 {
774        return ranges;
775    }
776    ranges.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
777
778    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
779    for (start, end) in ranges {
780        if let Some((_, last_end)) = merged.last_mut()
781            && start <= *last_end + 1
782        {
783            if end > *last_end {
784                *last_end = end;
785            }
786            continue;
787        }
788        merged.push((start, end));
789    }
790    merged
791}
792
793fn leading_ws_byte_len(line: &str) -> usize {
794    line.char_indices()
795        .find_map(|(idx, ch)| if ch.is_whitespace() { None } else { Some(idx) })
796        .unwrap_or(line.len())
797}
798
799fn normalize_statement_lines(lines: Vec<String>) -> String {
800    let Some(first_non_empty) = lines.iter().position(|line| !line.trim().is_empty()) else {
801        return String::new();
802    };
803    let Some(last_non_empty) = lines.iter().rposition(|line| !line.trim().is_empty()) else {
804        return String::new();
805    };
806
807    let slice = &lines[first_non_empty..=last_non_empty];
808    let continuation_indent = slice
809        .iter()
810        .enumerate()
811        .filter_map(|(idx, line)| {
812            if idx == 0 || line.trim().is_empty() {
813                return None;
814            }
815            Some(leading_ws_byte_len(line))
816        })
817        .min()
818        .unwrap_or(0);
819
820    let mut out = Vec::with_capacity(slice.len());
821    for (idx, line) in slice.iter().enumerate() {
822        let trimmed_end = line.trim_end_matches([' ', '\t']);
823        if trimmed_end.trim().is_empty() {
824            out.push(String::new());
825            continue;
826        }
827        let drop = if idx == 0 {
828            0
829        } else {
830            continuation_indent.min(leading_ws_byte_len(trimmed_end))
831        };
832        out.push(trimmed_end[drop..].to_string());
833    }
834    out.join("\n")
835}
836
837fn compact_statement_text(
838    statement: tree_sitter::Node<'_>,
839    text: &str,
840    text_start_row: usize,
841) -> String {
842    let mut lines: Vec<String> = text.lines().map(|line| line.to_string()).collect();
843    if lines.is_empty() {
844        return String::new();
845    }
846
847    let mut ranges = Vec::new();
848    collect_statement_elision_ranges(statement, &mut ranges);
849    let merged_ranges = merge_line_ranges(ranges);
850
851    for (start_row, end_row) in merged_ranges.into_iter().rev() {
852        if end_row < text_start_row {
853            continue;
854        }
855        let local_start = start_row.saturating_sub(text_start_row);
856        if local_start >= lines.len() {
857            continue;
858        }
859        let mut local_end = end_row.saturating_sub(text_start_row);
860        if local_end >= lines.len() {
861            local_end = lines.len() - 1;
862        }
863        if local_end < local_start {
864            continue;
865        }
866
867        let indent_len = leading_ws_byte_len(&lines[local_start]);
868        let indent = &lines[local_start][..indent_len];
869        lines.splice(local_start..=local_end, [format!("{indent}/* ... */")]);
870    }
871
872    normalize_statement_lines(lines)
873}
874
875/// Split a `CutResult` into context lines, using `render` to produce the content
876/// string for each real line. Cut markers and trailing empty lines become separators.
877fn collect_context_lines(
878    cut_result: &CutResult,
879    render: impl Fn(&str, usize, usize) -> String,
880) -> Vec<SourceContextLine> {
881    let source = &cut_result.cut_source;
882
883    let mut line_starts: Vec<usize> = vec![0];
884    for (i, &b) in source.as_bytes().iter().enumerate() {
885        if b == b'\n' {
886            line_starts.push(i + 1);
887        }
888    }
889
890    let mut result: Vec<SourceContextLine> = Vec::with_capacity(line_starts.len());
891    let mut skip_empty = false;
892
893    for (line_idx, &line_start) in line_starts.iter().enumerate() {
894        let line_end = line_starts
895            .get(line_idx + 1)
896            .map(|&s| s - 1)
897            .unwrap_or(source.len());
898        let line_text = &source[line_start..line_end];
899        let line_num = cut_result.scope_range.start + line_idx as u32;
900
901        if line_text.trim() == "/* ... */" {
902            result.push(SourceContextLine::Separator(ContextSeparator {
903                indent_cols: leading_indent_cols(line_text),
904            }));
905            skip_empty = true;
906            continue;
907        }
908
909        if skip_empty && line_text.trim().is_empty() {
910            continue;
911        }
912        skip_empty = false;
913
914        let content = render(source, line_start, line_end);
915        result.push(SourceContextLine::Line(ContextCodeLine {
916            line_num,
917            html: content,
918        }));
919    }
920
921    result
922}
923
924/// Convert a `CutResult` into context lines with arborium syntax-highlighted HTML content.
925pub fn highlighted_context_lines(
926    cut_result: &CutResult,
927    lang_name: &str,
928) -> Vec<SourceContextLine> {
929    let source = &cut_result.cut_source;
930    let spans: Vec<Span> = arborium::Highlighter::new()
931        .highlight_spans(lang_name, source)
932        .unwrap_or_default();
933    let format = HtmlFormat::CustomElements;
934
935    collect_context_lines(cut_result, |src, line_start, line_end| {
936        let line_text = &src[line_start..line_end];
937        let line_spans: Vec<Span> = spans
938            .iter()
939            .filter(|s| (s.start as usize) < line_end && (s.end as usize) > line_start)
940            .map(|s| Span {
941                start: (s.start as usize).saturating_sub(line_start) as u32,
942                end: ((s.end as usize).min(line_end) - line_start) as u32,
943                capture: s.capture.clone(),
944                pattern_index: s.pattern_index,
945            })
946            .collect();
947        spans_to_html(line_text, line_spans, &format)
948    })
949}
950
951/// Convert a `CutResult` into context lines with plain text content (no highlighting).
952pub fn text_context_lines(cut_result: &CutResult) -> Vec<SourceContextLine> {
953    collect_context_lines(cut_result, |src, line_start, line_end| {
954        src[line_start..line_end].to_string()
955    })
956}
957
958fn leading_indent_cols(text: &str) -> u32 {
959    let mut cols = 0u32;
960    for ch in text.chars() {
961        match ch {
962            ' ' => cols += 1,
963            '\t' => cols += 4,
964            _ => break,
965        }
966    }
967    cols
968}
969
970#[cfg(test)]
971mod tests;