feat: add script parser MVP #1
@@ -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
|
settings. The format is intentionally human-readable while the data model is
|
||||||
still evolving.
|
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
|
## Project principles
|
||||||
|
|
||||||
- Clean-room implementation: do not decompile, copy, or translate proprietary binaries.
|
- Clean-room implementation: do not decompile, copy, or translate proprietary binaries.
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ pub mod colormap;
|
|||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
pub mod scene_file;
|
pub mod scene_file;
|
||||||
|
pub mod script;
|
||||||
pub mod terrain;
|
pub mod terrain;
|
||||||
|
|||||||
+342
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user