use ls_types::{Position, Range};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LineIndex {
line_starts: Vec<usize>,
all_ascii: bool,
}
impl Default for LineIndex {
fn default() -> Self {
Self::new("")
}
}
impl LineIndex {
pub fn new(source: &str) -> Self {
let mut line_starts = Vec::with_capacity(source.len() / 40 + 1);
line_starts.push(0);
for (offset, byte) in source.bytes().enumerate() {
if byte == b'\n' {
line_starts.push(offset + 1);
}
}
Self {
line_starts,
all_ascii: source.is_ascii(),
}
}
pub fn line_count(&self) -> usize {
self.line_starts.len()
}
fn line_at(&self, offset: usize) -> (usize, usize) {
let line = match self.line_starts.binary_search(&offset) {
Ok(exact) => exact,
Err(next) => next - 1,
};
(line, self.line_starts[line])
}
pub fn position(&self, source: &str, offset: usize) -> Position {
let offset = offset.min(source.len());
let (line, line_start) = self.line_at(offset);
let character = if self.all_ascii {
(offset - line_start) as u32
} else {
let mut end = offset;
while end < source.len() && !source.is_char_boundary(end) {
end += 1;
}
source[line_start..end]
.chars()
.map(|ch| ch.len_utf16() as u32)
.sum()
};
Position::new(line as u32, character)
}
pub fn offset(&self, source: &str, position: Position) -> usize {
let Some(&line_start) = self.line_starts.get(position.line as usize) else {
return source.len();
};
let line_end = self
.line_starts
.get(position.line as usize + 1)
.map(|next| next.saturating_sub(1))
.unwrap_or(source.len());
if self.all_ascii {
return (line_start + position.character as usize).min(line_end);
}
let mut utf16 = 0u32;
for (offset, ch) in source[line_start..line_end].char_indices() {
if utf16 >= position.character {
return line_start + offset;
}
let next = utf16 + ch.len_utf16() as u32;
if next > position.character {
return line_start + offset;
}
utf16 = next;
}
line_end
}
pub fn line_text<'a>(&self, source: &'a str, line: usize) -> Option<&'a str> {
let start = *self.line_starts.get(line)?;
let end = self
.line_starts
.get(line + 1)
.map(|next| next.saturating_sub(1))
.unwrap_or(source.len());
let line = source.get(start..end)?;
Some(line.strip_suffix('\r').unwrap_or(line))
}
pub fn range(&self, source: &str, start: usize, end: usize) -> Range {
Range {
start: self.position(source, start),
end: self.position(source, end),
}
}
}
pub fn compact_preview(text: &str) -> String {
let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() <= 80 {
collapsed
} else {
let preview = collapsed.chars().take(77).collect::<String>();
format!("{preview}...")
}
}
pub fn offset_to_position(source: &str, offset: usize) -> Position {
let target = offset.min(source.len());
let mut line = 0u32;
let mut character = 0u32;
for (byte, ch) in source.char_indices() {
if byte >= target {
break;
}
if ch == '\n' {
line += 1;
character = 0;
} else {
character += ch.len_utf16() as u32;
}
}
Position::new(line, character)
}
pub fn position_to_offset(source: &str, position: Position) -> usize {
let mut line = 0u32;
let mut character = 0u32;
for (byte, ch) in source.char_indices() {
if line == position.line && character >= position.character {
return byte;
}
if ch == '\n' {
if line == position.line {
return byte;
}
line += 1;
character = 0;
} else if line == position.line {
let next = character + ch.len_utf16() as u32;
if next > position.character {
return byte;
}
character = next;
}
}
source.len()
}
pub fn byte_range_to_lsp(source: &str, start: usize, end: usize) -> Range {
Range {
start: offset_to_position(source, start),
end: offset_to_position(source, end),
}
}
fn floor_boundary(source: &str, mut offset: usize) -> usize {
while offset > 0 && !source.is_char_boundary(offset) {
offset -= 1;
}
offset
}
fn ceil_boundary(source: &str, mut offset: usize) -> usize {
while offset < source.len() && !source.is_char_boundary(offset) {
offset += 1;
}
offset
}
pub fn preceding_char(source: &str, offset: usize) -> Option<(usize, char)> {
if offset == 0 || offset > source.len() {
return None;
}
let start = floor_boundary(source, offset - 1);
source[start..].chars().next().map(|ch| (start, ch))
}
fn char_before(source: &str, offset: usize) -> Option<(usize, char)> {
let start = if offset == 0 {
0
} else {
floor_boundary(source, offset - 1)
};
source[start..].chars().next().map(|ch| (start, ch))
}
fn token_bounds(source: &str, lines: &LineIndex, position: Position) -> Option<(usize, usize)> {
if source.is_empty() {
return None;
}
let offset = lines.offset(source, position);
let (cursor_start, cursor_char) = char_before(source, offset)
.filter(|(_, ch)| is_token_char(*ch))
.or_else(|| {
let ch = source.get(offset..)?.chars().next()?;
is_token_char(ch).then_some((offset, ch))
})?;
let mut start = cursor_start;
while start > 0 {
let previous = floor_boundary(source, start - 1);
match source[previous..].chars().next() {
Some(ch) if is_token_char(ch) => start = previous,
_ => break,
}
}
let mut end = cursor_start + cursor_char.len_utf8();
for ch in source[end..].chars() {
if !is_token_char(ch) {
break;
}
end += ch.len_utf8();
}
narrow_to_hop(source, start, end, cursor_start)
}
const HOP_ARROWS: [&str; 4] = ["<->", "->", "<-", "<~"];
fn holds_arrow(span: &str) -> bool {
HOP_ARROWS.iter().any(|arrow| span.contains(arrow))
}
fn narrow_to_hop(source: &str, start: usize, end: usize, cursor: usize) -> Option<(usize, usize)> {
let scan_end = ceil_boundary(source, end.saturating_add(2).min(source.len()));
if !holds_arrow(&source[start..scan_end]) {
return Some((start, end));
}
let mut segment_start = start;
let mut index = start;
while index < end {
let rest = &source[index..scan_end];
let Some(arrow) = HOP_ARROWS.iter().find(|arrow| rest.starts_with(**arrow)) else {
index += rest.chars().next().map_or(1, char::len_utf8);
continue;
};
if cursor < index {
return (segment_start < index).then_some((segment_start, index));
}
if cursor < index + arrow.len() {
return None;
}
index += arrow.len();
segment_start = index;
}
(segment_start < end).then_some((segment_start, end))
}
pub fn is_token_char(ch: char) -> bool {
ch.is_alphanumeric() || matches!(ch, '_' | ':' | '$' | '<' | '>' | '-')
}
pub fn token_prefix(source: &str, lines: &LineIndex, position: Position) -> Option<String> {
if source.is_empty() {
return Some(String::new());
}
let offset = lines.offset(source, position);
let end = ceil_boundary(source, offset);
if end == 0 {
return Some(String::new());
}
let previous_start = floor_boundary(source, end - 1);
let Some(previous) = source[previous_start..].chars().next() else {
return Some(String::new());
};
if !is_token_char(previous) {
return Some(String::new());
}
let mut start = previous_start;
while start > 0 {
let candidate = floor_boundary(source, start - 1);
match source[candidate..].chars().next() {
Some(ch) if is_token_char(ch) => start = candidate,
_ => break,
}
}
let prefix = source.get(start..end)?;
Some(after_last_arrow(prefix).to_owned())
}
pub fn after_last_arrow(prefix: &str) -> &str {
HOP_ARROWS
.iter()
.filter_map(|arrow| prefix.rfind(arrow).map(|at| at + arrow.len()))
.max()
.map_or(prefix, |cut| &prefix[cut..])
}
pub fn token_at(source: &str, lines: &LineIndex, position: Position) -> Option<String> {
let (start, end) = token_bounds(source, lines, position)?;
source.get(start..end).map(ToOwned::to_owned)
}
pub fn word_range(source: &str, lines: &LineIndex, position: Position) -> Option<Range> {
let (start, end) = token_bounds(source, lines, position)?;
Some(lines.range(source, start, end))
}
pub fn dotted_path_at(source: &str, lines: &LineIndex, position: Position) -> Option<String> {
let (mut start, mut end) = token_bounds(source, lines, position)?;
while source[..start].ends_with('.') {
let dot = start - 1;
let mut name = dot;
while let Some((at, ch)) = preceding_char(source, name) {
if !is_token_char(ch) {
break;
}
name = at;
}
if name == dot {
break;
}
start = name;
}
while source[end..].starts_with('.') {
let after = end + 1;
let mut name = after;
for ch in source[after..].chars() {
if !is_token_char(ch) {
break;
}
name += ch.len_utf8();
}
if name == after {
break;
}
end = name;
}
source.get(start..end).map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::{LineIndex, compact_preview, offset_to_position, position_to_offset};
use ls_types::Position;
fn corpus() -> Vec<(&'static str, &'static str)> {
vec![
("empty", ""),
("no newline at all", "SELECT * FROM person"),
("trailing newline", "SELECT 1;\n"),
("no trailing newline", "SELECT 1;\nSELECT 2;"),
("blank first line", "\nSELECT 1;"),
("consecutive newlines", "SELECT 1;\n\n\nSELECT 2;"),
("only newlines", "\n\n\n"),
("carriage returns", "SELECT 1;\r\nSELECT 2;\r\n"),
("three-byte char", "SET sym = '₹';\nSELECT sym;"),
("four-byte char", "SET emoji = '🚀';\nSELECT emoji;"),
("four-byte char at line start", "🚀 = 1;\nSELECT 2;"),
("many four-byte chars", "'🚀🚀🚀';\n'🚀';\n"),
("mixed widths", "a₹b🚀c;\nd;\n"),
("non-ascii on the last line", "SELECT 1;\n'🚀'"),
]
}
#[test]
fn line_index_position_matches_the_scan_at_every_offset() {
for (name, source) in corpus() {
let index = LineIndex::new(source);
for offset in 0..=source.len() + 2 {
assert_eq!(
index.position(source, offset),
offset_to_position(source, offset),
"{name}: position mismatch at offset {offset} of {source:?}"
);
}
}
}
#[test]
fn line_index_offset_matches_the_scan_at_every_position() {
for (name, source) in corpus() {
let index = LineIndex::new(source);
for line in 0..index.line_count() as u32 + 2 {
for character in 0..20u32 {
let position = Position::new(line, character);
assert_eq!(
index.offset(source, position),
position_to_offset(source, position),
"{name}: offset mismatch at {position:?} of {source:?}"
);
}
}
}
}
#[test]
fn position_and_offset_round_trip() {
for (name, source) in corpus() {
let index = LineIndex::new(source);
for offset in 0..=source.len() {
if !source.is_char_boundary(offset) {
continue;
}
let position = index.position(source, offset);
assert_eq!(
index.offset(source, position),
offset,
"{name}: round trip broke at offset {offset} of {source:?}"
);
}
}
}
#[test]
fn line_index_range_matches_the_scan() {
for (name, source) in corpus() {
let index = LineIndex::new(source);
for start in 0..=source.len() {
for end in start..=source.len() {
assert_eq!(
index.range(source, start, end),
super::byte_range_to_lsp(source, start, end),
"{name}: range mismatch for {start}..{end} of {source:?}"
);
}
}
}
}
#[test]
fn every_document_has_at_least_one_line() {
assert_eq!(LineIndex::new("").line_count(), 1);
assert_eq!(LineIndex::new("no newline").line_count(), 1);
assert_eq!(LineIndex::new("one\n").line_count(), 2);
}
#[test]
fn the_ascii_fast_path_agrees_with_the_counted_path() {
let ascii = "SELECT name FROM person;\nSELECT 2;\n";
let fast = LineIndex::new(ascii);
assert!(fast.all_ascii, "corpus entry must exercise the fast path");
let counted = LineIndex {
line_starts: fast.line_starts.clone(),
all_ascii: false,
};
for offset in 0..=ascii.len() {
assert_eq!(
fast.position(ascii, offset),
counted.position(ascii, offset),
"fast and counted paths disagree at offset {offset}"
);
}
for line in 0..3u32 {
for character in 0..30u32 {
let position = Position::new(line, character);
assert_eq!(
fast.offset(ascii, position),
counted.offset(ascii, position),
"fast and counted paths disagree at {position:?}"
);
}
}
}
fn token_prefix_scanning(source: &str, position: Position) -> Option<String> {
let offset = position_to_offset(source, position);
if source.is_empty() {
return Some(String::new());
}
let chars: Vec<(usize, char)> = source.char_indices().collect();
let cursor_index = chars.partition_point(|(byte, _)| *byte < offset);
let Some((_, prev_char)) = cursor_index
.checked_sub(1)
.and_then(|index| chars.get(index))
else {
return Some(String::new());
};
if !super::is_token_char(*prev_char) {
return Some(String::new());
}
let mut start = cursor_index - 1;
while start > 0 && super::is_token_char(chars[start - 1].1) {
start -= 1;
}
let start_byte = chars[start].0;
let end_byte = chars
.get(cursor_index)
.map(|(byte, _)| *byte)
.unwrap_or(source.len());
source
.get(start_byte..end_byte)
.map(|prefix| super::after_last_arrow(prefix).to_owned())
}
fn token_bounds_scanning(source: &str, position: Position) -> Option<(usize, usize)> {
let offset = position_to_offset(source, position);
let chars: Vec<(usize, char)> = source.char_indices().collect();
if chars.is_empty() {
return None;
}
let before = chars
.partition_point(|(byte, _)| *byte < offset)
.saturating_sub(1);
let index = match chars.get(before) {
Some((_, ch)) if super::is_token_char(*ch) => before,
_ => match chars.get(before + 1) {
Some((byte, ch)) if super::is_token_char(*ch) && *byte == offset => before + 1,
_ => return None,
},
};
let current = chars.get(index)?;
let mut start = index;
while start > 0 && super::is_token_char(chars[start - 1].1) {
start -= 1;
}
let mut end = index + 1;
while end < chars.len() && super::is_token_char(chars[end].1) {
end += 1;
}
let start_byte = chars[start].0;
let end_byte = chars
.get(end)
.map(|(byte, _)| *byte)
.unwrap_or(source.len());
super::narrow_to_hop(source, start_byte, end_byte, current.0)
}
fn probe_positions(source: &str) -> Vec<Position> {
let lines = source.split('\n').count().max(1);
let widest = source
.split('\n')
.map(|l| l.chars().count())
.max()
.unwrap_or(0);
let mut out = Vec::new();
for line in 0..lines as u32 + 1 {
for character in 0..widest as u32 + 3 {
out.push(Position::new(line, character));
}
}
out
}
fn cursor_corpus() -> Vec<&'static str> {
vec![
"",
"SELECT",
"SELECT * FROM person",
"SELECT name FROM person WHERE age > 21;",
"fn::slugify($text)",
"$param",
"table:id",
"a-b_c<d>e",
" leading space",
"trailing space ",
";;;",
"SELECT 1;\nSELECT 2;",
"SET sym = '₹';\nSELECT sym;",
"SET emoji = '🚀';",
"naïve_name",
"🚀token",
"token🚀",
"\n\n\n",
"SELECT ->knows->person AS friends FROM person",
"SELECT <-knows<-person FROM person",
"SELECT <->knows<->person FROM person",
"SELECT <~knows<~person FROM person",
"person:alice->knows->person",
"->naïve_edge->🚀table",
"my-table->knows",
"->",
"a<->b",
]
}
#[test]
fn token_at_matches_the_scanning_version() {
for source in cursor_corpus() {
let index = LineIndex::new(source);
for position in probe_positions(source) {
let expected = token_bounds_scanning(source, position)
.and_then(|(s, e)| source.get(s..e).map(ToOwned::to_owned));
assert_eq!(
super::token_at(source, &index, position),
expected,
"token_at differs at {position:?} of {source:?}"
);
}
}
}
#[test]
fn word_range_matches_the_scanning_version() {
for source in cursor_corpus() {
let index = LineIndex::new(source);
for position in probe_positions(source) {
let expected = token_bounds_scanning(source, position)
.map(|(s, e)| super::byte_range_to_lsp(source, s, e));
assert_eq!(
super::word_range(source, &index, position),
expected,
"word_range differs at {position:?} of {source:?}"
);
}
}
}
fn token_after(source: &str, needle: &str) -> Option<String> {
let at = source.find(needle).expect("needle in source") + needle.len();
let index = LineIndex::new(source);
super::token_at(source, &index, index.position(source, at))
}
#[test]
fn a_traversal_resolves_one_hop_at_a_time() {
let source = "SELECT ->is_friends_with->person AS friends FROM person";
assert_eq!(
token_after(source, "is_friends_with"),
Some("is_friends_with".to_string()),
"the edge name must resolve alone, not as the whole traversal"
);
assert_eq!(
token_after(source, "->is_friends_with->person"),
Some("person".to_string()),
"the far table must resolve alone too"
);
}
#[test]
fn every_arrow_spelling_splits_a_hop() {
for arrow in ["->", "<-", "<->", "<~"] {
let source = format!("a{arrow}knows");
assert_eq!(
token_after(&source, "knows"),
Some("knows".to_string()),
"`{arrow}` must end the previous hop"
);
assert_eq!(
token_after(&source, "a"),
Some("a".to_string()),
"`{arrow}` must end the hop before it, in {source:?}"
);
}
}
#[test]
fn a_bidirectional_arrow_is_one_arrow() {
assert_eq!(token_after("a<->b", "a"), Some("a".to_string()));
assert_eq!(token_after("a<->b", "<->"), None, "the arrow names nothing");
assert_eq!(token_after("a<->b", "<->b"), Some("b".to_string()));
}
#[test]
fn a_name_holding_an_arrow_character_stays_whole() {
assert_eq!(
token_after("my-table", "my-table"),
Some("my-table".to_string()),
"a hyphen is part of the name"
);
assert_eq!(
token_after("LET $x: record<person> = 1", "record<person>"),
Some("record<person>".to_string()),
"a record type is one token"
);
assert_eq!(
token_after("my-table->knows", "my-table"),
Some("my-table".to_string()),
"a hyphenated name beside an arrow keeps its hyphen"
);
}
#[test]
fn the_completion_prefix_restarts_after_an_arrow() {
for (prefix, expected) in [
("person->", ""),
("person->kno", "kno"),
("->knows->per", "per"),
("a<->b", "b"),
("a<~b", "b"),
("my-table", "my-table"),
("record<person", "record<person"),
] {
assert_eq!(
super::after_last_arrow(prefix),
expected,
"prefix {prefix:?}"
);
}
}
#[test]
fn a_cursor_after_an_arrow_has_an_empty_prefix() {
let source = "SELECT * FROM person->";
let index = LineIndex::new(source);
assert_eq!(
super::token_prefix(source, &index, index.position(source, source.len())),
Some(String::new())
);
}
#[test]
fn token_prefix_matches_the_scanning_version() {
for source in cursor_corpus() {
let index = LineIndex::new(source);
for position in probe_positions(source) {
assert_eq!(
super::token_prefix(source, &index, position),
token_prefix_scanning(source, position),
"token_prefix differs at {position:?} of {source:?}"
);
}
}
}
#[test]
fn compact_preview_preserves_unicode_boundaries() {
let text = "UPSERT currency:inr SET name = 'Indian Rupee', iso_code = 'INR', symbol = '₹', subunits = 2";
let preview = compact_preview(text);
assert!(preview.ends_with("..."));
assert!(preview.contains('₹'));
assert!(preview.is_char_boundary(preview.len()));
}
}