Skip to main content

rumdl_lib/utils/
range_utils.rs

1//! Utilities for position/range conversions
2
3use crate::utils::calculate_indentation_width_default;
4use std::collections::HashSet;
5use std::ops::Range;
6
7/// Find the nearest valid UTF-8 character boundary at or before the given byte index.
8/// This is critical for safely slicing strings that may contain multi-byte UTF-8 characters.
9///
10/// # Safety
11/// Returns a byte index that is guaranteed to be a valid character boundary,
12/// or the string length if the index is beyond the string.
13fn find_char_boundary(s: &str, byte_idx: usize) -> usize {
14    if byte_idx >= s.len() {
15        return s.len();
16    }
17
18    // If the index is already at a character boundary, return it
19    if s.is_char_boundary(byte_idx) {
20        return byte_idx;
21    }
22
23    // Find the nearest character boundary by scanning backwards
24    // This is safe because we know byte_idx < s.len()
25    let mut pos = byte_idx;
26    while pos > 0 && !s.is_char_boundary(pos) {
27        pos -= 1;
28    }
29    pos
30}
31
32/// Convert a byte index within a line into a 1-indexed character column.
33///
34/// rumdl reports diagnostic columns as character offsets, not byte offsets, so
35/// any position derived from a byte index (regex match, `str::find`, parser byte
36/// offset) must pass through this before being stored in a `LintWarning`.
37/// Multi-byte UTF-8 characters are handled by snapping to the nearest character
38/// boundary at or before `byte_idx`.
39pub(crate) fn byte_to_char_count(s: &str, byte_idx: usize) -> usize {
40    let safe_byte_idx = find_char_boundary(s, byte_idx);
41    s[..safe_byte_idx].chars().count() + 1 // 1-indexed
42}
43
44#[derive(Debug)]
45pub struct LineIndex<'a> {
46    line_starts: Vec<usize>,
47    content: &'a str,
48    code_block_lines: Option<HashSet<usize>>,
49}
50
51impl<'a> LineIndex<'a> {
52    pub fn new(content: &'a str) -> Self {
53        let mut line_starts = vec![0];
54        let mut pos = 0;
55
56        for c in content.chars() {
57            pos += c.len_utf8();
58            if c == '\n' {
59                line_starts.push(pos);
60            }
61        }
62
63        let mut index = Self {
64            line_starts,
65            content,
66            code_block_lines: None,
67        };
68
69        // Pre-compute code block lines for better performance
70        index.compute_code_block_lines();
71
72        index
73    }
74
75    /// Create a `LineIndex` from pre-computed line start byte offsets.
76    /// Each entry is the byte offset of the first character on that line.
77    /// The first entry must be 0 (start of content).
78    pub fn with_line_starts(content: &'a str, line_starts: Vec<usize>) -> Self {
79        let mut index = Self {
80            line_starts,
81            content,
82            code_block_lines: None,
83        };
84
85        // Pre-compute code block lines for better performance
86        index.compute_code_block_lines();
87
88        index
89    }
90
91    /// Create a `LineIndex` from pre-computed line starts and code block byte ranges.
92    ///
93    /// Instead of re-scanning content to find code blocks, this converts
94    /// the already-detected byte ranges into line-level information.
95    pub fn with_line_starts_and_code_blocks(
96        content: &'a str,
97        line_starts: Vec<usize>,
98        code_block_byte_ranges: &[(usize, usize)],
99    ) -> Self {
100        let mut code_block_lines = HashSet::new();
101
102        for &(block_start, block_end) in code_block_byte_ranges {
103            let start_line = match line_starts.binary_search(&block_start) {
104                Ok(idx) => idx,
105                Err(idx) => idx.saturating_sub(1),
106            };
107            let end_line = if block_end == 0 {
108                0
109            } else {
110                match line_starts.binary_search(&block_end) {
111                    // block_end exactly at a line start means the block ended on the previous line
112                    Ok(idx) => idx.saturating_sub(1),
113                    Err(idx) => idx.saturating_sub(1),
114                }
115            };
116            for line_idx in start_line..=end_line {
117                code_block_lines.insert(line_idx);
118            }
119        }
120
121        Self {
122            line_starts,
123            content,
124            code_block_lines: Some(code_block_lines),
125        }
126    }
127
128    /// Get the content of a line by 0-based index using pre-computed byte offsets.
129    /// Returns the line content without the trailing newline character.
130    fn get_line(&self, line_idx: usize) -> Option<&'a str> {
131        let start = *self.line_starts.get(line_idx)?;
132        let end = self
133            .line_starts
134            .get(line_idx + 1)
135            .copied()
136            .unwrap_or(self.content.len());
137        let line = &self.content[start..end];
138        // Strip trailing newline (and optional \r before it)
139        let line = line.strip_suffix('\n').unwrap_or(line);
140        let line = line.strip_suffix('\r').unwrap_or(line);
141        Some(line)
142    }
143
144    pub fn line_col_to_byte_range(&self, line: usize, column: usize) -> Range<usize> {
145        let line = line.saturating_sub(1);
146        let line_start = *self.line_starts.get(line).unwrap_or(&self.content.len());
147
148        let current_line = self.get_line(line).unwrap_or("");
149        // Column is 1-indexed character position, not byte position
150        let char_col = column.saturating_sub(1);
151        let char_count = current_line.chars().count();
152        let safe_char_col = char_col.min(char_count);
153
154        // Convert character position to byte position
155        let byte_offset = current_line
156            .char_indices()
157            .nth(safe_char_col)
158            .map_or(current_line.len(), |(idx, _)| idx);
159
160        let start = line_start + byte_offset;
161        start..start
162    }
163
164    /// Calculate a proper byte range for replacing text with a specific length
165    /// This is the correct function to use for LSP fixes
166    ///
167    /// # Safety
168    /// This function correctly handles multi-byte UTF-8 characters by converting
169    /// character positions (columns) to byte positions.
170    pub fn line_col_to_byte_range_with_length(&self, line: usize, column: usize, length: usize) -> Range<usize> {
171        let line = line.saturating_sub(1);
172        let line_start = *self.line_starts.get(line).unwrap_or(&self.content.len());
173        let line_end = self.line_starts.get(line + 1).copied().unwrap_or(self.content.len());
174        let mut current_line = &self.content[line_start..line_end];
175        if let Some(stripped) = current_line.strip_suffix('\n') {
176            current_line = stripped.strip_suffix('\r').unwrap_or(stripped);
177        }
178        if current_line.is_ascii() {
179            let line_len = current_line.len();
180            let start_byte = column.saturating_sub(1).min(line_len);
181            let end_byte = start_byte.saturating_add(length).min(line_len);
182            let start = line_start + start_byte;
183            let end = line_start + end_byte;
184            return start..end;
185        }
186        // Column is 1-indexed character position, not byte position
187        let char_col = column.saturating_sub(1);
188        let char_count = current_line.chars().count();
189        let safe_char_col = char_col.min(char_count);
190
191        // Convert character positions to byte positions
192        let mut char_indices = current_line.char_indices();
193        let start_byte = char_indices
194            .nth(safe_char_col)
195            .map_or(current_line.len(), |(idx, _)| idx);
196
197        // Calculate end position (start + length in characters)
198        let end_char_col = (safe_char_col + length).min(char_count);
199        let end_byte = current_line
200            .char_indices()
201            .nth(end_char_col)
202            .map_or(current_line.len(), |(idx, _)| idx);
203
204        let start = line_start + start_byte;
205        let end = line_start + end_byte;
206        start..end
207    }
208
209    /// Calculate byte range for entire line replacement (including newline)
210    /// This is ideal for rules that need to replace complete lines
211    pub fn whole_line_range(&self, line: usize) -> Range<usize> {
212        let line_idx = line.saturating_sub(1);
213        let start = *self.line_starts.get(line_idx).unwrap_or(&self.content.len());
214        let end = self
215            .line_starts
216            .get(line_idx + 1)
217            .copied()
218            .unwrap_or(self.content.len());
219        start..end
220    }
221
222    /// Calculate byte range spanning multiple lines (from start_line to end_line inclusive)
223    /// Both lines are 1-indexed. This is useful for replacing entire blocks like tables.
224    pub fn multi_line_range(&self, start_line: usize, end_line: usize) -> Range<usize> {
225        let start_idx = start_line.saturating_sub(1);
226        let end_idx = end_line.saturating_sub(1);
227
228        let start = *self.line_starts.get(start_idx).unwrap_or(&self.content.len());
229        let end = self.line_starts.get(end_idx + 1).copied().unwrap_or(self.content.len());
230        start..end
231    }
232
233    /// Calculate byte range for text within a line (excluding newline)
234    /// Useful for replacing specific parts of a line
235    ///
236    /// # Safety
237    /// This function correctly handles multi-byte UTF-8 characters by converting
238    /// character positions (columns) to byte positions.
239    pub fn line_text_range(&self, line: usize, start_col: usize, end_col: usize) -> Range<usize> {
240        let line_idx = line.saturating_sub(1);
241        let line_start = *self.line_starts.get(line_idx).unwrap_or(&self.content.len());
242
243        // Get the actual line content to ensure we don't exceed bounds
244        let current_line = self.get_line(line_idx).unwrap_or("");
245        let char_count = current_line.chars().count();
246
247        // Convert character positions to byte positions
248        let start_char_col = start_col.saturating_sub(1).min(char_count);
249        let end_char_col = end_col.saturating_sub(1).min(char_count);
250
251        let mut char_indices = current_line.char_indices();
252        let start_byte = char_indices
253            .nth(start_char_col)
254            .map_or(current_line.len(), |(idx, _)| idx);
255
256        let end_byte = current_line
257            .char_indices()
258            .nth(end_char_col)
259            .map_or(current_line.len(), |(idx, _)| idx);
260
261        let start = line_start + start_byte;
262        let end = line_start + end_byte.max(start_byte);
263        start..end
264    }
265
266    /// Calculate byte range from start of line to end of line content (excluding newline)
267    /// Useful for replacing line content while preserving line structure
268    pub fn line_content_range(&self, line: usize) -> Range<usize> {
269        let line_idx = line.saturating_sub(1);
270        let line_start = *self.line_starts.get(line_idx).unwrap_or(&self.content.len());
271
272        let current_line = self.get_line(line_idx).unwrap_or("");
273        let line_end = line_start + current_line.len();
274        line_start..line_end
275    }
276
277    /// Get the global start byte offset for a given 1-based line number.
278    pub fn get_line_start_byte(&self, line_num: usize) -> Option<usize> {
279        if line_num == 0 {
280            return None; // Lines are 1-based
281        }
282        // line_num is 1-based, line_starts index is 0-based
283        self.line_starts.get(line_num - 1).copied()
284    }
285
286    /// Check if the line at the given index is within a code block
287    pub fn is_code_block(&self, line: usize) -> bool {
288        if let Some(ref code_block_lines) = self.code_block_lines {
289            code_block_lines.contains(&line)
290        } else {
291            // Fallback to a simpler check if pre-computation wasn't done
292            self.is_code_fence(line)
293        }
294    }
295
296    /// Check if the line is a code fence marker (``` or ~~~)
297    pub fn is_code_fence(&self, line: usize) -> bool {
298        self.get_line(line).is_some_and(|l| {
299            let trimmed = l.trim();
300            trimmed.starts_with("```") || trimmed.starts_with("~~~")
301        })
302    }
303
304    /// Check if the line is a tilde code fence marker (~~~)
305    pub fn is_tilde_code_block(&self, line: usize) -> bool {
306        self.get_line(line).is_some_and(|l| l.trim().starts_with("~~~"))
307    }
308
309    /// Get a reference to the content
310    pub fn get_content(&self) -> &str {
311        self.content
312    }
313
314    /// Pre-compute which lines are within code blocks for faster lookup
315    fn compute_code_block_lines(&mut self) {
316        let mut code_block_lines = HashSet::new();
317        let lines: Vec<&str> = self.content.lines().collect();
318
319        // Initialize block tracking
320        let mut in_block = false;
321        let mut active_fence_type = ' '; // '`' or '~'
322        let mut block_indent = 0;
323        let mut block_fence_length = 0;
324        let mut in_markdown_block = false;
325        let mut nested_fence_start = None;
326        let mut nested_fence_end = None;
327
328        // Process each line
329        for (i, line) in lines.iter().enumerate() {
330            let trimmed = line.trim();
331            let indent = line.len() - trimmed.len();
332
333            // 1. Detect indented code blocks (4+ columns accounting for tab expansion)
334            if calculate_indentation_width_default(line) >= 4 {
335                code_block_lines.insert(i);
336                continue; // Skip further processing for indented code blocks
337            }
338
339            // 2. Handle fenced code blocks (backticks and tildes)
340            if !in_block {
341                // Check for opening fences
342                if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
343                    let char_type = if trimmed.starts_with("```") { '`' } else { '~' };
344                    let count = trimmed.chars().take_while(|&c| c == char_type).count();
345                    let info_string = if trimmed.len() > count {
346                        trimmed[count..].trim()
347                    } else {
348                        ""
349                    };
350
351                    // Mark the start of a new code block
352                    in_block = true;
353                    active_fence_type = char_type;
354                    block_indent = indent;
355                    block_fence_length = count;
356                    in_markdown_block = info_string == "markdown";
357                    nested_fence_start = None;
358                    nested_fence_end = None;
359
360                    code_block_lines.insert(i);
361                }
362            } else {
363                // We're inside a code block
364                code_block_lines.insert(i);
365
366                // Detection of nested fences in markdown blocks
367                if in_markdown_block && nested_fence_start.is_none() && trimmed.starts_with("```") {
368                    // Check if this looks like a nested fence opening (has content after the backticks)
369                    let count = trimmed.chars().take_while(|&c| c == '`').count();
370                    let remaining = if trimmed.len() > count {
371                        trimmed[count..].trim()
372                    } else {
373                        ""
374                    };
375
376                    if !remaining.is_empty() {
377                        nested_fence_start = Some(i);
378                    }
379                }
380
381                // Check if we've found a nested fence end (only if we have a start)
382                if in_markdown_block
383                    && nested_fence_start.is_some()
384                    && nested_fence_end.is_none()
385                    && trimmed.starts_with("```")
386                    && trimmed.trim_start_matches('`').trim().is_empty()
387                {
388                    nested_fence_end = Some(i);
389                }
390
391                // Check if this line matches the closing fence pattern for the outer block
392                if trimmed.starts_with(&active_fence_type.to_string().repeat(3)) {
393                    let count = trimmed.chars().take_while(|&c| c == active_fence_type).count();
394                    let remaining = if trimmed.len() > count {
395                        trimmed[count..].trim()
396                    } else {
397                        ""
398                    };
399
400                    // A line is a closing fence if:
401                    // 1. It uses the same fence character as the opening fence
402                    // 2. It has at least as many fence characters as the opening fence
403                    // 3. It has no content after the fence characters (except for whitespace)
404                    // 4. Its indentation level is less than or equal to the opening fence
405                    let is_valid_closing_fence =
406                        count >= block_fence_length && remaining.is_empty() && indent <= block_indent;
407
408                    // For nested code blocks in markdown, the first backtick fence after the nested content
409                    // should be recognized as the closing fence for the outer block
410                    let is_nested_closing = nested_fence_end.is_some() && i == nested_fence_end.unwrap();
411
412                    // Skip nested closing fences
413                    if is_valid_closing_fence && !is_nested_closing {
414                        in_block = false;
415                        in_markdown_block = false;
416                    }
417                }
418            }
419        }
420
421        self.code_block_lines = Some(code_block_lines);
422    }
423}
424
425/// Calculate end position for a single-line range
426pub fn calculate_single_line_range(line: usize, start_col: usize, length: usize) -> (usize, usize, usize, usize) {
427    (line, start_col, line, start_col + length)
428}
429
430/// Calculate range for entire line.
431///
432/// The end column is a character count (rumdl's diagnostic convention), not a
433/// byte length, so the range is correct on lines containing multi-byte UTF-8.
434pub fn calculate_line_range(line: usize, line_content: &str) -> (usize, usize, usize, usize) {
435    let trimmed_char_len = line_content.trim_end().chars().count();
436    (line, 1, line, trimmed_char_len + 1)
437}
438
439/// Calculate the diagnostic range of a match on a line.
440///
441/// `match_start_byte` and `match_len_bytes` are byte quantities within
442/// `line_content`, as regex matches and `str::find` produce. The returned
443/// columns are 1-indexed character columns. Passing a character index as the
444/// start misplaces the diagnostic on any line with multi-byte text before the
445/// match. An offset inside a character is clamped to a boundary rather than
446/// panicking.
447pub fn calculate_match_range(
448    line: usize,
449    line_content: &str,
450    match_start_byte: usize,
451    match_len_bytes: usize,
452) -> (usize, usize, usize, usize) {
453    let line_len = line_content.len();
454    if match_start_byte > line_len {
455        // If match_start_byte is beyond line bounds, return a safe range at end of line
456        let char_count = line_content.chars().count();
457        return (line, char_count + 1, line, char_count + 1);
458    }
459
460    // Find safe character boundaries for the match range
461    let safe_match_start = find_char_boundary(line_content, match_start_byte);
462    let safe_match_end_byte = find_char_boundary(line_content, (match_start_byte + match_len_bytes).min(line_len));
463
464    // Convert byte positions to character positions safely
465    let char_start = byte_to_char_count(line_content, safe_match_start);
466    let char_len = if safe_match_end_byte > safe_match_start {
467        // Count characters in the safe range
468        line_content[safe_match_start..safe_match_end_byte].chars().count()
469    } else {
470        0
471    };
472    (line, char_start, line, char_start + char_len)
473}
474
475/// Calculate range for trailing content (like trailing spaces)
476///
477/// # Safety
478/// This function safely handles multi-byte UTF-8 characters by ensuring all
479/// string slicing operations occur at valid character boundaries.
480pub fn calculate_trailing_range(line: usize, line_content: &str, content_end: usize) -> (usize, usize, usize, usize) {
481    // Find safe character boundary for content_end
482    let safe_content_end = find_char_boundary(line_content, content_end);
483    let char_content_end = byte_to_char_count(line_content, safe_content_end);
484    let line_char_len = line_content.chars().count() + 1;
485    (line, char_content_end, line, line_char_len)
486}
487
488/// Calculate range for a heading, from the start of the line its text begins on
489/// to the end of the text on the line it ends on.
490///
491/// An ATX heading holds its text on one line, so both are the same line. The
492/// text of a setext heading is the whole paragraph its underline ends, which can
493/// span several lines, and `last_line_content` is the last of them.
494pub fn calculate_heading_range(
495    first_line: usize,
496    last_line: usize,
497    last_line_content: &str,
498) -> (usize, usize, usize, usize) {
499    let trimmed_char_len = last_line_content.trim_end().chars().count();
500    (first_line, 1, last_line, trimmed_char_len + 1)
501}
502
503/// Calculate range for emphasis markers and content
504///
505/// # Safety
506/// This function safely handles multi-byte UTF-8 characters by ensuring all
507/// string slicing operations occur at valid character boundaries.
508pub fn calculate_emphasis_range(
509    line: usize,
510    line_content: &str,
511    start_pos: usize,
512    end_pos: usize,
513) -> (usize, usize, usize, usize) {
514    // Find safe character boundaries for start and end positions
515    let safe_start_pos = find_char_boundary(line_content, start_pos);
516    let safe_end_pos = find_char_boundary(line_content, end_pos);
517    let char_start = byte_to_char_count(line_content, safe_start_pos);
518    let char_end = byte_to_char_count(line_content, safe_end_pos);
519    (line, char_start, line, char_end)
520}
521
522/// Calculate range for HTML tags
523pub fn calculate_html_tag_range(
524    line: usize,
525    line_content: &str,
526    tag_start: usize,
527    tag_len: usize,
528) -> (usize, usize, usize, usize) {
529    calculate_match_range(line, line_content, tag_start, tag_len)
530}
531
532/// Calculate range for URLs
533pub fn calculate_url_range(
534    line: usize,
535    line_content: &str,
536    url_start: usize,
537    url_len: usize,
538) -> (usize, usize, usize, usize) {
539    calculate_match_range(line, line_content, url_start, url_len)
540}
541
542/// Calculate range for list markers
543pub fn calculate_list_marker_range(
544    line: usize,
545    line_content: &str,
546    marker_start: usize,
547    marker_len: usize,
548) -> (usize, usize, usize, usize) {
549    calculate_match_range(line, line_content, marker_start, marker_len)
550}
551
552/// Calculate range that exceeds a limit (like line length)
553pub fn calculate_excess_range(line: usize, line_content: &str, limit: usize) -> (usize, usize, usize, usize) {
554    let char_limit = std::cmp::min(limit, line_content.chars().count());
555    let line_char_len = line_content.chars().count() + 1;
556    (line, char_limit + 1, line, line_char_len)
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn test_single_line_range() {
565        let (start_line, start_col, end_line, end_col) = calculate_single_line_range(5, 10, 3);
566        assert_eq!(start_line, 5);
567        assert_eq!(start_col, 10);
568        assert_eq!(end_line, 5);
569        assert_eq!(end_col, 13);
570    }
571
572    #[test]
573    fn test_heading_range_over_one_line() {
574        let (start_line, start_col, end_line, end_col) = calculate_heading_range(4, 4, "# A heading  ");
575        assert_eq!((start_line, start_col, end_line, end_col), (4, 1, 4, 12));
576    }
577
578    #[test]
579    fn test_heading_range_over_several_lines() {
580        // The text of a setext heading is the whole paragraph its underline
581        // ends, so the range runs from the first of those lines to the end of
582        // the text on the last one.
583        let (start_line, start_col, end_line, end_col) = calculate_heading_range(4, 6, "second line  ");
584        assert_eq!((start_line, start_col, end_line, end_col), (4, 1, 6, 12));
585    }
586
587    #[test]
588    fn test_line_range() {
589        let content = "# This is a heading  ";
590        let (start_line, start_col, end_line, end_col) = calculate_line_range(1, content);
591        assert_eq!(start_line, 1);
592        assert_eq!(start_col, 1);
593        assert_eq!(end_line, 1);
594        assert_eq!(end_col, 20); // Trimmed length + 1
595    }
596
597    #[test]
598    fn test_line_range_non_ascii() {
599        // Issue #670: end column is a character count, not a byte length.
600        // "你好 heading" is 10 characters, so end_column is 11 (the byte length
601        // would be 16).
602        let content = "你好 heading  ";
603        let (_, start_col, _, end_col) = calculate_line_range(1, content);
604        assert_eq!(start_col, 1);
605        assert_eq!(end_col, 11);
606    }
607
608    #[test]
609    fn test_match_range() {
610        let content = "Text <div>content</div> more";
611        let tag_start = 5; // Position of '<'
612        let tag_len = 5; // Length of "<div>"
613        let (start_line, start_col, end_line, end_col) = calculate_match_range(1, content, tag_start, tag_len);
614        assert_eq!(start_line, 1);
615        assert_eq!(start_col, 6); // 1-indexed
616        assert_eq!(end_line, 1);
617        assert_eq!(end_col, 11); // 6 + 5
618    }
619
620    #[test]
621    fn test_trailing_range() {
622        let content = "Text content   "; // 3 trailing spaces
623        let content_end = 12; // End of "Text content"
624        let (start_line, start_col, end_line, end_col) = calculate_trailing_range(1, content, content_end);
625        assert_eq!(start_line, 1);
626        assert_eq!(start_col, 13); // content_end + 1 (1-indexed)
627        assert_eq!(end_line, 1);
628        assert_eq!(end_col, 16); // Total length + 1
629    }
630
631    #[test]
632    fn test_excess_range() {
633        let content = "This line is too long for the limit";
634        let limit = 20;
635        let (start_line, start_col, end_line, end_col) = calculate_excess_range(1, content, limit);
636        assert_eq!(start_line, 1);
637        assert_eq!(start_col, 21); // limit + 1
638        assert_eq!(end_line, 1);
639        assert_eq!(end_col, 36); // Total length + 1 (35 chars + 1 = 36)
640    }
641
642    #[test]
643    fn test_whole_line_range() {
644        let content = "Line 1\nLine 2\nLine 3";
645        let line_index = LineIndex::new(content);
646
647        // Test first line (includes newline)
648        let range = line_index.whole_line_range(1);
649        assert_eq!(range, 0..7); // "Line 1\n"
650
651        // Test middle line
652        let range = line_index.whole_line_range(2);
653        assert_eq!(range, 7..14); // "Line 2\n"
654
655        // Test last line (no newline)
656        let range = line_index.whole_line_range(3);
657        assert_eq!(range, 14..20); // "Line 3"
658    }
659
660    #[test]
661    fn test_line_content_range() {
662        let content = "Line 1\nLine 2\nLine 3";
663        let line_index = LineIndex::new(content);
664
665        // Test first line content (excludes newline)
666        let range = line_index.line_content_range(1);
667        assert_eq!(range, 0..6); // "Line 1"
668
669        // Test middle line content
670        let range = line_index.line_content_range(2);
671        assert_eq!(range, 7..13); // "Line 2"
672
673        // Test last line content
674        let range = line_index.line_content_range(3);
675        assert_eq!(range, 14..20); // "Line 3"
676    }
677
678    #[test]
679    fn test_line_text_range() {
680        let content = "Hello world\nAnother line";
681        let line_index = LineIndex::new(content);
682
683        // Test partial text in first line
684        let range = line_index.line_text_range(1, 1, 5); // "Hell"
685        assert_eq!(range, 0..4);
686
687        // Test partial text in second line
688        let range = line_index.line_text_range(2, 1, 7); // "Another"
689        assert_eq!(range, 12..18);
690
691        // Test bounds checking
692        let range = line_index.line_text_range(1, 1, 100); // Should clamp to line end
693        assert_eq!(range, 0..11); // "Hello world"
694    }
695
696    #[test]
697    fn test_calculate_match_range_bounds_checking() {
698        // Test case 1: match_start beyond line bounds
699        let line_content = "] not a link [";
700        let (line, start_col, end_line, end_col) = calculate_match_range(121, line_content, 57, 10);
701        assert_eq!(line, 121);
702        assert_eq!(start_col, 15); // line length + 1
703        assert_eq!(end_line, 121);
704        assert_eq!(end_col, 15); // same as start when out of bounds
705
706        // Test case 2: match extends beyond line end
707        let line_content = "short";
708        let (line, start_col, end_line, end_col) = calculate_match_range(1, line_content, 2, 10);
709        assert_eq!(line, 1);
710        assert_eq!(start_col, 3); // position 2 + 1
711        assert_eq!(end_line, 1);
712        assert_eq!(end_col, 6); // clamped to line length + 1
713
714        // Test case 3: normal case within bounds
715        let line_content = "normal text here";
716        let (line, start_col, end_line, end_col) = calculate_match_range(5, line_content, 7, 4);
717        assert_eq!(line, 5);
718        assert_eq!(start_col, 8); // position 7 + 1
719        assert_eq!(end_line, 5);
720        assert_eq!(end_col, 12); // position 7 + 4 + 1
721
722        // Test case 4: zero length match
723        let line_content = "test line";
724        let (line, start_col, end_line, end_col) = calculate_match_range(10, line_content, 5, 0);
725        assert_eq!(line, 10);
726        assert_eq!(start_col, 6); // position 5 + 1
727        assert_eq!(end_line, 10);
728        assert_eq!(end_col, 6); // same as start for zero length
729    }
730
731    // ============================================================================
732    // UTF-8 Multi-byte Character Tests (Issue #154)
733    // ============================================================================
734
735    #[test]
736    fn test_issue_154_korean_character_boundary() {
737        // Exact reproduction of issue #154: Korean character '후' (3 bytes: 18..21)
738        // The error was: "byte index 19 is not a char boundary; it is inside '후'"
739        let line_content = "- 2023 년 초 이후 주가 상승        +1,000% (10 배 상승)  ";
740
741        // Test match at byte 19 (middle of '후' character)
742        // This should not panic and should find the nearest character boundary
743        let (line, start_col, end_line, end_col) = calculate_match_range(1, line_content, 19, 1);
744
745        // Should successfully calculate without panicking
746        assert!(start_col > 0);
747        assert_eq!(line, 1);
748        assert_eq!(end_line, 1);
749        assert!(end_col >= start_col);
750    }
751
752    #[test]
753    fn test_calculate_match_range_korean() {
754        // Korean text: "안녕하세요" (Hello in Korean)
755        // Each character is 3 bytes
756        let line_content = "안녕하세요";
757        // Match at byte 3 (start of second character)
758        let (line, start_col, end_line, end_col) = calculate_match_range(1, line_content, 3, 3);
759        assert_eq!(line, 1);
760        assert_eq!(start_col, 2); // Second character (1-indexed)
761        assert_eq!(end_line, 1);
762        assert_eq!(end_col, 3); // End of second character
763
764        // Match at byte 4 (middle of second character - should round down)
765        let (line, start_col, end_line, _end_col) = calculate_match_range(1, line_content, 4, 3);
766        assert_eq!(line, 1);
767        assert_eq!(start_col, 2); // Should round to start of character
768        assert_eq!(end_line, 1);
769    }
770
771    #[test]
772    fn test_calculate_match_range_chinese() {
773        // Chinese text: "你好世界" (Hello World)
774        // Each character is 3 bytes
775        let line_content = "你好世界";
776        // Match at byte 6 (start of third character)
777        let (line, start_col, end_line, end_col) = calculate_match_range(1, line_content, 6, 3);
778        assert_eq!(line, 1);
779        assert_eq!(start_col, 3); // Third character (1-indexed)
780        assert_eq!(end_line, 1);
781        assert_eq!(end_col, 4); // End of third character
782    }
783
784    #[test]
785    fn test_calculate_match_range_japanese() {
786        // Japanese text: "こんにちは" (Hello)
787        // Each character is 3 bytes
788        let line_content = "こんにちは";
789        // Match at byte 9 (start of fourth character)
790        let (line, start_col, end_line, end_col) = calculate_match_range(1, line_content, 9, 3);
791        assert_eq!(line, 1);
792        assert_eq!(start_col, 4); // Fourth character (1-indexed)
793        assert_eq!(end_line, 1);
794        assert_eq!(end_col, 5); // End of fourth character
795    }
796
797    #[test]
798    fn test_calculate_match_range_mixed_unicode() {
799        // Mixed ASCII and CJK: "Hello 世界"
800        // "Hello " = 6 bytes (H, e, l, l, o, space)
801        // "世" = bytes 6-8 (3 bytes), character 7
802        // "界" = bytes 9-11 (3 bytes), character 8
803        let line_content = "Hello 世界";
804
805        // Match at byte 5 (space character)
806        let (line, start_col, end_line, end_col) = calculate_match_range(1, line_content, 5, 1);
807        assert_eq!(line, 1);
808        assert_eq!(start_col, 6); // Space character (1-indexed: H=1, e=2, l=3, l=4, o=5, space=6)
809        assert_eq!(end_line, 1);
810        assert_eq!(end_col, 7); // After space
811
812        // Match at byte 6 (start of first Chinese character "世")
813        let (line, start_col, end_line, end_col) = calculate_match_range(1, line_content, 6, 3);
814        assert_eq!(line, 1);
815        assert_eq!(start_col, 7); // First Chinese character (1-indexed)
816        assert_eq!(end_line, 1);
817        assert_eq!(end_col, 8); // End of first Chinese character
818    }
819
820    #[test]
821    fn test_calculate_trailing_range_korean() {
822        // Korean text with trailing spaces
823        let line_content = "안녕하세요   ";
824        // content_end at byte 15 (middle of last character + spaces)
825        let (line, start_col, end_line, end_col) = calculate_trailing_range(1, line_content, 15);
826        assert_eq!(line, 1);
827        assert!(start_col > 0);
828        assert_eq!(end_line, 1);
829        assert!(end_col > start_col);
830    }
831
832    #[test]
833    fn test_calculate_emphasis_range_chinese() {
834        // Chinese text with emphasis markers
835        let line_content = "这是**重要**的";
836        // start_pos and end_pos at byte boundaries within Chinese characters
837        let (line, start_col, end_line, end_col) = calculate_emphasis_range(1, line_content, 6, 12);
838        assert_eq!(line, 1);
839        assert!(start_col > 0);
840        assert_eq!(end_line, 1);
841        assert!(end_col > start_col);
842    }
843
844    #[test]
845    fn test_line_col_to_byte_range_korean() {
846        // Test that column positions (character positions) are correctly converted to byte positions
847        let content = "안녕하세요\nWorld";
848        let line_index = LineIndex::new(content);
849
850        // Column 1 (first character)
851        let range = line_index.line_col_to_byte_range(1, 1);
852        assert_eq!(range, 0..0);
853
854        // Column 2 (second character)
855        let range = line_index.line_col_to_byte_range(1, 2);
856        assert_eq!(range, 3..3); // 3 bytes for first character
857
858        // Column 3 (third character)
859        let range = line_index.line_col_to_byte_range(1, 3);
860        assert_eq!(range, 6..6); // 6 bytes for first two characters
861    }
862
863    #[test]
864    fn test_line_col_to_byte_range_with_length_chinese() {
865        // Test byte range calculation with length for Chinese characters
866        let content = "你好世界\nTest";
867        let line_index = LineIndex::new(content);
868
869        // Column 1, length 2 (first two Chinese characters)
870        let range = line_index.line_col_to_byte_range_with_length(1, 1, 2);
871        assert_eq!(range, 0..6); // 6 bytes for two 3-byte characters
872
873        // Column 2, length 1 (second Chinese character)
874        let range = line_index.line_col_to_byte_range_with_length(1, 2, 1);
875        assert_eq!(range, 3..6); // Bytes 3-6 for second character
876    }
877
878    #[test]
879    fn test_line_text_range_japanese() {
880        // Test text range calculation for Japanese characters
881        let content = "こんにちは\nHello";
882        let line_index = LineIndex::new(content);
883
884        // Columns 2-4 (second to fourth Japanese characters)
885        let range = line_index.line_text_range(1, 2, 4);
886        assert_eq!(range, 3..9); // Bytes 3-9 for three 3-byte characters
887    }
888
889    #[test]
890    fn test_find_char_boundary_edge_cases() {
891        // Test the helper function directly
892        let s = "안녕";
893
894        // Byte 0 (start) - should be valid
895        assert_eq!(find_char_boundary(s, 0), 0);
896
897        // Byte 1 (middle of first character) - should round down to 0
898        assert_eq!(find_char_boundary(s, 1), 0);
899
900        // Byte 2 (middle of first character) - should round down to 0
901        assert_eq!(find_char_boundary(s, 2), 0);
902
903        // Byte 3 (start of second character) - should be valid
904        assert_eq!(find_char_boundary(s, 3), 3);
905
906        // Byte 4 (middle of second character) - should round down to 3
907        assert_eq!(find_char_boundary(s, 4), 3);
908
909        // Byte beyond string length - should return string length
910        assert_eq!(find_char_boundary(s, 100), s.len());
911    }
912
913    #[test]
914    fn test_byte_to_char_count_unicode() {
915        // Test character counting with multi-byte characters
916        let s = "안녕하세요";
917
918        // Byte 0 (start) - 1 character
919        assert_eq!(byte_to_char_count(s, 0), 1);
920
921        // Byte 3 (start of second character) - 2 characters
922        assert_eq!(byte_to_char_count(s, 3), 2);
923
924        // Byte 6 (start of third character) - 3 characters
925        assert_eq!(byte_to_char_count(s, 6), 3);
926
927        // Byte 9 (start of fourth character) - 4 characters
928        assert_eq!(byte_to_char_count(s, 9), 4);
929
930        // Byte 12 (start of fifth character) - 5 characters
931        assert_eq!(byte_to_char_count(s, 12), 5);
932
933        // Byte 15 (end) - 6 characters (5 + 1 for 1-indexed)
934        assert_eq!(byte_to_char_count(s, 15), 6);
935    }
936
937    #[test]
938    fn test_all_range_functions_with_emoji() {
939        // Test with emoji (4-byte UTF-8 characters)
940        let line_content = "Hello 🎉 World 🌍";
941
942        // calculate_match_range
943        let (line, start_col, end_line, end_col) = calculate_match_range(1, line_content, 6, 4);
944        assert_eq!(line, 1);
945        assert!(start_col > 0);
946        assert_eq!(end_line, 1);
947        assert!(end_col > start_col);
948
949        // calculate_trailing_range
950        let (line, start_col, end_line, end_col) = calculate_trailing_range(1, line_content, 12);
951        assert_eq!(line, 1);
952        assert!(start_col > 0);
953        assert_eq!(end_line, 1);
954        assert!(end_col > start_col);
955
956        // calculate_emphasis_range
957        let (line, start_col, end_line, end_col) = calculate_emphasis_range(1, line_content, 0, 5);
958        assert_eq!(line, 1);
959        assert_eq!(start_col, 1);
960        assert_eq!(end_line, 1);
961        assert!(end_col > start_col);
962    }
963}