This repository has been archived on 2024-04-19. You can view files and clone it, but cannot push or open issues or pull requests.
Jessist/addons/dialogue_manager/components/parser.gd
2022-06-18 13:05:48 +02:00

1029 lines
33 KiB
GDScript

tool
extends Node
const DialogueConstants = preload("res://addons/dialogue_manager/constants.gd")
export var _settings := NodePath()
onready var settings = get_node(_settings)
var VALID_TITLE_REGEX := RegEx.new()
var TRANSLATION_REGEX := RegEx.new()
var MUTATION_REGEX := RegEx.new()
var CONDITION_REGEX := RegEx.new()
var WRAPPED_CONDITION_REGEX := RegEx.new()
var CONDITION_PARTS_REGEX := RegEx.new()
var REPLACEMENTS_REGEX := RegEx.new()
var GOTO_REGEX := RegEx.new()
var BB_CODE_REGEX := RegEx.new()
var MARKER_CODE_REGEX := RegEx.new()
var TOKEN_DEFINITIONS: Dictionary = {}
func _init() -> void:
VALID_TITLE_REGEX.compile("^[^\\!\\@\\#\\$\\%\\^\\&\\*\\(\\)\\-\\=\\+\\{\\}\\[\\]\\;\\:\\\"\\'\\,\\.\\<\\>\\?\\/\\s]+$")
TRANSLATION_REGEX.compile("\\[TR:(?<tr>.*?)\\]")
MUTATION_REGEX.compile("(do|set) ((?<mutation>[a-z_A-Z][a-z_A-Z0-9\\[\\]\"\\.]+ ?(\\+=|\\-=|\\*=|/=|=) ? .*)|(?<function>[a-z_A-Z][a-z_A-Z0-9]+)\\((?<args>.*)\\))")
WRAPPED_CONDITION_REGEX.compile("\\[if (?<condition>.*)\\]")
CONDITION_REGEX.compile("(if|elif) (?<condition>.*)")
REPLACEMENTS_REGEX.compile("{{(.*?)}}")
GOTO_REGEX.compile("=> (?<jump_to_title>.*)")
BB_CODE_REGEX.compile("\\[[^\\]]+\\]")
MARKER_CODE_REGEX.compile("\\[(?<code>wait|/?speed|do |set |next)(?<args>[^\\]]+)?\\]")
# Build our list of tokeniser tokens
var tokens = {
DialogueConstants.TOKEN_FUNCTION: "^[a-zA-Z_][a-zA-Z_0-9]+\\(",
DialogueConstants.TOKEN_DICTIONARY_REFERENCE: "^[a-zA-Z_][a-zA-Z_0-9]+\\[",
DialogueConstants.TOKEN_PARENS_OPEN: "^\\(",
DialogueConstants.TOKEN_PARENS_CLOSE: "^\\)",
DialogueConstants.TOKEN_BRACKET_OPEN: "^\\[",
DialogueConstants.TOKEN_BRACKET_CLOSE: "^\\]",
DialogueConstants.TOKEN_BRACE_OPEN: "^\\{",
DialogueConstants.TOKEN_BRACE_CLOSE: "^\\}",
DialogueConstants.TOKEN_COLON: "^:",
DialogueConstants.TOKEN_COMPARISON: "^(==|<=|>=|<|>|!=|in )",
DialogueConstants.TOKEN_ASSIGNMENT: "^(\\+=|\\-=|\\*=|/=|=)",
DialogueConstants.TOKEN_NUMBER: "^\\-?\\d+(\\.\\d+)?",
DialogueConstants.TOKEN_OPERATOR: "^(\\+|\\-|\\*|/|%)",
DialogueConstants.TOKEN_COMMA: "^,",
DialogueConstants.TOKEN_DOT: "^\\.",
DialogueConstants.TOKEN_BOOL: "^(true|false)",
DialogueConstants.TOKEN_NOT: "^(not( |$)|!)",
DialogueConstants.TOKEN_AND_OR: "^(and|or)( |$)",
DialogueConstants.TOKEN_STRING: "^\".*?\"",
DialogueConstants.TOKEN_VARIABLE: "^[a-zA-Z_][a-zA-Z_0-9]+",
}
for key in tokens.keys():
var regex = RegEx.new()
regex.compile(tokens.get(key))
TOKEN_DEFINITIONS[key] = regex
func parse(content: String) -> Dictionary:
var dialogue: Dictionary = {}
var errors: Array = []
var titles: Dictionary = {}
var known_translations = {}
var parent_stack: Array = []
var raw_lines = content.split("\n")
# Find all titles first
for id in range(0, raw_lines.size()):
if raw_lines[id].begins_with("~ "):
var title = raw_lines[id].substr(2).strip_edges()
if titles.has(title):
errors.append(error(id, "Duplicate title"))
else:
var next_nonempty_line_id = get_next_nonempty_line_id(id, raw_lines)
if next_nonempty_line_id != DialogueConstants.ID_NULL:
titles[title] = next_nonempty_line_id
else:
titles[title] = DialogueConstants.ID_TITLE_HAS_NO_BODY
# Then parse all lines
for id in range(0, raw_lines.size()):
var raw_line = raw_lines[id]
var line: Dictionary = {
next_id = DialogueConstants.ID_NULL
}
# Ignore empty lines and comments
if is_line_empty(raw_line): continue
# Work out if we are inside a conditional or option or if we just
# indented back out of one
var indent_size = get_indent(raw_line)
if indent_size < parent_stack.size():
for _tab in range(0, parent_stack.size() - indent_size):
parent_stack.pop_back()
# If we are indented then this line should know about its parent
if parent_stack.size() > 0:
line["parent_id"] = parent_stack.back()
# Trim any indentation (now that we've calculated it) so we can check
# the begining of each line for its type
raw_line = raw_line.strip_edges()
# Grab translations
var translation_key = extract_translation(raw_line)
if translation_key != "":
line["translation_key"] = translation_key
raw_line = raw_line.replace("[TR:%s]" % translation_key, "")
## Check for each kind of line
# Response
if raw_line.begins_with("- "):
line["type"] = DialogueConstants.TYPE_RESPONSE
parent_stack.append(str(id))
if " [if " in raw_line:
line["condition"] = extract_condition(raw_line, true)
if " => " in raw_line:
line["next_id"] = extract_goto(raw_line, titles)
line["text"] = extract_response(raw_line)
var previous_response_id = find_previous_response_id(id, raw_lines)
if dialogue.has(previous_response_id):
var previous_response = dialogue[previous_response_id]
# Add this response to the list on the first response so that it is the
# authority on what is in the list of responses
previous_response["responses"] = previous_response["responses"] + PoolStringArray([str(id)])
else:
# No previous response so this is the first in the list
line["responses"] = PoolStringArray([str(id)])
line["next_id_after"] = find_next_line_after_responses(id, raw_lines, dialogue, parent_stack)
# If this response has no body then the next id is the next id after
if not line.has("next_id") or line.get("next_id") == DialogueConstants.ID_NULL:
var next_nonempty_line_id = get_next_nonempty_line_id(id, raw_lines)
if next_nonempty_line_id != DialogueConstants.ID_NULL:
if get_indent(raw_lines[next_nonempty_line_id.to_int()]) <= indent_size:
line["next_id"] = line.get("next_id_after")
else:
line["next_id"] = next_nonempty_line_id
line["replacements"] = extract_dialogue_replacements(line.get("text"))
if line.get("replacements").size() > 0 and line.get("replacements")[0].has("error"):
errors.append(error(id, "Invalid expression"))
# If this response has a character name in it then it will automatically be
# injected as a line of dialogue if the player selects it
var l = line.get("text").replace("\\:", "!ESCAPED_COLON!")
if ": " in l:
var first_child: Dictionary = {
type = DialogueConstants.TYPE_DIALOGUE,
next_id = line.get("next_id"),
next_id_after = line.get("next_id_after"),
replacements = line.get("replacements"),
translation_key = line.get("translation_key")
}
var bits = Array(l.strip_edges().split(": "))
first_child["character"] = bits.pop_front()
# You can use variables in the character's name
first_child["character_replacements"] = extract_dialogue_replacements(first_child.get("character"))
if first_child.get("character_replacements").size() > 0 and first_child.get("character_replacements")[0].has("error"):
errors.append(error(id, "Invalid expression in character name"))
first_child["text"] = PoolStringArray(bits).join(": ").replace("!ESCAPED_COLON!", ":")
line["character"] = first_child.get("character")
line["text"] = first_child.get("text")
if first_child.get("translation_key") == null:
first_child["translation_key"] = first_child.get("text")
dialogue[str(id) + ".1"] = first_child
line["next_id"] = str(id) + ".1"
else:
line["text"] = l.replace("!ESCAPED_COLON!", ":")
# Title
elif raw_line.begins_with("~ "):
line["type"] = DialogueConstants.TYPE_TITLE
line["text"] = raw_line.replace("~ ", "")
var valid_title = VALID_TITLE_REGEX.search(raw_line.substr(2).strip_edges())
if not valid_title:
errors.append(error(id, "Titles can only contain alphanumerics and underscores"))
# Condition
elif raw_line.begins_with("if ") or raw_line.begins_with("elif "):
parent_stack.append(str(id))
line["type"] = DialogueConstants.TYPE_CONDITION
line["condition"] = extract_condition(raw_line)
line["next_id_after"] = find_next_line_after_conditions(id, raw_lines, dialogue)
var next_sibling_id = find_next_condition_sibling(id, raw_lines)
line["next_conditional_id"] = next_sibling_id if is_valid_id(next_sibling_id) else line.get("next_id_after")
elif raw_line.begins_with("else"):
parent_stack.append(str(id))
line["type"] = DialogueConstants.TYPE_CONDITION
line["next_id_after"] = find_next_line_after_conditions(id, raw_lines, dialogue)
line["next_conditional_id"] = line["next_id_after"]
# Mutation
elif raw_line.begins_with("do "):
line["type"] = DialogueConstants.TYPE_MUTATION
line["mutation"] = extract_mutation(raw_line)
elif raw_line.begins_with("set "):
line["type"] = DialogueConstants.TYPE_MUTATION
line["mutation"] = extract_mutation(raw_line)
# Goto
elif raw_line.begins_with("=> "):
line["type"] = DialogueConstants.TYPE_GOTO
line["next_id"] = extract_goto(raw_line, titles)
# Dialogue
else:
line["type"] = DialogueConstants.TYPE_DIALOGUE
var l = raw_line.replace("\\:", "!ESCAPED_COLON!")
if ": " in l:
var bits = Array(l.strip_edges().split(": "))
line["character"] = bits.pop_front()
# You can use variables in the character's name
line["character_replacements"] = extract_dialogue_replacements(line.get("character"))
if line.get("character_replacements").size() > 0 and line.get("character_replacements")[0].has("error"):
errors.append(error(id, "Invalid expression in character name"))
line["text"] = PoolStringArray(bits).join(": ").replace("!ESCAPED_COLON!", ":")
else:
line["character"] = ""
line["text"] = l.replace("!ESCAPED_COLON!", ":")
line["replacements"] = extract_dialogue_replacements(line.get("text"))
if line.get("replacements").size() > 0 and line.get("replacements")[0].has("error"):
errors.append(error(id, "Invalid expression"))
# Extract any BB style codes out of the text
var markers = extract_markers(line.get("text"))
line["text"] = markers.get("text")
line["pauses"] = markers.get("pauses")
line["speeds"] = markers.get("speeds")
line["inline_mutations"] = markers.get("mutations")
line["time"] = markers.get("time")
# Unescape any newlines
line["text"] = line.get("text").replace("\\n", "\n")
# Work out where to go after this line
if line.get("next_id") == DialogueConstants.ID_NULL:
# Unless the next line is an outdent then we can assume
# it comes next
var next_nonempty_line_id = get_next_nonempty_line_id(id, raw_lines)
if next_nonempty_line_id != DialogueConstants.ID_NULL \
and indent_size <= get_indent(raw_lines[next_nonempty_line_id.to_int()]):
# The next line is a title so we can end here
if raw_lines[next_nonempty_line_id.to_int()].strip_edges().begins_with("~ "):
line["next_id"] = DialogueConstants.ID_END_CONVERSATION
# Otherwise it's a normal line
else:
line["next_id"] = next_nonempty_line_id
# Otherwise, we grab the ID from the parents next ID after children
elif dialogue.has(line.get("parent_id")):
line["next_id"] = dialogue[line.get("parent_id")].get("next_id_after")
# Check for duplicate transaction keys
if line.get("type") in [DialogueConstants.TYPE_DIALOGUE, DialogueConstants.TYPE_RESPONSE]:
if line.has("translation_key"):
if known_translations.has(line.get("translation_key")) and known_translations.get(line.get("translation_key")) != line.get("text"):
errors.append(error(id, "Duplicate translation key"))
else:
known_translations[line.get("translation_key")] = line.get("text")
else:
# Default translations key
if settings != null and settings.get_editor_value("missing_translations_are_errors", false):
errors.append(error(id, "Missing translation"))
else:
line["translation_key"] = line.get("text")
## Error checking
# Can't find goto
match line.get("next_id"):
DialogueConstants.ID_ERROR:
errors.append(error(id, "Unknown title"))
DialogueConstants.ID_TITLE_HAS_NO_BODY:
errors.append(error(id, "Referenced node has no body"))
# Line after condition isn't indented once to the right
if line.get("type") == DialogueConstants.TYPE_CONDITION and is_valid_id(line.get("next_id")):
var next_line = raw_lines[line.get("next_id").to_int()]
if next_line != null and get_indent(next_line) != indent_size + 1:
errors.append(error(line.get("next_id").to_int(), "Invalid indentation"))
# Line after normal line is indented to the right
elif line.get("type") in [DialogueConstants.TYPE_TITLE, DialogueConstants.TYPE_DIALOGUE, DialogueConstants.TYPE_MUTATION, DialogueConstants.TYPE_GOTO] and is_valid_id(line.get("next_id")):
var next_line = raw_lines[line.get("next_id").to_int()]
if next_line != null and get_indent(next_line) > indent_size:
errors.append(error(line.get("next_id").to_int(), "Invalid indentation"))
# Parsing condition failed
if line.has("condition") and line.get("condition").has("error"):
errors.append(error(id, line.get("condition").get("error")))
# Parsing mutation failed
elif line.has("mutation") and line.get("mutation").has("error"):
errors.append(error(id, line.get("mutation").get("error")))
# Line failed to parse at all
if line.get("type") == DialogueConstants.TYPE_UNKNOWN:
errors.append(error(id, "Unknown line syntax"))
# Done!
dialogue[str(id)] = line
return {
"titles": titles,
"lines": dialogue,
"errors": errors
}
func error(line_number: int, message: String) -> Dictionary:
return {
"line": line_number,
"message": message
}
func is_valid_id(id: String) -> bool:
return false if id in [DialogueConstants.ID_NULL, DialogueConstants.ID_ERROR, DialogueConstants.ID_END_CONVERSATION] else true
func is_line_empty(line: String) -> bool:
line = line.strip_edges()
if line == "": return true
if line == "endif": return true
if line.begins_with("#"): return true
return false
func get_indent(line: String) -> int:
return line.count("\t", 0, line.find(line.strip_edges()))
func get_next_nonempty_line_id(line_number: int, all_lines: Array) -> String:
for i in range(line_number + 1, all_lines.size()):
if not is_line_empty(all_lines[i]):
return str(i)
return DialogueConstants.ID_NULL
func find_previous_response_id(line_number: int, all_lines: Array) -> String:
var line = all_lines[line_number]
var indent_size = get_indent(line)
# Look back up the list to find the previous response
var last_found_response_id: String = str(line_number)
for i in range(line_number - 1, -1, -1):
line = all_lines[i]
if is_line_empty(line): continue
# If its a response at the same indent level then its a match
if get_indent(line) == indent_size:
if line.strip_edges().begins_with("- "):
last_found_response_id = str(i)
else:
return last_found_response_id
# Return itself if nothing was found
return last_found_response_id
func find_next_condition_sibling(line_number: int, all_lines: Array) -> String:
var line = all_lines[line_number]
var expected_indent = get_indent(line)
# Look down the list and find an elif or else at the same indent level
var last_valid_id: int = line_number
for i in range(line_number + 1, all_lines.size()):
line = all_lines[i]
if is_line_empty(line): continue
var l = line.strip_edges()
if l.begins_with("~ "):
return DialogueConstants.ID_END_CONVERSATION
elif get_indent(line) < expected_indent:
return DialogueConstants.ID_NULL
elif get_indent(line) == expected_indent:
# Found an if, which begins a different block
if l.begins_with("if"):
return DialogueConstants.ID_NULL
# Found what we're looking for
elif (l.begins_with("elif ") or l.begins_with("else")):
return str(i)
last_valid_id = i
return DialogueConstants.ID_NULL
func find_next_line_after_conditions(line_number: int, all_lines: Array, dialogue: Dictionary) -> String:
var line = all_lines[line_number]
var expected_indent = get_indent(line)
# Look down the list for the first non condition line at the same or less indent level
for i in range(line_number + 1, all_lines.size()):
line = all_lines[i]
if is_line_empty(line): continue
var line_indent = get_indent(line)
line = line.strip_edges()
if line.begins_with("~ "):
return DialogueConstants.ID_END_CONVERSATION
elif line_indent > expected_indent:
continue
elif line_indent == expected_indent:
if line.begins_with("elif ") or line.begins_with("else"):
continue
else:
return str(i)
elif line_indent < expected_indent:
# We have to check the parent of this block
for p in range(line_number - 1, -1, -1):
line = all_lines[p]
if is_line_empty(line): continue
line_indent = get_indent(line)
if line_indent < expected_indent:
return dialogue[str(p)].next_id_after
return DialogueConstants.ID_END_CONVERSATION
func find_next_line_after_responses(line_number: int, all_lines: Array, dialogue: Dictionary, parent_stack: Array) -> String:
var line = all_lines[line_number]
var expected_indent = get_indent(line)
# Find the first line after this one that has a smaller indent that isn't another option
# If we hit a title or the eof then we give up
for i in range(line_number + 1, all_lines.size()):
line = all_lines[i]
if is_line_empty(line): continue
var indent = get_indent(line)
line = line.strip_edges()
# We hit a title so the next line is the end of the conversation
if line.begins_with("~ "):
return DialogueConstants.ID_END_CONVERSATION
# Another option
elif line.begins_with("- "):
if indent == expected_indent:
# ...at the same level so we continue
continue
elif indent < expected_indent:
# ...outdented so check the previous parent
var previous_parent = parent_stack[parent_stack.size() - 2]
return dialogue[str(previous_parent)].next_id_after
# We're at the end of a conditional so jump back up to see what's after it
elif line.begins_with("elif ") or line.begins_with("else"):
for p in range(line_number - 1, -1, -1):
line = all_lines[p]
if is_line_empty(line): continue
var line_indent = get_indent(line)
if line_indent < expected_indent:
return dialogue[str(p)].next_id_after
# Otherwise check the indent for an outdent
else:
line_number = i
line = all_lines[line_number]
if get_indent(line) == expected_indent:
return str(line_number)
# EOF so must be end of conversation
return DialogueConstants.ID_END_CONVERSATION
func extract_translation(line: String) -> String:
# Find a static translation key, eg. [TR:something]
var found = TRANSLATION_REGEX.search(line)
if found:
return found.strings[found.names.get("tr")]
else:
return ""
func extract_response(line: String) -> String:
# Find just the text prompt from a response, ignoring any conditions or gotos
line = line.replace("- ", "")
if " [if " in line:
line = line.substr(0, line.find(" [if "))
if " =>" in line:
line = line.substr(0, line.find(" =>"))
return line.strip_edges()
func extract_mutation(line: String) -> Dictionary:
var found = MUTATION_REGEX.search(line)
if not found:
return { "error": "Incomplete expression" }
# If the mutation starts with a function then grab it and and parse
# the args as expressions
if found.names.has("function"):
var expression = tokenise(found.strings[found.names.get("args")])
if expression.size() > 0 and expression[0].get("type") == DialogueConstants.TYPE_ERROR:
return { "error": expression[0].get("value") }
else:
return {
"function": found.strings[found.names.get("function")],
"args": tokens_to_list(expression)
}
# Otherwise we are setting a variable so expressionise its new value
elif found.names.has("mutation"):
var expression = tokenise(found.strings[found.names.get("mutation")])
if expression[0].get("type") == DialogueConstants.TYPE_ERROR:
return { "error": "Invalid expression for value" }
else:
return { "expression": expression }
else:
return { "error": "Incomplete expression" }
func extract_condition(raw_line: String, is_wrapped: bool = false) -> Dictionary:
var condition := {}
var regex = WRAPPED_CONDITION_REGEX if is_wrapped else CONDITION_REGEX
var found = regex.search(raw_line)
if found == null:
return { "error": "Incomplete condition" }
var raw_condition = found.strings[found.names.get("condition")]
var expression = tokenise(raw_condition)
if expression[0].get("type") == DialogueConstants.TYPE_ERROR:
return { "error": expression[0].get("value") }
else:
return { "expression": expression }
func extract_dialogue_replacements(text: String) -> Array:
var founds = REPLACEMENTS_REGEX.search_all(text)
if founds == null or founds.size() == 0:
return []
var replacements: Array = []
for found in founds:
var replacement: Dictionary = {}
var value_in_text = found.strings[1]
var expression = tokenise(value_in_text)
if expression[0].get("type") == DialogueConstants.TYPE_ERROR:
replacement = { "error": expression[0].get("value") }
else:
replacement = {
"value_in_text": "{{%s}}" % value_in_text,
"expression": expression
}
replacements.append(replacement)
return replacements
func extract_goto(line: String, titles: Dictionary) -> String:
var found = GOTO_REGEX.search(line)
if found == null: return DialogueConstants.ID_ERROR
var title = found.strings[found.names.get("jump_to_title")].strip_edges()
# "goto # END" means end the conversation
if title == "END":
return DialogueConstants.ID_END_CONVERSATION
elif titles.has(title):
return titles.get(title)
else:
return DialogueConstants.ID_ERROR
func extract_markers(line: String) -> Dictionary:
var text = line
var pauses = {}
var speeds = []
var mutations = []
var bb_codes = []
var index_map = {}
var time = null
# Extract all of the BB codes so that we know the actual text (we could do this easier with
# a RichTextLabel but then we'd need to await idle_frame which is annoying)
var founds = BB_CODE_REGEX.search_all(text)
var accumulaive_length_offset = 0
if founds:
for found in founds:
var code = found.strings[0]
# Ignore our own markers
if MARKER_CODE_REGEX.search(code):
continue
bb_codes.append({
code = code,
start = found.get_start(),
offset_start = found.get_start() - accumulaive_length_offset
})
accumulaive_length_offset += code.length()
for bb_code in bb_codes:
text.erase(bb_code.offset_start, bb_code.code.length())
var found = MARKER_CODE_REGEX.search(text)
var limit = 0
var prev_codes_len = 0
while found and limit < 1000:
limit += 1
var index = text.find(found.strings[0])
var code = found.strings[found.names.get("code")].strip_edges()
var raw_args = ""
var args = {}
if found.names.has("args"):
raw_args = found.strings[found.names.get("args")]
if code in ["do", "set"]:
args["value"] = extract_mutation("%s %s" % [code, raw_args])
else:
# Could be something like:
# "=1.0"
# " rate=20 level=10"
if raw_args[0] == "=":
raw_args = "value" + raw_args
for pair in raw_args.strip_edges().split(" "):
var bits = pair.split("=")
args[bits[0]] = bits[1]
match code:
"wait":
if pauses.has(index):
pauses[index] += args.get("value").to_float()
else:
pauses[index] = args.get("value").to_float()
"speed":
speeds.append([index, args.get("value").to_float()])
"/speed":
speeds.append([index, 1.0])
"do":
mutations.append([index, args.get("value")])
"next":
time = args.get("value") if args.has("value") else "0"
# Find any BB codes that are after this index and remove the length from their start
var length = found.strings[0].length()
for bb_code in bb_codes:
if bb_code.offset_start >= found.get_start():
bb_code.offset_start -= length
bb_code.start -= length
text.erase(index, length)
found = MARKER_CODE_REGEX.search(text)
# Put the BB Codes back in
for bb_code in bb_codes:
text = text.insert(bb_code.start, bb_code.code)
return {
"text": text,
"pauses": pauses,
"speeds": speeds,
"mutations": mutations,
"time": time
}
func tokenise(text: String) -> Array:
var tokens = []
var limit = 0
while text.strip_edges() != "" and limit < 1000:
limit += 1
var found = find_match(text)
if found.size() > 0:
tokens.append({
"type": found.get("type"),
"value": found.get("value")
})
text = found.get("remaining_text")
elif text.begins_with(" "):
text = text.substr(1)
else:
return [{ "type": "error", "value": "Invalid expression" }]
return build_token_tree(tokens)[0]
func build_token_tree_error(message: String) -> Array:
return [{ "type": DialogueConstants.TOKEN_ERROR, "value": message}]
func build_token_tree(tokens: Array, expected_close_token: String = "") -> Array:
var tree = []
var limit = 0
while tokens.size() > 0 and limit < 1000:
limit += 1
var token = tokens.pop_front()
var error = check_next_token(token, tokens)
if error != "":
return [build_token_tree_error(error), tokens]
match token.type:
DialogueConstants.TOKEN_FUNCTION:
var sub_tree = build_token_tree(tokens, DialogueConstants.TOKEN_PARENS_CLOSE)
if sub_tree[0].size() > 0 and sub_tree[0][0].get("type") == DialogueConstants.TOKEN_ERROR:
return [build_token_tree_error(sub_tree[0][0].get("value")), tokens]
tree.append({
"type": DialogueConstants.TOKEN_FUNCTION,
# Consume the trailing "("
"function": token.get("value").substr(0, token.get("value").length() - 1),
"value": tokens_to_list(sub_tree[0])
})
tokens = sub_tree[1]
DialogueConstants.TOKEN_DICTIONARY_REFERENCE:
var sub_tree = build_token_tree(tokens, DialogueConstants.TOKEN_BRACKET_CLOSE)
if sub_tree[0].size() > 0 and sub_tree[0][0].get("type") == DialogueConstants.TOKEN_ERROR:
return [build_token_tree_error(sub_tree[0][0].get("value")), tokens]
var args = tokens_to_list(sub_tree[0])
if args.size() != 1:
return [build_token_tree_error("Invalid index"), tokens]
tree.append({
"type": DialogueConstants.TOKEN_DICTIONARY_REFERENCE,
# Consume the trailing "["
"variable": token.get("value").substr(0, token.get("value").length() - 1),
"value": args[0]
})
tokens = sub_tree[1]
DialogueConstants.TOKEN_BRACE_OPEN:
var sub_tree = build_token_tree(tokens, DialogueConstants.TOKEN_BRACE_CLOSE)
if sub_tree[0].size() > 0 and sub_tree[0][0].get("type") == DialogueConstants.TOKEN_ERROR:
return [build_token_tree_error(sub_tree[0][0].get("value")), tokens]
tree.append({
"type": DialogueConstants.TOKEN_DICTIONARY,
"value": tokens_to_dictionary(sub_tree[0])
})
tokens = sub_tree[1]
DialogueConstants.TOKEN_BRACKET_OPEN:
var sub_tree = build_token_tree(tokens, DialogueConstants.TOKEN_BRACKET_CLOSE)
if sub_tree[0].size() > 0 and sub_tree[0][0].get("type") == DialogueConstants.TOKEN_ERROR:
return [build_token_tree_error(sub_tree[0][0].get("value")), tokens]
tree.append({
"type": DialogueConstants.TOKEN_ARRAY,
"value": tokens_to_list(sub_tree[0])
})
tokens = sub_tree[1]
DialogueConstants.TOKEN_PARENS_OPEN:
var sub_tree = build_token_tree(tokens, DialogueConstants.TOKEN_PARENS_CLOSE)
if sub_tree[0][0].get("type") == DialogueConstants.TOKEN_ERROR:
return [build_token_tree_error(sub_tree[0][0].get("value")), tokens]
tree.append({
"type": DialogueConstants.TOKEN_GROUP,
"value": sub_tree[0]
})
tokens = sub_tree[1]
DialogueConstants.TOKEN_PARENS_CLOSE, \
DialogueConstants.TOKEN_BRACE_CLOSE, \
DialogueConstants.TOKEN_BRACKET_CLOSE:
if token.get("type") != expected_close_token:
return [build_token_tree_error("Unexpected closing bracket"), tokens]
return [tree, tokens]
DialogueConstants.TOKEN_NOT:
# Double nots negate each other
if tokens.size() > 0 and tokens.front().get("type") == DialogueConstants.TOKEN_NOT:
tokens.pop_front()
else:
tree.append({
"type": token.get("type")
})
DialogueConstants.TOKEN_COMMA, \
DialogueConstants.TOKEN_COLON, \
DialogueConstants.TOKEN_DOT:
tree.append({
"type": token.get("type")
})
DialogueConstants.TOKEN_COMPARISON, \
DialogueConstants.TOKEN_ASSIGNMENT, \
DialogueConstants.TOKEN_OPERATOR, \
DialogueConstants.TOKEN_AND_OR, \
DialogueConstants.TOKEN_VARIABLE: \
tree.append({
"type": token.get("type"),
"value": token.get("value").strip_edges()
})
DialogueConstants.TOKEN_STRING:
tree.append({
"type": token.get("type"),
"value": token.get("value").substr(1, token.get("value").length() - 2)
})
DialogueConstants.TOKEN_BOOL:
tree.append({
"type": token.get("type"),
"value": token.get("value").to_lower() == "true"
})
DialogueConstants.TOKEN_NUMBER:
tree.append({
"type": token.get("type"),
"value": token.get("value").to_float() if "." in token.get("value") else token.get("value").to_int()
})
if expected_close_token != "":
return [build_token_tree_error("Missing closing bracket"), tokens]
return [tree, tokens]
func check_next_token(token: Dictionary, next_tokens: Array) -> String:
var next_token_type = null
if next_tokens.size() > 0:
next_token_type = next_tokens.front().get("type")
var unexpected_token_types = []
match token.get("type"):
DialogueConstants.TOKEN_FUNCTION, \
DialogueConstants.TOKEN_PARENS_OPEN:
unexpected_token_types = [
null,
DialogueConstants.TOKEN_COMMA,
DialogueConstants.TOKEN_COLON,
DialogueConstants.TOKEN_COMPARISON,
DialogueConstants.TOKEN_ASSIGNMENT,
DialogueConstants.TOKEN_OPERATOR,
DialogueConstants.TOKEN_AND_OR,
DialogueConstants.TOKEN_DOT
]
DialogueConstants.TOKEN_BRACKET_CLOSE:
unexpected_token_types = [
DialogueConstants.TOKEN_NOT,
DialogueConstants.TOKEN_BOOL,
DialogueConstants.TOKEN_STRING,
DialogueConstants.TOKEN_NUMBER,
DialogueConstants.TOKEN_VARIABLE
]
DialogueConstants.TOKEN_PARENS_CLOSE, \
DialogueConstants.TOKEN_BRACE_CLOSE:
unexpected_token_types = [
DialogueConstants.TOKEN_NOT,
DialogueConstants.TOKEN_ASSIGNMENT,
DialogueConstants.TOKEN_BOOL,
DialogueConstants.TOKEN_STRING,
DialogueConstants.TOKEN_NUMBER,
DialogueConstants.TOKEN_VARIABLE
]
DialogueConstants.TOKEN_COMPARISON, \
DialogueConstants.TOKEN_OPERATOR, \
DialogueConstants.TOKEN_COMMA, \
DialogueConstants.TOKEN_COLON, \
DialogueConstants.TOKEN_DOT, \
DialogueConstants.TOKEN_NOT, \
DialogueConstants.TOKEN_AND_OR, \
DialogueConstants.TOKEN_DICTIONARY_REFERENCE:
unexpected_token_types = [
null,
DialogueConstants.TOKEN_COMMA,
DialogueConstants.TOKEN_COLON,
DialogueConstants.TOKEN_COMPARISON,
DialogueConstants.TOKEN_ASSIGNMENT,
DialogueConstants.TOKEN_OPERATOR,
DialogueConstants.TOKEN_AND_OR,
DialogueConstants.TOKEN_PARENS_CLOSE,
DialogueConstants.TOKEN_BRACE_CLOSE,
DialogueConstants.TOKEN_BRACKET_CLOSE,
DialogueConstants.TOKEN_DOT
]
DialogueConstants.TOKEN_BOOL, \
DialogueConstants.TOKEN_STRING, \
DialogueConstants.TOKEN_NUMBER:
unexpected_token_types = [
DialogueConstants.TOKEN_NOT,
DialogueConstants.TOKEN_ASSIGNMENT,
DialogueConstants.TOKEN_BOOL,
DialogueConstants.TOKEN_STRING,
DialogueConstants.TOKEN_NUMBER,
DialogueConstants.TOKEN_VARIABLE,
DialogueConstants.TOKEN_FUNCTION,
DialogueConstants.TOKEN_PARENS_OPEN,
DialogueConstants.TOKEN_BRACE_OPEN,
DialogueConstants.TOKEN_BRACKET_OPEN
]
DialogueConstants.TOKEN_VARIABLE:
unexpected_token_types = [
DialogueConstants.TOKEN_NOT,
DialogueConstants.TOKEN_BOOL,
DialogueConstants.TOKEN_STRING,
DialogueConstants.TOKEN_NUMBER,
DialogueConstants.TOKEN_VARIABLE,
DialogueConstants.TOKEN_FUNCTION,
DialogueConstants.TOKEN_PARENS_OPEN,
DialogueConstants.TOKEN_BRACE_OPEN,
DialogueConstants.TOKEN_BRACKET_OPEN
]
if next_token_type in unexpected_token_types:
match next_token_type:
null:
return "Unexpected end of expression"
DialogueConstants.TOKEN_FUNCTION:
return "Unexpected function"
DialogueConstants.TOKEN_PARENS_OPEN, \
DialogueConstants.TOKEN_PARENS_CLOSE:
return "Unexpected bracket"
DialogueConstants.TOKEN_COMPARISON, \
DialogueConstants.TOKEN_ASSIGNMENT, \
DialogueConstants.TOKEN_OPERATOR, \
DialogueConstants.TOKEN_NOT, \
DialogueConstants.TOKEN_AND_OR:
return "Unexpected operator"
DialogueConstants.TOKEN_COMMA:
return "Unexpected comma"
DialogueConstants.TOKEN_COLON:
return "Unexpected colon"
DialogueConstants.TOKEN_DOT:
return "Unexpected dot"
DialogueConstants.TOKEN_BOOL:
return "Unexpected boolean"
DialogueConstants.TOKEN_STRING:
return "Unexpected string"
DialogueConstants.TOKEN_NUMBER:
return "Unexpected number"
DialogueConstants.TOKEN_VARIABLE:
return "Unexpected variable"
_:
return "Invalid expression"
return ""
func tokens_to_list(tokens: Array) -> Array:
var list = []
var current_item = []
for token in tokens:
if token.get("type") == DialogueConstants.TOKEN_COMMA:
list.append(current_item)
current_item = []
else:
current_item.append(token)
if current_item.size() > 0:
list.append(current_item)
return list
func tokens_to_dictionary(tokens: Array) -> Dictionary:
var dictionary = {}
for i in range(0, tokens.size()):
if tokens[i].get("type") == DialogueConstants.TOKEN_COLON:
dictionary[tokens[i-1]] = tokens[i+1]
return dictionary
func find_match(input: String) -> Dictionary:
for key in TOKEN_DEFINITIONS.keys():
var regex = TOKEN_DEFINITIONS.get(key)
var found = regex.search(input)
if found:
return {
"type": key,
"remaining_text": input.substr(found.strings[0].length()),
"value": found.strings[0]
}
return {}