modded7/tools/tasks/helpers/questPorting/portQBModifications.ts
tracer4b b526677126 Questbook Update + Buildscript Improvements (#681)
[EXPAND]
[[messages]]
messageTitle = "QB Update for GT 2.8 (#681)"
messageBody = """
[QB]
[DETAILS]
details = ["Fixes many Quest Book issues", "Updates QB with changes in GT 2.8"]
[DETAILS]
"""

[[messages]]
messageTitle = "Buildscript Refactor (#681)"
messageBody = """
[INTERNAL]
[DETAILS]
details = ["**Important: Buildscript has changed from `npx gulp...` or `gulp...` to `npm run gulp...`**!", "Moves to Node 16 Package Management + Typescript Strict Mode", "New Port QB, Check QB and Fix QB Tasks"]
[DETAILS]
"""
[EXPAND]


Co-authored-by: Integer Limit <103940576+IntegerLimit@users.noreply.github.com>
Co-authored-by: Ghzdude <44148655+ghzdude@users.noreply.github.com>
Co-authored-by: SparkedTheorem <162088357+SparkedTheorem@users.noreply.github.com>
2024-05-14 21:57:00 +10:00

902 lines
23 KiB
TypeScript

import {
BunchedParserPath,
ChangeAndPath,
CustomDescriptionTaskTemplate,
DescriptionTaskChange,
LogicType,
Message,
Modified,
Parser,
QuestChange,
SimpleLogic,
TaskDifferentSolution,
YesIgnoreNo,
} from "#types/actionQBTypes.ts";
import PortQBData from "./portQBData.ts";
import DiffMatchPatch from "diff-match-patch";
import picomatch from "picomatch";
import { booleanSelect, findQuest, id, name } from "../actionQBUtils.ts";
import fakeDiff from "fake-diff";
import { Operation } from "just-diff";
import logInfo, { logError, logNotImportant, logWarn } from "#utils/log.ts";
import dedent from "dedent-js";
import { confirm, editor, input, select } from "@inquirer/prompts";
import colors from "colors";
import { stringify } from "javascript-stringify";
import { Quest, Task } from "#types/bqQuestBook.ts";
import { ArrayUnique } from "#utils/util.ts";
import lodash from "lodash";
let data: PortQBData;
const dmp = new DiffMatchPatch();
const taskKey = "tasks";
export function setupModifications(dataIn: PortQBData): void {
data = dataIn;
}
export async function performModification(modify: Modified): Promise<void> {
const { formattedNames, callableFunctions } = findAllParsers(modify);
if (formattedNames.length === 0 || callableFunctions.length === 0) {
logNotImportant(
`All Modification Changes on this Quest with ID ${id(modify.currentQuest)} and Name ${name(
modify.currentQuest,
)} were Skipped.`,
);
return;
}
// Relying on the fact that the messages are sorted, condense duplicates.
const output: Message[] = [];
let lastMsg: string | undefined = undefined;
for (let i = 0; i < formattedNames.length; i++) {
if (i !== 0 && formattedNames[i] === lastMsg) {
output[i - 1].incrementRepeats();
continue;
}
output.push(new Message(formattedNames[i]));
lastMsg = formattedNames[i];
}
if (
!(await booleanSelect(
`Would you like to perform modifications on Quest with ID ${id(modify.currentQuest)} and Name ${name(
modify.currentQuest,
)}?\nChanges: ${output.map((msg) => msg.toFormattedString()).join(", ")}`,
))
) {
logNotImportant("Skipping...");
return;
}
const quest = await findQuest(id(modify.currentQuest));
if (!quest) {
logInfo("Skipping, Could not find Corresponding Quest...");
return;
}
for (const func of callableFunctions) {
await func(quest);
}
}
function getSimpleFormattedParserName(
parser: Parser,
logic: SimpleLogic,
path: string[],
op: Operation,
) {
if (!logic.formattedName) return `${parser.name} ${formatOp(op)}`;
return logic.formattedName(path, op);
}
function formatOp(operation: Operation): string {
switch (operation) {
case "add":
return "Addition";
default:
case "replace":
return "Modification";
case "remove":
return "Removal";
}
}
function findAllParsers(modify: Modified): {
formattedNames: string[];
callableFunctions: ((questToModify: Quest) => Promise<void>)[];
} {
const outputFunctions: ((questToModify: Quest) => Promise<void>)[] = [];
const outputFormatted: string[] = [];
const foundBuncableParsers = new Map<string, BunchedParserPath[]>();
const foundSimpleParserIds = new Set<string>();
for (const change of modify.change) {
const pathList = (change.path as string[] | number[]).map(
(path: string | number): string => {
if (typeof path === "number") path = (path as number).toString();
return path.split(":")[0];
},
);
const path = pathList.join("/");
// Instead of filtering out ignored parsers before, we must check if the parser match is one that is ignored
// This is because otherwise the general parser would be called instead
for (const parser of modificationParsers) {
if (!parser.condition(path)) continue;
// ID Checks and Handles
if (data.modifyParsersIgnore.has(parser.id)) {
logNotImportant(
`Skipping Change with Parser with id '${parser.id}'...`,
);
break;
}
if (parser.logic.type === LogicType.Simple) {
if (foundSimpleParserIds.has(parser.id)) break;
if (parser.logic.applyOnce) foundSimpleParserIds.add(parser.id);
}
// Simple Parser Logic
if (parser.logic.type === LogicType.Simple) {
outputFunctions.push(
async (quest) =>
await (parser.logic as SimpleLogic).func(
quest,
modify,
change,
pathList,
),
);
outputFormatted.push(
getSimpleFormattedParserName(
parser,
parser.logic,
pathList,
change.op,
),
);
break;
}
// Bunched Parser Logic
const changeAndPath: ChangeAndPath = { change: change, path: pathList };
if (!foundBuncableParsers.has(parser.id)) {
foundBuncableParsers.set(parser.id, [
{ logic: parser.logic, changeAndPath: [changeAndPath] },
]);
break;
}
let foundBunch = false;
for (const parserBunch of foundBuncableParsers.get(parser.id) ?? []) {
if (
!parserBunch.logic.applyTogether(
parserBunch.changeAndPath[0].path,
pathList,
)
)
continue;
parserBunch.changeAndPath.push(changeAndPath);
foundBunch = true;
}
if (!foundBunch) {
foundBuncableParsers
.get(parser.id)
?.push({ logic: parser.logic, changeAndPath: [changeAndPath] });
}
break;
}
}
// Change Bunched Parsers Into Function
for (const bunchList of foundBuncableParsers.values()) {
for (const bunch of bunchList) {
outputFunctions.push(
async (quest) =>
await bunch.logic.func(quest, modify, bunch.changeAndPath),
);
outputFormatted.push(...bunch.logic.formattedName(bunch.changeAndPath));
}
}
return {
formattedNames: outputFormatted.sort(),
callableFunctions: outputFunctions,
};
}
function assertIsModification(change: QuestChange) {
if (change.op !== "replace")
throw new Error(dedent`
Runtime Exception: Addition/Removal Should Not Happen Here! Report this to the Core Devs of Nomi-CEu!
Path: ${change.path.toString()}
`);
}
const modifyDesc = async (
questToModify: Quest,
modify: Modified,
change: QuestChange,
) => {
assertIsModification(change);
const oldQuest =
modify.oldQuest["properties:10"]["betterquesting:10"]["desc:8"];
const newQuest =
modify.currentQuest["properties:10"]["betterquesting:10"]["desc:8"];
const originalQuest =
questToModify["properties:10"]["betterquesting:10"]["desc:8"];
logInfo(colors.bold("Change in Source Quest:"));
console.log(fakeDiff(oldQuest, newQuest));
const apply = dmp.patch_apply(
dmp.patch_make(oldQuest, newQuest),
originalQuest,
)[0];
logInfo(colors.bold("If Applied:"));
console.log(fakeDiff(originalQuest, apply));
logInfo(colors.bold("If Replaced:"));
console.log(fakeDiff(originalQuest, newQuest));
const applyMode = (await select({
message: "How Should we Apply this Change to the Description?",
choices: [
{ name: "Apply Changes on top of Existing Description", value: "APPLY" },
{ name: "Replace Existing Description", value: "REPLACE" },
{ name: "Enter Own Description", value: "CUSTOM" },
{ name: "Ignore this Change", value: "IGNORE" },
],
})) as DescriptionTaskChange;
if (applyMode === "IGNORE") {
logInfo("Ignoring...");
return;
}
let description: string;
switch (applyMode) {
case "APPLY":
logInfo("Applying Description Change...");
description = apply;
break;
case "REPLACE":
logInfo("Replacing Description...");
description = newQuest;
break;
case "CUSTOM":
const template = (await select({
message: "What Should the Original Text Be?",
choices: [
{ name: "Description with Changes Applied", value: "APPLY" },
{ name: "Description with Changes Replaced", value: "REPLACE" },
{ name: "Original Description", value: "ORIGINAL" },
],
})) as CustomDescriptionTaskTemplate;
const templateStr =
template === "APPLY"
? apply
: template === "REPLACE"
? newQuest
: originalQuest;
description = await editor({
message:
"Enter your Custom Description. Enter an Empty String to Cancel!",
default: templateStr,
});
if (!description) {
logInfo("Cancelling...");
return;
}
break;
}
questToModify["properties:10"]["betterquesting:10"]["desc:8"] = description;
};
const modifyIcon = async (
questToModify: Quest,
modify: Modified,
change: QuestChange,
) => {
assertIsModification(change);
const oldIcon =
modify.oldQuest["properties:10"]["betterquesting:10"]["icon:10"];
const newIcon =
modify.currentQuest["properties:10"]["betterquesting:10"]["icon:10"];
const currentIcon =
questToModify["properties:10"]["betterquesting:10"]["icon:10"];
const newIconString = stringify(newIcon, null, 2) ?? "";
logInfo(colors.bold("Change in Source Quest:"));
console.log(fakeDiff(stringify(oldIcon, null, 2) ?? "", newIconString));
logInfo(colors.bold("If Applied to Current Quest:"));
console.log(fakeDiff(stringify(currentIcon, null, 2) ?? "", newIconString));
if (!(await booleanSelect("Should we Apply This Icon Change?"))) {
logNotImportant("Skipping...");
return;
}
logInfo("Applying Icon Change...");
questToModify["properties:10"]["betterquesting:10"]["icon:10"] = newIcon;
};
const modifyTasks = async (
questToModify: Quest,
modify: Modified,
changeAndPaths: ChangeAndPath[],
) => {
logInfo("Performing Tasks Change...");
const oldTasks = modify.oldQuest["tasks:9"];
const newTasks = modify.currentQuest["tasks:9"];
const currentTasks = questToModify["tasks:9"];
let same: boolean = true;
let toModify: Map<number, Task>;
if (!lodash.isEqual(oldTasks, currentTasks)) {
logWarn(
"The Tasks Object in the Current Quest and the Original Source Quest is different!",
);
logInfo(colors.bold("Change:"));
console.log(
fakeDiff(
stringify(Object.values(currentTasks), null, 2) ?? "",
stringify(Object.values(oldTasks), null, 2) ?? "",
),
);
const solution = (await select({
message: "What should we do?",
choices: [
{
name: "Replace the Current Tasks with the Original Tasks",
value: "APPLY",
},
{
name: "Continue without Replacing (MAY CAUSE PROBLEMS!)",
value: "CONTINUE",
},
{
name: "Ignore this Change",
value: "IGNORE",
},
],
})) as TaskDifferentSolution;
switch (solution) {
case "APPLY":
logInfo("Replacing...");
toModify = new Map(
Object.values(oldTasks).map((task) => [task["index:3"], task]),
);
break;
case "CONTINUE":
logWarn(
"Warning: Please Check the Context of each Change in the JSON File before Applying!",
);
same = false;
break;
case "IGNORE":
logNotImportant("Skipping...");
return;
}
} else {
if (!(await booleanSelect("Should we Apply Task Changes on this Quest?"))) {
logNotImportant("Skipping...");
return;
}
}
toModify ??= new Map(
Object.values(currentTasks).map((task) => [task["index:3"], task]),
);
// Sort Changes into Map of Index to Changes
const changes = new Map<number, ChangeAndPath[]>();
changeAndPaths.forEach((change) => {
const index = getIndex(change.path, taskKey);
if (!changes.has(index)) changes.set(index, [change]);
else changes.get(index)?.push(change);
});
for (const entry of changes.entries()) {
const [index, changes] = entry;
if (index < 0)
throw new Error("Invalid Path! Report to the Core Devs of Nomi-CEu!");
// Are we adding/removing a whole task?
if (
changes.length === 1 &&
isAddingOrRemovingComplexTask(changes[0].path)
) {
let task: Task;
const change = changes[0];
if (change.change.op === "add")
task = newTasks[change.change.path.at(-1) ?? "0:10"];
else {
const foundTask = toModify.get(index);
if (!foundTask) {
logError(
`Current Task Object does not Contain Index ${index}! Skipping...`,
);
continue;
}
task = foundTask;
}
const id = task["taskID:8"];
if (
!(await booleanSelect(
`Should we ${change.change.op === "add" ? "Add" : "Remove"} Task No. ${index + 1} with ID ${id}?`,
))
) {
logNotImportant("Skipping...");
continue;
}
if (change.change.op === "add") {
const newIndex = same
? index
: (lodash.max(Array.from(toModify.keys())) ?? 0) + 1;
const newTask: Task = { ...task };
if (!same) newTask["index:3"] = newIndex;
logInfo(`Adding Task No. ${newIndex + 1} and ID ${id}...`);
toModify.set(newIndex, newTask);
} else {
logInfo(`Removing Task No. ${index + 1} and ID ${id}...`);
toModify.delete(index);
}
continue;
}
// Modification of a Task
const oldTask = Object.values(oldTasks)[index];
const newTask = Object.values(newTasks)[index];
let task = toModify.get(index);
if (same) {
if (!task) {
throw new Error(
`Current Task Object does not Contain Index ${index}! Please Report this to the Core Devs of Nomi-CEu!`,
);
}
logInfo(colors.bold("Change:"));
console.log(
fakeDiff(
stringify(task, null, 2) ?? "",
stringify(newTask, null, 2) ?? "",
),
);
if (!(await booleanSelect("Should we Apply this Change?"))) {
logNotImportant("Skipping...");
continue;
}
logInfo("Applying Change...");
toModify.set(index, { ...newTask });
continue;
}
let confirmedTask: Task | undefined = undefined;
let cancelled: boolean = false;
while (!confirmedTask) {
if (!task) {
const retrievedIndex = Number.parseInt(
await input({
message: `Corresponding Index for Task with Index ${index} is Empty! Please enter the Corresponding Index: (-1 to Cancel/Ignore)`,
validate: (value) => {
const numValue = Number.parseInt(value);
if (numValue === -1) return true; // Allow Cancelling
if (isNaN(numValue) || numValue < 0) {
return "Please Enter a Number Value >= 0!";
}
const foundTask = toModify.get(numValue);
if (!foundTask) {
return "Please Enter a Valid 0-Based Index!";
}
return true;
},
}),
);
if (retrievedIndex === -1) {
logNotImportant("Skipping...");
cancelled = true;
break;
}
task = toModify.get(retrievedIndex);
}
if (!task)
throw new Error(
"Task is Undefined! This should not Happen! Report this to the Core Devs of Nomi-CEu!",
);
logInfo(
`Does Task with Index ${index} in Source Quest Correspond to Task with Index ${task["index:3"]} in the Target Quest?`,
);
logInfo(colors.bold("Difference:"));
console.log(
fakeDiff(
stringify(oldTask, null, 2) ?? "",
stringify(task, null, 2) ?? "",
),
);
const choice = (await select({
message: "Is this Correct?",
choices: [
{
name: "Yes",
value: "YES",
},
{
name: "No",
value: "NO",
},
{
name: "Ignore",
value: "IGNORE",
},
],
})) as YesIgnoreNo;
if (choice === "IGNORE") {
logNotImportant("Skipping...");
cancelled = true;
break;
}
if (choice === "NO") {
logInfo("Please Enter the Correct Index Below.");
task = undefined;
continue;
}
confirmedTask = task;
}
if (cancelled) continue;
const oldTaskString = stringify(oldTask, null, 2) ?? "";
const newTaskString = stringify(newTask, null, 2) ?? "";
const currentTaskString = stringify(confirmedTask, null, 2) ?? "";
logInfo(colors.bold("Change in Source Quest:"));
console.log(fakeDiff(oldTaskString, newTaskString));
const apply = dmp.patch_apply(
dmp.patch_make(oldTaskString, newTaskString),
currentTaskString,
)[0];
logInfo(colors.bold("If Applied:"));
console.log(fakeDiff(currentTaskString, apply));
logInfo(colors.bold("If Replaced:"));
console.log(fakeDiff(currentTaskString, newTaskString));
const applyMode = (await select({
message: "How Should we Apply this Task Change?",
choices: [
{
name: "Apply Changes on top of Existing Task",
value: "APPLY",
},
{ name: "Replace Existing Task", value: "REPLACE" },
{ name: "Enter Own Task", value: "CUSTOM" },
{ name: "Ignore this Change", value: "IGNORE" },
],
})) as DescriptionTaskChange;
if (applyMode === "IGNORE") {
logInfo("Ignoring...");
return;
}
let taskObj: Task | undefined;
switch (applyMode) {
case "APPLY":
logInfo("Applying Description Change...");
try {
taskObj = JSON.parse(apply) as Task;
} catch (e) {
logWarn("Invalid JSON! Enter your own Below!");
taskObj = await getCustomTasks(
currentTaskString,
newTaskString,
apply,
);
}
break;
case "REPLACE":
logInfo("Replacing Description...");
taskObj = { ...newTask };
break;
case "CUSTOM":
taskObj = await getCustomTasks(currentTaskString, newTaskString, apply);
break;
}
if (!taskObj) continue;
logInfo("Performing Task Modification...");
toModify.set(confirmedTask?.["index:3"] ?? 0, taskObj);
}
questToModify["tasks:9"] = {};
for (const entry of toModify) {
questToModify["tasks:9"][`${entry[0]}:10`] = entry[1];
}
};
async function getCustomTasks(
originalTask: string,
newTask: string,
apply: string,
): Promise<Task | undefined> {
let foundTask: Task | undefined = undefined;
while (!foundTask) {
const template = (await select({
message: "What Should the Default Text Be?",
choices: [
{ name: "Description with Changes Applied", value: "APPLY" },
{ name: "Description with Changes Replaced", value: "REPLACE" },
{ name: "Original Description", value: "ORIGINAL" },
],
})) as CustomDescriptionTaskTemplate;
const templateStr =
template === "APPLY"
? apply
: template === "REPLACE"
? newTask
: originalTask;
const taskString = await editor({
message: "Enter your Custom Task. Enter an Empty String to Cancel!",
default: templateStr,
});
if (!taskString) {
logInfo("Cancelling...");
return undefined;
}
try {
foundTask = JSON.parse(taskString) as Task;
} catch (e) {
logWarn("Invalid JSON!");
foundTask = undefined;
}
}
return foundTask;
}
const modifyPrerequisites = async (
questToModify: Quest,
modify: Modified,
change: QuestChange,
) => {
logInfo("Performing Prerequisite Modifications...");
// Get Array Diff
const arrayDiff = change.value as ArrayUnique<number>;
const preRequisiteArrayCurrent = modify.currentQuest["preRequisites:11"];
const preRequisiteTypeArrayCurrent =
modify.currentQuest["preRequisiteTypes:7"];
const preRequisiteArray = questToModify["preRequisites:11"];
const preRequisiteTypeArray = questToModify["preRequisiteTypes:7"];
const preRequisites = new Map<number, number>();
preRequisiteArray.forEach((pre, index) =>
preRequisites.set(
pre,
preRequisiteTypeArray ? preRequisiteTypeArray[index] : 0,
),
);
// Unique to Current: Added.
for (const added of arrayDiff.arr2Unique) {
const toAdd = await findQuest(added);
if (!toAdd) {
logInfo("Skipping, Could not find Corresponding Quest...");
return;
}
if (preRequisites.has(id(toAdd))) {
logNotImportant("Quest Already Contains Added Prerequisite.");
return;
}
if (
!(await booleanSelect(
`Should we Add Quest with ID ${id(toAdd)} and Name ${name(toAdd)} as a Prerequisite?`,
))
) {
logNotImportant("Skipping...");
return;
}
logInfo("Adding Prerequisite...");
const index = preRequisiteArrayCurrent.indexOf(added);
preRequisites.set(
id(toAdd),
index === -1 || !preRequisiteTypeArrayCurrent
? 0
: preRequisiteTypeArrayCurrent[index] ?? 0,
);
}
// Unique to Old: Removed.
for (const removed of arrayDiff.arr1Unique) {
const toRemove = await findQuest(removed);
if (!toRemove) {
logInfo("Skipping, Could not find Corresponding Quest...");
return;
}
if (!preRequisites.has(id(toRemove))) {
logNotImportant("Quest Does Not Contain Removed Prerequisite.");
return;
}
if (
!(await booleanSelect(
`Should we Remove Quest with ID ${id(toRemove)} and Name ${name(toRemove)} as a Prerequisite?`,
))
) {
logNotImportant("Skipping...");
return;
}
logInfo("Removing Prerequisite...");
preRequisites.delete(id(toRemove));
}
// Save
questToModify["preRequisites:11"] = Array.from(preRequisites.keys()).sort(
(a, b) => a - b,
);
if (
Array.from(preRequisites.values()).findIndex((value) => value !== 0) === -1
)
return;
const types: number[] = [];
for (let i = 0; i < questToModify["preRequisites:11"].length; i++) {
types[i] = preRequisites.get(questToModify["preRequisites:11"][i]) ?? 0;
}
questToModify["preRequisiteTypes:7"] = types;
};
const modifyGeneral = async (
questToModify: Quest,
modify: Modified,
change: QuestChange,
path: string[],
): Promise<void> => {
assertIsModification(change);
logInfo(`Change in '${path.pop()}':`);
const newValue = lodash.get(modify.currentQuest, change.path);
const newValueAsString = stringify(newValue) ?? "";
logInfo(colors.bold("Change in Source Quest:"));
console.log(
fakeDiff(
stringify(lodash.get(modify.oldQuest, change.path)) ?? "",
newValueAsString,
),
);
logInfo(colors.bold("Change if Applied:"));
console.log(
fakeDiff(
stringify(lodash.get(questToModify, change.path)) ?? "",
newValueAsString,
),
);
const shouldContinue = await confirm({
message: "Would you like to apply the Change?",
});
if (!shouldContinue) {
logNotImportant("Skipping...");
return;
}
logInfo("Applying Change...");
lodash.set(questToModify, change.path, newValue);
};
function isAddingOrRemovingComplexTask(path: string[]): boolean {
return path.length === 2;
}
function getIndex(path: string[], pathKey: string): number {
const index = path.indexOf(pathKey) + 1;
if (index == 0 || index >= path.length) return -1; // indexOf returns -1 if not found, +1 = 0
const num = Number.parseInt(path[index]);
if (Number.isNaN(num)) return -1;
return num;
}
function getFormattedNameWithIndex(
path: string[],
op: Operation,
pathKey: string,
baseName: string,
): string {
const defaultVal = `${baseName} ${formatOp(op)}`;
if (op !== "replace") return defaultVal;
const index = getIndex(path, pathKey);
if (index === -1) return defaultVal;
return `${baseName} No. ${index + 1} Modification`;
}
export const modificationParsers = [
{
id: "icon",
name: "Icon",
condition: picomatch("properties/betterquesting/icon/*"),
logic: {
type: LogicType.Simple,
applyOnce: true,
func: modifyIcon,
},
},
{
id: "desc",
name: "Description",
condition: picomatch("properties/betterquesting/desc"),
logic: {
type: LogicType.Simple,
applyOnce: true,
func: modifyDesc,
},
},
{
id: taskKey,
name: "Task",
condition: picomatch("tasks/**/*"),
logic: {
type: LogicType.Bunched,
applyTogether: () => true,
formattedName: (changes) => {
const result: string[] = [];
changes = lodash.uniqBy(changes, (change) =>
getIndex(change.path, taskKey),
);
for (const change of changes) {
if (
!isAddingOrRemovingComplexTask(change.path) &&
change.change.op !== "replace"
)
change.change.op = "replace";
result.push(
getFormattedNameWithIndex(
change.path,
change.change.op,
taskKey,
"Task",
),
);
}
return result;
},
func: modifyTasks,
},
},
{
id: "prerequisites",
name: "Prerequisite",
condition: picomatch("preRequisites-CUSTOM"),
logic: {
type: LogicType.Simple,
applyOnce: false,
func: modifyPrerequisites,
},
},
{
id: "general",
name: "General Changes",
condition: picomatch("**/*"),
logic: {
type: LogicType.Simple,
applyOnce: false,
formattedName: (path, op) => `'${path.at(-1)}' ${formatOp(op)}`,
func: modifyGeneral,
},
},
] as Parser[];