feat: add script parser MVP #1

Merged
moldybits merged 1 commits from feat/script-parser-mvp into main 2026-05-15 20:42:33 -04:00
3 changed files with 365 additions and 0 deletions
+22
View File
@@ -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.
+1
View File
@@ -3,4 +3,5 @@ pub mod colormap;
pub mod render;
pub mod scene;
pub mod scene_file;
pub mod script;
pub mod terrain;
+342
View File
@@ -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 <name> # <name> is `hill` or `plane`
//! set thresholds water=<f> tree=<f> snow=<f>
//! import heightmap "<path>"
//! render output "<path>"
//! ```
//!
//! 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<Command>,
}
/// A built-in terrain preset selectable via `use preset <name>`.
#[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<String>) -> 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<Script, ParseError> {
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<Vec<String>, 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<Command, ParseError> {
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<Command, ParseError> {
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<Command, ParseError> {
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::<f32>().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<Command, ParseError> {
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<Command, ParseError> {
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<f32>, name: &str, line: usize) -> Result<f32, ParseError> {
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"));
}
}