use std::sync::OnceLock;
use crate::error::{OxMf2ErrorCode, SourceTextErrorCode};
use crate::span::{SourceId, Span};
#[derive(Debug, Default, Clone, Copy)]
pub struct SourceFileInput<'a> {
pub source: &'a str,
pub path: Option<&'a str>,
pub locale: Option<&'a str>,
pub message_id: Option<&'a str>,
pub base_offset: Option<u32>,
}
#[derive(Debug, Clone, Default)]
pub struct SourceFile {
pub id: SourceId,
pub path: Option<String>,
pub locale: Option<String>,
pub message_id: Option<String>,
pub base_offset: u32,
pub text: String,
pub(crate) line_starts: OnceLock<Vec<u32>>,
}
impl SourceFile {
#[inline]
pub fn len(&self) -> u32 {
self.text.len() as u32
}
#[inline]
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn line_starts(&self) -> &[u32] {
self.line_starts
.get_or_init(|| compute_line_starts(&self.text))
.as_slice()
}
fn line_index_for_offset(&self, offset: u32) -> u32 {
let len = self.len();
let offset = offset.min(len);
let line_starts = self.line_starts();
let index = line_starts.partition_point(|&start| start <= offset);
index.saturating_sub(1) as u32
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SourceLocation {
pub line: u32,
pub column: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceStoreError {
SourceTooLarge,
}
impl core::fmt::Display for SourceStoreError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::SourceTooLarge => f.write_str("source length exceeds u32::MAX byte offsets"),
}
}
}
impl std::error::Error for SourceStoreError {}
impl SourceStoreError {
#[inline]
pub const fn code(self) -> SourceTextErrorCode {
match self {
Self::SourceTooLarge => SourceTextErrorCode::SourceTextTooLarge,
}
}
#[inline]
pub const fn as_ox_mf2_error_code(self) -> OxMf2ErrorCode {
self.code().as_ox_mf2_error_code()
}
}
#[derive(Debug, Default, Clone)]
pub struct SourceStore {
files: Vec<SourceFile>,
}
impl SourceStore {
pub fn new() -> Self {
Self::default()
}
pub fn with_capacity(cap: usize) -> Self {
Self {
files: Vec::with_capacity(cap),
}
}
pub fn len(&self) -> usize {
self.files.len()
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
pub fn files(&self) -> &[SourceFile] {
&self.files
}
pub fn add(&mut self, input: SourceFileInput<'_>) -> SourceId {
self.try_add(input).expect("source length fits in u32")
}
pub fn try_add(&mut self, input: SourceFileInput<'_>) -> Result<SourceId, SourceStoreError> {
if input.source.len() > u32::MAX as usize {
return Err(SourceStoreError::SourceTooLarge);
}
let id = SourceId::new(self.files.len() as u32);
self.files.push(SourceFile {
id,
path: input.path.map(str::to_owned),
locale: input.locale.map(str::to_owned),
message_id: input.message_id.map(str::to_owned),
base_offset: input.base_offset.unwrap_or(0),
text: input.source.to_owned(),
line_starts: OnceLock::new(),
});
Ok(id)
}
pub fn get(&self, id: SourceId) -> Option<&SourceFile> {
if id.is_none() {
return None;
}
self.files.get(id.index())
}
pub fn slice(&self, span: Span) -> &str {
let Some(file) = self.files.first() else {
return "";
};
Self::slice_str(&file.text, span)
}
pub fn slice_in(&self, source: SourceId, span: Span) -> &str {
let Some(file) = self.get(source) else {
return "";
};
Self::slice_str(&file.text, span)
}
fn slice_str(text: &str, span: Span) -> &str {
let len = text.len() as u32;
let start = span.start.min(len) as usize;
let end = span.end.min(len) as usize;
if end < start {
return "";
}
let Some(slice) = text.get(start..end) else {
return "";
};
slice
}
pub fn location(&self, source: SourceId, span: Span) -> SourceLocation {
let Some(file) = self.get(source) else {
return SourceLocation::default();
};
let line0 = file.line_index_for_offset(span.start);
let line_start = file.line_starts().get(line0 as usize).copied().unwrap_or(0);
let column0 = span.start.saturating_sub(line_start);
SourceLocation {
line: line0 + 1,
column: column0 + 1,
}
}
}
fn compute_line_starts(text: &str) -> Vec<u32> {
let mut starts = Vec::with_capacity(text.len() / 32 + 1);
starts.push(0);
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'\n' {
starts.push((i + 1) as u32);
i += 1;
} else if b == b'\r' {
let next_index = i + 1;
let skip = if bytes.get(next_index) == Some(&b'\n') {
2
} else {
1
};
starts.push((i + skip) as u32);
i += skip;
} else {
i += 1;
}
}
starts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_id_zero_is_valid() {
let mut store = SourceStore::new();
let id = store.add(SourceFileInput {
source: "hello",
..Default::default()
});
assert_eq!(id, SourceId::new(0));
assert_eq!(store.get(id).unwrap().text, "hello");
}
#[test]
fn span_keeps_utf8_byte_offsets() {
let text = "あいう"; let mut store = SourceStore::new();
let id = store.add(SourceFileInput {
source: text,
..Default::default()
});
let span = Span::new(3, 6);
assert_eq!(store.slice_in(id, span), "い");
}
#[test]
fn line_column_conversion_handles_lf_cr_crlf() {
let text = "ab\ncd\r\nef\rgh";
let mut store = SourceStore::new();
let id = store.add(SourceFileInput {
source: text,
..Default::default()
});
assert_eq!(
store.location(id, Span::at(0)),
SourceLocation { line: 1, column: 1 }
);
assert_eq!(
store.location(id, Span::at(1)),
SourceLocation { line: 1, column: 2 }
);
assert_eq!(
store.location(id, Span::at(3)),
SourceLocation { line: 2, column: 1 }
);
assert_eq!(
store.location(id, Span::at(7)),
SourceLocation { line: 3, column: 1 }
);
assert_eq!(
store.location(id, Span::at(10)),
SourceLocation { line: 4, column: 1 }
);
}
#[test]
fn out_of_range_source_id_returns_none() {
let store = SourceStore::new();
assert!(store.get(SourceId::new(5)).is_none());
assert!(store.get(SourceId::NONE).is_none());
}
#[test]
fn line_starts_are_lazy_until_location_is_called() {
let mut store = SourceStore::new();
let id = store.add(SourceFileInput {
source: "a\nb\nc\nd",
..Default::default()
});
let file = store.get(id).unwrap();
assert!(
file.line_starts.get().is_none(),
"line_starts should stay unevaluated until first read"
);
let _ = store.location(id, Span::at(3));
let file = store.get(id).unwrap();
assert!(
file.line_starts.get().is_some(),
"line_starts should be cached after first location()"
);
let starts = file.line_starts();
assert_eq!(starts, &[0, 2, 4, 6]);
}
#[test]
fn slice_handles_clamping() {
let mut store = SourceStore::new();
let id = store.add(SourceFileInput {
source: "abc",
..Default::default()
});
assert_eq!(store.slice_in(id, Span::new(0, 3)), "abc");
assert_eq!(store.slice_in(id, Span::new(0, 99)), "abc");
assert_eq!(store.slice_in(id, Span::new(99, 100)), "");
}
}