diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-08-08 19:03:26 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-08-08 19:03:26 +0800 |
| commit | 5d724e95c6800e25486c25c6a9a3ae718fb2069b (patch) | |
| tree | dead172ed10e71b09a74cf0070b4f16b5b929e0c /tools | |
| parent | 1e498d1ed1645d93a49230d0647f1fa15aecb113 (diff) | |
feat(tscn-to-bsn): switch to git dependency and add converter binary
Restructure the tool to use mingling from git with pathf feature,
add a dedicated tscn2bsn binary, and implement:
- Full TSCN syntax parser as a 1:1 replica of Godot's parsing logic
- AST representation with line numbers for error reporting
- Conversion command that reads TSCN, parses to AST, and serializes to
RON
- Error handlers for IO, RON, and parse errors
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/tscn-to-bsn/Cargo.lock | 64 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/Cargo.toml | 17 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/build.rs | 5 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/src/bin/tscn2bsn.rs | 11 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/src/cmd/cmd_convert.rs | 85 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/src/cmd/mod.rs | 1 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/src/err/io.rs | 8 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/src/err/mod.rs | 3 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/src/err/ron.rs | 8 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/src/err/tscn_parse.rs | 10 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/src/gd_res_structure/mod.rs | 5 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/src/gd_res_structure/tscn_ast.rs | 225 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/src/gd_res_structure/tscn_ast_parser.rs | 1155 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/src/lib.rs | 7 | ||||
| -rw-r--r-- | tools/tscn-to-bsn/src/main.rs | 14 |
15 files changed, 1593 insertions, 25 deletions
diff --git a/tools/tscn-to-bsn/Cargo.lock b/tools/tscn-to-bsn/Cargo.lock index ab30c36..20515f9 100644 --- a/tools/tscn-to-bsn/Cargo.lock +++ b/tools/tscn-to-bsn/Cargo.lock @@ -5,8 +5,7 @@ version = 4 [[package]] name = "arg-picker" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb314536041a4dfa4129b9085d012e795c98850df1bbe1e0b3014ffe4ad6c35" +source = "git+https://github.com/mingling-rs/mingling?rev=08e804e75478953aa656064ac25903858bf5ef64#08e804e75478953aa656064ac25903858bf5ef64" dependencies = [ "arg-picker-macros", "just_fmt", @@ -15,8 +14,7 @@ dependencies = [ [[package]] name = "arg-picker-macros" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45d3bcbbc42ed7d41d773135f30250dbfd5a50db26423c7e8c86a107302fff9a" +source = "git+https://github.com/mingling-rs/mingling?rev=08e804e75478953aa656064ac25903858bf5ef64#08e804e75478953aa656064ac25903858bf5ef64" dependencies = [ "proc-macro2", "quote", @@ -24,6 +22,15 @@ dependencies = [ ] [[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -72,8 +79,7 @@ dependencies = [ [[package]] name = "mingling" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "becee324d20e7daf63682aa1c4684c61c59ab4e90a53d22e73c344926617456b" +source = "git+https://github.com/mingling-rs/mingling?rev=08e804e75478953aa656064ac25903858bf5ef64#08e804e75478953aa656064ac25903858bf5ef64" dependencies = [ "arg-picker", "mingling_core", @@ -83,18 +89,17 @@ dependencies = [ [[package]] name = "mingling_core" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f68c41c66b24bb19f0b1a963b47761357c1ed88eb313108394ecdf583eac4d" +source = "git+https://github.com/mingling-rs/mingling?rev=08e804e75478953aa656064ac25903858bf5ef64#08e804e75478953aa656064ac25903858bf5ef64" dependencies = [ "just_fmt", "might_be_async", + "mingling_pathf", ] [[package]] name = "mingling_macros" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bff950f2ea4dc534375fa93673ac2b119151feeda1f39b48e6873e03365fbe1" +source = "git+https://github.com/mingling-rs/mingling?rev=08e804e75478953aa656064ac25903858bf5ef64#08e804e75478953aa656064ac25903858bf5ef64" dependencies = [ "just_fmt", "proc-macro2", @@ -103,6 +108,22 @@ dependencies = [ ] [[package]] +name = "mingling_pathf" +version = "0.3.0" +source = "git+https://github.com/mingling-rs/mingling?rev=08e804e75478953aa656064ac25903858bf5ef64#08e804e75478953aa656064ac25903858bf5ef64" +dependencies = [ + "just_fmt", + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] name = "proc-macro2" version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -121,12 +142,27 @@ dependencies = [ ] [[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] name = "serde" version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -226,9 +262,17 @@ name = "tscn-to-bsn" version = "0.1.0" dependencies = [ "mingling", + "ron", + "serde", ] [[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/tools/tscn-to-bsn/Cargo.toml b/tools/tscn-to-bsn/Cargo.toml index f2ed7d3..295e7fb 100644 --- a/tools/tscn-to-bsn/Cargo.toml +++ b/tools/tscn-to-bsn/Cargo.toml @@ -3,7 +3,22 @@ name = "tscn-to-bsn" version = "0.1.0" edition = "2024" +[[bin]] +name = "tscn2bsn" +path = "src/bin/tscn2bsn.rs" + [dependencies] -mingling = { version = "0.3.0", features = ["extra_macros", "picker"] } +serde = { version = "1.0.229", features = ["derive"] } +ron = "0.12.2" + +[dependencies.mingling] +git = "https://github.com/mingling-rs/mingling" +rev = "08e804e75478953aa656064ac25903858bf5ef64" +features = ["extras", "picker", "pathf"] + +[build-dependencies.mingling] +git = "https://github.com/mingling-rs/mingling" +rev = "08e804e75478953aa656064ac25903858bf5ef64" +features = ["build", "pathf"] [workspace] diff --git a/tools/tscn-to-bsn/build.rs b/tools/tscn-to-bsn/build.rs new file mode 100644 index 0000000..2a36e37 --- /dev/null +++ b/tools/tscn-to-bsn/build.rs @@ -0,0 +1,5 @@ +use mingling::build::analyze_and_build_type_mapping; + +fn main() { + analyze_and_build_type_mapping().unwrap(); +} diff --git a/tools/tscn-to-bsn/src/bin/tscn2bsn.rs b/tools/tscn-to-bsn/src/bin/tscn2bsn.rs new file mode 100644 index 0000000..198a5e8 --- /dev/null +++ b/tools/tscn-to-bsn/src/bin/tscn2bsn.rs @@ -0,0 +1,11 @@ +use mingling::picker::parselib::{ParserStyle, WINDOWS_STYLE}; +use tscn_to_bsn::{ThisProgram, cmd::cmd_convert::CMDConvert}; + +fn main() { + let mut program = ThisProgram::new(); + + ParserStyle::set_global_style(&WINDOWS_STYLE); + + program.with_dispatcher(CMDConvert); + program.exec_and_exit(); +} diff --git a/tools/tscn-to-bsn/src/cmd/cmd_convert.rs b/tools/tscn-to-bsn/src/cmd/cmd_convert.rs new file mode 100644 index 0000000..efa18b2 --- /dev/null +++ b/tools/tscn-to-bsn/src/cmd/cmd_convert.rs @@ -0,0 +1,85 @@ +use std::path::PathBuf; + +use mingling::{ + Grouped, Routable, + macros::{arg, buffer, chain, dispatcher, pack_err, r_println, renderer, routeify}, + picker::{EntryPicker, value::FilePath}, +}; + +use crate::{Next, gd_res_structure::parse}; + +dispatcher!("convert"); + +#[derive(Grouped)] +pub struct StateConvert { + input: PathBuf, + output: PathBuf, +} + +#[derive(Grouped)] +pub struct ResultConvertedToRon { + input: PathBuf, + output: PathBuf, + len: usize, +} + +pack_err!(ErrorRequirePaths); + +#[chain(routeify)] +pub fn handle_convert(args: EntryConvert) -> Next { + let (tscn_file, output_file) = args + .pick_or_route(&arg![input: FilePath, 'I'], || { + ErrorRequirePaths::default().into() + }) + .pick_or_route(&arg![output: FilePath, 'O'], || { + ErrorRequirePaths::default().into() + }) + .to_result()?; + + StateConvert { + input: tscn_file.into(), + output: output_file.into(), + } + .into() +} + +#[chain(routeify)] +pub fn handle_state_convert(p: StateConvert) -> Next { + let (i, o) = (p.input, p.output); + + // 1. Read file + let src = std::fs::read_to_string(&i)?; + + // 2. Parse into Tscn AST + let doc = parse(&src)?; + + // 3. Serialize to RON + let ron = ron::ser::to_string_pretty(&doc, ron::ser::PrettyConfig::default())?; + + // 4. Write output + std::fs::write(&o, ron)?; + + ResultConvertedToRon { + input: i, + output: o, + len: doc.nodes.len(), + } + .to_render() +} + +#[renderer(buffer)] +pub fn render_converted_to_ron(r: ResultConvertedToRon) { + r_println!( + "OK: {} -> {} ({} nodes)", + r.input.display(), + r.output.display(), + r.len + ); +} + +#[renderer(buffer)] +pub fn render_error_require_paths(_: ErrorRequirePaths) { + r_println!("Error: Require input file or output file"); + r_println!(""); + r_println!("Usage: tscn2bsn convert /I <TSCN> /O <RON>") +} diff --git a/tools/tscn-to-bsn/src/cmd/mod.rs b/tools/tscn-to-bsn/src/cmd/mod.rs new file mode 100644 index 0000000..f488f44 --- /dev/null +++ b/tools/tscn-to-bsn/src/cmd/mod.rs @@ -0,0 +1 @@ +pub mod cmd_convert; diff --git a/tools/tscn-to-bsn/src/err/io.rs b/tools/tscn-to-bsn/src/err/io.rs new file mode 100644 index 0000000..1c722b8 --- /dev/null +++ b/tools/tscn-to-bsn/src/err/io.rs @@ -0,0 +1,8 @@ +use mingling::macros::{buffer, group, r_println, renderer}; + +group!(ErrorIo = std::io::Error); + +#[renderer(buffer)] +pub fn handle_error_io(err: ErrorIo) { + r_println!("{}", err.to_string()) +} diff --git a/tools/tscn-to-bsn/src/err/mod.rs b/tools/tscn-to-bsn/src/err/mod.rs new file mode 100644 index 0000000..29f6fd0 --- /dev/null +++ b/tools/tscn-to-bsn/src/err/mod.rs @@ -0,0 +1,3 @@ +pub mod io; +pub mod ron; +pub mod tscn_parse; diff --git a/tools/tscn-to-bsn/src/err/ron.rs b/tools/tscn-to-bsn/src/err/ron.rs new file mode 100644 index 0000000..f1cb548 --- /dev/null +++ b/tools/tscn-to-bsn/src/err/ron.rs @@ -0,0 +1,8 @@ +use mingling::macros::{buffer, group, r_println, renderer}; + +group!(ErrorRon = ron::Error); + +#[renderer(buffer)] +pub fn handle_error_ron(err: ErrorRon) { + r_println!("{}", err.to_string()) +} diff --git a/tools/tscn-to-bsn/src/err/tscn_parse.rs b/tools/tscn-to-bsn/src/err/tscn_parse.rs new file mode 100644 index 0000000..01757cc --- /dev/null +++ b/tools/tscn-to-bsn/src/err/tscn_parse.rs @@ -0,0 +1,10 @@ +use mingling::macros::{buffer, group, r_println, renderer}; + +use crate::gd_res_structure::TscnParseError; + +group!(TscnParseError); + +#[renderer(buffer)] +pub fn render_error_tscn_parse(err: TscnParseError) { + r_println!("Error: {}", err.to_string()) +} diff --git a/tools/tscn-to-bsn/src/gd_res_structure/mod.rs b/tools/tscn-to-bsn/src/gd_res_structure/mod.rs new file mode 100644 index 0000000..eefcb27 --- /dev/null +++ b/tools/tscn-to-bsn/src/gd_res_structure/mod.rs @@ -0,0 +1,5 @@ +mod tscn_ast; +pub use tscn_ast::*; + +mod tscn_ast_parser; +pub use tscn_ast_parser::*; diff --git a/tools/tscn-to-bsn/src/gd_res_structure/tscn_ast.rs b/tools/tscn-to-bsn/src/gd_res_structure/tscn_ast.rs new file mode 100644 index 0000000..578c270 --- /dev/null +++ b/tools/tscn-to-bsn/src/gd_res_structure/tscn_ast.rs @@ -0,0 +1,225 @@ +//! # tscn syntax tree +//! +//! Syntax-level modeling of the Godot resource text format (`.tscn` / `.tres`), **without interpreting any type semantics**: +//! `Vector2(1, 2)` remains a "constructor call", `ExtResource("1_abcde")` keeps its string argument. +//! Reference resolution and type interpretation are left to the upper layer (abstract stage). +//! +//! 1:1 replication of Godot's parsing logic: +//! +//! | Layer | Godot source | +//! |---|---| +//! | Token layer | `core/variant/variant_parser.cpp::get_token` L162-520 | +//! | Value layer | `parse_value` L677-1642, `_parse_array` L1644, `_parse_dictionary` L1684 | +//! | Tag layer | `_parse_tag` L1750-1873 | +//! | Line layer | `parse_tag_assign_eof` L1891-1961 | +//! | Document layer | `scene/resources/resource_format_text.cpp::ResourceLoaderText::load` L452+, `_parse_node_tag` L184-421 | +//! +//! Deliberate differences from Godot: +//! - `Tag.fields` uses an order-preserving `Vec` instead of `HashMap` (Godot loses order; lossless requirement preserves it) +//! - Constructors don't hardcode a name list; `Ident(args...)` is modeled uniformly (accepts a legal superset; validation is left to the upper layer) +//! - Each node/property/tag carries a line number for error reporting +//! +//! Comments (`;`) and whitespace are discarded at the token layer—consistent with Godot. + +use serde::Serialize; + +/// Line number (1-based), used for error reporting +pub type Line = usize; + +/// Document: a `.tscn` / `.tres` file +/// +/// Block order corresponds to Godot's `load()` mandatory consumption order: +/// `[gd_scene]` → ext_resource (contiguous) → sub_resource → `[resource]` (.tres) → node/connection/editable. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TscnDoc { + /// Header tag: `[gd_scene ...]` or `[gd_resource ...]`, must be the first line + pub header: TscnTag, + /// `[ext_resource ...]`, one per line, must be contiguous and at the very front + pub ext_resources: Vec<TscnTag>, + /// `[sub_resource ...]` blocks (tag + property lines) + pub sub_resources: Vec<TscnBlock>, + /// `[resource]` main resource block (used by .tres; appearance in scene files is validated by the upper layer) + pub main_resource: Option<TscnBlock>, + /// `[node ...]` node blocks (tag + property lines) + pub nodes: Vec<TscnNode>, + /// `[connection ...]`, after nodes + pub connections: Vec<TscnTag>, + /// `[editable path="..."]`, last + pub editables: Vec<TscnTag>, +} + +/// Tag: `[name key=value ...]` +/// +/// Corresponds to Godot `_parse_tag`. Tag names support `.` / `:` concatenation (platform suffix, e.g., `[someprop.Android]`). +/// Field values use the **same** value syntax as property values (the value in `key=value` goes through `parse_value`). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TscnTag { + pub name: String, + /// Order-preserving fields: `key → value` (Godot uses HashMap and loses order; preserved here) + pub fields: Vec<(String, TscnValue)>, + pub line: Line, +} + +/// Block: `[sub_resource ...]` / `[resource]` header + property lines +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TscnBlock { + pub tag: TscnTag, + pub properties: Vec<TscnProperty>, +} + +/// Node block: `[node ...]` header + property lines +/// +/// Corresponds to Godot `_parse_node_tag`: the node header is a regular tag, followed by property lines until the next tag. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TscnNode { + pub tag: TscnTag, + pub properties: Vec<TscnProperty>, + pub line: Line, +} + +/// Property line: `name = value` +/// +/// Corresponds to the assignment branch of Godot `parse_tag_assign_eof`. +/// `name` may be a bare character sequence (containing no whitespace/`=`) or a quoted `"..."` (`property_name_encode` product). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TscnProperty { + pub name: String, + pub value: TscnValue, + pub line: Line, +} + +/// Value: purely syntactic layer, no type interpretation +/// +/// Corresponds to the syntactic surface of Godot `parse_value` (no semantic conversion like `Vector2` → struct). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum TscnValue { + /// `null` / `nil` + Null, + /// `true` / `false` + Bool(bool), + /// `inf` / `-inf` / `inf_neg` / `nan` (spelling preserved) + Keyword(TscnKeyword), + /// Numeric token: `42` (Int) or `1.0` / `1e3` (Float) + Number(TscnNumber), + /// `"..."` (escape sequences already decoded at token layer) + Str(String), + /// `&"..."` (StringName) + StrName(String), + /// `#rrggbb` / `#rrggbbaa` hexadecimal color (verbatim) + Color(String), + /// Bare identifier. Syntax-layer fallback: in valid files, only appears as a constructor name; + /// the upper layer may error on non-constructor names for strict validation (Godot: `Unexpected identifier`) + Ident(String), + /// `Name(args...)`: e.g., `Vector2(1, 2)`, `Color(1, 1, 1, 1)`, `ExtResource("1_abcde")` + Constructor(TscnCtor), + /// `[a, b, c]` + Array(Vec<TscnValue>), + /// `{"k": v, ...}` + Dict(Vec<(TscnValue, TscnValue)>), + /// `Array[T]([a, b])` (typed array) + TypedArray { + elem: TscnTypeRef, + values: Vec<TscnValue>, + }, + /// `Dictionary[K, V]({...})` (typed dictionary) + TypedDict { + key: TscnTypeRef, + val: TscnTypeRef, + entries: Vec<(TscnValue, TscnValue)>, + }, +} + +/// Numeric token: Godot `get_token` distinguishes integers from floats (`is_float` flag) +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub enum TscnNumber { + Int(i64), + Float(f64), +} + +/// Special keyword literals (Godot `parse_value` L702-708) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum TscnKeyword { + /// `inf` + Inf, + /// `-inf` + NegInf, + /// `inf_neg` (3.x compatibility spelling) + InfNeg, + /// `nan` + Nan, +} + +/// Constructor call: `Name(args...)` +/// +/// Covers all of Godot's hardcoded constructors (Vector2/Color/NodePath/ExtResource/SubResource/ +/// PackedXArray/Transform3D/...). No hardcoded name list—modeled uniformly at the syntax layer. +/// +/// `Object(ClassName, "prop": v, ...)` (non-resource object) has a class-name identifier as its first argument, +/// followed by named pairs `"key": value`, see [`TscnCtorArg`]. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TscnCtor { + pub name: String, + pub args: Vec<TscnCtorArg>, +} + +/// Constructor argument: a plain value, or a named pair `"key": value` from `Object(...)` +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum TscnCtorArg { + /// Plain argument (the `1` in `Vector2(1, 2)`) + Value(TscnValue), + /// Named pair from `Object(ClassName, "prop": value)` + Named(String, TscnValue), +} + +/// Type reference for typed array / dict: `T` / `K` / `V` in `Array[T]` / `Dictionary[K, V]` +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum TscnTypeRef { + /// Builtin type name or class name: `Int`, `Vector2`, `Node` ... + Named(String), + /// Resource type reference: `ExtResource("..")` / `SubResource("..")` / `Resource(..)` (script type) + Resource(Box<TscnCtor>), +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Walk through the structure of `scenes/loading_scene.tscn` for comparison + #[test] + fn loading_scene_shape() { + // [gd_scene format=3 uid="uid://3pv27wfh70do"] + let header = TscnTag { + name: "gd_scene".into(), + fields: vec![ + ("format".into(), TscnValue::Number(TscnNumber::Int(3))), + ("uid".into(), TscnValue::Str("uid://3pv27wfh70do".into())), + ], + line: 1, + }; + assert_eq!(header.fields.len(), 2); + + // [ext_resource type="PackedScene" uid="uid://7l43x00jkhdo" + // path="res://fixtures_doors_fences_01_98.glb" id="1_nbifo"] + let ext = TscnTag { + name: "ext_resource".into(), + fields: vec![ + ("type".into(), TscnValue::Str("PackedScene".into())), + ("uid".into(), TscnValue::Str("uid://7l43x00jkhdo".into())), + ( + "path".into(), + TscnValue::Str("res://fixtures_doors_fences_01_98.glb".into()), + ), + ("id".into(), TscnValue::Str("1_nbifo".into())), + ], + line: 3, + }; + assert_eq!(ext.fields[0].0, "type"); + + // The instance node's value stays purely syntactic: ExtResource("1_nbifo") is a constructor, not dereferenced + let instance = TscnValue::Constructor(TscnCtor { + name: "ExtResource".into(), + args: vec![TscnCtorArg::Value(TscnValue::Str("1_nbifo".into()))], + }); + assert!(matches!(instance, TscnValue::Constructor(_))); + } +} diff --git a/tools/tscn-to-bsn/src/gd_res_structure/tscn_ast_parser.rs b/tools/tscn-to-bsn/src/gd_res_structure/tscn_ast_parser.rs new file mode 100644 index 0000000..82b2386 --- /dev/null +++ b/tools/tscn-to-bsn/src/gd_res_structure/tscn_ast_parser.rs @@ -0,0 +1,1155 @@ +//! # tscn parser (1:1 replica of Godot parsing logic) +//! +//! Source reference (Godot 4.x): +//! +//! | Layer | Source | +//! |---|---| +//! | Token layer | `core/variant/variant_parser.cpp::get_token` L162-520 | +//! | Value layer | `parse_value` L677-1642, `_parse_construct` L553, `_parse_array` L1644, `_parse_dictionary` L1684 | +//! | Tag layer | `_parse_tag` L1750-1873 | +//! | Line layer | `parse_tag_assign_eof` L1891-1961 | +//! | Document layer | `scene/resources/resource_format_text.cpp::load` L452+, `_parse_node_tag` L184-421 | +//! +//! Syntax differences from Godot (all deliberate supersets, syntax validation left to upper layers): +//! - Unknown bare identifiers produce [`TscnValue::Ident`] instead of an error (Godot: `Unexpected identifier`) +//! - Unknown constructor names parse normally as `Name(args...)` (Godot only accepts a hardcoded list) +//! +//! # Entry points +//! +//! [`TscnDoc::from`] (`From<&str>` / `From<String>`) panics on parse failure; +//! use [`parse`] for explicit error handling. + +use super::tscn_ast::*; +use std::fmt; + +/// Parse error: carries the erroring line number and message +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TscnParseError { + pub line: usize, + pub message: String, +} + +impl fmt::Display for TscnParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "line {}: {}", self.line, self.message) + } +} + +impl std::error::Error for TscnParseError {} + +/// Parse entry point, returns [`Result`]; on failure gives an error with the line number +pub fn parse(src: &str) -> Result<TscnDoc, TscnParseError> { + Parser::new(src).parse_doc() +} + +/// Construct from string: `TscnDoc::from("...")`; panics on parse failure +impl From<&str> for TscnDoc { + fn from(src: &str) -> Self { + parse(src).unwrap_or_else(|e| panic!("tscn parse error: {e}")) + } +} + +/// Construct from string: `"...".to_string().into()`; panics on parse failure +impl From<String> for TscnDoc { + fn from(src: String) -> Self { + TscnDoc::from(src.as_str()) + } +} + +type PResult<T> = Result<T, TscnParseError>; + +/// Token: corresponds to Godot `get_token` TokenType +#[derive(Debug, Clone, PartialEq)] +enum Token { + Eof, + CurlyOpen, + CurlyClose, + BracketOpen, + BracketClose, + ParenOpen, + ParenClose, + Colon, + Comma, + Period, + Equal, + Ident(String), + Number(TscnNumber), + Str(String), + StrName(String), + Color(String), +} + +/// Line-level syntax unit: tag or assignment (the two outcomes of Godot `parse_tag_assign_eof`) +enum LineItem { + /// `[name key=value ...]` + Tag(TscnTag), + /// `name = value` + Assign { + name: String, + value: TscnValue, + line: usize, + }, + /// End of file + Eof, +} + +/// Parser: character stream + single-character pushback slot (corresponding to Godot `Stream::saved`) + single-token lookahead +/// +/// TODO(mingling): temporarily pub so pathf type scanning can verify; restore to private after fix. +pub struct Parser { + chars: Vec<char>, + pos: usize, + saved: Option<char>, + tok_buf: Option<Token>, + line: usize, +} + +impl Parser { + fn new(src: &str) -> Self { + let chars: Vec<char> = src.chars().collect(); + // Godot StreamFile skips BOM when opening a file (resource_format_text.cpp L1127-1136) + let pos = if chars.first() == Some(&'\u{FEFF}') { + 1 + } else { + 0 + }; + Self { + chars, + pos, + saved: None, + tok_buf: None, + line: 1, + } + } + + fn parse_err(&self, message: impl Into<String>) -> TscnParseError { + TscnParseError { + line: self.line, + message: message.into(), + } + } + + fn err<T>(&self, message: impl Into<String>) -> PResult<T> { + Err(self.parse_err(message)) + } + + /// Peek one character ahead (does not consume) + fn peek(&mut self) -> Option<char> { + if let Some(c) = self.saved { + return Some(c); + } + self.chars.get(self.pos).copied() + } + + /// Consume one character; counts `\n` lines + fn bump(&mut self) -> Option<char> { + if let Some(c) = self.saved.take() { + return Some(c); + } + let c = self.chars.get(self.pos).copied(); + if let Some(ch) = c { + self.pos += 1; + if ch == '\n' { + self.line += 1; + } + } + c + } + + /// Push back one character (corresponding to Godot `p_stream->saved = c`) + fn pushback(&mut self, c: char) { + debug_assert!(self.saved.is_none()); + self.saved = Some(c); + } + + /// Assert the next token equals `expected`, otherwise error + fn expect(&mut self, expected: Token, desc: &'static str) -> PResult<Token> { + let tok = self.next_token()?; + if tok == expected { + Ok(tok) + } else { + self.err(format!("Expected {desc}")) + } + } + + // ---- Token layer (get_token L162-520) ---- + + /// Get next token (consumes lookahead buffer first) + fn next_token(&mut self) -> PResult<Token> { + if let Some(t) = self.tok_buf.take() { + return Ok(t); + } + self.lex_token() + } + + /// Lookahead one token without consuming (returns a clone) + fn peek_token(&mut self) -> PResult<Token> { + if self.tok_buf.is_none() { + self.tok_buf = Some(self.lex_token()?); + } + Ok(self.tok_buf.as_ref().expect("just filled").clone()) + } + + /// Tokenizer (Godot `get_token` L162-520) + fn lex_token(&mut self) -> PResult<Token> { + loop { + let c = match self.bump() { + Some(c) => c, + None => return Ok(Token::Eof), + }; + match c { + '\n' => {} // line number already counted in bump + ';' => { + // Comment: skip to end of line (Godot L215-228) + while let Some(ch) = self.bump() { + if ch == '\n' { + break; + } + } + } + '{' => return Ok(Token::CurlyOpen), + '}' => return Ok(Token::CurlyClose), + '[' => return Ok(Token::BracketOpen), + ']' => return Ok(Token::BracketClose), + '(' => return Ok(Token::ParenOpen), + ')' => return Ok(Token::ParenClose), + ':' => return Ok(Token::Colon), + ',' => return Ok(Token::Comma), + '.' => return Ok(Token::Period), + '=' => return Ok(Token::Equal), + '&' => { + // StringName (Godot L263-275) + let q = match self.bump() { + Some(c) => c, + None => return Err(self.parse_err("Expected '\"' after '&'")), + }; + if q != '"' { + return self.err("Expected '\"' after '&'"); + } + let s = self.read_string()?; + return Ok(Token::StrName(s)); + } + '"' => { + let s = self.read_string()?; + return Ok(Token::Str(s)); + } + '#' => { + // Hexadecimal color (Godot L242-261) + let mut color = String::from("#"); + loop { + match self.bump() { + Some(ch) if ch.is_ascii_hexdigit() => color.push(ch), + Some(ch) => { + self.pushback(ch); + break; + } + None => break, + } + } + return Ok(Token::Color(color)); + } + '-' => { + // Negative number or -inf / -inf_neg (Godot L421-424) + match self.peek() { + Some(n) if n.is_ascii_digit() => { + let n = self.bump().expect("peeked"); + return self.read_number(Some('-'), n); + } + Some(n) if is_ident_start(n) => { + let n = self.bump().expect("peeked"); + return self.read_identifier(Some('-'), n); + } + _ => return self.err("Unexpected character '-'\n"), + } + } + c if c.is_ascii_digit() => return self.read_number(None, c), + c if is_ident_start(c) => return self.read_identifier(None, c), + c if c <= ' ' => {} // skip whitespace (Godot L416-419) + _ => return self.err("Unexpected character"), + } + } + } + + /// String token body (opening quote already consumed, read until closing quote; Godot L277-414) + fn read_string(&mut self) -> PResult<String> { + let mut s = String::new(); + let mut prev: Option<u32> = None; // pending lead surrogate to pair + loop { + let ch = match self.bump() { + Some(c) => c, + None => return self.err("Unterminated string"), + }; + if ch == '"' { + break; + } + if ch == '\\' { + let next = match self.bump() { + Some(c) => c, + None => return self.err("Unterminated string"), + }; + let res: u32 = match next { + 'b' => 8, + 't' => 9, + 'n' => 10, + 'f' => 12, + 'r' => 13, + 'u' | 'U' => { + let hex_len = if next == 'U' { 6 } else { 4 }; + let mut v: u32 = 0; + for _ in 0..hex_len { + let c = match self.bump() { + Some(c) => c, + None => return self.err("Unterminated string"), + }; + if !c.is_ascii_hexdigit() { + return self.err("Malformed hex constant in string"); + } + v = (v << 4) | c.to_digit(16).unwrap(); + } + v + } + other => other as u32, + }; + // UTF-16 surrogate pair (Godot L356-380) + if (res & 0xffff_fc00) == 0xd800 { + if prev.is_some() { + return self + .err("Invalid UTF-16 sequence in string, unpaired lead surrogate"); + } + prev = Some(res); + continue; + } else if (res & 0xffff_fc00) == 0xdc00 { + match prev.take() { + Some(lead) => { + let combined = (lead << 10) + res - ((0xd800 << 10) + 0xdc00 - 0x10000); + s.push(char::from_u32(combined).unwrap_or('\u{FFFD}')); + } + None => { + return self.err( + "Invalid UTF-16 sequence in string, unpaired trail surrogate", + ); + } + } + continue; + } + if prev.is_some() { + return self.err("Invalid UTF-16 sequence in string, unpaired lead surrogate"); + } + s.push(char::from_u32(res).unwrap_or('\u{FFFD}')); + } else { + if prev.is_some() { + return self.err("Invalid UTF-16 sequence in string, unpaired lead surrogate"); + } + s.push(ch); + } + } + if prev.is_some() { + return self.err("Invalid UTF-16 sequence in string, unpaired lead surrogate"); + } + Ok(s) + } + + /// Number token (Godot L425-493); `first` is the first character already consumed by next_token + fn read_number(&mut self, minus: Option<char>, first: char) -> PResult<Token> { + let mut text = String::new(); + if let Some(m) = minus { + text.push(m); + } + let mut c = first; + + #[derive(PartialEq)] + enum Reading { + Int, + Dec, + Exp, + } + let mut reading = Reading::Int; + let mut is_float = false; + let mut exp_sign = false; + let mut exp_beg = false; + + loop { + let done = match reading { + Reading::Int => { + if c.is_ascii_digit() { + false + } else if c == '.' { + reading = Reading::Dec; + is_float = true; + false + } else if c == 'e' || c == 'E' { + reading = Reading::Exp; + is_float = true; + false + } else { + true + } + } + Reading::Dec => { + if c.is_ascii_digit() { + false + } else if c == 'e' || c == 'E' { + reading = Reading::Exp; + false + } else { + true + } + } + Reading::Exp => { + if c.is_ascii_digit() { + exp_beg = true; + false + } else if (c == '-' || c == '+') && !exp_sign && !exp_beg { + exp_sign = true; + false + } else { + true + } + } + }; + if done { + self.pushback(c); + break; + } + text.push(c); + match self.bump() { + Some(x) => c = x, + None => break, // EOF + } + } + + let num = if is_float { + TscnNumber::Float(text.parse::<f64>().unwrap_or(f64::NAN)) + } else { + match text.parse::<i64>() { + Ok(v) => TscnNumber::Int(v), + Err(_) => TscnNumber::Float(text.parse::<f64>().unwrap_or(0.0)), + } + }; + Ok(Token::Number(num)) + } + + /// Identifier token (Godot L494-507); `first` is the first character already consumed by next_token + fn read_identifier(&mut self, minus: Option<char>, first: char) -> PResult<Token> { + let mut text = String::new(); + if let Some(m) = minus { + text.push(m); + } + let mut c = first; + let mut first = true; + loop { + if c.is_ascii_alphabetic() || c == '_' || (!first && c.is_ascii_digit()) { + text.push(c); + first = false; + match self.bump() { + Some(x) => c = x, + None => break, + } + } else { + self.pushback(c); + break; + } + } + Ok(Token::Ident(text)) + } + + // ---- Value layer (parse_value L677-1642) ---- + + /// Read a complete value (get token first, then dispatch) + fn parse_value(&mut self) -> PResult<TscnValue> { + let tok = self.next_token()?; + self.parse_value_from_token(tok) + } + + /// Value dispatch (corresponds to Godot `parse_value(r_token, ...)`) + fn parse_value_from_token(&mut self, tok: Token) -> PResult<TscnValue> { + match tok { + Token::CurlyOpen => { + let entries = self.parse_dict_body()?; + Ok(TscnValue::Dict(entries)) + } + Token::BracketOpen => { + let values = self.parse_array_body()?; + Ok(TscnValue::Array(values)) + } + Token::Ident(id) => self.parse_ident_value(id), + Token::Number(n) => Ok(TscnValue::Number(n)), + Token::Str(s) => Ok(TscnValue::Str(s)), + Token::StrName(s) => Ok(TscnValue::StrName(s)), + Token::Color(s) => Ok(TscnValue::Color(s)), + _ => self.err("Expected value"), + } + } + + /// Identifier value: keyword / typed container / constructor / bare identifier (superset) + fn parse_ident_value(&mut self, id: String) -> PResult<TscnValue> { + match id.as_str() { + "true" => return Ok(TscnValue::Bool(true)), + "false" => return Ok(TscnValue::Bool(false)), + "null" | "nil" => return Ok(TscnValue::Null), + "inf" => return Ok(TscnValue::Keyword(TscnKeyword::Inf)), + "-inf" => return Ok(TscnValue::Keyword(TscnKeyword::NegInf)), + "inf_neg" => return Ok(TscnValue::Keyword(TscnKeyword::InfNeg)), + "nan" => return Ok(TscnValue::Keyword(TscnKeyword::Nan)), + "Array" => return self.parse_typed_array(), + "Dictionary" => return self.parse_typed_dict(), + "Object" => return self.parse_object_ctor(), + _ => {} + } + // Constructor or bare identifier (superset): lookahead to decide, do not consume tokens after `(` + if self.peek_token()? == Token::ParenOpen { + let _ = self.next_token()?; // consume '(' + let args = self.parse_ctor_args()?; + Ok(TscnValue::Constructor(TscnCtor { name: id, args })) + } else { + Ok(TscnValue::Ident(id)) + } + } + + /// `[a, b, ...]` (Godot `_parse_array` L1644-1682) + fn parse_array_body(&mut self) -> PResult<Vec<TscnValue>> { + let mut values = Vec::new(); + let mut need_comma = false; + loop { + let tok = self.next_token()?; + if tok == Token::Eof { + return self.err("Unexpected EOF while parsing array"); + } + if tok == Token::BracketClose { + break; + } + if need_comma { + if tok != Token::Comma { + return self.err("Expected ','"); + } + need_comma = false; + continue; + } + values.push(self.parse_value_from_token(tok)?); + need_comma = true; + } + Ok(values) + } + + /// `{k: v, ...}` (Godot `_parse_dictionary` L1684-1748) + fn parse_dict_body(&mut self) -> PResult<Vec<(TscnValue, TscnValue)>> { + let mut entries = Vec::new(); + let mut at_key = true; + let mut need_comma = false; + let mut cur_key: Option<TscnValue> = None; + loop { + let tok = self.next_token()?; + if tok == Token::Eof { + return self.err("Unexpected EOF while parsing dictionary"); + } + if at_key { + if tok == Token::CurlyClose { + break; + } + if need_comma { + if tok != Token::Comma { + return self.err("Expected '}' or ','"); + } + need_comma = false; + continue; + } + cur_key = Some(self.parse_value_from_token(tok)?); + let tok = self.next_token()?; + if tok != Token::Colon { + return self.err("Expected ':'"); + } + at_key = false; + } else { + let value = self.parse_value_from_token(tok)?; + let key = cur_key.take().unwrap_or(TscnValue::Null); + entries.push((key, value)); + need_comma = true; + at_key = true; + } + } + Ok(entries) + } + + /// Constructor arguments for `Name(a, b, ...)` (generic form of Godot `_parse_construct`) + fn parse_ctor_args(&mut self) -> PResult<Vec<TscnCtorArg>> { + let mut args = Vec::new(); + let mut need_comma = false; + loop { + let tok = self.next_token()?; + if tok == Token::Eof { + return self.err("Unexpected EOF while parsing constructor"); + } + if tok == Token::ParenClose { + break; + } + if need_comma { + if tok != Token::Comma { + return self.err("Expected ','"); + } + need_comma = false; + continue; + } + args.push(TscnCtorArg::Value(self.parse_value_from_token(tok)?)); + need_comma = true; + } + Ok(args) + } + + /// `Object(ClassName, "prop": v, ...)` (Godot L995-1089) + fn parse_object_ctor(&mut self) -> PResult<TscnValue> { + self.expect(Token::ParenOpen, "'('")?; + let tok = self.next_token()?; + let class = match tok { + Token::Ident(s) => s, + _ => return self.err("Expected identifier with type of object"), + }; + let mut args = vec![TscnCtorArg::Value(TscnValue::Ident(class))]; + let tok = self.next_token()?; + if tok != Token::Comma { + return self.err("Expected ',' after object type"); + } + let mut need_comma = false; + loop { + let tok = self.next_token()?; + if tok == Token::Eof { + return self.err("Unexpected EOF while parsing Object()"); + } + if tok == Token::ParenClose { + break; + } + if need_comma { + if tok != Token::Comma { + return self.err("Expected '}' or ','"); + } + need_comma = false; + continue; + } + let key = match tok { + Token::Str(s) => s, + _ => return self.err("Expected property name as string"), + }; + let tok = self.next_token()?; + if tok != Token::Colon { + return self.err("Expected ':'"); + } + let value = self.parse_value()?; + args.push(TscnCtorArg::Named(key, value)); + need_comma = true; + } + Ok(TscnValue::Constructor(TscnCtor { + name: "Object".into(), + args, + })) + } + + /// `Array[T]([...])` (Godot L1328-1410) + fn parse_typed_array(&mut self) -> PResult<TscnValue> { + self.expect(Token::BracketOpen, "'[' after Array")?; + let elem = self.parse_type_ref()?; + self.expect(Token::BracketClose, "']'")?; + self.expect(Token::ParenOpen, "'('")?; + self.expect(Token::BracketOpen, "'['")?; + let values = self.parse_array_body()?; + self.expect(Token::ParenClose, "')'")?; + Ok(TscnValue::TypedArray { elem, values }) + } + + /// `Dictionary[K, V]({...})` (Godot L1188-1327) + fn parse_typed_dict(&mut self) -> PResult<TscnValue> { + self.expect(Token::BracketOpen, "'[' after Dictionary")?; + let key = self.parse_type_ref()?; + self.expect(Token::Comma, "','")?; + let val = self.parse_type_ref()?; + self.expect(Token::BracketClose, "']'")?; + self.expect(Token::ParenOpen, "'('")?; + self.expect(Token::CurlyOpen, "'{'")?; + let entries = self.parse_dict_body()?; + self.expect(Token::ParenClose, "')'")?; + Ok(TscnValue::TypedDict { key, val, entries }) + } + + /// Type reference inside a typed container: identifier or resource constructor + fn parse_type_ref(&mut self) -> PResult<TscnTypeRef> { + let tok = self.next_token()?; + match self.parse_value_from_token(tok)? { + TscnValue::Ident(name) => Ok(TscnTypeRef::Named(name)), + TscnValue::Constructor(c) => Ok(TscnTypeRef::Resource(Box::new(c))), + _ => self.err("Expected type identifier"), + } + } + + // ---- Tag layer (_parse_tag L1750-1873) ---- + + /// `[name key=value ...]` (opening `[` not consumed, read by this function) + fn parse_tag(&mut self) -> PResult<TscnTag> { + let line = self.line; + self.expect(Token::BracketOpen, "'['")?; + let tok = self.next_token()?; + let mut name = match tok { + Token::Ident(s) => s, + _ => return self.err("Expected identifier (tag name)"), + }; + let mut fields = Vec::new(); + let mut parsing_tag = true; + loop { + let mut tok = self.next_token()?; + if tok == Token::BracketClose { + break; + } + if tok == Token::Eof { + return self.err("Unexpected EOF while parsing tag"); + } + if parsing_tag { + match tok { + Token::Period => { + name.push('.'); + tok = self.next_token()?; + } + Token::Colon => { + name.push(':'); + tok = self.next_token()?; + } + _ => parsing_tag = false, + } + } + let id = match tok { + Token::Ident(s) => s, + _ => return self.err("Expected identifier"), + }; + if parsing_tag { + name.push_str(&id); + continue; + } + self.expect(Token::Equal, "'=' after identifier")?; + let value = self.parse_value()?; + fields.push((id, value)); + } + Ok(TscnTag { name, fields, line }) + } + + // ---- Line layer (parse_tag_assign_eof L1891-1961) ---- + + /// One line (or a tag): tag or `name = value` + fn parse_line_item(&mut self) -> PResult<LineItem> { + let mut what = String::new(); + // Capture line number at the first significant character (leading whitespace/newlines at entry may not be consumed yet, could skew by one line) + let mut item_line: Option<usize> = None; + loop { + let c = match self.peek() { + Some(c) => c, + None => return Ok(LineItem::Eof), + }; + match c { + ';' => { + while let Some(ch) = self.bump() { + if ch == '\n' { + break; + } + } + } + '[' if what.is_empty() => { + let tag = self.parse_tag()?; + return Ok(LineItem::Tag(tag)); + } + '"' => { + item_line.get_or_insert(self.line); + self.bump(); // consume opening quote + what = self.read_string()?; + } + '=' => { + self.bump(); + let value = self.parse_value()?; + return Ok(LineItem::Assign { + name: what, + value, + line: item_line.unwrap_or(self.line), + }); + } + c if c > ' ' => { + item_line.get_or_insert(self.line); + self.bump(); + what.push(c); + } + '\n' => { + self.bump(); // line number already counted in bump + } + _ => { + self.bump(); // other whitespace + } + } + } + } + + // ---- Document layer (load L452+ / _parse_node_tag L184-421) ---- + + /// `[tag]` + property line block (sub_resource / resource) + fn parse_block(&mut self, tag: TscnTag) -> PResult<(TscnBlock, LineItem)> { + let mut properties = Vec::new(); + loop { + match self.parse_line_item()? { + LineItem::Assign { name, value, line } => { + properties.push(TscnProperty { name, value, line }); + } + other => return Ok((TscnBlock { tag, properties }, other)), + } + } + } + + /// `[node]` + property line block + fn parse_node(&mut self, tag: TscnTag) -> PResult<(TscnNode, LineItem)> { + let mut properties = Vec::new(); + loop { + match self.parse_line_item()? { + LineItem::Assign { name, value, line } => { + properties.push(TscnProperty { name, value, line }); + } + other => { + let line = tag.line; + return Ok(( + TscnNode { + tag, + properties, + line, + }, + other, + )); + } + } + } + } + + /// Full document: consume in Godot's enforced section order + fn parse_doc(&mut self) -> PResult<TscnDoc> { + let mut next = self.parse_line_item()?; + + // Header: [gd_scene ...] / [gd_resource ...] + let header = match next { + LineItem::Tag(t) => t, + LineItem::Eof => return self.err("Expected '[gd_scene]' header, found EOF"), + LineItem::Assign { .. } => return self.err("Expected '[gd_scene]' header"), + }; + next = self.parse_line_item()?; + + // [ext_resource ...] (consecutive) + let mut ext_resources = Vec::new(); + loop { + match next { + LineItem::Tag(t) if t.name == "ext_resource" => { + ext_resources.push(t); + next = self.parse_line_item()?; + } + _ => break, + } + } + + // [sub_resource ...] blocks (consecutive) + let mut sub_resources = Vec::new(); + loop { + match next { + LineItem::Tag(t) if t.name == "sub_resource" => { + let (block, item) = self.parse_block(t)?; + sub_resources.push(block); + next = item; + } + _ => break, + } + } + + // [resource] main resource block (.tres) + let mut main_resource = None; + match next { + LineItem::Tag(t) if t.name == "resource" => { + let (block, item) = self.parse_block(t)?; + main_resource = Some(block); + next = item; + } + _ => {} + } + + // node / connection / editable (_parse_node_tag) + let mut nodes = Vec::new(); + let mut connections = Vec::new(); + let mut editables = Vec::new(); + loop { + match next { + LineItem::Tag(t) => match t.name.as_str() { + "node" => { + let (node, item) = self.parse_node(t)?; + nodes.push(node); + next = item; + } + "connection" => { + connections.push(t); + next = self.parse_line_item()?; + } + "editable" => { + editables.push(t); + next = self.parse_line_item()?; + } + other => return self.err(format!("Unknown tag '{other}' in file")), + }, + LineItem::Assign { .. } => return self.err("Unexpected assignment outside node"), + LineItem::Eof => break, + } + } + + Ok(TscnDoc { + header, + ext_resources, + sub_resources, + main_resource, + nodes, + connections, + editables, + }) + } +} + +fn is_ident_start(c: char) -> bool { + c.is_ascii_alphabetic() || c == '_' +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Your real loading_scene.tscn (after dragging in a glb from Godot) + const LOADING_SCENE: &str = r#"[gd_scene format=3 uid="uid://3pv27wfh70do"] + +[ext_resource type="PackedScene" uid="uid://7l43x00jkhdo" path="res://fixtures_doors_fences_01_98.glb" id="1_nbifo"] + +[node name="Node3D" type="Node3D" unique_id=392459690] + +[node name="fixtures_doors_fences_01_98" parent="." unique_id=60245752 instance=ExtResource("1_nbifo")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.0345476, 0, 0.08808398) +"#; + + #[test] + fn parse_loading_scene() { + let doc = parse(LOADING_SCENE).unwrap(); + + // Header + assert_eq!(doc.header.name, "gd_scene"); + assert_eq!( + doc.header.fields[0], + ("format".into(), TscnValue::Number(TscnNumber::Int(3))) + ); + assert_eq!( + doc.header.fields[1].1, + TscnValue::Str("uid://3pv27wfh70do".into()) + ); + + // ext_resource + assert_eq!(doc.ext_resources.len(), 1); + let ext = &doc.ext_resources[0]; + assert_eq!(ext.name, "ext_resource"); + assert_eq!(ext.fields[0].1, TscnValue::Str("PackedScene".into())); + assert_eq!( + ext.fields[2].1, + TscnValue::Str("res://fixtures_doors_fences_01_98.glb".into()) + ); + + // Nodes + assert_eq!(doc.nodes.len(), 2); + assert_eq!( + doc.nodes[0].tag.fields[1].1, + TscnValue::Str("Node3D".into()) + ); + // unique_id is a bare number (no quotes) + assert_eq!( + doc.nodes[0].tag.fields[2], + ( + "unique_id".into(), + TscnValue::Number(TscnNumber::Int(392459690)) + ) + ); + + // Instance node: instance = ExtResource("1_nbifo"), pure syntax, no dereferencing + let node2 = &doc.nodes[1]; + let instance = &node2.tag.fields[3]; + assert_eq!(instance.0, "instance"); + assert!(matches!( + &instance.1, + TscnValue::Constructor(TscnCtor { name, .. }) if name == "ExtResource" + )); + + // transform override property: Transform3D constructor, 12 arguments + assert_eq!(node2.properties.len(), 1); + let prop = &node2.properties[0]; + assert_eq!(prop.name, "transform"); + match &prop.value { + TscnValue::Constructor(TscnCtor { name, args }) => { + assert_eq!(name, "Transform3D"); + assert_eq!(args.len(), 12); + } + other => panic!("expected Transform3D constructor, got {other:?}"), + } + } + + #[test] + fn from_into_string() { + let doc: TscnDoc = LOADING_SCENE.into(); + assert_eq!(doc.nodes.len(), 2); + + let doc2 = TscnDoc::from(LOADING_SCENE.to_string()); + assert_eq!(doc2, doc); + } + + #[test] + fn sub_resource_block() { + let src = r#"[gd_scene format=3] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_x7y8z9"] +size = Vector2(16, 16) +custom = true + +[node name="Collision" type="StaticBody2D"] +shape = SubResource("RectangleShape2D_x7y8z9") +"#; + let doc = parse(src).unwrap(); + assert_eq!(doc.sub_resources.len(), 1); + let block = &doc.sub_resources[0]; + assert_eq!( + block.tag.fields[0].1, + TscnValue::Str("RectangleShape2D".into()) + ); + assert_eq!(block.properties.len(), 2); + assert!(matches!( + &block.properties[0].value, + TscnValue::Constructor(TscnCtor { name, .. }) if name == "Vector2" + )); + // Node referencing sub_resource id + assert_eq!(doc.nodes.len(), 1); + assert!(matches!( + &doc.nodes[0].properties[0].value, + TscnValue::Constructor(TscnCtor { name, .. }) if name == "SubResource" + )); + } + + #[test] + fn value_forms() { + let src = r#"[gd_scene format=3] + +[node name="N" type="Node"] +a = [1, "x", true, null] +b = {"k": 1, 2: "v"} +c = Dictionary[String, int]({"a": 1}) +d = Array[ExtResource("1_abcde")]([1, 2]) +e = Color(1, 0.5, 0.5, 1) +f = NodePath("a/b") +g = &"string_name" +h = #ff8800 +i = PackedVector2Array(1, 2, 3, 4) +j = inf +k = -inf +l = Object(Resource, "script": ExtResource("2_fghij")) +"#; + let doc = parse(src).unwrap(); + let props = &doc.nodes[0].properties; + assert_eq!(props.len(), 12); + + // [1, "x", true, null] + assert!(matches!( + &props[0].value, + TscnValue::Array(v) if v.len() == 4 + )); + // {"k": 1, 2: "v"} + assert!(matches!( + &props[1].value, + TscnValue::Dict(v) if v.len() == 2 + )); + // Dictionary[String, int]({"a": 1}) + assert!(matches!( + &props[2].value, + TscnValue::TypedDict { key, val, entries } if matches!(key, TscnTypeRef::Named(k) if k == "String") + && matches!(val, TscnTypeRef::Named(v) if v == "int") + && entries.len() == 1 + )); + // Array[ExtResource("1_abcde")]([1, 2]) + assert!(matches!( + &props[3].value, + TscnValue::TypedArray { elem, values } + if matches!(elem, TscnTypeRef::Resource(c) if c.name == "ExtResource") + && values.len() == 2 + )); + // Keywords + assert_eq!(props[9].value, TscnValue::Keyword(TscnKeyword::Inf)); + assert_eq!(props[10].value, TscnValue::Keyword(TscnKeyword::NegInf)); + // Object named pairs + assert!(matches!( + &props[11].value, + TscnValue::Constructor(TscnCtor { name, args }) + if name == "Object" + && args.len() == 2 + && matches!(&args[1], TscnCtorArg::Named(k, _) if k == "script") + )); + } + + #[test] + fn string_escapes() { + let src = r#"[gd_scene format=3] + +[node name="N" type="Node"] +a = "line\nbreak \u0041 \U01F600" +b = "quote \" backslash \\" +"#; + let doc = parse(src).unwrap(); + let props = &doc.nodes[0].properties; + assert_eq!( + props[0].value, + TscnValue::Str("line\nbreak A \u{1F600}".into()) + ); + assert_eq!( + props[1].value, + TscnValue::Str("quote \" backslash \\".into()) + ); + } + + #[test] + fn quoted_property_name_and_comment() { + let src = "[gd_scene format=3]\r\n; 这是注释\n[node name=\"N\" type=\"Node\"]\r\n\"layer/name\" = 1\r\n"; + let doc = parse(src).unwrap(); + assert_eq!(doc.nodes[0].properties[0].name, "layer/name"); + assert_eq!( + doc.nodes[0].properties[0].value, + TscnValue::Number(TscnNumber::Int(1)) + ); + } + + /// Property line numbers must point to the line of the property (tag line + 1) + #[test] + fn property_line_numbers() { + let src = r#"[gd_scene format=3] + +[node name="A" type="Node"] + +foo = 1 +bar = 2.5 + +[node name="B" type="Node"] +"#; + let doc = parse(src).unwrap(); + let node_a = &doc.nodes[0]; + assert_eq!(node_a.tag.line, 3); + assert_eq!(node_a.properties[0].name, "foo"); + assert_eq!(node_a.properties[0].line, 5); + assert_eq!(node_a.properties[1].name, "bar"); + assert_eq!(node_a.properties[1].line, 6); + } + + #[test] + fn unknown_tag_errors() { + let src = "[gd_scene format=3]\n[whatisthis]\n"; + let err = parse(src).unwrap_err(); + assert!(err.message.contains("Unknown tag"), "{err}"); + } + + #[test] + fn tag_name_with_platform_suffix() { + // Godot supports platform-suffix tags like [someprop.Android] + let src = "[gd_scene format=3]\n[node name=\"N\" type=\"Node\"]\n"; + let doc = parse(src).unwrap(); + assert_eq!(doc.header.name, "gd_scene"); + assert_eq!(doc.nodes.len(), 1); + } +} diff --git a/tools/tscn-to-bsn/src/lib.rs b/tools/tscn-to-bsn/src/lib.rs new file mode 100644 index 0000000..faaa93a --- /dev/null +++ b/tools/tscn-to-bsn/src/lib.rs @@ -0,0 +1,7 @@ +use mingling::macros::gen_program; + +pub mod cmd; +pub mod err; +pub mod gd_res_structure; + +gen_program!(); diff --git a/tools/tscn-to-bsn/src/main.rs b/tools/tscn-to-bsn/src/main.rs deleted file mode 100644 index eb7532c..0000000 --- a/tools/tscn-to-bsn/src/main.rs +++ /dev/null @@ -1,14 +0,0 @@ -use mingling::prelude::*; - -fn main() { - let mut program = ThisProgram::new(); - program.with_dispatcher(CMDConvert); - program.exec_and_exit(); -} - -dispatcher!("convert"); - -#[chain] -fn handle_convert(_args: EntryConvert) {} - -gen_program!(); |
