Skip to main content

perl_lsp/util/
mod.rs

1//! Text processing utilities for LSP
2//!
3//! Common text processing helpers used across the LSP implementation.
4//! Includes panic-free accessors for safe string processing.
5
6pub mod command_timeout;
7pub mod uri;
8
9pub use command_timeout::run_command_with_timeout;
10
11use std::io;
12use std::path::Path;
13
14use lsp_types::Position;
15use perl_module::reference::extract_module_reference as extract_module_reference_at_cursor;
16use perl_module::reference::extract_module_reference_extended as extract_module_reference_extended_at_cursor;
17
18// Re-export engine utilities
19pub use perl_parser::util::{code_slice, find_data_marker_byte_lexed};
20pub use perl_symbol::cursor::{
21    byte_offset_utf16, is_modchar, is_word_boundary, token_under_cursor,
22};
23
24// =============================================================================
25// Panic-free character accessors (Issue #143)
26// =============================================================================
27
28/// Safely get the first character of a string slice.
29/// Returns None for empty strings instead of panicking.
30#[inline]
31pub fn first_char(s: &str) -> Option<char> {
32    s.chars().next()
33}
34
35/// Safely get the nth character of a string slice.
36/// Returns None if index is out of bounds instead of panicking.
37#[inline]
38pub fn nth_char(s: &str, n: usize) -> Option<char> {
39    s.chars().nth(n)
40}
41
42/// Safely get the first character as a String.
43/// Useful when you need the sigil character as a string.
44#[inline]
45pub fn first_char_string(s: &str) -> Option<String> {
46    s.chars().next().map(|c| c.to_string())
47}
48
49/// Safely check if the first character satisfies a predicate.
50/// Returns false for empty strings.
51#[inline]
52pub fn first_char_is<F: FnOnce(char) -> bool>(s: &str, predicate: F) -> bool {
53    s.chars().next().is_some_and(predicate)
54}
55
56/// Safely check if the nth character satisfies a predicate.
57/// Returns false if index is out of bounds.
58#[inline]
59pub fn nth_char_is<F: FnOnce(char) -> bool>(s: &str, n: usize, predicate: F) -> bool {
60    s.chars().nth(n).is_some_and(predicate)
61}
62
63/// Escape special markdown characters in plain text to prevent unintended formatting.
64///
65/// Escapes: backtick (`), hash (#), asterisk (*), underscore (_), brackets ([, ]),
66/// and other markdown formatting characters so they render as literal text in hover cards.
67///
68/// This preserves the semantic content of comments and documentation while preventing
69/// markdown special characters from being interpreted as formatting directives.
70///
71/// # Examples
72///
73/// ```ignore
74/// assert_eq!(escape_markdown_text("*bold*"), "\\*bold\\*");
75/// assert_eq!(escape_markdown_text("[link]"), "\\[link\\]");
76/// assert_eq!(escape_markdown_text("code`here"), "code\\`here");
77/// ```
78pub fn escape_markdown_text(text: &str) -> String {
79    let mut result = String::with_capacity(text.len());
80    for ch in text.chars() {
81        match ch {
82            '`' | '#' | '*' | '_' | '[' | ']' | '\\' | '|' | '-' => {
83                result.push('\\');
84                result.push(ch);
85            }
86            _ => result.push(ch),
87        }
88    }
89    result
90}
91
92/// Decode source text bytes while handling common editor encodings.
93///
94/// Behavior:
95/// - UTF-8 with optional BOM
96/// - UTF-16 LE/BE when BOM is present (odd-length payloads fall back to
97///   Latin-1 decoding of the original bytes rather than silently
98///   truncating the trailing byte)
99/// - Latin-1 byte-preserving fallback for non-UTF8 legacy files
100pub fn decode_text_bytes(bytes: &[u8]) -> String {
101    if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
102        if let Ok(utf8) = std::str::from_utf8(&bytes[3..]) {
103            return utf8.to_string();
104        }
105    }
106
107    if bytes.starts_with(&[0xFF, 0xFE]) {
108        if let Some(decoded) = decode_utf16_lossy(&bytes[2..], true) {
109            return decoded;
110        }
111        // Odd-length UTF-16 payload — fall through to latin-1 on the full bytes.
112    }
113
114    if bytes.starts_with(&[0xFE, 0xFF]) {
115        if let Some(decoded) = decode_utf16_lossy(&bytes[2..], false) {
116            return decoded;
117        }
118        // Odd-length UTF-16 payload — fall through to latin-1 on the full bytes.
119    }
120
121    match std::str::from_utf8(bytes) {
122        Ok(utf8) => utf8.to_string(),
123        Err(_) => bytes.iter().map(|byte| char::from(*byte)).collect(),
124    }
125}
126
127/// Read a text file and decode it with [`decode_text_bytes`].
128pub fn read_text_file_with_encoding(path: &Path) -> io::Result<String> {
129    std::fs::read(path).map(|bytes| decode_text_bytes(&bytes))
130}
131
132/// Decode a UTF-16 byte payload (BOM already stripped) into a String.
133///
134/// Returns `None` when the payload has an odd byte length, since UTF-16
135/// code units are always 2 bytes and a dangling odd byte indicates
136/// corrupted or mis-detected input. Callers should fall back to another
137/// decoder in that case rather than silently truncating the trailing byte.
138fn decode_utf16_lossy(bytes: &[u8], little_endian: bool) -> Option<String> {
139    if !bytes.len().is_multiple_of(2) {
140        return None;
141    }
142    let mut words = Vec::with_capacity(bytes.len() / 2);
143    for pair in bytes.chunks_exact(2) {
144        let word = if little_endian {
145            u16::from_le_bytes([pair[0], pair[1]])
146        } else {
147            u16::from_be_bytes([pair[0], pair[1]])
148        };
149        words.push(word);
150    }
151    Some(String::from_utf16_lossy(&words))
152}
153
154/// Convert byte offset to UTF-16 column position
155///
156/// LSP uses UTF-16 code units for character positions, but Rust strings use
157/// UTF-8 byte offsets. This function converts a byte position within a line
158/// to the corresponding UTF-16 column position.
159pub fn byte_to_utf16_col(line_text: &str, byte_pos: usize) -> usize {
160    let mut units = 0;
161    for (i, ch) in line_text.char_indices() {
162        if i >= byte_pos {
163            break;
164        }
165        // UTF-16 encoding: chars >= U+10000 use 2 units
166        units += if ch as u32 >= 0x10000 { 2 } else { 1 };
167    }
168    units
169}
170
171/// Convert byte offset to line and column
172pub fn byte_to_line_col(source: &str, offset: usize) -> (u32, u32) {
173    let mut line = 0;
174    let mut col = 0;
175
176    for (i, ch) in source.char_indices() {
177        if i >= offset {
178            break;
179        }
180        if ch == '\n' {
181            line += 1;
182            col = 0;
183        } else {
184            col += 1;
185        }
186    }
187
188    (line, col)
189}
190
191/// Find matching closing parenthesis
192pub fn find_matching_paren(s: &str, open_at: usize) -> Option<usize> {
193    // s[open_at] must be '('; walk forwards tracking () and quotes.
194    let mut i = open_at;
195    let mut depth_par = 0i32;
196    let mut in_s = false;
197    let mut in_d = false;
198    while i < s.len() {
199        let b = s.as_bytes()[i];
200        let prev_backslash = i > 0 && s.as_bytes()[i - 1] == b'\\';
201        if in_s {
202            if b == b'\'' && !prev_backslash {
203                in_s = false;
204            }
205        } else if in_d {
206            if b == b'"' && !prev_backslash {
207                in_d = false;
208            }
209        } else {
210            match b {
211                b'\'' => in_s = true,
212                b'"' => in_d = true,
213                b'(' => depth_par += 1,
214                b')' => {
215                    depth_par -= 1;
216                    if depth_par == 0 {
217                        return Some(i);
218                    }
219                }
220                _ => {}
221            }
222        }
223        i += 1;
224    }
225    None
226}
227
228/// Scan forward until end of statement (top-level `;`) honoring quotes/brackets.
229pub fn slice_until_stmt_end(src: &str, from: usize) -> usize {
230    let mut i = from;
231    let mut depth_par = 0i32;
232    let mut depth_brk = 0i32;
233    let mut depth_brc = 0i32;
234    let mut in_s = false;
235    let mut in_d = false;
236    while i < src.len() {
237        let b = src.as_bytes()[i];
238        let esc = i > 0 && src.as_bytes()[i - 1] == b'\\';
239        if in_s {
240            if b == b'\'' && !esc {
241                in_s = false;
242            }
243        } else if in_d {
244            if b == b'"' && !esc {
245                in_d = false;
246            }
247        } else {
248            match b {
249                b'\'' => in_s = true,
250                b'"' => in_d = true,
251                b'(' => depth_par += 1,
252                b')' => depth_par -= 1,
253                b'[' => depth_brk += 1,
254                b']' => depth_brk -= 1,
255                b'{' => depth_brc += 1,
256                b'}' => depth_brc -= 1,
257                b';' if depth_par == 0 && depth_brk == 0 && depth_brc == 0 => return i,
258                _ => {}
259            }
260        }
261        i += 1;
262    }
263    src.len()
264}
265
266/// Top-level argument starts for a comma-separated list without surrounding parens.
267pub fn arg_starts_top_level(src: &str) -> Vec<usize> {
268    let mut v = Vec::new();
269    let mut i = 0usize;
270    while i < src.len() && src.as_bytes()[i].is_ascii_whitespace() {
271        i += 1;
272    }
273    if i < src.len() {
274        v.push(i);
275    }
276    let mut j = i;
277    let mut depth_par = 0i32;
278    let mut depth_brk = 0i32;
279    let mut depth_brc = 0i32;
280    let mut in_s = false;
281    let mut in_d = false;
282    while j < src.len() {
283        let b = src.as_bytes()[j];
284        let esc = j > 0 && src.as_bytes()[j - 1] == b'\\';
285        if in_s {
286            if b == b'\'' && !esc {
287                in_s = false;
288            }
289        } else if in_d {
290            if b == b'"' && !esc {
291                in_d = false;
292            }
293        } else {
294            match b {
295                b'\'' => in_s = true,
296                b'"' => in_d = true,
297                b'(' => depth_par += 1,
298                b')' => depth_par -= 1,
299                b'[' => depth_brk += 1,
300                b']' => depth_brk -= 1,
301                b'{' => depth_brc += 1,
302                b'}' => depth_brc -= 1,
303                b',' if depth_par == 0 && depth_brk == 0 && depth_brc == 0 => {
304                    let mut k = j + 1;
305                    while k < src.len() && src.as_bytes()[k].is_ascii_whitespace() {
306                        k += 1;
307                    }
308                    if k < src.len() {
309                        v.push(k);
310                    }
311                }
312                _ => {}
313            }
314        }
315        j += 1;
316    }
317    v
318}
319
320/// Move the anchor inside an argument to the "interesting" token:
321///  - skip leading whitespace
322///  - for `my|our` args, jump to the first sigiled var (`$foo`/`@a`/`%h`)
323///  - for bareword filehandles (e.g., `FH`), jump to the bareword
324pub fn anchor_arg_start(body: &str, rel: usize) -> usize {
325    let s = &body[rel..];
326    let mut i = 0usize;
327    while i < s.len() && s.as_bytes()[i].is_ascii_whitespace() {
328        i += 1;
329    }
330    // my/our <sigiled-var>
331    if s[i..].starts_with("my ") || s[i..].starts_with("our ") {
332        let mut j = i + 3; // skip "my " / "our "
333        while j < s.len() && s.as_bytes()[j].is_ascii_whitespace() {
334            j += 1;
335        }
336        return rel + j;
337    }
338    // If next is sigiled variable, keep; else keep bareword start
339    rel + i
340}
341
342/// If argument starts at `my $fh`, retarget anchor to the `$fh` (or bareword FH).
343pub fn smart_arg_anchor(body: &str, rel: usize) -> usize {
344    let s = &body[rel..];
345    let mut i = 0usize;
346    while i < s.len() && s.as_bytes()[i].is_ascii_whitespace() {
347        i += 1;
348    }
349
350    // handle my/our
351    for kw in ["my ", "our "] {
352        if s[i..].starts_with(kw) {
353            i += kw.len();
354            while i < s.len() && s.as_bytes()[i].is_ascii_whitespace() {
355                i += 1;
356            }
357            break;
358        }
359    }
360
361    // valid anchors: sigils, barewords, deref braces and array/hash derefs
362    // $, @, %, &, { (for @{ ... }, %{ ... }), [ (rare, but safe), or A-Za-z_ bareword
363    let b = s.as_bytes().get(i).copied().unwrap_or(b' ');
364    if matches!(b, b'$' | b'@' | b'%' | b'&' | b'{' | b'[') || b.is_ascii_alphabetic() || b == b'_'
365    {
366        return rel + i;
367    }
368    rel + i
369}
370
371/// Find argument starts in function call body
372pub fn arg_starts_in_call_body(body: &str) -> Vec<usize> {
373    // Return byte offsets (within body) where each top-level argument starts.
374    let mut starts = Vec::new();
375    let mut i = 0usize;
376    let mut depth_par = 0i32;
377    let mut depth_brk = 0i32;
378    let mut depth_brc = 0i32;
379    let mut in_s = false;
380    let mut in_d = false;
381    // First arg always starts at the first non-space
382    while i < body.len() && body.as_bytes()[i].is_ascii_whitespace() {
383        i += 1;
384    }
385    if i < body.len() {
386        starts.push(i);
387    }
388    let mut j = i;
389    while j < body.len() {
390        let b = body.as_bytes()[j];
391        let prev_backslash = j > 0 && body.as_bytes()[j - 1] == b'\\';
392        if in_s {
393            if b == b'\'' && !prev_backslash {
394                in_s = false;
395            }
396        } else if in_d {
397            if b == b'"' && !prev_backslash {
398                in_d = false;
399            }
400        } else {
401            match b {
402                b'\'' => in_s = true,
403                b'"' => in_d = true,
404                b'(' => depth_par += 1,
405                b')' => depth_par -= 1,
406                b'[' => depth_brk += 1,
407                b']' => depth_brk -= 1,
408                b'{' => depth_brc += 1,
409                b'}' => depth_brc -= 1,
410                b',' if depth_par == 0 && depth_brk == 0 && depth_brc == 0 => {
411                    // next arg start = first non-space after comma
412                    let mut k = j + 1;
413                    while k < body.len() && body.as_bytes()[k].is_ascii_whitespace() {
414                        k += 1;
415                    }
416                    if k < body.len() {
417                        starts.push(k);
418                    }
419                }
420                _ => {}
421            }
422        }
423        j += 1;
424    }
425    starts
426}
427
428/// Convert position to byte offset
429pub fn pos_to_offset_bytes(text: &str, line: u32, ch: u32) -> usize {
430    let mut byte = 0usize;
431    for (cur, l) in text.split_inclusive('\n').enumerate() {
432        if cur as u32 == line {
433            return byte + (ch as usize).min(l.len());
434        }
435        byte += l.len();
436    }
437    text.len()
438}
439
440/// Slice text within range
441pub fn slice_in_range(text: &str, start: (u32, u32), end: (u32, u32)) -> (usize, usize, &str) {
442    let s = pos_to_offset_bytes(text, start.0, start.1);
443    let e = pos_to_offset_bytes(text, end.0, end.1);
444    let (s, e) = char_boundary_range(text, s, e);
445    (s, e, &text[s..e])
446}
447
448/// Get text around an offset position
449pub fn get_text_around_offset(content: &str, offset: usize, radius: usize) -> String {
450    get_text_window_around_offset(content, offset, radius).1
451}
452
453/// Get text around an offset position and return the adjusted byte start.
454pub fn get_text_window_around_offset(
455    content: &str,
456    offset: usize,
457    radius: usize,
458) -> (usize, String) {
459    let offset = offset.min(content.len());
460    let start = offset.saturating_sub(radius);
461    let end = offset.saturating_add(radius).min(content.len());
462    let (start, end) = char_boundary_range(content, start, end);
463    (start, content[start..end].to_string())
464}
465
466fn char_boundary_range(text: &str, start: usize, end: usize) -> (usize, usize) {
467    let start = start.min(text.len());
468    let end = end.min(text.len());
469    if start > end {
470        let boundary = floor_char_boundary(text, end);
471        return (boundary, boundary);
472    }
473    (floor_char_boundary(text, start), ceil_char_boundary(text, end))
474}
475
476fn floor_char_boundary(text: &str, index: usize) -> usize {
477    let mut index = index.min(text.len());
478    while index > 0 && !text.is_char_boundary(index) {
479        index -= 1;
480    }
481    index
482}
483
484fn ceil_char_boundary(text: &str, index: usize) -> usize {
485    let mut index = index.min(text.len());
486    while index < text.len() && !text.is_char_boundary(index) {
487        index += 1;
488    }
489    index
490}
491
492/// Extract module reference from text (e.g., from "use Module::Name" or "require Module::Name")
493pub fn extract_module_reference(text: &str, cursor_pos: usize) -> Option<String> {
494    extract_module_reference_at_cursor(text, cursor_pos)
495}
496
497/// Extract module reference including `use parent`/`use base` argument modules.
498///
499/// This extends [`extract_module_reference`] to also resolve quoted module names
500/// inside `use parent 'Module::Name'` and `use base qw(Module::Name)` statements.
501pub fn extract_module_reference_extended(text: &str, cursor_pos: usize) -> Option<String> {
502    extract_module_reference_extended_at_cursor(text, cursor_pos)
503}
504
505/// Convert an LSP position to a byte offset in the text (UTF-16 aware, CRLF safe)
506pub fn position_to_offset(content: &str, line: u32, character: u32) -> Option<usize> {
507    let mut cur_line = 0u32;
508    let mut col_utf16 = 0u32;
509    let mut prev_was_cr = false;
510
511    for (byte_idx, ch) in content.char_indices() {
512        // Check if we've reached the target position
513        if cur_line == line && col_utf16 == character {
514            return Some(byte_idx);
515        }
516
517        // Handle line endings and character counting
518        match ch {
519            '\n' => {
520                if !prev_was_cr {
521                    // Standalone \n
522                    cur_line += 1;
523                    col_utf16 = 0;
524                }
525                // If prev_was_cr, this \n is part of CRLF and we already incremented the line
526            }
527            '\r' => {
528                // Always increment line on \r (whether solo or part of CRLF)
529                cur_line += 1;
530                col_utf16 = 0;
531            }
532            _ => {
533                // Regular character - only count UTF-16 units on target line
534                if cur_line == line {
535                    col_utf16 += if ch.len_utf16() == 2 { 2 } else { 1 };
536                }
537            }
538        }
539
540        prev_was_cr = ch == '\r';
541    }
542
543    // Handle end of file position
544    if cur_line == line && col_utf16 == character {
545        return Some(content.len());
546    }
547
548    // Return None if position is out of bounds
549    None
550}
551
552/// Convert a byte offset to an LSP position (UTF-16 aware, CRLF safe)
553pub fn offset_to_position(content: &str, offset: usize) -> Position {
554    let mut line = 0u32;
555    let mut col_utf16 = 0u32;
556    let mut byte_pos = 0usize;
557    let mut chars = content.chars().peekable();
558
559    while let Some(ch) = chars.next() {
560        if byte_pos >= offset {
561            break;
562        }
563
564        match ch {
565            '\r' => {
566                // Peek ahead to see if this is CRLF
567                if chars.peek() == Some(&'\n') {
568                    // This is CRLF - treat as single line ending
569                    if byte_pos + 1 >= offset {
570                        // Offset is at the \r - treat as end of current line
571                        break;
572                    }
573                    // Skip both \r and \n
574                    chars.next(); // consume the \n
575                    line += 1;
576                    col_utf16 = 0;
577                    byte_pos += 2; // \r + \n
578                } else {
579                    // Solo \r - treat as line ending
580                    line += 1;
581                    col_utf16 = 0;
582                    byte_pos += ch.len_utf8();
583                }
584            }
585            '\n' => {
586                // LF (could be standalone or part of CRLF, but CRLF is handled above)
587                line += 1;
588                col_utf16 = 0;
589                byte_pos += ch.len_utf8();
590            }
591            _ => {
592                // Regular character
593                col_utf16 += if ch.len_utf16() == 2 { 2 } else { 1 };
594                byte_pos += ch.len_utf8();
595            }
596        }
597    }
598
599    Position { line, character: col_utf16 }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::{
605        arg_starts_in_call_body, arg_starts_top_level, byte_to_utf16_col, decode_text_bytes,
606        escape_markdown_text, extract_module_reference, find_matching_paren,
607        get_text_around_offset, get_text_window_around_offset, offset_to_position,
608        position_to_offset, slice_in_range, slice_until_stmt_end, smart_arg_anchor,
609    };
610    use lsp_types::Position;
611
612    #[test]
613    fn extract_module_reference_detects_use_statement_token() {
614        let line = "use Demo::Worker;";
615        let cursor = line.find("Worker").unwrap_or(0);
616
617        assert_eq!(extract_module_reference(line, cursor), Some("Demo::Worker".to_string()));
618    }
619
620    #[test]
621    fn extract_module_reference_normalizes_legacy_separators() {
622        let line = "require Demo'Worker;";
623        let cursor = line.find("Worker").unwrap_or(0);
624
625        assert_eq!(extract_module_reference(line, cursor), Some("Demo::Worker".to_string()));
626    }
627
628    #[test]
629    fn find_matching_paren_ignores_parens_inside_quotes() {
630        let text = r#"call("ignored ) token", nested(1, 2)) more"#;
631        assert_eq!(find_matching_paren(text, 4), Some(36));
632    }
633
634    #[test]
635    fn slice_until_stmt_end_skips_nested_structures() {
636        let text = r#"my $x = foo(";", [1, 2], { k => ";" }); my $y = 1;"#;
637        assert_eq!(slice_until_stmt_end(text, 0), 38);
638    }
639
640    #[test]
641    fn arg_start_helpers_ignore_nested_commas() {
642        let text = r#" $a, fn(1,2), {k=>3}, "x,y" "#;
643        assert_eq!(arg_starts_top_level(text), vec![1, 5, 14, 22]);
644        assert_eq!(arg_starts_in_call_body(text), vec![1, 5, 14, 22]);
645    }
646
647    #[test]
648    fn smart_arg_anchor_skips_our_keyword() {
649        let body = "our $fh, @rest";
650        assert_eq!(smart_arg_anchor(body, 0), 4);
651    }
652
653    #[test]
654    fn byte_to_utf16_col_counts_surrogate_pairs() {
655        let line = "a😀z";
656        assert_eq!(byte_to_utf16_col(line, 0), 0);
657        assert_eq!(byte_to_utf16_col(line, 1), 1);
658        assert_eq!(byte_to_utf16_col(line, 5), 3);
659    }
660
661    #[test]
662    fn position_offset_roundtrip_handles_crlf_and_emoji() {
663        let content = "my 😀\r\nnext";
664        let pos_after_emoji = Position { line: 0, character: 5 };
665        let offset = position_to_offset(content, pos_after_emoji.line, pos_after_emoji.character);
666        assert_eq!(offset, Some(7));
667        assert_eq!(offset_to_position(content, 7), pos_after_emoji);
668    }
669
670    #[test]
671    fn get_text_around_offset_snaps_start_to_utf8_boundary() {
672        let prefix = format!("{}{}\n", "🦀", "x".repeat(48));
673        let content = format!("{prefix}sub foo {{}}");
674        let offset = prefix.len();
675
676        let text_around = get_text_around_offset(&content, offset, 50);
677
678        assert!(text_around.starts_with("🦀"));
679        assert!(text_around.contains("sub foo"));
680    }
681
682    #[test]
683    fn get_text_window_around_offset_reports_adjusted_utf8_start() {
684        let prefix = format!("{}{}\n", "🦀", "x".repeat(48));
685        let content = format!("{prefix}sub foo {{}}");
686        let offset = prefix.len();
687
688        let (start, text_around) = get_text_window_around_offset(&content, offset, 50);
689        let cursor_in_text = offset.saturating_sub(start);
690
691        assert_eq!(start, 0);
692        assert!(text_around.is_char_boundary(cursor_in_text));
693        assert!(text_around[cursor_in_text..].starts_with("sub foo"));
694    }
695
696    #[test]
697    fn slice_in_range_snaps_utf8_cut_points() {
698        let text = "🦀abc";
699
700        let (start, end, slice) = slice_in_range(text, (0, 2), (0, 3));
701
702        assert_eq!(start, 0);
703        assert_eq!(end, "🦀".len());
704        assert_eq!(slice, "🦀");
705    }
706
707    #[test]
708    fn slice_in_range_returns_empty_for_reversed_range() {
709        let text = "🦀abc";
710
711        let (start, end, slice) = slice_in_range(text, (0, 5), (0, 2));
712
713        assert_eq!(start, 0);
714        assert_eq!(end, 0);
715        assert!(slice.is_empty());
716    }
717
718    #[test]
719    fn decode_text_bytes_supports_utf16_le_bom() {
720        let bytes = [0xFF, 0xFE, b'P', 0x00, b'e', 0x00, b'r', 0x00, b'l', 0x00];
721        assert_eq!(decode_text_bytes(&bytes), "Perl");
722    }
723
724    #[test]
725    fn decode_text_bytes_falls_back_to_latin1() {
726        let bytes = [0x63, 0x61, 0x66, 0xE9];
727        assert_eq!(decode_text_bytes(&bytes), "café");
728    }
729
730    /// Regression: UTF-16 LE BOM followed by an odd number of payload bytes
731    /// must not panic or silently truncate the trailing byte.
732    #[test]
733    fn decode_text_bytes_handles_odd_length_utf16_le() {
734        // BOM (2 bytes) + 3 payload bytes = odd-length UTF-16 payload.
735        let bytes = [0xFF, 0xFE, 0x6D, 0x00, 0x79];
736        let decoded = decode_text_bytes(&bytes);
737        // Must return something (not panic); falls back to latin-1 of
738        // the full original bytes so the caller still sees the content.
739        assert!(!decoded.is_empty());
740    }
741
742    /// Regression: UTF-16 BE BOM followed by an odd number of payload bytes
743    /// must not panic or silently truncate the trailing byte.
744    #[test]
745    fn decode_text_bytes_handles_odd_length_utf16_be() {
746        let bytes = [0xFE, 0xFF, 0x00, 0x6D, 0x00];
747        let decoded = decode_text_bytes(&bytes);
748        assert!(!decoded.is_empty());
749    }
750
751    #[test]
752    fn escape_markdown_text_escapes_asterisks() {
753        let text = "This is *not* bold";
754        let escaped = escape_markdown_text(text);
755        assert_eq!(escaped, "This is \\*not\\* bold");
756    }
757
758    #[test]
759    fn escape_markdown_text_escapes_underscores() {
760        let text = "This is _not_ italic";
761        let escaped = escape_markdown_text(text);
762        assert_eq!(escaped, "This is \\_not\\_ italic");
763    }
764
765    #[test]
766    fn escape_markdown_text_escapes_backticks() {
767        let text = "This is `code` text";
768        let escaped = escape_markdown_text(text);
769        assert_eq!(escaped, "This is \\`code\\` text");
770    }
771
772    #[test]
773    fn escape_markdown_text_escapes_brackets() {
774        let text = "This is [link] text";
775        let escaped = escape_markdown_text(text);
776        assert_eq!(escaped, "This is \\[link\\] text");
777    }
778
779    #[test]
780    fn escape_markdown_text_escapes_hash() {
781        let text = "This is #heading text";
782        let escaped = escape_markdown_text(text);
783        assert_eq!(escaped, "This is \\#heading text");
784    }
785
786    #[test]
787    fn escape_markdown_text_escapes_multiple_special_chars() {
788        let text = "Variable *tracks* [documentation] with `code`";
789        let escaped = escape_markdown_text(text);
790        assert_eq!(escaped, "Variable \\*tracks\\* \\[documentation\\] with \\`code\\`");
791    }
792
793    #[test]
794    fn escape_markdown_text_preserves_plain_text() {
795        let text = "This is plain text";
796        let escaped = escape_markdown_text(text);
797        assert_eq!(escaped, text);
798    }
799
800    #[test]
801    fn escape_markdown_text_escapes_dash() {
802        // Dashes are escaped conservatively (they can start list items at line
803        // start or form setext headings `---`). The output renders identically
804        // in all markdown renderers — `read\-only` displays as `read-only`.
805        let text = "read-only access";
806        let escaped = escape_markdown_text(text);
807        assert_eq!(escaped, r"read\-only access");
808    }
809
810    #[test]
811    fn escape_markdown_text_escapes_backslash() {
812        // Backslashes are escaped so they render as literal backslashes.
813        // A comment like `C:\path\to\file` becomes `C:\\path\\to\\file`.
814        let text = r"C:\path\file";
815        let escaped = escape_markdown_text(text);
816        assert_eq!(escaped, r"C:\\path\\file");
817    }
818
819    #[test]
820    fn escape_markdown_text_handles_empty_string() {
821        assert_eq!(escape_markdown_text(""), "");
822    }
823
824    #[test]
825    fn escape_markdown_text_handles_unicode() {
826        // Multi-byte UTF-8 should pass through unchanged.
827        let text = "Résumé: *important*";
828        let escaped = escape_markdown_text(text);
829        assert_eq!(escaped, r"Résumé: \*important\*");
830    }
831}