From 713fb37525f52c67afbd81b7d73ef76cf77bed39 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 15 May 2026 18:37:01 -0400 Subject: [PATCH] feat: add script parser MVP --- README.md | 22 ++++ src/lib.rs | 1 + src/script.rs | 342 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 365 insertions(+) create mode 100644 src/script.rs diff --git a/README.md b/README.md index b470b30..b848d3c 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,28 @@ top-level `schema = "openvistapro.scene"`, `version = 1`, and a serialized settings. The format is intentionally human-readable while the data model is still evolving. +## Script language (MVP) + +OpenVistaPro includes a small, line-oriented scripting language for driving +terrain and render jobs from a plain-text file (`src/script.rs`). The grammar +is **clean-room and project-owned**: it is **not VistaPro-compatible** and +deliberately does not mirror the legacy VistaPro scripting syntax. + +Each line is a blank line, a `#` comment (also usable as a trailing comment), +or one command: + +```text +use preset hill # `hill` or `plane` +set thresholds water=0.18 tree=0.42 snow=0.77 +import heightmap "data/demo-height.png" +render output "out/demo.png" +``` + +Design goals for the MVP: one command per line for readable diffs, +deterministic parsing with no I/O, and parse errors that carry a 1-based line +number. This slice only parses scripts into an AST; executing those commands is +intentionally left for a later card. + ## Project principles - Clean-room implementation: do not decompile, copy, or translate proprietary binaries. diff --git a/src/lib.rs b/src/lib.rs index 3a1b346..9f28c02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,4 +3,5 @@ pub mod colormap; pub mod render; pub mod scene; pub mod scene_file; +pub mod script; pub mod terrain; diff --git a/src/script.rs b/src/script.rs new file mode 100644 index 0000000..23e3bd1 --- /dev/null +++ b/src/script.rs @@ -0,0 +1,342 @@ +//! OpenVistaPro project-owned scripting language (MVP). +//! +//! This module defines a small, line-oriented command language used to drive +//! terrain and render jobs from a plain-text file. The grammar is **clean-room +//! and project-owned**: it is intentionally *not* compatible with the legacy +//! VistaPro scripting syntax, and no proprietary grammar, keywords, or behavior +//! were referenced while designing it. +//! +//! # Grammar (MVP) +//! +//! A script is a sequence of newline-separated lines. Each line is one of: +//! +//! - blank / whitespace-only — ignored +//! - a comment — everything from an unquoted `#` to end of line is ignored, +//! so `#` may also be used as a trailing comment after a command +//! - a command — a keyword followed by whitespace-separated arguments +//! +//! Supported commands: +//! +//! ```text +//! use preset # is `hill` or `plane` +//! set thresholds water= tree= snow= +//! import heightmap "" +//! render output "" +//! ``` +//! +//! Paths are written as double-quoted strings. Threshold values are decimal +//! numbers. Tokens are whitespace-delimited; the order of the three +//! `set thresholds` assignments is not significant. +//! +//! # Design goals +//! +//! - **Readable and diffable**: one command per line, no nesting. +//! - **Deterministic**: parsing has no I/O and no global state. +//! - **Helpful errors**: every failure carries a 1-based line number. +//! - **Small surface**: the MVP only parses commands into an AST; execution is +//! intentionally left for a later integration slice. + +/// A fully parsed OpenVistaPro script. +#[derive(Debug, Clone, PartialEq)] +pub struct Script { + /// Commands in source order, with blank lines and comments removed. + pub commands: Vec, +} + +/// A built-in terrain preset selectable via `use preset `. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PresetName { + /// A single rolling hill. + Hill, + /// A flat plane. + Plane, +} + +/// Elevation thresholds set by `set thresholds`. +/// +/// Each value is a normalized height at which a terrain feature begins: +/// terrain below `water` is submerged, terrain above `tree` loses tree cover, +/// and terrain above `snow` is snow-capped. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SceneThresholds { + pub water: f32, + pub tree: f32, + pub snow: f32, +} + +/// A single executable command parsed from a script line. +#[derive(Debug, Clone, PartialEq)] +pub enum Command { + /// Select a built-in terrain preset. + UsePreset(PresetName), + /// Set the water/tree/snow elevation thresholds. + SetThresholds(SceneThresholds), + /// Load a heightmap image from `path`. + ImportHeightmap { path: String }, + /// Render the current scene to the image file at `path`. + RenderOutput { path: String }, +} + +/// An error produced while parsing a script, tagged with its source line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError { + line: usize, + message: String, +} + +impl ParseError { + fn new(line: usize, message: impl Into) -> Self { + ParseError { + line, + message: message.into(), + } + } + + /// The 1-based line number the error occurred on. + pub fn line(&self) -> usize { + self.line + } +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "line {}: {}", self.line, self.message) + } +} + +impl std::error::Error for ParseError {} + +/// Parse the text of an OpenVistaPro script into a [`Script`] AST. +pub fn parse_script(source: &str) -> Result { + let mut commands = Vec::new(); + + for (index, raw_line) in source.lines().enumerate() { + let line = index + 1; + let tokens = tokenize(raw_line, line)?; + if tokens.is_empty() { + continue; + } + commands.push(parse_command(&tokens, line)?); + } + + Ok(Script { commands }) +} + +/// Split a single line into whitespace-delimited tokens. +/// +/// Double-quoted spans are kept as part of the current token with the quotes +/// stripped, so quoted paths survive intact. An unquoted `#` starts a comment +/// that runs to the end of the line. +fn tokenize(line: &str, line_no: usize) -> Result, ParseError> { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut in_token = false; + let mut chars = line.chars(); + + while let Some(c) = chars.next() { + if c == '"' { + in_token = true; + let mut closed = false; + for qc in chars.by_ref() { + if qc == '"' { + closed = true; + break; + } + current.push(qc); + } + if !closed { + return Err(ParseError::new(line_no, "unterminated string literal")); + } + } else if c == '#' { + break; + } else if c.is_whitespace() { + if in_token { + tokens.push(std::mem::take(&mut current)); + in_token = false; + } + } else { + in_token = true; + current.push(c); + } + } + + if in_token { + tokens.push(current); + } + Ok(tokens) +} + +/// Parse one non-empty token list into a [`Command`]. +fn parse_command(tokens: &[String], line: usize) -> Result { + let args = &tokens[1..]; + match tokens[0].as_str() { + "use" => parse_use(args, line), + "set" => parse_set(args, line), + "import" => parse_import(args, line), + "render" => parse_render(args, line), + other => Err(ParseError::new(line, format!("unknown command {other:?}"))), + } +} + +fn parse_use(args: &[String], line: usize) -> Result { + expect_keyword(args.first(), "preset", line, "use")?; + let name = expect_arg(args.get(1), line, "use preset", "a preset name")?; + let preset = match name.as_str() { + "hill" => PresetName::Hill, + "plane" => PresetName::Plane, + other => return Err(ParseError::new(line, format!("unknown preset {other:?}"))), + }; + Ok(Command::UsePreset(preset)) +} + +fn parse_set(args: &[String], line: usize) -> Result { + expect_keyword(args.first(), "thresholds", line, "set")?; + + let mut water = None; + let mut tree = None; + let mut snow = None; + + for assignment in &args[1..] { + let (key, value) = assignment.split_once('=').ok_or_else(|| { + ParseError::new(line, format!("expected key=value, found {assignment:?}")) + })?; + let parsed = value.parse::().map_err(|_| { + ParseError::new(line, format!("invalid numeric value {value:?} for {key}")) + })?; + match key { + "water" => water = Some(parsed), + "tree" => tree = Some(parsed), + "snow" => snow = Some(parsed), + other => { + return Err(ParseError::new( + line, + format!("unknown threshold {other:?}"), + )); + } + } + } + + Ok(Command::SetThresholds(SceneThresholds { + water: require_threshold(water, "water", line)?, + tree: require_threshold(tree, "tree", line)?, + snow: require_threshold(snow, "snow", line)?, + })) +} + +fn parse_import(args: &[String], line: usize) -> Result { + expect_keyword(args.first(), "heightmap", line, "import")?; + let path = expect_arg(args.get(1), line, "import heightmap", "a quoted path")?; + Ok(Command::ImportHeightmap { path: path.clone() }) +} + +fn parse_render(args: &[String], line: usize) -> Result { + expect_keyword(args.first(), "output", line, "render")?; + let path = expect_arg(args.get(1), line, "render output", "a quoted path")?; + Ok(Command::RenderOutput { path: path.clone() }) +} + +/// Require that `actual` is the expected sub-keyword for `command`. +fn expect_keyword( + actual: Option<&String>, + expected: &str, + line: usize, + command: &str, +) -> Result<(), ParseError> { + match actual { + Some(word) if word == expected => Ok(()), + Some(word) => Err(ParseError::new( + line, + format!("expected {expected:?} after {command:?}, found {word:?}"), + )), + None => Err(ParseError::new( + line, + format!("expected {expected:?} after {command:?}"), + )), + } +} + +/// Require that a positional argument is present. +fn expect_arg<'a>( + actual: Option<&'a String>, + line: usize, + command: &str, + what: &str, +) -> Result<&'a String, ParseError> { + actual.ok_or_else(|| ParseError::new(line, format!("expected {what} after {command:?}"))) +} + +/// Require that a named threshold was supplied. +fn require_threshold(value: Option, name: &str, line: usize) -> Result { + value.ok_or_else(|| ParseError::new(line, format!("missing threshold {name:?}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_happy_path_script_into_ast() { + let script = r#" + # OpenVistaPro project-owned script, not legacy VistaPro syntax + use preset hill + set thresholds water=0.18 tree=0.42 snow=0.77 + import heightmap "data/demo-height.png" + render output "out/demo.png" + "#; + + let ast = parse_script(script).unwrap(); + + assert_eq!( + ast.commands, + vec![ + Command::UsePreset(PresetName::Hill), + Command::SetThresholds(SceneThresholds { + water: 0.18, + tree: 0.42, + snow: 0.77, + }), + Command::ImportHeightmap { + path: "data/demo-height.png".into(), + }, + Command::RenderOutput { + path: "out/demo.png".into(), + }, + ] + ); + } + + #[test] + fn ignores_comments_blank_lines_and_extra_whitespace() { + let script = "\n # comment\n\n\tuse preset plane \n render output \"out.png\" # trailing comment\n"; + + let ast = parse_script(script).unwrap(); + + assert_eq!( + ast.commands, + vec![ + Command::UsePreset(PresetName::Plane), + Command::RenderOutput { + path: "out.png".into() + }, + ] + ); + } + + #[test] + fn rejects_unknown_command_with_line_number() { + let err = parse_script("spin camera 45").unwrap_err(); + + assert_eq!(err.line(), 1); + assert!(err.to_string().contains("unknown command")); + assert!(err.to_string().contains("spin")); + } + + #[test] + fn rejects_invalid_numeric_threshold_value() { + let err = parse_script("set thresholds water=low tree=0.4 snow=0.8").unwrap_err(); + + assert_eq!(err.line(), 1); + assert!(err.to_string().contains("invalid numeric value")); + assert!(err.to_string().contains("water")); + } +} -- 2.39.5