aboutsummaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-08-08 19:03:26 +0800
committer魏曹先生 <1992414357@qq.com>2026-08-08 19:03:26 +0800
commit5d724e95c6800e25486c25c6a9a3ae718fb2069b (patch)
treedead172ed10e71b09a74cf0070b4f16b5b929e0c /tools
parent1e498d1ed1645d93a49230d0647f1fa15aecb113 (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.lock64
-rw-r--r--tools/tscn-to-bsn/Cargo.toml17
-rw-r--r--tools/tscn-to-bsn/build.rs5
-rw-r--r--tools/tscn-to-bsn/src/bin/tscn2bsn.rs11
-rw-r--r--tools/tscn-to-bsn/src/cmd/cmd_convert.rs85
-rw-r--r--tools/tscn-to-bsn/src/cmd/mod.rs1
-rw-r--r--tools/tscn-to-bsn/src/err/io.rs8
-rw-r--r--tools/tscn-to-bsn/src/err/mod.rs3
-rw-r--r--tools/tscn-to-bsn/src/err/ron.rs8
-rw-r--r--tools/tscn-to-bsn/src/err/tscn_parse.rs10
-rw-r--r--tools/tscn-to-bsn/src/gd_res_structure/mod.rs5
-rw-r--r--tools/tscn-to-bsn/src/gd_res_structure/tscn_ast.rs225
-rw-r--r--tools/tscn-to-bsn/src/gd_res_structure/tscn_ast_parser.rs1155
-rw-r--r--tools/tscn-to-bsn/src/lib.rs7
-rw-r--r--tools/tscn-to-bsn/src/main.rs14
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!();