use crate::ast::Node;
use crate::editor::{Edit, preview_row, resolve};
use crate::render::{RenderCtx, render_root};
use crate::symbols;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Run(String),
Step(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
pub symbol: String,
pub names: String,
pub action: Action,
}
impl Item {
pub fn is_step(&self) -> bool {
matches!(self.action, Action::Step(_))
}
pub fn commit(&self) -> Option<&str> {
match &self.action {
Action::Run(cmd) => Some(cmd),
Action::Step(_) => None,
}
}
pub fn step_to(&self) -> Option<&str> {
match &self.action {
Action::Step(next) => Some(next),
Action::Run(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Completion {
pub items: Vec<Item>,
pub sel: usize,
}
impl Completion {
pub fn build(query: &str) -> Option<Completion> {
let items = complete(query);
(!items.is_empty()).then_some(Completion { items, sel: 0 })
}
pub fn highlighted(&self) -> Option<&Item> {
self.items.get(self.sel)
}
pub fn selected(&self) -> Option<&Item> {
self.highlighted().filter(|i| !i.is_step())
}
pub fn step(&mut self, down: bool) {
let n = self.items.len();
if n == 0 {
return;
}
self.sel = if down {
(self.sel + 1) % n
} else {
(self.sel + n - 1) % n
};
}
}
pub const MAX_ITEMS: usize = 12;
const MAX_TAIL: usize = 4;
const STRUCTURAL: &[&str] = &[
"frac",
"norm",
"overbrace",
"underbrace",
"ceil",
"floor",
"abs",
"bra",
"ket",
"braket",
"set",
"mid",
"addrow",
"addcol",
"delrow",
"delcol",
"op",
"op*",
"rm",
"text",
"!",
"negate",
"operatorname",
"operatorname*",
"limits",
"latex",
];
fn all_names() -> Vec<String> {
let mut names: Vec<String> = Vec::new();
let tables = [
symbols::NAMES.keys().copied().collect::<Vec<_>>(),
symbols::FUNCS.keys().copied().collect(),
symbols::ACCENT_NAMES.keys().copied().collect(),
symbols::ARROW_NAMES.keys().copied().collect(),
symbols::RADICAL_NAMES.keys().copied().collect(),
symbols::DELIM_NAMES.keys().copied().collect(),
symbols::GRID_ENVS.keys().copied().collect(),
STRUCTURAL.to_vec(),
];
names.extend(tables.into_iter().flatten().map(str::to_string));
let negatable: Vec<String> = symbols::NAMES
.entries()
.filter(|(_, c)| symbols::negated(**c).is_some())
.map(|(n, _)| format!("!{}", n))
.collect();
names.extend(negatable);
names.sort_unstable();
names.dedup();
names
}
const PREFIX: u32 = 100;
const SUBSTRING: u32 = 10_000;
const SUBSEQUENCE: u32 = 1_000_000;
fn score(name: &str, query: &str) -> Option<u32> {
if name == query {
return Some(0);
}
if name.starts_with(query) {
return Some(PREFIX + (name.len() - query.len()) as u32);
}
if let Some(at) = name.find(query) {
return Some(SUBSTRING + (at * 100 + name.len()) as u32);
}
subsequence(name, query).map(|spread| SUBSEQUENCE + spread)
}
fn subsequence(name: &str, query: &str) -> Option<u32> {
let mut chars = name.char_indices();
let mut spread = 0u32;
let mut last: Option<usize> = None;
for q in query.chars() {
let (at, _) = chars.find(|&(_, c)| c == q)?;
if let Some(prev) = last {
spread += (at - prev) as u32;
}
last = Some(at);
}
Some(spread * 10 + name.len() as u32)
}
fn collapse(mut names: Vec<&str>) -> String {
names.sort_unstable_by_key(|n| (n.len(), *n));
names.dedup();
let mut chains: Vec<Vec<&str>> = Vec::new();
for n in names {
match chains
.iter_mut()
.find(|c| n.starts_with(c.last().copied().unwrap_or_default()))
{
Some(chain) => chain.push(n),
None => chains.push(vec![n]),
}
}
chains.sort_by_key(|c| (std::cmp::Reverse(c.len()), c[0]));
chains
.iter()
.map(|chain| {
let mut s = chain[0].to_string();
for pair in chain.windows(2) {
s.push('[');
s.push_str(&pair[1][pair[0].len()..]);
}
let closes = chain.len() - 1;
match closes {
0 => {}
1 | 2 => s.push_str(&"]".repeat(closes)),
_ => s.push_str("]…]"),
}
s
})
.collect::<Vec<_>>()
.join(", ")
}
const SHAPE_MAX: usize = 9;
fn gloss(edit: &Edit) -> Option<&'static str> {
Some(match edit {
Edit::Mid => "a │ segment, or the ∣ atom outside a pair",
Edit::Negate => "slash the symbol before the cursor",
Edit::AddRow => "row below",
Edit::AddCol => "column to the right",
Edit::DelRow => "delete this row",
Edit::DelCol => "delete this column",
_ => return None,
})
}
fn shape(cmd: &str) -> String {
match resolve(cmd) {
Some(Edit::Accent(mark)) => return mark.info().preview.to_string(),
Some(
Edit::Mid | Edit::Negate | Edit::AddRow | Edit::AddCol | Edit::DelRow | Edit::DelCol,
) => return String::new(),
_ => {}
}
if let Some(row) = preview_row(cmd) {
let block = render_root(&row, None, &RenderCtx::canonical());
let at = if matches!(row[..], [Node::Sqrt { .. }]) {
Some(block.baseline)
} else {
(block.height() == 1).then_some(0)
};
if let Some(line) = at.and_then(|y| block.lines.get(y)) {
let s: String = line.iter().collect();
let s = s.trim();
if !s.is_empty() {
return match s.chars().count() > SHAPE_MAX {
true => s.chars().take(SHAPE_MAX - 1).chain(['…']).collect(),
false => s.to_string(),
};
}
}
}
String::new()
}
fn rows() -> &'static [(Item, Vec<String>)] {
use std::collections::HashMap;
use std::sync::OnceLock;
static ROWS: OnceLock<Vec<(Item, Vec<String>)>> = OnceLock::new();
ROWS.get_or_init(|| {
let mut by_edit: HashMap<String, Vec<String>> = HashMap::new();
let mut order: Vec<String> = Vec::new();
for name in all_names() {
let Some(edit) = resolve(&name) else { continue };
let key = format!("{:?}", edit);
by_edit.entry(key.clone()).or_insert_with(|| {
order.push(key.clone());
Vec::new()
});
by_edit.get_mut(&key).expect("just inserted").push(name);
}
order
.into_iter()
.map(|key| {
let names = by_edit.remove(&key).expect("one entry per key");
let commit = names
.iter()
.max_by_key(|n| (n.len(), n.as_str()))
.cloned()
.unwrap_or_default();
let mut names_shown = collapse(names.iter().map(String::as_str).collect());
if let Some(g) = resolve(&commit).as_ref().and_then(gloss) {
names_shown.push_str(&format!(" ({})", g));
}
let item = Item {
symbol: shape(&commit),
names: names_shown,
action: Action::Run(commit),
};
(item, names)
})
.collect()
})
}
fn family_hints(query: &str) -> Vec<(u32, Item)> {
let mut out = Vec::new();
for (prefix, family) in symbols::ALPHABETS.entries() {
let Some(score) = score(prefix, query).filter(|&s| s < SUBSTRING) else {
continue;
};
for (rank, (lo, hi, digits)) in [('a', 'z', false), ('A', 'Z', false), ('0', '9', true)]
.into_iter()
.enumerate()
{
if digits && family.digits.is_none() {
continue;
}
let styled = |c: char| symbols::alphabet_char(&format!("{}{}", prefix, c));
let (Some(first), Some(last)) = (styled(lo), styled(hi)) else {
continue;
};
out.push((
score + rank as u32,
Item {
symbol: format!("{}…{}", first, last),
names: format!("{}{{{}…{}}}", prefix, lo, hi),
action: Action::Step(prefix.to_string()),
},
));
}
}
out
}
fn delim_hints(query: &str) -> Vec<(u32, Item)> {
let Some(spec) = query
.strip_prefix("delim")
.or_else(|| query.strip_prefix("lr"))
else {
return Vec::new();
};
let Some((settled, tail)) = crate::editor::lr_split(spec) else {
return Vec::new();
};
let naming = settled.len() != spec.len();
let Some(opening) = crate::editor::lr_spec_more(settled) else {
return Vec::new();
};
let prefix = if query.starts_with("delim") {
"delim"
} else {
"lr"
};
let mut out: Vec<(u32, Item)> = symbols::DELIM_NAMES
.entries()
.filter(|(name, glyph)| {
name.starts_with(tail)
&& symbols::Delim::of_spec_side(**glyph, opening).is_some()
&& !matches!(**name, "mid" | "dot")
})
.map(|(name, &glyph)| {
let spec = format!("{}{}\\{}", prefix, settled, name);
(
PREFIX + (name.len() - tail.len()) as u32,
Item {
symbol: glyph.to_string(),
names: format!("{}…", spec),
action: Action::Step(spec),
},
)
})
.collect();
if !naming {
for (glyph, _) in symbols::DELIM_SPECS.entries() {
if symbols::Delim::of_spec_side(*glyph, opening).is_none() {
continue;
}
let spec = format!("{}{}{}", prefix, settled, glyph);
out.push((
0,
Item {
symbol: glyph.to_string(),
names: format!("{}…", spec),
action: Action::Step(spec),
},
));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.names.cmp(&b.1.names)));
out
}
fn grid_hints(query: &str) -> Vec<(u32, Item)> {
symbols::GRID_ENVS
.keys()
.filter(|env| **env != "smallmatrix")
.filter_map(|env| {
if let Some(rest) = query.strip_prefix(env)
&& matches!(rest.as_bytes(), [b'1'..=b'9'])
{
return Some((
1,
Item {
symbol: String::new(),
names: format!("{}{{1…9}} (columns)", query),
action: Action::Step(query.to_string()),
},
));
}
let score = score(env, query).filter(|&s| s < SUBSTRING).or_else(|| {
let sibling = symbols::GRID_ENVS.contains_key(query) && env.ends_with(query);
sibling.then_some(SUBSTRING)
})?;
Some((
score + 1,
Item {
symbol: String::new(),
names: format!("{}{{1…9}}{{1…9}} (rows, columns)", env),
action: Action::Step(env.to_string()),
},
))
})
.collect()
}
fn mode_hints(query: &str) -> Vec<(u32, Item)> {
crate::editor::MODE_COMMANDS
.iter()
.filter_map(|(names, _, chord, gloss)| {
let best = names.iter().filter_map(|n| score(n, query)).min()?;
let commit = names
.iter()
.max_by_key(|n| (n.len(), **n))
.expect("no spellings");
let mut shown = collapse(names.to_vec());
shown.push_str(&format!(" ({})", gloss));
Some((
best,
Item {
symbol: format!("[{}]", chord),
names: shown,
action: Action::Run(commit.to_string()),
},
))
})
.collect()
}
pub fn complete(query: &str) -> Vec<Item> {
if query.is_empty() {
return Vec::new();
}
let hits: Vec<(u32, &Item)> = rows()
.iter()
.filter_map(|(item, names)| {
let best = names.iter().filter_map(|n| score(n, query)).min()?;
Some((best, item))
})
.collect();
let mut hits: Vec<(u32, Item)> = hits
.into_iter()
.map(|(s, i)| (s, i.clone()))
.chain(mode_hints(query))
.chain(family_hints(query))
.chain(delim_hints(query))
.chain(grid_hints(query))
.collect();
hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.names.cmp(&b.1.names)));
let hint_count = hits.iter().filter(|(_, i)| i.is_step()).count();
let mut room = (
usize::MAX,
MAX_TAIL.max(MAX_ITEMS.saturating_sub(hint_count)),
);
let mut items: Vec<Item> = hits
.into_iter()
.filter_map(|(_, i)| {
let left = if i.is_step() {
&mut room.0
} else {
&mut room.1
};
(*left > 0).then(|| {
*left -= 1;
i
})
})
.collect();
let cap = items.len().max(1);
if let Some(edit) = resolve(query)
&& !items.iter().any(|i| {
i.commit()
.is_some_and(|c| resolve(c).as_ref() == Some(&edit))
})
{
items.insert(
0,
Item {
symbol: shape(query),
names: query.to_string(),
action: Action::Run(query.to_string()),
},
);
items.truncate(cap);
}
items
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn structural_commands_all_resolve() {
for &cmd in STRUCTURAL {
let edit = resolve(cmd);
assert!(edit.is_some(), "\\{} no longer resolves", cmd);
let offered = complete(cmd);
let first = offered
.first()
.unwrap_or_else(|| panic!("\\{} completes to nothing", cmd));
assert_eq!(
first.commit().and_then(resolve),
edit,
"\\{} + Tab + Enter would run {:?}",
cmd,
first.action
);
}
}
#[test]
fn completing_a_command_keeps_its_meaning() {
for cmd in [
"!",
"negate",
"bra",
"ket",
"operatorname",
"operatorname*",
"limits",
"latex",
"frac",
"sqrt",
"cbrt",
"norm",
"mid",
"op*",
"text",
"pmatrix22",
"alpha",
"int",
"xto",
] {
let edit = resolve(cmd);
assert!(edit.is_some(), "\\{} is not a command", cmd);
let first = complete(cmd)
.into_iter()
.next()
.unwrap_or_else(|| panic!("\\{} completes to nothing", cmd));
assert_eq!(
first.commit().and_then(resolve),
edit,
"\\{} + Tab + Enter would run {:?} instead",
cmd,
first.action
);
}
}
#[test]
fn shapes_are_shown_but_cannot_be_committed() {
let frak = complete("frak");
assert_eq!(frak[0].names, "frak{a…z}");
assert_eq!(frak[0].symbol, "𝔞…𝔷");
assert_eq!(frak[1].names, "frak{A…Z}");
assert!(frak.iter().take(2).all(|i| i.is_step()), "{:?}", frak);
let after_open = complete("lr(\\r");
assert!(!after_open.is_empty(), "no hint after an opener");
assert!(
after_open
.iter()
.all(|i| i.is_step() && i.names.starts_with("lr(")),
"{:?}",
after_open
);
assert!(
complete("lr\\l").iter().any(|i| i.names == "lr\\lceil…"),
"\\lceil is offered as an opener"
);
let done = complete("lr(]");
assert_eq!(done[0].action, Action::Run("lr(]".into()), "{:?}", done[0]);
let list = Completion::build("frak").expect("shapes are listed");
assert!(list.selected().is_none(), "a shape was selectable");
}
#[test]
fn a_grid_asks_for_its_size() {
for env in ["matrix", "pmatrix", "cases"] {
assert!(resolve(env).is_none(), "\\{} still builds something", env);
let rows = complete(env);
let shape = format!("{}{{1…9}}{{1…9}}", env);
let row = rows
.iter()
.find(|r| r.names.starts_with(&shape))
.unwrap_or_else(|| panic!("no shape row for \\{}: {:?}", env, rows));
assert!(row.is_step(), "the shape row was committable");
}
let sized = complete("matrix34");
assert_eq!(sized.len(), 1, "{:?}", sized);
assert!(!sized[0].is_step());
assert!(resolve("matrix34").is_some());
}
#[test]
fn a_parametric_command_leads_its_own_list() {
for cmd in [
"rmx", "rmabc", "textfoo", "matrix34", "pmatrix12", "lr(]", "^z", "_i",
] {
let edit = resolve(cmd);
assert!(edit.is_some(), "\\{} is not a command", cmd);
let first = complete(cmd)
.into_iter()
.next()
.unwrap_or_else(|| panic!("\\{} completes to nothing", cmd));
assert_eq!(
first.commit().and_then(resolve),
edit,
"\\{} + Tab + Enter would run {:?} instead",
cmd,
first.action
);
}
}
#[test]
fn ranking_and_grouping() {
let items = complete("alpha");
let first = &items[0];
assert_eq!(first.symbol, "α");
assert_eq!(first.names, "al[p[ha]]");
assert_eq!(first.action, Action::Run("alpha".into()));
let items = complete("in");
let names: Vec<&str> = items.iter().map(|i| i.names.as_str()).collect();
let notin = items
.iter()
.position(|i| i.symbol == "∉")
.unwrap_or_else(|| panic!("no ∉ among {:?}", names));
let int = items
.iter()
.position(|i| i.commit() == Some("int"))
.unwrap_or_else(|| panic!("no \\int among {:?}", names));
assert!(int < notin, "\\int should outrank \\!in: {:?}", names);
}
#[test]
fn collapse_shows_where_a_name_may_stop() {
assert_eq!(collapse(vec!["abcde", "abc", "ab", "A"]), "ab[c[de]], A");
assert_eq!(
collapse(vec!["a", "al", "alp", "alph", "alpha"]),
"a[l[p[h[a]…]"
);
assert_eq!(collapse(vec!["to"]), "to");
assert_eq!(collapse(vec!["xto", "xrightarrow"]), "xrightarrow, xto");
}
#[test]
fn rows_show_what_they_insert() {
for tall in ["frac", "matrix34"] {
let row = complete(tall).into_iter().next().unwrap();
assert_eq!(row.symbol, "", "\\\\{} is taller than the column", tall);
}
let sqrt = complete("sqrt").into_iter().next().unwrap();
assert_eq!(sqrt.symbol, "√⬚");
let hat = complete("hat").into_iter().next().unwrap();
assert_eq!(hat.symbol, "ˆ");
let vec = complete("vec").into_iter().next().unwrap();
assert_eq!(vec.symbol, "→");
let cbrt = complete("cbrt").into_iter().next().unwrap();
assert_eq!(cbrt.symbol, "∛⬚");
let abs = complete("abs").into_iter().next().unwrap();
assert_eq!(abs.symbol, "⎢⬚⎥");
let braket = complete("braket").into_iter().next().unwrap();
assert!(braket.symbol.contains('⟨'), "{:?}", braket.symbol);
let sym = complete("alpha").into_iter().next().unwrap();
assert_eq!(sym.symbol, "α");
assert!(
complete("mid")
.into_iter()
.next()
.unwrap()
.symbol
.is_empty()
);
}
#[test]
fn no_matches_means_no_popup() {
assert!(Completion::build("qqzzxx").is_none());
assert!(Completion::build("alpha").is_some());
}
#[test]
fn a_hint_family_leaves_room_for_matches() {
let rows = complete("lr");
assert!(
rows.iter().filter(|r| r.is_step()).count() >= 8,
"the \\lr tokens are all there: {:?}",
rows
);
let tail: Vec<&Item> = rows.iter().filter(|r| !r.is_step()).collect();
assert!(!tail.is_empty(), "no ordinary match survived: {:?}", rows);
assert!(
tail.iter().any(|r| r.names.contains("longlr")),
"{:?}",
tail
);
}
#[test]
fn the_grid_family_is_found_through_its_shared_name() {
let rows = complete("matrix");
for env in ["pmatrix", "bmatrix", "Bmatrix"] {
let row = rows
.iter()
.find(|r| r.names.starts_with(env))
.unwrap_or_else(|| panic!("no {} row: {:?}", env, rows));
assert!(row.is_step() && row.symbol.is_empty(), "{:?}", row);
}
}
#[test]
fn a_broken_spec_gets_no_continuation() {
for q in ["lr)", "lr(]x", "delim}", "lrx"] {
assert!(resolve(q).is_none(), "\\{} resolves", q);
let offered: Vec<Item> = complete(q).into_iter().filter(|i| i.is_step()).collect();
assert!(offered.is_empty(), "\\{} was offered {:?}", q, offered);
}
let rows = complete("lr\\langle");
assert!(!rows.is_empty(), "the closers should be offered");
for row in &rows {
let ext = row.step_to().expect("a step row");
assert!(
ext.len() > "lr\\langle".len() && ext.starts_with("lr\\langle"),
"{:?} does not move the spelling on",
row
);
}
}
#[test]
fn a_middle_keeps_the_flow_alive() {
for q in ["lr(|", "lr\\vert|", "lr(||"] {
let rows = complete(q);
assert_eq!(rows[0].action, Action::Run(q.into()), "{:?}", rows);
let steps: Vec<&Item> = rows.iter().filter(|i| i.is_step()).collect();
assert!(!steps.is_empty(), "\\{} dead-ends: {:?}", q, rows);
assert!(
steps.iter().all(|i| i
.step_to()
.is_some_and(|s| s.starts_with(q) && s.len() > q.len())),
"{:?}",
steps
);
}
assert!(complete("lr()").iter().all(|i| !i.is_step()));
}
#[test]
fn an_empty_query_builds_no_list() {
assert!(Completion::build("").is_none());
}
#[test]
fn a_half_sized_grid_keeps_its_row() {
let rows = complete("matrix3");
assert_eq!(rows.len(), 1, "{:?}", rows);
assert_eq!(rows[0].names, "matrix3{1…9} (columns)");
assert_eq!(rows[0].action, Action::Step("matrix3".into()));
assert!(complete("matrix0").is_empty());
}
#[test]
fn mode_commands_are_listed() {
let rows = complete("f");
assert_eq!(rows[0].names, "f[ree], F (free cursor)");
assert_eq!(rows[0].symbol, "[^F]");
assert_eq!(rows[0].commit(), Some("free"));
let rows = complete("blocksel");
assert!(
rows.iter().any(|r| r.commit() == Some("blockselect")),
"{:?}",
rows
);
}
#[test]
fn a_hint_family_is_never_truncated() {
let rows = complete("lr");
for want in [
"none", "vert", "lceil", "lfloor", "langle", "lbrace", "lbrack", "lparen",
] {
assert!(
rows.iter()
.any(|i| i.step_to().is_some_and(|s| s == format!("lr\\{want}"))),
"lr\\{want} missing: {:?}",
rows.iter().filter_map(|i| i.step_to()).collect::<Vec<_>>()
);
}
}
}