use serde_json::{Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LineSelector {
Single(usize),
Range { start: usize, end: usize },
FromStart { end: usize },
FromLine { start: usize },
Count { start: usize, count: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct ReadSelector {
pub(crate) raw: bool,
pub(crate) lines: Option<LineSelector>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SelectorSplit<'a> {
pub(crate) target: &'a str,
pub(crate) selector: ReadSelector,
}
impl ReadSelector {
pub(crate) fn is_empty(self) -> bool {
!self.raw && self.lines.is_none()
}
pub(crate) fn apply(self, text: &str) -> (String, Option<usize>, bool) {
let Some(lines) = self.lines else {
let total = count_lines(text);
return (text.to_string(), Some(total), false);
};
let all = split_lines_lossless(text);
let total = all.len();
let (start, end) = match lines {
LineSelector::Single(line) => (line.saturating_sub(1), line),
LineSelector::Range { start, end } => (start.saturating_sub(1), end),
LineSelector::FromStart { end } => (0, end),
LineSelector::FromLine { start } => (start.saturating_sub(1), total),
LineSelector::Count { start, count } => {
let zero = start.saturating_sub(1);
(zero, zero.saturating_add(count))
}
};
let end = end.min(total);
let selected = if start >= total || start >= end {
String::new()
} else {
all[start..end].join("\n")
};
(selected, Some(total), end < total)
}
pub(crate) fn metadata(self) -> Value {
if self.is_empty() {
return Value::Null;
}
json!({
"raw": self.raw,
"lines": self.lines.map(|lines| match lines {
LineSelector::Single(line) => json!({"type":"single", "line": line}),
LineSelector::Range { start, end } => json!({"type":"range", "start": start, "end": end}),
LineSelector::FromStart { end } => json!({"type":"from_start", "end": end}),
LineSelector::FromLine { start } => json!({"type":"from_line", "start": start}),
LineSelector::Count { start, count } => json!({"type":"count", "start": start, "count": count}),
})
})
}
}
pub(crate) fn split_selector_suffix(input: &str) -> anyhow::Result<SelectorSplit<'_>> {
let mut selector = ReadSelector::default();
let mut target = input;
while let Some((prefix, token)) = target.rsplit_once(':') {
if prefix.is_empty() {
break;
}
if token == "raw" {
selector.raw = true;
target = prefix;
continue;
}
if selector.lines.is_none()
&& let Some(lines) = parse_line_selector(token)?
{
selector.lines = Some(lines);
target = prefix;
continue;
}
break;
}
Ok(SelectorSplit { target, selector })
}
fn parse_line_selector(token: &str) -> anyhow::Result<Option<LineSelector>> {
if token.is_empty() {
return Ok(None);
}
if let Some(end) = token.strip_prefix('-') {
let end = parse_positive(end)?;
return Ok(Some(LineSelector::FromStart { end }));
}
if let Some((start, rest)) = token.split_once('+') {
if !is_digits(start) || (!rest.is_empty() && !is_digits(rest)) {
return Ok(None);
}
let start = parse_positive(start)?;
if rest.is_empty() {
return Ok(Some(LineSelector::FromLine { start }));
}
let count = parse_positive(rest)?;
return Ok(Some(LineSelector::Count { start, count }));
}
if let Some((start, end)) = token.split_once('-') {
if !is_digits(start) || !is_digits(end) {
return Ok(None);
}
let start = parse_positive(start)?;
let end = parse_positive(end)?;
if end < start {
anyhow::bail!("read selector range end must be greater than or equal to start");
}
return Ok(Some(LineSelector::Range { start, end }));
}
if is_digits(token) {
return Ok(Some(LineSelector::Single(parse_positive(token)?)));
}
Ok(None)
}
fn is_digits(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
}
fn parse_positive(value: &str) -> anyhow::Result<usize> {
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
anyhow::bail!("invalid read selector line number '{value}'");
}
let parsed = value.parse::<usize>()?;
if parsed == 0 {
anyhow::bail!("read selector line numbers are 1-based");
}
Ok(parsed)
}
fn split_lines_lossless(text: &str) -> Vec<String> {
if text.is_empty() {
return Vec::new();
}
text.split_inclusive('\n')
.map(|line| {
let line = line.strip_suffix('\n').unwrap_or(line);
line.strip_suffix('\r').unwrap_or(line).to_string()
})
.collect()
}
fn count_lines(text: &str) -> usize {
split_lines_lossless(text).len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_selector_parses_variants() {
assert_eq!(
split_selector_suffix("file:2").unwrap().selector.lines,
Some(LineSelector::Single(2))
);
assert_eq!(
split_selector_suffix("file:2-4").unwrap().selector.lines,
Some(LineSelector::Range { start: 2, end: 4 })
);
assert_eq!(
split_selector_suffix("file:-3").unwrap().selector.lines,
Some(LineSelector::FromStart { end: 3 })
);
assert_eq!(
split_selector_suffix("file:3+").unwrap().selector.lines,
Some(LineSelector::FromLine { start: 3 })
);
assert_eq!(
split_selector_suffix("file:3+2").unwrap().selector.lines,
Some(LineSelector::Count { start: 3, count: 2 })
);
let parsed = split_selector_suffix("file:raw:2-3").unwrap();
assert!(parsed.selector.raw);
assert_eq!(
parsed.selector.lines,
Some(LineSelector::Range { start: 2, end: 3 })
);
}
#[test]
fn read_selector_applies_lines() {
let text = "one\ntwo\nthree\nfour\n";
assert_eq!(
ReadSelector {
raw: false,
lines: Some(LineSelector::Single(2))
}
.apply(text)
.0,
"two"
);
assert_eq!(
ReadSelector {
raw: false,
lines: Some(LineSelector::Range { start: 2, end: 3 })
}
.apply(text)
.0,
"two\nthree"
);
assert_eq!(
ReadSelector {
raw: false,
lines: Some(LineSelector::FromStart { end: 2 })
}
.apply(text)
.0,
"one\ntwo"
);
assert_eq!(
ReadSelector {
raw: false,
lines: Some(LineSelector::FromLine { start: 3 })
}
.apply(text)
.0,
"three\nfour"
);
assert_eq!(
ReadSelector {
raw: false,
lines: Some(LineSelector::Count { start: 3, count: 1 })
}
.apply(text)
.0,
"three"
);
}
}