385 lines
15 KiB
GDScript
385 lines
15 KiB
GDScript
@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
|