Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
166 lines
5.9 KiB
GDScript
166 lines
5.9 KiB
GDScript
class_name YamlParser
|
|
## Shared YAML parser — common subset used by ui_strings.gd and checklist_evaluator.gd.
|
|
##
|
|
## Handles: nested sections (maps), arrays of dict items (- key: val), typed values.
|
|
## Returns a hierarchical Dictionary. Use flatten() to convert to dotted-key format
|
|
## (as UIStrings._parse_yaml() requires).
|
|
##
|
|
## Limitations: single-line values only; no YAML anchors/aliases; no flow syntax.
|
|
## String values: quotes stripped. Booleans, ints, and floats are type-inferred.
|
|
##
|
|
## Spec ref: #560 (Sprint 20 — unify duplicate YAML parsers), D-030 (testability).
|
|
|
|
|
|
## Parse YAML text into a hierarchical Dictionary.
|
|
## Nested sections become nested dicts. Array items (- key: val) become Arrays.
|
|
## Values are type-inferred: bool, int, float, or String.
|
|
static func parse(text: String) -> Dictionary:
|
|
var root: Dictionary = {}
|
|
# Stack: [{indent: int, key: String}] — path of open section headers
|
|
var stack: Array = []
|
|
# Array state
|
|
var current_array: Variant = null # Array being built, or null
|
|
var current_item: Variant = null # Dict being built for current array item, or null
|
|
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
|
|
|
|
for raw_line in text.split("\n"):
|
|
var stripped := raw_line.strip_edges(false, true)
|
|
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
|
continue
|
|
var indent: int = raw_line.length() - raw_line.lstrip(" ").length()
|
|
var content: String = stripped.strip_edges()
|
|
|
|
# --- Array item (- key: value) ---
|
|
if content.begins_with("- "):
|
|
# First item: convert parent section's {} placeholder to []
|
|
if current_array == null and stack.size() > 0:
|
|
var parent := _node_at(root, stack, true)
|
|
var arr_key: String = stack.back()["key"]
|
|
var new_arr: Array = []
|
|
parent[arr_key] = new_arr
|
|
current_array = new_arr
|
|
array_parent_indent = stack.back()["indent"]
|
|
# Flush previous item and start a new one
|
|
if current_item != null:
|
|
current_array.append(current_item)
|
|
current_item = {}
|
|
var rest: String = content.substr(2).strip_edges()
|
|
var colon: int = rest.find(":")
|
|
if colon >= 0:
|
|
var k: String = rest.substr(0, colon).strip_edges()
|
|
var v: String = rest.substr(colon + 1).strip_edges()
|
|
current_item[k] = _parse_value(v)
|
|
continue
|
|
|
|
# --- Continuation line within current array item ---
|
|
if current_array != null and indent > array_parent_indent:
|
|
var colon: int = content.find(":")
|
|
if colon >= 0 and current_item != null:
|
|
var k: String = content.substr(0, colon).strip_edges()
|
|
var v: String = content.substr(colon + 1).strip_edges()
|
|
current_item[k] = _parse_value(v)
|
|
continue
|
|
|
|
# --- End of array (indent has returned to array level or above) ---
|
|
if current_array != null:
|
|
if current_item != null:
|
|
current_array.append(current_item)
|
|
current_item = null
|
|
current_array = null
|
|
array_parent_indent = -1
|
|
if stack.size() > 0:
|
|
stack.pop_back() # pop the array-owning key
|
|
|
|
# --- Regular key: value or section header ---
|
|
var colon: int = content.find(":")
|
|
if colon < 0:
|
|
continue
|
|
var key: String = content.substr(0, colon).strip_edges()
|
|
var val_str: String = content.substr(colon + 1).strip_edges()
|
|
|
|
# Pop sections at the same or deeper indent (we're back at a shallower level)
|
|
while stack.size() > 0 and stack.back()["indent"] >= indent:
|
|
stack.pop_back()
|
|
|
|
var node: Dictionary = _node_at(root, stack, false)
|
|
|
|
if val_str.is_empty() or val_str.begins_with("#"):
|
|
# Section header — create nested dict (may become Array if - items follow)
|
|
node[key] = {}
|
|
stack.push_back({"indent": indent, "key": key})
|
|
else:
|
|
node[key] = _parse_value(val_str)
|
|
|
|
# Flush the last array item if the file ended inside an array
|
|
if current_array != null and current_item != null:
|
|
current_array.append(current_item)
|
|
|
|
return root
|
|
|
|
|
|
## Convenience: parse text and flatten to dotted-key format in one call.
|
|
## Used by UIStrings._parse_yaml() — equivalent to flatten(parse(text)).
|
|
static func parse_flat(text: String) -> Dictionary:
|
|
return flatten(parse(text))
|
|
|
|
|
|
## Flatten a hierarchical dict to dotted-key format (for UIStrings compatibility).
|
|
## {"a": {"b": "v"}} → {"a.b": "v"}
|
|
## Arrays are skipped — dotted-key format does not represent them.
|
|
## All values are converted to String (UIStrings stores display text, not typed data).
|
|
static func flatten(d: Dictionary, prefix: String = "") -> Dictionary:
|
|
var result: Dictionary = {}
|
|
for k in d:
|
|
var full_key: String = (prefix + "." if not prefix.is_empty() else "") + str(k)
|
|
var v = d[k]
|
|
if v is Dictionary:
|
|
result.merge(flatten(v, full_key))
|
|
elif not v is Array:
|
|
result[full_key] = str(v)
|
|
return result
|
|
|
|
|
|
## Parse a single YAML value string into a typed GDScript value.
|
|
## Strips inline comments, handles quoted strings, infers bool/int/float/String.
|
|
static func _parse_value(val: String) -> Variant:
|
|
if val.is_empty():
|
|
return ""
|
|
# Strip inline comment outside quotes
|
|
if not val.begins_with('"'):
|
|
var comment_pos: int = val.find(" #")
|
|
if comment_pos >= 0:
|
|
val = val.substr(0, comment_pos).strip_edges()
|
|
# Quoted string — extract content between quotes
|
|
if val.begins_with('"'):
|
|
var end_quote: int = val.find('"', 1)
|
|
if end_quote > 0:
|
|
return val.substr(1, end_quote - 1)
|
|
return val.substr(1)
|
|
# Boolean
|
|
if val == "true":
|
|
return true
|
|
if val == "false":
|
|
return false
|
|
# Float (must have decimal point)
|
|
if val.contains(".") and val.is_valid_float():
|
|
return val.to_float()
|
|
# Integer
|
|
if val.is_valid_int():
|
|
return val.to_int()
|
|
# Plain string
|
|
return val
|
|
|
|
|
|
## Navigate root following the stack key path.
|
|
## parent=true: navigate one level less (returns the parent node, not the leaf).
|
|
static func _node_at(root: Dictionary, stack: Array, parent: bool) -> Dictionary:
|
|
var node: Dictionary = root
|
|
var depth: int = stack.size() - (1 if parent else 0)
|
|
for i in range(depth):
|
|
var k: String = stack[i]["key"]
|
|
if node.has(k) and node[k] is Dictionary:
|
|
node = node[k]
|
|
else:
|
|
break
|
|
return node
|