use crate::catalog::{Catalog, CatalogEntry, Kind, Locale};
use crate::core::error::{Result, WorkshopError};
use crate::core::suggest;
use crate::settings::{self, PathPart, SettingDefinition, SettingValueDomain, table};
const INLINE_MEMBER_LIMIT: usize = 32;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum LookupMatch {
Builtin {
kind: Kind,
id: String,
display_name: Option<String>,
signature: Option<Signature>,
},
EnumMember {
domain: String,
member: String,
display_name: Option<String>,
},
EnumDomain {
domain: String,
display_name: Option<String>,
members: Vec<LookupEnumMember>,
},
Setting {
definition: SettingDefinition,
display_name: String,
},
Parameter {
callable: String,
position: usize,
required: bool,
param: SignatureParam,
domain: Option<SignatureDomain>,
},
SettingPath {
path: String,
segment: String,
},
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct LookupEnumMember {
pub id: String,
pub display_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct SignatureDomain {
pub domain: String,
pub members: Vec<LookupEnumMember>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Signature {
pub text: String,
pub params: Vec<SignatureParam>,
pub required_params: usize,
pub variadic: bool,
pub return_type: Option<String>,
pub domains: Vec<SignatureDomain>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct SignatureParam {
pub name: String,
pub param_type: Option<String>,
pub domain: Option<String>,
pub default: Option<String>,
}
impl Catalog {
pub fn lookup(&self, locale: &Locale, query: &str) -> Result<Vec<LookupMatch>> {
if !self.supports(locale) {
return Err(WorkshopError::unsupported(
format!(
"lookup for locale '{locale}' is not supported: \
the catalog does not declare it"
),
None,
));
}
let primary = self.primary_locale();
let distinct_locale = locale != primary;
let prepared = PreparedQuery::new(query);
let query_segments: Vec<&str> = query.split('.').collect();
let mut matches: Vec<(u32, LookupMatch)> = Vec::new();
for entry in &self.entries {
let primary_spellings: &[String] = if distinct_locale {
entry.spellings(primary)
} else {
&[]
};
let score = std::iter::once(entry.id.as_str())
.chain(entry.spellings(locale).iter().map(String::as_str))
.chain(primary_spellings.iter().map(String::as_str))
.filter_map(|text| prepared.name_score(text))
.min();
if let Some(score) = score {
matches.push((
score,
LookupMatch::Builtin {
kind: entry.kind,
id: entry.id.clone(),
display_name: entry.spelling(locale).map(String::from),
signature: signature(self, entry, locale),
},
));
}
}
for domain in self.enum_domains() {
let score = std::iter::once(domain.domain.as_str())
.chain(domain.spelling(locale))
.chain(domain.spelling(primary).filter(|_| distinct_locale))
.filter_map(|text| prepared.name_score(text))
.min();
if let Some(score) = score {
matches.push((
score,
LookupMatch::EnumDomain {
domain: domain.domain.clone(),
display_name: domain.spelling(locale).map(String::from),
members: self.enum_members(&domain.domain, locale),
},
));
}
}
for domain in self.enum_domains() {
for member in &domain.members {
let qualified = format!("{}.{}", domain.domain, member.member);
let primary_spellings: &[String] = if distinct_locale {
member.spellings(primary)
} else {
&[]
};
let score = std::iter::once(member.member.as_str())
.chain(std::iter::once(qualified.as_str()))
.chain(member.spellings(locale).iter().map(String::as_str))
.chain(primary_spellings.iter().map(String::as_str))
.filter_map(|text| prepared.name_score(text))
.min();
if let Some(score) = score {
matches.push((
score,
LookupMatch::EnumMember {
domain: domain.domain.clone(),
member: member.member.clone(),
display_name: self
.enum_spelling(&domain.domain, locale, &member.member)
.map(String::from),
},
));
}
}
}
for definition in settings::definitions() {
let name_texts = [
Some(definition.path().rsplit('.').next().unwrap_or_default()),
Some(definition.presentation().english_name),
definition.presentation().localized_name(locale.as_str()),
definition.id().map(|id| id.as_str()),
Some(definition.path()),
];
let score = name_texts
.into_iter()
.flatten()
.filter_map(|text| prepared.name_score(text))
.min()
.into_iter()
.chain(path_prefix_score(&query_segments, definition.path()))
.min();
if let Some(score) = score {
matches.push((
score,
LookupMatch::Setting {
display_name: definition
.presentation()
.localized_name(locale.as_str())
.unwrap_or(definition.presentation().english_name)
.to_string(),
definition,
},
));
}
}
matches.sort_by_key(|(score, _)| *score);
Ok(matches.into_iter().map(|(_, lookup)| lookup).collect())
}
pub fn lookup_within(
&self,
locale: &Locale,
within: &str,
query: Option<&str>,
) -> Result<Vec<LookupMatch>> {
if !self.supports(locale) {
return Err(WorkshopError::unsupported(
format!(
"lookup for locale '{locale}' is not supported: \
the catalog does not declare it"
),
None,
));
}
let prepared = query
.filter(|query| !query.is_empty())
.map(PreparedQuery::new);
let mut scored = self
.within_enum_domain(locale, within, prepared.as_ref())
.or_else(|| self.within_settings_enum(locale, within, prepared.as_ref()))
.or_else(|| self.within_callable(locale, within, prepared.as_ref()))
.or_else(|| within_settings(within, locale, prepared.as_ref()))
.ok_or_else(|| WorkshopError::unknown("lookup scope", within, locale.clone(), None))?;
if prepared.is_some() {
scored.retain(|(score, _)| *score != u32::MAX);
scored.sort_by_key(|(score, _)| *score);
}
Ok(scored.into_iter().map(|(_, lookup)| lookup).collect())
}
fn within_enum_domain(
&self,
locale: &Locale,
value: &str,
prepared: Option<&PreparedQuery>,
) -> Option<Vec<(u32, LookupMatch)>> {
let domain = if self.enum_domain(value).is_some() {
Some(value.to_string())
} else {
self.resolve_enum_domain(locale, value).map(str::to_string)
}?;
let domain = self.enum_domain(&domain)?;
let primary = self.primary_locale();
let distinct_locale = locale != primary;
Some(
domain
.members
.iter()
.map(|member| {
let qualified = format!("{}.{}", domain.domain, member.member);
let mut texts = vec![member.member.as_str(), qualified.as_str()];
texts.extend(member.spellings(locale).iter().map(String::as_str));
if distinct_locale {
texts.extend(member.spellings(primary).iter().map(String::as_str));
}
(
child_score(prepared, texts.into_iter()),
LookupMatch::EnumMember {
domain: domain.domain.clone(),
member: member.member.clone(),
display_name: self
.enum_spelling(&domain.domain, locale, &member.member)
.map(String::from),
},
)
})
.collect(),
)
}
fn within_settings_enum(
&self,
locale: &Locale,
value: &str,
prepared: Option<&PreparedQuery>,
) -> Option<Vec<(u32, LookupMatch)>> {
let mut seen = std::collections::HashSet::new();
let mut members = Vec::new();
for definition in settings::definitions() {
let SettingValueDomain::Enum { domain } = definition.domain() else {
continue;
};
if domain.as_str() != value {
continue;
}
for member in definition.enum_members() {
if !seen.insert((member.domain().to_string(), member.id().to_string())) {
continue;
}
let localized =
table::localized_name(locale.as_str(), "enums", member.english_name());
let qualified = format!("{}.{}", member.domain(), member.id());
members.push((
child_score(
prepared,
[
member.id(),
qualified.as_str(),
member.english_name(),
localized.unwrap_or_default(),
]
.into_iter(),
),
LookupMatch::EnumMember {
domain: member.domain().to_string(),
member: member.id().to_string(),
display_name: Some(localized.unwrap_or(member.english_name()).to_string()),
},
));
}
}
(!members.is_empty()).then_some(members)
}
fn within_callable(
&self,
locale: &Locale,
value: &str,
prepared: Option<&PreparedQuery>,
) -> Option<Vec<(u32, LookupMatch)>> {
let entry = [Kind::Action, Kind::Value].into_iter().find_map(|kind| {
self.entry(kind, value)
.or_else(|| self.resolve(kind, locale, value))
})?;
let signature = signature(self, entry, locale)?;
Some(
signature
.params
.iter()
.enumerate()
.map(|(position, param)| {
let qualified = format!("{}.{}", entry.id, param.name);
(
child_score(
prepared,
[param.name.as_str(), qualified.as_str()].into_iter(),
),
LookupMatch::Parameter {
callable: entry.id.clone(),
position,
required: position < signature.required_params,
param: param.clone(),
domain: param.domain.as_ref().and_then(|name| {
signature
.domains
.iter()
.find(|domain| &domain.domain == name)
.cloned()
}),
},
)
})
.collect(),
)
}
fn enum_members(&self, domain: &str, locale: &Locale) -> Vec<LookupEnumMember> {
self.enum_domain(domain)
.map(|domain| {
domain
.members
.iter()
.map(|member| LookupEnumMember {
id: member.member.clone(),
display_name: self
.enum_spelling(&domain.domain, locale, &member.member)
.map(String::from),
})
.collect()
})
.unwrap_or_default()
}
}
fn signature(catalog: &Catalog, entry: &CatalogEntry, locale: &Locale) -> Option<Signature> {
if !matches!(entry.kind, Kind::Action | Kind::Value) {
return None;
}
let params: Vec<SignatureParam> = (0..entry.param_count())
.map(|index| SignatureParam {
name: entry.param_name(index).unwrap_or_default().to_string(),
param_type: entry.param_type(index).map(String::from),
domain: entry.param_domain(index).map(String::from),
default: entry.param_default(index).map(String::from),
})
.collect();
let mut domains = Vec::new();
for param in ¶ms {
let Some(domain) = ¶m.domain else {
continue;
};
if domains
.iter()
.any(|known: &SignatureDomain| known.domain == *domain)
{
continue;
}
domains.push(SignatureDomain {
domain: domain.clone(),
members: catalog.enum_members(domain, locale),
});
}
Some(Signature {
text: signature_text(catalog, entry, locale, ¶ms),
params,
required_params: entry.required_param_count(),
variadic: entry.is_variadic(),
return_type: entry.return_type().map(String::from),
domains,
})
}
fn signature_text(
catalog: &Catalog,
entry: &CatalogEntry,
locale: &Locale,
params: &[SignatureParam],
) -> String {
let display = entry.spelling(locale).unwrap_or(entry.id.as_str());
let mut listed_domains = std::collections::HashSet::new();
let mut rendered: Vec<String> = Vec::with_capacity(params.len());
for param in params {
rendered.push(match ¶m.default {
Some(default) if default == "null" => format!("{}?", param.name),
Some(default) => format!(
"{}={}",
param.name,
render_default(catalog, locale, default)
),
None => match ¶m.domain {
Some(domain) => {
let Some(domain_entry) = catalog.enum_domain(domain) else {
match ¶m.param_type {
Some(param_type) => {
rendered.push(format!("{}: {}", param.name, param_type));
}
None => rendered.push(param.name.clone()),
}
continue;
};
let domain_display = domain_entry
.spelling(locale)
.unwrap_or(domain_entry.domain.as_str());
if !listed_domains.insert(domain.clone()) {
format!("{}: {}", param.name, domain_display)
} else if domain_entry.members.len() <= INLINE_MEMBER_LIMIT {
let members = domain_entry
.members
.iter()
.map(|member| {
catalog
.enum_spelling(&domain_entry.domain, locale, &member.member)
.unwrap_or(member.member.as_str())
})
.collect::<Vec<_>>()
.join("|");
format!("{}: {}({})", param.name, domain_display, members)
} else {
format!(
"{}: {}({} members)",
param.name,
domain_display,
domain_entry.members.len()
)
}
}
None => match ¶m.param_type {
Some(param_type) => format!("{}: {}", param.name, param_type),
None => param.name.clone(),
},
},
});
}
if entry.is_variadic() {
rendered.push("...".to_string());
}
format!("{}({})", display, rendered.join(", "))
}
fn render_default(catalog: &Catalog, locale: &Locale, default: &str) -> String {
if let Some((domain, member)) = default.split_once('.') {
if let Some(member_spelling) = catalog.enum_spelling(domain, locale, member) {
return catalog.enum_member_form(domain, member_spelling, locale);
}
}
catalog
.spelling(Kind::Value, locale, default)
.unwrap_or(default)
.to_string()
}
fn child_score<'a>(prepared: Option<&PreparedQuery>, texts: impl Iterator<Item = &'a str>) -> u32 {
prepared
.and_then(|prepared| texts.filter_map(|text| prepared.name_score(text)).min())
.unwrap_or(u32::MAX)
}
fn declared_segment_accepts(declared: PathPart<'_>, asked: &str) -> bool {
match declared {
PathPart::Part(name) => name == asked,
PathPart::Team => asked == "<team>" || table::team_name(asked).is_some(),
PathPart::Hero => asked == "<hero>" || table::hero_name(asked).is_some(),
}
}
fn within_settings(
prefix: &str,
locale: &Locale,
prepared: Option<&PreparedQuery>,
) -> Option<Vec<(u32, LookupMatch)>> {
let prefix_segments: Vec<&str> = if prefix.is_empty() {
Vec::new()
} else {
prefix.split('.').collect()
};
let mut seen = std::collections::HashSet::new();
let mut children = Vec::new();
let mut known = prefix.is_empty();
for definition in settings::definitions() {
let parts = definition.path_parts();
if parts.len() < prefix_segments.len() {
continue;
}
let matches_prefix = prefix_segments
.iter()
.zip(parts.iter())
.all(|(asked, declared)| declared_segment_accepts(*declared, asked));
if !matches_prefix {
continue;
}
known = true;
if parts.len() == prefix_segments.len() {
continue;
}
let segment = match &parts[prefix_segments.len()] {
PathPart::Part(name) => *name,
PathPart::Team => "<team>",
PathPart::Hero => "<hero>",
};
let child_path = if prefix.is_empty() {
segment.to_string()
} else {
format!("{prefix}.{segment}")
};
if parts.len() == prefix_segments.len() + 1 {
if !seen.insert(definition.path().to_string()) {
continue;
}
let texts = [
Some(definition.path().rsplit('.').next().unwrap_or_default()),
Some(definition.presentation().english_name),
definition.presentation().localized_name(locale.as_str()),
definition.id().map(|id| id.as_str()),
Some(definition.path()),
];
children.push((
child_score(prepared, texts.into_iter().flatten()),
LookupMatch::Setting {
display_name: definition
.presentation()
.localized_name(locale.as_str())
.unwrap_or(definition.presentation().english_name)
.to_string(),
definition,
},
));
} else if seen.insert(child_path.clone()) {
children.push((
child_score(prepared, [segment, child_path.as_str()].into_iter()),
LookupMatch::SettingPath {
path: child_path,
segment: segment.to_string(),
},
));
}
}
known.then_some(children)
}
struct PreparedQuery {
raw: String,
folded: String,
folded_chars: Vec<char>,
tokens: Vec<String>,
max_distance: usize,
}
impl PreparedQuery {
fn new(query: &str) -> Self {
let folded = suggest::fold_loose(query);
let folded_chars: Vec<char> = folded.chars().collect();
Self {
raw: query.to_string(),
max_distance: suggest::max_distance(folded_chars.len()),
folded,
folded_chars,
tokens: query
.split(|character: char| !character.is_alphanumeric())
.map(suggest::fold_loose)
.filter(|token| !token.is_empty())
.collect(),
}
}
fn name_score(&self, candidate: &str) -> Option<u32> {
if self.raw == candidate {
return Some(0);
}
let candidate_folded = fold_candidate(candidate);
if self.folded.is_empty() || candidate_folded.is_empty() {
return None;
}
if self.folded == candidate_folded {
return Some(1);
}
if candidate_folded
.chars()
.count()
.abs_diff(self.folded_chars.len())
<= self.max_distance
{
let candidate_chars: Vec<char> = candidate_folded.chars().collect();
let distance =
suggest::edit_distance(&self.folded_chars, &candidate_chars, self.max_distance);
if distance <= self.max_distance {
return Some(2 + distance as u32);
}
}
if self.folded_chars.len() >= 3 {
if self.token_prefix_match(candidate) {
return Some(6);
}
if candidate_folded.contains(&self.folded) {
return Some(8);
}
}
None
}
fn token_prefix_match(&self, candidate: &str) -> bool {
let candidate_tokens = candidate
.split(|character: char| !character.is_alphanumeric())
.map(fold_candidate)
.filter(|token| !token.is_empty());
!self.tokens.is_empty()
&& self.tokens.iter().all(|token| {
candidate_tokens
.clone()
.any(|candidate| candidate.starts_with(token))
})
}
}
fn fold_candidate(candidate: &str) -> String {
if candidate.is_ascii() {
candidate
.bytes()
.filter(u8::is_ascii_alphanumeric)
.map(|byte| char::from(byte.to_ascii_lowercase()))
.collect()
} else {
suggest::fold_loose(candidate)
}
}
fn path_prefix_score(query_segments: &[&str], path: &str) -> Option<u32> {
let mut path_segments = path.split('.');
let mut consumed = 0usize;
for segment in query_segments {
match path_segments.next() {
Some("<team>") | Some("<hero>") => {}
Some(part) if part.eq_ignore_ascii_case(segment) => {}
_ => return None,
}
consumed += 1;
}
let depth = path.matches('.').count() + 1;
Some(2 + (depth - consumed) as u32)
}