Skip to main content

perl_lsp_text_utils/
lib.rs

1#![warn(missing_docs)]
2//! Text helpers for code-action style source edits.
3
4/// Helper wrapper for source text and pre-split lines.
5pub struct TextEditHelpers<'a> {
6    source: &'a str,
7    lines: &'a [String],
8}
9
10impl<'a> TextEditHelpers<'a> {
11    /// Create a new helper view.
12    #[must_use]
13    pub fn new(source: &'a str, lines: &'a [String]) -> Self {
14        Self { source, lines }
15    }
16
17    /// Borrow the source lines backing this helper.
18    #[must_use]
19    pub fn lines(&self) -> &'a [String] {
20        self.lines
21    }
22
23    /// Find the start of the statement containing `pos`.
24    ///
25    /// Only `;` is treated as a statement boundary. Newlines are not statement
26    /// boundaries in Perl — a multi-line expression like `some_func(\n    $arg)`
27    /// is a single statement, so treating `\n` as a boundary would insert the
28    /// extracted declaration inside the argument list.
29    ///
30    /// After finding the position immediately following a `;`, a single trailing
31    /// `\n` is skipped so that the returned position is the first character of
32    /// the next statement line, not the newline between statements.  This keeps
33    /// the inserted declaration on its own line rather than appended to the end
34    /// of the preceding statement.
35    #[must_use]
36    pub fn find_statement_start(&self, pos: usize) -> usize {
37        let after_semi = self
38            .source
39            .char_indices()
40            .take_while(|(idx, _)| *idx < pos)
41            .filter(|(_, ch)| *ch == ';')
42            .map(|(idx, _)| idx + 1)
43            .last()
44            .unwrap_or(0);
45        // Skip a single newline that immediately follows the semicolon so the
46        // insertion point is the first real character of the next line.
47        if self.source.as_bytes().get(after_semi) == Some(&b'\n') {
48            after_semi + 1
49        } else {
50            after_semi
51        }
52    }
53
54    /// Find where to insert an extracted subroutine near `current_pos`.
55    #[must_use]
56    pub fn find_subroutine_insert_position(&self, current_pos: usize) -> usize {
57        let search_end = current_pos.min(self.source.len());
58        self.source[..search_end].rfind("sub ").unwrap_or(self.source.len())
59    }
60
61    /// Find where leading pragmas should be inserted.
62    #[must_use]
63    pub fn find_pragma_insert_position(&self) -> usize {
64        if self.source.starts_with("#!")
65            && let Some(pos) = self.source.find('\n')
66        {
67            return pos + 1;
68        }
69        0
70    }
71
72    /// Find where imports should be inserted.
73    #[must_use]
74    pub fn find_import_insert_position(&self) -> usize {
75        let mut pos = self.find_pragma_insert_position();
76
77        for line in self.lines {
78            if line.starts_with("use ") || line.starts_with("require ") {
79                pos = self.source.find(line).unwrap_or(0) + line.len() + 1;
80            } else if !line.is_empty() && !line.starts_with('#') {
81                break;
82            }
83        }
84
85        pos
86    }
87
88    /// Get leading indentation at the line containing `pos`.
89    #[must_use]
90    pub fn get_indent_at(&self, pos: usize) -> String {
91        let safe_pos = pos.min(self.source.len());
92        let line_start = self.source[..safe_pos].rfind('\n').map_or(0, |p| p + 1);
93
94        self.source[line_start..].chars().take_while(|ch| *ch == ' ' || *ch == '\t').collect()
95    }
96
97    /// Truncate an expression for display.
98    #[must_use]
99    pub fn truncate_expr(&self, expr: &str, max_len: usize) -> String {
100        if expr.chars().count() <= max_len {
101            return expr.to_string();
102        }
103
104        if max_len <= 3 {
105            return "...".to_string();
106        }
107
108        format!("{}...", expr.chars().take(max_len - 3).collect::<String>())
109    }
110
111    /// Whether the source includes non-ASCII content.
112    #[must_use]
113    pub fn has_non_ascii_content(&self) -> bool {
114        !self.source.is_ascii()
115    }
116}