390 lines
12 KiB
Rust
390 lines
12 KiB
Rust
use std::collections::HashMap;
|
|
use std::fs;
|
|
|
|
#[derive(Debug)]
|
|
pub struct ScriptedGoal {
|
|
pub minute: u8,
|
|
pub team: String,
|
|
pub player: String,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
pub enum OutputMode {
|
|
UnicodeTable,
|
|
AsciiTable,
|
|
Json,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct TsrcScript {
|
|
pub teams: HashMap<String, String>,
|
|
pub roster: HashMap<String, Vec<String>>,
|
|
pub home_var: String,
|
|
pub away_var: String,
|
|
pub goals: Option<Vec<ScriptedGoal>>,
|
|
pub output_mode: OutputMode,
|
|
}
|
|
|
|
pub fn parse_tsrc(path: &str) -> Result<TsrcScript, String> {
|
|
let content = fs::read_to_string(path)
|
|
.map_err(|e| format!("tsrc error: cannot read '{}': {}", path, e))?;
|
|
|
|
let mut teams = HashMap::new();
|
|
let mut roster: HashMap<String, Vec<String>> = HashMap::new();
|
|
let mut match_found = false;
|
|
let mut home_var = String::new();
|
|
let mut away_var = String::new();
|
|
|
|
let mut var_lines: HashMap<String, usize> = HashMap::new();
|
|
let mut player_entries: HashMap<(String, String), usize> = HashMap::new();
|
|
let mut goals: Vec<ScriptedGoal> = Vec::new();
|
|
let mut goal_entries: HashMap<(u8, String), usize> = HashMap::new();
|
|
let mut output_mode: OutputMode = OutputMode::UnicodeTable;
|
|
|
|
for (line_num, raw_line) in content.lines().enumerate() {
|
|
let n = line_num + 1;
|
|
let line = raw_line.trim();
|
|
|
|
if line.is_empty() || line.starts_with("//") || line.starts_with("#!") {
|
|
continue;
|
|
}
|
|
|
|
if let Some(rest) = line.strip_prefix("#define ") {
|
|
if output_mode != OutputMode::UnicodeTable {
|
|
return Err(format!(
|
|
"tsrc error: output mode already defined on line {} (duplicate #define)",
|
|
n
|
|
));
|
|
}
|
|
output_mode = parse_define(rest, n)?;
|
|
}
|
|
else if let Some(rest) = line.strip_prefix("string ") {
|
|
parse_string_decl(rest, n, &mut teams, &mut var_lines)?;
|
|
}
|
|
else if let Some(rest) = line.strip_prefix("players") {
|
|
parse_player_insert(rest, n, &mut roster, &mut player_entries, &teams)?;
|
|
}
|
|
else if let Some(rest) = line.strip_prefix("goal") {
|
|
let goal = parse_goal(rest, n, &teams, &roster, &mut goal_entries)?;
|
|
goals.push(goal);
|
|
}
|
|
else if let Some(rest) = line.strip_prefix("match") {
|
|
let rest = rest.trim_end_matches(';');
|
|
(home_var, away_var) = parse_match(rest, n, &teams)?;
|
|
match_found = true;
|
|
}
|
|
else {
|
|
return Err(format!("tsrc error: unrecognized statement on line {}: '{}'", n, line));
|
|
}
|
|
}
|
|
|
|
if !match_found {
|
|
return Err("tsrc error: no match() directive found".to_string());
|
|
}
|
|
|
|
validate_script(&teams, &roster, &home_var, &away_var)?;
|
|
|
|
if !goals.is_empty() {
|
|
let home_team = teams.get(&home_var).unwrap();
|
|
let away_team = teams.get(&away_var).unwrap();
|
|
validate_goals(&goals, home_team, away_team, &roster)?;
|
|
}
|
|
|
|
Ok(TsrcScript {
|
|
teams,
|
|
roster,
|
|
home_var,
|
|
away_var,
|
|
goals: if goals.is_empty() { None } else { Some(goals) },
|
|
output_mode,
|
|
})
|
|
}
|
|
|
|
fn parse_define(s: &str, n: usize) -> Result<OutputMode, String> {
|
|
match s.trim() {
|
|
"JSON" => Ok(OutputMode::Json),
|
|
"UNICODE_TABLE" => Ok(OutputMode::UnicodeTable),
|
|
"ASCII_TABLE" => Ok(OutputMode::AsciiTable),
|
|
other => Err(format!(
|
|
"tsrc error: unknown #define '{}' on line {} (expected JSON, UNICODE_TABLE, or ASCII_TABLE)",
|
|
other, n
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn validate_script(
|
|
teams: &HashMap<String, String>,
|
|
roster: &HashMap<String, Vec<String>>,
|
|
home_var: &str,
|
|
away_var: &str,
|
|
) -> Result<(), String> {
|
|
let home_team = teams.get(home_var).unwrap();
|
|
let away_team = teams.get(away_var).unwrap();
|
|
|
|
if home_team == away_team {
|
|
return Err(format!(
|
|
"tsrc error: match() has the same team for both home and away (\"{}\")",
|
|
home_team
|
|
));
|
|
}
|
|
|
|
for (team_name, players) in roster {
|
|
if team_name != home_team && team_name != away_team {
|
|
return Err(format!(
|
|
"tsrc error: {} player(s) assigned to \"{}\" which is not a match team (home=\"{}\", away=\"{}\")",
|
|
players.len(), team_name, home_team, away_team
|
|
));
|
|
}
|
|
}
|
|
|
|
let home_count = roster.get(home_team).map(|v| v.len()).unwrap_or(0);
|
|
let away_count = roster.get(away_team).map(|v| v.len()).unwrap_or(0);
|
|
|
|
if home_count == 0 {
|
|
return Err(format!(
|
|
"tsrc error: \"{}\" ({}) has no players assigned",
|
|
home_team, home_var
|
|
));
|
|
}
|
|
if away_count == 0 {
|
|
return Err(format!(
|
|
"tsrc error: \"{}\" ({}) has no players assigned",
|
|
away_team, away_var
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_string_decl(
|
|
s: &str,
|
|
n: usize,
|
|
teams: &mut HashMap<String, String>,
|
|
var_lines: &mut HashMap<String, usize>,
|
|
) -> Result<(), String> {
|
|
let s = s.trim();
|
|
let eq_pos = s.find('=')
|
|
.ok_or_else(|| format!("tsrc error: missing '=' on line {}", n))?;
|
|
let var_name = s[..eq_pos].trim();
|
|
let value_part = s[eq_pos + 1..].trim().trim_end_matches(';');
|
|
|
|
if !is_valid_ident(var_name) {
|
|
return Err(format!("tsrc error: invalid variable name '{}' on line {}", var_name, n));
|
|
}
|
|
|
|
if let Some(&first_line) = var_lines.get(var_name) {
|
|
return Err(format!(
|
|
"tsrc error: variable '{}' redefined on line {} (first seen on line {})",
|
|
var_name, n, first_line
|
|
));
|
|
}
|
|
|
|
let value = parse_string_literal(value_part, n)?;
|
|
var_lines.insert(var_name.to_string(), n);
|
|
teams.insert(var_name.to_string(), value);
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_player_insert(
|
|
s: &str,
|
|
n: usize,
|
|
roster: &mut HashMap<String, Vec<String>>,
|
|
player_entries: &mut HashMap<(String, String), usize>,
|
|
teams: &HashMap<String, String>,
|
|
) -> Result<(), String> {
|
|
let s = s.trim();
|
|
|
|
let bracket_end = s.find(']')
|
|
.ok_or_else(|| format!("tsrc error: missing ']' on line {}", n))?;
|
|
let key_part = &s[1..bracket_end];
|
|
let after = &s[bracket_end + 1..];
|
|
|
|
let player_name = parse_string_literal(key_part.trim(), n)?;
|
|
|
|
let eq_pos = after.find('=')
|
|
.ok_or_else(|| format!("tsrc error: missing '=' on line {}", n))?;
|
|
let value_part = after[eq_pos + 1..].trim().trim_end_matches(';');
|
|
|
|
let team_name = if value_part.starts_with('"') {
|
|
parse_string_literal(value_part, n)?
|
|
}
|
|
else {
|
|
let var_name = value_part.trim();
|
|
if !is_valid_ident(var_name) {
|
|
return Err(format!(
|
|
"tsrc error: expected string literal or variable on line {}",
|
|
n
|
|
));
|
|
}
|
|
match teams.get(var_name) {
|
|
Some(v) => v.clone(),
|
|
None => {
|
|
return Err(format!(
|
|
"tsrc error: undefined variable '{}' on line {} (declare it with `string {} = \"...\"` first)",
|
|
var_name, n, var_name
|
|
));
|
|
}
|
|
}
|
|
};
|
|
|
|
let key = (player_name.clone(), team_name.clone());
|
|
if let Some(&first_line) = player_entries.get(&key) {
|
|
return Err(format!(
|
|
"tsrc error: duplicate player \"{}\" on team \"{}\" (first seen on line {})",
|
|
player_name, team_name, first_line
|
|
));
|
|
}
|
|
|
|
player_entries.insert(key, n);
|
|
roster.entry(team_name).or_default().push(player_name);
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_match(s: &str, n: usize, teams: &HashMap<String, String>) -> Result<(String, String), String> {
|
|
let s = s.trim();
|
|
|
|
if !s.starts_with('(') || !s.ends_with(')') {
|
|
return Err(format!("tsrc error: match() requires parentheses on line {}", n));
|
|
}
|
|
|
|
let inner = &s[1..s.len() - 1];
|
|
let comma_pos = inner.find(',')
|
|
.ok_or_else(|| format!("tsrc error: match() requires two arguments separated by ',' on line {}", n))?;
|
|
|
|
let var1 = inner[..comma_pos].trim();
|
|
let var2 = inner[comma_pos + 1..].trim();
|
|
|
|
if !is_valid_ident(var1) {
|
|
return Err(format!("tsrc error: invalid variable '{}' in match() on line {}", var1, n));
|
|
}
|
|
if !is_valid_ident(var2) {
|
|
return Err(format!("tsrc error: invalid variable '{}' in match() on line {}", var2, n));
|
|
}
|
|
if !teams.contains_key(var1) {
|
|
return Err(format!("tsrc error: undefined variable '{}' in match() on line {}", var1, n));
|
|
}
|
|
if !teams.contains_key(var2) {
|
|
return Err(format!("tsrc error: undefined variable '{}' in match() on line {}", var2, n));
|
|
}
|
|
|
|
Ok((var1.to_string(), var2.to_string()))
|
|
}
|
|
|
|
fn parse_goal(
|
|
s: &str,
|
|
n: usize,
|
|
teams: &HashMap<String, String>,
|
|
roster: &HashMap<String, Vec<String>>,
|
|
goal_entries: &mut HashMap<(u8, String), usize>,
|
|
) -> Result<ScriptedGoal, String> {
|
|
let s = s.trim().trim_end_matches(';');
|
|
|
|
if !s.starts_with('(') || !s.ends_with(')') {
|
|
return Err(format!("tsrc error: goal() requires parentheses on line {}", n));
|
|
}
|
|
|
|
let inner = &s[1..s.len() - 1];
|
|
let parts: Vec<&str> = inner.splitn(3, ',').collect();
|
|
if parts.len() != 3 {
|
|
return Err(format!(
|
|
"tsrc error: goal() requires 3 arguments (minute, team, player) on line {}",
|
|
n
|
|
));
|
|
}
|
|
|
|
let minute: u8 = parts[0].trim().parse().map_err(|_| {
|
|
format!("tsrc error: goal() minute must be a number on line {}", n)
|
|
})?;
|
|
if minute < 1 || minute > 90 {
|
|
return Err(format!(
|
|
"tsrc error: goal() minute must be between 1 and 90 on line {}",
|
|
n
|
|
));
|
|
}
|
|
|
|
let team_part = parts[1].trim();
|
|
let team_name = if team_part.starts_with('"') {
|
|
parse_string_literal(team_part, n)?
|
|
}
|
|
else {
|
|
if !is_valid_ident(team_part) {
|
|
return Err(format!(
|
|
"tsrc error: goal() team must be a string literal or variable on line {}",
|
|
n
|
|
));
|
|
}
|
|
match teams.get(team_part) {
|
|
Some(v) => v.clone(),
|
|
None => {
|
|
return Err(format!(
|
|
"tsrc error: undefined variable '{}' in goal() on line {} (declare it with `string {} = \"...\"` first)",
|
|
team_part, n, team_part
|
|
));
|
|
}
|
|
}
|
|
};
|
|
|
|
let player_name = parse_string_literal(parts[2].trim(), n)?;
|
|
|
|
let key = (minute, player_name.clone());
|
|
if let Some(&first_line) = goal_entries.get(&key) {
|
|
return Err(format!(
|
|
"tsrc error: duplicate goal on line {} — {} already scored at minute {} (line {})",
|
|
n, player_name, minute, first_line
|
|
));
|
|
}
|
|
goal_entries.insert(key, n);
|
|
|
|
if let Some(players) = roster.get(&team_name) {
|
|
if !players.contains(&player_name) {
|
|
return Err(format!(
|
|
"tsrc error: goal() on line {} — player \"{}\" is not on team \"{}\"",
|
|
n, player_name, team_name
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(ScriptedGoal {
|
|
minute,
|
|
team: team_name,
|
|
player: player_name,
|
|
})
|
|
}
|
|
|
|
fn validate_goals(
|
|
goals: &[ScriptedGoal],
|
|
home_team: &str,
|
|
away_team: &str,
|
|
roster: &HashMap<String, Vec<String>>,
|
|
) -> Result<(), String> {
|
|
for goal in goals {
|
|
if goal.team != home_team && goal.team != away_team {
|
|
return Err(format!(
|
|
"tsrc error: goal() for \"{}\" references team \"{}\" which is not a match team (home=\"{}\", away=\"{}\")",
|
|
goal.player, goal.team, home_team, away_team
|
|
));
|
|
}
|
|
if let Some(players) = roster.get(&goal.team) {
|
|
if !players.contains(&goal.player) {
|
|
return Err(format!(
|
|
"tsrc error: goal() — player \"{}\" is not on team \"{}\"",
|
|
goal.player, goal.team
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_string_literal(s: &str, n: usize) -> Result<String, String> {
|
|
let s = s.trim();
|
|
if s.len() < 2 || !s.starts_with('"') || !s.ends_with('"') {
|
|
return Err(format!("tsrc error: expected string literal on line {}", n));
|
|
}
|
|
Ok(s[1..s.len() - 1].to_string())
|
|
}
|
|
|
|
fn is_valid_ident(s: &str) -> bool {
|
|
if s.is_empty() { return false; }
|
|
s.chars().all(|c| c.is_alphanumeric() || c == '_')
|
|
}
|