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