From 3a85c5fcad786b5ae339ca4b6bd82a9bcb9df4ed Mon Sep 17 00:00:00 2001 From: widberg Date: Thu, 6 Aug 2026 21:00:30 -0400 Subject: [PATCH] Add a TSC executor --- README.md | 1 + bff/src/tsc/mod.rs | 6 + bff/src/tsc/tsc.rs | 831 +++++++++++++++++++++++++++++++++++++++++++++ bff/tests/tests.rs | 1 + bff/tests/tsc.rs | 105 ++++++ 5 files changed, 944 insertions(+) create mode 100644 bff/src/tsc/tsc.rs create mode 100644 bff/tests/tsc.rs diff --git a/README.md b/README.md index 08df109..7fea419 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ A ✔ indicates that the format has been tested and is working. An ❌ indicates | Format | Status | |--------|--------| +| tsc | ✔ | | csc | ✔ | | psc | ✔ | | CPS | ✔ | diff --git a/bff/src/tsc/mod.rs b/bff/src/tsc/mod.rs index 62b3909..94ec41c 100644 --- a/bff/src/tsc/mod.rs +++ b/bff/src/tsc/mod.rs @@ -2,8 +2,14 @@ mod cps; mod csc; mod mqfel_settings_bin; mod psc; +#[expect( + clippy::module_inception, + reason = "tsc.rs is the implementation module for the tsc format" +)] +mod tsc; pub use cps::*; pub use csc::*; pub use mqfel_settings_bin::*; pub use psc::*; +pub use tsc::*; diff --git a/bff/src/tsc/tsc.rs b/bff/src/tsc/tsc.rs new file mode 100644 index 0000000..5b4e627 --- /dev/null +++ b/bff/src/tsc/tsc.rs @@ -0,0 +1,831 @@ +use std::collections::BTreeSet; +use std::error::Error; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::{fmt, fs}; + +use encoding_rs::WINDOWS_1252; + +// TODO: This should all probably work better with the other tsc formats. + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Script { + pub commands: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CommandArgument { + pub string: String, + pub number: Option, +} + +impl CommandArgument { + #[must_use] + pub fn new(string: impl Into) -> Self { + let string = string.into(); + let numeric_string = string.strip_suffix('f').unwrap_or(&string); + let number = match normalize(numeric_string).as_str() { + "TRUE" | "ON" => Some(1.0), + "FALSE" | "OFF" => Some(0.0), + _ => numeric_string.parse::().ok(), + }; + Self { string, number } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Command { + pub command_name: String, + pub arguments: Vec, + pub line: usize, +} + +#[derive(Clone, Debug)] +struct Condition { + first: String, + rest: Vec<(LogicalOperator, String)>, +} + +impl Condition { + fn parse(arguments: &[CommandArgument], single_name: bool) -> Result { + let Some(first) = arguments.first() else { + return Err("conditional directive requires an expression".to_owned()); + }; + + if single_name && arguments.len() != 1 { + return Err("conditional directive requires exactly one variable name".to_owned()); + } + if arguments.len().is_multiple_of(2) { + return Err("conditional expression must alternate names and operators".to_owned()); + } + + let mut rest = Vec::new(); + for [operator, name] in arguments[1..].as_chunks::<2>().0 { + let operator = match operator.string.as_str() { + "&&" => LogicalOperator::And, + "||" => LogicalOperator::Or, + _ => { + return Err(format!( + "unknown conditional operator {:?}", + operator.string + )); + } + }; + rest.push((operator, name.string.clone())); + } + + Ok(Self { + first: first.string.clone(), + rest, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LogicalOperator { + And, + Or, +} + +pub trait ScriptParser { + type Script; + type Error; + + fn parse(&self, input: &str) -> Result; +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct AsoboParser; + +impl ScriptParser for AsoboParser { + type Script = Script; + type Error = ParseError; + + fn parse(&self, input: &str) -> Result { + let uncommented = strip_comments(input); + let mut commands = Vec::new(); + + for (line_index, line) in uncommented.lines().enumerate() { + let line_number = line_index + 1; + let arguments = tokenize(line, line_number)?; + let Some((name, arguments)) = arguments.split_first() else { + continue; + }; + + commands.push(Command { + command_name: name.string.clone(), + arguments: arguments.to_vec(), + line: line_number, + }); + } + + Ok(Script { commands }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParseError { + pub line: usize, + pub message: String, +} + +impl fmt::Display for ParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "line {}: {}", self.line, self.message) + } +} + +impl Error for ParseError {} + +pub trait ScriptExecutor { + type Error; + + fn execute(&mut self, script: &S) -> Result<(), Self::Error>; +} + +pub trait ScriptLoader { + fn load(&self, path: &Path) -> std::io::Result; +} + +#[derive(Clone, Debug)] +pub struct FileSystemScriptLoader { + root: PathBuf, +} + +impl FileSystemScriptLoader { + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } +} + +impl ScriptLoader for FileSystemScriptLoader { + fn load(&self, path: &Path) -> std::io::Result { + let bytes = fs::read(self.root.join(path))?; + Ok(WINDOWS_1252.decode(&bytes).0.into_owned()) + } +} + +pub type CommandCallback = + Rc, &Command) -> Result<(), ExecutionError>>; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandKey { + pub full_name: String, + pub short_name: String, +} + +impl CommandKey { + #[must_use] + pub fn new(command_name: impl AsRef) -> Self { + let command_name = command_name.as_ref(); + Self { + full_name: normalize(command_name), + short_name: command_name + .chars() + .filter(|character| character.is_ascii_uppercase() || character.is_ascii_digit()) + .take(15) + .collect(), + } + } + + fn matches(&self, command_name: &str) -> bool { + command_name == self.full_name + || (!self.short_name.is_empty() && command_name == self.short_name) + } +} + +#[derive(Debug)] +pub enum ExecutionError { + Parse { + path: PathBuf, + source: ParseError, + }, + Io { + path: PathBuf, + source: std::io::Error, + }, + MissingSourcePath { + command: String, + line: usize, + }, + InvalidDirective { + command: String, + line: usize, + message: String, + }, + UnmatchedElse { + line: usize, + }, + DuplicateElse { + line: usize, + }, + UnmatchedEndIf { + line: usize, + }, + UnterminatedIf, +} + +impl fmt::Display for ExecutionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parse { path, source } => { + write!(formatter, "failed to parse {}: {source}", path.display()) + } + Self::Io { path, source } => { + write!(formatter, "failed to load {}: {source}", path.display()) + } + Self::MissingSourcePath { command, line } => { + write!(formatter, "line {line}: {command} requires a script path") + } + Self::InvalidDirective { + command, + line, + message, + } => write!(formatter, "line {line}: invalid {command}: {message}"), + Self::UnmatchedElse { line } => write!(formatter, "line {line}: #else without #if"), + Self::DuplicateElse { line } => write!(formatter, "line {line}: duplicate #else"), + Self::UnmatchedEndIf { line } => write!(formatter, "line {line}: #endif without #if"), + Self::UnterminatedIf => formatter.write_str("script ended before #endif"), + } + } +} + +impl Error for ExecutionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Parse { source, .. } => Some(source), + Self::Io { source, .. } => Some(source), + _ => None, + } + } +} + +#[derive(Clone, Debug)] +enum ExecutionFrame { + CommandLine, + Script { + path: PathBuf, + arguments: Vec, + }, +} + +#[derive(Clone, Copy, Debug)] +struct ConditionalFrame { + parent_active: bool, + condition: bool, + active: bool, + saw_else: bool, +} + +pub struct AsoboExecutor

