use super::workspace::{READ_CAP, Workspace, io_problem, not_regular, read_capped};
pub(super) const MAX_LINES: usize = 2000;
pub(super) const MAX_BYTES: usize = 100 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Lines {
pub start: Option<i64>,
pub end: Option<i64>,
}
impl Lines {
#[must_use]
pub fn asked(&self) -> bool {
self.start.is_some() || self.end.is_some()
}
}
pub(super) fn read(workspace: &Workspace, path: &str, lines: Lines) -> Result<String, String> {
let real = workspace.resolve(path)?;
let shown = workspace.relative(&real);
if real.is_dir() {
return Err(format!("`{shown}` is a folder; use list_dir to see what is in it."));
}
let contents =
read_capped(&real).map_err(|error| io_problem(&shown, &error))?.ok_or_else(|| not_regular(&shown))?;
let (whole, size) = (contents.whole(), contents.size);
let mut bytes = contents.bytes;
if !whole && let Some(end) = bytes.iter().rposition(|byte| *byte == b'\n') {
bytes.truncate(end + 1);
}
let text = match String::from_utf8(bytes) {
Ok(text) if !text.contains('\0') => text,
_ => {
return Err(format!("`{shown}` is not a text file ({size} bytes); it cannot be shown."));
}
};
if text.is_empty() {
return Ok(format!("`{shown}` is empty."));
}
let shown_part = if lines.asked() { part(&text, lines) } else { Ok(head(&text)) };
if whole {
return shown_part;
}
let cap = format!(
"(The file is {size} bytes and only its first {} MB are read, so the lines counted here are of that part.)",
READ_CAP / (1024 * 1024)
);
match shown_part {
Ok(text) => Ok(join(&text, &cap)),
Err(sentence) => Err(format!("{sentence} {cap}")),
}
}
fn head(text: &str) -> String {
let mut end = 0;
let mut lines = 0;
for line in text.split_inclusive('\n') {
if lines == MAX_LINES || end + line.len() > MAX_BYTES {
break;
}
end += line.len();
lines += 1;
}
if end == text.len() {
return text.to_owned();
}
let total = text.lines().count();
let note = if lines == 0 {
end = text.floor_char_boundary(MAX_BYTES);
format!(
"(Cut: the file has {total} lines and the first one alone is longer than {} KB; only its start is shown.)",
MAX_BYTES / 1024
)
} else {
format!("(Cut: the file has {total} lines; only the first {lines} are shown.)")
};
join(&text[..end], ¬e)
}
fn part(text: &str, lines: Lines) -> Result<String, String> {
let total = text.lines().count();
let (first, last) = bounds(lines, total)?;
let mut out = String::new();
let mut first_shown = 0;
let mut last_shown = 0;
let mut shown = 0;
let mut cut: Option<String> = None;
let mut offset = 0;
for (index, line) in text.split_inclusive('\n').enumerate() {
let number = index + 1;
if number < first {
offset += line.len();
continue;
}
if number > last {
break;
}
if shown == MAX_LINES {
cut = Some(format!("The {MAX_LINES}-line cap stopped it there."));
break;
}
if out.len() + line.len() > MAX_BYTES {
cut = Some(format!("The {} KB cap stopped it there.", MAX_BYTES / 1024));
break;
}
if first_shown == 0 {
first_shown = number;
}
out.push_str(line);
last_shown = number;
shown += 1;
}
if first_shown == 0 {
let rest = &text[offset..];
out.push_str(&rest[..rest.floor_char_boundary(MAX_BYTES)]);
first_shown = first;
last_shown = first;
cut = Some(format!("That one line is longer than {} KB and only its start is shown.", MAX_BYTES / 1024));
}
let note = note(first_shown, last_shown, total, cut.as_deref());
Ok(join(&out, ¬e))
}
fn bounds(lines: Lines, total: usize) -> Result<(usize, usize), String> {
for (field, number) in [("start_line", lines.start), ("end_line", lines.end)] {
if number.is_some_and(|number| number < 1) {
return Err(format!("Lines are counted from 1, so `{field}` must be 1 or more."));
}
}
let first = usize::try_from(lines.start.unwrap_or(1)).unwrap_or(usize::MAX);
let last = match lines.end {
Some(end) => {
let end = usize::try_from(end).unwrap_or(usize::MAX);
if end < first {
return Err("`end_line` is before `start_line`; give a range that starts before it ends.".to_owned());
}
end.min(total)
}
None => total,
};
if first > total {
return Err(format!("The file has {total} lines, so `start_line` {first} is past its end."));
}
Ok((first, last))
}
fn note(first: usize, last: usize, total: usize, cut: Option<&str>) -> String {
let above = first.saturating_sub(1);
let below = total.saturating_sub(last);
let left = match (above > 0, below > 0) {
(true, true) => format!("{} above and {} below", count(above), count(below)),
(true, false) => format!("{} above", count(above)),
(false, true) => format!("{} below", count(below)),
(false, false) => String::new(),
};
let mut note = if left.is_empty() {
format!("(The whole file: {}.", count(total))
} else {
format!("(Lines {first}-{last} of {total}.")
};
if let Some(cut) = cut {
note.push(' ');
note.push_str(cut);
}
if !left.is_empty() {
note.push_str(&format!(" Not shown: {left}."));
}
note.push(')');
note
}
fn count(lines: usize) -> String {
format!("{lines} line{}", if lines == 1 { "" } else { "s" })
}
fn join(lines: &str, note: &str) -> String {
let mut out = lines.to_owned();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(note);
out
}