1use std::ops::RangeInclusive;
14
15use crate::config::ConfigSet;
16use crate::diff::{is_func_line, PosixBre};
17use crate::userdiff::FuncnameMatcher;
18use thiserror::Error;
19
20#[derive(Debug, Error, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum LineRangeError {
24 #[error("-L line range `{spec}`: {detail}")]
26 InvalidSpec {
27 spec: String,
29 detail: String,
31 },
32 #[error("-L line range `{spec}`: invalid line number {line}")]
34 InvalidLineNumber {
35 spec: String,
37 line: i64,
39 },
40 #[error("-L line range `{spec}`: empty line range")]
42 EmptyRange {
43 spec: String,
45 },
46 #[error("-L parameter `{pattern}`: {detail}")]
48 InvalidRegex {
49 pattern: String,
51 detail: String,
53 },
54 #[error("-L parameter `{pattern}` starting at line {start_line}: no match")]
56 RegexNoMatch {
57 pattern: String,
59 start_line: usize,
61 },
62 #[error("-L parameter `{pattern}` starting at line {start_line}: no match")]
64 FuncnameNoMatch {
65 pattern: String,
67 start_line: usize,
69 },
70 #[error("-L parameter `{pattern}`: {detail}")]
72 FuncnameInvalidPattern {
73 pattern: String,
75 detail: String,
77 },
78 #[error("-L parameter `{pattern}` matches at EOF")]
80 FuncnameAtEof {
81 pattern: String,
83 },
84 #[error("-L line range `{spec}`: file has only {lines} line(s)")]
86 OutOfRange {
87 spec: String,
89 lines: usize,
91 },
92}
93
94#[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
107pub 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
144struct 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}