#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Vocab {
pub labels: Vec<LabelInfo>,
pub edges: Vec<EdgeInfo>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LabelInfo {
pub name: String,
pub count: u64,
pub properties: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EdgeInfo {
pub name: String,
pub count: u64,
pub connections: Vec<Connection>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Connection {
pub src: String,
pub dst: String,
pub count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Expect {
Statement,
Node { var: String },
NodeVar { var: String },
Label,
NodeEnd,
Hop {
from: Option<String>,
var: String,
bound: usize,
},
EdgeType {
from: Option<String>,
var: String,
incoming: bool,
lead: &'static str,
},
RelEnd { incoming: bool, ranged: bool },
Arrow { incoming: bool },
Predicate { vars: Vec<String> },
Projection { vars: Vec<String> },
Property { var: String, label: Option<String> },
Value,
SortKey { vars: Vec<String> },
Argument { vars: Vec<String> },
Clause { done: Vec<String> },
Nothing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Keyword,
Label,
EdgeType,
Property,
Variable,
Snippet,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Suggestion {
pub text: String,
pub insert: String,
pub detail: Option<String>,
pub kind: Kind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Completion {
pub best: Option<String>,
pub suggestions: Vec<Suggestion>,
pub expects: Expect,
pub word: String,
}
const MAX_SUGGESTIONS: usize = 12;
pub fn complete(prefix: &str, vocab: &Vocab) -> Completion {
let Some((tokens, word)) = tokenize(prefix) else {
return Completion {
best: None,
suggestions: Vec::new(),
expects: Expect::Nothing,
word: String::new(),
};
};
let (expects, closing) = scan(&tokens);
let loose = word.is_empty() && prefix.ends_with(char::is_whitespace);
let mut suggestions = suggest(&expects, &word, vocab, loose, closing);
suggestions.truncate(MAX_SUGGESTIONS);
Completion {
best: suggestions.first().map(|s| s.insert.clone()),
suggestions,
expects,
word,
}
}
#[derive(Debug, Clone, PartialEq)]
enum Tok<'a> {
Word(&'a str),
Punct(&'a str),
Num,
Str,
}
const ARROWY: &str = "-<>=!";
fn tokenize(prefix: &str) -> Option<(Vec<Tok<'_>>, String)> {
let mut toks = Vec::new();
let mut partial = None;
let mut chars = prefix.char_indices().peekable();
let end_of = |chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
keep: &dyn Fn(char) -> bool| {
while let Some(&(_, c)) = chars.peek() {
if !keep(c) {
return;
}
chars.next();
}
};
let at = |chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>| {
chars.peek().map_or(prefix.len(), |&(i, _)| i)
};
while let Some((start, c)) = chars.next() {
if c.is_whitespace() {
continue;
}
if c == '"' || c == '\'' {
loop {
match chars.next() {
None => return None,
Some((_, '\\')) => {
chars.next();
}
Some((_, q)) if q == c => break,
Some(_) => {}
}
}
toks.push(Tok::Str);
} else if c == '`' {
end_of(&mut chars, &|c| c != '`');
chars.next()?;
toks.push(Tok::Word(&prefix[start..at(&mut chars)]));
} else if c.is_alphabetic() || c == '_' || c == '$' {
end_of(&mut chars, &|c| c.is_alphanumeric() || c == '_');
let end = at(&mut chars);
let word = &prefix[start..end];
if end == prefix.len() {
partial = Some(word.to_string()); } else {
toks.push(Tok::Word(word));
}
} else if c.is_ascii_digit() {
end_of(&mut chars, &|c| c.is_ascii_digit());
if let Some(&(dot, '.')) = chars.peek()
&& prefix[dot + 1..].starts_with(|c: char| c.is_ascii_digit())
{
chars.next();
end_of(&mut chars, &|c| c.is_ascii_digit());
}
if let Some(&(e, 'e' | 'E')) = chars.peek()
&& prefix[e + 1..]
.trim_start_matches(['+', '-'])
.starts_with(|c: char| c.is_ascii_digit())
{
chars.next();
end_of(&mut chars, &|c| c == '+' || c == '-');
end_of(&mut chars, &|c| c.is_ascii_digit());
}
toks.push(Tok::Num);
} else {
if ARROWY.contains(c) || c == '.' {
let run = if c == '.' { "." } else { ARROWY };
end_of(&mut chars, &|c| run.contains(c));
}
toks.push(Tok::Punct(&prefix[start..at(&mut chars)]));
}
}
Some((toks, partial.unwrap_or_default()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Pos {
Statement,
NodeOpen,
NodeVar,
NodeColon,
NodeLabel,
NodeClose,
PropKey,
PropRest,
Hop,
RelOpen,
RelVar,
RelColon,
RelType,
RelClose,
Arrow,
Predicate,
Projection,
Term,
Call,
Property(String),
Value,
Sort,
Clause,
Refused,
}
struct Scan {
pos: Pos,
vars: Vec<(String, Option<String>)>,
from: Option<String>,
var: Option<String>,
label: Option<String>,
incoming: bool,
owed: &'static str,
star: Option<bool>,
term: Option<String>,
done: Vec<String>,
writing: bool,
}
impl Scan {
fn close_node(&mut self) {
let label = self.label.take();
if let Some(var) = self.var.take() {
self.vars.push((var, label.clone()));
}
self.from = label;
}
fn label_of(&self, var: &str) -> Option<String> {
self.vars
.iter()
.find(|(v, _)| v == var)
.and_then(|(_, l)| l.clone())
}
fn names(&self) -> Vec<String> {
self.vars.iter().map(|(v, _)| v.clone()).collect()
}
fn next_var(&self) -> String {
const NAMES: &[&str] = &["n", "m", "o", "p", "q", "r", "s", "t"];
NAMES
.iter()
.find(|name| !self.vars.iter().any(|(v, _)| v == *name))
.map_or_else(
|| format!("n{}", self.vars.len()),
|name| (*name).to_string(),
)
}
}
fn clause_keyword(word: &str) -> Option<&'static str> {
Some(match word.to_ascii_uppercase().as_str() {
"WHERE" => "WHERE",
"RETURN" => "RETURN",
"CREATE" => "CREATE",
"MERGE" => "MERGE",
"SET" | "REMOVE" | "DELETE" => "SET",
"AND" | "OR" | "NOT" => "AND",
"BY" => "BY",
"DISTINCT" => "RETURN",
"SKIP" | "LIMIT" => "COUNT",
"ORDER" | "DETACH" | "AS" | "OF" => "TAIL",
_ => return None,
})
}
fn scan(tokens: &[Tok<'_>]) -> (Expect, bool) {
let mut s = Scan {
pos: Pos::Statement,
vars: Vec::new(),
from: None,
var: None,
label: None,
incoming: false,
owed: "[:",
star: None,
term: None,
done: Vec::new(),
writing: false,
};
for tok in tokens {
step(&mut s, tok);
}
let closing = s.pos == Pos::NodeClose;
let expects = match &s.pos {
Pos::Statement => Expect::Statement,
Pos::NodeOpen => Expect::Node { var: s.next_var() },
Pos::NodeVar => Expect::NodeVar { var: s.next_var() },
Pos::NodeColon | Pos::NodeClose => Expect::NodeEnd,
Pos::PropKey => Expect::Property {
var: s.var.clone().unwrap_or_default(),
label: s.label.clone(),
},
Pos::PropRest => Expect::Value,
Pos::NodeLabel => Expect::Label,
Pos::Hop => Expect::Hop {
from: s.from.clone(),
var: s.next_var(),
bound: s.vars.len(),
},
Pos::RelOpen | Pos::RelVar | Pos::RelColon | Pos::RelType => Expect::EdgeType {
from: s.from.clone(),
var: s.next_var(),
incoming: s.incoming,
lead: match s.pos {
Pos::RelOpen => s.owed,
Pos::RelVar | Pos::RelColon => ":",
_ => "",
},
},
Pos::RelClose => Expect::RelEnd {
incoming: s.incoming,
ranged: s.star == Some(false),
},
Pos::Arrow => Expect::Arrow {
incoming: s.incoming,
},
Pos::Predicate => Expect::Predicate { vars: s.names() },
Pos::Projection => Expect::Projection { vars: s.names() },
Pos::Property(var) => Expect::Property {
label: s.label_of(var),
var: var.clone(),
},
Pos::Value => Expect::Value,
Pos::Sort => Expect::SortKey { vars: s.names() },
Pos::Call => Expect::Argument { vars: s.names() },
Pos::Term | Pos::Clause => Expect::Clause {
done: s.done.clone(),
},
Pos::Refused => Expect::Nothing,
};
(expects, closing)
}
fn step(s: &mut Scan, tok: &Tok<'_>) {
if let Tok::Word(w) = tok
&& let Some(kw) = clause_keyword(w)
{
let written = w.to_ascii_uppercase();
if let Some((tail, _)) = TAIL
.iter()
.find(|(t, _)| t.split(' ').next() == Some(written.as_str()))
{
s.done.push((*tail).to_string());
}
if s.pos == Pos::Refused {
return;
}
if matches!(kw, "CREATE" | "MERGE") {
s.writing = true;
}
s.pos = match kw {
"WHERE" | "AND" | "SET" => Pos::Predicate,
"RETURN" => Pos::Projection,
"CREATE" | "MERGE" => Pos::NodeOpen,
"BY" => Pos::Sort,
"COUNT" => Pos::Value,
_ => Pos::Clause,
};
return;
}
match (&s.pos, tok) {
(Pos::Statement, Tok::Word(w)) => {
if matches!(
w.to_ascii_uppercase().as_str(),
"MATCH" | "SEARCH" | "HYBRID" | "BEAM" | "ON"
) {
s.pos = Pos::NodeOpen;
}
}
(Pos::NodeOpen, Tok::Punct("(")) => s.pos = Pos::NodeVar,
(Pos::NodeVar, Tok::Word(w)) => {
s.var = Some((*w).to_string());
s.pos = Pos::NodeColon;
}
(Pos::NodeVar | Pos::NodeColon, Tok::Punct(":")) => s.pos = Pos::NodeLabel,
(Pos::NodeLabel, Tok::Word(w)) => {
s.label = Some((*w).to_string());
s.pos = Pos::NodeClose;
}
(Pos::NodeVar | Pos::NodeColon | Pos::NodeClose, Tok::Punct(")")) => {
s.close_node();
s.pos = Pos::Hop;
}
(Pos::NodeVar | Pos::NodeColon | Pos::NodeClose, Tok::Punct("{")) => {
s.pos = if s.writing {
Pos::PropKey
} else {
Pos::Refused
};
}
(Pos::Refused, _) => {}
(Pos::PropKey, Tok::Word(_)) => s.pos = Pos::PropRest,
(Pos::PropRest, Tok::Punct(",")) => s.pos = Pos::PropKey,
(Pos::PropKey | Pos::PropRest, Tok::Punct("}")) => s.pos = Pos::NodeClose,
(Pos::PropRest, _) => {}
(Pos::Hop, Tok::Punct(p)) if p.starts_with('-') || p.starts_with('<') => {
s.incoming = p.starts_with('<');
s.star = None;
s.owed = if *p == "<" { "-[:" } else { "[:" };
s.pos = if *p == "--" || *p == "-->" || *p == "<--" {
Pos::NodeOpen
} else {
Pos::RelOpen
};
}
(Pos::RelOpen, Tok::Punct("[")) => s.pos = Pos::RelVar,
(Pos::RelVar, Tok::Word(_)) => s.pos = Pos::RelColon,
(Pos::RelVar | Pos::RelColon, Tok::Punct(":")) => s.pos = Pos::RelType,
(Pos::RelType, Tok::Word(_)) => s.pos = Pos::RelClose,
(Pos::RelVar | Pos::RelColon | Pos::RelType | Pos::RelClose, Tok::Punct("]")) => {
s.pos = Pos::Arrow;
}
(Pos::RelClose, Tok::Punct("*")) => s.star = Some(false),
(Pos::RelClose, Tok::Punct("..") | Tok::Num) => s.star = Some(true),
(Pos::Arrow, Tok::Punct(_)) => s.pos = Pos::NodeOpen,
(Pos::Predicate | Pos::Projection | Pos::Value | Pos::Clause | Pos::Sort, Tok::Word(w)) => {
s.term = Some((*w).to_string());
s.pos = Pos::Term;
}
(Pos::Term, Tok::Punct(".")) => {
s.pos = Pos::Property(s.term.clone().unwrap_or_default());
}
(Pos::Property(_), Tok::Word(_)) => s.pos = Pos::Term,
(Pos::Term, Tok::Punct("(")) => s.pos = Pos::Call,
(Pos::Call, Tok::Word(w)) => {
s.term = Some((*w).to_string());
s.pos = Pos::Term;
}
(Pos::Call, Tok::Punct("*")) => s.pos = Pos::Term,
(Pos::Term, Tok::Punct(p)) if is_comparison(p) => s.pos = Pos::Value,
(Pos::Term, Tok::Punct(",")) => s.pos = Pos::Projection,
(Pos::Value, Tok::Str | Tok::Num) => s.pos = Pos::Clause,
_ => {}
}
}
fn is_comparison(p: &str) -> bool {
matches!(p, "=" | "<>" | "!=" | "<" | "<=" | ">" | ">=")
}
const OPENERS: &[(&str, &str)] = &[
("MATCH", "walk a pattern"),
("SEARCH", "a vector or keyword seed"),
("HYBRID", "fused retrieval"),
("CALL", "a graph algorithm"),
("CREATE", "write nodes and edges"),
("MERGE", "upsert by key"),
];
const AFTER_PATTERN: &[(&str, &str)] =
&[("RETURN", "what to return"), ("WHERE", "filter the rows")];
const TAIL: &[(&str, &str)] = &[
("ORDER BY", "sort the rows"),
("LIMIT", "cap the rows"),
("SKIP", "drop the first n"),
("AS", "name the column"),
("AS OF", "read a past snapshot"),
];
const PREDICATE_TERMS: &[(&str, &str)] = &[
("NOT", "negate"),
("key(", "the external key"),
("score()", "the retrieval score"),
];
const FOLDS: &[(&str, &str)] = &[
("count(*)", "how many rows"),
("collect(", "gather into a list"),
("sum(", "total"),
("avg(", "mean"),
("min(", "least"),
("max(", "greatest"),
];
fn suggest(
expects: &Expect,
word: &str,
vocab: &Vocab,
loose: bool,
closing: bool,
) -> Vec<Suggestion> {
match expects {
Expect::Nothing | Expect::Value => Vec::new(),
Expect::Statement => keywords(OPENERS, word),
Expect::Node { var } => node_patterns(word, vocab, var),
Expect::NodeVar { var } => at_boundary(word, var, "a name for this node"),
Expect::Label => labels(word, vocab, ")"),
Expect::NodeEnd if closing => at_boundary(word, ")", "close the node"),
Expect::NodeEnd => node_ends(word, vocab),
Expect::Hop { from, var, bound } => hops(word, vocab, from.as_deref(), var, *bound),
Expect::EdgeType {
from,
var,
incoming,
lead,
} => {
if lead.contains('[') && !word.is_empty() {
return Vec::new();
}
edge_types(word, vocab, from.as_deref(), var, *incoming, lead)
}
Expect::RelEnd { .. } | Expect::Arrow { .. } if loose => Vec::new(),
Expect::RelEnd { incoming, ranged } => {
let close = if *incoming { "]-" } else { "]->" };
if *ranged {
at_boundary(word, &format!("1..3{close}"), "how many hops to walk")
} else {
at_boundary(word, close, "close the relationship")
}
}
Expect::Arrow { incoming } => {
let arrow = if *incoming { "-" } else { "->" };
at_boundary(word, arrow, "close the relationship")
}
Expect::Predicate { vars } => {
let mut out = variables(vars, word);
out.extend(keywords(PREDICATE_TERMS, word));
out
}
Expect::Projection { vars } => {
let terminal: Vec<String> = vars.iter().rev().cloned().collect();
let mut out = variables(&terminal, word);
out.extend(keys_of(&terminal, word));
out.extend(keywords(FOLDS, word));
out
}
Expect::SortKey { vars } => {
let terminal: Vec<String> = vars.iter().rev().cloned().collect();
let mut out = keys_of(&terminal, word);
out.extend(variables(&terminal, word));
out.extend(keywords(FOLDS, word));
out
}
Expect::Argument { vars } => {
let terminal: Vec<String> = vars.iter().rev().cloned().collect();
variables(&terminal, word)
}
Expect::Property { label, .. } => properties(word, vocab, label.as_deref()),
Expect::Clause { done } => keywords(TAIL, word)
.into_iter()
.filter(|s| !done.iter().any(|d| d.eq_ignore_ascii_case(&s.text)))
.collect(),
}
}
fn at_boundary(word: &str, text: &str, detail: &str) -> Vec<Suggestion> {
if !text.starts_with(word) || text.len() == word.len() {
return Vec::new();
}
vec![Suggestion {
insert: text[word.len()..].to_string(),
text: text.to_string(),
detail: Some(detail.to_string()),
kind: Kind::Snippet,
}]
}
fn keywords(list: &[(&str, &str)], word: &str) -> Vec<Suggestion> {
list.iter()
.filter(|(kw, _)| starts_with(kw, word))
.map(|(kw, detail)| {
let text = fit_case(kw, word);
Suggestion {
insert: text[word.len()..].to_string(),
text,
detail: Some((*detail).to_string()),
kind: Kind::Keyword,
}
})
.collect()
}
fn fit_case(keyword: &str, word: &str) -> String {
let lower = !word.is_empty() && word.chars().all(|c| !c.is_alphabetic() || c.is_lowercase());
if lower {
keyword.to_ascii_lowercase()
} else {
keyword.to_string()
}
}
fn keys_of(vars: &[String], word: &str) -> Vec<Suggestion> {
vars.iter()
.map(|v| format!("key({v})"))
.filter(|k| starts_with(k, word))
.map(|k| Suggestion {
insert: k[word.len()..].to_string(),
text: k,
detail: Some("what it is called".to_string()),
kind: Kind::Snippet,
})
.collect()
}
fn variables(vars: &[String], word: &str) -> Vec<Suggestion> {
vars.iter()
.filter(|v| starts_with(v, word))
.map(|v| Suggestion {
insert: v[word.len()..].to_string(),
text: v.clone(),
detail: Some("a bound node".to_string()),
kind: Kind::Variable,
})
.collect()
}
fn labels(word: &str, vocab: &Vocab, trailer: &str) -> Vec<Suggestion> {
let mut hits: Vec<&LabelInfo> = vocab
.labels
.iter()
.filter(|l| starts_with(&l.name, word))
.collect();
hits.sort_by_key(|l| (std::cmp::Reverse(l.count), l.name.clone()));
hits.iter()
.map(|l| Suggestion {
insert: format!("{}{trailer}", &l.name[word.len()..]),
text: l.name.clone(),
detail: Some(nodes(l.count)),
kind: Kind::Label,
})
.collect()
}
fn node_patterns(word: &str, vocab: &Vocab, var: &str) -> Vec<Suggestion> {
if !word.is_empty() {
return Vec::new();
}
if vocab.labels.is_empty() {
return at_boundary(word, &format!("({var})"), "any node");
}
ranked_labels(vocab)
.iter()
.map(|l| {
let text = format!("({var}:{})", l.name);
Suggestion {
insert: text.clone(),
text,
detail: Some(nodes(l.count)),
kind: Kind::Snippet,
}
})
.collect()
}
fn node_ends(word: &str, vocab: &Vocab) -> Vec<Suggestion> {
if !word.is_empty() {
return Vec::new();
}
let mut out: Vec<Suggestion> = ranked_labels(vocab)
.iter()
.map(|l| {
let text = format!(":{})", l.name);
Suggestion {
insert: text.clone(),
text,
detail: Some(nodes(l.count)),
kind: Kind::Snippet,
}
})
.collect();
out.extend(at_boundary(word, ")", "any node"));
out
}
fn edge_types(
word: &str,
vocab: &Vocab,
from: Option<&str>,
var: &str,
incoming: bool,
lead: &str,
) -> Vec<Suggestion> {
let typed = if lead.is_empty() { word } else { "" };
ranked_edges(vocab, from, incoming)
.iter()
.filter(|(e, _, _)| starts_with(&e.name, typed))
.map(|(e, count, dst)| Suggestion {
insert: format!(
"{lead}{}{}",
&e.name[typed.len()..],
hop_tail(*dst, var, incoming)
),
text: e.name.clone(),
detail: Some(edge_detail(*count, *dst)),
kind: Kind::EdgeType,
})
.collect()
}
fn hops(word: &str, vocab: &Vocab, from: Option<&str>, var: &str, bound: usize) -> Vec<Suggestion> {
let mut shapes = Vec::new();
if word.is_empty() {
let out = ranked_edges(vocab, from, false)
.into_iter()
.map(|(e, count, dst)| (e, count, dst, false));
let back = from
.map(|_| ranked_edges(vocab, from, true))
.unwrap_or_default()
.into_iter()
.map(|(e, count, dst)| (e, count, dst, true));
let mut both: Vec<_> = out.chain(back).collect();
both.sort_by_key(|(e, count, _, incoming)| {
(std::cmp::Reverse(*count), *incoming, e.name.clone())
});
shapes.extend(both.iter().map(|(e, count, dst, incoming)| {
let arrow = if *incoming { "<-" } else { "-" };
let text = format!("{arrow}[:{}{}", e.name, hop_tail(*dst, var, *incoming));
Suggestion {
insert: text.clone(),
text,
detail: Some(edge_detail(*count, *dst)),
kind: Kind::Snippet,
}
}));
}
let mut clauses = keywords(AFTER_PATTERN, word);
if bound > 1 {
clauses.extend(shapes);
return clauses;
}
shapes.extend(clauses);
shapes
}
fn hop_tail(dst: Option<&str>, var: &str, incoming: bool) -> String {
let close = if incoming { "]-" } else { "]->" };
match dst {
Some(dst) => format!("{close}({var}:{dst})"),
None => format!("{close}({var})"),
}
}
fn edge_detail(count: u64, dst: Option<&str>) -> String {
match dst {
Some(dst) => format!("{count} → {dst}"),
None => format!("{count} edges"),
}
}
fn nodes(count: u64) -> String {
format!("{count} node{}", if count == 1 { "" } else { "s" })
}
fn ranked_labels(vocab: &Vocab) -> Vec<&LabelInfo> {
let mut out: Vec<&LabelInfo> = vocab.labels.iter().collect();
out.sort_by_key(|l| (std::cmp::Reverse(l.count), l.name.clone()));
out
}
type End = fn(&Connection) -> &String;
fn ranked_edges<'a>(
vocab: &'a Vocab,
from: Option<&str>,
incoming: bool,
) -> Vec<(&'a EdgeInfo, u64, Option<&'a str>)> {
let mut out: Vec<(&EdgeInfo, u64, Option<&str>)> = vocab
.edges
.iter()
.filter_map(|e| {
let Some(from) = from else {
return Some((e, e.count, None));
};
let (here, there): (End, End) = if incoming {
(|c| &c.dst, |c| &c.src)
} else {
(|c| &c.src, |c| &c.dst)
};
let mut total = 0;
let mut best: Option<(&str, u64)> = None;
for c in e.connections.iter().filter(|c| here(c) == from) {
total += c.count;
if best.is_none_or(|(_, n)| c.count > n) {
best = Some((there(c).as_str(), c.count));
}
}
(total > 0).then_some((e, total, best.map(|(l, _)| l)))
})
.collect();
out.sort_by_key(|(e, count, _)| (std::cmp::Reverse(*count), e.name.clone()));
out
}
fn properties(word: &str, vocab: &Vocab, label: Option<&str>) -> Vec<Suggestion> {
let mut names: Vec<&str> = Vec::new();
for l in &vocab.labels {
if label.is_none_or(|want| want == l.name) {
for p in &l.properties {
if starts_with(p, word) && !names.contains(&p.as_str()) {
names.push(p);
}
}
}
}
names
.iter()
.map(|p| Suggestion {
insert: p[word.len()..].to_string(),
text: (*p).to_string(),
detail: label.map(|l| format!("of {l}")),
kind: Kind::Property,
})
.collect()
}
fn starts_with(candidate: &str, word: &str) -> bool {
candidate.len() > word.len()
&& candidate
.get(..word.len())
.is_some_and(|head| head.eq_ignore_ascii_case(word))
}