use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::descriptor::{Descriptor, Mount};
pub const MAX_TOKENS: usize = 64;
pub const VOCAB_VERSION: u32 = 1;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Vocabulary {
pub tokens: Vec<Token>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Token {
pub name: String,
#[serde(default)]
pub note: String,
#[serde(default)]
pub implies: Vec<String>,
}
impl Vocabulary {
pub fn of(pairs: &[(&str, &str)]) -> Vocabulary {
Vocabulary {
tokens: pairs
.iter()
.map(|(n, d)| Token {
name: (*n).to_owned(),
note: (*d).to_owned(),
implies: Vec::new(),
})
.collect(),
}
}
pub fn bit(&self, token: &str) -> Option<u64> {
self.tokens
.iter()
.position(|t| t.name == token)
.map(|i| 1u64 << i)
}
pub fn mask(&self, tokens: &[String]) -> Result<u64, String> {
let mut m = 0u64;
for t in tokens {
m |= self
.bit(t)
.ok_or_else(|| format!("`{t}` is not in this vocabulary"))?;
}
Ok(m)
}
pub fn contains(&self, token: &str) -> bool {
self.tokens.iter().any(|t| t.name == token)
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.tokens.iter().map(|t| t.name.as_str())
}
fn validate(&self, axis: &str) -> Result<(), String> {
if self.tokens.len() > MAX_TOKENS {
return Err(format!(
"vocab: axis `{axis}` declares {} tokens; the mask holds {MAX_TOKENS}. Widen the mask \
deliberately or split the axis — do not let the 65th token silently alias the 1st.",
self.tokens.len()
));
}
for (i, t) in self.tokens.iter().enumerate() {
if t.name.trim().is_empty() {
return Err(format!("vocab: axis `{axis}` has an empty token at index {i}"));
}
if let Some(j) = self.tokens.iter().position(|o| o.name == t.name)
&& j != i
{
return Err(format!(
"vocab: axis `{axis}` declares `{}` twice (indices {j} and {i}). Tokens take \
their bit from their position, so a duplicate is two different bits meaning \
one thing.",
t.name
));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Vocabularies {
#[serde(default = "crate::format_version::first", deserialize_with = "crate::format_version::read::<_, VOCAB_VERSION>")]
pub version: u32,
pub kind: Vocabulary,
pub effects: Vocabulary,
pub look: Vocabulary,
pub surfaces: Vocabulary,
#[serde(default)]
pub capabilities: Vocabulary,
#[serde(default)]
pub edge: Vocabulary,
#[serde(default)]
pub slot: Vocabulary,
}
impl Default for Vocabularies {
fn default() -> Self {
Vocabularies {
version: VOCAB_VERSION,
kind: Vocabulary::default(),
effects: Vocabulary::default(),
look: Vocabulary::default(),
surfaces: Vocabulary::default(),
capabilities: Vocabulary::default(),
edge: Vocabulary::default(),
slot: Vocabulary::default(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Masks {
pub kind: u64,
pub effects: u64,
pub look: u64,
pub provides: u64,
pub presents: u64,
pub requires: u64,
pub edges: u64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub struct Can(pub u64);
impl Can {
pub fn meets(self, needs: u64) -> bool {
(self.0 & needs) == needs
}
}
impl Masks {
pub fn rests_on(&self, host: &Masks) -> bool {
self.requires != 0 && (host.provides & self.requires) != 0
}
}
impl Vocabularies {
pub fn parse(text: &str) -> Result<Vocabularies, String> {
let v: Vocabularies =
ron::from_str(text).map_err(|e| format!("vocab: does not parse: {e}"))?;
v.validate_tables()?;
Ok(v)
}
pub fn validate_tables(&self) -> Result<(), String> {
self.kind.validate("kind")?;
self.effects.validate("effects")?;
self.look.validate("look")?;
self.surfaces.validate("surfaces")?;
self.capabilities.validate("capabilities")?;
self.edge.validate("edge")?;
self.slot.validate("slot")?;
self.validate_implications()
}
fn validate_implications(&self) -> Result<(), String> {
for (axis, table) in [
("effects", &self.effects),
("look", &self.look),
("surfaces", &self.surfaces),
("capabilities", &self.capabilities),
("edge", &self.edge),
("slot", &self.slot),
] {
if let Some(t) = table.tokens.iter().find(|t| !t.implies.is_empty()) {
return Err(format!(
"vocab: axis `{axis}` token `{}` declares `implies`, which only `kind` \
resolves. Nothing would ever read it — move the implication to the `kind` \
token that brings it, or delete it.",
t.name
));
}
}
for t in &self.kind.tokens {
for implied in &t.implies {
if self.effects.contains(implied) {
continue;
}
let hint = nearest(&self.effects, implied)
.map(|n| format!(" Did you mean `{n}`?"))
.unwrap_or_default();
return Err(format!(
"vocab: `kind` token `{}` implies `{implied}`, which is not an `effects` \
token.{hint} The axis holds: {}.",
t.name,
self.effects.names().collect::<Vec<_>>().join(", ")
));
}
}
Ok(())
}
pub fn implied_effects(&self, kinds: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for token in &self.kind.tokens {
if !kinds.iter().any(|k| k == &token.name) {
continue;
}
for implied in &token.implies {
if !out.contains(implied) {
out.push(implied.clone());
}
}
}
out.sort_by_key(|t| self.effects.names().position(|n| n == t).unwrap_or(usize::MAX));
out
}
pub fn masks(&self, d: &Descriptor) -> Result<Masks, String> {
let axis = |v: &Vocabulary, name: &str, tokens: &[String]| -> Result<u64, String> {
v.mask(tokens).map_err(|_| {
let bad = tokens
.iter()
.find(|t| !v.contains(t))
.map_or_else(String::new, |t| t.clone());
let hint = nearest(v, &bad)
.map(|n| format!(" Did you mean `{n}`?"))
.unwrap_or_default();
format!(
"descriptor `{}`: `{bad}` is not a `{name}` token.{hint} The axis holds: {}. \
Growing a vocabulary is one row in the table — never a second list.",
d.id,
v.names().collect::<Vec<_>>().join(", ")
)
})
};
let requires = match &d.mount {
Some(
Mount::OnSurface { class }
| Mount::OnSurfaceOrFloor { class }
| Mount::OnFace { class, .. },
) => {
let how = if matches!(d.mount, Some(Mount::OnFace { .. })) {
"is fixed to face class"
} else {
"mounts on surface class"
};
self.surfaces.bit(class).ok_or_else(|| {
let hint = nearest(&self.surfaces, class)
.map(|n| format!(" Did you mean `{n}`?"))
.unwrap_or_default();
format!(
"descriptor `{}`: {how} `{class}`, which is not a `surfaces` token.{hint} \
The axis holds: {}.",
d.id,
self.surfaces.names().collect::<Vec<_>>().join(", ")
)
})?
}
_ => 0,
};
let mut cell_tokens: Vec<String> = Vec::new();
if let Some(g) = &d.subgrid {
for c in &g.cells {
if let Some(e) = &c.edge
&& !cell_tokens.contains(e)
{
cell_tokens.push(e.clone());
}
}
}
let implied = self.implied_effects(&d.kind);
let mut effects = d.effects.clone();
for token in implied {
if !effects.contains(&token) {
effects.push(token);
}
}
Ok(Masks {
kind: axis(&self.kind, "kind", &d.kind)?,
effects: axis(&self.effects, "effects", &effects)?,
look: axis(&self.look, "look", &d.look)?,
provides: axis(&self.surfaces, "surfaces", &d.offers.surfaces)?,
presents: axis(&self.surfaces, "surfaces", &d.offers.faces)?,
requires,
edges: axis(&self.edge, "edge", &cell_tokens)?,
})
}
pub fn check_slots(&self, compositions: &[crate::composition::Composition]) -> Result<(), String> {
for c in compositions {
for m in &c.members {
let crate::composition::Body::Slot { accepts } = &m.body else {
continue;
};
if self.slot.contains(accepts) {
continue;
}
let axis: Vec<&str> = self.slot.names().collect();
let holds = if axis.is_empty() {
"the axis is empty — declare the first one in `vocab.ron` rather than at a \
keyboard"
.to_owned()
} else {
format!("the axis holds: {}", axis.join(", "))
};
return Err(format!(
"composition `{}` slot `{}` accepts `{accepts}`, which is not a `slot` token. \
{holds}.",
c.id, m.id
));
}
}
Ok(())
}
pub fn role_mask(&self, role: &crate::map::RoleSlot, where_: &str) -> Result<u64, String> {
self.capabilities.mask(&role.requires).map_err(|_| {
let bad = role
.requires
.iter()
.find(|t| !self.capabilities.contains(t))
.map_or_else(String::new, |t| t.clone());
let hint = nearest(&self.capabilities, &bad)
.map(|n| format!(" Did you mean `{n}`?"))
.unwrap_or_default();
format!(
"{where_}: role `{}` requires `{bad}`, which is not a `capabilities` token.{hint} \
The axis holds: {}.",
role.name,
self.capabilities.names().collect::<Vec<_>>().join(", ")
)
})
}
pub fn validate_library(&self, library: &[Descriptor]) -> Result<Vec<Masks>, String> {
let masks: Vec<Masks> = library
.iter()
.map(|d| self.masks(d))
.collect::<Result<_, _>>()?;
let offered = masks.iter().fold(0u64, |acc, m| acc | m.provides);
for (d, m) in library.iter().zip(&masks) {
if m.requires != 0 && (offered & m.requires) == 0 {
let class = match &d.mount {
Some(Mount::OnSurface { class } | Mount::OnSurfaceOrFloor { class }) => {
class.clone()
}
_ => String::new(),
};
return Err(format!(
"descriptor `{}` rests on surface class `{class}`, which no descriptor in this \
library offers. Nothing would ever place it, and an empty shelf looks exactly \
like a shelf nobody authored. Either give a piece `offers.surfaces` containing \
`{class}`, or change this prop's mount.",
d.id
));
}
}
Ok(masks)
}
}
pub fn append_token(path: &Path, axis: &str, name: &str, note: &str) -> Result<(), String> {
const AXES: [&str; 4] = ["kind", "effects", "look", "surfaces"];
if !AXES.contains(&axis) {
return Err(format!(
"vocab: `{axis}` is not an axis the editor draws. The tag block draws {}; \
`capabilities`, `edge` and `slot` are hand-authored.",
AXES.join(", ")
));
}
let mut chars = name.chars();
let ok = chars
.next()
.is_some_and(|c| c.is_ascii_lowercase())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if !ok {
return Err(format!(
"vocab: `{name}` is not a valid token name. Tokens are lowercase letters, digits and \
hyphens, starting with a letter — like `uses-electricity`."
));
}
if note.is_empty() {
return Err("vocab: a token needs a note — a token nobody can describe in a sentence is a \
token whose meaning has not been decided yet"
.to_owned());
}
if note.contains('"') || note.contains('\\') || note.chars().any(|c| c.is_control()) {
return Err(
"vocab: the note must not contain a quote, a backslash or a control character — those \
are RON escapes and would break the parse or silently alter the note"
.to_owned(),
);
}
let text = std::fs::read_to_string(path)
.map_err(|e| format!("{}: {e}", path.display()))?;
let vocab = Vocabularies::parse(&text)
.map_err(|e| format!("{}: {e}", path.display()))?;
let table = match axis {
"kind" => &vocab.kind,
"effects" => &vocab.effects,
"look" => &vocab.look,
"surfaces" => &vocab.surfaces,
_ => unreachable!("checked above"),
};
if table.contains(name) {
return Err(format!(
"vocab: `{name}` is already a `{axis}` token. The axis holds: {}.",
table.names().collect::<Vec<_>>().join(", ")
));
}
if table.tokens.len() >= MAX_TOKENS {
return Err(format!(
"vocab: `{axis}` already holds {} tokens, the most the mask can pack. Widen the mask \
deliberately or split the axis — do not let the 65th token silently alias the 1st.",
table.tokens.len()
));
}
let span = crate::ron_surgery::find_block_value(&text, axis)
.map_err(|e| format!("{}: {e}", path.display()))?;
let mut doc = crate::ron_surgery::LineDoc::parse(&text[span.start..span.end], &["tokens"])
.map_err(|e| format!("{}: {e}", path.display()))?;
let record = format!(" ( name: \"{name}\", note: \"{note}\" ),");
doc.append("tokens", record)
.map_err(|e| format!("{}: {e}", path.display()))?;
let out = format!("{}{}{}", &text[..span.start], doc.render(), &text[span.end..]);
Vocabularies::parse(&out).map_err(|e| format!("{}: {e}", path.display()))?;
crate::ron_surgery::save_atomic(path, &out)
}
pub fn nearest<'a>(v: &'a Vocabulary, bad: &str) -> Option<&'a str> {
if bad.is_empty() {
return None;
}
let budget = (bad.len() / 3).max(1);
v.names()
.map(|n| (edit_distance(n, bad), n))
.filter(|(d, _)| *d <= budget)
.min_by_key(|(d, n)| (*d, n.len()))
.map(|(_, n)| n)
}
pub fn edit_distance(a: &str, b: &str) -> usize {
let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let sub = prev[j] + usize::from(ca != cb);
cur[j + 1] = sub.min(prev[j + 1] + 1).min(cur[j] + 1);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::Offers;
fn vocabs() -> Vocabularies {
Vocabularies {
version: VOCAB_VERSION,
kind: Vocabulary::of(&[
("seating", "a thing to sit on"),
("table", "a thing with a top"),
("light", "a thing that lights the room"),
("food", "a thing that can be eaten"),
]),
effects: Vocabulary::of(&[
("emit", "casts light; read by light::LightEmitter"),
("uses-electricity", "stops working when the power does"),
("stamina-recharge", "restores stamina when used"),
]),
look: Vocabulary::of(&[("brown", "brown"), ("metal", "bare metal")]),
edge: Vocabulary::of(&[
("wall", "a solid run-face"),
("door", "an opening that must stay clear"),
]),
slot: Vocabulary::of(&[("shelf-item", "something small standing on a shelf")]),
surfaces: Vocabulary::of(&[
("support", "any support top"),
("worktop", "a desk or table top"),
]),
capabilities: Vocabulary::of(&[
("eat", "can take a meal"),
("cook", "can prepare food"),
]),
}
}
fn desc(id: &str) -> Descriptor {
Descriptor {
id: id.to_owned(),
..Descriptor::default()
}
}
#[test]
fn a_token_takes_its_bit_from_its_position() {
let v = vocabs();
assert_eq!(v.kind.bit("seating"), Some(1));
assert_eq!(v.kind.bit("table"), Some(2));
assert_eq!(v.kind.bit("light"), Some(4));
assert_eq!(v.kind.bit("chair"), None);
}
#[test]
fn masks_or_together() {
let v = vocabs();
let m = v
.kind
.mask(&["seating".into(), "light".into()])
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(m, 1 | 4);
}
#[test]
fn an_unknown_token_names_the_descriptor_and_the_token() {
let v = vocabs();
let mut d = desc("ozea/chair");
d.kind = vec!["seeting".into()];
let err = v.masks(&d).err().unwrap_or_default();
assert!(err.contains("ozea/chair"), "must name the descriptor: {err}");
assert!(err.contains("seeting"), "must name the token: {err}");
assert!(err.contains("kind"), "must name the axis: {err}");
}
#[test]
fn a_near_miss_suggests_the_token_that_was_meant() {
let v = vocabs();
let mut d = desc("x");
d.kind = vec!["seeting".into()];
assert!(
v.masks(&d).err().unwrap_or_default().contains("Did you mean `seating`?"),
"a one-character slip should be caught"
);
let mut far = desc("x");
far.kind = vec!["helicopter".into()];
let err = far_err(&v, &far);
assert!(
!err.contains("Did you mean"),
"a wrong guess sends the reader to the wrong table: {err}"
);
}
fn far_err(v: &Vocabularies, d: &Descriptor) -> String {
v.masks(d).err().unwrap_or_default()
}
#[test]
fn the_mount_class_is_validated_too() {
let v = vocabs();
let mut d = desc("ozea/mug");
d.mount = Some(Mount::OnSurface {
class: "worktopp".into(),
});
let err = v.masks(&d).err().unwrap_or_default();
assert!(err.contains("worktopp") && err.contains("Did you mean `worktop`?"), "{err}");
}
#[test]
fn a_surface_nothing_offers_is_refused() {
let v = vocabs();
let mut mug = desc("ozea/mug");
mug.mount = Some(Mount::OnSurface {
class: "worktop".into(),
});
let err = v.validate_library(&[mug]).err().unwrap_or_default();
assert!(err.contains("no descriptor in this library offers"), "{err}");
assert!(err.contains("ozea/mug"), "must name the prop: {err}");
}
#[test]
fn a_surface_something_offers_is_accepted_and_the_pair_matches() {
let v = vocabs();
let mut mug = desc("ozea/mug");
mug.mount = Some(Mount::OnSurface {
class: "worktop".into(),
});
let mut table = desc("ozea/mess_table");
table.offers = Offers {
surfaces: vec!["support".into(), "worktop".into()],
..Offers::default()
};
let masks = v
.validate_library(&[table, mug])
.unwrap_or_else(|e| panic!("{e}"));
let (t, m) = (masks[0], masks[1]);
assert!(m.rests_on(&t), "the mug should rest on the table");
assert!(!t.rests_on(&m), "the table should not rest on the mug");
}
#[test]
fn a_piece_that_requires_nothing_rests_on_nothing() {
let free = Masks::default();
let table = Masks {
provides: 0b11,
..Masks::default()
};
assert!(!free.rests_on(&table));
}
#[test]
fn a_duplicate_token_is_refused_because_bits_come_from_position() {
let v = Vocabulary::of(&[("a", ""), ("b", ""), ("a", "")]);
let err = v.validate("kind").err().unwrap_or_default();
assert!(err.contains("twice"), "{err}");
}
#[test]
fn an_axis_wider_than_the_mask_is_refused_rather_than_wrapped() {
let names: Vec<String> = (0..=MAX_TOKENS).map(|i| format!("t{i}")).collect();
let v = Vocabulary {
tokens: names
.iter()
.map(|n| Token {
name: n.clone(),
note: String::new(),
implies: Vec::new(),
})
.collect(),
};
let err = v.validate("kind").err().unwrap_or_default();
assert!(err.contains("65") || err.contains("holds"), "{err}");
}
#[test]
fn a_vocabulary_file_round_trips() {
let v = vocabs();
let text =
ron::ser::to_string_pretty(&v, ron::ser::PrettyConfig::default()).expect("serializes");
assert_eq!(Vocabularies::parse(&text).expect("parses"), v);
}
#[test]
fn an_unknown_axis_is_refused() {
let err = Vocabularies::parse(
"( kind: ( tokens: [] ), effects: ( tokens: [] ), look: ( tokens: [] ), \
surfaces: ( tokens: [] ), mood: ( tokens: [] ) )",
)
.err()
.unwrap_or_default();
assert!(err.contains("does not parse"), "{err}");
}
#[test]
fn a_kind_implying_a_token_the_effects_axis_does_not_hold_is_refused_at_parse() {
let err = Vocabularies::parse(
r#"(
kind: ( tokens: [
( name: "bed", note: "a thing to lie on", implies: ["stamina-recharg"] ),
] ),
effects: ( tokens: [
( name: "stamina-recharge", note: "restores stamina to whoever uses it" ),
] ),
look: ( tokens: [] ),
surfaces: ( tokens: [] ),
)"#,
)
.err()
.unwrap_or_default();
assert!(err.contains("`kind` token `bed`"), "must name the kind: {err}");
assert!(
err.contains("implies `stamina-recharg`"),
"must name the token that does not resolve, not just the one that does: {err}"
);
assert!(
err.contains("Did you mean `stamina-recharge`?"),
"a one-character slip is the common case and must be said out loud: {err}"
);
}
#[test]
fn an_implication_on_an_axis_that_does_not_resolve_one_is_refused_at_parse() {
let err = Vocabularies::parse(
r#"(
kind: ( tokens: [] ),
effects: ( tokens: [
( name: "emit", note: "casts light", implies: ["uses-electricity"] ),
( name: "uses-electricity", note: "stops working when the power does" ),
] ),
look: ( tokens: [] ),
surfaces: ( tokens: [] ),
)"#,
)
.err()
.unwrap_or_default();
assert!(err.contains("axis `effects`"), "must name the axis: {err}");
assert!(err.contains("token `emit`"), "must name the token: {err}");
assert!(
err.contains("only `kind` resolves"),
"must say why it can never be read: {err}"
);
}
#[test]
fn a_kinds_implication_lands_in_the_effects_mask_and_only_when_that_kind_is_present() {
let mut v = vocabs();
v.kind.tokens[3].implies = vec!["stamina-recharge".to_owned()];
v.validate_tables()
.unwrap_or_else(|e| panic!("the implication resolves, so the door must open: {e}"));
let recharge = v.effects.bit("stamina-recharge").expect("the fixture holds it");
let food = v.kind.bit("food").expect("the fixture holds it");
let mut ration = desc("ozea/ration");
ration.kind = vec!["food".to_owned()];
let m = v.masks(&ration).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(
m.effects, recharge,
"the kind brings the effect with it, with nothing authored"
);
assert_eq!(
m.kind, food,
"the implication must not smear onto the axis that implied it"
);
let mut chair = desc("ozea/chair");
chair.kind = vec!["seating".to_owned()];
assert_eq!(
v.masks(&chair).unwrap_or_else(|e| panic!("{e}")).effects,
0,
"a kind that implies nothing brings nothing"
);
}
#[test]
fn a_slot_accepting_an_undeclared_token_is_refused_by_name() {
use crate::composition::{Body, Composition, Envelope, Member};
let tile = |accepts: &str| Composition {
id: "site/tile_wall_n".to_owned(),
envelope: Envelope::Bounded { size: (1.0, 1.0, 1.0) },
members: vec![Member {
id: "fixture".to_owned(),
body: Body::Slot { accepts: accepts.to_owned() },
at: (0.0, 0.0),
yaw: 0.0,
lift: 0.0,
paint: 0,
of_fingerprint: None,
note: None,
}],
locations: Vec::new(),
note: None,
};
let mut v = vocabs();
v.slot = Vocabulary::of(&[("wall-fixture", "something bolted to a wall")]);
v.check_slots(&[tile("wall-fixture")]).expect("a declared token passes");
let err = v.check_slots(&[tile("wall-fixtre")]).err().unwrap_or_default();
assert!(err.contains("site/tile_wall_n"), "{err}");
assert!(err.contains("fixture"), "{err}");
assert!(err.contains("wall-fixture"), "the message must list the axis: {err}");
}
fn temp_vocab(name: &str, text: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("grimoire-vocab-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("cannot make {dir:?}: {e}"));
let path = dir.join("vocab.ron");
std::fs::write(&path, text).unwrap_or_else(|e| panic!("cannot write {path:?}: {e}"));
path
}
const COMMENTED: &str = r#"(
kind: (
// the kind comment
tokens: [
( name: "seating", note: "a thing to sit on" ),
],
),
effects: (
// the effects comment
tokens: [
( name: "emit", note: "casts light" ),
],
),
look: (
// the look comment
tokens: [
( name: "brown", note: "" ),
],
),
surfaces: (
// the surfaces comment
tokens: [
( name: "support", note: "any support top" ),
],
),
)
"#;
#[test]
fn appending_a_token_keeps_every_comment_on_every_axis() {
for (axis, token, note) in [
("kind", "table", "a thing with a top"),
("effects", "uses-electricity", "stops working when the power does"),
("look", "metal", "bare metal"),
("surfaces", "worktop", "a desk or table top"),
] {
let path = temp_vocab(axis, COMMENTED);
append_token(&path, axis, token, note).unwrap_or_else(|e| panic!("{axis}: {e}"));
let out = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{e}"));
let comment = format!("// the {axis} comment");
assert!(out.contains(&comment), "{axis}: comment lost:\n{out}");
let v = Vocabularies::parse(&out).unwrap_or_else(|e| panic!("{axis}: {e}"));
let table = match axis {
"kind" => &v.kind,
"effects" => &v.effects,
"look" => &v.look,
"surfaces" => &v.surfaces,
_ => unreachable!(),
};
assert_eq!(
table.tokens.last().map(|t| t.name.as_str()),
Some(token),
"{axis}: new token must be last"
);
assert_eq!(table.tokens.len(), 2, "{axis}: exactly one record added");
for (other, other_table) in [
("kind", &v.kind),
("effects", &v.effects),
("look", &v.look),
("surfaces", &v.surfaces),
] {
if other != axis {
assert_eq!(
other_table.tokens.len(),
1,
"{axis}: `{other}` must not gain a record:\n{out}"
);
}
}
}
}
#[test]
fn a_refused_append_leaves_the_file_untouched() {
let cases: Vec<(&str, &str, &str)> = vec![
("kind", "seating", "a duplicate"),
("kind", "table", ""),
("kind", "table", "a \"quoted\" note"),
("kind", "table", "a \\ note"),
("kind", "table", "two\nlines"),
("capabilities", "eat", "can take a meal"),
("kind", "Table", "a thing with a top"),
];
for (axis, name, note) in cases {
let path = temp_vocab(&format!("{axis}-{name}"), COMMENTED);
let before = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{e}"));
let err = append_token(&path, axis, name, note)
.err()
.unwrap_or_else(|| panic!("`{name}` on `{axis}` should have been refused"));
assert!(!err.is_empty(), "a refusal must say something");
let after = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(before, after, "`{name}` on `{axis}` must not write: {err}");
}
}
}