use ls_types::Position;
use crate::core::statement_shape::{SlotYield, head_slot};
use crate::semantic::text::{LineIndex, preceding_char};
use crate::semantic::types::{DocumentAnalysis, LookupDirection, QueryFact};
const LITERAL: &str = "\u{1}literal";
pub fn statement_words(source: &str, lines: &LineIndex, position: Position) -> Option<Vec<String>> {
let offset = lines.offset(source, position);
let before = source.get(..offset)?;
let mut words: Vec<String> = Vec::new();
let mut current = String::new();
let mut marks: Vec<usize> = Vec::new();
let mut chars = before.chars().peekable();
while let Some(ch) = chars.next() {
if matches!(ch, '\'' | '"') {
flush(&mut current, &mut words);
if !skip_string(&mut chars, ch) {
return None;
}
words.push(LITERAL.to_string());
continue;
}
if is_line_comment_start(ch, chars.peek().copied()) {
flush(&mut current, &mut words);
if !skip_line_comment(&mut chars) {
return None;
}
continue;
}
if ch == '/' && chars.peek() == Some(&'*') {
flush(&mut current, &mut words);
if !skip_block_comment(&mut chars) {
return None;
}
continue;
}
if matches!(ch, '(' | '[' | '{') {
flush(&mut current, &mut words);
marks.push(words.len());
continue;
}
if matches!(ch, ')' | ']' | '}') {
flush(&mut current, &mut words);
if let Some(mark) = marks.pop() {
words.truncate(mark);
}
words.push(LITERAL.to_string());
continue;
}
if ch == ';' && marks.is_empty() {
words.clear();
current.clear();
continue;
}
if is_word_char(ch) {
current.push(ch);
continue;
}
flush(&mut current, &mut words);
}
if !marks.is_empty() {
return None;
}
Some(words)
}
pub fn head_slot_at(source: &str, lines: &LineIndex, position: Position) -> SlotYield {
match statement_words(source, lines, position) {
Some(words) => {
let borrowed: Vec<&str> = words.iter().map(String::as_str).collect();
head_slot(&borrowed)
}
None => SlotYield::Expression,
}
}
fn flush(current: &mut String, words: &mut Vec<String>) {
if !current.is_empty() {
words.push(std::mem::take(current));
}
}
fn is_word_char(ch: char) -> bool {
ch.is_alphanumeric() || matches!(ch, '_' | ':' | '$' | '.' | '*' | '`' | '-')
}
fn is_line_comment_start(ch: char, next: Option<char>) -> bool {
ch == '#' || (ch == '-' && next == Some('-')) || (ch == '/' && next == Some('/'))
}
fn skip_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, quote: char) -> bool {
while let Some(ch) = chars.next() {
if ch == '\\' {
chars.next();
continue;
}
if ch == quote {
return true;
}
}
false
}
fn skip_line_comment(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
for ch in chars.by_ref() {
if ch == '\n' {
return true;
}
}
false
}
fn skip_block_comment(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
let mut previous = ' ';
for ch in chars.by_ref() {
if previous == '*' && ch == '/' {
return true;
}
previous = ch;
}
false
}
pub fn is_table_name_context(source: &str, lines: &LineIndex, position: Position) -> bool {
let offset = lines.offset(source, position);
let Some(before) = source.get(..offset) else {
return false;
};
let mut i = before.len();
skip_back_while(before, &mut i, is_table_ident_char);
loop {
skip_back_while(before, &mut i, char::is_whitespace);
match preceding_char(before, i) {
Some((start, ',')) => i = start,
_ => break,
}
skip_back_while(before, &mut i, char::is_whitespace);
skip_back_while(before, &mut i, is_table_ident_char);
}
let keyword_end = i;
skip_back_while(before, &mut i, is_table_ident_char);
if i == keyword_end {
return false;
}
matches!(
before[i..keyword_end].to_ascii_uppercase().as_str(),
"FROM" | "INTO" | "UPDATE"
)
}
fn skip_back_while(source: &str, i: &mut usize, predicate: impl Fn(char) -> bool) {
while let Some((start, ch)) = preceding_char(source, *i) {
if !predicate(ch) {
return;
}
*i = start;
}
}
fn is_table_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '`'
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnSlot {
Strict { allow_star: bool },
}
pub fn column_completion_context(
source: &str,
lines: &LineIndex,
position: Position,
) -> Option<ColumnSlot> {
let offset = lines.offset(source, position);
let before = source.get(..offset)?;
let mut i = before.len();
skip_back_while(before, &mut i, is_table_ident_char);
loop {
skip_back_while(before, &mut i, char::is_whitespace);
match preceding_char(before, i) {
Some((start, ',')) => i = start,
_ => break,
}
loop {
if ends_with_list_keyword(&before[..i]) {
break;
}
match preceding_char(before, i) {
None => break,
Some((_, ',')) => break,
Some((_, '\'' | '"' | '(' | ')' | '{' | '}' | '[' | ']' | ';')) => return None,
Some((start, _)) => i = start,
}
}
}
skip_back_while(before, &mut i, char::is_whitespace);
let keyword_end = i;
skip_back_while(before, &mut i, is_table_ident_char);
if i == keyword_end {
return None;
}
let keyword = before[i..keyword_end].to_ascii_uppercase();
match keyword.as_str() {
"SELECT" => Some(ColumnSlot::Strict { allow_star: true }),
"SET" => Some(ColumnSlot::Strict { allow_star: false }),
_ => None,
}
}
fn ends_with_list_keyword(text: &str) -> bool {
let trimmed = text.trim_end();
["SELECT", "SET"].iter().any(|keyword| {
let Some(tail) = trimmed
.len()
.checked_sub(keyword.len())
.and_then(|at| trimmed.get(at..))
else {
return false;
};
tail.eq_ignore_ascii_case(keyword)
&& trimmed[..trimmed.len() - keyword.len()]
.chars()
.next_back()
.is_none_or(|ch| !is_table_ident_char(ch))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphSlot {
pub direction: LookupDirection,
pub anchor: Option<String>,
pub from_edge: bool,
}
pub fn graph_edge_context(
source: &str,
lines: &LineIndex,
position: Position,
) -> Option<GraphSlot> {
let offset = lines.offset(source, position);
let before = source.get(..offset)?;
let mut index = before.len();
skip_back_over_name(before, &mut index);
let direction = take_arrow_back(before, &mut index)?;
let mut arrows = 1;
let mut at = index;
let mut anchor: Option<&str> = None;
loop {
let name_end = at;
let mut name_start = at;
skip_back_over_name(before, &mut name_start);
if name_start == name_end {
break;
}
anchor.get_or_insert(&before[name_start..name_end]);
let mut behind = name_start;
if take_arrow_back(before, &mut behind).is_none() {
break;
}
arrows += 1;
at = behind;
}
Some(GraphSlot {
direction,
anchor: anchor
.and_then(|name| name.split(':').next())
.map(|name| name.trim_matches('`').to_string())
.filter(|name| !name.is_empty()),
from_edge: arrows % 2 == 0,
})
}
pub fn graph_anchors(
slot: &GraphSlot,
analysis: &DocumentAnalysis,
position: Position,
) -> Vec<String> {
if let Some(anchor) = &slot.anchor {
return vec![anchor.clone()];
}
if let Some(fact) = active_query_fact(analysis, position)
&& !fact.target_tables.is_empty()
{
return fact.target_tables.clone();
}
statement_target_in_text(&analysis.text, &analysis.line_index, position)
.into_iter()
.collect()
}
const TARGET_KEYWORDS: [&str; 6] = ["FROM", "INTO", "UPDATE", "UPSERT", "CREATE", "DELETE"];
pub fn statement_target_in_text(
source: &str,
lines: &LineIndex,
position: Position,
) -> Option<String> {
let cursor = lines.offset(source, position);
let start = source[..cursor].rfind(';').map_or(0, |at| at + 1);
let end = source[cursor..]
.find(';')
.map_or(source.len(), |at| cursor + at);
let words: Vec<&str> = source.get(start..end)?.split_whitespace().collect();
let is_keyword = |word: &str| {
TARGET_KEYWORDS
.iter()
.any(|keyword| word.eq_ignore_ascii_case(keyword))
};
for (index, word) in words.iter().enumerate() {
if !is_keyword(word) {
continue;
}
let Some(next) = words.get(index + 1) else {
continue;
};
if is_keyword(next) {
continue;
}
let name: String = next
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
.collect();
if !name.is_empty() {
return Some(name);
}
}
None
}
fn take_arrow_back(source: &str, index: &mut usize) -> Option<LookupDirection> {
for (arrow, direction) in [
("<->", LookupDirection::Both),
("->", LookupDirection::Right),
("<-", LookupDirection::Left),
("<~", LookupDirection::Left),
] {
if source[..*index].ends_with(arrow) {
*index -= arrow.len();
return Some(direction);
}
}
None
}
fn skip_back_over_name(source: &str, index: &mut usize) {
while let Some((start, ch)) = preceding_char(source, *index) {
let is_name = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '`' | ':');
let is_arrow_half = source[start..].starts_with("->") || source[..start].ends_with('<');
let is_inner_hyphen = ch == '-' && !is_arrow_half;
if !(is_name || is_inner_hyphen) {
return;
}
*index = start;
}
}
pub fn completion_prefix(
source: &str,
lines: &LineIndex,
position: Position,
record_type_context: bool,
) -> String {
let prefix = crate::semantic::text::token_prefix(source, lines, position).unwrap_or_default();
if record_type_context {
prefix
.rsplit_once('<')
.map(|(_, suffix)| suffix.to_string())
.unwrap_or(prefix)
} else {
prefix
}
}
pub fn active_query_fact<'a>(
analysis: &'a DocumentAnalysis,
position: Position,
) -> Option<&'a QueryFact> {
analysis
.query_facts
.iter()
.find(|fact| range_contains_position(fact.location.range, position))
}
fn range_contains_position(range: ls_types::Range, position: Position) -> bool {
position_gte(position, range.start) && position_lte(position, range.end)
}
fn position_lte(left: Position, right: Position) -> bool {
left.line < right.line || (left.line == right.line && left.character <= right.character)
}
fn position_gte(left: Position, right: Position) -> bool {
left.line > right.line || (left.line == right.line && left.character >= right.character)
}
pub fn completion_table_qualifier(
source: &str,
lines: &LineIndex,
position: Position,
) -> Option<String> {
let offset = lines.offset(source, position);
let before_cursor = source.get(..offset)?;
let (left, right) = before_cursor.rsplit_once('.')?;
if !right.chars().all(is_field_prefix_char) {
return None;
}
let raw: String = left
.chars()
.rev()
.take_while(|ch| is_table_qualifier_char(*ch))
.collect();
if left.chars().rev().nth(raw.chars().count()) == Some('$') {
return None;
}
let qualifier: String = raw.chars().rev().collect();
let qualifier = qualifier.trim_matches('`');
if qualifier.is_empty() {
return None;
}
if qualifier
.chars()
.next()
.map(|ch| ch.is_ascii_digit())
.unwrap_or(false)
{
return None;
}
let table = qualifier.split(':').next().unwrap_or(qualifier).trim();
if table.is_empty() {
None
} else {
Some(table.to_string())
}
}
fn is_table_qualifier_char(ch: char) -> bool {
ch.is_alphanumeric() || matches!(ch, '_' | ':' | '-' | '`')
}
fn is_field_prefix_char(ch: char) -> bool {
ch.is_alphanumeric() || matches!(ch, '_' | ':' | '-')
}
#[cfg(test)]
mod tests {
use super::*;
fn words_at_end(source: &str) -> Option<Vec<String>> {
let line = source.lines().count().saturating_sub(1) as u32;
let character = source.lines().last().map_or(0, str::len) as u32;
statement_words(
source,
&LineIndex::new(source),
Position { line, character },
)
}
fn words(source: &str) -> Vec<String> {
words_at_end(source).expect("expected a classifiable position")
}
fn slot(source: &str) -> Option<GraphSlot> {
let line = source.lines().count().saturating_sub(1) as u32;
let character = source.lines().last().map_or(0, str::len) as u32;
graph_edge_context(
source,
&LineIndex::new(source),
Position { line, character },
)
}
#[test]
fn a_cursor_after_an_arrow_is_a_graph_slot() {
let found = slot("SELECT * FROM person->").expect("a graph slot");
assert_eq!(found.direction, LookupDirection::Right);
assert_eq!(found.anchor.as_deref(), Some("person"));
assert!(!found.from_edge, "the first hop leaves a table");
}
#[test]
fn a_half_typed_hop_name_is_still_a_graph_slot() {
let found = slot("SELECT * FROM person->kno").expect("a graph slot");
assert_eq!(found.anchor.as_deref(), Some("person"));
}
#[test]
fn every_arrow_spelling_is_classified() {
for (arrow, expected) in [
("->", LookupDirection::Right),
("<-", LookupDirection::Left),
("<~", LookupDirection::Left),
("<->", LookupDirection::Both),
] {
let source = format!("SELECT * FROM person{arrow}");
let found = slot(&source).unwrap_or_else(|| panic!("a graph slot for {source}"));
assert_eq!(found.direction, expected, "for {arrow}");
assert_eq!(found.anchor.as_deref(), Some("person"), "for {arrow}");
}
}
#[test]
fn the_second_hop_reads_from_the_edge() {
let found = slot("SELECT * FROM person->knows->").expect("a graph slot");
assert_eq!(found.anchor.as_deref(), Some("knows"));
assert!(found.from_edge);
}
#[test]
fn the_third_hop_reads_from_a_table_again() {
let found = slot("SELECT * FROM person->knows->person->").expect("a graph slot");
assert_eq!(found.anchor.as_deref(), Some("person"));
assert!(!found.from_edge);
}
#[test]
fn a_bodiless_traversal_reports_no_written_anchor() {
let found = slot("SELECT ->").expect("a graph slot");
assert_eq!(found.anchor, None);
assert!(!found.from_edge, "the first hop still leaves a table");
let second = slot("SELECT ->knows->").expect("a graph slot");
assert_eq!(second.anchor.as_deref(), Some("knows"));
assert!(second.from_edge);
}
#[test]
fn a_record_id_base_anchors_on_its_table() {
let found = slot("SELECT * FROM person:alice->").expect("a graph slot");
assert_eq!(found.anchor.as_deref(), Some("person"));
}
#[test]
fn a_hyphenated_anchor_keeps_its_hyphen() {
let found = slot("SELECT * FROM my-table->").expect("a graph slot");
assert_eq!(found.anchor.as_deref(), Some("my-table"));
}
#[test]
fn a_cursor_that_follows_no_arrow_is_not_a_graph_slot() {
for source in [
"SELECT * FROM person",
"SELECT * FROM ",
"SELECT name FROM person WHERE age > ",
"LET $x: record<person",
"SELECT 1 - ",
] {
assert_eq!(slot(source), None, "{source} is not a graph slot");
}
}
#[test]
fn a_trailing_space_commits_the_last_word() {
assert_eq!(words("INFO FOR "), vec!["INFO", "FOR"]);
}
#[test]
fn a_half_typed_word_is_left_for_the_prefix() {
assert_eq!(words("INFO FOR RO"), vec!["INFO", "FOR"]);
assert_eq!(words("INFO F"), vec!["INFO"]);
}
#[test]
fn a_top_level_semicolon_starts_a_new_statement() {
assert_eq!(
words("SELECT * FROM person; INFO FOR "),
vec!["INFO", "FOR"]
);
assert_eq!(words("USE NS a;\nINFO FOR "), vec!["INFO", "FOR"]);
}
#[test]
fn a_closed_bracket_group_collapses_to_one_word() {
assert_eq!(
words("DEFINE FUNCTION fn::x($a: int) ").len(),
4,
"got {:?}",
words("DEFINE FUNCTION fn::x($a: int) ")
);
}
#[test]
fn an_unclosed_bracket_is_not_classifiable() {
assert_eq!(words_at_end("RETURN string::len("), None);
assert_eq!(words_at_end("CREATE person CONTENT { name: "), None);
assert_eq!(words_at_end("SELECT * FROM (SELECT * FROM "), None);
}
#[test]
fn a_cursor_inside_a_string_is_not_classifiable() {
assert_eq!(words_at_end("INFO FOR TABLE 'unterm"), None);
}
#[test]
fn a_closed_string_is_one_word() {
assert_eq!(words("KILL 'abc' ").len(), 2);
}
#[test]
fn a_semicolon_inside_a_string_does_not_split_the_statement() {
assert_eq!(words("INFO FOR TABLE 'a;b' ").len(), 4);
}
#[test]
fn a_cursor_inside_a_comment_is_not_classifiable() {
assert_eq!(words_at_end("INFO FOR -- note "), None);
assert_eq!(words_at_end("INFO FOR # note "), None);
assert_eq!(words_at_end("INFO FOR /* note "), None);
}
#[test]
fn a_finished_comment_is_skipped() {
assert_eq!(words("INFO /* note */ FOR "), vec!["INFO", "FOR"]);
assert_eq!(words("INFO -- note\nFOR "), vec!["INFO", "FOR"]);
}
#[test]
fn the_head_slot_falls_back_to_expression_when_unclassifiable() {
let source = "RETURN string::len(";
let position = Position {
line: 0,
character: source.len() as u32,
};
assert_eq!(
head_slot_at(source, &LineIndex::new(source), position),
SlotYield::Expression
);
}
#[test]
fn the_head_slot_answers_for_a_modelled_head() {
let source = "INFO FOR ";
let position = Position {
line: 0,
character: source.len() as u32,
};
assert!(matches!(
head_slot_at(source, &LineIndex::new(source), position),
SlotYield::Keywords(_)
));
}
}