refactor(client): unify duplicate YAML parsers into YamlParser (#560)

Extract shared YamlParser utility (client/scripts/util/yaml_parser.gd)
with parse() for nested typed dicts and parse_flat() for dotted-key
string format. UIStrings._parse_yaml() and ChecklistEvaluator's inline
parser both delegate to YamlParser, removing ~140 lines of duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 22:00:05 +01:00
co-authored by Claude Opus 4.6
parent 964bb9c459
commit de136fc1a5
4 changed files with 429 additions and 145 deletions
+2 -40
View File
@@ -49,44 +49,6 @@ func reload() -> void:
## Parse YAML with arbitrary nesting depth.
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
## Delegates to YamlParser.parse_flat() (#560).
static func _parse_yaml(text: String) -> Dictionary:
var strings := {}
var stack: Array = [] # [[indent, key], ...]
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
var colon_pos := content.find(":")
if colon_pos < 0:
continue
var key := content.substr(0, colon_pos).strip_edges()
var val := content.substr(colon_pos + 1).strip_edges()
# Trailing comment without a value — treat as section header
if val.begins_with("#"):
val = ""
# Pop sections at same or deeper indent
while stack.size() > 0 and stack.back()[0] >= indent:
stack.pop_back()
if val.is_empty():
# Section header — push onto stack
stack.push_back([indent, key])
else:
# Leaf value — extract from quotes or strip inline comment
if val.begins_with("\""):
var end_quote := val.find("\"", 1)
if end_quote > 0:
val = val.substr(1, end_quote - 1)
else:
val = val.substr(1)
else:
var comment_pos := val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
var dotted_key := ""
for entry in stack:
dotted_key += entry[1] + "."
dotted_key += key
strings[dotted_key] = val
return strings
return YamlParser.parse_flat(text)
+3 -105
View File
@@ -233,12 +233,7 @@ func _find_entity(entity_id: int) -> bool:
return false
# -- YAML parsing (checklist-specific) -----------------------------------------
# Handles the constrained checklist YAML format: top-level key:value pairs,
# a conditions array of flat dictionaries. No nested arrays or anchors.
#
# Limitation: unquoted values containing " #" are truncated at the comment marker.
# Use quoted strings ("value # with hash") if values must contain literal hashes.
# -- YAML parsing --------------------------------------------------------------
func _load_checklist_file(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
@@ -252,103 +247,6 @@ func _load_checklist_file(path: String) -> Dictionary:
return parse_checklist_yaml(text)
## Delegates to YamlParser.parse() (#560).
static func parse_checklist_yaml(text: String) -> Dictionary:
var result := {}
var conditions: Array = []
var current_item: Dictionary = {}
var in_conditions := false
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
# Detect conditions: array header
if content == "conditions:":
in_conditions = true
continue
if not in_conditions:
# Top-level key: value
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
else:
if content.begins_with("- "):
# New array item — flush previous
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var rest := content.substr(2).strip_edges()
var colon := rest.find(":")
if colon >= 0:
var key := rest.substr(0, colon).strip_edges()
var val_str := rest.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent >= 2 and not current_item.is_empty():
# Continuation of current array item
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent == 0:
# Back to top level — shouldn't happen in valid checklist YAML
in_conditions = false
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
# Flush last item
if not current_item.is_empty():
conditions.append(current_item)
if not conditions.is_empty():
result["conditions"] = conditions
return result
static func _parse_value(val: String) -> Variant:
if val.is_empty():
return ""
# Strip inline comments (not inside quotes)
if not val.begins_with("\""):
var comment_pos := val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
# Quoted string
if val.begins_with("\""):
var end_quote := 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 (contains 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
return YamlParser.parse(text)
+163
View File
@@ -0,0 +1,163 @@
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