mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-11-04 13:07:32 +00:00
917 lines
39 KiB
Python
917 lines
39 KiB
Python
import argparse
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
LISTS = "core/libs/prx/libSceAgcDriver/CMakeLists.txt"
|
|
FRAGMENTS = "core/libs/prx/libSceAgcDriver/tests/cmake"
|
|
EXECUTION = "core/libs/prx/libSceAgcDriver/tests/execution"
|
|
INCLUDE = [
|
|
"file(GLOB agcTestFragments CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/tests/cmake/*.cmake)",
|
|
"list(SORT agcTestFragments)",
|
|
"foreach(agcTestFragment IN LISTS agcTestFragments)",
|
|
" include(${agcTestFragment})",
|
|
"endforeach()",
|
|
]
|
|
|
|
OPEN = {"if": "endif", "foreach": "endforeach", "while": "endwhile", "function": "endfunction", "macro": "endmacro", "block": "endblock"}
|
|
BRANCH = {"elseif", "else"}
|
|
CALL = re.compile(r"[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*\(")
|
|
UNIT = re.compile(r"[a-z][a-z0-9_]*_tests")
|
|
MENTION = re.compile(r"(?<![A-Za-z0-9_{])[a-z][a-z0-9_]*_tests(?![A-Za-z0-9_])")
|
|
VARIABLE = re.compile(r"\$\{([A-Za-z0-9_]+)\}")
|
|
IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
SPECIAL = re.compile(r'if\(agcTestName STREQUAL "([A-Za-z0-9_]+)"\)')
|
|
DEFINERS = {"add_executable", "add_test_executable", "add_library", "add_custom_target"}
|
|
SUBJECT_FIRST = {"target_link_libraries", "target_include_directories", "target_compile_definitions", "target_compile_options",
|
|
"target_sources", "target_link_options", "target_compile_features", "target_precompile_headers",
|
|
"configure_windows_unwind", "add_dependencies"}
|
|
CONTROL = {"if", "elseif", "else", "endif", "foreach", "endforeach", "while", "endwhile", "function", "endfunction", "macro",
|
|
"endmacro", "block", "endblock", "return", "break", "continue", "set", "unset", "list", "string", "file", "message",
|
|
"math", "option", "include", "get_filename_component", "add_custom_command", "add_subdirectory", "cmake_path",
|
|
"set_source_files_properties", "separate_arguments"}
|
|
SETTERS = {"set", "unset", "option", "list", "string", "math", "file", "get_filename_component", "get_target_property",
|
|
"get_test_property", "get_directory_property", "get_property", "cmake_path", "separate_arguments", "foreach"}
|
|
LIST_READERS = {"APPEND", "PREPEND", "INSERT", "REMOVE_ITEM", "REMOVE_AT", "REMOVE_DUPLICATES", "FILTER", "SORT", "REVERSE",
|
|
"TRANSFORM", "POP_BACK", "POP_FRONT", "GET", "LENGTH", "FIND", "JOIN"}
|
|
HELPERS = {
|
|
"agc_add_default_test": lambda target: (target, [target[:-6]]),
|
|
"agc_add_draw_pipeline_test": lambda stem: (stem + "_tests", [stem + "_draw_pipeline"]),
|
|
"agc_add_spirv_mode_tests": lambda target: (target, [target[:-6], target[:-6] + "_full", target[:-6] + "_none"]),
|
|
}
|
|
TEST_KEYWORDS = {"CONFIGURATIONS", "WORKING_DIRECTORY", "COMMAND_EXPAND_LISTS"}
|
|
|
|
|
|
class SplitError(Exception):
|
|
pass
|
|
|
|
|
|
class Command:
|
|
def __init__(self, name, arguments, first, last):
|
|
self.name = name
|
|
self.arguments = arguments
|
|
self.first = first
|
|
self.last = last
|
|
|
|
|
|
class Block:
|
|
def __init__(self, header):
|
|
self.header = header
|
|
self.branches = [(header, [])]
|
|
self.end = None
|
|
self.first = header.first
|
|
|
|
@property
|
|
def last(self):
|
|
return self.end.last
|
|
|
|
|
|
def arguments_of(text, position, line):
|
|
arguments, depth, token = [], 0, None
|
|
while True:
|
|
if position >= len(text):
|
|
raise SplitError(f"line {line + 1}: unterminated command")
|
|
character = text[position]
|
|
if character == "\n":
|
|
line += 1
|
|
if character == '"':
|
|
end = position + 1
|
|
while text[end] != '"':
|
|
if text[end] == "\\":
|
|
end += 1
|
|
if text[end] == "\n":
|
|
line += 1
|
|
end += 1
|
|
token = (token or "") + text[position + 1:end]
|
|
position = end + 1
|
|
continue
|
|
bracket = re.match(r"\[(=*)\[", text[position:position + 32])
|
|
if bracket and token is None:
|
|
close = text.index("]" + bracket[1] + "]", position)
|
|
value = text[position + len(bracket[0]):close]
|
|
line += value.count("\n")
|
|
arguments.append(value)
|
|
position = close + len(bracket[0])
|
|
continue
|
|
if character == "#":
|
|
comment = re.match(r"#\[(=*)\[", text[position:position + 32])
|
|
close = text.index("]" + comment[1] + "]", position) + len(comment[0]) if comment else text.find("\n", position)
|
|
line += text[position:close].count("\n")
|
|
position = close
|
|
continue
|
|
if character == "\\" and token is not None:
|
|
token += text[position:position + 2]
|
|
position += 2
|
|
continue
|
|
if character in " \t\r\n()":
|
|
if token is not None:
|
|
arguments.append(token)
|
|
token = None
|
|
if character == "(":
|
|
depth += 1
|
|
arguments.append("(")
|
|
elif character == ")":
|
|
if depth == 0:
|
|
return arguments, position + 1, line
|
|
depth -= 1
|
|
arguments.append(")")
|
|
position += 1
|
|
continue
|
|
token = (token or "") + character
|
|
position += 1
|
|
|
|
|
|
def commands(text):
|
|
lines = text.split("\n")
|
|
offsets = [0]
|
|
for item in lines:
|
|
offsets.append(offsets[-1] + len(item) + 1)
|
|
found, index = [], 0
|
|
while index < len(lines):
|
|
stripped = lines[index].strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
index += 1
|
|
continue
|
|
call = CALL.match(lines[index])
|
|
if not call:
|
|
raise SplitError(f"line {index + 1}: not a command: {lines[index]}")
|
|
arguments, end, last = arguments_of(text, offsets[index] + call.end(), index)
|
|
rest = text[end:offsets[last + 1] - 1] if last + 1 < len(offsets) else text[end:]
|
|
if rest.strip() and not rest.strip().startswith("#"):
|
|
raise SplitError(f"line {last + 1}: more than one command on a line")
|
|
found.append(Command(call[1].lower(), arguments, index, last))
|
|
index = last + 1
|
|
return found
|
|
|
|
|
|
def tree(text):
|
|
root, stack = [], []
|
|
for command in commands(text):
|
|
if command.name in OPEN:
|
|
block = Block(command)
|
|
(stack[-1].branches[-1][1] if stack else root).append(block)
|
|
stack.append(block)
|
|
elif command.name in BRANCH:
|
|
if not stack or stack[-1].header.name != "if":
|
|
raise SplitError(f"line {command.first + 1}: {command.name} outside if")
|
|
stack[-1].branches.append((command, []))
|
|
elif command.name in OPEN.values():
|
|
if not stack or OPEN[stack[-1].header.name] != command.name:
|
|
raise SplitError(f"line {command.first + 1}: unmatched {command.name}")
|
|
stack.pop().end = command
|
|
else:
|
|
(stack[-1].branches[-1][1] if stack else root).append(command)
|
|
if stack:
|
|
raise SplitError(f"line {stack[-1].first + 1}: unclosed {stack[-1].header.name}")
|
|
return root
|
|
|
|
|
|
def flatten(node):
|
|
if isinstance(node, Command):
|
|
yield node
|
|
return
|
|
for branch, children in node.branches:
|
|
yield branch
|
|
for child in children:
|
|
yield from flatten(child)
|
|
yield node.end
|
|
|
|
|
|
class Atom:
|
|
def __init__(self, node, conditions):
|
|
self.node = node
|
|
self.conditions = conditions
|
|
|
|
def flat(self):
|
|
return flatten(self.node)
|
|
|
|
|
|
def atoms(nodes, conditions=()):
|
|
for node in nodes:
|
|
if isinstance(node, Block) and node.header.name == "if" and len(node.branches) == 1:
|
|
yield from atoms(node.branches[0][1], conditions + (node,))
|
|
else:
|
|
yield Atom(node, conditions)
|
|
|
|
|
|
def enclosing_loop(lines, start):
|
|
depth = 0
|
|
for index in range(start, len(lines)):
|
|
stripped = lines[index].strip()
|
|
if re.match(r"foreach\s*\(", stripped):
|
|
depth += 1
|
|
elif re.match(r"endforeach\s*\(", stripped):
|
|
depth -= 1
|
|
if depth == 0:
|
|
return start, index, len(lines[start]) - len(lines[start].lstrip())
|
|
raise SplitError(f"line {start + 1}: unterminated loop")
|
|
|
|
|
|
def find_loop(lines, predicate, what):
|
|
starts = [index for index, line in enumerate(lines) if predicate(index, line.strip())]
|
|
if len(starts) > 1:
|
|
raise SplitError(f"more than one {what}")
|
|
return enclosing_loop(lines, starts[0]) if starts else None
|
|
|
|
|
|
def dedent(lines, amount):
|
|
return [line[amount:] if line.startswith(" " * amount) else line for line in lines]
|
|
|
|
|
|
def loop_list(header):
|
|
return header[header.index("ITEMS") + len("ITEMS"):header.rindex(")")].split()
|
|
|
|
|
|
def default_tests(lines):
|
|
found = find_loop(lines, lambda index, line: line.startswith("foreach(agcTest IN ITEMS ") and "${agcExecutionTests}" in line,
|
|
"default-test loop")
|
|
if found is None:
|
|
raise SplitError("the default-test loop over ${agcExecutionTests} is missing")
|
|
start, end, indent = found
|
|
body = lines[start + 1:end]
|
|
skip = []
|
|
if body and body[0].strip().startswith("if(agcTest STREQUAL"):
|
|
close = next(index for index, line in enumerate(body) if line.strip() == "endif()")
|
|
skip, body = body[:close + 1], body[close + 1:]
|
|
kept, tweaks, index = [], [], 0
|
|
while index < len(body):
|
|
special = SPECIAL.fullmatch(body[index].strip())
|
|
if not special:
|
|
kept.append(body[index])
|
|
index += 1
|
|
continue
|
|
depth = len(body[index]) - len(body[index].lstrip())
|
|
close = next(position for position in range(index, len(body))
|
|
if body[position].strip() == "endif()" and len(body[position]) - len(body[position].lstrip()) == depth)
|
|
inner = body[index + 1:close]
|
|
if not inner or not inner[0].strip().startswith("set_tests_properties(${agcTestName} "):
|
|
raise SplitError(f"line {start + index + 2}: a default-test case does more than set properties")
|
|
tweaks += [" " * indent + line.strip().replace("${agcTestName}", special[1]) if position == 0 else line[8:]
|
|
for position, line in enumerate(inner)]
|
|
index = close + 1
|
|
pad = " " * indent
|
|
lines[start:end + 1] = [f"{pad}agc_add_default_test({name})" for name in loop_list(lines[start])
|
|
if name != "${agcExecutionTests}"] + tweaks
|
|
function = ["function(agc_add_default_test agcTest)"] + dedent(kept, indent) + ["endfunction()"]
|
|
execution = ["if(BUILD_TESTING)", " foreach(agcTest IN LISTS agcExecutionTests)"] + \
|
|
[" " * 8 + line[indent + 4:] for line in skip] + [" agc_add_default_test(${agcTest})", " endforeach()", "endif()"]
|
|
return function, execution
|
|
|
|
|
|
def draw_pipeline_tests(lines):
|
|
found = find_loop(lines, lambda index, line: line == "foreach(agcTestName IN LISTS agcDrawPipelineTests)", "draw pipeline loop")
|
|
if found is None:
|
|
return None
|
|
start, end, indent = found
|
|
listing = lines[start - 1].strip()
|
|
if not listing.startswith("set(agcDrawPipelineTests "):
|
|
raise SplitError("agcDrawPipelineTests is not set right before its loop")
|
|
names = listing[len("set(agcDrawPipelineTests "):-1].split()
|
|
body = lines[start + 1:end]
|
|
lines[start - 1:end + 1] = [f"{' ' * indent}agc_add_draw_pipeline_test({name})" for name in names]
|
|
return ["function(agc_add_draw_pipeline_test agcTestName)"] + dedent(body, indent) + ["endfunction()"]
|
|
|
|
|
|
def spirv_mode_tests(lines):
|
|
found = find_loop(lines, lambda index, line: line.startswith("foreach(agcTest IN ITEMS ") and index + 1 < len(lines)
|
|
and lines[index + 1].strip() == "if(NOT TARGET ${agcTest})", "SPIR-V mode loop")
|
|
if found is None:
|
|
return None
|
|
start, end, indent = found
|
|
body = lines[start + 1:end]
|
|
if body[1].strip() != "continue()":
|
|
raise SplitError("the SPIR-V mode loop no longer starts with its target check")
|
|
body[1] = body[1].replace("continue()", "return()")
|
|
lines[start:end + 1] = [f"{' ' * indent}agc_add_spirv_mode_tests({name})" for name in loop_list(lines[start])]
|
|
return ["function(agc_add_spirv_mode_tests agcTest)"] + dedent(body, indent) + ["endfunction()"]
|
|
|
|
|
|
def refactor(text):
|
|
if "function(agc_add_default_test " in text:
|
|
return text
|
|
lines = text.split("\n")
|
|
function, execution = default_tests(lines)
|
|
functions = [function]
|
|
for convert in (draw_pipeline_tests, spirv_mode_tests):
|
|
function = convert(lines)
|
|
if function:
|
|
functions.append(function)
|
|
glob = [index for index, line in enumerate(lines) if line.startswith("foreach(agcExecutionTestSource IN LISTS agcExecutionTestSources)")]
|
|
if len(glob) != 1:
|
|
raise SplitError("the execution-test loop is missing")
|
|
close = next(index for index in range(glob[0], len(lines)) if lines[index] == "endforeach()")
|
|
added = [""]
|
|
for function in functions:
|
|
added += function + [""]
|
|
lines[close + 1:close + 1] = added + execution + [""]
|
|
return "\n".join(lines)
|
|
|
|
|
|
class Facts:
|
|
def __init__(self):
|
|
self.targets = set()
|
|
self.tests = {}
|
|
self.dynamic_tests = []
|
|
self.subject_targets = set()
|
|
self.subject_tests = set()
|
|
self.test_patterns = set()
|
|
self.dynamic = set()
|
|
self.defines = set()
|
|
self.accumulates = set()
|
|
self.uses = set()
|
|
self.loop = set()
|
|
self.mentions = set()
|
|
self.calls = set()
|
|
self.functions = set()
|
|
self.reads_all_tests = False
|
|
self.creates_defaults = False
|
|
self.dynamic_targets = False
|
|
|
|
|
|
def until(arguments, keywords):
|
|
for index, argument in enumerate(arguments):
|
|
if argument in keywords:
|
|
return arguments[:index]
|
|
return arguments
|
|
|
|
|
|
def literal(name):
|
|
return "${" not in name and "$<" not in name
|
|
|
|
|
|
def subject(facts, names, kind):
|
|
for name in names:
|
|
if literal(name):
|
|
(facts.subject_targets if kind == "target" else facts.subject_tests).add(name)
|
|
else:
|
|
facts.dynamic.update(VARIABLE.findall(name))
|
|
if kind == "test":
|
|
facts.test_patterns.add(name)
|
|
|
|
|
|
def loop_items(atom):
|
|
items = {}
|
|
for command in atom.flat():
|
|
arguments = command.arguments
|
|
if command.name != "foreach" or not arguments:
|
|
continue
|
|
rest = arguments[1:]
|
|
if rest[:1] == ["IN"]:
|
|
if rest[1:2] != ["ITEMS"]:
|
|
continue
|
|
rest = rest[2:]
|
|
elif rest[:1] == ["RANGE"]:
|
|
bounds = rest[1:]
|
|
if not all(bound.isdigit() for bound in bounds) or not 1 <= len(bounds) <= 3:
|
|
continue
|
|
numbers = [int(bound) for bound in bounds]
|
|
start, stop = (0, numbers[0]) if len(numbers) == 1 else (numbers[0], numbers[1])
|
|
rest = [str(value) for value in range(start, stop + 1, numbers[2] if len(numbers) == 3 else 1)]
|
|
if all(literal(item) for item in rest):
|
|
items[arguments[0]] = rest
|
|
return items
|
|
|
|
|
|
def expand(name, items):
|
|
names = [name]
|
|
for variable in set(VARIABLE.findall(name)):
|
|
if variable not in items:
|
|
return []
|
|
names = [candidate.replace("${" + variable + "}", value) for candidate in names for value in items[variable]]
|
|
return names
|
|
|
|
|
|
def facts_of(atom):
|
|
facts = Facts()
|
|
if isinstance(atom.node, Block) and atom.node.header.name in ("function", "macro"):
|
|
facts.functions.add(atom.node.header.arguments[0].lower())
|
|
return facts
|
|
for command in atom.flat():
|
|
arguments, name = command.arguments, command.name
|
|
facts.calls.add(name)
|
|
for argument in arguments:
|
|
facts.uses.update(VARIABLE.findall(argument))
|
|
if "$<" not in argument:
|
|
facts.mentions.update(MENTION.findall(argument))
|
|
if name in ("if", "elseif", "while"):
|
|
facts.uses.update(argument for argument in arguments if IDENT.fullmatch(argument))
|
|
if name == "foreach" and arguments:
|
|
facts.loop.add(arguments[0])
|
|
if "LISTS" in arguments:
|
|
facts.uses.update(argument for argument in arguments[arguments.index("LISTS") + 1:]
|
|
if IDENT.fullmatch(argument) and argument != "ITEMS")
|
|
for index, argument in enumerate(arguments[1:-1], 1):
|
|
if argument == "IN_LIST":
|
|
facts.uses.add(arguments[index + 1])
|
|
if name in ("list", "string") and len(arguments) > 1 and arguments[0] in LIST_READERS:
|
|
facts.uses.add(arguments[1])
|
|
facts.accumulates.add(arguments[1])
|
|
if name in SETTERS:
|
|
for index, argument in enumerate(arguments):
|
|
if IDENT.fullmatch(argument) and not (name == "foreach" and index):
|
|
facts.defines.add(argument)
|
|
if name in ("set", "unset", "option", "foreach"):
|
|
break
|
|
if name in HELPERS and arguments:
|
|
if literal(arguments[0]):
|
|
executable, tests = HELPERS[name](arguments[0])
|
|
facts.tests.update({test: executable for test in tests})
|
|
if name != "agc_add_draw_pipeline_test":
|
|
facts.subject_targets.add(arguments[0])
|
|
else:
|
|
facts.dynamic.update(VARIABLE.findall(arguments[0]))
|
|
facts.dynamic_tests.append((arguments[0], None))
|
|
facts.creates_defaults = True
|
|
if name in DEFINERS and arguments:
|
|
if literal(arguments[0]):
|
|
facts.targets.add(arguments[0])
|
|
else:
|
|
facts.dynamic.update(VARIABLE.findall(arguments[0]))
|
|
facts.dynamic_targets = True
|
|
elif name == "add_test" and arguments:
|
|
if arguments[0] == "NAME":
|
|
test = arguments[1]
|
|
rest = until(arguments[2:], TEST_KEYWORDS)
|
|
executable = rest[rest.index("COMMAND") + 1] if "COMMAND" in rest[:-1] else None
|
|
else:
|
|
test, executable = arguments[0], arguments[1] if len(arguments) > 1 else None
|
|
if literal(test):
|
|
facts.tests[test] = executable
|
|
else:
|
|
facts.dynamic_tests.append((test, executable))
|
|
elif name in SUBJECT_FIRST and arguments:
|
|
subject(facts, arguments[:1], "target")
|
|
elif name == "add_asan_test_options" and arguments:
|
|
subject(facts, arguments[:1], "test")
|
|
elif name == "set_target_properties":
|
|
subject(facts, until(arguments, {"PROPERTIES"}), "target")
|
|
elif name == "set_tests_properties":
|
|
subject(facts, until(arguments, {"PROPERTIES", "DIRECTORY"}), "test")
|
|
elif name == "set_property" and len(arguments) > 1 and arguments[0] in ("TARGET", "TEST"):
|
|
subject(facts, until(arguments[1:], {"PROPERTY", "APPEND", "APPEND_STRING", "DIRECTORY"}), arguments[0].lower())
|
|
elif name in ("get_target_property", "get_test_property") and len(arguments) > 1:
|
|
subject(facts, arguments[1:2], "target" if name == "get_target_property" else "test")
|
|
elif name == "get_directory_property" and "TESTS" in arguments:
|
|
facts.reads_all_tests = True
|
|
elif name not in CONTROL and name not in HELPERS and arguments and UNIT.fullmatch(arguments[0]):
|
|
subject(facts, arguments[:1], "target")
|
|
items = loop_items(atom)
|
|
for test, executable in facts.dynamic_tests:
|
|
for name in expand(test, items):
|
|
facts.tests.setdefault(name, executable)
|
|
facts.test_patterns = {prefix for prefix in (re.split(r"\$[{<]", name)[0] for name in
|
|
facts.test_patterns - {test for test, _executable in facts.dynamic_tests}) if prefix}
|
|
return facts
|
|
|
|
|
|
def union_find(size):
|
|
parent = list(range(size))
|
|
|
|
def find(index):
|
|
while parent[index] != index:
|
|
parent[index] = parent[parent[index]]
|
|
index = parent[index]
|
|
return index
|
|
|
|
def join(first, second):
|
|
parent[find(first)] = find(second)
|
|
|
|
return find, join
|
|
|
|
|
|
def reads(facts):
|
|
return (facts.uses - (facts.defines - facts.accumulates)) | facts.accumulates
|
|
|
|
|
|
class Plan:
|
|
def __init__(self, text, execution=()):
|
|
self.lines = text.split("\n")
|
|
self.roots = tree(text)
|
|
self.atoms = list(atoms(self.roots))
|
|
self.facts = [facts_of(atom) for atom in self.atoms]
|
|
self.known = {name for facts in self.facts for name in facts.targets | facts.subject_targets | set(facts.tests.values())
|
|
if name and UNIT.fullmatch(name)} | set(execution)
|
|
self.external = self.known - {name for facts in self.facts for name in facts.targets}
|
|
self.test_units = {test: self.unit_of_test(test, executable) for facts in self.facts for test, executable in facts.tests.items()}
|
|
self.units = self.assign()
|
|
self.include = self.place()
|
|
|
|
def unit_of_test_name(self, test):
|
|
stems = [unit for unit in self.known if test == unit[:-6] or test.startswith(unit[:-6] + "_")]
|
|
return max(stems, key=len) if stems else None
|
|
|
|
def unit_of_test(self, test, executable):
|
|
return executable if executable and literal(executable) and UNIT.fullmatch(executable) else self.unit_of_test_name(test)
|
|
|
|
def candidates(self, index, test_owner):
|
|
facts = self.facts[index]
|
|
owners, unknown = set(), False
|
|
for target in facts.targets | facts.subject_targets:
|
|
if UNIT.fullmatch(target):
|
|
owners.add(target)
|
|
else:
|
|
unknown = True
|
|
for test, executable in facts.tests.items():
|
|
owner = self.unit_of_test(test, executable)
|
|
if owner:
|
|
owners.add(owner)
|
|
for test in facts.subject_tests - set(facts.tests):
|
|
if test in test_owner:
|
|
owners.add(test_owner[test])
|
|
elif test + "_tests" in self.external:
|
|
owners.add(test + "_tests")
|
|
else:
|
|
unknown = True
|
|
for prefix in facts.test_patterns:
|
|
matched = {unit for test, unit in self.test_units.items() if test.startswith(prefix)}
|
|
if len(matched) == 1 and None not in matched:
|
|
owners |= matched
|
|
elif matched:
|
|
unknown = True
|
|
if facts.dynamic - facts.defines or facts.reads_all_tests or facts.creates_defaults or facts.dynamic_targets:
|
|
unknown = True
|
|
if facts.dynamic or facts.dynamic_tests:
|
|
for _test, executable in facts.dynamic_tests:
|
|
if executable and literal(executable) and UNIT.fullmatch(executable):
|
|
owners.add(executable)
|
|
if facts.dynamic and len(facts.mentions | owners) > 1:
|
|
unknown = True
|
|
owners |= facts.mentions
|
|
return owners, unknown
|
|
|
|
def write_groups(self):
|
|
defining = {}
|
|
for index, facts in enumerate(self.facts):
|
|
for name in facts.defines:
|
|
defining.setdefault(name, []).append(index)
|
|
mutable = {name for name, indices in defining.items() if len(indices) > 1}
|
|
mutable |= set().union(*(facts.accumulates for facts in self.facts))
|
|
find, join = union_find(len(self.atoms))
|
|
for index, facts in enumerate(self.facts):
|
|
for name in reads(facts) & mutable & facts.defines:
|
|
for earlier in reversed([other for other in defining[name] if other < index]):
|
|
join(earlier, index)
|
|
if name in self.facts[earlier].defines - self.facts[earlier].accumulates:
|
|
break
|
|
return [find(index) for index in range(len(self.atoms))]
|
|
|
|
def variable_components(self, free):
|
|
defining = {}
|
|
find, join = union_find(len(self.atoms))
|
|
for index, facts in enumerate(self.facts):
|
|
for name in reads(facts):
|
|
for earlier in reversed(defining.get(name, [])):
|
|
if free[earlier] or free[index]:
|
|
join(earlier, index)
|
|
if name in self.facts[earlier].defines - self.facts[earlier].accumulates:
|
|
break
|
|
for name in facts.defines - facts.loop:
|
|
defining.setdefault(name, []).append(index)
|
|
components = {}
|
|
for index in range(len(self.atoms)):
|
|
components.setdefault(find(index), []).append(index)
|
|
return [indices for indices in components.values() if any(free[index] for index in indices)]
|
|
|
|
def assign(self):
|
|
group = self.write_groups()
|
|
members = {}
|
|
for index in range(len(self.atoms)):
|
|
members.setdefault(group[index], []).append(index)
|
|
test_owner, pinned = {}, set()
|
|
while True:
|
|
units, unknowns = [], []
|
|
for index in range(len(self.atoms)):
|
|
owners, unknown = self.candidates(index, test_owner)
|
|
units.append(owners)
|
|
unknowns.append(unknown or group[index] in pinned)
|
|
fresh_pinned = set(pinned)
|
|
for root, indices in members.items():
|
|
if len(indices) > 1 and any(unknowns[index] for index in indices) and any(units[index] for index in indices):
|
|
fresh_pinned.add(root)
|
|
result = []
|
|
for index in range(len(self.atoms)):
|
|
if unknowns[index] or group[index] in fresh_pinned:
|
|
result.append(None)
|
|
else:
|
|
result.append(set().union(*(units[other] for other in members[group[index]])) or None)
|
|
first = min((index for index in range(len(self.atoms)) if result[index]), default=len(self.atoms))
|
|
free = [not result[index] and not unknowns[index] and not units[index] and index > first
|
|
and group[index] not in fresh_pinned for index in range(len(self.atoms))]
|
|
for indices in self.variable_components(free):
|
|
if any(not free[index] and not result[index] for index in indices):
|
|
continue
|
|
owners = {frozenset(result[index]) for index in indices if result[index]}
|
|
if len(owners) == 1:
|
|
for index in indices:
|
|
if free[index]:
|
|
result[index] = set(next(iter(owners)))
|
|
fresh = {}
|
|
for index, owners in enumerate(result):
|
|
if owners and len(owners) == 1:
|
|
for test in self.facts[index].tests:
|
|
fresh[test] = next(iter(owners))
|
|
if fresh == test_owner and fresh_pinned == pinned:
|
|
break
|
|
test_owner, pinned = fresh, fresh_pinned
|
|
names = sorted({name for owners in result if owners for name in owners})
|
|
position = {name: index for index, name in enumerate(names)}
|
|
find, join = union_find(len(names))
|
|
sizes = {}
|
|
for index, owners in enumerate(result):
|
|
if not owners:
|
|
continue
|
|
ordered = sorted(owners)
|
|
for name in ordered:
|
|
join(position[name], position[ordered[0]])
|
|
sizes[name] = sizes.get(name, 0) + self.atoms[index].node.last - self.atoms[index].node.first + 1
|
|
largest = {}
|
|
for name in names:
|
|
root = find(position[name])
|
|
if root not in largest or (sizes[name], name) > (sizes[largest[root]], largest[root]):
|
|
largest[root] = name
|
|
return [largest[find(position[next(iter(owners))])] if owners else None for owners in result]
|
|
|
|
def place(self):
|
|
moved = [index for index, unit in enumerate(self.units) if unit]
|
|
if not moved:
|
|
raise SplitError(f"{LISTS} registers no tests to move")
|
|
|
|
def union(field):
|
|
return set().union(*(getattr(self.facts[index], field) for index in moved))
|
|
|
|
defined, used, targets, calls = union("defines"), union("uses"), union("targets"), union("calls")
|
|
tests = set().union(*(set(self.facts[index].tests) for index in moved))
|
|
everywhere = set().union(*(facts.targets for facts in self.facts))
|
|
external = {name for name in union("subject_targets") if UNIT.fullmatch(name) and name not in everywhere}
|
|
needed, depends = [], []
|
|
for index, facts in enumerate(self.facts):
|
|
if self.units[index]:
|
|
continue
|
|
if ((facts.defines - facts.loop) & used or (facts.dynamic_targets and external) or facts.creates_defaults
|
|
or facts.functions & calls):
|
|
needed.append(index)
|
|
if ((facts.uses - facts.defines) & defined or facts.subject_targets & targets or facts.subject_tests & tests
|
|
or facts.mentions & targets or facts.reads_all_tests
|
|
or any(test.startswith(prefix) for prefix in facts.test_patterns for test in tests)):
|
|
depends.append(index)
|
|
top = [next(position for position, node in enumerate(self.roots) if node.first <= atom.node.first <= node.last)
|
|
for atom in self.atoms]
|
|
low = top[max(needed)] + 1 if needed else 0
|
|
high = top[min(depends)] if depends else len(self.roots)
|
|
if low > high:
|
|
raise SplitError(f"no single place to include the fragments: line {self.atoms[max(needed)].node.first + 1} must come "
|
|
f"before them and line {self.atoms[min(depends)].node.first + 1} after them")
|
|
return max(low, min(high, max(top[index] for index in moved) + 1))
|
|
|
|
|
|
def leading(lines, first):
|
|
while first > 0 and lines[first - 1].lstrip().startswith("#"):
|
|
first -= 1
|
|
return first
|
|
|
|
|
|
def opener(line):
|
|
call = CALL.match(line)
|
|
return bool(call) and (call[1].lower() in OPEN or call[1].lower() in BRANCH)
|
|
|
|
|
|
def closer(line):
|
|
call = CALL.match(line)
|
|
return bool(call) and (call[1].lower() in OPEN.values() or call[1].lower() in BRANCH)
|
|
|
|
|
|
def wrappers(nodes):
|
|
for node in nodes:
|
|
if isinstance(node, Block):
|
|
for _branch, children in node.branches:
|
|
yield from wrappers(children)
|
|
yield node
|
|
|
|
|
|
def fragment_text(lines, members):
|
|
out, stack = [], []
|
|
for atom in members:
|
|
conditions = list(atom.conditions)
|
|
common = 0
|
|
while common < min(len(stack), len(conditions)) and stack[common] is conditions[common]:
|
|
common += 1
|
|
while len(stack) > common:
|
|
block = stack.pop()
|
|
out.extend(lines[block.end.first:block.end.last + 1])
|
|
first = leading(lines, atom.node.first)
|
|
separated = first > 0 and not lines[first - 1].strip()
|
|
for block in conditions[common:]:
|
|
if out and not stack and out[-1].strip():
|
|
out.append("")
|
|
out.extend(lines[block.header.first:block.header.last + 1])
|
|
stack.append(block)
|
|
if out and separated and out[-1].strip() and not opener(out[-1]):
|
|
out.append("")
|
|
out.extend(line for line in lines[first:atom.node.last + 1] if not line.lstrip().startswith("#"))
|
|
while stack:
|
|
block = stack.pop()
|
|
out.extend(lines[block.end.first:block.end.last + 1])
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
def split_text(text, execution=()):
|
|
plan = Plan(text, execution)
|
|
lines = plan.lines
|
|
units = {}
|
|
for atom, unit in zip(plan.atoms, plan.units):
|
|
if unit:
|
|
units.setdefault(unit, []).append(atom)
|
|
deleted = set()
|
|
for members in units.values():
|
|
for atom in members:
|
|
deleted.update(range(leading(lines, atom.node.first), atom.node.last + 1))
|
|
for block in wrappers(plan.roots):
|
|
if block.header.name != "if" or len(block.branches) != 1:
|
|
continue
|
|
body = range(block.header.last + 1, block.end.first)
|
|
if all(index in deleted or not lines[index].strip() or lines[index].lstrip().startswith("#") for index in body):
|
|
deleted.update(range(leading(lines, block.header.first), block.end.last + 1))
|
|
at = leading(lines, plan.roots[plan.include].first) if plan.include < len(plan.roots) else len(lines)
|
|
kept = []
|
|
for index, line in enumerate(lines):
|
|
if index == at:
|
|
kept.extend([""] + INCLUDE + [""])
|
|
if index not in deleted:
|
|
kept.append(line)
|
|
if at >= len(lines):
|
|
kept.extend([""] + INCLUDE)
|
|
residual = []
|
|
for line in kept:
|
|
if not line.strip() and (not residual or not residual[-1].strip() or opener(residual[-1])):
|
|
continue
|
|
if closer(line) and residual and not residual[-1].strip():
|
|
residual.pop()
|
|
residual.append(line)
|
|
while residual and not residual[-1].strip():
|
|
residual.pop()
|
|
return "\n".join(residual) + "\n", {name: fragment_text(lines, members) for name, members in units.items()}
|
|
|
|
|
|
def migrated(text, execution=()):
|
|
return split_text(refactor(text), execution)
|
|
|
|
|
|
def execution_target(stem):
|
|
return "agc_driver_" + re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", stem).lower() + "_tests"
|
|
|
|
|
|
def execution_targets(root):
|
|
return sorted(execution_target(path.stem) for path in (root / EXECUTION).glob("*.cpp"))
|
|
|
|
|
|
def known_targets(root):
|
|
known = set(execution_targets(root)) | {path.stem for path in (root / FRAGMENTS).glob("*.cmake")}
|
|
revisions = ["HEAD"]
|
|
for head in ("REBASE_HEAD", "CHERRY_PICK_HEAD", "MERGE_HEAD"):
|
|
if git(root, "rev-parse", "-q", "--verify", head, check=False).strip():
|
|
revisions += [head, head + "^", git(root, "merge-base", "HEAD", head, check=False).strip()]
|
|
for revision in filter(None, revisions):
|
|
listing = git(root, "ls-tree", "--name-only", f"{revision}:{EXECUTION}", check=False)
|
|
known |= {execution_target(Path(name).stem) for name in listing.splitlines() if name.endswith(".cpp")}
|
|
return sorted(known)
|
|
|
|
|
|
def is_split(text):
|
|
return INCLUDE[0] in text
|
|
|
|
|
|
def git(root, *args, check=True):
|
|
return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
check=check).stdout
|
|
|
|
|
|
def write_lf(path, text):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(path, "w", encoding="utf-8", newline="\n") as file:
|
|
file.write(text)
|
|
|
|
|
|
def fragment_path(name):
|
|
return f"{FRAGMENTS}/{name}.cmake"
|
|
|
|
|
|
def command_refactor(root):
|
|
text = (root / LISTS).read_text(encoding="utf-8")
|
|
changed = refactor(text)
|
|
if changed == text:
|
|
sys.exit(f"{LISTS} already registers its default tests through agc_add_default_test")
|
|
write_lf(root / LISTS, changed)
|
|
print(LISTS)
|
|
|
|
|
|
def command_split(root):
|
|
text = (root / LISTS).read_text(encoding="utf-8")
|
|
if is_split(text):
|
|
sys.exit(f"{LISTS} already includes {FRAGMENTS}")
|
|
lists, fragments = migrated(text, execution_targets(root))
|
|
existing = sorted(set(fragments) & {path.stem for path in (root / FRAGMENTS).glob("*.cmake")})
|
|
if existing:
|
|
sys.exit(f"{FRAGMENTS} already has {', '.join(name + '.cmake' for name in existing)}")
|
|
for name, body in fragments.items():
|
|
write_lf(root / fragment_path(name), body)
|
|
write_lf(root / LISTS, lists)
|
|
print(f"{len(fragments)} fragments in {FRAGMENTS}, {len(lists.splitlines())} lines left in {LISTS}")
|
|
|
|
|
|
def merge(base, ours, theirs, label):
|
|
if base == theirs:
|
|
return ours, False
|
|
if base == ours:
|
|
return theirs, False
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
paths = []
|
|
for name, text in (("ours", ours), ("base", base), ("theirs", theirs)):
|
|
write_lf(Path(directory) / name, text)
|
|
paths.append(str(Path(directory) / name))
|
|
result = subprocess.run(["git", "merge-file", "-p", "-L", f"{label} (split)", "-L", "before the split",
|
|
"-L", f"{label} (change)", *paths], capture_output=True, text=True, encoding="utf-8")
|
|
if not 0 <= result.returncode < 128:
|
|
raise SplitError(f"git merge-file failed on {label}")
|
|
return result.stdout, result.returncode > 0
|
|
|
|
|
|
def apply_change(base, change, lists, read, execution=()):
|
|
base_lists, base_fragments = migrated(base, execution)
|
|
change_lists, change_fragments = migrated(change, execution)
|
|
written, conflicted = {}, []
|
|
merged, clash = merge(base_lists, lists, change_lists, LISTS)
|
|
if merged != lists:
|
|
written[LISTS] = merged
|
|
if clash:
|
|
conflicted.append(LISTS)
|
|
for name in sorted(set(base_fragments) | set(change_fragments)):
|
|
before, after = base_fragments.get(name, ""), change_fragments.get(name, "")
|
|
if before == after:
|
|
continue
|
|
path = fragment_path(name)
|
|
merged, clash = merge(before, read(path), after, path)
|
|
written[path] = merged
|
|
if clash:
|
|
conflicted.append(path)
|
|
return written, conflicted
|
|
|
|
|
|
def command_resolve(root):
|
|
stages = {}
|
|
for line in git(root, "ls-files", "-u", "--", LISTS).splitlines():
|
|
meta, _ = line.split("\t", 1)
|
|
_, sha, stage = meta.split()
|
|
stages[stage] = sha
|
|
if not {"1", "2", "3"} <= set(stages):
|
|
sys.exit(f"{LISTS} is not in a three-way conflict")
|
|
base, ours, theirs = (git(root, "cat-file", "-p", stages[stage]) for stage in "123")
|
|
if is_split(base) or is_split(ours) == is_split(theirs):
|
|
sys.exit(f"{LISTS}: the conflict is not between a change made before the split and the split; resolve it by hand")
|
|
lists, change = (ours, theirs) if is_split(ours) else (theirs, ours)
|
|
|
|
def read(path):
|
|
file = root / path
|
|
return file.read_text(encoding="utf-8") if file.exists() else ""
|
|
|
|
try:
|
|
written, conflicted = apply_change(base, change, lists, read, known_targets(root))
|
|
except SplitError as error:
|
|
sys.exit(f"{LISTS}: {error}")
|
|
written.setdefault(LISTS, lists)
|
|
for path, text in written.items():
|
|
if text.strip():
|
|
write_lf(root / path, text)
|
|
elif (root / path).exists():
|
|
(root / path).unlink()
|
|
clean = [path for path in written if path not in conflicted]
|
|
if clean:
|
|
git(root, "add", "-A", "--", *clean)
|
|
print("\n".join(sorted(written)))
|
|
if conflicted:
|
|
sys.exit("resolve the conflict markers in " + ", ".join(conflicted) + ", then git add them")
|
|
|
|
|
|
def main(argv=None):
|
|
for stream in (sys.stdout, sys.stderr):
|
|
if hasattr(stream, "reconfigure"):
|
|
stream.reconfigure(encoding="utf-8")
|
|
parser = argparse.ArgumentParser(description=f"Move the AGC test registrations of {LISTS} into one fragment per test executable, "
|
|
"and move registrations of a change made before the split")
|
|
parser.add_argument("command", choices=("refactor", "split", "resolve"),
|
|
help="refactor: register default, draw-pipeline and SPIR-V mode tests through functions; "
|
|
f"split: refactor, then move the registrations into {FRAGMENTS}/; "
|
|
f"resolve: during a rebase or merge stopped on {LISTS}, apply the change's registrations to the "
|
|
"fragments and stage them")
|
|
args = parser.parse_args(argv)
|
|
root = Path(git(Path.cwd(), "rev-parse", "--show-toplevel").strip())
|
|
try:
|
|
{"refactor": command_refactor, "split": command_split, "resolve": command_resolve}[args.command](root)
|
|
except SplitError as error:
|
|
sys.exit(f"{LISTS}: {error}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|