Skip to main content

grit_lib/
line_range.rs

1//! Resolve Git-style `-L` line ranges for blame (and future log `-L`).
2//!
3//! Behavior matches [git-scm.com line-range documentation](https://git-scm.com/docs/git-blame#Documentation/git-blame.txt--Lltstartgtltendgt):
4//! numeric endpoints, `/search/` (POSIX basic regex), relative `,+N` / `,-N` tails,
5//! and `:symbol` spans using the same funcname rules as diff hunk headers.
6//!
7//! ```rust
8//! use grit_lib::line_range::split_content_lines;
9//!
10//! assert_eq!(split_content_lines("a\nb\n"), ["a", "b"]);
11//! ```
12
13use std::ops::RangeInclusive;
14
15use crate::config::ConfigSet;
16use crate::diff::{is_func_line, PosixBre};
17use crate::userdiff::FuncnameMatcher;
18use thiserror::Error;
19
20/// Failure while parsing or resolving a `-L` line range specification.
21#[derive(Debug, Error, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum LineRangeError {
24    /// The spec could not be parsed or had trailing garbage.
25    #[error("-L line range `{spec}`: {detail}")]
26    InvalidSpec {
27        /// Raw `-L` argument.
28        spec: String,
29        /// Human-readable detail.
30        detail: String,
31    },
32    /// A line number was zero or negative.
33    #[error("-L line range `{spec}`: invalid line number {line}")]
34    InvalidLineNumber {
35        /// Raw `-L` argument.
36        spec: String,
37        /// Offending number.
38        line: i64,
39    },
40    /// Relative end `,+0` / `,-0` style empty span.
41    #[error("-L line range `{spec}`: empty line range")]
42    EmptyRange {
43        /// Raw `-L` argument.
44        spec: String,
45    },
46    /// POSIX regex compilation failed.
47    #[error("-L parameter `{pattern}`: {detail}")]
48    InvalidRegex {
49        /// Pattern (without slashes).
50        pattern: String,
51        /// `regcomp` / engine message.
52        detail: String,
53    },
54    /// Regex did not match from the anchor line.
55    #[error("-L parameter `{pattern}` starting at line {start_line}: no match")]
56    RegexNoMatch {
57        /// Pattern (without slashes).
58        pattern: String,
59        /// 1-based anchor line searched from.
60        start_line: usize,
61    },
62    /// `:funcname` pattern did not match a function header.
63    #[error("-L parameter `{pattern}` starting at line {start_line}: no match")]
64    FuncnameNoMatch {
65        /// Funcname search pattern.
66        pattern: String,
67        /// 1-based anchor line searched from.
68        start_line: usize,
69    },
70    /// `:funcname` pattern is invalid.
71    #[error("-L parameter `{pattern}`: {detail}")]
72    FuncnameInvalidPattern {
73        /// Funcname search pattern.
74        pattern: String,
75        /// Detail.
76        detail: String,
77    },
78    /// `:funcname` match ended at EOF (Git rejects blaming through EOF for func ranges).
79    #[error("-L parameter `{pattern}` matches at EOF")]
80    FuncnameAtEof {
81        /// Funcname search pattern.
82        pattern: String,
83    },
84    /// Resolved range lies outside the file.
85    #[error("-L line range `{spec}`: file has only {lines} line(s)")]
86    OutOfRange {
87        /// Raw `-L` argument.
88        spec: String,
89        /// Line count in the file at the blamed revision.
90        lines: usize,
91    },
92}
93
94/// Split blob text into lines the same way as [`crate::blame`] (final line without `\n` is still a line).
95#[must_use]
96pub fn split_content_lines(content: &str) -> Vec<&str> {
97    if content.is_empty() {
98        return Vec::new();
99    }
100    let mut out: Vec<&str> = content.split('\n').collect();
101    if out.last() == Some(&"") {
102        out.pop();
103    }
104    out
105}
106
107/// Resolve one `-L` spec against `content` using Git funcname rules when `matcher` is set.
108///
109/// `anchor` is the 1-based line where chained `-L` ranges would continue (always `1` for a single `-L`).
110///
111/// `path` and `config` are accepted for API stability; resolution uses `matcher` and `content` only.
112///
113/// # Errors
114///
115/// Returns [`LineRangeError`] when the spec is invalid or cannot be resolved on `content`.
116pub fn resolve_line_range(
117    spec: &str,
118    content: &str,
119    _path: &str,
120    _config: &ConfigSet,
121    funcname_matcher: Option<&FuncnameMatcher>,
122    anchor: usize,
123) -> Result<RangeInclusive<usize>, LineRangeError> {
124    let spec_owned = spec.to_owned();
125    let lines = split_content_lines(content);
126    let line_count = lines.len();
127    let mut anchor = anchor.max(1);
128    if line_count > 0 && anchor > line_count {
129        anchor = line_count + 1;
130    }
131
132    let view = TextView::new(content, &lines);
133    let draft = decode_range_spec(
134        spec.trim(),
135        &spec_owned,
136        &view,
137        line_count,
138        anchor,
139        funcname_matcher,
140    )?;
141    finalize_inclusive_range(draft, line_count, &spec_owned)
142}
143
144/// Parsed but not yet clamped 1-based endpoints (`0` means “unset”).
145struct DraftRange {
146    start: usize,
147    end: usize,
148}
149
150struct TextView<'a> {
151    raw: &'a str,
152    lines: &'a [&'a str],
153}
154
155impl<'a> TextView<'a> {
156    fn new(raw: &'a str, lines: &'a [&'a str]) -> Self {
157        Self { raw, lines }
158    }
159
160    fn line_count(&self) -> usize {
161        self.lines.len()
162    }
163
164    fn line_byte_range(&self, index_0: usize) -> (usize, usize) {
165        let start = byte_offset_of_line(self.raw, index_0);
166        let end = if index_0 + 1 < self.line_count() {
167            byte_offset_of_line(self.raw, index_0 + 1)
168        } else {
169            self.raw.len()
170        };
171        let end = if end > start && self.raw.as_bytes()[end - 1] == b'\n' {
172            end - 1
173        } else {
174            end
175        };
176        (start, end)
177    }
178
179    fn line_text(&self, index_0: usize) -> &str {
180        let (start, end) = self.line_byte_range(index_0);
181        &self.raw[start..end]
182    }
183}
184
185fn decode_range_spec(
186    spec: &str,
187    spec_owned: &str,
188    view: &TextView<'_>,
189    line_count: usize,
190    anchor_1: usize,
191    matcher: Option<&FuncnameMatcher>,
192) -> Result<DraftRange, LineRangeError> {
193    if spec.starts_with(':') || spec.starts_with("^:") {
194        return decode_symbol_range(spec, spec_owned, view, line_count, anchor_1, matcher);
195    }
196
197    let (start_token, tail) = split_first_comma(spec);
198    let start = resolve_endpoint(
199        start_token,
200        spec_owned,
201        view,
202        EndpointContext {
203            role: EndpointRole::Start,
204            anchor_1,
205            pivot_1: 0,
206        },
207    )?;
208    let mut draft = DraftRange { start, end: 0 };
209    if let Some(end_token) = tail {
210        let end = resolve_endpoint(
211            end_token.trim_start(),
212            spec_owned,
213            view,
214            EndpointContext {
215                role: EndpointRole::End,
216                anchor_1,
217                pivot_1: draft.start,
218            },
219        )?;
220        draft.end = end;
221    }
222    Ok(draft)
223}
224
225enum EndpointRole {
226    Start,
227    End,
228}
229
230struct EndpointContext {
231    role: EndpointRole,
232    anchor_1: usize,
233    pivot_1: usize,
234}
235
236fn resolve_endpoint(
237    token: &str,
238    spec_owned: &str,
239    view: &TextView<'_>,
240    ctx: EndpointContext,
241) -> Result<usize, LineRangeError> {
242    let token = token.trim();
243    if token.is_empty() {
244        return Err(LineRangeError::InvalidSpec {
245            spec: spec_owned.to_owned(),
246            detail: "empty range component".into(),
247        });
248    }
249
250    let (token, scan_from_line_one) = if let Some(rest) = token.strip_prefix('^') {
251        (rest.trim_start(), true)
252    } else {
253        (token, false)
254    };
255
256    let relative_base = match ctx.role {
257        EndpointRole::Start => -(ctx.anchor_1 as i64),
258        EndpointRole::End => (ctx.pivot_1.saturating_add(1)) as i64,
259    };
260
261    if relative_base >= 1 && (token.starts_with('+') || token.starts_with('-')) {
262        return relative_offset_line(token, spec_owned, relative_base);
263    }
264
265    if let Some((num, rest)) = leading_decimal(token) {
266        if num <= 0 {
267            return Err(LineRangeError::InvalidLineNumber {
268                spec: spec_owned.to_owned(),
269                line: num,
270            });
271        }
272        if !rest.is_empty() {
273            return Err(LineRangeError::InvalidSpec {
274                spec: spec_owned.to_owned(),
275                detail: format!("unexpected trailing text `{rest}`"),
276            });
277        }
278        return Ok(num as usize);
279    }
280
281    if token.starts_with('/') {
282        let scan_anchor = if scan_from_line_one { 1 } else { relative_base };
283        return slash_search_line(token, spec_owned, view, scan_anchor);
284    }
285
286    Err(LineRangeError::InvalidSpec {
287        spec: spec_owned.to_owned(),
288        detail: format!("could not parse `{token}`"),
289    })
290}
291
292fn relative_offset_line(
293    token: &str,
294    spec_owned: &str,
295    base_1: i64,
296) -> Result<usize, LineRangeError> {
297    let (num, rest) = leading_decimal(&token[1..]).ok_or_else(|| LineRangeError::InvalidSpec {
298        spec: spec_owned.to_owned(),
299        detail: format!(
300            "expected relative offset after `{}`",
301            token.chars().next().unwrap_or('?')
302        ),
303    })?;
304    if !rest.is_empty() {
305        return Err(LineRangeError::InvalidSpec {
306            spec: spec_owned.to_owned(),
307            detail: format!("unexpected trailing text `{rest}`"),
308        });
309    }
310    if num == 0 {
311        return Err(LineRangeError::EmptyRange {
312            spec: spec_owned.to_owned(),
313        });
314    }
315    let signed = if token.starts_with('-') { -num } else { num };
316    let resolved = if signed > 0 {
317        (base_1 as usize)
318            .saturating_add(signed as usize)
319            .saturating_sub(2)
320    } else if signed == 0 {
321        base_1 as usize
322    } else {
323        let shifted = base_1 + signed;
324        if shifted > 0 {
325            shifted as usize
326        } else {
327            1
328        }
329    };
330    Ok(resolved)
331}
332
333fn slash_search_line(
334    token: &str,
335    spec_owned: &str,
336    view: &TextView<'_>,
337    scan_anchor: i64,
338) -> Result<usize, LineRangeError> {
339    let mut body = token;
340    let mut scan_line_1 = scan_anchor;
341    if scan_line_1 < 0 {
342        if body.starts_with('^') {
343            scan_line_1 = 1;
344            body = body.trim_start_matches('^').trim_start();
345        } else {
346            scan_line_1 = -scan_line_1;
347        }
348    }
349    let (pattern, rest) = delimited_slashes(body).ok_or_else(|| LineRangeError::InvalidSpec {
350        spec: spec_owned.to_owned(),
351        detail: "expected /pattern/".into(),
352    })?;
353    if !rest.is_empty() {
354        return Err(LineRangeError::InvalidSpec {
355            spec: spec_owned.to_owned(),
356            detail: format!("unexpected trailing text `{rest}`"),
357        });
358    }
359    let line_index_0 = scan_line_1.saturating_sub(1) as usize;
360    let start_byte = byte_offset_of_line(view.raw, line_index_0);
361    let haystack = &view.raw.as_bytes()[start_byte..];
362    let re = PosixBre::compile(&pattern).map_err(|detail| LineRangeError::InvalidRegex {
363        pattern: pattern.clone(),
364        detail,
365    })?;
366    let (rel_start, _) = re
367        .find_from(haystack, 0)
368        .ok_or_else(|| LineRangeError::RegexNoMatch {
369            pattern,
370            start_line: line_index_0.saturating_add(1),
371        })?;
372    Ok(one_based_line_at_byte(view.raw, start_byte + rel_start))
373}
374
375fn decode_symbol_range(
376    spec: &str,
377    spec_owned: &str,
378    view: &TextView<'_>,
379    line_count: usize,
380    anchor_1: usize,
381    matcher: Option<&FuncnameMatcher>,
382) -> Result<DraftRange, LineRangeError> {
383    let mut body = spec;
384    let mut anchor = anchor_1;
385    if body.starts_with('^') {
386        anchor = 1;
387        body = &body[1..];
388    }
389    if !body.starts_with(':') {
390        return Err(LineRangeError::InvalidSpec {
391            spec: spec_owned.to_owned(),
392            detail: "expected :symbol".into(),
393        });
394    }
395    let (pattern, rest) =
396        delimited_colon_pattern(&body[1..]).ok_or_else(|| LineRangeError::InvalidSpec {
397            spec: spec_owned.to_owned(),
398            detail: "expected :symbol".into(),
399        })?;
400    if !rest.is_empty() {
401        return Err(LineRangeError::InvalidSpec {
402            spec: spec_owned.to_owned(),
403            detail: format!("unexpected trailing text `{rest}`"),
404        });
405    }
406
407    let re =
408        PosixBre::compile(&pattern).map_err(|detail| LineRangeError::FuncnameInvalidPattern {
409            pattern: pattern.clone(),
410            detail,
411        })?;
412
413    let start_index_0 = anchor.saturating_sub(1);
414    let start_byte = byte_offset_of_line(view.raw, start_index_0.min(line_count.saturating_sub(1)));
415    let header_byte =
416        locate_func_header_after(view, start_byte, &re, matcher).ok_or_else(|| {
417            LineRangeError::FuncnameNoMatch {
418                pattern: pattern.clone(),
419                start_line: anchor,
420            }
421        })?;
422
423    let mut begin_idx = 0usize;
424    while header_byte > byte_offset_of_line(view.raw, begin_idx) && begin_idx + 1 < line_count {
425        begin_idx += 1;
426    }
427    if begin_idx >= line_count {
428        return Err(LineRangeError::FuncnameAtEof { pattern });
429    }
430
431    let mut end_idx = begin_idx + 1;
432    while end_idx < line_count {
433        if is_func_line(view.line_text(end_idx), matcher) {
434            break;
435        }
436        end_idx += 1;
437    }
438
439    Ok(DraftRange {
440        start: begin_idx + 1,
441        end: end_idx,
442    })
443}
444
445fn locate_func_header_after(
446    view: &TextView<'_>,
447    start_byte: usize,
448    re: &PosixBre,
449    matcher: Option<&FuncnameMatcher>,
450) -> Option<usize> {
451    let bytes = view.raw.as_bytes();
452    let mut pos = start_byte;
453    while pos <= bytes.len() {
454        let haystack = &bytes[pos..];
455        let (rel_start, _) = re.find_from(haystack, 0)?;
456        let match_start = pos + rel_start;
457        let line_0 = one_based_line_at_byte(view.raw, match_start).saturating_sub(1);
458        let (bol, _) = view.line_byte_range(line_0);
459        if is_func_line(view.line_text(line_0), matcher) {
460            return Some(bol);
461        }
462        pos = if line_0 + 1 < view.line_count() {
463            byte_offset_of_line(view.raw, line_0 + 1)
464        } else {
465            bytes.len()
466        };
467    }
468    None
469}
470
471fn finalize_inclusive_range(
472    mut draft: DraftRange,
473    line_count: usize,
474    spec_owned: &str,
475) -> Result<RangeInclusive<usize>, LineRangeError> {
476    if draft.start == 0 {
477        draft.start = 1;
478    }
479    if draft.end == 0 || draft.end > line_count {
480        draft.end = line_count;
481    }
482    if draft.start > line_count && line_count > 0 {
483        return Err(LineRangeError::OutOfRange {
484            spec: spec_owned.to_owned(),
485            lines: line_count,
486        });
487    }
488    if line_count == 0 && (draft.start > 0 || draft.end > 0) {
489        return Err(LineRangeError::OutOfRange {
490            spec: spec_owned.to_owned(),
491            lines: 0,
492        });
493    }
494    if draft.start > draft.end {
495        std::mem::swap(&mut draft.start, &mut draft.end);
496    }
497    Ok(draft.start..=draft.end)
498}
499
500fn split_first_comma(spec: &str) -> (&str, Option<&str>) {
501    let mut in_slashes = false;
502    let mut escaped = false;
503    for (idx, c) in spec.char_indices() {
504        if escaped {
505            escaped = false;
506            continue;
507        }
508        if c == '\\' {
509            escaped = true;
510            continue;
511        }
512        if c == '/' {
513            in_slashes = !in_slashes;
514            continue;
515        }
516        if c == ',' && !in_slashes {
517            return (&spec[..idx], Some(&spec[idx + 1..]));
518        }
519    }
520    (spec, None)
521}
522
523fn delimited_slashes(token: &str) -> Option<(String, &str)> {
524    if !token.starts_with('/') {
525        return None;
526    }
527    let mut term = 1usize;
528    let bytes = token.as_bytes();
529    while term < bytes.len() && bytes[term] != b'/' {
530        if bytes[term] == b'\\' && term + 1 < bytes.len() {
531            term += 2;
532        } else {
533            term += 1;
534        }
535    }
536    if term >= bytes.len() {
537        return None;
538    }
539    let pattern = token[1..term].to_owned();
540    Some((pattern, &token[term + 1..]))
541}
542
543fn delimited_colon_pattern(token: &str) -> Option<(String, &str)> {
544    let mut term = 0usize;
545    let bytes = token.as_bytes();
546    while term < bytes.len() && bytes[term] != b':' {
547        if bytes[term] == b'\\' && term + 1 < bytes.len() {
548            term += 2;
549        } else {
550            term += 1;
551        }
552    }
553    if term == 0 {
554        return None;
555    }
556    Some((token[..term].to_owned(), &token[term..]))
557}
558
559fn leading_decimal(token: &str) -> Option<(i64, &str)> {
560    let token = token.trim_start();
561    let mut end = 0usize;
562    for (i, c) in token.char_indices() {
563        if c.is_ascii_digit() {
564            end = i + c.len_utf8();
565        } else {
566            break;
567        }
568    }
569    if end == 0 {
570        return None;
571    }
572    let num: i64 = token[..end].parse().ok()?;
573    Some((num, &token[end..]))
574}
575
576fn byte_offset_of_line(content: &str, line_index_0: usize) -> usize {
577    if line_index_0 == 0 {
578        return 0;
579    }
580    let mut line = 0usize;
581    for (idx, b) in content.bytes().enumerate() {
582        if b == b'\n' {
583            line += 1;
584            if line == line_index_0 {
585                return idx + 1;
586            }
587        }
588    }
589    content.len()
590}
591
592fn one_based_line_at_byte(content: &str, byte: usize) -> usize {
593    content[..byte.min(content.len())]
594        .bytes()
595        .filter(|&b| b == b'\n')
596        .count()
597        + 1
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    #[test]
605    fn resolve_numeric_comma_range() {
606        let content = "a\nb\nc\n";
607        let r =
608            resolve_line_range("2,3", content, "f.txt", &ConfigSet::default(), None, 1).unwrap();
609        assert_eq!(r, 2..=3);
610    }
611
612    #[test]
613    fn resolve_open_end_from_start_line() {
614        let content = "a\nb\nc\n";
615        let r = resolve_line_range("2", content, "f.txt", &ConfigSet::default(), None, 1).unwrap();
616        assert_eq!(r, 2..=3);
617    }
618
619    #[test]
620    fn resolve_relative_plus() {
621        let content = "a\nb\nc\nd\n";
622        let r =
623            resolve_line_range("2,+2", content, "f.txt", &ConfigSet::default(), None, 1).unwrap();
624        assert_eq!(r, 2..=3);
625    }
626
627    #[test]
628    fn resolve_relative_minus() {
629        let content = "a\nb\nc\nd\n";
630        let r =
631            resolve_line_range("4,-2", content, "f.txt", &ConfigSet::default(), None, 1).unwrap();
632        assert_eq!(r, 3..=4);
633    }
634
635    #[test]
636    fn bre_slash_pattern_treats_plus_as_literal() {
637        let content = "aaa\na+\n";
638        let r =
639            resolve_line_range("/a+/", content, "f.txt", &ConfigSet::default(), None, 1).unwrap();
640        assert_eq!(r, 2..=2);
641    }
642
643    #[test]
644    fn bre_slash_pattern_preserves_backslash_before_plus() {
645        let content = "aaa\na+\n";
646        let r =
647            resolve_line_range("/a\\+/", content, "f.txt", &ConfigSet::default(), None, 1).unwrap();
648        assert_eq!(r, 1..=2);
649    }
650
651    #[test]
652    fn regex_searches_from_anchor_through_eof() {
653        let content = "aaa\nbbb\nccc\n";
654        let r =
655            resolve_line_range("/bbb/", content, "f.txt", &ConfigSet::default(), None, 1).unwrap();
656        assert_eq!(r, 2..=3);
657    }
658
659    #[test]
660    fn regex_no_match_is_error() {
661        let err = resolve_line_range("/zzz/", "aaa\n", "f.txt", &ConfigSet::default(), None, 1)
662            .unwrap_err();
663        assert!(matches!(err, LineRangeError::RegexNoMatch { .. }));
664    }
665
666    #[test]
667    fn empty_relative_range_is_error() {
668        let err =
669            resolve_line_range("1,+0", "a\n", "f.txt", &ConfigSet::default(), None, 1).unwrap_err();
670        assert!(matches!(err, LineRangeError::EmptyRange { .. }));
671    }
672
673    #[test]
674    fn invalid_line_number_zero() {
675        let err =
676            resolve_line_range("0,1", "a\n", "f.txt", &ConfigSet::default(), None, 1).unwrap_err();
677        assert!(matches!(err, LineRangeError::InvalidLineNumber { .. }));
678    }
679
680    #[test]
681    fn out_of_range_when_start_past_eof() {
682        let err = resolve_line_range("5,5", "a\nb\n", "f.txt", &ConfigSet::default(), None, 1)
683            .unwrap_err();
684        assert!(matches!(err, LineRangeError::OutOfRange { .. }));
685    }
686}