use core::fmt;
use std::collections::HashMap;
use crate::counting::CountingFlags;
use crate::definition::{
AddressDef, AddressPath, ContainerDef, ExternalFnDef, GlobalVarDef, LineEntry, ListDef,
ListItemDef,
};
use crate::id::DefinitionId;
use crate::line::{LineContent, LinePart, SelectKey};
use crate::opcode::{ChoiceFlags, Opcode, SequenceKind};
use crate::story::StoryData;
use crate::value::{ListValue, Value, ValueType};
pub fn write_inkt(story: &StoryData, w: &mut dyn fmt::Write) -> fmt::Result {
if story.source_checksum != 0 {
writeln!(w, "(story checksum=0x{:08x}", story.source_checksum)?;
} else {
writeln!(w, "(story")?;
}
write_name_table(w, &story.name_table)?;
write_globals(w, &story.variables)?;
write_lists(w, &story.list_defs)?;
write_list_items(w, &story.list_items)?;
write_externals(w, &story.externals)?;
write_addresses(w, &story.addresses)?;
write_address_paths(w, &story.address_paths)?;
write_list_literals(w, &story.list_literals)?;
let line_map: HashMap<DefinitionId, &[LineEntry]> = story
.line_tables
.iter()
.map(|lt| (lt.scope_id, lt.lines.as_slice()))
.collect();
for container in &story.containers {
let lines = if container.scope_id == container.id {
line_map.get(&container.scope_id).copied().unwrap_or(&[])
} else {
&[]
};
write_container(w, container, lines)?;
}
write!(w, ")")
}
impl fmt::Display for StoryData {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write_inkt(self, f)
}
}
fn write_name_table(w: &mut dyn fmt::Write, names: &[String]) -> fmt::Result {
if names.is_empty() {
return Ok(());
}
writeln!(w)?;
writeln!(w, " (name_table")?;
for (i, name) in names.iter().enumerate() {
writeln!(w, " {i} \"{}\"", escape_string(name))?;
}
writeln!(w, " )")
}
fn write_globals(w: &mut dyn fmt::Write, globals: &[GlobalVarDef]) -> fmt::Result {
if globals.is_empty() {
return Ok(());
}
writeln!(w)?;
writeln!(w, " (globals")?;
for g in globals {
write!(
w,
" (global {} :{} ",
g.id,
value_type_name(g.value_type)
)?;
write_value(w, &g.default_value)?;
if g.mutable {
write!(w, " mutable")?;
}
writeln!(w)?;
writeln!(w, " (name {}))", g.name.0)?;
}
writeln!(w, " )")
}
fn write_lists(w: &mut dyn fmt::Write, list_defs: &[ListDef]) -> fmt::Result {
if list_defs.is_empty() {
return Ok(());
}
writeln!(w)?;
writeln!(w, " (lists")?;
for ld in list_defs {
writeln!(w, " (list {}", ld.id)?;
writeln!(w, " (name {})", ld.name.0)?;
for (item_name, ordinal) in &ld.items {
writeln!(w, " (item name={} ordinal={ordinal})", item_name.0)?;
}
writeln!(w, " )")?;
}
writeln!(w, " )")
}
fn write_list_items(w: &mut dyn fmt::Write, list_items: &[ListItemDef]) -> fmt::Result {
if list_items.is_empty() {
return Ok(());
}
writeln!(w)?;
writeln!(w, " (list_items")?;
for li in list_items {
writeln!(
w,
" (list_item {} (origin {}) (ordinal {}) (name {}))",
li.id, li.origin, li.ordinal, li.name.0
)?;
}
writeln!(w, " )")
}
fn write_list_literals(w: &mut dyn fmt::Write, list_literals: &[ListValue]) -> fmt::Result {
if list_literals.is_empty() {
return Ok(());
}
writeln!(w)?;
writeln!(w, " (list_literals")?;
for lv in list_literals {
write!(w, " (list (items")?;
for item in &lv.items {
write!(w, " {item}")?;
}
write!(w, ") (origins")?;
for origin in &lv.origins {
write!(w, " {origin}")?;
}
writeln!(w, "))")?;
}
writeln!(w, " )")
}
fn write_externals(w: &mut dyn fmt::Write, externals: &[ExternalFnDef]) -> fmt::Result {
if externals.is_empty() {
return Ok(());
}
writeln!(w)?;
writeln!(w, " (externals")?;
for ext in externals {
write!(w, " (extern {} argc={}", ext.id, ext.arg_count)?;
writeln!(w)?;
writeln!(w, " (name {})", ext.name.0)?;
if let Some(fb) = ext.fallback {
writeln!(w, " (fallback {fb})")?;
}
writeln!(w, " )")?;
}
writeln!(w, " )")
}
fn write_addresses(w: &mut dyn fmt::Write, addresses: &[AddressDef]) -> fmt::Result {
if addresses.is_empty() {
return Ok(());
}
writeln!(w)?;
writeln!(w, " (addresses")?;
for addr in addresses {
writeln!(
w,
" (address {} -> {} +{})",
addr.id, addr.container_id, addr.byte_offset
)?;
}
writeln!(w, " )")
}
fn write_address_paths(w: &mut dyn fmt::Write, address_paths: &[AddressPath]) -> fmt::Result {
if address_paths.is_empty() {
return Ok(());
}
writeln!(w)?;
writeln!(w, " (address_paths")?;
for ap in address_paths {
writeln!(w, " (path {} -> {})", ap.path.0, ap.target)?;
}
writeln!(w, " )")
}
fn write_container(w: &mut dyn fmt::Write, c: &ContainerDef, lines: &[LineEntry]) -> fmt::Result {
writeln!(w)?;
writeln!(w, " (container {}", c.id)?;
if c.scope_id != c.id {
writeln!(w, " (scope {})", c.scope_id)?;
}
if let Some(name_id) = c.name {
writeln!(w, " (name {})", name_id.0)?;
}
if !c.counting_flags.is_empty() {
write!(w, " (flags")?;
if c.counting_flags.contains(CountingFlags::VISITS) {
write!(w, " visits")?;
}
if c.counting_flags.contains(CountingFlags::TURNS) {
write!(w, " turns")?;
}
if c.counting_flags.contains(CountingFlags::COUNT_START_ONLY) {
write!(w, " start_only")?;
}
writeln!(w, ")")?;
}
if c.path_hash != 0 {
writeln!(w, " (path_hash {})", c.path_hash)?;
}
if c.param_count != 0 {
writeln!(w, " (params {})", c.param_count)?;
}
if !lines.is_empty() {
writeln!(w, " (lines")?;
for (i, entry) in lines.iter().enumerate() {
write!(w, " {i} ")?;
write_line_content(w, &entry.content)?;
write!(w, " @{:016x}", entry.source_hash)?;
if let Some(audio) = &entry.audio_ref {
write!(w, " (audio \"{}\")", escape_string(audio))?;
}
if !entry.slot_info.is_empty() {
write!(w, " (slots")?;
for slot in &entry.slot_info {
write!(w, " {}:\"{}\"", slot.index, escape_string(&slot.name))?;
}
write!(w, ")")?;
}
if let Some(loc) = &entry.source_location {
write!(
w,
" (source \"{}\" {}..{})",
escape_string(&loc.file),
loc.range_start,
loc.range_end,
)?;
}
writeln!(w)?;
}
writeln!(w, " )")?;
}
if !c.bytecode.is_empty() {
writeln!(w, " (code")?;
write_bytecode(w, &c.bytecode)?;
writeln!(w, " )")?;
}
writeln!(w, " )")
}
fn write_line_content(w: &mut dyn fmt::Write, content: &LineContent) -> fmt::Result {
match content {
LineContent::Plain(s) => write!(w, "\"{}\"", escape_string(s)),
LineContent::Template(parts) => {
write!(w, "(template")?;
for part in parts {
write!(w, " ")?;
match part {
LinePart::Literal(s) => write!(w, "(lit \"{}\")", escape_string(s))?,
LinePart::Slot(idx) => write!(w, "(slot {idx})")?,
LinePart::Select {
slot,
variants,
default,
} => {
write!(w, "(select slot={slot}")?;
for (key, text) in variants {
write!(w, " (")?;
write_select_key(w, key)?;
write!(w, " \"{}\")", escape_string(text))?;
}
write!(w, " (default \"{}\"))", escape_string(default))?;
}
}
}
write!(w, ")")
}
}
}
fn write_select_key(w: &mut dyn fmt::Write, key: &SelectKey) -> fmt::Result {
match key {
SelectKey::Cardinal(cat) => write!(w, "cardinal:{cat:?}"),
SelectKey::Ordinal(cat) => write!(w, "ordinal:{cat:?}"),
SelectKey::Exact(n) => write!(w, "={n}"),
SelectKey::Keyword(k) => write!(w, "keyword:{k}"),
}
}
fn write_bytecode(w: &mut dyn fmt::Write, bytecode: &[u8]) -> fmt::Result {
let mut offset = 0;
while offset < bytecode.len() {
match Opcode::decode(bytecode, &mut offset) {
Ok(op) => {
write!(w, " ")?;
write_opcode(w, &op)?;
writeln!(w)?;
}
Err(e) => {
writeln!(w, " <decode error: {e}>")?;
break;
}
}
}
Ok(())
}
#[expect(clippy::too_many_lines)]
fn write_opcode(w: &mut dyn fmt::Write, op: &Opcode) -> fmt::Result {
match op {
Opcode::PushInt(v) => write!(w, "push_int {v}"),
Opcode::PushFloat(v) => write!(w, "push_float {v}"),
Opcode::PushBool(v) => write!(w, "push_bool {v}"),
Opcode::PushString(idx) => write!(w, "push_string {idx}"),
Opcode::PushList(idx) => write!(w, "push_list {idx}"),
Opcode::PushDivertTarget(id) => write!(w, "push_divert_target {id}"),
Opcode::PushNull => write!(w, "push_null"),
Opcode::Pop => write!(w, "pop"),
Opcode::Duplicate => write!(w, "duplicate"),
Opcode::Add => write!(w, "add"),
Opcode::Subtract => write!(w, "subtract"),
Opcode::Multiply => write!(w, "multiply"),
Opcode::Divide => write!(w, "divide"),
Opcode::Modulo => write!(w, "modulo"),
Opcode::Negate => write!(w, "negate"),
Opcode::Equal => write!(w, "equal"),
Opcode::NotEqual => write!(w, "not_equal"),
Opcode::Greater => write!(w, "greater"),
Opcode::GreaterOrEqual => write!(w, "greater_or_equal"),
Opcode::Less => write!(w, "less"),
Opcode::LessOrEqual => write!(w, "less_or_equal"),
Opcode::Not => write!(w, "not"),
Opcode::And => write!(w, "and"),
Opcode::Or => write!(w, "or"),
Opcode::GetGlobal(id) => write!(w, "get_global {id}"),
Opcode::SetGlobal(id) => write!(w, "set_global {id}"),
Opcode::DeclareTemp(idx) => write!(w, "declare_temp {idx}"),
Opcode::GetTemp(idx) => write!(w, "get_temp {idx}"),
Opcode::SetTemp(idx) => write!(w, "set_temp {idx}"),
Opcode::GetTempRaw(idx) => write!(w, "get_temp_raw {idx}"),
Opcode::PushVarPointer(id) => write!(w, "push_var_pointer {id}"),
Opcode::PushTempPointer(slot) => write!(w, "push_temp_pointer {slot}"),
Opcode::Jump(off) => write!(w, "jump {off}"),
Opcode::JumpIfFalse(off) => write!(w, "jump_if_false {off}"),
Opcode::Goto(id) => write!(w, "goto {id}"),
Opcode::GotoIf(id) => write!(w, "goto_if {id}"),
Opcode::GotoVariable => write!(w, "goto_variable"),
Opcode::EnterContainer(id) => write!(w, "enter_container {id}"),
Opcode::ExitContainer => write!(w, "exit_container"),
Opcode::Call(id) => write!(w, "call {id}"),
Opcode::Return => write!(w, "return"),
Opcode::TunnelCall(id) => write!(w, "tunnel_call {id}"),
Opcode::TunnelReturn => write!(w, "tunnel_return"),
Opcode::TunnelCallVariable => write!(w, "tunnel_call_variable"),
Opcode::CallVariable => write!(w, "call_variable"),
Opcode::ThreadCall(id) => write!(w, "thread_call {id}"),
Opcode::ThreadStart => write!(w, "thread_start"),
Opcode::ThreadDone => write!(w, "thread_done"),
Opcode::EmitLine(idx, slots) => write!(w, "emit_line {idx} {slots}"),
Opcode::EmitValue => write!(w, "emit_value"),
Opcode::EmitNewline => write!(w, "emit_newline"),
Opcode::Spring => write!(w, "spring"),
Opcode::Glue => write!(w, "glue"),
Opcode::BeginTag => write!(w, "begin_tag"),
Opcode::EndTag => write!(w, "end_tag"),
Opcode::EvalLine(idx, slots) => write!(w, "eval_line {idx} {slots}"),
Opcode::BeginFragment => write!(w, "begin_fragment"),
Opcode::EndFragment => write!(w, "end_fragment"),
Opcode::BeginChoice(flags, target) => {
write!(w, "begin_choice {} {target}", format_choice_flags(*flags))
}
Opcode::EndChoice => write!(w, "end_choice"),
Opcode::Sequence(kind, count) => {
write!(w, "sequence {} {count}", format_sequence_kind(*kind))
}
Opcode::SequenceBranch(off) => write!(w, "sequence_branch {off}"),
Opcode::VisitCount => write!(w, "visit_count"),
Opcode::TurnsSince => write!(w, "turns_since"),
Opcode::TurnIndex => write!(w, "turn_index"),
Opcode::ChoiceCount => write!(w, "choice_count"),
Opcode::Random => write!(w, "random"),
Opcode::SeedRandom => write!(w, "seed_random"),
Opcode::CastToInt => write!(w, "cast_to_int"),
Opcode::CastToFloat => write!(w, "cast_to_float"),
Opcode::Floor => write!(w, "floor"),
Opcode::Ceiling => write!(w, "ceiling"),
Opcode::Pow => write!(w, "pow"),
Opcode::Min => write!(w, "min"),
Opcode::Max => write!(w, "max"),
Opcode::CallExternal(id, argc) => write!(w, "call_external {id} argc={argc}"),
Opcode::ListContains => write!(w, "list_contains"),
Opcode::ListNotContains => write!(w, "list_not_contains"),
Opcode::ListIntersect => write!(w, "list_intersect"),
Opcode::ListAll => write!(w, "list_all"),
Opcode::ListInvert => write!(w, "list_invert"),
Opcode::ListCount => write!(w, "list_count"),
Opcode::ListMin => write!(w, "list_min"),
Opcode::ListMax => write!(w, "list_max"),
Opcode::ListValue => write!(w, "list_value"),
Opcode::ListRange => write!(w, "list_range"),
Opcode::ListFromInt => write!(w, "list_from_int"),
Opcode::ListRandom => write!(w, "list_random"),
Opcode::Done => write!(w, "done"),
Opcode::Yield => write!(w, "yield"),
Opcode::End => write!(w, "end"),
Opcode::Nop => write!(w, "nop"),
Opcode::BeginStringEval => write!(w, "begin_string_eval"),
Opcode::EndStringEval => write!(w, "end_string_eval"),
Opcode::CurrentVisitCount => write!(w, "current_visit_count"),
Opcode::SourceLocation(line, col) => write!(w, "source_location {line}:{col}"),
}
}
fn format_choice_flags(flags: ChoiceFlags) -> String {
let mut parts = Vec::new();
if flags.has_condition {
parts.push("cond");
}
if flags.has_start_content {
parts.push("start");
}
if flags.has_choice_only_content {
parts.push("choice_only");
}
if flags.once_only {
parts.push("once");
}
if flags.is_invisible_default {
parts.push("invis_default");
}
if parts.is_empty() {
"none".to_owned()
} else {
parts.join("+")
}
}
fn format_sequence_kind(kind: SequenceKind) -> &'static str {
match kind {
SequenceKind::Cycle => "cycle",
SequenceKind::Stopping => "stopping",
SequenceKind::OnceOnly => "once_only",
SequenceKind::Shuffle => "shuffle",
}
}
fn value_type_name(vt: ValueType) -> &'static str {
match vt {
ValueType::Int => "int",
ValueType::Float => "float",
ValueType::Bool => "bool",
ValueType::String => "string",
ValueType::List => "list",
ValueType::DivertTarget => "divert_target",
ValueType::VariablePointer => "var_pointer",
ValueType::TempPointer => "temp_pointer",
ValueType::Null => "null",
ValueType::FragmentRef => "fragment_ref",
}
}
fn write_value(w: &mut dyn fmt::Write, v: &Value) -> fmt::Result {
match v {
Value::Int(n) => write!(w, "{n}"),
Value::Float(n) => {
let s = format!("{n}");
if s.contains('.') || s.contains("inf") || s.contains("NaN") {
write!(w, "{s}")
} else {
write!(w, "{s}.0")
}
}
Value::Bool(b) => write!(w, "{b}"),
Value::String(s) => write!(w, "\"{}\"", escape_string(s)),
Value::List(lv) => {
write!(w, "(list (items")?;
for item in &lv.items {
write!(w, " {item}")?;
}
write!(w, ") (origins")?;
for origin in &lv.origins {
write!(w, " {origin}")?;
}
write!(w, "))")
}
Value::DivertTarget(id) => write!(w, "{id}"),
Value::VariablePointer(id) => write!(w, "(var_pointer {id})"),
Value::TempPointer { slot, frame_depth } => {
write!(w, "(temp_pointer {slot} {frame_depth})")
}
Value::Null => write!(w, "null"),
Value::FragmentRef(idx) => write!(w, "(fragment_ref {idx})"),
}
}
pub(crate) fn escape_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\r' => out.push_str("\\r"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::id::{DefinitionId, DefinitionTag};
#[test]
fn definition_id_display() {
let id = DefinitionId::new(DefinitionTag::Address, 0xDEAD_BEEF);
assert_eq!(format!("{id}"), "$01_000000deadbeef");
}
#[test]
fn escape_special_chars() {
assert_eq!(escape_string("hello"), "hello");
assert_eq!(escape_string("a\"b"), "a\\\"b");
assert_eq!(escape_string("a\\b"), "a\\\\b");
assert_eq!(escape_string("a\nb"), "a\\nb");
assert_eq!(escape_string("a\tb"), "a\\tb");
}
#[test]
fn empty_story() {
let story = StoryData {
containers: vec![],
line_tables: vec![],
variables: vec![],
list_defs: vec![],
list_items: vec![],
externals: vec![],
addresses: vec![],
address_paths: vec![],
name_table: vec![],
list_literals: vec![],
source_checksum: 0,
};
let mut buf = String::new();
write_inkt(&story, &mut buf).unwrap();
assert_eq!(buf, "(story\n)");
}
#[test]
fn choice_flags_formatting() {
let flags = ChoiceFlags {
has_condition: true,
has_start_content: false,
has_choice_only_content: false,
once_only: true,
is_invisible_default: false,
};
assert_eq!(format_choice_flags(flags), "cond+once");
let empty = ChoiceFlags {
has_condition: false,
has_start_content: false,
has_choice_only_content: false,
once_only: false,
is_invisible_default: false,
};
assert_eq!(format_choice_flags(empty), "none");
}
}