{ + parser: P, + loader: L, + variables: BTreeSet, + user_data: U, + handlers: Vec<(CommandKey, CommandCallback)>, + default_callback: Option>, +} + +impl AsoboExecutor +where + L: ScriptLoader, +{ + #[must_use] + pub fn new(loader: L) -> Self { + Self::with_parser(AsoboParser, loader) + } +} + +impl AsoboExecutor +where + L: ScriptLoader, +{ + #[must_use] + pub fn with_user_data(loader: L, user_data: U) -> Self { + Self::with_parser_and_user_data(AsoboParser, loader, user_data) + } +} + +impl AsoboExecutor { + pub fn set_variable(&mut self, name: impl AsRef) { + self.variables.insert(normalize(name.as_ref())); + } + + pub fn unset_variable(&mut self, name: impl AsRef) { + self.variables.remove(&normalize(name.as_ref())); + } + + #[must_use] + pub fn has_variable(&self, name: impl AsRef) -> bool { + self.variables.contains(&normalize(name.as_ref())) + } + + pub fn variables(&self) -> impl Iterator { + self.variables.iter().map(String::as_str) + } + + #[must_use] + pub const fn user_data(&self) -> &U { + &self.user_data + } + + #[must_use] + pub const fn user_data_mut(&mut self) -> &mut U { + &mut self.user_data + } + + #[must_use] + pub fn into_user_data(self) -> U { + self.user_data + } + + pub fn on_command(&mut self, command_name: impl AsRef, callback: F) + where + F: Fn(&mut Self, &Command) -> Result<(), ExecutionError> + 'static, + { + self.handlers + .push((CommandKey::new(command_name), Rc::new(callback))); + } + + pub fn remove_command(&mut self, command_name: impl AsRef) -> bool { + let command_name = normalize(command_name); + let Some(index) = self + .handlers + .iter() + .rposition(|(key, _)| key.matches(&command_name)) + else { + return false; + }; + self.handlers.remove(index); + true + } + + pub fn on_default(&mut self, callback: F) + where + F: Fn(&mut Self, &Command) -> Result<(), ExecutionError> + 'static, + { + self.default_callback = Some(Rc::new(callback)); + } + + pub fn remove_default(&mut self) -> bool { + self.default_callback.take().is_some() + } +} + +impl AsoboExecutor +where + P: ScriptParser