use grapha_core::graph::{NodeKind, Span};
pub fn should_extract_snippet(kind: NodeKind) -> bool {
!matches!(
kind,
NodeKind::Field
| NodeKind::Variant
| NodeKind::Property
| NodeKind::Constant
| NodeKind::View
| NodeKind::Branch
)
}
pub struct LineIndex<'a> {
source: &'a str,
line_starts: Vec<usize>,
}
impl<'a> LineIndex<'a> {
pub fn new(source: &'a str) -> Self {
let mut line_starts = vec![0usize];
for (i, b) in source.bytes().enumerate() {
if b == b'\n' && i + 1 < source.len() {
line_starts.push(i + 1);
}
}
Self {
source,
line_starts,
}
}
pub fn extract_snippet(&self, span: &Span, max_len: usize) -> Option<String> {
let start_line = span.start[0];
let end_line = span.end[0];
if start_line >= self.line_starts.len() {
return None;
}
let end_line = end_line.min(self.line_starts.len().saturating_sub(1));
let byte_start = self.line_starts[start_line];
let byte_end = if end_line + 1 < self.line_starts.len() {
self.line_starts[end_line + 1].saturating_sub(1)
} else {
self.source.len()
};
let slice = &self.source[byte_start..byte_end];
let slice = slice.trim_end();
if slice.len() <= max_len {
return Some(slice.to_string());
}
let mut truncate_at = max_len;
while !slice.is_char_boundary(truncate_at) {
truncate_at -= 1;
}
let truncated = &slice[..truncate_at];
match truncated.rfind('\n') {
Some(pos) if pos > 0 => Some(truncated[..pos].to_string()),
_ => Some(truncated.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::LineIndex;
use grapha_core::graph::Span;
#[test]
fn extract_snippet_truncates_single_line_at_utf8_boundary() {
let source = "abc中def";
let index = LineIndex::new(source);
let span = Span {
start: [0, 0],
end: [0, 0],
};
assert_eq!(index.extract_snippet(&span, 4), Some("abc".to_string()));
}
#[test]
fn extract_snippet_truncates_multiline_at_newline_before_utf8_cutoff() {
let source = "alpha\n中文beta";
let index = LineIndex::new(source);
let span = Span {
start: [0, 0],
end: [1, 0],
};
assert_eq!(index.extract_snippet(&span, 8), Some("alpha".to_string()));
}
}