use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DialogueDialect {
pub version: u32,
#[serde(default)]
pub name: String,
#[serde(default)]
pub elements: Vec<DialectElement>,
#[serde(default)]
pub chain: Vec<ChainRule>,
#[serde(default)]
pub transitions: Vec<TransitionRow>,
#[serde(default)]
pub templates: Templates,
}
impl Default for DialogueDialect {
fn default() -> Self {
at_cue_preset()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DialectElement {
pub kind: String,
pub nature: ElementNature,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<SourceShape>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub emitted: Option<EmittedShape>,
#[serde(default)]
pub malformed: Vec<MalformedRule>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ElementNature {
Narrative,
Machinery,
Structural,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SourceShape {
Pattern(PatternShape),
Affix(AffixShape),
}
impl SourceShape {
#[must_use]
pub fn resolve(&self) -> PatternShape {
match self {
SourceShape::Pattern(p) => p.clone(),
SourceShape::Affix(a) => compile_affix(a),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PatternShape {
pub pattern: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template_group: Option<String>,
#[serde(default)]
pub hidden: Vec<String>,
pub template: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AffixShape {
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub suffix: Option<String>,
#[serde(default)]
pub glued: bool,
#[serde(default = "default_content_role")]
pub content_role: String,
}
fn default_content_role() -> String {
"content".to_owned()
}
const GLUE: &str = "<>";
#[must_use]
pub fn compile_affix(affix: &AffixShape) -> PatternShape {
use std::fmt::Write as _;
let prefix = affix.prefix.as_deref().unwrap_or("");
let mut suffix = affix.suffix.as_deref().unwrap_or("").to_owned();
if affix.glued {
suffix.push_str(GLUE);
}
let role = affix.content_role.as_str();
let mut pattern = String::from("^");
let mut hidden = Vec::new();
let mut template = String::new();
if !prefix.is_empty() {
pattern.push_str("(?<lead>");
pattern.push_str(®ex_escape_literal(prefix));
pattern.push(')');
hidden.push("lead".to_owned());
template.push_str(prefix);
}
if let Some(first) = suffix.chars().next() {
let _ = write!(pattern, "(?<{role}>[^{}]*)", regex_escape_class_char(first));
} else {
let _ = write!(pattern, "(?<{role}>.*)");
}
let _ = write!(template, "${{{role}}}");
if !suffix.is_empty() {
pattern.push_str("(?<tail>");
pattern.push_str(®ex_escape_literal(&suffix));
pattern.push(')');
hidden.push("tail".to_owned());
template.push_str(&suffix);
}
pattern.push('$');
PatternShape {
pattern,
content_group: Some(role.to_owned()),
template_group: None,
hidden,
template,
}
}
fn regex_escape_literal(s: &str) -> String {
regex::escape(s)
}
fn regex_escape_class_char(c: char) -> String {
if matches!(c, ']' | '\\' | '^' | '-') {
format!("\\{c}")
} else {
c.to_string()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EmittedShape {
pub pattern: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_group: Option<String>,
#[serde(default)]
pub reserved_prefix: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChainRule {
pub after: Vec<String>,
#[serde(default = "default_chain_is")]
pub is: Vec<String>,
pub becomes: String,
#[serde(default)]
pub carry: Vec<String>,
#[serde(default)]
pub run_ends_at: Vec<String>,
}
fn default_chain_is() -> Vec<String> {
vec!["narrative".to_owned()]
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransitionRow {
pub on: String,
pub key: String,
#[serde(default)]
pub has_content: Option<bool>,
pub action: TransitionAction,
#[serde(default)]
pub hint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum TransitionAction {
Convert { kind: String },
Newline,
Strip,
Clear,
Trap,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Templates {
#[serde(default)]
pub entries: Vec<TemplateEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemplateEntry {
pub kind: String,
pub label: String,
#[serde(default)]
pub picker_key: Option<String>,
#[serde(default)]
pub blank_tab: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MalformedRule {
pub pattern: String,
pub message: String,
#[serde(default = "default_severity")]
pub severity: String,
}
fn default_severity() -> String {
"warning".to_owned()
}
#[must_use]
pub fn reserved_structural_kinds() -> &'static [&'static str] {
&[
"knot_header",
"stitch_header",
"narrative",
"choice",
"choice_body",
"gather",
"divert",
"logic",
"var_decl",
"comment",
"include",
"external",
"tag",
"blank",
]
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DialectError {
#[error("unsupported dialect version {0} (only version 1 is defined)")]
UnsupportedVersion(u32),
#[error("kind '{kind}': pattern uses a non-portable construct: {reason}")]
NonPortablePattern { kind: String, reason: String },
#[error("kind '{kind}': pattern failed to compile: {reason}")]
InvalidPattern { kind: String, reason: String },
#[error("kind '{kind}': template '{template}' does not round-trip against its pattern")]
TemplateRoundtripFailed { kind: String, template: String },
#[error("chain rule references undeclared, non-structural kind '{0}'")]
ChainUndeclaredKind(String),
#[error("transition row references undeclared, non-structural kind '{0}'")]
TransitionUndeclaredKind(String),
#[error("template entry references undeclared, non-structural kind '{0}'")]
TemplateUndeclaredKind(String),
#[error("duplicate element kind '{0}'")]
DuplicateKind(String),
#[error("chain rule produces undeclared kind '{0}' (add it to `elements` with no `source`)")]
ChainBecomesUndeclared(String),
}
pub fn validate(dialect: &DialogueDialect) -> Result<(), Vec<DialectError>> {
let mut errors = Vec::new();
if dialect.version != 1 {
errors.push(DialectError::UnsupportedVersion(dialect.version));
}
let mut seen_kinds = BTreeSet::new();
let mut declared_kinds = BTreeSet::new();
for el in &dialect.elements {
if !seen_kinds.insert(el.kind.clone()) {
errors.push(DialectError::DuplicateKind(el.kind.clone()));
}
declared_kinds.insert(el.kind.clone());
if let Some(source) = &el.source {
let resolved = source.resolve();
if let Err(reason) = check_portable_pattern(&resolved.pattern) {
errors.push(DialectError::NonPortablePattern {
kind: el.kind.clone(),
reason,
});
continue;
}
match regex::Regex::new(&resolved.pattern) {
Ok(re) => {
if !validate_template_roundtrip(&re, &resolved) {
errors.push(DialectError::TemplateRoundtripFailed {
kind: el.kind.clone(),
template: resolved.template.clone(),
});
}
}
Err(e) => {
errors.push(DialectError::InvalidPattern {
kind: el.kind.clone(),
reason: e.to_string(),
});
}
}
}
}
let reserved: BTreeSet<&str> = reserved_structural_kinds().iter().copied().collect();
let is_known = |k: &str| declared_kinds.contains(k) || reserved.contains(k);
for rule in &dialect.chain {
for k in rule.after.iter().chain(rule.is.iter()) {
if !is_known(k) {
errors.push(DialectError::ChainUndeclaredKind(k.clone()));
}
}
if !declared_kinds.contains(&rule.becomes) {
errors.push(DialectError::ChainBecomesUndeclared(rule.becomes.clone()));
}
for k in &rule.run_ends_at {
if k != "choices" && !is_known(k) {
errors.push(DialectError::ChainUndeclaredKind(k.clone()));
}
}
}
errors.extend(validate_succession(
&dialect.transitions,
&dialect.templates,
is_known,
));
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[must_use]
pub fn validate_succession(
transitions: &[TransitionRow],
templates: &Templates,
is_known: impl Fn(&str) -> bool,
) -> Vec<DialectError> {
let mut errors = Vec::new();
for row in transitions {
if !is_known(&row.on) {
errors.push(DialectError::TransitionUndeclaredKind(row.on.clone()));
}
if let TransitionAction::Convert { kind } = &row.action
&& !is_known(kind)
{
errors.push(DialectError::TransitionUndeclaredKind(kind.clone()));
}
}
for entry in &templates.entries {
if !is_known(&entry.kind) {
errors.push(DialectError::TemplateUndeclaredKind(entry.kind.clone()));
}
}
errors
}
fn check_portable_pattern(pattern: &str) -> Result<(), String> {
let bytes = pattern.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' {
if let Some(&next) = bytes.get(i + 1) {
if next.is_ascii_digit() && next != b'0' {
return Err("backreferences are not allowed".to_owned());
}
if next == b'k' && bytes.get(i + 2) == Some(&b'<') {
return Err("backreferences are not allowed".to_owned());
}
}
i += 2;
continue;
}
if bytes[i] == b'(' && bytes.get(i + 1) == Some(&b'?') {
let rest = &pattern[i + 2..];
if rest.starts_with('=') || rest.starts_with('!') {
return Err("lookahead is not allowed".to_owned());
}
if rest.starts_with("<=") || rest.starts_with("<!") {
return Err("lookbehind is not allowed".to_owned());
}
}
i += 1;
}
Ok(())
}
fn validate_template_roundtrip(re: ®ex::Regex, shape: &PatternShape) -> bool {
const CANDIDATES: &[&str] = &["PROBE", "(PROBE)", "[PROBE]"];
CANDIDATES
.iter()
.any(|probe| roundtrips_with_probe(re, shape, probe))
}
fn roundtrips_with_probe(re: ®ex::Regex, shape: &PatternShape, probe: &str) -> bool {
let mut rendered = shape.template.clone();
for name in re.capture_names().flatten() {
rendered = rendered.replace(&format!("${{{name}}}"), probe);
}
let checked_group = shape
.template_group
.as_deref()
.or(shape.content_group.as_deref());
match re.captures(&rendered) {
Some(caps) => {
if let Some(checked_group) = checked_group {
caps.name(checked_group)
.is_some_and(|m| m.as_str() == probe)
} else {
true
}
}
None => false,
}
}
pub struct ResolvedDialect {
elements: Vec<ResolvedElement>,
chain: Vec<ChainRule>,
}
struct ResolvedElement {
decl: DialectElement,
pattern: Option<(regex::Regex, PatternShape)>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DialectMatch {
pub kind: String,
pub attrs: Vec<(String, String)>,
pub hidden_spans: Vec<(u32, u32)>,
pub content_span: Option<(u32, u32)>,
}
impl ResolvedDialect {
pub fn compile(dialect: &DialogueDialect) -> Result<Self, DialectError> {
let mut elements = Vec::with_capacity(dialect.elements.len());
for decl in &dialect.elements {
let pattern = match &decl.source {
Some(source) => {
let resolved = source.resolve();
let re = regex::Regex::new(&resolved.pattern).map_err(|e| {
DialectError::InvalidPattern {
kind: decl.kind.clone(),
reason: e.to_string(),
}
})?;
Some((re, resolved))
}
None => None,
};
elements.push(ResolvedElement {
decl: decl.clone(),
pattern,
});
}
Ok(Self {
elements,
chain: dialect.chain.clone(),
})
}
#[must_use]
pub fn classify(&self, trimmed: &str, leading_ws: u32) -> Option<DialectMatch> {
for el in &self.elements {
let Some((re, shape)) = &el.pattern else {
continue;
};
if let Some(caps) = re.captures(trimmed) {
return Some(build_match(&el.decl.kind, re, &caps, shape, leading_ws));
}
}
None
}
#[must_use]
pub fn chain_rules(&self) -> &[ChainRule] {
&self.chain
}
#[must_use]
pub fn nature_of(&self, kind: &str) -> Option<ElementNature> {
self.elements
.iter()
.find(|el| el.decl.kind == kind)
.map(|el| el.decl.nature)
}
#[must_use]
pub fn chain_rule_after(&self, prev_kind: &str) -> Option<&ChainRule> {
self.chain
.iter()
.find(|r| r.after.iter().any(|k| k == prev_kind))
}
}
#[expect(clippy::cast_possible_truncation)]
fn span_of(m: ®ex::Match<'_>, leading_ws: u32) -> (u32, u32) {
(leading_ws + m.start() as u32, leading_ws + m.end() as u32)
}
fn build_match(
kind: &str,
re: ®ex::Regex,
caps: ®ex::Captures<'_>,
shape: &PatternShape,
leading_ws: u32,
) -> DialectMatch {
let mut attrs = Vec::new();
let mut hidden_spans = Vec::new();
let mut content_span = None;
let hidden: BTreeSet<&str> = shape.hidden.iter().map(String::as_str).collect();
for hidden_name in &shape.hidden {
if let Some(m) = caps.name(hidden_name) {
hidden_spans.push(span_of(&m, leading_ws));
}
}
if let Some(content_group) = &shape.content_group
&& let Some(m) = caps.name(content_group)
{
content_span = Some(span_of(&m, leading_ws));
}
let template_only_group: Option<&str> = shape
.template_group
.as_deref()
.filter(|g| Some(*g) != shape.content_group.as_deref());
for name in re.capture_names().flatten() {
if hidden.contains(name) || Some(name) == template_only_group {
continue;
}
if let Some(m) = caps.name(name) {
attrs.push((name.to_owned(), m.as_str().to_owned()));
}
}
attrs.sort();
DialectMatch {
kind: kind.to_owned(),
attrs,
hidden_spans,
content_span,
}
}
#[must_use]
pub fn at_cue_preset() -> DialogueDialect {
let character = DialectElement {
kind: "character".to_owned(),
nature: ElementNature::Narrative,
source: Some(SourceShape::Pattern(PatternShape {
pattern: r"^(?<lead>@)(?<speaker>[^:]*)(?<tail>:<>)$".to_owned(),
content_group: Some("speaker".to_owned()),
template_group: None,
hidden: vec!["lead".to_owned(), "tail".to_owned()],
template: "@${speaker}:<>".to_owned(),
})),
emitted: Some(EmittedShape {
pattern: r"^@(?<speaker>[^:]*):\s*".to_owned(),
content_group: Some("speaker".to_owned()),
reserved_prefix: true,
}),
malformed: vec![MalformedRule {
pattern: r"^@[^:]*$".to_owned(),
message: "Character cue is missing the ':<>' terminator".to_owned(),
severity: "warning".to_owned(),
}],
};
let parenthetical = DialectElement {
kind: "parenthetical".to_owned(),
nature: ElementNature::Narrative,
source: Some(SourceShape::Pattern(PatternShape {
pattern: r"^(?<content>\((?<content_inner>[^)]*)\))(?<tail><>)$".to_owned(),
content_group: Some("content".to_owned()),
template_group: Some("content_inner".to_owned()),
hidden: vec!["tail".to_owned()],
template: "(${content_inner})<>".to_owned(),
})),
emitted: Some(EmittedShape {
pattern: r"^(?<content>\([^)]*\))\s*".to_owned(),
content_group: Some("content".to_owned()),
reserved_prefix: false,
}),
malformed: vec![MalformedRule {
pattern: r"^\([^)]*\)$".to_owned(),
message: "Parenthetical is missing the '<>' terminator".to_owned(),
severity: "warning".to_owned(),
}],
};
let dialogue = DialectElement {
kind: "dialogue".to_owned(),
nature: ElementNature::Narrative,
source: None,
emitted: None,
malformed: Vec::new(),
};
DialogueDialect {
version: 1,
name: "at-cue".to_owned(),
elements: vec![character, parenthetical, dialogue],
chain: vec![ChainRule {
after: vec![
"character".to_owned(),
"parenthetical".to_owned(),
"dialogue".to_owned(),
],
is: vec!["narrative".to_owned()],
becomes: "dialogue".to_owned(),
carry: vec!["speaker".to_owned()],
run_ends_at: Vec::new(),
}],
transitions: Vec::new(),
templates: Templates {
entries: vec![
TemplateEntry {
kind: "character".to_owned(),
label: "Character cue".to_owned(),
picker_key: Some("@".to_owned()),
blank_tab: true,
},
TemplateEntry {
kind: "parenthetical".to_owned(),
label: "Parenthetical".to_owned(),
picker_key: Some("(".to_owned()),
blank_tab: false,
},
],
},
}
}
pub const PRESET_NAMES: &[&str] = &["at-cue"];
#[must_use]
pub fn preset_by_name(name: &str) -> Option<DialogueDialect> {
match name {
"at-cue" => Some(at_cue_preset()),
_ => None,
}
}
#[must_use]
pub fn extend_dialect(base: &DialogueDialect, overrides: &DialogueDialect) -> DialogueDialect {
let mut elements = base.elements.clone();
for el in &overrides.elements {
if let Some(existing) = elements.iter_mut().find(|e| e.kind == el.kind) {
*existing = el.clone();
} else {
elements.push(el.clone());
}
}
let mut entries = base.templates.entries.clone();
for entry in &overrides.templates.entries {
if let Some(existing) = entries.iter_mut().find(|e| e.kind == entry.kind) {
*existing = entry.clone();
} else {
entries.push(entry.clone());
}
}
let mut chain = base.chain.clone();
chain.extend(overrides.chain.iter().cloned());
let mut transitions = base.transitions.clone();
transitions.extend(overrides.transitions.iter().cloned());
DialogueDialect {
version: base.version,
name: if overrides.name.is_empty() {
base.name.clone()
} else {
overrides.name.clone()
},
elements,
chain,
transitions,
templates: Templates { entries },
}
}
#[must_use]
pub fn emitted_for_affix(affix: &AffixShape) -> EmittedShape {
use std::fmt::Write as _;
let prefix = affix.prefix.as_deref().unwrap_or("");
let suffix = affix.suffix.as_deref().unwrap_or("");
let role = affix.content_role.as_str();
let mut pattern = String::from("^");
if !prefix.is_empty() {
pattern.push_str(®ex_escape_literal(prefix));
pattern.push_str(r"\s*");
}
if suffix.is_empty() {
let _ = write!(pattern, "(?<{role}>.*)$");
} else {
let first = suffix.chars().next().unwrap_or(' ');
let _ = write!(pattern, "(?<{role}>[^{}]*)", regex_escape_class_char(first));
pattern.push_str(®ex_escape_literal(suffix));
pattern.push_str(r"\s*");
}
EmittedShape {
pattern,
content_group: Some(role.to_owned()),
reserved_prefix: !prefix.is_empty(),
}
}
#[must_use]
pub fn affix_element(kind: &str, nature: ElementNature, affix: AffixShape) -> DialectElement {
DialectElement {
kind: kind.to_owned(),
nature,
emitted: Some(emitted_for_affix(&affix)),
source: Some(SourceShape::Affix(affix)),
malformed: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_at_cue_preset() {
let d = DialogueDialect::default();
assert_eq!(d.name, "at-cue");
assert_eq!(d.elements.len(), 3);
}
#[test]
fn at_cue_preset_validates() {
let d = at_cue_preset();
assert_eq!(validate(&d), Ok(()));
}
#[test]
fn character_cue_classifies() {
let d = ResolvedDialect::compile(&at_cue_preset()).expect("compile");
let m = d.classify("@Alice:<>", 0).expect("match");
assert_eq!(m.kind, "character");
assert_eq!(m.attrs, vec![("speaker".to_owned(), "Alice".to_owned())]);
assert_eq!(m.hidden_spans, vec![(0, 1), (6, 9)]);
assert_eq!(m.content_span, Some((1, 6)));
}
#[test]
fn parenthetical_classifies_with_parens_in_content() {
let d = ResolvedDialect::compile(&at_cue_preset()).expect("compile");
let m = d.classify("(warmly)<>", 0).expect("match");
assert_eq!(m.kind, "parenthetical");
assert_eq!(m.content_span, Some((0, 8)));
assert_eq!(m.hidden_spans, vec![(8, 10)]);
}
#[test]
fn parenthetical_template_group_is_bare_and_excluded_from_attrs() {
let d = ResolvedDialect::compile(&at_cue_preset()).expect("compile");
let m = d.classify("(warmly)<>", 0).expect("match");
assert_eq!(m.kind, "parenthetical");
assert_eq!(m.content_span, Some((0, 8)));
assert_eq!(m.attrs, vec![("content".to_owned(), "(warmly)".to_owned())]);
}
#[test]
fn parenthetical_template_group_round_trips_bare_content_through_template() {
let dialect = at_cue_preset();
let el = dialect
.elements
.iter()
.find(|e| e.kind == "parenthetical")
.expect("parenthetical element");
let source = el
.source
.as_ref()
.expect("parenthetical has a source shape");
let SourceShape::Pattern(shape) = source else {
unreachable!("at_cue_preset's parenthetical is a raw Pattern shape, not Affix sugar");
};
let role = shape.template_group.as_deref().expect("template_group set");
assert_eq!(role, "content_inner");
let rendered = shape.template.replace(&format!("${{{role}}}"), "radio");
assert_eq!(rendered, "(radio)<>");
let re = regex::Regex::new(&shape.pattern).expect("pattern compiles");
let caps = re.captures(&rendered).expect("rendered line re-matches");
assert_eq!(caps.name(role).map(|m| m.as_str()), Some("radio"));
}
#[test]
fn plain_prose_does_not_classify() {
let d = ResolvedDialect::compile(&at_cue_preset()).expect("compile");
assert!(d.classify("Hello world", 0).is_none());
assert!(d.classify("@channel: hello", 0).is_none());
assert!(d.classify("(aside) unterminated", 0).is_none());
}
#[test]
fn affix_sugar_compiles_to_equivalent_pattern() {
let affix = AffixShape {
prefix: Some("@".to_owned()),
suffix: Some(":".to_owned()),
glued: true,
content_role: "speaker".to_owned(),
};
let compiled = compile_affix(&affix);
let re = regex::Regex::new(&compiled.pattern).expect("valid regex");
let caps = re.captures("@Bob:<>").expect("matches");
assert_eq!(&caps["speaker"], "Bob");
assert_eq!(compiled.template, "@${speaker}:<>");
}
#[test]
fn portable_pattern_rejects_lookaround_and_backrefs() {
assert!(check_portable_pattern(r"^(?=foo)bar$").is_err());
assert!(check_portable_pattern(r"^(?!foo)bar$").is_err());
assert!(check_portable_pattern(r"^(?<=foo)bar$").is_err());
assert!(check_portable_pattern(r"^(?<!foo)bar$").is_err());
assert!(check_portable_pattern(r"^(\w+)\1$").is_err());
assert!(check_portable_pattern(r"^(?<name>\w+)\k<name>$").is_err());
assert!(check_portable_pattern(r"^(?<lead>@)(?<speaker>[^:]*)$").is_ok());
}
#[test]
fn validate_rejects_non_portable_pattern() {
let mut d = at_cue_preset();
d.elements[0].source = Some(SourceShape::Pattern(PatternShape {
pattern: r"^(?=@)(?<speaker>[^:]*):<>$".to_owned(),
content_group: Some("speaker".to_owned()),
template_group: None,
hidden: Vec::new(),
template: "@${speaker}:<>".to_owned(),
}));
let result = validate(&d);
assert!(result.is_err());
}
#[test]
fn validate_rejects_undeclared_chain_kind() {
let mut d = at_cue_preset();
d.chain[0].after.push("nonexistent_kind".to_owned());
let errs = validate(&d).expect_err("should fail");
assert!(
errs.iter().any(
|e| matches!(e, DialectError::ChainUndeclaredKind(k) if k == "nonexistent_kind")
)
);
}
#[test]
fn validate_allows_reserved_structural_chain_kind() {
let mut d = at_cue_preset();
d.chain[0].after.push("narrative".to_owned());
assert_eq!(validate(&d), Ok(()));
}
#[test]
fn validate_rejects_undeclared_template_kind() {
let mut d = at_cue_preset();
d.templates.entries.push(TemplateEntry {
kind: "nonexistent_kind".to_owned(),
label: "Nonexistent".to_owned(),
picker_key: None,
blank_tab: false,
});
let errs = validate(&d).expect_err("should fail");
assert!(errs.iter().any(
|e| matches!(e, DialectError::TemplateUndeclaredKind(k) if k == "nonexistent_kind")
));
}
#[test]
fn validate_rejects_duplicate_kind() {
let mut d = at_cue_preset();
let dup = d.elements[0].clone();
d.elements.push(dup);
let errs = validate(&d).expect_err("should fail");
assert!(
errs.iter()
.any(|e| matches!(e, DialectError::DuplicateKind(k) if k == "character"))
);
}
#[test]
fn json_roundtrip() {
let d = at_cue_preset();
let json = serde_json::to_string_pretty(&d).expect("serialize");
let back: DialogueDialect = serde_json::from_str(&json).expect("deserialize");
assert_eq!(d, back);
}
#[test]
fn chain_rule_after_lookup() {
let d = ResolvedDialect::compile(&at_cue_preset()).expect("compile");
let rule = d.chain_rule_after("character").expect("rule");
assert_eq!(rule.becomes, "dialogue");
assert_eq!(rule.carry, vec!["speaker".to_owned()]);
assert!(d.chain_rule_after("choice").is_none());
}
#[test]
fn pattern_less_kind_has_no_pattern() {
let d = ResolvedDialect::compile(&at_cue_preset()).expect("compile");
assert!(d.classify("dialogue", 0).is_none());
}
#[test]
fn preset_registry_knows_at_cue_and_nothing_else() {
assert_eq!(PRESET_NAMES, &["at-cue"]);
assert_eq!(
preset_by_name("at-cue").map(|d| d.name),
Some("at-cue".to_owned())
);
assert!(preset_by_name("fountain").is_none());
}
#[test]
fn extend_replaces_same_kind_and_appends_new_kinds() {
let base = at_cue_preset();
let action = affix_element(
"action",
ElementNature::Narrative,
AffixShape {
prefix: Some(">".to_owned()),
suffix: None,
glued: false,
content_role: "content".to_owned(),
},
);
let mut replaced_dialogue = base.elements[2].clone();
replaced_dialogue.nature = ElementNature::Machinery;
let overlay = DialogueDialect {
version: 1,
name: String::new(),
elements: vec![action, replaced_dialogue],
chain: Vec::new(),
transitions: Vec::new(),
templates: Templates::default(),
};
let merged = extend_dialect(&base, &overlay);
assert_eq!(
merged.name, "at-cue",
"empty overlay name keeps the base name"
);
assert_eq!(
merged.elements.len(),
4,
"action appended, dialogue replaced in place"
);
assert_eq!(merged.elements[2].kind, "dialogue");
assert_eq!(merged.elements[2].nature, ElementNature::Machinery);
assert_eq!(merged.elements[3].kind, "action");
assert_eq!(merged.chain.len(), 1, "base chain kept");
validate(&merged).expect("the merged dialect validates");
ResolvedDialect::compile(&merged).expect("and compiles");
}
#[test]
fn affix_element_derives_both_source_and_emitted_shapes() {
let el = affix_element(
"action",
ElementNature::Narrative,
AffixShape {
prefix: Some(">".to_owned()),
suffix: None,
glued: false,
content_role: "content".to_owned(),
},
);
let src = el.source.as_ref().expect("source").resolve();
assert_eq!(src.pattern, r"^(?<lead>>)(?<content>.*)$");
assert_eq!(src.template, ">${content}");
let em = el.emitted.as_ref().expect("emitted");
assert_eq!(em.pattern, r"^>\s*(?<content>.*)$");
assert!(em.reserved_prefix, "a prefix makes the kind reserved");
let cue = emitted_for_affix(&AffixShape {
prefix: Some("@".to_owned()),
suffix: Some(":".to_owned()),
glued: true,
content_role: "speaker".to_owned(),
});
assert_eq!(cue.pattern, r"^@\s*(?<speaker>[^:]*):\s*");
assert!(cue.reserved_prefix);
}
}