Skip to main content

perl_parser/
ast_utils.rs

1//! AST utilities for Perl LSP microcrates.
2//!
3//! This crate has a narrow responsibility: provide AST and source-text helpers
4//! used by higher-level LSP features (for example, code actions).
5
6#![deny(unsafe_code)]
7#![warn(missing_docs)]
8
9use perl_ast::{Node, NodeKind};
10
11/// Find the best position to insert a declaration.
12#[must_use]
13pub fn find_declaration_position(source: &str, error_pos: usize) -> usize {
14    find_statement_start(source, error_pos)
15}
16
17/// Find the start of the current statement.
18///
19/// Scans backwards from `pos` for the nearest `;` or `\n` and returns the
20/// byte index immediately after it, or 0 if no boundary is found.
21#[must_use]
22pub fn find_statement_start(source: &str, pos: usize) -> usize {
23    let bytes = source.as_bytes();
24    let start = pos.min(bytes.len());
25
26    for i in (0..start).rev() {
27        if bytes[i] == b';' || bytes[i] == b'\n' {
28            return i + 1;
29        }
30    }
31
32    0
33}
34
35/// Find a good position to insert a function.
36///
37/// Current policy inserts at end-of-file.
38#[must_use]
39pub fn find_function_insert_position(source: &str) -> usize {
40    source.len()
41}
42
43/// Find the most specific node covering the provided byte range.
44#[allow(clippy::only_used_in_recursion)]
45#[must_use]
46pub fn find_node_at_range(node: &Node, range: (usize, usize)) -> Option<&Node> {
47    if node.location.start <= range.0 && node.location.end >= range.1 {
48        match &node.kind {
49            NodeKind::Program { statements } | NodeKind::Block { statements } => {
50                for stmt in statements {
51                    if let Some(result) = find_node_at_range(stmt, range) {
52                        return Some(result);
53                    }
54                }
55            }
56            NodeKind::If { condition, then_branch, elsif_branches, else_branch, .. } => {
57                if let Some(result) = find_node_at_range(condition, range) {
58                    return Some(result);
59                }
60                if let Some(result) = find_node_at_range(then_branch, range) {
61                    return Some(result);
62                }
63                for (cond, branch) in elsif_branches {
64                    if let Some(result) = find_node_at_range(cond, range) {
65                        return Some(result);
66                    }
67                    if let Some(result) = find_node_at_range(branch, range) {
68                        return Some(result);
69                    }
70                }
71                if let Some(branch) = else_branch
72                    && let Some(result) = find_node_at_range(branch, range)
73                {
74                    return Some(result);
75                }
76            }
77            NodeKind::Binary { left, right, .. } => {
78                if let Some(result) = find_node_at_range(left, range) {
79                    return Some(result);
80                }
81                if let Some(result) = find_node_at_range(right, range) {
82                    return Some(result);
83                }
84            }
85            _ => {}
86        }
87        return Some(node);
88    }
89
90    None
91}
92
93/// Get indentation at a position.
94#[must_use]
95pub fn get_indent_at(source: &str, pos: usize) -> String {
96    let clamped_pos = pos.min(source.len());
97    let line_start = source[..clamped_pos].rfind('\n').map_or(0, |p| p + 1);
98    let line = &source[line_start..];
99
100    let mut indent = String::new();
101    for ch in line.chars() {
102        if ch == ' ' || ch == '\t' {
103            indent.push(ch);
104        } else {
105            break;
106        }
107    }
108    indent
109}
110
111#[cfg(test)]
112mod tests {
113    use super::{find_declaration_position, find_statement_start, get_indent_at};
114
115    #[test]
116    fn finds_statement_start_after_semicolon() {
117        let src = "my $x = 1;\nmy $y = 2;";
118        let pos = src.find("$y").unwrap_or(0);
119        assert_eq!(find_statement_start(src, pos), src.find('\n').unwrap_or(0) + 1);
120    }
121
122    #[test]
123    fn finds_statement_start_when_terminator_is_at_index_zero() {
124        // Regression: the backwards scan must inspect byte 0. Previously the
125        // loop guard `while i > 0` skipped index 0, so a terminator at the
126        // very start of the source was never recognised.
127        assert_eq!(find_statement_start(";x", 1), 1);
128        assert_eq!(find_statement_start("\nfoo", 1), 1);
129    }
130
131    #[test]
132    fn returns_zero_when_no_terminator_precedes_pos() {
133        assert_eq!(find_statement_start("abc", 3), 0);
134        assert_eq!(find_statement_start("", 0), 0);
135        assert_eq!(find_statement_start("abc", 0), 0);
136    }
137
138    #[test]
139    fn handles_pos_beyond_source_len() {
140        let src = "foo;bar";
141        assert_eq!(find_statement_start(src, 100), 4);
142    }
143
144    #[test]
145    fn declaration_position_delegates_to_statement_start() {
146        let src = "print 'a';\nprint 'b';";
147        let pos = src.find("'b'").unwrap_or(0);
148        assert_eq!(find_declaration_position(src, pos), find_statement_start(src, pos));
149    }
150
151    #[test]
152    fn captures_whitespace_indent() {
153        let src = "if (1) {\n    say 'x';\n}\n";
154        let pos = src.find("say").unwrap_or(0);
155        assert_eq!(get_indent_at(src, pos), "    ");
156    }
157    #[test]
158    fn get_indent_clamps_out_of_bounds_pos() {
159        let src = "line1\n  line2";
160        assert_eq!(get_indent_at(src, src.len() + 50), "  ");
161    }
162}