use std::collections::{HashMap, HashSet};
#[cfg(not(target_arch = "wasm32"))]
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
#[cfg(not(target_arch = "wasm32"))]
use std::time::UNIX_EPOCH;
#[cfg(not(target_arch = "wasm32"))]
use std::collections::hash_map::DefaultHasher;
#[cfg(not(target_arch = "wasm32"))]
use std::hash::{Hash, Hasher};
#[cfg(not(target_arch = "wasm32"))]
use std::env;
use rand::{seq::SliceRandom, Rng};
use crate::types::Pos;
use crate::merkle::WordlistTree;
pub fn wordlist_filenames(language: &str, wordlist: &str) -> (String, String) {
match wordlist {
"default" => ("payload.yaml".into(), "cover.yaml".into()),
other => {
let payload = format!("payload_{}.yaml", other);
let cover = format!("cover_{}.yaml", other);
let cover_key = format!("{}/{}", language, cover);
if get_embedded_yaml(&cover_key).is_some() {
return (payload, cover);
}
#[cfg(not(target_arch = "wasm32"))]
{
if find_language_file(language, &cover).is_some() {
return (payload, cover);
}
}
(payload, "cover.yaml".into())
}
}
}
#[allow(dead_code)]
mod language_index {
include!(concat!(env!("OUT_DIR"), "/language_index.rs"));
}
pub fn get_embedded_yaml(path: &str) -> Option<&'static str> {
language_index::get_embedded_yaml(path)
}
pub fn has_embedded_files(language: &str) -> bool {
language_index::has_embedded_files(language)
}
pub fn load_semantics(language: &str) -> Option<crate::generator::semantics::SemanticModel> {
#[cfg(not(target_arch = "wasm32"))]
if std::env::var("GLOSSIA_DISABLE_SEMANTICS").is_ok() {
return None;
}
let content = get_embedded_yaml(&format!("{}/semantics.yaml", language))?;
match crate::generator::semantics::SemanticModel::from_yaml(content) {
Ok(model) if !model.is_empty() => Some(model),
Ok(_) => None,
Err(e) => {
#[cfg(not(target_arch = "wasm32"))]
eprintln!("warning: ignoring {}/semantics.yaml: {}", language, e);
let _ = e;
None
}
}
}
pub fn get_available_languages() -> &'static [&'static str] {
language_index::get_available_languages()
}
pub fn get_available_wordlists(language: &str) -> Vec<String> {
language_index::get_wordlist_profiles(language)
.iter()
.map(|s| s.to_string())
.collect()
}
pub fn default_wordlist(language: &str) -> &'static str {
if let Some(dw) = language_index::get_grammar_default_wordlist(language) {
return dw;
}
let profiles = language_index::get_wordlist_profiles(language);
profiles.first().copied().unwrap_or("default")
}
pub fn get_wordlist_size(language: &str, wordlist: &str) -> usize {
language_index::get_payload_word_count(language, wordlist)
}
#[derive(serde::Serialize, serde::Deserialize)]
struct PayloadCacheData {
words: Vec<String>,
pos_mapping: HashMap<String, Vec<Pos>>,
}
static PAYLOAD_CACHE_BY_LANGUAGE: OnceLock<Mutex<HashMap<String, Arc<PayloadCacheData>>>> = OnceLock::new();
#[cfg(not(target_arch = "wasm32"))]
fn glossia_cache_dir() -> PathBuf {
if let Some(p) = std::env::var_os("GLOSSIA_CACHE_DIR") {
return PathBuf::from(p);
}
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
.unwrap_or_else(std::env::temp_dir);
base.join("glossia")
}
#[cfg(not(target_arch = "wasm32"))]
fn file_fingerprint(path: &Path) -> Option<(u64, u64, u32)> {
let md = std::fs::metadata(path).ok()?;
let modified = md.modified().ok()?;
let dur = modified.duration_since(UNIX_EPOCH).ok()?;
Some((md.len(), dur.as_secs(), dur.subsec_nanos()))
}
#[cfg(not(target_arch = "wasm32"))]
fn cache_key_u64_for_language(language: &str, wordlist: &str, payload_path: Option<&Path>, pos_mapping_path: Option<&Path>) -> u64 {
let mut hasher = DefaultHasher::new();
language.hash(&mut hasher);
wordlist.hash(&mut hasher);
env!("CARGO_PKG_VERSION").hash(&mut hasher);
let (payload_filename, _) = wordlist_filenames(language, wordlist);
if let Some(p) = payload_path {
p.to_string_lossy().hash(&mut hasher);
file_fingerprint(p).hash(&mut hasher);
} else {
if let Some(embedded_payload) = get_embedded_yaml(&format!("{}/{}", language, payload_filename)) {
embedded_payload.as_bytes().hash(&mut hasher);
}
}
if let Some(p) = pos_mapping_path {
p.to_string_lossy().hash(&mut hasher);
file_fingerprint(p).hash(&mut hasher);
}
hasher.finish()
}
#[cfg(not(target_arch = "wasm32"))]
fn payload_cache_file_path(language: &str, wordlist: &str, payload_path: Option<&Path>, pos_mapping_path: Option<&Path>) -> PathBuf {
let key = cache_key_u64_for_language(language, wordlist, payload_path, pos_mapping_path);
glossia_cache_dir().join(format!("payload_cache_{language}_{wordlist}_{key:016x}.bin"))
}
fn load_or_build_payload_cache(language: &str, wordlist: &str) -> Result<Arc<PayloadCacheData>, String> {
let cache_key = format!("{}:{}", language, wordlist);
let cache_map = PAYLOAD_CACHE_BY_LANGUAGE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(existing) = cache_map.lock().unwrap().get(&cache_key).cloned() {
return Ok(existing);
}
let (payload_filename, _) = wordlist_filenames(language, wordlist);
#[cfg(not(target_arch = "wasm32"))]
let payload_path: Option<PathBuf> = if has_embedded_files(language) {
None
} else {
Some(PathBuf::from(get_wordlist_path(language, wordlist)?))
};
#[cfg(not(target_arch = "wasm32"))]
let pos_mapping_path: Option<PathBuf> = payload_path
.as_ref()
.and_then(|p| p.parent().map(|d| d.join("pos_mapping.yaml")))
.filter(|p| p.exists());
#[cfg(not(target_arch = "wasm32"))]
{
let cache_file = payload_cache_file_path(language, wordlist, payload_path.as_deref(), pos_mapping_path.as_deref());
if let Ok(bytes) = std::fs::read(&cache_file) {
if let Ok(data) = bincode::deserialize::<PayloadCacheData>(&bytes) {
let arc = Arc::new(data);
cache_map.lock().unwrap().insert(cache_key.clone(), arc.clone());
return Ok(arc);
}
}
}
let yaml_content = if let Some(embedded) = get_embedded_yaml(&format!("{}/{}", language, payload_filename)) {
embedded.to_string()
} else if language != "cs" {
if let Some(embedded) = get_embedded_yaml(&format!("cs/{}", payload_filename)) {
embedded.to_string()
} else {
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(ref p) = payload_path {
std::fs::read_to_string(p).map_err(|e| format!("Failed to read YAML file '{}': {}", p.display(), e))?
} else if let Some(cs_path) = find_language_file("cs", &payload_filename) {
std::fs::read_to_string(&cs_path).map_err(|e| format!("Failed to read YAML file '{}': {}", cs_path, e))?
} else {
return Err(format!("No payload YAML for language '{}', wordlist '{}' (also tried cs/)", language, wordlist));
}
}
#[cfg(target_arch = "wasm32")]
{
return Err(format!("No embedded YAML for language '{}', wordlist '{}'", language, wordlist));
}
}
} else {
#[cfg(not(target_arch = "wasm32"))]
{
let p = payload_path.as_ref().expect("payload_path must exist for non-embedded languages");
std::fs::read_to_string(p).map_err(|e| format!("Failed to read YAML file '{}': {}", p.display(), e))?
}
#[cfg(target_arch = "wasm32")]
{
return Err(format!("No embedded YAML for language '{}', wordlist '{}'", language, wordlist));
}
};
use serde_yaml::Value;
let yaml_value: Value = serde_yaml::from_str(&yaml_content)
.map_err(|e| format!("Failed to parse YAML: {}", e))?;
let mapping = yaml_value.as_mapping()
.ok_or_else(|| "Payload YAML is not a mapping".to_string())?;
let pos_mappings = load_pos_mappings(language);
let mut words: Vec<String> = Vec::with_capacity(mapping.len());
let mut pos_mapping: HashMap<String, Vec<Pos>> = HashMap::with_capacity(mapping.len());
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::with_capacity(mapping.len());
for (key, value) in mapping {
let word = match key.as_str() {
Some(s) => s.to_lowercase(),
None => continue,
};
if word.is_empty() || !seen.insert(word.clone()) {
continue;
}
words.push(word.clone());
let mut pos_tags = Vec::new();
if let Some(pos_map) = value.as_mapping() {
for (pos_key, pos_val) in pos_map {
if let (Some(pos_str), Some(weight)) = (pos_key.as_str(), pos_val.as_f64()) {
if weight > 0.0 {
if let Some(pos) = parse_pos_tag(pos_str, &pos_mappings) {
pos_tags.push(pos);
}
}
}
}
}
if !pos_tags.is_empty() {
pos_mapping.insert(word, pos_tags);
}
}
let data = PayloadCacheData { words, pos_mapping };
#[cfg(not(target_arch = "wasm32"))]
{
let cache_file = payload_cache_file_path(language, wordlist, payload_path.as_deref(), pos_mapping_path.as_deref());
let cache_dir = glossia_cache_dir();
let _ = std::fs::create_dir_all(&cache_dir);
if let Ok(bytes) = bincode::serialize(&data) {
let _ = std::fs::write(&cache_file, bytes);
}
}
let arc = Arc::new(data);
cache_map.lock().unwrap().insert(cache_key, arc.clone());
Ok(arc)
}
fn load_pos_mappings(language: &str) -> HashMap<String, Pos> {
let yaml_content = if let Some(embedded) = get_embedded_yaml(&format!("{}/pos_mapping.yaml", language)) {
embedded.to_string()
} else {
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(pos_mapping_path) = find_language_file(language, "pos_mapping.yaml") {
match std::fs::read_to_string(&pos_mapping_path) {
Ok(content) => content,
Err(_) => return HashMap::new(),
}
} else {
return HashMap::new();
}
}
#[cfg(target_arch = "wasm32")]
{
return HashMap::new();
}
};
#[derive(serde::Deserialize)]
struct PosMappingFile {
mappings: Option<HashMap<String, String>>,
}
let mapping_file: PosMappingFile = match serde_yaml::from_str(&yaml_content) {
Ok(m) => m,
Err(_) => return HashMap::new(),
};
let mut result = HashMap::new();
if let Some(mappings) = mapping_file.mappings {
for (lang_pos, glossia_pos_str) in mappings {
if let Some(pos) = Pos::from_str(&glossia_pos_str) {
result.insert(lang_pos, pos);
}
}
}
result
}
pub fn build_pos_mapping(language: &str) -> Result<HashMap<String, Vec<Pos>>, String> {
build_pos_mapping_for_wordlist(language, default_wordlist(language))
}
pub fn build_pos_mapping_for_wordlist(language: &str, wordlist: &str) -> Result<HashMap<String, Vec<Pos>>, String> {
Ok(load_or_build_payload_cache(language, wordlist)?.pos_mapping.clone())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn build_pos_mapping_from_yaml(path: &str, pos_mappings: &HashMap<String, Pos>) -> Result<HashMap<String, Vec<Pos>>, String> {
let yaml_content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read YAML file '{}': {}", path, e))?;
build_pos_mapping_from_yaml_content(&yaml_content, pos_mappings)
}
pub fn build_pos_mapping_from_yaml_content(yaml_content: &str, pos_mappings: &HashMap<String, Pos>) -> Result<HashMap<String, Vec<Pos>>, String> {
use serde_yaml::Value;
let yaml_data: HashMap<String, HashMap<String, Value>> = serde_yaml::from_str(yaml_content)
.map_err(|e| format!("Failed to parse YAML: {}", e))?;
let mut mapping = HashMap::new();
for (word, pos_weights) in yaml_data {
let word_lower = word.to_lowercase();
let mut pos_tags = Vec::new();
for (pos_str, value) in pos_weights {
if let Some(weight) = value.as_f64() {
if weight > 0.0 {
if let Some(pos) = parse_pos_tag(&pos_str, pos_mappings) {
pos_tags.push(pos);
}
}
}
}
if !word_lower.is_empty() && !pos_tags.is_empty() {
mapping.insert(word_lower, pos_tags);
}
}
Ok(mapping)
}
pub fn parse_pos_tag(pos_str: &str, pos_mappings: &HashMap<String, Pos>) -> Option<Pos> {
let pos_str_trimmed = pos_str.trim();
if let Some(pos) = pos_mappings.get(pos_str_trimmed) {
return Some(*pos);
}
Pos::from_str(pos_str_trimmed)
}
pub fn tag_word(word: &str) -> Vec<Pos> {
static POS_MAP: OnceLock<HashMap<String, Vec<Pos>>> = OnceLock::new();
let mapping = POS_MAP.get_or_init(|| {
build_pos_mapping("english").unwrap_or_else(|_| HashMap::new())
});
let word_lower = word.to_lowercase();
mapping.get(&word_lower).cloned().unwrap_or_default()
}
pub fn load_payload_words(language: &str) -> Result<Vec<String>, String> {
load_payload_words_for_wordlist(language, default_wordlist(language))
}
pub fn load_payload_words_for_wordlist(language: &str, wordlist: &str) -> Result<Vec<String>, String> {
Ok(load_or_build_payload_cache(language, wordlist)?.words.clone())
}
pub fn inject_scale_payload(language: &str, wordlist_name: &str, mut words: Vec<String>) -> Result<(), String> {
let cache_key = format!("{}:{}", language, wordlist_name);
let cache_map = PAYLOAD_CACHE_BY_LANGUAGE.get_or_init(|| Mutex::new(HashMap::new()));
let mut pos_mapping: HashMap<String, Vec<Pos>> = HashMap::with_capacity(words.len());
for word in &words {
pos_mapping.insert(word.to_lowercase(), vec![Pos::N]);
}
words.sort();
words.dedup();
let data = PayloadCacheData { words, pos_mapping };
cache_map.lock().unwrap().insert(cache_key, Arc::new(data));
Ok(())
}
pub fn load_payload_words_from_embedded(language: &str) -> Result<Vec<String>, String> {
let (payload_filename, _) = wordlist_filenames(language, default_wordlist(language));
let payload_yaml = get_embedded_yaml(&format!("{}/{}", language, payload_filename))
.ok_or_else(|| format!("No embedded file for language: {}", language))?;
load_payload_words_from_yaml_content(payload_yaml)
}
pub fn load_payload_words_from_yaml_content(yaml_content: &str) -> Result<Vec<String>, String> {
use serde_yaml::Value;
let yaml_value: Value = serde_yaml::from_str(yaml_content)
.map_err(|e| format!("Failed to parse YAML: {}", e))?;
let mapping = yaml_value.as_mapping()
.ok_or_else(|| "Payload YAML is not a mapping".to_string())?;
let mut words: Vec<String> = Vec::with_capacity(mapping.len());
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::with_capacity(mapping.len());
for (key, _) in mapping {
if let Some(s) = key.as_str() {
let word = s.to_lowercase();
if !word.is_empty() && seen.insert(word.clone()) {
words.push(word);
}
}
}
Ok(words)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn load_payload_words_from_yaml(path: &str) -> Result<Vec<String>, String> {
let yaml_content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read YAML file '{}': {}", path, e))?;
load_payload_words_from_yaml_content(&yaml_content)
}
pub fn load_payload_tree(language: &str) -> Result<WordlistTree, String> {
let words = load_payload_words(language)?;
Ok(WordlistTree::new(words))
}
pub fn load_cover_words_by_pos(wordlist_set: &HashSet<String>, language: &str) -> (HashMap<Pos, Vec<String>>, HashMap<(Pos, String), Vec<String>>) {
load_cover_words_by_pos_for_wordlist(wordlist_set, language, default_wordlist(language))
}
pub fn load_cover_words_by_pos_for_wordlist(wordlist_set: &HashSet<String>, language: &str, wordlist: &str) -> (HashMap<Pos, Vec<String>>, HashMap<(Pos, String), Vec<String>>) {
let (_, cover_filename) = wordlist_filenames(language, wordlist);
let pos_mappings = load_pos_mappings(language);
let yaml_content = if let Some(embedded) = get_embedded_yaml(&format!("{}/{}", language, cover_filename)) {
embedded.to_string()
} else {
#[cfg(not(target_arch = "wasm32"))]
{
let cover_yaml_path = find_language_file(language, &cover_filename)
.unwrap_or_else(|| {
let languages_dir = find_languages_dir()
.unwrap_or_else(|| "languages".to_string());
format!("{}/{}/{}", languages_dir, language, cover_filename)
});
std::fs::read_to_string(&cover_yaml_path)
.unwrap_or_else(|e| {
panic!("Error: Failed to read {} from '{}': {}", cover_filename, cover_yaml_path, e);
})
}
#[cfg(target_arch = "wasm32")]
{
panic!("No embedded cover file for language '{}', wordlist '{}'", language, wordlist);
}
};
use serde_yaml::Value;
let yaml_data: HashMap<String, Value> = serde_yaml::from_str(&yaml_content)
.unwrap_or_else(|e| {
panic!("Error: Failed to parse cover.yaml as YAML: {}", e);
});
let mut by_pos: HashMap<Pos, Vec<String>> = HashMap::new();
let mut refined_cover: HashMap<(Pos, String), Vec<String>> = HashMap::new();
for (word, value) in &yaml_data {
let word_lower = word.to_lowercase();
if wordlist_set.contains(&word_lower) {
continue;
}
let mapping = match value.as_mapping() {
Some(m) => m,
None => continue,
};
let refinement = mapping.get("refinement")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
for (key, val) in mapping {
let pos_str = match key.as_str() {
Some(s) if s != "refinement" => s,
_ => continue,
};
let weight = match val.as_f64() {
Some(w) if w > 0.0 => w,
_ => continue,
};
if weight > 0.0 {
if let Some(pos) = parse_pos_tag(pos_str, &pos_mappings) {
by_pos.entry(pos).or_insert_with(Vec::new).push(word.clone());
if let Some(ref tag) = refinement {
refined_cover.entry((pos, tag.clone()))
.or_insert_with(Vec::new)
.push(word.clone());
}
}
}
}
}
for words in by_pos.values_mut() {
words.sort();
words.dedup();
}
for words in refined_cover.values_mut() {
words.sort();
words.dedup();
}
(by_pos, refined_cover)
}
pub fn load_cover_words_in_file_order(language: &str) -> Vec<String> {
load_cover_words_in_file_order_for_wordlist(language, default_wordlist(language))
}
pub fn load_cover_words_in_file_order_for_wordlist(language: &str, wordlist: &str) -> Vec<String> {
let (_, cover_filename) = wordlist_filenames(language, wordlist);
let yaml_content = if let Some(embedded) = get_embedded_yaml(&format!("{}/{}", language, cover_filename)) {
embedded.to_string()
} else {
#[cfg(not(target_arch = "wasm32"))]
{
let cover_yaml_path = find_language_file(language, &cover_filename)
.unwrap_or_else(|| {
let languages_dir = find_languages_dir()
.unwrap_or_else(|| "languages".to_string());
format!("{}/{}/{}", languages_dir, language, cover_filename)
});
std::fs::read_to_string(&cover_yaml_path)
.unwrap_or_else(|e| {
panic!("Error: Failed to read {} from '{}': {}", cover_filename, cover_yaml_path, e);
})
}
#[cfg(target_arch = "wasm32")]
{
panic!("No embedded cover file for language '{}', wordlist '{}'", language, wordlist);
}
};
use serde_yaml::Value;
let value: Value = serde_yaml::from_str(&yaml_content)
.unwrap_or_else(|e| {
panic!("Error: Failed to parse cover.yaml as YAML Value: {}", e);
});
let mut words = Vec::new();
if let Value::Mapping(mapping) = value {
for (key, _) in mapping {
if let Value::String(word) = key {
words.push(word);
}
}
}
words
}
pub fn load_cover_tree(language: &str) -> WordlistTree {
let words = load_cover_words_in_file_order(language);
WordlistTree::new(words)
}
pub fn load_cover_word_pos_tags(language: &str) -> HashMap<String, Vec<Pos>> {
load_cover_word_pos_tags_for_wordlist(language, "default")
}
pub fn load_cover_word_pos_tags_for_wordlist(language: &str, wordlist: &str) -> HashMap<String, Vec<Pos>> {
let (_, cover_filename) = wordlist_filenames(language, wordlist);
let pos_mappings = load_pos_mappings(language);
let yaml_content = if let Some(embedded) = get_embedded_yaml(&format!("{}/{}", language, cover_filename)) {
embedded.to_string()
} else {
#[cfg(not(target_arch = "wasm32"))]
{
let cover_yaml_path = find_language_file(language, &cover_filename)
.unwrap_or_else(|| {
let languages_dir = find_languages_dir()
.unwrap_or_else(|| "languages".to_string());
format!("{}/{}/{}", languages_dir, language, cover_filename)
});
std::fs::read_to_string(&cover_yaml_path)
.unwrap_or_else(|e| {
panic!("Error: Failed to read {} from '{}': {}", cover_filename, cover_yaml_path, e);
})
}
#[cfg(target_arch = "wasm32")]
{
panic!("No embedded cover file for language '{}', wordlist '{}'", language, wordlist);
}
};
use serde_yaml::Value;
let yaml_data: HashMap<String, HashMap<String, Value>> = serde_yaml::from_str(&yaml_content)
.unwrap_or_else(|e| {
panic!("Error: Failed to parse cover.yaml as YAML: {}", e);
});
let mut result: HashMap<String, Vec<Pos>> = HashMap::new();
for (word, pos_weights) in yaml_data {
let word_lower = word.to_lowercase();
let mut pos_tags = Vec::new();
for (pos_str, value) in pos_weights {
let weight = value.as_f64().unwrap_or(0.0);
if weight > 0.0 {
if let Some(pos) = parse_pos_tag(&pos_str, &pos_mappings) {
pos_tags.push(pos);
}
}
}
if !pos_tags.is_empty() {
result.insert(word_lower, pos_tags);
}
}
result
}
pub fn select_random_words<R: Rng>(rng: &mut R, count: usize, language: &str) -> Result<Vec<String>, String> {
let all_words = load_payload_words(language)?;
if all_words.is_empty() || count == 0 {
return Ok(Vec::new());
}
let mut selected = Vec::with_capacity(count);
for _ in 0..count {
selected.push(all_words.choose(rng).unwrap().clone());
}
Ok(selected)
}
#[cfg(not(target_arch = "wasm32"))]
fn find_languages_dir() -> Option<String> {
let probe = "languages/english/payload_bip39.yaml";
if std::path::Path::new(probe).exists() {
return Some("languages".to_string());
}
if let Ok(mut dir) = std::env::current_dir() {
loop {
let candidate = dir.join("languages");
if candidate.join("english/payload_bip39.yaml").exists() {
return Some(candidate.to_string_lossy().to_string());
}
if !dir.pop() {
break;
}
}
}
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let languages_path = exe_dir.join("../share/glossia/languages");
if languages_path.join("english/payload_bip39.yaml").exists() {
return Some(languages_path.to_string_lossy().to_string());
}
let languages_path = exe_dir.join("languages");
if languages_path.join("english/payload_bip39.yaml").exists() {
return Some(languages_path.to_string_lossy().to_string());
}
if exe_dir.ends_with("bin") {
let share_path = exe_dir.join("../share/glossia/languages");
if share_path.join("english/payload_bip39.yaml").exists() {
return Some(share_path.to_string_lossy().to_string());
}
}
}
}
if let Some(home) = std::env::var_os("HOME") {
let cargo_share = std::path::Path::new(&home).join(".cargo/share/glossia/languages");
if cargo_share.join("english/payload_bip39.yaml").exists() {
return Some(cargo_share.to_string_lossy().to_string());
}
}
if let Ok(cargo_home) = std::env::var("CARGO_HOME") {
let cargo_share = std::path::Path::new(&cargo_home).join("share/glossia/languages");
if cargo_share.join("english/payload_bip39.yaml").exists() {
return Some(cargo_share.to_string_lossy().to_string());
}
}
None
}
#[cfg(not(target_arch = "wasm32"))]
pub fn get_wordlist_path(language: &str, wordlist: &str) -> Result<String, String> {
if has_embedded_files(language) {
return Err(format!("Language '{}' uses embedded files and should not call get_wordlist_path", language));
}
let (payload_filename, _) = wordlist_filenames(language, wordlist);
let languages_dir = find_languages_dir()
.ok_or_else(|| format!("Could not find languages directory. Please ensure the 'languages' folder is accessible.\nTried: current directory, binary location, ~/.cargo/share/glossia/languages"))?;
if let Some(payload_yaml) = find_language_file(language, &payload_filename) {
return Ok(payload_yaml);
}
let expected_path = format!("{}/{}/{}", languages_dir, language, payload_filename);
Err(format!("Wordlist file not found for language '{}'. Expected: {}\nOnly languages with a payload file are supported.",
language, expected_path))
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn find_language_file(language: &str, filename: &str) -> Option<String> {
let languages_dir = find_languages_dir()?;
let exact_path = format!("{}/{}/{}", languages_dir, language, filename);
if std::path::Path::new(&exact_path).exists() {
return Some(exact_path);
}
let languages_path = std::path::Path::new(&languages_dir);
if let Ok(entries) = std::fs::read_dir(languages_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(found) = find_language_file_recursive(&path, language, filename) {
return Some(found);
}
}
}
}
None
}
#[cfg(not(target_arch = "wasm32"))]
fn find_language_file_recursive(dir: &std::path::Path, language: &str, filename: &str) -> Option<String> {
if let Some(dir_name) = dir.file_name().and_then(|n| n.to_str()) {
if dir_name == language {
let file_path = dir.join(filename);
if file_path.exists() {
return Some(file_path.to_string_lossy().to_string());
}
}
if language.contains('/') {
let lang_path = std::path::Path::new(language);
if dir.ends_with(lang_path) {
let file_path = dir.join(filename);
if file_path.exists() {
return Some(file_path.to_string_lossy().to_string());
}
}
}
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(found) = find_language_file_recursive(&path, language, filename) {
return Some(found);
}
}
}
}
None
}
#[derive(Clone, Debug)]
pub struct DialectMatch {
pub language: String,
pub wordlist: String,
pub dialects: Vec<String>,
pub hits: usize,
pub total: usize,
pub hit_rate: f64,
pub wordlist_size: usize,
}
impl std::fmt::Display for DialectMatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}/{} — {}/{} hits ({:.1}%), wordlist size: {}",
self.language,
self.wordlist,
self.hits,
self.total,
self.hit_rate * 100.0,
self.wordlist_size,
)
}
}
#[derive(Clone, Debug, Default)]
pub struct DialectFilter {
min_hit_rate: f64,
languages: HashSet<String>,
wordlists: HashSet<(String, String)>,
}
impl DialectFilter {
pub fn new() -> Self {
Self::default()
}
pub fn min_hit_rate(mut self, min_hit_rate: f64) -> Self {
self.min_hit_rate = min_hit_rate;
self
}
pub fn allow_language(mut self, language: impl Into<String>) -> Self {
self.languages.insert(language.into());
self
}
pub fn allow_languages<I, S>(mut self, languages: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.languages.extend(languages.into_iter().map(Into::into));
self
}
pub fn allow_wordlist(
mut self,
language: impl Into<String>,
wordlist: impl Into<String>,
) -> Self {
self.wordlists.insert((language.into(), wordlist.into()));
self
}
pub fn allow_wordlists<I, L, W>(mut self, wordlists: I) -> Self
where
I: IntoIterator<Item = (L, W)>,
L: Into<String>,
W: Into<String>,
{
self.wordlists
.extend(wordlists.into_iter().map(|(l, w)| (l.into(), w.into())));
self
}
fn has_allowlist(&self) -> bool {
!self.languages.is_empty() || !self.wordlists.is_empty()
}
fn allows(&self, language: &str, wordlist: &str) -> bool {
if !self.has_allowlist() {
return true;
}
if self.languages.contains(language) {
return true;
}
self.wordlists
.iter()
.any(|(l, w)| l == language && w == wordlist)
}
}
fn binary_search_sorted_words(sorted_text: &str, word: &str) -> bool {
let bytes = sorted_text.as_bytes();
if bytes.is_empty() {
return false;
}
let mut lo: usize = 0;
let mut hi: usize = bytes.len();
while lo < hi {
let mid = lo + (hi - lo) / 2;
let line_start = if mid == 0 || bytes[mid - 1] == b'\n' {
mid
} else {
let mut pos = mid;
while pos > lo && bytes[pos - 1] != b'\n' {
pos -= 1;
}
pos
};
let mut line_end = line_start;
while line_end < bytes.len() && bytes[line_end] != b'\n' {
line_end += 1;
}
let line = &sorted_text[line_start..line_end];
match line.cmp(word) {
std::cmp::Ordering::Equal => return true,
std::cmp::Ordering::Less => lo = line_end + 1,
std::cmp::Ordering::Greater => {
if line_start == 0 {
return false;
}
hi = line_start;
}
}
}
false
}
pub fn detect_dialect(input_words: &[String]) -> Vec<DialectMatch> {
detect_dialect_with(input_words, &DialectFilter::new())
}
pub fn detect_dialect_filtered(input_words: &[String], min_hit_rate: f64) -> Vec<DialectMatch> {
detect_dialect_with(input_words, &DialectFilter::new().min_hit_rate(min_hit_rate))
}
pub fn detect_dialect_with(input_words: &[String], filter: &DialectFilter) -> Vec<DialectMatch> {
use crate::grammar::DialectConfig;
if input_words.is_empty() {
return Vec::new();
}
let normalized: Vec<String> = input_words
.iter()
.map(|w| w.trim_end_matches('.').trim_end_matches(',').to_lowercase())
.filter(|w| !w.is_empty())
.collect();
let total = normalized.len();
let mut input_counts: HashMap<&str, usize> = HashMap::new();
for w in &normalized {
*input_counts.entry(w.as_str()).or_insert(0) += 1;
}
let mut results: Vec<DialectMatch> = Vec::new();
let languages = get_available_languages();
for &lang in languages {
let wordlists = get_available_wordlists(lang);
for wl_name in &wordlists {
if !filter.allows(lang, wl_name) {
continue;
}
let sorted_words = match language_index::get_payload_word_index(lang, wl_name) {
Some(w) => w,
None => continue,
};
let mut hits = 0usize;
for (word, &count) in &input_counts {
if binary_search_sorted_words(sorted_words, word) {
hits += count;
}
}
if hits > 0 {
let hit_rate = hits as f64 / total as f64;
if hit_rate < filter.min_hit_rate {
continue;
}
let wordlist_size = language_index::get_payload_word_count(lang, wl_name);
let dialects = DialectConfig::available_dialects(lang);
results.push(DialectMatch {
language: lang.to_string(),
wordlist: wl_name.clone(),
dialects,
hits,
total,
hit_rate,
wordlist_size,
});
}
}
}
results.sort_by(|a, b| {
b.hits.cmp(&a.hits)
.then_with(|| b.hit_rate.partial_cmp(&a.hit_rate).unwrap_or(std::cmp::Ordering::Equal))
.then_with(|| a.wordlist_size.cmp(&b.wordlist_size))
});
results
}
pub fn detect_dialect_best(input_words: &[String]) -> Option<DialectMatch> {
detect_dialect(input_words).into_iter().next()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use crate::types::Pos;
#[test]
fn test_cover_yaml_loads_refinements() {
let wordlist_set: HashSet<String> = HashSet::new();
let (by_pos, refined_cover) = load_cover_words_by_pos(&wordlist_set, "english");
let def_dets = refined_cover.get(&(Pos::Det, "def".to_string()));
assert!(def_dets.is_some(), "Should have refined_cover entry for (Det, def)");
let def_dets = def_dets.unwrap();
assert!(def_dets.contains(&"the".to_string()), "Det[def] should contain 'the'");
assert!(def_dets.contains(&"its".to_string()), "Det[def] should contain 'its'");
assert!(def_dets.contains(&"our".to_string()), "Det[def] should contain 'our'");
let indef_dets = refined_cover.get(&(Pos::Det, "indef".to_string()));
assert!(indef_dets.is_some(), "Should have refined_cover entry for (Det, indef)");
let indef_dets = indef_dets.unwrap();
assert!(indef_dets.contains(&"a".to_string()), "Det[indef] should contain 'a'");
assert!(indef_dets.contains(&"an".to_string()), "Det[indef] should contain 'an'");
let sg_cops = refined_cover.get(&(Pos::Cop, "sg".to_string()));
assert!(sg_cops.is_some(), "Should have refined_cover entry for (Cop, sg)");
let sg_cops = sg_cops.unwrap();
assert!(sg_cops.contains(&"is".to_string()), "Cop[sg] should contain 'is'");
let pl_cops = refined_cover.get(&(Pos::Cop, "pl".to_string()));
assert!(pl_cops.is_some(), "Should have refined_cover entry for (Cop, pl)");
let pl_cops = pl_cops.unwrap();
assert!(pl_cops.contains(&"are".to_string()), "Cop[pl] should contain 'are'");
let quant_dets = refined_cover.get(&(Pos::Det, "quant".to_string()));
assert!(quant_dets.is_some(), "Should have refined_cover entry for (Det, quant)");
let quant_dets = quant_dets.unwrap();
assert!(quant_dets.contains(&"each".to_string()), "Det[quant] should contain 'each'");
assert!(quant_dets.contains(&"every".to_string()), "Det[quant] should contain 'every'");
assert!(quant_dets.contains(&"some".to_string()), "Det[quant] should contain 'some'");
let all_dets = by_pos.get(&Pos::Det);
assert!(all_dets.is_some(), "by_pos should have Det");
let all_dets = all_dets.unwrap();
assert!(all_dets.contains(&"the".to_string()), "by_pos[Det] should contain 'the'");
assert!(all_dets.contains(&"a".to_string()), "by_pos[Det] should contain 'a'");
}
#[test]
fn test_is_and_are_load_as_cop() {
let wordlist_set: HashSet<String> = HashSet::new();
let (by_pos, _) = load_cover_words_by_pos(&wordlist_set, "english");
let cop_words = by_pos.get(&Pos::Cop);
assert!(cop_words.is_some(), "Should have Cop words");
let cop_words = cop_words.unwrap();
assert!(cop_words.contains(&"is".to_string()), "'is' should be in Cop");
assert!(cop_words.contains(&"are".to_string()), "'are' should be in Cop");
if let Some(v_words) = by_pos.get(&Pos::V) {
assert!(!v_words.contains(&"is".to_string()), "'is' should NOT be in V");
assert!(!v_words.contains(&"are".to_string()), "'are' should NOT be in V");
}
}
#[test]
fn test_words_without_refinement_not_in_refined_cover() {
let wordlist_set: HashSet<String> = HashSet::new();
let (by_pos, refined_cover) = load_cover_words_by_pos(&wordlist_set, "english");
let adj_words = by_pos.get(&Pos::Adj);
assert!(adj_words.is_some(), "Should have Adj words");
let adj_words = adj_words.unwrap();
assert!(adj_words.contains(&"bad".to_string()), "'bad' should be in by_pos[Adj]");
let in_refined = refined_cover.iter().any(|(_, words)| {
words.contains(&"bad".to_string())
});
assert!(!in_refined, "'bad' should NOT appear in refined_cover (no refinement tag)");
}
#[test]
fn test_cover_words_exclude_payload_words() {
let mut wordlist_set: HashSet<String> = HashSet::new();
wordlist_set.insert("aid".to_string());
let (by_pos, _) = load_cover_words_by_pos(&wordlist_set, "english");
for (_, words) in &by_pos {
assert!(!words.contains(&"aid".to_string()),
"'aid' should be excluded from cover words when it is in the wordlist set");
}
}
#[test]
fn test_load_payload_words_english() {
let words = load_payload_words("english").expect("Should load English payload words");
assert!(!words.is_empty(), "English payload words should not be empty");
assert!(words.contains(&"abandon".to_string()), "Should contain 'abandon'");
let mut sorted = words.clone();
sorted.sort();
assert_eq!(words, sorted, "Payload words should be sorted");
}
#[test]
fn test_build_pos_mapping_english() {
let mapping = build_pos_mapping("english").expect("Should build English POS mapping");
assert!(!mapping.is_empty(), "POS mapping should not be empty");
let abandon_pos = mapping.get("abandon");
assert!(abandon_pos.is_some(), "'abandon' should have POS tags");
let abandon_pos = abandon_pos.unwrap();
assert!(!abandon_pos.is_empty(), "'abandon' should have at least one POS tag");
}
#[test]
fn test_pos_from_str_all_variants() {
for pos in Pos::ALL {
let s = pos.as_str();
assert_eq!(Pos::from_str(s), Some(*pos), "round-trip failed for {:?}", pos);
}
assert_eq!(Pos::from_str("Unknown"), None);
}
#[test]
fn test_detect_dialect_bip39_words() {
let words: Vec<String> = "abandon ability able about above"
.split_whitespace()
.map(|s| s.to_string())
.collect();
let matches = detect_dialect(&words);
assert!(!matches.is_empty(), "Should detect at least one dialect for BIP39 words");
let best = &matches[0];
assert_eq!(best.language, "english", "Best match should be English");
assert_eq!(best.hits, 5, "All 5 words should be hits");
assert!((best.hit_rate - 1.0).abs() < 0.001, "Hit rate should be 1.0");
}
#[test]
fn test_detect_dialect_hit_rate_length_invariant() {
let base = ["abandon", "ability", "able"];
let short: Vec<String> = base.iter().map(|s| s.to_string()).collect();
let long: Vec<String> = base
.iter()
.cycle()
.take(base.len() * 20)
.map(|s| s.to_string())
.collect();
let short_best = detect_dialect_best(&short).expect("short input should detect");
let long_best = detect_dialect_best(&long).expect("long input should detect");
assert!(
(short_best.hit_rate - 1.0).abs() < 1e-9,
"short hit_rate should be 1.0, got {}",
short_best.hit_rate
);
assert!(
(long_best.hit_rate - 1.0).abs() < 1e-9,
"long hit_rate should be 1.0, got {} — metric decayed with length",
long_best.hit_rate
);
assert_eq!(long_best.hits, base.len() * 20);
assert_eq!(long_best.total, base.len() * 20);
}
#[test]
fn test_detect_dialect_empty_input() {
let words: Vec<String> = Vec::new();
let matches = detect_dialect(&words);
assert!(matches.is_empty(), "Empty input should return no matches");
}
#[test]
fn test_detect_dialect_no_matches() {
let words: Vec<String> = "xyzzyplugh fnord"
.split_whitespace()
.map(|s| s.to_string())
.collect();
let matches = detect_dialect(&words);
assert!(matches.is_empty(), "Nonsense words should return no matches");
}
#[test]
fn test_detect_dialect_filtered_drops_low_hit_rate() {
let words: Vec<String> = "abandon ability able xyzzyplugh"
.split_whitespace()
.map(|s| s.to_string())
.collect();
let unfiltered = detect_dialect_filtered(&words, 0.0);
assert_eq!(unfiltered.len(), detect_dialect(&words).len());
let filtered = detect_dialect_filtered(&words, 0.5);
assert!(!filtered.is_empty(), "Strong match should survive filtering");
for m in &filtered {
assert!(
m.hit_rate >= 0.5,
"filtered result {} has hit_rate {} below threshold",
m, m.hit_rate
);
}
let none = detect_dialect_filtered(&words, 1.01);
assert!(none.is_empty(), "hit_rate can't exceed 1.0, so all should be dropped");
}
#[test]
fn test_detect_dialect_with_language_allowlist() {
let words: Vec<String> = "abandon ability able about above"
.split_whitespace()
.map(|s| s.to_string())
.collect();
let all = detect_dialect_with(&words, &DialectFilter::new());
assert_eq!(all.len(), detect_dialect(&words).len());
assert!(all.iter().any(|m| m.language == "english"));
let only_english = detect_dialect_with(&words, &DialectFilter::new().allow_language("english"));
assert!(!only_english.is_empty(), "english should still match BIP39 words");
assert!(
only_english.iter().all(|m| m.language == "english"),
"language allowlist must exclude non-english matches"
);
}
#[test]
fn test_detect_dialect_with_wordlist_allowlist_union() {
let words: Vec<String> = "abandon ability able about above"
.split_whitespace()
.map(|s| s.to_string())
.collect();
let filter = DialectFilter::new().allow_wordlist("english", "bip39");
let matches = detect_dialect_with(&words, &filter);
assert!(!matches.is_empty(), "english/bip39 should match BIP39 words");
assert!(
matches.iter().all(|m| m.language == "english" && m.wordlist == "bip39"),
"wordlist allowlist must restrict to the exact (language, wordlist) pair"
);
let union = detect_dialect_with(
&words,
&DialectFilter::new()
.allow_language("english")
.allow_wordlist("latin", "default"),
);
assert!(
union.iter().all(|m| m.language == "english" || (m.language == "latin" && m.wordlist == "default")),
"union allowlist should admit english (any wordlist) and latin/default only"
);
}
#[test]
fn test_detect_dialect_mixed_payload_and_cover() {
let words: Vec<String> = "the abandon is zoo"
.split_whitespace()
.map(|s| s.to_string())
.collect();
let matches = detect_dialect(&words);
assert!(!matches.is_empty(), "Should detect dialect from mixed words");
let best = &matches[0];
assert_eq!(best.language, "english");
assert!(best.hits >= 2, "Should have at least 2 payload hits, got {}", best.hits);
}
#[test]
fn test_detect_dialect_strips_punctuation() {
let words: Vec<String> = "abandon. ability, able"
.split_whitespace()
.map(|s| s.to_string())
.collect();
let matches = detect_dialect(&words);
assert!(!matches.is_empty(), "Should detect dialect despite punctuation");
let best = &matches[0];
assert_eq!(best.hits, 3, "All 3 words should match after stripping punctuation");
}
#[test]
fn test_detect_dialect_best_convenience() {
let words: Vec<String> = "abandon ability"
.split_whitespace()
.map(|s| s.to_string())
.collect();
let best = detect_dialect_best(&words);
assert!(best.is_some(), "Should detect best dialect");
assert_eq!(best.unwrap().language, "english");
}
#[test]
fn test_detect_dialect_includes_dialects() {
let words: Vec<String> = "abandon ability able"
.split_whitespace()
.map(|s| s.to_string())
.collect();
let matches = detect_dialect(&words);
let best = &matches[0];
assert!(best.dialects.contains(&"body".to_string()), "Should include body dialect");
assert!(best.dialects.contains(&"subject".to_string()), "Should include subject dialect");
}
#[test]
fn test_binary_search_sorted_words() {
let text = "apple\nbanana\ncherry\ndate\nelderberry";
assert!(binary_search_sorted_words(text, "apple"), "should find first word");
assert!(binary_search_sorted_words(text, "cherry"), "should find middle word");
assert!(binary_search_sorted_words(text, "elderberry"), "should find last word");
assert!(binary_search_sorted_words(text, "banana"), "should find second word");
assert!(binary_search_sorted_words(text, "date"), "should find fourth word");
assert!(!binary_search_sorted_words(text, "aaa"), "should not find word before first");
assert!(!binary_search_sorted_words(text, "zzz"), "should not find word after last");
assert!(!binary_search_sorted_words(text, "car"), "should not find word between entries");
assert!(!binary_search_sorted_words(text, ""), "should not find empty string");
assert!(!binary_search_sorted_words("", "apple"), "empty text should find nothing");
assert!(binary_search_sorted_words("solo", "solo"), "single word should be found");
assert!(!binary_search_sorted_words("solo", "other"), "single word should reject others");
}
#[test]
fn test_detect_dialect_uses_precomputed_index() {
let words: Vec<String> = "abandon ability able about above"
.split_whitespace()
.map(|s| s.to_string())
.collect();
let matches = detect_dialect(&words);
let best = &matches[0];
assert_eq!(best.language, "english");
assert_eq!(best.wordlist, "bip39");
assert_eq!(best.wordlist_size, 2048, "wordlist_size should be exact (2048), not an estimate");
}
}