use std::collections::HashMap;
use anyhow::{anyhow, Result};
use easy_error::Error as BundError;
use rust_dynamic::value::Value;
use rust_multistackvm::multistackvm::VM;
use super::helpers::{
active_config, active_store, pull, push, require_depth, value_to_i64, value_to_string,
};
use crate::cli::language as langapi;
use crate::conlang::types::TemplateRole;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
use crate::store::Store;
pub fn register(vm: &mut VM) -> Result<()> {
let words: &[(&str, fn(&mut VM) -> std::result::Result<&mut VM, BundError>)] = &[
("ink.lang.list", w_list),
("ink.lang.generate_word", w_generate_word),
("ink.lang.syllabify", w_syllabify),
("ink.lang.ipa", w_ipa),
("ink.lang.stress", w_stress),
("ink.lang.tone", w_tone),
("ink.lang.transliterate", w_transliterate),
("ink.lang.gloss", w_gloss),
("ink.lang.paradigm", w_paradigm),
("ink.lang.derive", w_derive),
("ink.lang.agree", w_agree),
("ink.lang.sentence", w_sentence),
("ink.lang.relative", w_relative),
("ink.lang.complement", w_complement),
("ink.lang.coordinate", w_coordinate),
("ink.lang.stats", w_stats),
("ink.lang.audit", w_audit),
("ink.lang.query", w_query),
("ink.lang.gaps", w_gaps),
("ink.lang.sound_change", w_sound_change),
("ink.lang.cognates", w_cognates),
("ink.lang.family_tree", w_family_tree),
("ink.lang.names", w_names),
("ink.lang.prose", w_prose),
("ink.lang.poem", w_poem),
("ink.lang.varieties", w_varieties),
("ink.lang.lect", w_lect),
("ink.lang.borrow", w_borrow),
("ink.lang.areal", w_areal),
("ink.lang.idiolect", w_idiolect),
("ink.lang.ecology", w_ecology),
("ink.lang.dict", w_dict),
("ink.lang.init", w_init),
("ink.lang.define", w_define),
("ink.lang.add_word", w_add_word),
("ink.lang.remove_word", w_remove_word),
("ink.lang.derive_add", w_derive_add),
("ink.lang.grammar_set", w_grammar_set),
("ink.lang.idiom_add", w_idiom_add),
("ink.lang.metaphor_add", w_metaphor_add),
("ink.lang.compose", w_compose),
("ink.lang.reconstruct", w_reconstruct),
("ink.lang.realism_check", w_realism_check),
("ink.lang.generate_lexicon", w_generate_lexicon),
("ink.lang.glyph_lint", w_glyph_lint),
("ink.lang.dictionary", w_dictionary),
("ink.lang.grammar_book", w_grammar_book),
("ink.lang.font_build", w_font_build),
("ink.lang.glyph_draft", w_glyph_draft),
];
for (name, f) in words {
vm.register_inline(name.to_string(), *f)
.map_err(|e| anyhow!("register {name}: {e}"))?;
}
for (name, _) in words {
if let Some(short) = name.strip_prefix("ink.") {
let _ = vm.register_alias(short.to_string(), name.to_string());
}
}
for sugar in ["word", "rule", "phoneme", "block"] {
let _ = vm.register_alias(sugar.to_string(), "ink.lang.dict".to_string());
}
Ok(())
}
fn to_bund_err(e: anyhow::Error) -> BundError {
easy_error::err_msg(e.to_string())
}
fn ctx(tag: &str, name: &str) -> Result<(&'static Store, Hierarchy, Node)> {
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag}: {e}"))?;
let book = langapi::find_language_book(&hierarchy, name).map_err(|e| anyhow!("{tag}: {e}"))?;
Ok((store, hierarchy, book))
}
fn w_list(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_list(vm).map_err(to_bund_err)
}
fn do_list(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.list";
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag}: {e}"))?;
let root = hierarchy.iter().find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_LANGUAGES)
});
let mut names: Vec<Value> = Vec::new();
if let Some(root) = root {
for n in hierarchy.children_of(Some(root.id)) {
if n.kind == NodeKind::Book {
names.push(Value::from_string(n.title.clone()));
}
}
}
push(vm, Value::from_list(names));
Ok(vm)
}
fn w_generate_word(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_generate_word(vm).map_err(to_bund_err)
}
fn do_generate_word(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.generate_word";
require_depth(vm, 3, tag)?;
let seed = value_to_i64(pull(vm, tag)?, "seed", tag)?;
let role_s = value_to_string(pull(vm, tag)?, "role", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let role = TemplateRole::parse(&role_s)
.ok_or_else(|| anyhow!("{tag}: unknown role `{role_s}` (root/prefix/suffix/…)"))?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.ok_or_else(|| anyhow!("{tag}: language `{name}` has no phonology block"))?;
let word = crate::conlang::generate::word::generate_word(&phon, role, seed as u64)
.ok_or_else(|| anyhow!("{tag}: no `{role_s}` template to generate from"))?;
push(vm, Value::from_string(word));
Ok(vm)
}
fn w_syllabify(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_syllabify(vm).map_err(to_bund_err)
}
fn do_syllabify(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.syllabify";
require_depth(vm, 2, tag)?;
let word = value_to_string(pull(vm, tag)?, "word", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let seq = phon.segment(&word);
let sylls = crate::conlang::phonology::syllable::syllabify(&phon, &seq);
let out: Vec<Value> = sylls
.iter()
.map(|s| {
let mut seg = String::new();
seg.push_str(&s.onset.join(""));
seg.push_str(&s.nucleus.join(""));
seg.push_str(&s.coda.join(""));
Value::from_string(seg)
})
.collect();
push(vm, Value::from_list(out));
Ok(vm)
}
fn w_ipa(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_ipa(vm).map_err(to_bund_err)
}
fn do_ipa(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.ipa";
require_depth(vm, 2, tag)?;
let word = value_to_string(pull(vm, tag)?, "word", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let underlying = phon.segment(&word);
let surface = crate::conlang::phonology::allophony_eval::surface_form(&phon, &underlying);
push(vm, Value::from_string(surface.join("")));
Ok(vm)
}
fn w_gloss(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_gloss(vm).map_err(to_bund_err)
}
fn do_gloss(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.gloss";
require_depth(vm, 2, tag)?;
let text = value_to_string(pull(vm, tag)?, "text", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let morph = langapi::load_morphology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let entries =
langapi::load_dictionary(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let index = crate::conlang::morphology::gloss::build_index(&phon, &morph, &entries);
let items = index.gloss_text(&text);
let glossed = items
.iter()
.map(|it| {
format!(
"{}={}",
it.surface,
it.gloss.clone().unwrap_or_else(|| "?".to_string())
)
})
.collect::<Vec<_>>()
.join(" ");
push(vm, Value::from_string(glossed));
Ok(vm)
}
fn w_sentence(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_sentence(vm).map_err(to_bund_err)
}
fn do_sentence(vm: &mut VM) -> Result<&mut VM> {
use crate::conlang::syntax::{self, Clause, NounPhrase};
let tag = "ink.lang.sentence";
require_depth(vm, 4, tag)?;
let object = value_to_string(pull(vm, tag)?, "object", tag)?;
let verb = value_to_string(pull(vm, tag)?, "verb", tag)?;
let subject = value_to_string(pull(vm, tag)?, "subject", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let morph = langapi::load_morphology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let (grammar_spec, _) =
langapi::load_grammar_spec(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let np = |w: &str| {
if w.trim().is_empty() {
None
} else {
Some(NounPhrase {
head: langapi::parse_word(w),
number: "sg".into(),
adjective: None,
})
}
};
let clause = Clause {
subject: np(&subject),
verb: if verb.trim().is_empty() {
None
} else {
Some(langapi::parse_word(&verb))
},
verb_person: "3".into(),
object: np(&object),
noun_paradigm: "noun".into(),
verb_paradigm: "verb".into(),
..Default::default()
};
let r = syntax::assemble(&phon, &morph, &grammar_spec.grammar, &clause);
let gloss = r
.words
.iter()
.map(|(w, g)| format!("{w}={g}"))
.collect::<Vec<_>>()
.join(" ");
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("surface".into(), Value::from_string(r.surface));
h.insert("gloss".into(), Value::from_string(gloss));
h.insert("literal".into(), Value::from_string(r.literal));
push(vm, Value::from_dict(h));
Ok(vm)
}
fn w_init(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_init(vm).map_err(to_bund_err)
}
fn do_init(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.init";
require_depth(vm, 1, tag)?;
let name = value_to_string(pull(vm, tag)?, "name", tag)?;
let store = active_store(tag)?;
let cfg = active_config(tag)?;
langapi::init_language(store, cfg, &name).map_err(|e| anyhow!("{tag}: {e}"))?;
Ok(vm)
}
fn w_define(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_define(vm).map_err(to_bund_err)
}
fn do_define(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.define";
require_depth(vm, 3, tag)?;
let block_v = pull(vm, tag)?;
let chapter = value_to_string(pull(vm, tag)?, "chapter", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (body, title) = match block_v.clone().cast_string() {
Ok(s) => (bund_unescape(&s), "block".to_string()),
Err(_) => {
let json = crate::scripting::value_to_json(&block_v);
let title = json
.as_object()
.and_then(|m| m.keys().next().cloned())
.unwrap_or_else(|| "block".to_string());
let body = serde_json::to_string_pretty(&json)
.map_err(|e| anyhow!("{tag}: serialize block: {e}"))?;
(body, title)
}
};
let store = active_store(tag)?;
let cfg = active_config(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag}: {e}"))?;
let book = langapi::find_language_book(&hierarchy, &name).map_err(|e| anyhow!("{tag}: {e}"))?;
langapi::create_chapter_paragraph(store, cfg, &book, &chapter, &title, &body)
.map_err(|e| anyhow!("{tag}: {e}"))?;
Ok(vm)
}
fn bund_unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
fn w_add_word(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_add_word(vm).map_err(to_bund_err)
}
fn do_add_word(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.add_word";
require_depth(vm, 4, tag)?;
let translation = value_to_string(pull(vm, tag)?, "translation", tag)?;
let pos = value_to_string(pull(vm, tag)?, "pos", tag)?;
let word = value_to_string(pull(vm, tag)?, "word", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let store = active_store(tag)?;
let cfg = active_config(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag}: {e}"))?;
let book = langapi::find_language_book(&hierarchy, &name).map_err(|e| anyhow!("{tag}: {e}"))?;
let entry = langapi::ImportEntry {
word,
pos,
translation,
..Default::default()
};
langapi::add_imported_dictionary_entry(store, cfg, &book, &entry)
.map_err(|e| anyhow!("{tag}: {e}"))?;
Ok(vm)
}
fn w_dict(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_dict(vm).map_err(to_bund_err)
}
fn do_dict(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.dict";
require_depth(vm, 1, tag)?;
let list = pull(vm, tag)?
.cast_list()
.map_err(|e| anyhow!("{tag}: expected a list of key/value pairs: {e}"))?;
if list.len() % 2 != 0 {
return Err(anyhow!(
"{tag}: list has an odd length ({}) — it must be alternating key value pairs",
list.len()
));
}
let mut h: HashMap<String, Value> = HashMap::new();
let mut it = list.into_iter();
while let (Some(k), Some(v)) = (it.next(), it.next()) {
let key = k
.cast_string()
.map_err(|e| anyhow!("{tag}: dict key must be a string: {e}"))?;
h.insert(key, v);
}
push(vm, Value::from_dict(h));
Ok(vm)
}
fn json_to_value(j: &serde_json::Value) -> Value {
match j {
serde_json::Value::Null => Value::nodata(),
serde_json::Value::Bool(b) => Value::from_bool(*b),
serde_json::Value::Number(n) => n
.as_i64()
.map(Value::from_int)
.unwrap_or_else(|| Value::from_float(n.as_f64().unwrap_or(0.0))),
serde_json::Value::String(s) => Value::from_string(s.clone()),
serde_json::Value::Array(a) => Value::from_list(a.iter().map(json_to_value).collect()),
serde_json::Value::Object(m) => {
let mut h: HashMap<String, Value> = HashMap::new();
for (k, v) in m {
h.insert(k.clone(), json_to_value(v));
}
Value::from_dict(h)
}
}
}
fn serial_to_value<T: serde::Serialize>(tag: &str, v: &T) -> Result<Value> {
let j = serde_json::to_value(v).map_err(|e| anyhow!("{tag}: serialize: {e}"))?;
Ok(json_to_value(&j))
}
fn parse_features(s: &str) -> std::collections::BTreeMap<String, String> {
let mut m = std::collections::BTreeMap::new();
for pair in s.split(',') {
if let Some((k, v)) = pair.split_once('=') {
let (k, v) = (k.trim(), v.trim());
if !k.is_empty() {
m.insert(k.to_string(), v.to_string());
}
}
}
m
}
fn rendered_to_dict(r: &crate::conlang::syntax::RenderedClause) -> Value {
let gloss = r
.words
.iter()
.map(|(w, g)| format!("{w}={g}"))
.collect::<Vec<_>>()
.join(" ");
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("surface".into(), Value::from_string(r.surface.clone()));
h.insert("gloss".into(), Value::from_string(gloss));
h.insert("literal".into(), Value::from_string(r.literal.clone()));
Value::from_dict(h)
}
fn w_stress(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_stress(vm).map_err(to_bund_err)
}
fn do_stress(vm: &mut VM) -> Result<&mut VM> {
use crate::conlang::phonology::{stress_eval, syllable};
let tag = "ink.lang.stress";
require_depth(vm, 2, tag)?;
let word = value_to_string(pull(vm, tag)?, "word", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let rule = phon
.stress
.clone()
.ok_or_else(|| anyhow!("{tag}: language `{name}` declares no `stress` rule"))?;
let seq = phon.segment(&word);
let sylls = syllable::syllabify(&phon, &seq);
let stressed = stress_eval::primary_stress(&rule, &sylls);
let marked = sylls
.iter()
.enumerate()
.map(|(i, s)| {
let body = format!("{}{}{}", s.onset.join(""), s.nucleus.join(""), s.coda.join(""));
if Some(i) == stressed {
format!("ˈ{body}")
} else {
body
}
})
.collect::<Vec<_>>()
.join(".");
push(vm, Value::from_string(marked));
Ok(vm)
}
fn w_tone(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_tone(vm).map_err(to_bund_err)
}
fn do_tone(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.tone";
require_depth(vm, 2, tag)?;
let tones = value_to_string(pull(vm, tag)?, "tones", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let system = phon
.tone
.clone()
.ok_or_else(|| anyhow!("{tag}: language `{name}` declares no `tone` system"))?;
let seq: Vec<String> = tones.split_whitespace().map(String::from).collect();
let out = crate::conlang::phonology::tone_eval::apply_sandhi(&system, &seq);
push(vm, Value::from_string(out.join(" ")));
Ok(vm)
}
fn w_transliterate(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_transliterate(vm).map_err(to_bund_err)
}
fn do_transliterate(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.transliterate";
require_depth(vm, 2, tag)?;
let text = value_to_string(pull(vm, tag)?, "text", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let font = langapi::load_font_config(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.ok_or_else(|| anyhow!("{tag}: language `{name}` has no font config to transliterate with"))?;
let out = crate::conlang::writing::input::to_script(&font, &text);
push(vm, Value::from_string(out.script));
Ok(vm)
}
fn w_varieties(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_varieties(vm).map_err(to_bund_err)
}
fn do_varieties(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.varieties";
require_depth(vm, 1, tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let vs = langapi::load_varieties(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let out: Vec<Value> = vs
.varieties
.iter()
.map(|v| {
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("id".into(), Value::from_string(v.id.clone()));
h.insert("kind".into(), Value::from_string(v.kind.clone()));
h.insert("axis".into(), Value::from_string(v.axis.clone()));
h.insert(
"prestige".into(),
v.prestige.clone().map(Value::from_string).unwrap_or_else(Value::nodata),
);
h.insert("sound_changes".into(), Value::from_int(v.sound_changes.len() as i64));
h.insert("overrides".into(), Value::from_int(v.lexicon.len() as i64));
Value::from_dict(h)
})
.collect();
push(vm, Value::from_list(out));
Ok(vm)
}
fn w_lect(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_lect(vm).map_err(to_bund_err)
}
fn do_lect(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.lect";
require_depth(vm, 3, tag)?;
let word = value_to_string(pull(vm, tag)?, "word", tag)?;
let variety = value_to_string(pull(vm, tag)?, "variety", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let vs = langapi::load_varieties(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let v = vs
.get(&variety)
.ok_or_else(|| anyhow!("{tag}: language `{name}` has no variety `{variety}`"))?;
push(vm, Value::from_string(crate::conlang::variety::render_form(&phon, v, &word)));
Ok(vm)
}
fn w_borrow(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_borrow(vm).map_err(to_bund_err)
}
fn do_borrow(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.borrow";
require_depth(vm, 2, tag)?;
let donor = value_to_string(pull(vm, tag)?, "donor-form", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.ok_or_else(|| anyhow!("{tag}: language `{name}` has no phoneme block"))?;
let loan =
langapi::load_loan_phonology(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let a = crate::conlang::contact::adapt(&phon, &loan, &donor);
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("donor".into(), Value::from_string(a.donor));
h.insert("adapted".into(), Value::from_string(a.adapted));
h.insert(
"steps".into(),
Value::from_list(a.steps.into_iter().map(Value::from_string).collect()),
);
push(vm, Value::from_dict(h));
Ok(vm)
}
fn w_areal(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_areal(vm).map_err(to_bund_err)
}
fn do_areal(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.areal";
require_depth(vm, 1, tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let contact =
langapi::load_contact(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let mut h: HashMap<String, Value> = HashMap::new();
match contact {
None => {
h.insert("region".into(), Value::nodata());
h.insert("with".into(), Value::from_list(Vec::new()));
h.insert("convergence".into(), Value::from_list(Vec::new()));
}
Some(c) => {
let (spec, _) = langapi::load_grammar_spec(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?;
h.insert("region".into(), Value::from_string(c.region.clone()));
h.insert(
"with".into(),
Value::from_list(c.with.iter().map(|s| Value::from_string(s.clone())).collect()),
);
let conv: Vec<Value> =
crate::conlang::contact::converge(&spec.grammar, &c.areal_features)
.into_iter()
.map(|c| {
let mut d: HashMap<String, Value> = HashMap::new();
d.insert("feature".into(), Value::from_string(c.feature));
d.insert("areal_value".into(), Value::from_string(c.areal_value));
d.insert(
"current".into(),
c.current.map(Value::from_string).unwrap_or_else(Value::nodata),
);
d.insert("status".into(), Value::from_string(c.status.as_str().to_string()));
Value::from_dict(d)
})
.collect();
h.insert("convergence".into(), Value::from_list(conv));
}
}
push(vm, Value::from_dict(h));
Ok(vm)
}
fn w_idiolect(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_idiolect(vm).map_err(to_bund_err)
}
fn do_idiolect(vm: &mut VM) -> Result<&mut VM> {
use crate::conlang::links::ConlangLinks;
let tag = "ink.lang.idiolect";
require_depth(vm, 2, tag)?;
let word = value_to_string(pull(vm, tag)?, "word", tag)?;
let character = value_to_string(pull(vm, tag)?, "character", tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag}: {e}"))?;
let links = ConlangLinks::load(store.project_root())
.map_err(|e| anyhow!("{tag}: {e}"))?;
let link = links
.characters
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(&character))
.map(|(_, v)| v)
.ok_or_else(|| anyhow!("{tag}: no links for character `{character}`"))?;
let lang = link
.languages
.first()
.map(|p| p.language.clone())
.ok_or_else(|| anyhow!("{tag}: `{character}` commands no language"))?;
let var_id = link
.native_variety
.clone()
.ok_or_else(|| anyhow!("{tag}: `{character}` has no native variety"))?;
let book = langapi::find_language_book(&hierarchy, &lang).map_err(|e| anyhow!("{tag}: {e}"))?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let vs = langapi::load_varieties(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let v = vs
.get(&var_id)
.ok_or_else(|| anyhow!("{tag}: language `{lang}` has no variety `{var_id}`"))?;
push(vm, Value::from_string(crate::conlang::variety::render_form(&phon, v, &word)));
Ok(vm)
}
fn w_ecology(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_ecology(vm).map_err(to_bund_err)
}
fn do_ecology(vm: &mut VM) -> Result<&mut VM> {
use crate::conlang::links::ConlangLinks;
let tag = "ink.lang.ecology";
let store = active_store(tag)?;
let links = ConlangLinks::load(store.project_root())
.map_err(|e| anyhow!("{tag}: {e}"))?;
let places: Vec<Value> = links
.places
.iter()
.map(|(name, l)| {
let mut d: HashMap<String, Value> = HashMap::new();
d.insert("place".into(), Value::from_string(name.clone()));
d.insert(
"language".into(),
l.primary.clone().map(Value::from_string).unwrap_or_else(Value::nodata),
);
d.insert(
"variety".into(),
l.variety.clone().map(Value::from_string).unwrap_or_else(Value::nodata),
);
Value::from_dict(d)
})
.collect();
let characters: Vec<Value> = links
.characters
.iter()
.map(|(name, c)| {
let mut d: HashMap<String, Value> = HashMap::new();
d.insert("character".into(), Value::from_string(name.clone()));
d.insert(
"languages".into(),
Value::from_list(c.languages.iter().map(|p| Value::from_string(p.language.clone())).collect()),
);
d.insert(
"native_variety".into(),
c.native_variety.clone().map(Value::from_string).unwrap_or_else(Value::nodata),
);
Value::from_dict(d)
})
.collect();
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("places".into(), Value::from_list(places));
h.insert("characters".into(), Value::from_list(characters));
push(vm, Value::from_dict(h));
Ok(vm)
}
fn w_paradigm(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_paradigm(vm).map_err(to_bund_err)
}
fn do_paradigm(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.paradigm";
require_depth(vm, 4, tag)?;
let gloss = value_to_string(pull(vm, tag)?, "gloss", tag)?;
let template = value_to_string(pull(vm, tag)?, "template", tag)?;
let root = value_to_string(pull(vm, tag)?, "root", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let morph = langapi::load_morphology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let tmpl = morph
.paradigm(&template)
.ok_or_else(|| anyhow!("{tag}: no paradigm named `{template}`"))?;
let rows = crate::conlang::morphology::paradigm::generate(&phon, &morph, tmpl, &root, &gloss);
let out: Vec<Value> = rows
.iter()
.map(|r| {
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("form".into(), Value::from_string(r.form.clone()));
h.insert("gloss".into(), Value::from_string(r.gloss.clone()));
let feats: HashMap<String, Value> = r
.features
.iter()
.map(|(k, v)| (k.clone(), Value::from_string(v.clone())))
.collect();
h.insert("features".into(), Value::from_dict(feats));
Value::from_dict(h)
})
.collect();
push(vm, Value::from_list(out));
Ok(vm)
}
fn w_derive(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_derive(vm, false).map_err(to_bund_err)
}
fn w_derive_add(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_derive(vm, true).map_err(to_bund_err)
}
fn do_derive(vm: &mut VM, commit: bool) -> Result<&mut VM> {
let tag = if commit { "ink.lang.derive_add" } else { "ink.lang.derive" };
require_depth(vm, 4, tag)?;
let pos = value_to_string(pull(vm, tag)?, "pos", tag)?;
let gloss = value_to_string(pull(vm, tag)?, "gloss", tag)?;
let root = value_to_string(pull(vm, tag)?, "root", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let morph = langapi::load_morphology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let derived = crate::conlang::morphology::derive::generate(&phon, &morph, &root, &gloss, &pos);
if commit {
let cfg = active_config(tag)?;
let mut added = 0i64;
for d in &derived {
let entry = langapi::ImportEntry {
word: d.form.clone(),
pos: d.pos.clone(),
translation: d.gloss.clone(),
etymology: format!("derived from {root} via {}", d.rule),
..Default::default()
};
if langapi::add_imported_dictionary_entry(store, cfg, &book, &entry).is_ok() {
added += 1;
}
}
push(vm, Value::from_int(added));
} else {
let out: Vec<Value> = derived
.iter()
.map(|d| {
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("form".into(), Value::from_string(d.form.clone()));
h.insert("gloss".into(), Value::from_string(d.gloss.clone()));
h.insert("pos".into(), Value::from_string(d.pos.clone()));
h.insert("rule".into(), Value::from_string(d.rule.clone()));
Value::from_dict(h)
})
.collect();
push(vm, Value::from_list(out));
}
Ok(vm)
}
fn w_agree(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_agree(vm).map_err(to_bund_err)
}
fn do_agree(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.agree";
require_depth(vm, 4, tag)?;
let features = value_to_string(pull(vm, tag)?, "features", tag)?;
let pos = value_to_string(pull(vm, tag)?, "pos", tag)?;
let word = value_to_string(pull(vm, tag)?, "word", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let morph = langapi::load_morphology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let rule = morph
.agreement_for(&pos)
.ok_or_else(|| anyhow!("{tag}: no agreement rule for pos `{pos}`"))?;
let head = parse_features(&features);
let a = crate::conlang::morphology::agreement::agree(&phon, &morph, rule, &word, &word, &head)
.ok_or_else(|| anyhow!("{tag}: no matching paradigm cell for those features"))?;
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("form".into(), Value::from_string(a.form));
h.insert("gloss".into(), Value::from_string(a.gloss));
push(vm, Value::from_dict(h));
Ok(vm)
}
fn w_relative(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_relative(vm).map_err(to_bund_err)
}
fn do_relative(vm: &mut VM) -> Result<&mut VM> {
use crate::conlang::syntax::{self, RelativeClause};
let tag = "ink.lang.relative";
require_depth(vm, 6, tag)?;
let relativizer = value_to_string(pull(vm, tag)?, "relativizer", tag)?;
let with = value_to_string(pull(vm, tag)?, "with", tag)?;
let verb = value_to_string(pull(vm, tag)?, "verb", tag)?;
let role = value_to_string(pull(vm, tag)?, "role", tag)?;
let head = value_to_string(pull(vm, tag)?, "head", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let head_is_subject = !role.eq_ignore_ascii_case("object");
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let morph = langapi::load_morphology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let (gs, _) =
langapi::load_grammar_spec(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let rc = RelativeClause {
head_is_subject,
verb: langapi::parse_word(&verb),
other: if with.trim().is_empty() {
None
} else {
Some(langapi::parse_word(&with))
},
relativizer: if relativizer.trim().is_empty() {
None
} else {
Some(langapi::parse_word(&relativizer))
},
noun_paradigm: "noun".into(),
verb_paradigm: "verb".into(),
};
let r = syntax::relative_np(&phon, &morph, &gs.grammar, &langapi::parse_word(&head), &rc);
push(vm, rendered_to_dict(&r));
Ok(vm)
}
fn w_complement(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_complement(vm).map_err(to_bund_err)
}
fn do_complement(vm: &mut VM) -> Result<&mut VM> {
use crate::conlang::syntax::{self, Clause, ComplementSentence, NounPhrase};
let tag = "ink.lang.complement";
require_depth(vm, 7, tag)?;
let comp_object = value_to_string(pull(vm, tag)?, "comp_object", tag)?;
let comp_verb = value_to_string(pull(vm, tag)?, "comp_verb", tag)?;
let comp_subject = value_to_string(pull(vm, tag)?, "comp_subject", tag)?;
let complementizer = value_to_string(pull(vm, tag)?, "complementizer", tag)?;
let verb = value_to_string(pull(vm, tag)?, "verb", tag)?;
let subject = value_to_string(pull(vm, tag)?, "subject", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let morph = langapi::load_morphology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let (gs, _) =
langapi::load_grammar_spec(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let np = |w: &str| {
if w.trim().is_empty() {
None
} else {
Some(NounPhrase {
head: langapi::parse_word(w),
number: "sg".into(),
adjective: None,
})
}
};
let embedded = Clause {
subject: np(&comp_subject),
verb: Some(langapi::parse_word(&comp_verb)),
verb_person: "3".into(),
object: np(&comp_object),
noun_paradigm: "noun".into(),
verb_paradigm: "verb".into(),
..Default::default()
};
let cs = ComplementSentence {
matrix_subject: if subject.trim().is_empty() {
None
} else {
Some(langapi::parse_word(&subject))
},
matrix_verb: langapi::parse_word(&verb),
matrix_person: "1".into(),
matrix_number: "sg".into(),
complementizer: if complementizer.trim().is_empty() {
None
} else {
Some(langapi::parse_word(&complementizer))
},
complement: embedded,
noun_paradigm: "noun".into(),
verb_paradigm: "verb".into(),
};
let r = syntax::complement_sentence(&phon, &morph, &gs.grammar, &cs);
push(vm, rendered_to_dict(&r));
Ok(vm)
}
fn w_coordinate(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_coordinate(vm).map_err(to_bund_err)
}
fn do_coordinate(vm: &mut VM) -> Result<&mut VM> {
use crate::conlang::syntax::{self, Clause, NounPhrase};
let tag = "ink.lang.coordinate";
require_depth(vm, 3, tag)?;
let conj = value_to_string(pull(vm, tag)?, "conjunction", tag)?;
let clauses_v = pull(vm, tag)?
.cast_list()
.map_err(|e| anyhow!("{tag}: expected a list of clause strings: {e}"))?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let morph = langapi::load_morphology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let (gs, _) =
langapi::load_grammar_spec(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let np = |w: &str| NounPhrase {
head: langapi::parse_word(w),
number: "sg".into(),
adjective: None,
};
let mut parts = Vec::new();
for cv in clauses_v {
let spec = cv.cast_string().map_err(|e| anyhow!("{tag}: clause must be a string: {e}"))?;
let toks: Vec<&str> = spec.split_whitespace().collect();
if toks.len() < 2 {
return Err(anyhow!("{tag}: clause `{spec}` needs a subject and a verb"));
}
let c = Clause {
subject: Some(np(toks[0])),
verb: Some(langapi::parse_word(toks[1])),
verb_person: "3".into(),
object: toks.get(2).map(|t| np(t)),
noun_paradigm: "noun".into(),
verb_paradigm: "verb".into(),
..Default::default()
};
parts.push(syntax::assemble(&phon, &morph, &gs.grammar, &c));
}
let conj_word = if conj.trim().is_empty() {
None
} else {
Some(langapi::parse_word(&conj))
};
let r = syntax::coordinate(&parts, conj_word.as_ref());
push(vm, rendered_to_dict(&r));
Ok(vm)
}
fn w_stats(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_stats(vm).map_err(to_bund_err)
}
fn do_stats(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.stats";
require_depth(vm, 1, tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let entries =
langapi::load_dictionary(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let profile = crate::conlang::analysis::profile(&phon, &entries);
push(vm, serial_to_value(tag, &profile)?);
Ok(vm)
}
fn w_audit(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_audit(vm).map_err(to_bund_err)
}
fn do_audit(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.audit";
require_depth(vm, 1, tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let entries =
langapi::load_dictionary(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let report = crate::conlang::lexicon::analyze(&phon, &entries);
push(vm, serial_to_value(tag, &report)?);
Ok(vm)
}
fn w_query(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_query(vm).map_err(to_bund_err)
}
fn do_query(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.query";
require_depth(vm, 2, tag)?;
let text = value_to_string(pull(vm, tag)?, "text", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let entries =
langapi::load_dictionary(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let text_opt = if text.trim().is_empty() { None } else { Some(text.as_str()) };
let filter = crate::conlang::lexicon::Filter {
register: None,
domain: None,
era: None,
pos: None,
text: text_opt,
};
let matches = crate::conlang::lexicon::filter(&entries, &filter);
let out: Vec<Value> = matches
.iter()
.map(|e| {
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("word".into(), Value::from_string(e.word.clone()));
h.insert("pos".into(), Value::from_string(e.pos.clone()));
h.insert("translation".into(), Value::from_string(e.translation.clone()));
Value::from_dict(h)
})
.collect();
push(vm, Value::from_list(out));
Ok(vm)
}
fn w_gaps(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_gaps(vm).map_err(to_bund_err)
}
fn do_gaps(vm: &mut VM) -> Result<&mut VM> {
use crate::conlang::gaps as gapmod;
let tag = "ink.lang.gaps";
require_depth(vm, 2, tag)?;
let scope = value_to_string(pull(vm, tag)?, "scope", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let cfg = active_config(tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let entries =
langapi::load_dictionary(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let glosses: Vec<String> = entries
.iter()
.map(|e| e.translation.clone())
.filter(|g| !g.trim().is_empty())
.collect();
let (scope_name, concepts) = if gapmod::is_builtin(&scope) {
(
format!("Swadesh-100 ({})", cfg.language),
gapmod::swadesh_100(&cfg.language),
)
} else {
let body = std::fs::read_to_string(&scope)
.map_err(|e| anyhow!("{tag}: scope `{scope}` is not a builtin nor a readable file: {e}"))?;
let parsed = gapmod::ScopeFile::from_hjson(&body).map_err(|e| anyhow!("{tag}: {e}"))?;
let nm = parsed.name.clone().unwrap_or_else(|| scope.clone());
(nm, parsed.into_concepts())
};
let report = gapmod::find_gaps(&scope_name, &concepts, &glosses);
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("scope".into(), Value::from_string(report.scope_name.clone()));
h.insert("total".into(), Value::from_int(report.total as i64));
h.insert(
"coverage_pct".into(),
Value::from_float(report.coverage_pct() as f64),
);
h.insert(
"covered".into(),
Value::from_list(report.covered.iter().map(|s| Value::from_string(s.clone())).collect()),
);
h.insert(
"missing".into(),
Value::from_list(report.missing.iter().map(|s| Value::from_string(s.clone())).collect()),
);
push(vm, Value::from_dict(h));
Ok(vm)
}
fn w_sound_change(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_sound_change(vm).map_err(to_bund_err)
}
fn do_sound_change(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.sound_change";
require_depth(vm, 2, tag)?;
let form = value_to_string(pull(vm, tag)?, "form", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let dia = langapi::load_diachronics(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.ok_or_else(|| anyhow!("{tag}: language `{name}` declares no `diachronics` block"))?;
let proto_phon = match dia.proto.as_deref() {
Some(p) => {
let pbook = langapi::find_language_book(&hierarchy, p)
.map_err(|e| anyhow!("{tag}: proto `{p}`: {e}"))?;
langapi::load_phonology(store, &hierarchy, &pbook)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default()
}
None => langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default(),
};
let out = crate::conlang::diachronic::apply::derive_form(&proto_phon, &dia.rules, &form);
push(vm, Value::from_string(out));
Ok(vm)
}
fn w_cognates(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_cognates(vm).map_err(to_bund_err)
}
fn do_cognates(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.cognates";
require_depth(vm, 2, tag)?;
let form = value_to_string(pull(vm, tag)?, "form", tag)?;
let proto = value_to_string(pull(vm, tag)?, "proto", tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag}: {e}"))?;
let proto_book =
langapi::find_language_book(&hierarchy, &proto).map_err(|e| anyhow!("{tag}: {e}"))?;
let proto_phon = langapi::load_phonology(store, &hierarchy, &proto_book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let root = hierarchy.iter().find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_LANGUAGES)
});
let mut out: Vec<Value> = Vec::new();
if let Some(root) = root {
for n in hierarchy.children_of(Some(root.id)) {
if n.kind != NodeKind::Book || n.id == proto_book.id {
continue;
}
if let Ok(Some(dia)) = langapi::load_diachronics(store, &hierarchy, n) {
if dia.proto.as_deref().is_some_and(|p| p.eq_ignore_ascii_case(&proto)) {
let reflex = crate::conlang::diachronic::apply::derive_form(
&proto_phon,
&dia.rules,
&form,
);
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("language".into(), Value::from_string(n.title.clone()));
h.insert("reflex".into(), Value::from_string(reflex));
out.push(Value::from_dict(h));
}
}
}
}
push(vm, Value::from_list(out));
Ok(vm)
}
fn w_family_tree(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_family_tree(vm).map_err(to_bund_err)
}
fn do_family_tree(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.family_tree";
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag}: {e}"))?;
let root = hierarchy.iter().find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_LANGUAGES)
});
let mut pairs: Vec<(String, Option<String>)> = Vec::new();
if let Some(root) = root {
for n in hierarchy.children_of(Some(root.id)) {
if n.kind != NodeKind::Book {
continue;
}
let proto = langapi::load_diachronics(store, &hierarchy, n)
.ok()
.flatten()
.and_then(|d| d.proto);
pairs.push((n.title.clone(), proto));
}
}
let tree = crate::conlang::diachronic::family::render_tree(&pairs);
push(vm, Value::from_string(tree));
Ok(vm)
}
fn w_names(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_names(vm).map_err(to_bund_err)
}
fn do_names(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.names";
require_depth(vm, 3, tag)?;
let seed = value_to_i64(pull(vm, tag)?, "seed", tag)?;
let count = value_to_i64(pull(vm, tag)?, "count", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let names = crate::conlang::creative::names(&phon, count.max(0) as usize, seed as u64);
push(vm, Value::from_list(names.into_iter().map(Value::from_string).collect()));
Ok(vm)
}
fn w_prose(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_prose(vm).map_err(to_bund_err)
}
fn do_prose(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.prose";
require_depth(vm, 3, tag)?;
let seed = value_to_i64(pull(vm, tag)?, "seed", tag)?;
let count = value_to_i64(pull(vm, tag)?, "count", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let morph = langapi::load_morphology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let (gs, _) =
langapi::load_grammar_spec(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let entries =
langapi::load_dictionary(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let lines = crate::conlang::creative::prose(
&phon,
&morph,
&gs.grammar,
&entries,
count.max(0) as usize,
seed as u64,
);
push(vm, Value::from_list(lines.iter().map(rendered_to_dict).collect()));
Ok(vm)
}
fn w_poem(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_poem(vm).map_err(to_bund_err)
}
fn do_poem(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.poem";
require_depth(vm, 3, tag)?;
let seed = value_to_i64(pull(vm, tag)?, "seed", tag)?;
let meter_s = value_to_string(pull(vm, tag)?, "meter", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let meter: Vec<usize> = meter_s
.split(',')
.filter_map(|s| s.trim().parse::<usize>().ok())
.filter(|n| *n > 0)
.collect();
if meter.is_empty() {
return Err(anyhow!("{tag}: invalid meter `{meter_s}` (e.g. 5,7,5)"));
}
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let entries =
langapi::load_dictionary(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let lines = crate::conlang::creative::poem(&phon, &entries, &meter, seed as u64);
push(vm, Value::from_list(lines.iter().map(|l| Value::from_string(l.text.clone())).collect()));
Ok(vm)
}
fn w_remove_word(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_remove_word(vm).map_err(to_bund_err)
}
fn do_remove_word(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.remove_word";
require_depth(vm, 2, tag)?;
let word = value_to_string(pull(vm, tag)?, "word", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let _ = hierarchy;
langapi::remove_dictionary_entry(store, &book, &word).map_err(|e| anyhow!("{tag}: {e}"))?;
Ok(vm)
}
fn w_grammar_set(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_grammar_set(vm).map_err(to_bund_err)
}
fn do_grammar_set(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.grammar_set";
require_depth(vm, 3, tag)?;
let value = value_to_string(pull(vm, tag)?, "value", tag)?;
let feature = value_to_string(pull(vm, tag)?, "feature", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let cfg = active_config(tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let _ = hierarchy;
langapi::set_grammar_feature(store, cfg, &book, &feature, &value)
.map_err(|e| anyhow!("{tag}: {e}"))?;
Ok(vm)
}
fn w_idiom_add(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_idiom_add(vm).map_err(to_bund_err)
}
fn do_idiom_add(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.idiom_add";
require_depth(vm, 4, tag)?;
let meaning = value_to_string(pull(vm, tag)?, "meaning", tag)?;
let literal = value_to_string(pull(vm, tag)?, "literal", tag)?;
let form = value_to_string(pull(vm, tag)?, "form", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let cfg = active_config(tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let _ = hierarchy;
langapi::add_idiom(store, cfg, &book, &form, &literal, &meaning)
.map_err(|e| anyhow!("{tag}: {e}"))?;
Ok(vm)
}
fn w_metaphor_add(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_metaphor_add(vm).map_err(to_bund_err)
}
fn do_metaphor_add(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.metaphor_add";
require_depth(vm, 3, tag)?;
let target = value_to_string(pull(vm, tag)?, "target", tag)?;
let source = value_to_string(pull(vm, tag)?, "source", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let cfg = active_config(tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let _ = hierarchy;
langapi::add_metaphor(store, cfg, &book, &source, &target).map_err(|e| anyhow!("{tag}: {e}"))?;
Ok(vm)
}
fn ai_call(
tag: &str,
cfg: &crate::config::Config,
provider: &str,
system: String,
user: String,
) -> Result<String> {
let ai = crate::ai::AiClient::from_config(&cfg.llm).map_err(|e| anyhow!("{tag}: {e}"))?;
let prov = if provider.trim().is_empty() { None } else { Some(provider) };
let (model, _env) = ai
.resolve_provider(&cfg.llm, prov)
.map_err(|e| anyhow!("{tag}: {e}"))?;
crate::ai::stream::collect_blocking(ai.client.clone(), model.to_string(), Some(system), user)
.map_err(|e| anyhow!("{tag}: inference error: {e}"))
}
fn w_compose(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_compose(vm).map_err(to_bund_err)
}
fn do_compose(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.compose";
require_depth(vm, 3, tag)?;
let provider = value_to_string(pull(vm, tag)?, "provider", tag)?;
let kind = value_to_string(pull(vm, tag)?, "kind", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let cfg = active_config(tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let (gs, _) =
langapi::load_grammar_spec(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let entries =
langapi::load_dictionary(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
if entries.is_empty() {
return Err(anyhow!("{tag}: lexicon is empty — add words before composing themed text"));
}
let summary = langapi::summarize_typology(&gs.grammar);
let (system, user) =
crate::conlang::creative::themed_prompt(&book.title, &kind, &cfg.language, &summary, &entries);
let raw = ai_call(tag, cfg, &provider, system, user)?;
push(vm, Value::from_string(raw.trim().to_string()));
Ok(vm)
}
fn w_reconstruct(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_reconstruct(vm).map_err(to_bund_err)
}
fn do_reconstruct(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.reconstruct";
require_depth(vm, 3, tag)?;
let provider = value_to_string(pull(vm, tag)?, "provider", tag)?;
let gloss = value_to_string(pull(vm, tag)?, "gloss", tag)?;
let forms = value_to_string(pull(vm, tag)?, "forms", tag)?;
let cfg = active_config(tag)?;
let gloss_clause = if gloss.trim().is_empty() {
String::new()
} else {
format!(" meaning '{}'", gloss.trim())
};
let user = format!("Cognate daughter forms{gloss_clause}: {forms}.\n\nReconstruct the proto-form.");
let raw = ai_call(tag, cfg, &provider, langapi::RECONSTRUCT_SYSTEM.to_string(), user)?;
push(vm, Value::from_string(raw.trim().to_string()));
Ok(vm)
}
fn w_realism_check(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_realism_check(vm).map_err(to_bund_err)
}
fn do_realism_check(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.realism_check";
require_depth(vm, 2, tag)?;
let provider = value_to_string(pull(vm, tag)?, "provider", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let cfg = active_config(tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let dia = langapi::load_diachronics(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.ok_or_else(|| anyhow!("{tag}: language `{name}` has no diachronics chain to check"))?;
let rules_text = dia
.rules
.iter()
.enumerate()
.map(|(i, r)| format!("{}. {}", i + 1, r.source))
.collect::<Vec<_>>()
.join("\n");
let proto = dia.proto.as_deref().unwrap_or("the proto-language");
let user = format!(
"Sound-change chain deriving {name} from {proto} (applied in order):\n{rules_text}\n\n\
Assess the plausibility, rule by rule, then give an overall verdict."
);
let raw = ai_call(tag, cfg, &provider, langapi::REALISM_SYSTEM.to_string(), user)?;
push(vm, Value::from_string(raw.trim().to_string()));
Ok(vm)
}
fn w_generate_lexicon(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_generate_lexicon(vm).map_err(to_bund_err)
}
fn do_generate_lexicon(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.generate_lexicon";
require_depth(vm, 4, tag)?;
let provider = value_to_string(pull(vm, tag)?, "provider", tag)?;
let count = value_to_i64(pull(vm, tag)?, "count", tag)?.max(1) as usize;
let topic = value_to_string(pull(vm, tag)?, "topic", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let cfg = active_config(tag)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.ok_or_else(|| anyhow!("{tag}: language `{name}` has no phonology block"))?;
let existing =
langapi::load_dictionary(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let pool = crate::conlang::generate::lexicon::build_pool(&phon, &existing, count);
if pool.is_empty() {
return Err(anyhow!("{tag}: could not generate candidate forms — loosen the phonotactics"));
}
let work_lang = if cfg.language.trim().is_empty() { "english" } else { cfg.language.trim() };
let topic_opt = if topic.trim().is_empty() { None } else { Some(topic.as_str()) };
let user = langapi::build_lexgen_prompt(&book.title, topic_opt, count, None, None, work_lang, &pool);
let raw = ai_call(tag, cfg, &provider, langapi::LEXGEN_SYSTEM.to_string(), user)?;
let proposals = crate::conlang::generate::lexicon::parse_proposals(&raw)
.map_err(|e| anyhow!("{tag}: could not parse model reply: {e}"))?;
let (kept, _rejected) =
crate::conlang::generate::lexicon::dedup(&phon, &existing, proposals);
let out: Vec<Value> = kept
.iter()
.map(|p| {
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("word".into(), Value::from_string(p.form.clone()));
h.insert("gloss".into(), Value::from_string(p.gloss.clone()));
h.insert("pos".into(), Value::from_string(p.pos.clone()));
Value::from_dict(h)
})
.collect();
push(vm, Value::from_list(out));
Ok(vm)
}
fn require_category(tag: &str, category: &str) -> Result<()> {
if let Some(p) = crate::scripting::active_policy() {
if p.denies(category) {
return Err(anyhow!(
"{tag}: blocked — enable the `{category}` scripting category in inkhaven.hjson"
));
}
}
Ok(())
}
fn parse_doc_format(tag: &str, fmt: &str) -> Result<bool> {
match fmt.to_ascii_lowercase().as_str() {
"md" | "markdown" => Ok(false),
"typ" | "typst" => Ok(true),
other => Err(anyhow!("{tag}: unknown format `{other}` (expected md or typ)")),
}
}
fn w_glyph_lint(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_glyph_lint(vm).map_err(to_bund_err)
}
fn do_glyph_lint(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.lang.glyph_lint";
require_depth(vm, 1, tag)?;
let path = value_to_string(pull(vm, tag)?, "svg-path", tag)?;
let resolved = super::helpers::resolve_fs_path(tag, &path)?;
let body = std::fs::read_to_string(&resolved)
.map_err(|e| anyhow!("{tag}: reading {}: {e}", resolved.display()))?;
let report = crate::conlang::writing::preflight::lint_svg(&body);
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("usable".into(), Value::from_bool(report.is_usable()));
let strs = |v: &[String]| Value::from_list(v.iter().map(|s| Value::from_string(s.clone())).collect());
h.insert("info".into(), strs(&report.info));
h.insert("warnings".into(), strs(&report.warnings));
h.insert("errors".into(), strs(&report.errors));
push(vm, Value::from_dict(h));
Ok(vm)
}
fn w_dictionary(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_dictionary(vm).map_err(to_bund_err)
}
fn do_dictionary(vm: &mut VM) -> Result<&mut VM> {
use crate::conlang::analysis;
use crate::conlang::output::{self, DictMeta, RenderEntry};
use crate::conlang::writing::input;
let tag = "ink.lang.dictionary";
require_depth(vm, 4, tag)?;
let font = value_to_string(pull(vm, tag)?, "font", tag)?;
let out = value_to_string(pull(vm, tag)?, "out", tag)?;
let fmt = value_to_string(pull(vm, tag)?, "format", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let typst = parse_doc_format(tag, &fmt)?;
let resolved = super::helpers::resolve_fs_path(tag, &out)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let entries =
langapi::load_dictionary(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let font_cfg =
langapi::load_font_config(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let profile = analysis::profile(&phon, &entries);
let family = if font.trim().is_empty() {
font_cfg.as_ref().and_then(|c| c.family.clone())
} else {
Some(font.clone())
};
let can_translit = font_cfg.as_ref().is_some_and(|c| !c.glyphs.is_empty());
let rendered: Vec<RenderEntry> = entries
.iter()
.map(|e| RenderEntry {
headword: e.word.clone(),
conscript: match (&font_cfg, can_translit) {
(Some(c), true) => {
let o = input::to_script(c, &e.word);
(o.mapped > 0).then_some(o.script)
}
_ => None,
},
pronunciation: langapi::pronounce(&phon, &e.word),
pos: e.pos.clone(),
gloss: e.translation.clone(),
registers: e.registers.clone(),
domain: e.domain.clone(),
era: e.era.clone(),
etymology: e.etymology.clone(),
example: (!e.example.trim().is_empty()).then(|| e.example.clone()),
})
.collect();
let meta = DictMeta {
language: &book.title,
font_family: if typst { family.as_deref() } else { None },
profile: Some(&profile),
};
let doc = if typst {
output::dictionary_typst(&meta, &rendered)
} else {
output::dictionary_markdown(&meta, &rendered)
};
crate::io_atomic::write(&resolved, doc.as_bytes())
.map_err(|e| anyhow!("{tag}: write {}: {e}", resolved.display()))?;
push(vm, Value::from_string(resolved.display().to_string()));
Ok(vm)
}
fn w_grammar_book(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_grammar_book(vm).map_err(to_bund_err)
}
fn do_grammar_book(vm: &mut VM) -> Result<&mut VM> {
use crate::conlang::analysis;
use crate::conlang::output::{self, GrammarBook};
let tag = "ink.lang.grammar_book";
require_depth(vm, 4, tag)?;
let font = value_to_string(pull(vm, tag)?, "font", tag)?;
let out = value_to_string(pull(vm, tag)?, "out", tag)?;
let fmt = value_to_string(pull(vm, tag)?, "format", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let typst = parse_doc_format(tag, &fmt)?;
let resolved = super::helpers::resolve_fs_path(tag, &out)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let phon = langapi::load_phonology(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.unwrap_or_default();
let entries =
langapi::load_dictionary(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let morphology =
langapi::load_morphology(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let (grammar_spec, _) =
langapi::load_grammar_spec(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let (expressions, _) =
langapi::load_expressions(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let samples =
langapi::load_samples(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let font_cfg =
langapi::load_font_config(store, &hierarchy, &book).map_err(|e| anyhow!("{tag}: {e}"))?;
let profile = analysis::profile(&phon, &entries);
let family = if font.trim().is_empty() {
font_cfg.as_ref().and_then(|c| c.family.clone())
} else {
Some(font.clone())
};
let example_sentence = morphology
.as_ref()
.and_then(|m| langapi::build_example_sentence(&phon, m, &grammar_spec.grammar, &entries));
let has_expr = !expressions.idioms.is_empty() || !expressions.metaphors.is_empty();
let variation = langapi::build_variation(store, &hierarchy, &book, &phon, &entries, 12)
.map_err(|e| anyhow!("{tag}: {e}"))?;
let bk = GrammarBook {
language: &book.title,
font_family: if typst { family.as_deref() } else { None },
profile: &profile,
phonology: &phon,
morphology: morphology.as_ref(),
typology: &grammar_spec.grammar,
expressions: has_expr.then_some(&expressions),
samples: &samples,
study: None,
example_sentence,
variation,
};
let doc = if typst {
output::grammar_typst(&bk)
} else {
output::grammar_markdown(&bk)
};
crate::io_atomic::write(&resolved, doc.as_bytes())
.map_err(|e| anyhow!("{tag}: write {}: {e}", resolved.display()))?;
push(vm, Value::from_string(resolved.display().to_string()));
Ok(vm)
}
fn w_font_build(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_font_build(vm).map_err(to_bund_err)
}
fn do_font_build(vm: &mut VM) -> Result<&mut VM> {
use crate::conlang::writing::font::GlyphSource;
use crate::conlang::writing::preflight;
let tag = "ink.lang.font_build";
require_depth(vm, 3, tag)?;
let out = value_to_string(pull(vm, tag)?, "out", tag)?;
let fmt = value_to_string(pull(vm, tag)?, "format", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let (want_ufo, want_ttf) = match fmt.to_ascii_lowercase().as_str() {
"ufo" => (true, false),
"ttf" => (false, true),
"both" => (true, true),
other => return Err(anyhow!("{tag}: unknown format `{other}` (ufo/ttf/both)")),
};
let resolved = super::helpers::resolve_fs_path(tag, &out)?;
let (store, hierarchy, book) = ctx(tag, &name)?;
let cfg = langapi::load_font_config(store, &hierarchy, &book)
.map_err(|e| anyhow!("{tag}: {e}"))?
.ok_or_else(|| anyhow!("{tag}: language `{name}` has no `font` block"))?;
if cfg.glyphs.is_empty() {
return Err(anyhow!("{tag}: language `{name}` declares no glyphs"));
}
let family = cfg.family.clone().unwrap_or_else(|| book.title.clone());
let dir = langapi::glyph_store_dir(store.project_root(), &name);
let mut sources: Vec<GlyphSource> = Vec::new();
let mut skipped = 0usize;
for g in &cfg.glyphs {
let path = dir.join(format!("{}.svg", g.name));
let svg = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => {
skipped += 1;
continue;
}
};
if !preflight::lint_svg(&svg).is_usable() {
skipped += 1;
continue;
}
sources.push(GlyphSource {
name: g.name.clone(),
codepoint: g.codepoint,
svg,
});
}
langapi::emit_font(
&family,
cfg.upm,
&sources,
skipped,
Some(&resolved),
want_ufo,
want_ttf,
)
.map_err(|e| anyhow!("{tag}: {e}"))?;
push(vm, Value::from_string(resolved.display().to_string()));
Ok(vm)
}
fn w_glyph_draft(vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
do_glyph_draft(vm).map_err(to_bund_err)
}
fn do_glyph_draft(vm: &mut VM) -> Result<&mut VM> {
use crate::conlang::writing::{draft, preflight};
let tag = "ink.lang.glyph_draft";
require_depth(vm, 5, tag)?;
require_category(tag, "ai_write")?; let provider = value_to_string(pull(vm, tag)?, "provider", tag)?;
let out = value_to_string(pull(vm, tag)?, "out", tag)?;
let phoneme = value_to_string(pull(vm, tag)?, "phoneme", tag)?;
let describe = value_to_string(pull(vm, tag)?, "describe", tag)?;
let name = value_to_string(pull(vm, tag)?, "lang", tag)?;
let cfg = active_config(tag)?;
let resolved = super::helpers::resolve_fs_path(tag, &out)?;
let phon_clause = if phoneme.trim().is_empty() {
String::new()
} else {
format!(" It renders the phoneme /{}/.", phoneme.trim())
};
let user = format!(
"Draft a glyph for the constructed writing system of the language '{name}'.{phon_clause}\n\n\
Description: {describe}"
);
let raw = ai_call(tag, cfg, &provider, langapi::GLYPH_DRAFT_SYSTEM.to_string(), user)?;
let svg = draft::extract_svg(&raw)
.ok_or_else(|| anyhow!("{tag}: the model did not return an SVG glyph"))?;
let report = preflight::lint_svg(&svg);
if !report.is_usable() {
return Err(anyhow!(
"{tag}: drafted glyph is not usable — {}",
report.errors.join("; ")
));
}
crate::io_atomic::write(&resolved, svg.as_bytes())
.map_err(|e| anyhow!("{tag}: write {}: {e}", resolved.display()))?;
push(vm, Value::from_string(resolved.display().to_string()));
Ok(vm)
}
#[cfg(test)]
mod tests {
use super::bund_unescape;
#[test]
fn unescape_recovers_json_quotes() {
assert_eq!(bund_unescape("{ipa:\\\"k\\\"}"), "{ipa:\"k\"}");
assert_eq!(bund_unescape("a\\\\b"), "a\\b"); assert_eq!(bund_unescape("x\\ny"), "x\ny"); assert_eq!(bund_unescape("a\\zb"), "a\\zb");
assert_eq!(bund_unescape("plain"), "plain");
}
}