Skip to main content

scooter_core/
utils.rs

1use std::{
2    borrow::Cow,
3    fs::File,
4    io::{self, BufReader},
5    num::NonZeroUsize,
6    ops::{Add, Div, Mul, Rem},
7    path::Path,
8};
9
10use anyhow::{Context, Error, bail};
11use ignore::overrides::OverrideBuilder;
12use two_face::re_exports::syntect::{
13    easy::HighlightLines,
14    highlighting::{Style, Theme},
15    parsing::SyntaxSet,
16};
17
18use crate::line_reader::{BufReadExt, LinesSplitEndings};
19
20pub fn relative_path(base: &Path, target: &Path) -> String {
21    match target.strip_prefix(base) {
22        Ok(relative) => {
23            // Successfully stripped - base is an ancestor
24            if relative.as_os_str().is_empty() {
25                if target.is_file() {
26                    target
27                        .file_name()
28                        .and_then(|s| s.to_str())
29                        .unwrap_or(".")
30                        .to_string()
31                } else {
32                    ".".to_string()
33                }
34            } else {
35                relative.to_string_lossy().to_string()
36            }
37        }
38        Err(_) => {
39            // Base is not an ancestor - return full target path
40            target.to_string_lossy().to_string()
41        }
42    }
43}
44pub fn group_by<I, T, F>(iter: I, predicate: F) -> Vec<Vec<T>>
45where
46    I: IntoIterator<Item = T>,
47    F: Fn(&T, &T) -> bool,
48{
49    let mut result = Vec::new();
50    let mut current_group = Vec::new();
51
52    for item in iter {
53        if current_group.is_empty() || predicate(current_group.last().unwrap(), &item) {
54            current_group.push(item);
55        } else {
56            result.push(std::mem::take(&mut current_group));
57            current_group.push(item);
58        }
59    }
60
61    if !current_group.is_empty() {
62        result.push(current_group);
63    }
64
65    result
66}
67
68pub fn surrounding_line_window<R>(
69    reader: R,
70    start: usize,
71    end: usize,
72) -> impl Iterator<Item = (usize, String)>
73where
74    R: BufReadExt,
75{
76    assert!(
77        start <= end,
78        "Expected start <= end, found start={start}, end={end}"
79    );
80
81    reader
82        .lines_with_endings()
83        .enumerate()
84        .skip(start)
85        .take(end - start + 1)
86        .map(move |(idx, line_result)| {
87            let line = match line_result {
88                Ok((content, _ending)) => String::from_utf8_lossy(&content).into_owned(),
89                Err(e) => {
90                    log::error!("Error reading line {idx}: {e}");
91                    String::new()
92                }
93            };
94            (idx, line)
95        })
96}
97
98pub fn read_lines_range(
99    path: &Path,
100    start: usize,
101    end: usize,
102) -> io::Result<impl Iterator<Item = (usize, String)>> {
103    let file = File::open(path)?;
104    let reader = BufReader::new(file);
105
106    Ok(surrounding_line_window(reader, start, end))
107}
108
109/// Returns the largest range centred on `centre` that is both within `min_bound` and `max_bound`,
110/// and is no larger than `max_size`.
111///
112/// # Example
113/// ```
114/// use std::num::NonZeroUsize;
115/// use scooter_core::utils::largest_range_centered_on;
116///
117/// // Simple case - centered range with room on both sides
118/// let (start, end) = largest_range_centered_on(5, 0, 10, NonZeroUsize::new(5).unwrap()).unwrap();
119/// assert_eq!((3, 7), (start, end));
120///
121/// // Range limited by lower bound
122/// let (start, end) = largest_range_centered_on(0, 0, 10, NonZeroUsize::new(5).unwrap()).unwrap();
123/// assert_eq!((0, 4), (start, end));
124///
125/// // Range limited by upper bound
126/// let (start, end) = largest_range_centered_on(8, 0, 10, NonZeroUsize::new(5).unwrap()).unwrap();
127/// assert_eq!((6, 10), (start, end));
128///
129/// // Range limited by max_size
130/// let (start, end) = largest_range_centered_on(5, 0, 20, NonZeroUsize::new(3).unwrap()).unwrap();
131/// assert_eq!((4, 6), (start, end));
132/// ```
133pub fn largest_range_centered_on(
134    centre: usize,
135    lower_bound: usize,
136    upper_bound: usize,
137    max_size: NonZeroUsize,
138) -> anyhow::Result<(usize, usize)> {
139    if !(lower_bound <= centre && centre <= upper_bound) {
140        bail!(
141            "Expected start<=pos<=end, found start={lower_bound}, pos={centre}, end={upper_bound}",
142        );
143    }
144    let max_size = max_size.get();
145
146    let mut cur_size = 1;
147    let mut cur_start = centre;
148    let mut cur_end = centre;
149    while cur_size < max_size && (cur_start > lower_bound || cur_end < upper_bound) {
150        if cur_end < upper_bound {
151            cur_end += 1;
152            cur_size += 1;
153        }
154        if cur_size < max_size && cur_start > lower_bound {
155            cur_start -= 1;
156            cur_size += 1;
157        }
158    }
159
160    Ok((cur_start, cur_end))
161}
162
163#[allow(clippy::type_complexity)]
164pub fn split_indexed_lines<T>(
165    indexed_lines: Vec<(usize, T)>,
166    line_idx: usize,
167    num_lines_to_show: u16,
168) -> anyhow::Result<(Vec<(usize, T)>, (usize, T), Vec<(usize, T)>)> {
169    let file_start = indexed_lines.first().context("No lines found")?.0;
170    let file_end = indexed_lines.last().context("No lines found")?.0;
171    let (new_start, new_end) = largest_range_centered_on(
172        line_idx,
173        file_start,
174        file_end,
175        NonZeroUsize::new(num_lines_to_show as usize).context("preview will have height 0")?,
176    )?;
177
178    let mut filtered_lines = indexed_lines
179        .into_iter()
180        .skip_while(|(idx, _)| *idx < new_start)
181        .take_while(|(idx, _)| *idx <= new_end)
182        .collect::<Vec<_>>();
183
184    let position = filtered_lines
185        .iter()
186        .position(|(idx, _)| *idx == line_idx)
187        .context("Couldn't find line in file")?;
188    let after = filtered_lines.split_off(position + 1);
189    let current = filtered_lines.pop().unwrap();
190
191    Ok((filtered_lines, current, after))
192}
193
194/// Returns true if a character needs to be replaced or removed for display purposes.
195fn needs_replacement(c: char) -> bool {
196    c == '\t' || c == '\n' || c == '\r' || c.is_control()
197}
198
199/// Strips or replaces control characters in text for display purposes.
200/// Returns a `Cow` to avoid allocation when no replacements are needed.
201/// - Tabs are replaced with two spaces
202/// - Newlines are replaced with a space
203/// - Carriage returns are stripped entirely
204/// - Other control characters are replaced with the replacement character (�)
205pub fn strip_control_chars(text: &str) -> Cow<'_, str> {
206    if !text.chars().any(needs_replacement) {
207        return Cow::Borrowed(text);
208    }
209
210    let mut result = String::with_capacity(text.len());
211    for c in text.chars() {
212        match c {
213            '\t' => result.push_str("  "),
214            '\n' => result.push(' '),
215            '\r' => {} // Strip carriage returns entirely
216            c if c.is_control() => result.push('�'),
217            c => result.push(c),
218        }
219    }
220    Cow::Owned(result)
221}
222
223pub fn ceil_div<T>(a: T, b: T) -> T
224where
225    T: Add<Output = T>
226        + Div<Output = T>
227        + Mul<Output = T>
228        + Rem<Output = T>
229        + From<bool>
230        + From<u8>
231        + PartialEq
232        + PartialOrd
233        + Copy,
234{
235    // If a * b <= 0 then division already rounds towards 0 i.e. up
236    a / b + T::from((a * b > T::from(0)) && (a % b) != T::from(0))
237}
238
239pub type HighlightedLine = Vec<(Option<Style>, String)>;
240
241struct HighlightedLinesIterator<'a> {
242    lines: LinesSplitEndings<BufReader<File>>,
243    highlighter: HighlightLines<'a>,
244    syntax_set: &'a SyntaxSet,
245    current_idx: usize,
246    start_idx: usize,
247    end_idx: Option<usize>,
248    full_highlighting: bool,
249}
250
251impl<'a> HighlightedLinesIterator<'a> {
252    pub fn new(
253        path: &Path,
254        theme: &'a Theme,
255        syntax_set: &'a SyntaxSet,
256        start_idx: Option<usize>,
257        end_idx: Option<usize>,
258        full_highlighting: bool,
259    ) -> io::Result<Self> {
260        let start_idx = start_idx.unwrap_or(0);
261        if let Some(end_idx) = end_idx {
262            #[allow(clippy::manual_assert)]
263            if start_idx > end_idx {
264                panic!("Expected start <= end, found start={start_idx}, end={end_idx}");
265            }
266        }
267
268        let file = File::open(path)?;
269        let reader = BufReader::new(file);
270        let mut lines = reader.lines_with_endings();
271
272        // Skip lines if we're not doing full highlighting
273        if !full_highlighting {
274            for _ in 0..start_idx {
275                if lines.next().is_none() {
276                    break;
277                }
278            }
279        }
280
281        let syntax = syntax_set
282            .find_syntax_for_file(path)?
283            .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
284
285        Ok(Self {
286            lines,
287            highlighter: HighlightLines::new(syntax, theme),
288            syntax_set,
289            current_idx: if full_highlighting { 0 } else { start_idx },
290            start_idx,
291            end_idx,
292            full_highlighting,
293        })
294    }
295}
296
297impl Iterator for HighlightedLinesIterator<'_> {
298    type Item = (usize, HighlightedLine);
299
300    fn next(&mut self) -> Option<Self::Item> {
301        if let Some(end) = self.end_idx
302            && self.current_idx > end
303        {
304            return None;
305        }
306
307        loop {
308            let idx = self.current_idx;
309            self.current_idx += 1;
310
311            debug_assert!(
312                self.full_highlighting || idx >= self.start_idx,
313                "Should have skipped early lines before iteration"
314            );
315
316            match self.lines.next() {
317                Some(Ok((content, _ending))) => {
318                    // Convert to UTF-8 lossy, which replaces invalid sequences with the � character
319                    let line = String::from_utf8_lossy(&content).into_owned();
320
321                    let highlighted_res = self.highlighter.highlight_line(&line, self.syntax_set);
322                    if let Err(ref e) = highlighted_res {
323                        log::error!("Highlighting error at line {idx}: {e}");
324                    }
325
326                    if idx < self.start_idx {
327                        continue;
328                    }
329
330                    let highlighted = match highlighted_res {
331                        Ok(line) => line
332                            .into_iter()
333                            .map(|(style, text)| (Some(style), text.to_owned()))
334                            .collect(),
335                        Err(_) => {
336                            vec![(None, line)]
337                        }
338                    };
339                    return Some((idx, highlighted));
340                }
341                Some(Err(e)) => {
342                    log::error!("Error reading line {}: {e}", self.current_idx);
343                    return None;
344                }
345                None => return None, // EOF
346            }
347        }
348    }
349}
350
351pub fn read_lines_range_highlighted<'a>(
352    path: &'a Path,
353    start: Option<usize>,
354    end: Option<usize>,
355    theme: &'a Theme,
356    syntax_set: &'a SyntaxSet,
357    full_highlighting: bool,
358) -> io::Result<impl Iterator<Item = (usize, HighlightedLine)> + 'a> {
359    HighlightedLinesIterator::new(path, theme, syntax_set, start, end, full_highlighting)
360}
361
362#[allow(dead_code)]
363pub fn split_while<T, F>(vec: &[T], predicate: F) -> (&[T], &[T])
364where
365    F: Fn(&T) -> bool,
366{
367    match vec.iter().position(|x| !predicate(x)) {
368        Some(index) => vec.split_at(index),
369        None => (vec, &[]),
370    }
371}
372
373pub fn last_n<T>(vec: &[T], n: usize) -> &[T] {
374    &vec[vec.len().saturating_sub(n)..]
375}
376
377pub fn last_n_chars(s: &str, n: usize) -> &str {
378    if n == 0 || s.is_empty() {
379        return "";
380    }
381    let char_count = s.chars().count();
382    if n >= char_count {
383        return s;
384    }
385
386    let (idx, _) = s.char_indices().rev().nth(n - 1).unwrap();
387    &s[idx..]
388}
389
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub enum Either<T, S> {
392    Left(T),
393    Right(S),
394}
395
396pub fn is_regex_error(e: &Error) -> bool {
397    e.downcast_ref::<regex::Error>().is_some() || e.downcast_ref::<fancy_regex::Error>().is_some()
398}
399
400pub fn add_overrides(
401    overrides: &mut OverrideBuilder,
402    files: &str,
403    prefix: &str,
404) -> anyhow::Result<()> {
405    for file in files.split(',') {
406        let file = file.trim();
407        if !file.is_empty() {
408            overrides.add(&format!("{prefix}{file}"))?;
409        }
410    }
411    Ok(())
412}
413
414#[cfg(test)]
415mod tests {
416    use std::io::Write;
417    use tempfile::NamedTempFile;
418    use two_face::re_exports::syntect::highlighting::ThemeSet;
419
420    use super::*;
421
422    fn create_test_file(contents: &str) -> NamedTempFile {
423        let mut file = NamedTempFile::new().unwrap();
424        file.write_all(contents.as_bytes()).unwrap();
425        file
426    }
427
428    #[test]
429    fn test_relative_path_file_in_dir() {
430        // Directory to file in that directory
431        assert_eq!(
432            relative_path(Path::new("/foo/bar/"), Path::new("/foo/bar/baz.rs")),
433            "baz.rs"
434        );
435        assert_eq!(
436            relative_path(Path::new("/foo/bar"), Path::new("/foo/bar/baz.rs")),
437            "baz.rs"
438        );
439    }
440
441    #[test]
442    fn test_relative_path_same_file() {
443        // Same file to itself returns filename
444        // We need to create the file so that `relative_path` can determine that it is indeed a file
445        let test_file = create_test_file("");
446        assert_eq!(
447            relative_path(test_file.path(), test_file.path()),
448            test_file.path().file_name().unwrap().to_string_lossy()
449        );
450    }
451
452    #[test]
453    fn test_relative_path_same_dir() {
454        // Same directory to itself returns "."
455        assert_eq!(
456            relative_path(Path::new("/foo/bar/"), Path::new("/foo/bar/")),
457            "."
458        );
459    }
460
461    #[test]
462    fn test_relative_path_nested() {
463        // Directory to nested file/directory
464        assert_eq!(
465            relative_path(Path::new("/foo"), Path::new("/foo/bar/baz.rs")),
466            "bar/baz.rs"
467        );
468        assert_eq!(
469            relative_path(Path::new("/foo/"), Path::new("/foo/bar/baz/")),
470            "bar/baz"
471        );
472    }
473
474    #[test]
475    fn test_relative_path_not_ancestor() {
476        // Base is not ancestor - return full target path
477        assert_eq!(
478            relative_path(
479                Path::new("/foo/bar"),
480                Path::new("/completely/different/file.rs")
481            ),
482            "/completely/different/file.rs"
483        );
484        assert_eq!(
485            relative_path(Path::new("/foo/bar/"), Path::new("/foo/other.rs")),
486            "/foo/other.rs"
487        );
488    }
489
490    #[test]
491    fn test_relative_path_parent_to_child() {
492        // Going from parent to deeper descendant
493        assert_eq!(
494            relative_path(Path::new("/foo"), Path::new("/foo/bar/baz/qux.rs")),
495            "bar/baz/qux.rs"
496        );
497    }
498
499    #[test]
500    fn test_relative_path_root_cases() {
501        // From root
502        assert_eq!(
503            relative_path(Path::new("/"), Path::new("/foo/bar.rs")),
504            "foo/bar.rs"
505        );
506        // Root to root
507        assert_eq!(relative_path(Path::new("/"), Path::new("/")), ".");
508    }
509
510    #[test]
511    fn test_relative_path_no_trailing_slash() {
512        // Test ambiguous cases without trailing slash
513        assert_eq!(
514            relative_path(Path::new("/foo/bar"), Path::new("/foo/bar/baz")),
515            "baz"
516        );
517    }
518
519    #[test]
520    fn test_relative_path_relative_paths() {
521        // Both paths are relative (no leading slash)
522        assert_eq!(
523            relative_path(Path::new("foo/bar"), Path::new("foo/bar/baz.rs")),
524            "baz.rs"
525        );
526        assert_eq!(
527            relative_path(Path::new("foo"), Path::new("bar/baz.rs")),
528            "bar/baz.rs" // Not an ancestor, return full path
529        );
530    }
531
532    #[test]
533    fn test_vec() {
534        let numbers = vec![1, 2, 2, 3, 4, 4, 4, 5];
535        let grouped = group_by(numbers, |a, b| a == b);
536        assert_eq!(
537            grouped,
538            vec![vec![1], vec![2, 2], vec![3], vec![4, 4, 4], vec![5]]
539        );
540    }
541
542    #[test]
543    fn test_array() {
544        let numbers = [1, 2, 2, 3, 4, 4, 4, 5];
545        let grouped = group_by(numbers, |a, b| a == b);
546        assert_eq!(
547            grouped,
548            vec![vec![1], vec![2, 2], vec![3], vec![4, 4, 4], vec![5]]
549        );
550    }
551
552    #[test]
553    fn test_range() {
554        let grouped = group_by(1..=5, |a, b| b - a <= 1);
555        assert_eq!(grouped, vec![vec![1, 2, 3, 4, 5]]);
556    }
557
558    #[test]
559    fn test_chain() {
560        let first = [1, 2];
561        let second = [2, 3];
562        let grouped = group_by(first.into_iter().chain(second), |a, b| a == b);
563        assert_eq!(grouped, vec![vec![1], vec![2, 2], vec![3]]);
564    }
565
566    #[test]
567    fn test_empty() {
568        let empty: Vec<i32> = vec![];
569        let grouped = group_by(empty, |a, b| a == b);
570        assert_eq!(grouped, Vec::<Vec<i32>>::new());
571    }
572
573    #[test]
574    fn test_single() {
575        let single = std::iter::once(1);
576        let grouped = group_by(single, |a, b| a == b);
577        assert_eq!(grouped, vec![vec![1]]);
578    }
579
580    #[test]
581    fn test_string_slice() {
582        let words = ["apple", "app", "banana", "ban", "cat"];
583        let grouped = group_by(words, |a, b| a.starts_with(b) || b.starts_with(a));
584        assert_eq!(
585            grouped,
586            vec![vec!["apple", "app"], vec!["banana", "ban"], vec!["cat"]]
587        );
588    }
589
590    #[test]
591    fn test_read_lines_in_range() {
592        let contents = "line1\nline2\nline3\nline4\nline5";
593        let file = create_test_file(contents);
594        let path = file.path();
595
596        let result = read_lines_range(path, 1, 3).unwrap().collect::<Vec<_>>();
597
598        assert_eq!(
599            result,
600            vec![
601                (1, "line2".to_string()),
602                (2, "line3".to_string()),
603                (3, "line4".to_string())
604            ]
605        );
606    }
607
608    #[test]
609    fn test_read_single_line() {
610        let contents = "line1\nline2\nline3\nline4\nline5";
611        let file = create_test_file(contents);
612        let path = file.path();
613
614        let result = read_lines_range(path, 2, 2).unwrap().collect::<Vec<_>>();
615
616        assert_eq!(result, vec![(2, "line3".to_string())]);
617    }
618
619    #[test]
620    fn test_read_from_beginning() {
621        let contents = "line1\nline2\nline3\nline4\nline5";
622        let file = create_test_file(contents);
623        let path = file.path();
624
625        let result = read_lines_range(path, 0, 2).unwrap().collect::<Vec<_>>();
626
627        assert_eq!(
628            result,
629            vec![
630                (0, "line1".to_string()),
631                (1, "line2".to_string()),
632                (2, "line3".to_string())
633            ]
634        );
635    }
636
637    #[test]
638    fn test_read_to_end() {
639        let contents = "line1\nline2\nline3\nline4\nline5";
640        let file = create_test_file(contents);
641        let path = file.path();
642
643        let result = read_lines_range(path, 3, 4).unwrap().collect::<Vec<_>>();
644
645        assert_eq!(
646            result,
647            vec![(3, "line4".to_string()), (4, "line5".to_string())]
648        );
649    }
650
651    #[test]
652    fn test_read_all_lines() {
653        let contents = "line1\nline2\nline3\nline4\nline5";
654        let file = create_test_file(contents);
655        let path = file.path();
656
657        let result = read_lines_range(path, 0, 4).unwrap().collect::<Vec<_>>();
658
659        assert_eq!(
660            result,
661            vec![
662                (0, "line1".to_string()),
663                (1, "line2".to_string()),
664                (2, "line3".to_string()),
665                (3, "line4".to_string()),
666                (4, "line5".to_string())
667            ]
668        );
669    }
670
671    #[test]
672    fn test_empty_file() {
673        let contents = "";
674        let file = create_test_file(contents);
675        let path = file.path();
676
677        let result = read_lines_range(path, 0, 2).unwrap().collect::<Vec<_>>();
678
679        assert_eq!(result, Vec::<(usize, String)>::new());
680    }
681
682    #[test]
683    fn test_range_exceeds_file_length() {
684        let contents = "line1\nline2\nline3";
685        let file = create_test_file(contents);
686        let path = file.path();
687
688        let result = read_lines_range(path, 1, 10).unwrap().collect::<Vec<_>>();
689
690        assert_eq!(
691            result,
692            vec![(1, "line2".to_string()), (2, "line3".to_string())]
693        );
694    }
695
696    #[test]
697    fn test_start_exceeds_file_length() {
698        let contents = "line1\nline2\nline3";
699        let file = create_test_file(contents);
700        let path = file.path();
701
702        let result = read_lines_range(path, 5, 10).unwrap().collect::<Vec<_>>();
703
704        assert_eq!(result, Vec::<(usize, String)>::new());
705    }
706
707    #[test]
708    #[should_panic(expected = "Expected start <= end")]
709    fn test_start_greater_than_end() {
710        let contents = "line1\nline2\nline3";
711        let file = create_test_file(contents);
712        let path = file.path();
713
714        let _ = read_lines_range(path, 3, 1);
715    }
716
717    #[test]
718    fn test_with_empty_lines() {
719        let contents = "line1\n\nline3\n\nline5";
720        let file = create_test_file(contents);
721        let path = file.path();
722
723        let result = read_lines_range(path, 0, 4).unwrap().collect::<Vec<_>>();
724
725        assert_eq!(
726            result,
727            vec![
728                (0, "line1".to_string()),
729                (1, "".to_string()),
730                (2, "line3".to_string()),
731                (3, "".to_string()),
732                (4, "line5".to_string())
733            ]
734        );
735    }
736
737    #[test]
738    fn test_file_not_found() {
739        let path = Path::new("non_existent_file.txt");
740
741        let result = read_lines_range(path, 0, 5);
742
743        assert!(result.is_err());
744    }
745
746    #[test]
747    fn test_largest_window_around() {
748        assert!(largest_range_centered_on(5, 0, 0, NonZeroUsize::new(1).unwrap()).is_err());
749
750        assert_eq!(
751            largest_range_centered_on(5, 0, 9, NonZeroUsize::new(1).unwrap()).unwrap(),
752            (5, 5)
753        );
754        assert_eq!(
755            largest_range_centered_on(5, 0, 9, NonZeroUsize::new(2).unwrap()).unwrap(),
756            (5, 6)
757        );
758        assert_eq!(
759            largest_range_centered_on(5, 0, 9, NonZeroUsize::new(3).unwrap()).unwrap(),
760            (4, 6)
761        );
762        assert_eq!(
763            largest_range_centered_on(5, 0, 9, NonZeroUsize::new(4).unwrap()).unwrap(),
764            (4, 7)
765        );
766        assert_eq!(
767            largest_range_centered_on(5, 0, 9, NonZeroUsize::new(5).unwrap()).unwrap(),
768            (3, 7)
769        );
770        assert_eq!(
771            largest_range_centered_on(5, 0, 9, NonZeroUsize::new(6).unwrap()).unwrap(),
772            (3, 8)
773        );
774        assert_eq!(
775            largest_range_centered_on(5, 0, 9, NonZeroUsize::new(7).unwrap()).unwrap(),
776            (2, 8)
777        );
778        assert_eq!(
779            largest_range_centered_on(5, 0, 9, NonZeroUsize::new(8).unwrap()).unwrap(),
780            (2, 9)
781        );
782        assert_eq!(
783            largest_range_centered_on(5, 0, 9, NonZeroUsize::new(9).unwrap()).unwrap(),
784            (1, 9)
785        );
786        assert_eq!(
787            largest_range_centered_on(5, 0, 9, NonZeroUsize::new(10).unwrap()).unwrap(),
788            (0, 9)
789        );
790        assert_eq!(
791            largest_range_centered_on(5, 0, 9, NonZeroUsize::new(11).unwrap()).unwrap(),
792            (0, 9)
793        );
794        assert_eq!(
795            largest_range_centered_on(5, 0, 9, NonZeroUsize::new(999).unwrap()).unwrap(),
796            (0, 9)
797        );
798
799        assert_eq!(
800            largest_range_centered_on(0, 0, 9, NonZeroUsize::new(1).unwrap()).unwrap(),
801            (0, 0)
802        );
803        assert_eq!(
804            largest_range_centered_on(0, 0, 9, NonZeroUsize::new(2).unwrap()).unwrap(),
805            (0, 1)
806        );
807        assert_eq!(
808            largest_range_centered_on(0, 0, 9, NonZeroUsize::new(100).unwrap()).unwrap(),
809            (0, 9)
810        );
811
812        assert_eq!(
813            largest_range_centered_on(5, 3, 5, NonZeroUsize::new(1).unwrap()).unwrap(),
814            (5, 5)
815        );
816        assert_eq!(
817            largest_range_centered_on(5, 3, 5, NonZeroUsize::new(2).unwrap()).unwrap(),
818            (4, 5)
819        );
820        assert_eq!(
821            largest_range_centered_on(5, 3, 5, NonZeroUsize::new(100).unwrap()).unwrap(),
822            (3, 5)
823        );
824    }
825
826    #[test]
827    fn test_sanitize_normal_text() {
828        assert_eq!(strip_control_chars("hello world"), "hello world");
829    }
830
831    #[test]
832    fn test_sanitize_tabs() {
833        assert_eq!(strip_control_chars("hello\tworld"), "hello  world");
834        assert_eq!(strip_control_chars("\t\t"), "    ");
835    }
836
837    #[test]
838    fn test_sanitize_newlines() {
839        assert_eq!(strip_control_chars("hello\nworld"), "hello world");
840        assert_eq!(strip_control_chars("\n\n"), "  ");
841    }
842
843    #[test]
844    fn test_sanitize_control_chars() {
845        assert_eq!(strip_control_chars("hello\u{4}world"), "hello�world");
846        assert_eq!(strip_control_chars("test\u{7}"), "test�");
847        assert_eq!(strip_control_chars("\u{1b}[0m"), "�[0m");
848    }
849
850    #[test]
851    fn test_sanitize_unicode() {
852        assert_eq!(strip_control_chars("héllo→世界"), "héllo→世界");
853    }
854
855    #[test]
856    fn test_sanitize_empty_string() {
857        assert_eq!(strip_control_chars(""), "");
858    }
859
860    #[test]
861    fn test_sanitize_only_control_chars() {
862        assert_eq!(strip_control_chars("\u{1}\u{2}\u{3}\u{4}"), "����");
863    }
864
865    #[test]
866    fn test_sanitize_carriage_returns() {
867        // Carriage returns should be stripped entirely
868        assert_eq!(strip_control_chars("hello\rworld"), "helloworld");
869        assert_eq!(strip_control_chars("hello\r\nworld"), "hello world");
870        assert_eq!(strip_control_chars("\r\r\r"), "");
871    }
872
873    #[test]
874    fn test_ceil_div() {
875        assert_eq!(ceil_div(1, 1), 1);
876        assert_eq!(ceil_div(2, 1), 2);
877        assert_eq!(ceil_div(1, 2), 1);
878        assert_eq!(ceil_div(0, 1), 0);
879        assert_eq!(ceil_div(2, 3), 1);
880        assert_eq!(ceil_div(27, 4), 7);
881        assert_eq!(ceil_div(26, 9), 3);
882        assert_eq!(ceil_div(27, 9), 3);
883        assert_eq!(ceil_div(28, 9), 4);
884        assert_eq!(ceil_div(-2, 3), 0);
885        assert_eq!(ceil_div(-4, 3), -1);
886        assert_eq!(ceil_div(2, -3), 0);
887        assert_eq!(ceil_div(4, -3), -1);
888        assert_eq!(ceil_div(-2, -3), 1);
889        assert_eq!(ceil_div(-4, -3), 2);
890    }
891
892    fn get_theme() -> Theme {
893        let ts = ThemeSet::load_defaults();
894        ts.themes["base16-ocean.dark"].clone()
895    }
896
897    fn get_syntax() -> SyntaxSet {
898        two_face::syntax::extra_no_newlines()
899    }
900
901    #[allow(clippy::type_complexity)]
902    fn extract_text(
903        highlighted_lines: &[(usize, Vec<(Option<Style>, String)>)],
904    ) -> Vec<(usize, String)> {
905        highlighted_lines
906            .iter()
907            .map(|(idx, styles)| {
908                let text = styles
909                    .iter()
910                    .map(|(_, text)| text.clone())
911                    .collect::<String>();
912                (*idx, text)
913            })
914            .collect()
915    }
916
917    #[test]
918    fn test_read_lines_in_range_highlighted() {
919        for full in [true, false] {
920            let contents = "line1\nline2\nline3\nline4\nline5";
921            let file = create_test_file(contents);
922
923            let result = read_lines_range_highlighted(
924                file.path(),
925                Some(1),
926                Some(3),
927                &get_theme(),
928                &get_syntax(),
929                full,
930            )
931            .unwrap()
932            .collect::<Vec<_>>();
933
934            assert_eq!(
935                extract_text(&result),
936                vec![
937                    (1, "line2".to_string()),
938                    (2, "line3".to_string()),
939                    (3, "line4".to_string())
940                ]
941            );
942        }
943    }
944
945    #[test]
946    fn test_read_single_line_highlighted() {
947        for full in [true, false] {
948            let contents = "line1\nline2\nline3\nline4\nline5";
949            let file = create_test_file(contents);
950
951            let result = read_lines_range_highlighted(
952                file.path(),
953                Some(2),
954                Some(2),
955                &get_theme(),
956                &get_syntax(),
957                full,
958            )
959            .unwrap()
960            .collect::<Vec<_>>();
961
962            assert_eq!(extract_text(&result), vec![(2, "line3".to_string())]);
963        }
964    }
965
966    #[test]
967    fn test_read_from_beginning_highlighted() {
968        for full in [true, false] {
969            let contents = "line1\nline2\nline3\nline4\nline5";
970            let file = create_test_file(contents);
971
972            let result = read_lines_range_highlighted(
973                file.path(),
974                Some(0),
975                Some(2),
976                &get_theme(),
977                &get_syntax(),
978                full,
979            )
980            .unwrap()
981            .collect::<Vec<_>>();
982
983            assert_eq!(
984                extract_text(&result),
985                vec![
986                    (0, "line1".to_string()),
987                    (1, "line2".to_string()),
988                    (2, "line3".to_string())
989                ]
990            );
991        }
992    }
993
994    #[test]
995    fn test_read_to_end_highlighted() {
996        for full in [true, false] {
997            let contents = "line1\nline2\nline3\nline4\nline5";
998            let file = create_test_file(contents);
999
1000            let result = read_lines_range_highlighted(
1001                file.path(),
1002                Some(3),
1003                Some(4),
1004                &get_theme(),
1005                &get_syntax(),
1006                full,
1007            )
1008            .unwrap()
1009            .collect::<Vec<_>>();
1010
1011            assert_eq!(
1012                extract_text(&result),
1013                vec![(3, "line4".to_string()), (4, "line5".to_string())]
1014            );
1015        }
1016    }
1017
1018    #[test]
1019    fn test_read_all_lines_highlighted() {
1020        for full in [true, false] {
1021            let contents = "line1\nline2\nline3\nline4\nline5";
1022            let file = create_test_file(contents);
1023
1024            let result = read_lines_range_highlighted(
1025                file.path(),
1026                Some(0),
1027                Some(4),
1028                &get_theme(),
1029                &get_syntax(),
1030                full,
1031            )
1032            .unwrap()
1033            .collect::<Vec<_>>();
1034
1035            assert_eq!(
1036                extract_text(&result),
1037                vec![
1038                    (0, "line1".to_string()),
1039                    (1, "line2".to_string()),
1040                    (2, "line3".to_string()),
1041                    (3, "line4".to_string()),
1042                    (4, "line5".to_string())
1043                ]
1044            );
1045        }
1046    }
1047
1048    #[test]
1049    fn test_empty_file_highlighted() {
1050        for full in [true, false] {
1051            let contents = "";
1052            let file = create_test_file(contents);
1053
1054            let result = read_lines_range_highlighted(
1055                file.path(),
1056                Some(0),
1057                Some(2),
1058                &get_theme(),
1059                &get_syntax(),
1060                full,
1061            )
1062            .unwrap()
1063            .collect::<Vec<_>>();
1064
1065            assert_eq!(extract_text(&result), Vec::<(usize, String)>::new());
1066        }
1067    }
1068
1069    #[test]
1070    fn test_range_exceeds_file_length_highlighted() {
1071        for full in [true, false] {
1072            let contents = "line1\nline2\nline3";
1073            let file = create_test_file(contents);
1074
1075            let result = read_lines_range_highlighted(
1076                file.path(),
1077                Some(1),
1078                Some(10),
1079                &get_theme(),
1080                &get_syntax(),
1081                full,
1082            )
1083            .unwrap()
1084            .collect::<Vec<_>>();
1085
1086            assert_eq!(
1087                extract_text(&result),
1088                vec![(1, "line2".to_string()), (2, "line3".to_string())]
1089            );
1090        }
1091    }
1092
1093    #[test]
1094    fn test_start_exceeds_file_length_highlighted() {
1095        for full in [true, false] {
1096            let contents = "line1\nline2\nline3";
1097            let file = create_test_file(contents);
1098
1099            let result = read_lines_range_highlighted(
1100                file.path(),
1101                Some(5),
1102                Some(10),
1103                &get_theme(),
1104                &get_syntax(),
1105                full,
1106            )
1107            .unwrap()
1108            .collect::<Vec<_>>();
1109
1110            assert_eq!(extract_text(&result), Vec::<(usize, String)>::new());
1111        }
1112    }
1113
1114    #[test]
1115    #[should_panic(expected = "Expected start <= end")]
1116    fn test_start_greater_than_end_highlighted() {
1117        for full in [true, false] {
1118            let contents = "line1\nline2\nline3";
1119            let file = create_test_file(contents);
1120            let _ = read_lines_range_highlighted(
1121                file.path(),
1122                Some(3),
1123                Some(1),
1124                &get_theme(),
1125                &get_syntax(),
1126                full,
1127            );
1128        }
1129    }
1130
1131    #[test]
1132    fn test_with_empty_lines_highlighted() {
1133        for full in [true, false] {
1134            let contents = "line1\n\nline3\n\nline5";
1135            let file = create_test_file(contents);
1136
1137            let result = read_lines_range_highlighted(
1138                file.path(),
1139                Some(0),
1140                Some(4),
1141                &get_theme(),
1142                &get_syntax(),
1143                full,
1144            )
1145            .unwrap()
1146            .collect::<Vec<_>>();
1147
1148            assert_eq!(
1149                extract_text(&result),
1150                vec![
1151                    (0, "line1".to_string()),
1152                    (1, "".to_string()),
1153                    (2, "line3".to_string()),
1154                    (3, "".to_string()),
1155                    (4, "line5".to_string())
1156                ]
1157            );
1158        }
1159    }
1160
1161    #[test]
1162    fn test_file_not_found_highlighted() {
1163        let path = Path::new("non_existent_file.txt");
1164
1165        let theme = get_theme();
1166        let syntax = get_syntax();
1167        let result = read_lines_range_highlighted(path, Some(0), Some(5), &theme, &syntax, true);
1168
1169        assert!(result.is_err());
1170    }
1171
1172    #[test]
1173    fn test_highlighting_preserves_content() {
1174        let contents = "fn main() {\n    println!(\"Hello, world!\");\n}";
1175        let file = create_test_file(contents);
1176
1177        let result = read_lines_range_highlighted(
1178            file.path(),
1179            Some(0),
1180            Some(2),
1181            &get_theme(),
1182            &get_syntax(),
1183            true,
1184        )
1185        .unwrap()
1186        .collect::<Vec<_>>();
1187
1188        assert_eq!(
1189            extract_text(&result),
1190            vec![
1191                (0, "fn main() {".to_string()),
1192                (1, "    println!(\"Hello, world!\");".to_string()),
1193                (2, "}".to_string())
1194            ]
1195        );
1196    }
1197
1198    #[test]
1199    fn test_split_empty() {
1200        let empty: Vec<i32> = vec![];
1201        let (prefix, rest) = split_while(&empty, |&x| x > 0);
1202        assert_eq!(prefix, &[] as &[i32]);
1203        assert_eq!(rest, &[] as &[i32]);
1204    }
1205
1206    #[test]
1207    fn test_all_satisfy() {
1208        let vec = vec![1, 2, 3, 4, 5];
1209        let (prefix, rest) = split_while(&vec, |&x| x > 0);
1210        assert_eq!(prefix, &[1, 2, 3, 4, 5]);
1211        assert_eq!(rest, &[] as &[i32]);
1212    }
1213
1214    #[test]
1215    fn test_none_satisfy() {
1216        let vec = vec![1, 2, 3, 4, 5];
1217        let (prefix, rest) = split_while(&vec, |&x| x > 5);
1218        assert_eq!(prefix, &[] as &[i32]);
1219        assert_eq!(rest, &[1, 2, 3, 4, 5]);
1220    }
1221
1222    #[test]
1223    fn test_some_satisfy() {
1224        let vec = vec![1, 2, 3, 4, 5];
1225        let (prefix, rest) = split_while(&vec, |&x| x < 3);
1226        assert_eq!(prefix, &[1, 2]);
1227        assert_eq!(rest, &[3, 4, 5]);
1228    }
1229
1230    #[test]
1231    fn test_only_first_satisfies() {
1232        let vec = vec![1, 2, 3, 4, 5];
1233        let (prefix, rest) = split_while(&vec, |&x| x == 1);
1234        assert_eq!(prefix, &[1]);
1235        assert_eq!(rest, &[2, 3, 4, 5]);
1236    }
1237
1238    #[test]
1239    fn test_only_last_fails() {
1240        let vec = vec![1, 2, 3, 4, 5];
1241        let (prefix, rest) = split_while(&vec, |&x| x < 5);
1242        assert_eq!(prefix, &[1, 2, 3, 4]);
1243        assert_eq!(rest, &[5]);
1244    }
1245
1246    #[test]
1247    fn test_with_strings() {
1248        let vec = vec!["apple", "banana", "cherry", "date", "elderberry"];
1249        let (prefix, rest) = split_while(&vec, |&s| s.starts_with('a') || s.starts_with('b'));
1250        assert_eq!(prefix, &["apple", "banana"]);
1251        assert_eq!(rest, &["cherry", "date", "elderberry"]);
1252    }
1253
1254    #[test]
1255    fn test_owned_vec() {
1256        // Test for the owned version if you implemented it
1257        let vec = vec![1, 2, 3, 4, 5];
1258        let (prefix, rest) = split_while(&vec, |&x| x < 3);
1259        assert_eq!(prefix, vec![1, 2]);
1260        assert_eq!(rest, vec![3, 4, 5]);
1261    }
1262
1263    #[test]
1264    fn test_last_n_empty() {
1265        let vec: Vec<usize> = vec![];
1266        assert_eq!(last_n(&vec, 0), &(vec![] as Vec<usize>));
1267        assert_eq!(last_n(&vec, 3), &(vec![] as Vec<usize>));
1268    }
1269
1270    #[test]
1271    fn test_last_n_non_empty() {
1272        let vec = (0..10).collect::<Vec<usize>>();
1273        assert_eq!(last_n(&vec, 0), &(vec![] as Vec<usize>));
1274        assert_eq!(last_n(&vec, 1), &vec![9]);
1275        assert_eq!(last_n(&vec, 3), &vec![7, 8, 9]);
1276        assert_eq!(last_n(&vec, 10), &vec);
1277        assert_eq!(last_n(&vec, 200), &vec);
1278    }
1279
1280    #[test]
1281    fn test_last_string_n_empty() {
1282        let s = "".chars().collect::<Vec<_>>();
1283        assert_eq!(last_n(&s, 0), &(vec![] as Vec<char>));
1284        assert_eq!(last_n(&s, 3), &(vec![] as Vec<char>));
1285    }
1286
1287    #[test]
1288    fn test_last_n_string_non_empty() {
1289        let s = "abcdefghijkl".chars().collect::<Vec<_>>();
1290        assert_eq!(last_n(&s, 0), "".chars().collect::<Vec<_>>());
1291        assert_eq!(last_n(&s, 1), "l".chars().collect::<Vec<_>>());
1292        assert_eq!(last_n(&s, 3), "jkl".chars().collect::<Vec<_>>());
1293        assert_eq!(last_n(&s, 10), "cdefghijkl".chars().collect::<Vec<_>>());
1294        assert_eq!(last_n(&s, 12), "abcdefghijkl".chars().collect::<Vec<_>>());
1295        assert_eq!(last_n(&s, 13), "abcdefghijkl".chars().collect::<Vec<_>>());
1296        assert_eq!(last_n(&s, 200), "abcdefghijkl".chars().collect::<Vec<_>>());
1297    }
1298
1299    #[test]
1300    fn test_empty_string() {
1301        assert_eq!(last_n_chars("", 5), "");
1302    }
1303
1304    #[test]
1305    fn test_zero_chars() {
1306        assert_eq!(last_n_chars("hello", 0), "");
1307    }
1308
1309    #[test]
1310    fn test_ascii_full_string() {
1311        assert_eq!(last_n_chars("hello", 5), "hello");
1312    }
1313
1314    #[test]
1315    fn test_ascii_partial_string() {
1316        assert_eq!(last_n_chars("hello", 3), "llo");
1317    }
1318
1319    #[test]
1320    fn test_ascii_more_than_string() {
1321        assert_eq!(last_n_chars("hello", 10), "hello");
1322    }
1323
1324    #[test]
1325    fn test_unicode_chars() {
1326        assert_eq!(last_n_chars("héllö wörld", 5), "wörld");
1327    }
1328
1329    #[test]
1330    fn test_multibyte_chars() {
1331        let s = "こんにちは世界";
1332        assert_eq!(last_n_chars(s, 2), "世界");
1333    }
1334
1335    #[test]
1336    fn test_emoji() {
1337        let s = "Hello 👋 World 🌍";
1338        assert_eq!(last_n_chars(s, 9), "👋 World 🌍");
1339    }
1340
1341    #[allow(clippy::similar_names)]
1342    #[test]
1343    fn test_typescript_syntax_available() {
1344        let syntax_set = two_face::syntax::extra_no_newlines();
1345
1346        let ts_syntax = syntax_set.find_syntax_by_extension("ts");
1347        assert!(ts_syntax.is_some(), "TypeScript syntax should be available");
1348        assert_eq!(ts_syntax.unwrap().name, "TypeScript");
1349
1350        let tsx_syntax = syntax_set.find_syntax_by_extension("tsx");
1351        assert!(
1352            tsx_syntax.is_some(),
1353            "TypeScript React syntax should be available"
1354        );
1355        assert_eq!(tsx_syntax.unwrap().name, "TypeScriptReact");
1356
1357        let js_syntax = syntax_set.find_syntax_by_extension("js");
1358        assert!(js_syntax.is_some(), "JavaScript syntax should be available");
1359    }
1360}