Add Recipemanager that loads recipes from YAML

This commit is contained in:
JonShard
2026-07-17 13:54:14 +02:00
parent 840908e9ff
commit 4d06e72bc1
133 changed files with 9121 additions and 175 deletions
@@ -0,0 +1,384 @@
@tool
class_name YAMLSyntaxParser extends RefCounted
## Token types
enum TokenType {
TEXT, # For keys only
COMMENT, # Comments
SYMBOL, # Structural elements like :, -, >, |, &, *, [, ], {, }
STRING, # String values (default for unmatched values)
NUMBER, # Numeric values
KEYWORD, # Booleans, null, merge keys, tags
DOCUMENT_SEPARATOR, # New document separator
}
var re_patterns := {
"merge_key": RegEx.create_from_string("^\\s*<<:\\s*\\*[^\\s]+"),
"multiline_indicator": RegEx.create_from_string("(>|\\|-?)\\s*$"),
"array_item": RegEx.create_from_string("^(\\s*-(?:\\s*-)*\\s*)(.*)$"),
"key_value": RegEx.create_from_string("^\\s*([^:]+):(.*)$"),
# Scalar patterns
"quoted_string": RegEx.create_from_string("^(['\"])(?:\\\\.|[^\\\\])*\\1$"),
"number": RegEx.create_from_string("^(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][0-1]+|[-+]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][-+]?\\d+)?)$"),
"boolean": RegEx.create_from_string("^(true|false)$"),
"nullish": RegEx.create_from_string("^(null|~)$"),
"special": RegEx.create_from_string("^(\\.inf|\\.nan)$"),
# YAML functionality
"anchor": RegEx.create_from_string("^\\s*&([^\\s]+)"),
"alias": RegEx.create_from_string("^\\s*\\*([^\\s]+)"),
"tag": RegEx.create_from_string("!(?:![\\w\\-\\.]+|[^\\s\\[\\]{}'\"`]+)"),
"top_level_tag": RegEx.create_from_string("^\\s*(!(?:![\\w\\-\\.]+|[^\\s\\[\\]{}'\"`]+))\\s*(.*)$"),
"document_separator": RegEx.create_from_string("^---$")
}
class ParserState:
var in_string: bool = false
var string_char: String = ""
var stack: Array = [] # For nested flow collections
var token_start: int = -1
var colors: Dictionary = {}
func push(char: String) -> void:
stack.push_back(char)
func pop() -> String:
return stack.pop_back() if not stack.is_empty() else ""
func peek() -> String:
return stack.back() if not stack.is_empty() else ""
## Provides colors to the syntax highlighter
class ColorProvider:
# Default theme colors
var theme: Dictionary = {
"text": Color(0.8025, 0.81, 0.8225, 1),
"comment": Color(0.8025, 0.81, 0.8225, 0.5),
"symbol": Color(0.67, 0.79, 1, 1),
"string": Color(1, 0.93, 0.63, 1),
"number": Color(0.63, 1, 0.88, 1),
"keyword": Color(1, 0.44, 0.52, 1),
"document_separator": Color(0.8025, 0.81, 0.8225, 0.5)
}
func _init():
update_theme()
## Updates the theme colors from Godot Editor settings
func update_theme() -> void:
# Only inside the Godot Editor
if !Engine.is_editor_hint():
return
# Read theme from editor settings
var settings = EditorInterface.get_editor_settings()
theme = {
"text": settings.get_setting("text_editor/theme/highlighting/text_color"),
"comment": settings.get_setting("text_editor/theme/highlighting/comment_color"),
"symbol": settings.get_setting("text_editor/theme/highlighting/symbol_color"),
"string": settings.get_setting("text_editor/theme/highlighting/string_color"),
"number": settings.get_setting("text_editor/theme/highlighting/number_color"),
"keyword": settings.get_setting("text_editor/theme/highlighting/keyword_color"),
"document_separator": settings.get_setting("text_editor/theme/highlighting/comment_color"),
}
## Get color for tokens
func get_color_for_type(type: TokenType) -> Color:
match type:
YAMLSyntaxParser.TokenType.TEXT: return theme.text
YAMLSyntaxParser.TokenType.COMMENT: return theme.comment
YAMLSyntaxParser.TokenType.SYMBOL: return theme.symbol
YAMLSyntaxParser.TokenType.STRING: return theme.string
YAMLSyntaxParser.TokenType.NUMBER: return theme.number
YAMLSyntaxParser.TokenType.KEYWORD: return theme.keyword
YAMLSyntaxParser.TokenType.DOCUMENT_SEPARATOR: return theme.document_separator
_: return theme.string # Default fallback is string color
## Highlight a line of YAML
func highlight_line(text: String, color_provider: ColorProvider = ColorProvider.new()) -> Dictionary:
var comment_pos := _find_comment_start(text)
var content := text if comment_pos == -1 else text.substr(0, comment_pos).rstrip(" \t")
var colors := {}
if content.strip_edges():
colors = _highlight_line_content(content, color_provider)
if comment_pos != -1:
colors[comment_pos] = {"color": color_provider.get_color_for_type(TokenType.COMMENT)}
return _sort_colors(colors)
# Find the beginning position of a comment
func _find_comment_start(text: String) -> int:
var in_string := false
var string_char := ""
for i in range(text.length()):
var char := text[i]
if not in_string:
if char in ['"', "'"] and (i == 0 or text[i - 1] != '\\'):
in_string = true
string_char = char
elif char == '#' and (i == 0 or text[i - 1] == ' ' or text[i - 1] == '\t'):
return i
else:
if char == string_char and (i == 0 or text[i - 1] != '\\'):
in_string = false
string_char = ""
return -1
# Highlight line content
func _highlight_line_content(text: String, color_provider: ColorProvider) -> Dictionary:
# Handle document separator
var separator_match: RegExMatch = re_patterns.document_separator.search(text)
if separator_match:
return {0: {"color": color_provider.get_color_for_type(TokenType.DOCUMENT_SEPARATOR)}}
# Handle merge keys
var merge_match: RegExMatch = re_patterns.merge_key.search(text)
if merge_match:
return {
merge_match.get_start(0): {"color": color_provider.get_color_for_type(TokenType.KEYWORD)}
}
# Check for top-level tags
var tag_match: RegExMatch = re_patterns.top_level_tag.search(text)
if tag_match:
var colors := {}
# Color just the tag part as keyword (red)
_add_color(color_provider, colors, tag_match.get_start(1), tag_match.get_end(1), TokenType.KEYWORD)
# Process any remaining content after the tag
var remaining = tag_match.get_string(2).strip_edges()
if remaining:
var remaining_start = text.find(remaining, tag_match.get_end(1))
if remaining_start != -1:
if remaining.begins_with("{") or remaining.begins_with("["):
colors.merge(_parse_flow_style(color_provider, remaining, remaining_start))
else:
_add_scalar_color(color_provider, colors, remaining, remaining_start)
return colors
# Handle array items
var array_match: RegExMatch = re_patterns.array_item.search(text)
if array_match:
var colors := {}
# Color the entire dash section as symbols
_add_color(color_provider, colors, array_match.get_start(1), array_match.get_end(1), TokenType.SYMBOL)
# Process the content after the dashes
var content: String = array_match.get_string(2).strip_edges()
if content:
var content_start: int = array_match.get_start(2)
if content.begins_with("[") or content.begins_with("{"):
colors.merge(_parse_flow_style(color_provider, content, content_start))
else:
_add_scalar_color(color_provider, colors, content, content_start)
return colors
# Handle regular key-value pairs
var key_value_match: RegExMatch = re_patterns.key_value.search(text)
if key_value_match:
return _parse_key_value(color_provider, text, key_value_match)
# Handle flow-style collections at the root level
if "[" in text or "{" in text:
return _parse_flow_style(color_provider, text, 0)
# Handle multi-line string indicators
var multiline_match: RegExMatch = re_patterns.multiline_indicator.search(text)
if multiline_match:
var colors := {}
# Color the indicator (> or |) as symbol
_add_color(color_provider, colors, multiline_match.get_start(1), multiline_match.get_end(1), TokenType.SYMBOL)
return colors
# Default case: treat as string content (for multi-line string content)
if text.strip_edges():
return {0: {"color": color_provider.get_color_for_type(TokenType.STRING)}}
return {}
# Parse flow collections
func _parse_flow_style(color_provider: ColorProvider, text: String, offset: int) -> Dictionary:
var state := ParserState.new()
var pos := 0
while pos < text.length():
var char := text[pos]
# Handle string literals
if char in ['"', "'"] and (pos == 0 or text[pos - 1] != '\\'):
if not state.in_string:
state.in_string = true
state.string_char = char
state.token_start = pos
elif char == state.string_char:
state.in_string = false
_add_color(color_provider, state.colors, offset + state.token_start, offset + pos + 1, TokenType.STRING)
state.token_start = -1
# Handle flow collection brackets when not in string
elif not state.in_string:
if char in ['[', '{']:
state.push(char)
_add_color(color_provider, state.colors, offset + pos, offset + pos + 1, TokenType.SYMBOL)
state.token_start = pos + 1
elif char in [']', '}']:
var matching := '[' if char == ']' else '{'
if state.peek() == matching:
state.pop()
if state.token_start != -1:
var token := text.substr(state.token_start, pos - state.token_start).strip_edges()
if token:
_add_scalar_color(color_provider, state.colors, token, offset + state.token_start)
_add_color(color_provider, state.colors, offset + pos, offset + pos + 1, TokenType.SYMBOL)
state.token_start = -1
elif char in [':', ',']:
if state.token_start != -1:
var token := text.substr(state.token_start, pos - state.token_start).strip_edges()
if token:
if char == ':':
# All map keys should be text colored, regardless of content
_add_color(color_provider, state.colors, offset + state.token_start, offset + pos, TokenType.TEXT)
else:
_add_scalar_color(color_provider, state.colors, token, offset + state.token_start)
_add_color(color_provider, state.colors, offset + pos, offset + pos + 1, TokenType.SYMBOL)
state.token_start = pos + 1
elif char != ' ' and state.token_start == -1:
state.token_start = pos
pos += 1
# Handle any remaining token
if state.token_start != -1 and state.token_start < pos:
var token := text.substr(state.token_start, pos - state.token_start).strip_edges()
if token:
# Check if this is a key in a map context
if not state.stack.is_empty() and state.stack.back() == '{' and ':' in text.substr(pos):
_add_color(color_provider, state.colors, offset + state.token_start, offset + pos, TokenType.TEXT)
else:
_add_scalar_color(color_provider, state.colors, token, offset + state.token_start)
return state.colors
# Parse dictionary key and value
func _parse_key_value(color_provider: ColorProvider, text: String, match: RegExMatch) -> Dictionary:
var colors := {}
# Color the key
_add_color(color_provider, colors, match.get_start(1), match.get_end(1), TokenType.TEXT)
# Color the colon
_add_color(color_provider,colors, match.get_end(1), match.get_end(1) + 1, TokenType.SYMBOL)
# Get and process the value if present
var value := match.get_string(2).strip_edges()
if value:
var value_start := text.find(value, match.get_end(1))
if value_start != -1:
# First check for and handle any tags
var tag_match: RegExMatch = re_patterns.tag.search(value)
if tag_match:
_add_color(color_provider, colors, value_start + tag_match.get_start(0),
value_start + tag_match.get_end(0), TokenType.KEYWORD)
# Get remaining content after tag
var after_tag := value.substr(tag_match.get_end(0)).strip_edges()
if after_tag:
var after_tag_start = text.find(after_tag, value_start + tag_match.get_end(0))
if after_tag_start != -1:
# Now check for multiline indicator in remaining content
var indicator_match: RegExMatch = re_patterns.multiline_indicator.search(after_tag)
if indicator_match:
_add_color(color_provider, colors, after_tag_start + indicator_match.get_start(1), after_tag_start + indicator_match.get_end(1), TokenType.SYMBOL)
elif after_tag.begins_with("{") or after_tag.begins_with("["):
# Process flow style collections after the tag
colors.merge(_parse_flow_style(color_provider, after_tag, after_tag_start))
else:
# Process normal scalar after the tag
_add_scalar_color(color_provider, colors, after_tag, after_tag_start)
return colors
# If no tag, check for multiline indicator in full value
var indicator_match: RegExMatch = re_patterns.multiline_indicator.search(value)
if indicator_match:
_add_color(color_provider, colors, value_start + indicator_match.get_start(1), value_start + indicator_match.get_end(1), TokenType.SYMBOL)
elif value.begins_with("[") or value.begins_with("{"):
colors.merge(_parse_flow_style(color_provider, value, value_start))
else:
_add_scalar_color(color_provider, colors, value, value_start)
return colors
# Colors for scalar values
func _add_scalar_color(color_provider: ColorProvider, colors: Dictionary, token: String, start_index: int) -> void:
# Handle empty or whitespace-only tokens
token = token.strip_edges()
if token.is_empty():
return
# Check for quoted strings first
if re_patterns.quoted_string.search(token):
_add_color(color_provider, colors, start_index, start_index + token.length(), TokenType.STRING)
return # Important: return early to prevent parsing tags inside strings
# Check for tags
var tag_match: RegExMatch = re_patterns.tag.search(token)
if tag_match:
var tag_start := tag_match.get_start(0)
var tag_end := tag_match.get_end(0)
# Only color the tag portion
_add_color(color_provider, colors, start_index + tag_start, start_index + tag_end, TokenType.KEYWORD)
# Process any remaining content after the tag
if tag_end < token.length():
var remaining := token.substr(tag_end).strip_edges()
if remaining:
var remaining_start = start_index + token.find(remaining, tag_end)
if remaining_start != -1:
if remaining.begins_with("{") or remaining.begins_with("["):
colors.merge(_parse_flow_style(color_provider, remaining, remaining_start))
else:
# Apply appropriate coloring for the remaining content
if re_patterns.number.search(remaining):
_add_color(color_provider, colors, remaining_start, remaining_start + remaining.length(), TokenType.NUMBER)
elif re_patterns.boolean.search(remaining) or re_patterns.nullish.search(remaining) or re_patterns.special.search(remaining):
_add_color(color_provider, colors, remaining_start, remaining_start + remaining.length(), TokenType.KEYWORD)
else:
_add_color(color_provider, colors, remaining_start, remaining_start + remaining.length(), TokenType.STRING)
return
# Rest of the scalar checks for non-tag content
elif re_patterns.number.search(token):
_add_color(color_provider, colors, start_index, start_index + token.length(), TokenType.NUMBER)
elif re_patterns.boolean.search(token) or re_patterns.nullish.search(token) or re_patterns.special.search(token):
_add_color(color_provider, colors, start_index, start_index + token.length(), TokenType.KEYWORD)
elif re_patterns.anchor.search(token) or re_patterns.alias.search(token):
_add_color(color_provider, colors, start_index, start_index + token.length(), TokenType.SYMBOL)
else:
# Default fallback is string color
_add_color(color_provider, colors, start_index, start_index + token.length(), TokenType.STRING)
# Add color for a type
func _add_color(color_provider: ColorProvider, colors: Dictionary, start: int, end: int, type: TokenType) -> void:
colors[start] = {"color": color_provider.get_color_for_type(type)}
# Sort the colors dictionary by index
func _sort_colors(colors: Dictionary) -> Dictionary:
# Get all indices as an array
var indices := colors.keys()
indices.sort() # Sort indices in ascending order
# Create new dictionary with sorted indices
var sorted_colors := {}
for idx in indices:
sorted_colors[idx] = colors[idx]
return sorted_colors