//! # 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`) 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 { 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 for TscnDoc { fn from(src: String) -> Self { TscnDoc::from(src.as_str()) } } type PResult = Result; /// 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, pos: usize, saved: Option, tok_buf: Option, line: usize, } impl Parser { fn new(src: &str) -> Self { let chars: Vec = 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) -> TscnParseError { TscnParseError { line: self.line, message: message.into(), } } fn err(&self, message: impl Into) -> PResult { Err(self.parse_err(message)) } /// Peek one character ahead (does not consume) fn peek(&mut self) -> Option { 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 { 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 { 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 { 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 { 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 { 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 { let mut s = String::new(); let mut prev: Option = 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, first: char) -> PResult { 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::().unwrap_or(f64::NAN)) } else { match text.parse::() { Ok(v) => TscnNumber::Int(v), Err(_) => TscnNumber::Float(text.parse::().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, first: char) -> PResult { 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 { 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 { 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 { 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> { 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> { let mut entries = Vec::new(); let mut at_key = true; let mut need_comma = false; let mut cur_key: Option = 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> { 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 { 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 { 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 { 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 { 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 { 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 { 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 = 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 { 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); } }