From 95ec60461049fc75d0e73d6a9efaf5b4d5e05a67 Mon Sep 17 00:00:00 2001 From: Manos Kalaitzoglou Date: Sun, 2 Aug 2026 13:03:10 +0300 Subject: [PATCH 01/51] feat: integrate component and board variables --- CSharp/Interpreter/ArcscriptFunctions.cs | 10 +- CSharp/Interpreter/ArcscriptParserBase.cs | 16 +- CSharp/Interpreter/ArcscriptState.cs | 17 +- CSharp/Interpreter/INodes/IAttribute.cs | 20 ++- CSharp/Interpreter/INodes/IBoard.cs | 2 +- CSharp/Interpreter/INodes/IComponent.cs | 2 +- CSharp/Interpreter/INodes/IProject.cs | 3 + CSharp/Project/Board.cs | 10 +- CSharp/Project/Component.cs | 19 +- CSharp/Project/Project.cs | 21 ++- CSharp/Project/Variable.cs | 10 +- CSharp/UnitTests.cs | 127 ++++++++++++- CSharp/__tests__/member.json | 8 +- Cpp/demo/ArcscriptTest.cpp | 169 ++++++++++++++++++ Cpp/src/ArcscriptHelpers.h | 16 +- Cpp/src/ArcscriptTranspiler.cpp | 19 ++ Cpp/src/ArcscriptTranspiler.h | 10 ++ TypeScript/src/ArcscriptState.ts | 14 +- .../src/Generated/ArcscriptParserBase.ts | 3 +- .../src/__tests__/antlr4.interpreter.test.ts | 76 +++++++- TypeScript/src/antlr4.interpreter.ts | 5 +- TypeScript/src/scope.ts | 3 + 22 files changed, 509 insertions(+), 71 deletions(-) create mode 100644 TypeScript/src/scope.ts diff --git a/CSharp/Interpreter/ArcscriptFunctions.cs b/CSharp/Interpreter/ArcscriptFunctions.cs index cfae923..ddf54bf 100644 --- a/CSharp/Interpreter/ArcscriptFunctions.cs +++ b/CSharp/Interpreter/ArcscriptFunctions.cs @@ -167,12 +167,12 @@ public object Reset(IList args) { } public object ResetAll(IList args) { - List variableNames = new List(); + HashSet variableIds = new HashSet(); foreach ( IVariable argument in args ) { - variableNames.Add(argument.Name); + variableIds.Add(argument.Id); } - foreach ( IVariable variable in this._project.Variables ) { - if ( !variableNames.Contains(variable.Name) ) { + foreach ( IVariable variable in this._project.GetAllVariables() ) { + if ( !variableIds.Contains(variable.Id) ) { state.SetVarValue(variable, variable.DefaultValue); } } @@ -212,4 +212,4 @@ public object ResetVisits(IList args) { return null; } } -} \ No newline at end of file +} diff --git a/CSharp/Interpreter/ArcscriptParserBase.cs b/CSharp/Interpreter/ArcscriptParserBase.cs index 5f06ba1..f8f9f91 100644 --- a/CSharp/Interpreter/ArcscriptParserBase.cs +++ b/CSharp/Interpreter/ArcscriptParserBase.cs @@ -26,17 +26,11 @@ public ArcscriptParserBase(ITokenStream input, TextWriter output, TextWriter err public void SetProject(IProject project) { this.Project = project; - foreach (var projectVariable in project.Variables) + foreach (var projectVariable in project.GetAllVariables()) { - VariableNames.Add(projectVariable.Name); - } - foreach (var projectBoard in project.Boards) - { - if (projectBoard.Variables == null) continue; - foreach (var projectBoardVariable in projectBoard.Variables) - { - VariableNames.Add(projectBoard.CustomId + "." + projectBoardVariable.Name); - } + VariableNames.Add(projectVariable.Parent == null + ? projectVariable.Name + : projectVariable.Parent.CustomId + "." + projectVariable.Name); } } @@ -126,4 +120,4 @@ internal void setLineStart(IToken token) } } -} \ No newline at end of file +} diff --git a/CSharp/Interpreter/ArcscriptState.cs b/CSharp/Interpreter/ArcscriptState.cs index edba11c..f474ec4 100644 --- a/CSharp/Interpreter/ArcscriptState.cs +++ b/CSharp/Interpreter/ArcscriptState.cs @@ -23,15 +23,8 @@ public ArcscriptState(string elementId, IProject project, System.Action? this.currentElement = elementId; this.project = project; - this.Variables = project.Variables.ToDictionary(variable => variable.Id, variable => variable); - foreach (var projectBoard in project.Boards) - { - if (projectBoard.Variables == null) continue; - foreach (var projectBoardVariable in projectBoard.Variables) - { - Variables.Add(projectBoardVariable.Id, projectBoardVariable); - } - } + this.Variables = project.GetAllVariables() + .ToDictionary(variable => variable.Id, variable => variable); if (emit != null) { @@ -50,7 +43,9 @@ public ArcscriptState(string elementId, IProject project, System.Action? { if (scope != null) { - return variable.Name == name && scope == variable.Parent.CustomId; + return variable.Name == name && + variable.Parent != null && + scope == variable.Parent.CustomId; } return variable.Name == name && variable.Parent == null; @@ -109,4 +104,4 @@ public void ResetVisits() _emit("resetVisits"); } } -} \ No newline at end of file +} diff --git a/CSharp/Interpreter/INodes/IAttribute.cs b/CSharp/Interpreter/INodes/IAttribute.cs index c4200d3..de46ca8 100644 --- a/CSharp/Interpreter/INodes/IAttribute.cs +++ b/CSharp/Interpreter/INodes/IAttribute.cs @@ -4,18 +4,22 @@ public interface IAttribute { public enum DataType { - Undefined, - StringPlainText, - StringRichText, - ComponentList, - AssetList, + Undefined = 0, + StringPlainText = 1, + StringRichText = 2, + ComponentList = 3, + AssetList = 4, + Boolean = 5, + Integer = 6, + Float = 7, } public enum ContainerType { - Undefined, - Component, - Element, + Undefined = 0, + Component = 1, + Element = 2, + Board = 3, } public string Name { get; } diff --git a/CSharp/Interpreter/INodes/IBoard.cs b/CSharp/Interpreter/INodes/IBoard.cs index f60fead..63a796f 100644 --- a/CSharp/Interpreter/INodes/IBoard.cs +++ b/CSharp/Interpreter/INodes/IBoard.cs @@ -1,6 +1,6 @@ namespace Arcweave.Interpreter.INodes { - public interface IBoard : IHasVariables + public interface IBoard : IHasVariables, IHasAttributes { } } diff --git a/CSharp/Interpreter/INodes/IComponent.cs b/CSharp/Interpreter/INodes/IComponent.cs index 8e4efe8..40b4702 100644 --- a/CSharp/Interpreter/INodes/IComponent.cs +++ b/CSharp/Interpreter/INodes/IComponent.cs @@ -1,6 +1,6 @@ namespace Arcweave.Interpreter.INodes { - public interface IComponent : IHasAttributes + public interface IComponent : IHasAttributes, IHasVariables { } } diff --git a/CSharp/Interpreter/INodes/IProject.cs b/CSharp/Interpreter/INodes/IProject.cs index ec66040..4760ea1 100644 --- a/CSharp/Interpreter/INodes/IProject.cs +++ b/CSharp/Interpreter/INodes/IProject.cs @@ -12,10 +12,13 @@ public interface IProject #if GODOT public Array Variables { get; } public Dictionary Boards { get; } + public Dictionary Components { get; } #else public List Variables { get; } public List Boards { get; } + public List Components { get; } #endif + public System.Collections.Generic.IEnumerable GetAllVariables(); public Arcweave.Project.Element ElementWithId(string id); public Arcweave.Project.Variable GetVariable(string name, string scope = null); diff --git a/CSharp/Project/Board.cs b/CSharp/Project/Board.cs index ff62618..10c3e6d 100644 --- a/CSharp/Project/Board.cs +++ b/CSharp/Project/Board.cs @@ -13,6 +13,7 @@ public partial class Board #else public List Nodes { get; private set; } public List Variables { get; private set; } + public List Attributes { get; private set; } #endif public Board(string id, List nodes, string customId = null) { @@ -20,6 +21,7 @@ public Board(string id, List nodes, string customId = null) Nodes = nodes; CustomId = customId; Variables = new List(); + Attributes = new List(); } public Board(string id, List nodes, List variables, string customId = null) @@ -32,6 +34,7 @@ public Board(string id, List nodes, List variables, string cust } Variables = variables; CustomId = customId; + Attributes = new List(); } public T NodeWithID(string id) where T : INode => Nodes.OfType().FirstOrDefault(x => x.Id == id); @@ -41,5 +44,10 @@ public void AddVariable(Variable variable) variable.Parent = this; Variables.Add(variable); } + + public void AddAttribute(Attribute attribute) + { + Attributes.Add(attribute); + } } -} \ No newline at end of file +} diff --git a/CSharp/Project/Component.cs b/CSharp/Project/Component.cs index 5ad89c3..e5692e8 100644 --- a/CSharp/Project/Component.cs +++ b/CSharp/Project/Component.cs @@ -5,11 +5,26 @@ namespace Arcweave.Project { public partial class Component { + public string CustomId { get; } public List Attributes { get; } + public List Variables { get; } + + public Component(string customId = null) + { + CustomId = customId; + Attributes = new List(); + Variables = new List(); + } + public void AddAttribute(Attribute attribute) { - throw new NotImplementedException(); + Attributes.Add(attribute); + } + + public void AddVariable(Variable variable) + { + variable.Parent = this; + Variables.Add(variable); } } } - diff --git a/CSharp/Project/Project.cs b/CSharp/Project/Project.cs index d5dcdb8..5da0f57 100644 --- a/CSharp/Project/Project.cs +++ b/CSharp/Project/Project.cs @@ -8,10 +8,12 @@ public partial class Project { public List Variables { get; } public List Boards { get; } - public Project(List boards, List variables) + public List Components { get; } + public Project(List boards, List variables, List components = null) { Variables = variables; Boards = boards; + Components = components ?? new List(); } public Element ElementWithId(string id) => GetNodeWithID(id); @@ -21,13 +23,17 @@ public Variable GetVariable(string name, string scope = null) { return Variables.FirstOrDefault(variable => variable.Name == name); } - var board = Boards.FirstOrDefault(board => board.Id == scope); - if (board != null) - { - return board.Variables.FirstOrDefault(variable => variable.Name == name); - } - return null; + var container = Boards.Cast() + .Concat(Components) + .FirstOrDefault(container => container.CustomId == scope); + return container?.Variables.FirstOrDefault(variable => variable.Name == name); } + + public IEnumerable GetAllVariables() => Variables + .Concat(Boards.SelectMany(board => + board.Variables ?? Enumerable.Empty())) + .Concat(Components.SelectMany(component => + component.Variables ?? Enumerable.Empty())); public T GetNodeWithID(string id) where T : INode { T result = default(T); @@ -39,4 +45,3 @@ public T GetNodeWithID(string id) where T : INode { } } } - diff --git a/CSharp/Project/Variable.cs b/CSharp/Project/Variable.cs index f44e5fc..4873660 100644 --- a/CSharp/Project/Variable.cs +++ b/CSharp/Project/Variable.cs @@ -15,6 +15,10 @@ public partial class Variable public object DefaultValue => _defaultValue; public Variable(string id, string name, object value) { + if (value == null) + { + throw new ArgumentNullException(nameof(value), "Variable value cannot be null."); + } Id = id; Name = name; Value = value; @@ -22,12 +26,8 @@ public Variable(string id, string name, object value) Type = value.GetType(); } public Variable(string id, string name, object value, IHasVariables parent) + : this(id, name, value) { - Id = id; - Name = name; - Value = value; - _defaultValue = value; - Type = value.GetType(); Parent = parent; } public void ResetToDefaultValue() diff --git a/CSharp/UnitTests.cs b/CSharp/UnitTests.cs index 09f29d2..3287dfe 100644 --- a/CSharp/UnitTests.cs +++ b/CSharp/UnitTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using NUnit.Framework; using Newtonsoft.Json; using Arcweave.Interpreter; @@ -17,6 +18,7 @@ private static Project.Project CreateProject(TestFile testFile, TestCase testCas { var variables = new Dictionary(); var boards = new Dictionary(); + var components = new Dictionary(); foreach (var variable in testFile.initialVars.Values) { Variable v; @@ -41,6 +43,14 @@ private static Project.Project CreateProject(TestFile testFile, TestCase testCas } boards[variable.scope].AddVariable(v); } + else if (variable.cType == "components") + { + if (!components.ContainsKey(variable.scope)) + { + components[variable.scope] = new Component(variable.scope); + } + components[variable.scope].AddVariable(v); + } } if (testCase.values != null) @@ -79,7 +89,11 @@ private static Project.Project CreateProject(TestFile testFile, TestCase testCas var board = new Board("testBoardId", nodes); boards["testBoardId"] = board; - var project = new Project.Project(boards.Values.ToList(), variables.Values.ToList()); + var project = new Project.Project( + boards.Values.ToList(), + variables.Values.ToList(), + components.Values.ToList() + ); return project; } @@ -280,6 +294,115 @@ public void MemberTests(Project.Project project, TestCase testCase, Dictionary(), "board"); + board.AddVariable(new Variable("board-health", "health", 50)); + var component = new Component("component"); + component.AddVariable(new Variable("component-health", "health", 25)); + var project = new Project.Project( + new List { board }, + new List { global }, + new List { component } + ); + + var output = new AwInterpreter(project).RunScript( + "
show(health, \" \" , board.health, \" \" , component.health)
" + + "
health = 101
" + + "
board.health = 51
" + + "
component.health = 26
" + ); + + Assert.That(output.Output, Is.EqualTo("

100 50 25

")); + Assert.That(output.Changes, Is.EqualTo(new Dictionary + { + ["global-health"] = 101, + ["board-health"] = 51, + ["component-health"] = 26, + })); + } + + [Test] + public void ResetAndResetAllCoverBoardAndComponentVariablesById() + { + var global = new Variable("global-health", "health", 100); + var board = new Board("board-id", new List(), "board"); + board.AddVariable(new Variable("board-health", "health", 50)); + var component = new Component("component"); + component.AddVariable(new Variable("component-health", "health", 25)); + var project = new Project.Project( + new List { board }, + new List { global }, + new List { component } + ); + + var resetOutput = new AwInterpreter(project).RunScript( + "
reset(board.health, component.health)
" + ); + Assert.That(resetOutput.Changes, Is.EqualTo(new Dictionary + { + ["board-health"] = 50, + ["component-health"] = 25, + })); + + var resetAllOutput = new AwInterpreter(project).RunScript( + "
resetAll(component.health)
" + ); + Assert.That(resetAllOutput.Changes, Is.EqualTo(new Dictionary + { + ["global-health"] = 100, + ["board-health"] = 50, + })); + } + + [Test] + public void VariableRejectsNullValue() + { + Assert.Throws(() => new Variable("id", "name", null)); + } + + [Test] + public void AttributeEnumValuesRemainSerializationCompatible() + { + Assert.That((int)IAttribute.DataType.Undefined, Is.EqualTo(0)); + Assert.That((int)IAttribute.DataType.StringPlainText, Is.EqualTo(1)); + Assert.That((int)IAttribute.DataType.StringRichText, Is.EqualTo(2)); + Assert.That((int)IAttribute.DataType.ComponentList, Is.EqualTo(3)); + Assert.That((int)IAttribute.DataType.AssetList, Is.EqualTo(4)); + Assert.That((int)IAttribute.DataType.Boolean, Is.EqualTo(5)); + Assert.That((int)IAttribute.DataType.Integer, Is.EqualTo(6)); + Assert.That((int)IAttribute.DataType.Float, Is.EqualTo(7)); + + Assert.That((int)IAttribute.ContainerType.Undefined, Is.EqualTo(0)); + Assert.That((int)IAttribute.ContainerType.Component, Is.EqualTo(1)); + Assert.That((int)IAttribute.ContainerType.Element, Is.EqualTo(2)); + Assert.That((int)IAttribute.ContainerType.Board, Is.EqualTo(3)); + } + + [Test] + public void NullContainerVariableCollectionsAreTreatedAsEmpty() + { + var global = new Variable("global", "global", 1); + var board = new Board("board-id", new List(), "board"); + var component = new Component("component"); + + typeof(Board) + .GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(board, null); + typeof(Component) + .GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(component, null); + + var project = new Project.Project( + new List { board }, + new List { global }, + new List { component } + ); + + Assert.That(project.GetAllVariables(), Is.EqualTo(new[] { global })); + } } } - diff --git a/CSharp/__tests__/member.json b/CSharp/__tests__/member.json index 921379f..4eea97b 100644 --- a/CSharp/__tests__/member.json +++ b/CSharp/__tests__/member.json @@ -69,8 +69,8 @@ "name": "a", "type": "string", "defaultValue": "Component Text", - "cType": "boards", - "scope": "board2" + "cType": "components", + "scope": "comp1" } }, "cases": [ @@ -112,10 +112,10 @@ } }, { - "code": "
board2.a = \"Hello World\"
", + "code": "
comp1.a = \"Hello World\"
", "changes": { "var11": "Hello World" } } ] -} \ No newline at end of file +} diff --git a/Cpp/demo/ArcscriptTest.cpp b/Cpp/demo/ArcscriptTest.cpp index 00e8f20..a623296 100644 --- a/Cpp/demo/ArcscriptTest.cpp +++ b/Cpp/demo/ArcscriptTest.cpp @@ -28,6 +28,7 @@ UVariable* getInitialVars(json initialVarsJson) { initVars[i].id = strdup(id.c_str()); initVars[i].name = strdup(name.c_str()); + initVars[i].has_default_value = true; if (it.value().contains("scope")) { initVars[i].scope = strdup(it.value()["scope"].get().c_str()); } @@ -47,15 +48,19 @@ UVariable* getInitialVars(json initialVarsJson) { if (initVars[i].type == VariableType::AW_STRING) { initVars[i].string_val = strdup(it.value()["value"].get().c_str()); + initVars[i].default_string_val = strdup(it.value()["value"].get().c_str()); } else if (initVars[i].type == VariableType::AW_INTEGER) { initVars[i].int_val = it.value()["value"].get(); + initVars[i].default_int_val = it.value()["value"].get(); } else if (initVars[i].type == VariableType::AW_DOUBLE) { initVars[i].double_val= it.value()["value"].get(); + initVars[i].default_double_val = it.value()["value"].get(); } else if (initVars[i].type == VariableType::AW_BOOLEAN) { initVars[i].bool_val = it.value()["value"].get(); + initVars[i].default_bool_val = it.value()["value"].get(); } i += 1; } @@ -320,6 +325,7 @@ int testFile(const std::filesystem::path& path, int testIndex = -1) { free(const_cast(initVars[j].name)); if (initVars[j].type == VariableType::AW_STRING) { free(const_cast(initVars[j].string_val)); + free(const_cast(initVars[j].default_string_val)); } } delete[] initVars; @@ -331,6 +337,162 @@ int testFile(const std::filesystem::path& path, int testIndex = -1) { return 0; } +int testResetAcrossInvocations(const char* resetScript) { + UVariable variable; + variable.id = "score-id"; + variable.name = "score"; + variable.type = VariableType::AW_INTEGER; + variable.int_val = 10; + variable.default_int_val = 10; + variable.has_default_value = true; + + UTranspilerOutput* assignment = runScriptExport( + "
score = 25
", + "TestElement", + &variable, + 1, + nullptr, + 0, + onEvent + ); + variable.int_val = assignment->changes[0].int_result; + deallocateOutput(assignment); + + UTranspilerOutput* reset = runScriptExport( + resetScript, + "TestElement", + &variable, + 1, + nullptr, + 0, + onEvent + ); + const bool passed = reset->changesLen == 1 && + reset->changes[0].type == VariableType::AW_INTEGER && + reset->changes[0].int_result == 10; + deallocateOutput(reset); + + if (!passed) { + std::cout << "Two-invocation reset failed for script: " << resetScript << std::endl; + return 1; + } + return 0; +} + +int testMissingDefaultRejected() { + UVariable variable; + variable.id = "score-id"; + variable.name = "score"; + variable.type = VariableType::AW_INTEGER; + variable.int_val = 10; + + try { + UTranspilerOutput* result = runScriptExport( + "
show(score)
", + "TestElement", + &variable, + 1, + nullptr, + 0, + onEvent + ); + deallocateOutput(result); + } catch (RuntimeErrorException &e) { + return std::string(e.what()) == "Variable score-id is missing a default value." ? 0 : 1; + } + + return 1; +} + +int testNullStringDefaultRejected() { + UVariable variable; + variable.id = "name-id"; + variable.name = "name"; + variable.type = VariableType::AW_STRING; + variable.string_val = "current"; + variable.has_default_value = true; + + try { + UTranspilerOutput* result = runScriptExport( + "
show(name)
", + "TestElement", + &variable, + 1, + nullptr, + 0, + onEvent + ); + deallocateOutput(result); + } catch (RuntimeErrorException &e) { + return std::string(e.what()) == + "String variable name-id must have current and default values." ? 0 : 1; + } + + return 1; +} + +int testNativeMissingCurrentRejected() { + Variable variable; + variable.id = "score-id"; + variable.name = "score"; + variable.type = VariableType::AW_INTEGER; + variable.defaultValue = 10; + + try { + ArcscriptTranspiler transpiler( + "TestElement", + {{ variable.id, variable }}, + {}, + onEvent + ); + } catch (RuntimeErrorException &e) { + return std::string(e.what()) == "Variable score-id is missing a current value." ? 0 : 1; + } + + return 1; +} + +int testNativeMissingDefaultRejected() { + Variable variable; + variable.id = "score-id"; + variable.name = "score"; + variable.type = VariableType::AW_INTEGER; + variable.value = 10; + + try { + ArcscriptTranspiler transpiler( + "TestElement", + {{ variable.id, variable }}, + {}, + onEvent + ); + } catch (RuntimeErrorException &e) { + return std::string(e.what()) == "Variable score-id is missing a default value." ? 0 : 1; + } + + return 1; +} + +int testLegacyAggregatePreservesScopeAndRejectsMissingDefault() { + Variable variable{"score-id", "score", VariableType::AW_INTEGER, 10, "board"}; + if (variable.scope != "board") { + return 1; + } + + try { + ArcscriptTranspiler transpiler( + "TestElement", + {{ variable.id, variable }}, + {}, + onEvent + ); + } catch (RuntimeErrorException &e) { + return std::string(e.what()) == "Variable score-id is missing a default value." ? 0 : 1; + } + + return 1; +} + int main(int argc, char* argv[]) @@ -355,6 +517,13 @@ int main(int argc, char* argv[]) hasErrors = true; } } + hasErrors = testResetAcrossInvocations("
reset(score)
") != 0 || hasErrors; + hasErrors = testResetAcrossInvocations("
resetAll()
") != 0 || hasErrors; + hasErrors = testMissingDefaultRejected() != 0 || hasErrors; + hasErrors = testNullStringDefaultRejected() != 0 || hasErrors; + hasErrors = testNativeMissingCurrentRejected() != 0 || hasErrors; + hasErrors = testNativeMissingDefaultRejected() != 0 || hasErrors; + hasErrors = testLegacyAggregatePreservesScopeAndRejectsMissingDefault() != 0 || hasErrors; if (hasErrors) { return 1; } diff --git a/Cpp/src/ArcscriptHelpers.h b/Cpp/src/ArcscriptHelpers.h index fcbfde6..77c42c0 100755 --- a/Cpp/src/ArcscriptHelpers.h +++ b/Cpp/src/ArcscriptHelpers.h @@ -8,6 +8,7 @@ #include #include "ArcscriptOutputs.h" +#include "ArcscriptErrorExceptions.h" namespace Arcweave { @@ -30,6 +31,7 @@ struct Variable { VariableType type; std::any value; std::string scope; + std::any defaultValue; }; class ArcscriptState { @@ -47,6 +49,16 @@ class ArcscriptState { currentElement = std::move(elementId); variableValues = std::move(varValues); for(const auto&[varId, variable] : variableValues) { + if (!variable.value.has_value()) { + throw RuntimeErrorException( + "Variable " + variable.id + " is missing a current value." + ); + } + if (!variable.defaultValue.has_value()) { + throw RuntimeErrorException( + "Variable " + variable.id + " is missing a default value." + ); + } std::string name = variable.name; if (variable.scope != "") { name = variable.scope + "." + variable.name; @@ -83,7 +95,7 @@ class ArcscriptState { inline void resetVars(std::vector vars) { for (Variable var : vars) { - variableChanges[var.id] = var.value; + variableChanges[var.id] = var.defaultValue; } } @@ -96,7 +108,7 @@ class ArcscriptState { while (it != variableValues.end()) { if (exceptVariableIds.find(it->first) == exceptVariableIds.end()) { // not in except vars - variableChanges[it->first] = it->second.value; + variableChanges[it->first] = it->second.defaultValue; } it++; } diff --git a/Cpp/src/ArcscriptTranspiler.cpp b/Cpp/src/ArcscriptTranspiler.cpp index 1d0a091..8346243 100755 --- a/Cpp/src/ArcscriptTranspiler.cpp +++ b/Cpp/src/ArcscriptTranspiler.cpp @@ -71,6 +71,21 @@ UTranspilerOutput* runScriptExport(const char* code, const char* elId, UVariable std::map initVars; for (size_t i = 0; i < varLength; i++) { + if (!variables[i].has_default_value) { + throw RuntimeErrorException( + "Variable " + std::string(variables[i].id) + " is missing a default value." + ); + } + if ( + variables[i].type == VariableType::AW_STRING && + (variables[i].string_val == nullptr || variables[i].default_string_val == nullptr) + ) { + throw RuntimeErrorException( + "String variable " + std::string(variables[i].id) + + " must have current and default values." + ); + } + Variable var; var.id = std::string(variables[i].id); var.name = std::string(variables[i].name); @@ -82,15 +97,19 @@ UTranspilerOutput* runScriptExport(const char* code, const char* elId, UVariable if (var.type == VariableType::AW_STRING) { var.value = std::string(variables[i].string_val); + var.defaultValue = std::string(variables[i].default_string_val); } else if (var.type == VariableType::AW_INTEGER) { var.value = variables[i].int_val; + var.defaultValue = variables[i].default_int_val; } else if (var.type == VariableType::AW_DOUBLE) { var.value = variables[i].double_val; + var.defaultValue = variables[i].default_double_val; } else if (var.type == VariableType::AW_BOOLEAN) { var.value = variables[i].bool_val; + var.defaultValue = variables[i].default_bool_val; } initVars[variables[i].id] = var; } diff --git a/Cpp/src/ArcscriptTranspiler.h b/Cpp/src/ArcscriptTranspiler.h index 9291182..6bbec85 100755 --- a/Cpp/src/ArcscriptTranspiler.h +++ b/Cpp/src/ArcscriptTranspiler.h @@ -103,6 +103,11 @@ namespace Arcweave double double_val; const char* string_val; bool bool_val; + int default_int_val; + double default_double_val; + const char* default_string_val; + bool default_bool_val; + bool has_default_value; const char* scope; UVariable() { @@ -113,6 +118,11 @@ namespace Arcweave double_val = 0; string_val = nullptr; bool_val = false; + default_int_val = 0; + default_double_val = 0; + default_string_val = nullptr; + default_bool_val = false; + has_default_value = false; scope = nullptr; } }; diff --git a/TypeScript/src/ArcscriptState.ts b/TypeScript/src/ArcscriptState.ts index 0169f43..35a1779 100644 --- a/TypeScript/src/ArcscriptState.ts +++ b/TypeScript/src/ArcscriptState.ts @@ -1,5 +1,6 @@ import { ArcscriptStateDef, VarDef, VarValue } from './types.js'; import ArcscriptVariable from './ArcscriptVariable.js'; +import { isGlobalScope } from './scope.js'; function hasProperty(obj: T, prop: keyof T): boolean { if (Object.hasOwn) { @@ -26,6 +27,12 @@ function validateVarDef(variableId: string, varDef: VarDef) { if (!hasProperty(varDef, 'defaultValue')) { throw new Error(`Variable ${varDef.id} is missing defaultValue property`); } + if (varDef.defaultValue === null) { + throw new Error(`Variable ${varDef.id} has null defaultValue property`); + } + if (hasProperty(varDef, 'value') && varDef.value === null) { + throw new Error(`Variable ${varDef.id} has null value property`); + } if ( hasProperty(varDef, 'scope') && varDef.scope !== undefined && @@ -34,6 +41,9 @@ function validateVarDef(variableId: string, varDef: VarDef) { ) { throw new Error(`Variable ${varDef.id} has invalid scope property`); } + if (varDef.scope === '') { + throw new Error(`Variable ${varDef.id} has empty scope property`); + } } function validateStateDef(stateDef: ArcscriptStateDef) { @@ -103,10 +113,10 @@ export default class ArcscriptState { getVar(name: string, scope: string | null = null): ArcscriptVariable { const variable = Object.values(this.variables).find(v => { - if (scope) { + if (scope !== null) { return v.name === name && v.scope === scope; } - return v.name === name; + return v.name === name && isGlobalScope(v.scope); }); if (!variable) { throw new Error(`Variable ${name} not found`); diff --git a/TypeScript/src/Generated/ArcscriptParserBase.ts b/TypeScript/src/Generated/ArcscriptParserBase.ts index df5e327..ee6fd66 100644 --- a/TypeScript/src/Generated/ArcscriptParserBase.ts +++ b/TypeScript/src/Generated/ArcscriptParserBase.ts @@ -6,6 +6,7 @@ import ArcscriptParser, { Mention_attributesContext, } from './ArcscriptParser.js'; import { ArcscriptStateDef } from '../types.js'; +import { isGlobalScope } from '../scope.js'; type ArcscriptParserOptions = { arcscriptVariables: ArcscriptStateDef; @@ -60,7 +61,7 @@ export default class ArcscriptParserBase extends Parser { setOptions(options: ArcscriptParserOptions) { const variableNames = Object.values(options.arcscriptVariables).map( variable => { - if (variable.scope) { + if (!isGlobalScope(variable.scope)) { return `${variable.scope}.${variable.name}`; } return variable.name; diff --git a/TypeScript/src/__tests__/antlr4.interpreter.test.ts b/TypeScript/src/__tests__/antlr4.interpreter.test.ts index fa27e82..e4d9972 100644 --- a/TypeScript/src/__tests__/antlr4.interpreter.test.ts +++ b/TypeScript/src/__tests__/antlr4.interpreter.test.ts @@ -105,6 +105,78 @@ describe('Object members variables', () => { ); }); +describe('Global and scoped variables with matching names', () => { + const initialVars: ArcscriptStateDef = { + scopedHealth: { + id: 'scopedHealth', + name: 'health', + type: 'integer', + defaultValue: 10, + scope: 'component', + }, + globalHealth: { + id: 'globalHealth', + name: 'health', + type: 'integer', + defaultValue: 20, + scope: 'global', + }, + }; + + test('unqualified lookup ignores an earlier scoped variable', () => { + const interpreter = new Interpreter({ state: initialVars }); + const { changes, output } = interpreter.runScript( + '
show(health, " ", component.health)
health = 21
component.health = 11
' + ); + + expect(output).toBe('

20 10

'); + expect(changes).toEqual({ + globalHealth: 21, + scopedHealth: 11, + }); + }); +}); + +describe('Variable state validation', () => { + test.each([ + ['defaultValue', { defaultValue: null }], + ['value', { defaultValue: 1, value: null }], + ])('rejects a null %s', (_property, values) => { + const interpreter = new Interpreter({ + state: { + variable: { + id: 'variable', + name: 'variable', + type: 'integer', + ...values, + }, + } as unknown as ArcscriptStateDef, + }); + + expect(() => + interpreter.runScript('
show(variable)
') + ).toThrow(`Variable variable has null ${_property} property`); + }); + + test('rejects an empty scope', () => { + const interpreter = new Interpreter({ + state: { + variable: { + id: 'variable', + name: 'variable', + type: 'integer', + defaultValue: 1, + scope: '', + }, + }, + }); + + expect(() => + interpreter.runScript('
show(variable)
') + ).toThrow('Variable variable has empty scope property'); + }); +}); + describe('Interprete string test scripts', () => { test.each(stringTests.cases as unknown as TestCase[])( 'Tests script: $code', @@ -219,9 +291,7 @@ describe('Replace scopes', () => { } ); - expect(result).toBe( - '
comp2.x = boardTwo.xyz + x
' - ); + expect(result).toBe('
comp2.x = boardTwo.xyz + x
'); }); }); diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index 2aaf52e..1774fa1 100644 --- a/TypeScript/src/antlr4.interpreter.ts +++ b/TypeScript/src/antlr4.interpreter.ts @@ -6,6 +6,7 @@ import ArcscriptVisitor from './ArcscriptVisitor.js'; import ErrorListener from './ErrorListener.js'; import { ArcscriptStateDef, VarValue } from './types.js'; import ArcscriptState from './ArcscriptState.js'; +import { isGlobalScope } from './scope.js'; type ArcscriptInterpreterOptions = { state: ArcscriptStateDef; @@ -103,10 +104,6 @@ export default class Interpreter { const { tokenTypeNames, allTokens } = this.parseTokens(code); const tokenIdMap = new Map(); const stateVars = Object.values(this.arcscriptVariables); - const isGlobalScope = (scope: string | null | undefined) => { - return scope === undefined || scope === null || scope === 'global'; - }; - allTokens.forEach((token, index) => { if (tokenTypeNames[token.type] !== 'IDENTIFIER') { return; diff --git a/TypeScript/src/scope.ts b/TypeScript/src/scope.ts new file mode 100644 index 0000000..7a95df8 --- /dev/null +++ b/TypeScript/src/scope.ts @@ -0,0 +1,3 @@ +export function isGlobalScope(scope: string | null | undefined): boolean { + return scope === undefined || scope === null || scope === 'global'; +} From 0e69c0e3929732df88d3e512892c025d9a3f3024 Mon Sep 17 00:00:00 2001 From: Manos Kalaitzoglou Date: Sun, 2 Aug 2026 13:13:35 +0300 Subject: [PATCH 02/51] ci: fix TypeScript and Windows C++ workflows --- .github/workflows/TypeScript.yml | 6 ++++-- .github/workflows/cpp-build-test.yml | 16 ++++++++-------- .github/workflows/pkg.pr.new.yml | 6 ++++-- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/workflows/TypeScript.yml b/.github/workflows/TypeScript.yml index 5ffc0da..19a602a 100644 --- a/.github/workflows/TypeScript.yml +++ b/.github/workflows/TypeScript.yml @@ -31,11 +31,13 @@ jobs: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v4 - - run: corepack enable + - uses: pnpm/action-setup@v4 + with: + version: 10.12.2 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache-dependency-path: ./TypeScript/pnpm-lock.yaml cache: "pnpm" diff --git a/.github/workflows/cpp-build-test.yml b/.github/workflows/cpp-build-test.yml index 0538a65..acb0b51 100644 --- a/.github/workflows/cpp-build-test.yml +++ b/.github/workflows/cpp-build-test.yml @@ -38,16 +38,16 @@ jobs: run: sh ./generate.sh shell: bash - - name: Configure CMake - run: | - mkdir -p Cpp/build - cd Cpp/build - cmake .. -DWITH_TEST=true + - name: Configure CMake (Windows) + if: matrix.os == 'windows-latest' + run: cmake -S Cpp -B Cpp/build -G "Visual Studio 17 2022" -A x64 -DWITH_TEST=true + + - name: Configure CMake (macOS) + if: matrix.os == 'macos-latest' + run: cmake -S Cpp -B Cpp/build -DWITH_TEST=true - name: Build - run: | - cd Cpp/build - cmake --build . + run: cmake --build Cpp/build - name: Run tests run: | diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml index ec9b518..c9fd952 100644 --- a/.github/workflows/pkg.pr.new.yml +++ b/.github/workflows/pkg.pr.new.yml @@ -13,11 +13,13 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - run: corepack enable + - uses: pnpm/action-setup@v4 + with: + version: 10.12.2 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache-dependency-path: ./TypeScript/pnpm-lock.yaml cache: "pnpm" From 814897bf8ea593f060c916439e0f1bcdcaba005f Mon Sep 17 00:00:00 2001 From: Manos Kalaitzoglou Date: Sun, 2 Aug 2026 13:17:02 +0300 Subject: [PATCH 03/51] ci: pin C++ Windows runner --- .github/workflows/cpp-build-test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cpp-build-test.yml b/.github/workflows/cpp-build-test.yml index acb0b51..5328157 100644 --- a/.github/workflows/cpp-build-test.yml +++ b/.github/workflows/cpp-build-test.yml @@ -17,10 +17,10 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [macos-latest, windows-latest] + os: [macos-latest, windows-2022] fail-fast: false env: - DEMO_DIR: demo/${{ matrix.os == 'windows-latest' && 'Win32/Debug' || 'Mac' }} + DEMO_DIR: demo/${{ matrix.os == 'windows-2022' && 'Win32/Debug' || 'Mac' }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -30,7 +30,7 @@ jobs: cmake-version: "3.x" - name: Generate ANTLR4 files (Windows) - if: matrix.os == 'windows-latest' + if: matrix.os == 'windows-2022' run: .\generate.ps1 - name: Generate ANTLR4 files (macOS) @@ -39,7 +39,7 @@ jobs: shell: bash - name: Configure CMake (Windows) - if: matrix.os == 'windows-latest' + if: matrix.os == 'windows-2022' run: cmake -S Cpp -B Cpp/build -G "Visual Studio 17 2022" -A x64 -DWITH_TEST=true - name: Configure CMake (macOS) From ba9d49d3beb21babdd39d22341cf940d1bc37547 Mon Sep 17 00:00:00 2001 From: Manos Kalaitzoglou Date: Sun, 2 Aug 2026 16:40:11 +0300 Subject: [PATCH 04/51] ci: configure Java for ANTLR generation --- .github/workflows/cpp-build-test.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/cpp-build-test.yml b/.github/workflows/cpp-build-test.yml index 5328157..75b02f1 100644 --- a/.github/workflows/cpp-build-test.yml +++ b/.github/workflows/cpp-build-test.yml @@ -24,6 +24,11 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" - name: Setup cmake uses: jwlawson/actions-setup-cmake@v2 with: From a52e150e45064b08c34c7598c719be6e08b31303 Mon Sep 17 00:00:00 2001 From: Manos Kalaitzoglou Date: Sun, 9 Aug 2026 19:41:44 +0300 Subject: [PATCH 05/51] fix: tighten TypeScript variable scope validation --- TypeScript/src/ArcscriptState.ts | 5 +- .../src/__tests__/antlr4.interpreter.test.ts | 54 +++++++++++++++++-- TypeScript/src/scope.ts | 2 +- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/TypeScript/src/ArcscriptState.ts b/TypeScript/src/ArcscriptState.ts index 35a1779..31e0b2d 100644 --- a/TypeScript/src/ArcscriptState.ts +++ b/TypeScript/src/ArcscriptState.ts @@ -24,7 +24,10 @@ function validateVarDef(variableId: string, varDef: VarDef) { if (!hasProperty(varDef, 'type')) { throw new Error(`Variable ${varDef.id} is missing type property`); } - if (!hasProperty(varDef, 'defaultValue')) { + if ( + !hasProperty(varDef, 'defaultValue') || + varDef.defaultValue === undefined + ) { throw new Error(`Variable ${varDef.id} is missing defaultValue property`); } if (varDef.defaultValue === null) { diff --git a/TypeScript/src/__tests__/antlr4.interpreter.test.ts b/TypeScript/src/__tests__/antlr4.interpreter.test.ts index e4d9972..a1161d8 100644 --- a/TypeScript/src/__tests__/antlr4.interpreter.test.ts +++ b/TypeScript/src/__tests__/antlr4.interpreter.test.ts @@ -112,21 +112,20 @@ describe('Global and scoped variables with matching names', () => { name: 'health', type: 'integer', defaultValue: 10, - scope: 'component', + scope: 'global', }, globalHealth: { id: 'globalHealth', name: 'health', type: 'integer', defaultValue: 20, - scope: 'global', }, }; - test('unqualified lookup ignores an earlier scoped variable', () => { + test('unqualified lookup ignores an earlier variable scoped under the literal global', () => { const interpreter = new Interpreter({ state: initialVars }); const { changes, output } = interpreter.runScript( - '
show(health, " ", component.health)
health = 21
component.health = 11
' + '
show(health, " ", global.health)
health = 21
global.health = 11
' ); expect(output).toBe('

20 10

'); @@ -175,6 +174,23 @@ describe('Variable state validation', () => { interpreter.runScript('
show(variable)
') ).toThrow('Variable variable has empty scope property'); }); + + test('rejects an undefined defaultValue', () => { + const interpreter = new Interpreter({ + state: { + variable: { + id: 'variable', + name: 'variable', + type: 'integer', + defaultValue: undefined, + }, + } as unknown as ArcscriptStateDef, + }); + + expect(() => + interpreter.runScript('
show(variable)
') + ).toThrow('Variable variable is missing defaultValue property'); + }); }); describe('Interprete string test scripts', () => { @@ -261,6 +277,36 @@ describe('Replace variables', () => { expect(result).toStrictEqual(expectedResult); } ); + + test('distinguishes a scope named global from an unqualified global variable', () => { + const interpreter = new Interpreter({ + state: { + scopedHealth: { + id: 'scopedHealth', + name: 'health', + type: 'integer', + defaultValue: 10, + scope: 'global', + }, + globalHealth: { + id: 'globalHealth', + name: 'health', + type: 'integer', + defaultValue: 20, + }, + }, + }); + + const result = interpreter.replaceVariables( + '
health = global.health
', + { + scopedHealth: 'stamina', + globalHealth: 'energy', + } + ); + + expect(result).toBe('
energy = global.stamina
'); + }); }); describe('Replace scopes', () => { diff --git a/TypeScript/src/scope.ts b/TypeScript/src/scope.ts index 7a95df8..fca89c8 100644 --- a/TypeScript/src/scope.ts +++ b/TypeScript/src/scope.ts @@ -1,3 +1,3 @@ export function isGlobalScope(scope: string | null | undefined): boolean { - return scope === undefined || scope === null || scope === 'global'; + return scope === undefined || scope === null; } From 8eee475a0bb84a8784bbc8f828310d8f05fdf643 Mon Sep 17 00:00:00 2001 From: Manos Kalaitzoglou Date: Tue, 11 Aug 2026 01:10:23 +0300 Subject: [PATCH 06/51] ci: upload C++ build artifacts --- .github/workflows/cpp-build-test.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/cpp-build-test.yml b/.github/workflows/cpp-build-test.yml index 75b02f1..a27dd42 100644 --- a/.github/workflows/cpp-build-test.yml +++ b/.github/workflows/cpp-build-test.yml @@ -6,11 +6,13 @@ on: paths: - "Cpp/**" - "grammar/*.g4" + - ".github/workflows/cpp-build-test.yml" pull_request: branches: [main] paths: - "Cpp/**" - "grammar/*.g4" + - ".github/workflows/cpp-build-test.yml" jobs: build-and-test: @@ -59,3 +61,11 @@ jobs: cd Cpp/build/${{ env.DEMO_DIR }} ls ./ArcscriptTest + + - name: Upload native library artifact + uses: actions/upload-artifact@v4 + with: + name: arcscript-cpp-${{ matrix.os }}-${{ github.sha }} + path: Cpp/build/lib/** + if-no-files-found: error + retention-days: 14 From 59299dddd3b81150ac46fe565b598daf0f3e2e99 Mon Sep 17 00:00:00 2001 From: Manos Kalaitzoglou Date: Tue, 11 Aug 2026 01:15:34 +0300 Subject: [PATCH 07/51] ci: build release native artifacts --- .github/workflows/cpp-build-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cpp-build-test.yml b/.github/workflows/cpp-build-test.yml index a27dd42..1fd2c0e 100644 --- a/.github/workflows/cpp-build-test.yml +++ b/.github/workflows/cpp-build-test.yml @@ -22,7 +22,7 @@ jobs: os: [macos-latest, windows-2022] fail-fast: false env: - DEMO_DIR: demo/${{ matrix.os == 'windows-2022' && 'Win32/Debug' || 'Mac' }} + DEMO_DIR: demo/${{ matrix.os == 'windows-2022' && 'Win32/Release' || 'Mac' }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -51,10 +51,10 @@ jobs: - name: Configure CMake (macOS) if: matrix.os == 'macos-latest' - run: cmake -S Cpp -B Cpp/build -DWITH_TEST=true + run: cmake -S Cpp -B Cpp/build -DCMAKE_BUILD_TYPE=Release -DWITH_TEST=true - name: Build - run: cmake --build Cpp/build + run: cmake --build Cpp/build --config Release - name: Run tests run: | From 1450a938522bdc52773e5dec7f7e8b4f855963ca Mon Sep 17 00:00:00 2001 From: Manos Kalaitzoglou Date: Tue, 11 Aug 2026 01:19:12 +0300 Subject: [PATCH 08/51] fix(cpp): allow release test builds without PDB --- Cpp/CMakeLists.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Cpp/CMakeLists.txt b/Cpp/CMakeLists.txt index 7bf26c3..d437ecc 100755 --- a/Cpp/CMakeLists.txt +++ b/Cpp/CMakeLists.txt @@ -162,7 +162,6 @@ if (WITH_TEST) "$/${DLL_NAME}" "$/${LIBRARY_NAME}" "$/${PROJECT_NAME}.exp" - "$/${PROJECT_NAME}.pdb" ) else() set(ARCSCRIPT_TRANSPILER_LIB_FILES @@ -180,4 +179,4 @@ if (WITH_TEST) COMMAND ${CMAKE_COMMAND} -E copy ${ARCSCRIPT_TRANSPILER_LIB_FILES} "$" WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) -endif (WITH_TEST) \ No newline at end of file +endif (WITH_TEST) From 14ce33950016c53a7f3036718bbe2297f35666ab Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Wed, 19 Aug 2026 19:33:53 +0300 Subject: [PATCH 09/51] chore: remove dead code from interpreter.ts --- TypeScript/src/antlr4.interpreter.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index 1774fa1..32c251a 100644 --- a/TypeScript/src/antlr4.interpreter.ts +++ b/TypeScript/src/antlr4.interpreter.ts @@ -20,20 +20,16 @@ export default class Interpreter { state: ArcscriptState | null = null; elementVisits: Record; currentElement: string; - variableOffsets: { start: number; end: number }[]; emit: (event: string, data?: unknown) => void; constructor(options = {} as ArcscriptInterpreterOptions) { this.arcscriptVariables = options.state; this.elementVisits = options.elementVisits || {}; this.currentElement = options.currentElement || ''; - this.variableOffsets = []; this.emit = options.eventHandler || (() => {}); } runScript(code: string, varValues: Record = {}) { - this.variableOffsets = []; - this.state = new ArcscriptState( this.arcscriptVariables, this.elementVisits, @@ -76,7 +72,6 @@ export default class Interpreter { parser.removeErrorListeners(); parser.addErrorListener(errorListener); const tree = parser.input(); - // const visitor = new ArcscriptVisitor(); return { chars, From cc7dd0d4d0a630bf8b1818ea41be1ff5cbd0ccfb Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Wed, 19 Aug 2026 19:37:44 +0300 Subject: [PATCH 10/51] chore: simplify replaceVariables --- TypeScript/src/antlr4.interpreter.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index 32c251a..0c5bc80 100644 --- a/TypeScript/src/antlr4.interpreter.ts +++ b/TypeScript/src/antlr4.interpreter.ts @@ -136,15 +136,13 @@ export default class Interpreter { }); const variableTokens = allTokens.filter(token => tokenIdMap.has(token)); - const f = variableTokens + const replaceableTokens = variableTokens .filter(varToken => - tokenIdMap.has(varToken) - ? Object.keys(variables).includes(tokenIdMap.get(varToken)!) - : false + Object.hasOwn(variables, tokenIdMap.get(varToken)!) ) .sort((a, b) => b.start - a.start); let newCode = code; - f.forEach(varToken => { + replaceableTokens.forEach(varToken => { const { start } = varToken; const end = start + varToken.text.length; const replace = variables[tokenIdMap.get(varToken)!]; From d0ccccc085b799ca4b942e527a6a53e1dc96f2b3 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Wed, 19 Aug 2026 19:41:40 +0300 Subject: [PATCH 11/51] fix: do not allow Interpreter instantiation without a state --- TypeScript/src/antlr4.interpreter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index 0c5bc80..893e8d5 100644 --- a/TypeScript/src/antlr4.interpreter.ts +++ b/TypeScript/src/antlr4.interpreter.ts @@ -22,7 +22,7 @@ export default class Interpreter { currentElement: string; emit: (event: string, data?: unknown) => void; - constructor(options = {} as ArcscriptInterpreterOptions) { + constructor(options: ArcscriptInterpreterOptions) { this.arcscriptVariables = options.state; this.elementVisits = options.elementVisits || {}; this.currentElement = options.currentElement || ''; From bab17e94a15cb8694d2c078aaa1f50f9e90413f4 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Wed, 19 Aug 2026 23:11:29 +0300 Subject: [PATCH 12/51] fix: restore public API for Interpreter --- TypeScript/src/antlr4.interpreter.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index 893e8d5..2e5d46d 100644 --- a/TypeScript/src/antlr4.interpreter.ts +++ b/TypeScript/src/antlr4.interpreter.ts @@ -22,11 +22,11 @@ export default class Interpreter { currentElement: string; emit: (event: string, data?: unknown) => void; - constructor(options: ArcscriptInterpreterOptions) { + constructor(options: ArcscriptInterpreterOptions = { state: {} }) { this.arcscriptVariables = options.state; - this.elementVisits = options.elementVisits || {}; - this.currentElement = options.currentElement || ''; - this.emit = options.eventHandler || (() => {}); + this.elementVisits = options.elementVisits ?? {}; + this.currentElement = options.currentElement ?? ''; + this.emit = options.eventHandler ?? (() => {}); } runScript(code: string, varValues: Record = {}) { From c1d36ffb9828c7256c55b6992d17132b328a339c Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Wed, 19 Aug 2026 23:18:01 +0300 Subject: [PATCH 13/51] refactor: abstract lexer creation --- TypeScript/src/antlr4.interpreter.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index 2e5d46d..1d1d40b 100644 --- a/TypeScript/src/antlr4.interpreter.ts +++ b/TypeScript/src/antlr4.interpreter.ts @@ -55,12 +55,18 @@ export default class Interpreter { }; } - parse(code: string) { + private createLexer(code: string) { const chars = new CharStream(code); const lexer = new ArcscriptLexer(chars); const errorListener = new ErrorListener(); lexer.removeErrorListeners(); lexer.addErrorListener(errorListener); + + return { chars, lexer, errorListener }; + } + + parse(code: string) { + const { chars, lexer, errorListener } = this.createLexer(code); const tokens = new antlr4.CommonTokenStream(lexer); const parser = new ArcscriptParser(tokens); parser.setOptions({ @@ -83,11 +89,7 @@ export default class Interpreter { } private parseTokens(code: string) { - const chars = new CharStream(code); - const lexer = new ArcscriptLexer(chars); - const errorListener = new ErrorListener(); - lexer.removeErrorListeners(); - lexer.addErrorListener(errorListener); + const { lexer } = this.createLexer(code); return { tokenTypeNames: lexer.getSymbolicNames(), From bb613539ef343695f002570218f248e26fda7715 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Wed, 19 Aug 2026 23:20:56 +0300 Subject: [PATCH 14/51] refactor: abstract replacement logic --- TypeScript/src/antlr4.interpreter.ts | 63 ++++++++++++++++++---------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index 1d1d40b..5449ae3 100644 --- a/TypeScript/src/antlr4.interpreter.ts +++ b/TypeScript/src/antlr4.interpreter.ts @@ -15,6 +15,12 @@ type ArcscriptInterpreterOptions = { eventHandler?: (event: string, data?: unknown) => void; }; +type SourceReplacement = { + start: number; + end: number; + text: string; +}; + export default class Interpreter { arcscriptVariables: ArcscriptStateDef; state: ArcscriptState | null = null; @@ -97,6 +103,21 @@ export default class Interpreter { }; } + private applyReplacements( + code: string, + replacements: SourceReplacement[] + ) { + return [...replacements] + .sort((a, b) => b.start - a.start) + .reduce( + (updatedCode, replacement) => + updatedCode.slice(0, replacement.start) + + replacement.text + + updatedCode.slice(replacement.end), + code + ); + } + replaceVariables(code: string, variables: Record) { const { tokenTypeNames, allTokens } = this.parseTokens(code); const tokenIdMap = new Map(); @@ -141,16 +162,16 @@ export default class Interpreter { const replaceableTokens = variableTokens .filter(varToken => Object.hasOwn(variables, tokenIdMap.get(varToken)!) - ) - .sort((a, b) => b.start - a.start); - let newCode = code; - replaceableTokens.forEach(varToken => { - const { start } = varToken; - const end = start + varToken.text.length; - const replace = variables[tokenIdMap.get(varToken)!]; - newCode = newCode.slice(0, start) + replace + newCode.slice(end); - }); - return newCode; + ); + + return this.applyReplacements( + code, + replaceableTokens.map(varToken => ({ + start: varToken.start, + end: varToken.start + varToken.text.length, + text: variables[tokenIdMap.get(varToken)!], + })) + ); } replaceScopes(code: string, scopes: Record) { @@ -181,18 +202,16 @@ export default class Interpreter { }) .filter(scopeToken => Object.prototype.hasOwnProperty.call(scopes, scopeToken.text) - ) - .sort((a, b) => b.start - a.start); - - let newCode = code; - targetScopeTokens.forEach(scopeToken => { - const start = scopeToken.start; - const end = start + scopeToken.text.length; - const replace = scopes[scopeToken.text]; - newCode = newCode.slice(0, start) + replace + newCode.slice(end); - }); - - return newCode; + ); + + return this.applyReplacements( + code, + targetScopeTokens.map(scopeToken => ({ + start: scopeToken.start, + end: scopeToken.start + scopeToken.text.length, + text: scopes[scopeToken.text], + })) + ); } replaceScope(code: string, scope: string, replacement: string) { From f29a43f379a836deeb79cd409188306837aa3366 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Wed, 19 Aug 2026 23:24:14 +0300 Subject: [PATCH 15/51] refactor: simplify replaceVariables --- TypeScript/src/antlr4.interpreter.ts | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index 5449ae3..f7ccad3 100644 --- a/TypeScript/src/antlr4.interpreter.ts +++ b/TypeScript/src/antlr4.interpreter.ts @@ -120,8 +120,9 @@ export default class Interpreter { replaceVariables(code: string, variables: Record) { const { tokenTypeNames, allTokens } = this.parseTokens(code); - const tokenIdMap = new Map(); const stateVars = Object.values(this.arcscriptVariables); + const replacements: SourceReplacement[] = []; + allTokens.forEach((token, index) => { if (tokenTypeNames[token.type] !== 'IDENTIFIER') { return; @@ -153,25 +154,16 @@ export default class Interpreter { ) ?? null; } - if (targetVar) { - tokenIdMap.set(token, targetVar.id); + if (targetVar && Object.hasOwn(variables, targetVar.id)) { + replacements.push({ + start: token.start, + end: token.start + token.text.length, + text: variables[targetVar.id], + }); } }); - const variableTokens = allTokens.filter(token => tokenIdMap.has(token)); - const replaceableTokens = variableTokens - .filter(varToken => - Object.hasOwn(variables, tokenIdMap.get(varToken)!) - ); - - return this.applyReplacements( - code, - replaceableTokens.map(varToken => ({ - start: varToken.start, - end: varToken.start + varToken.text.length, - text: variables[tokenIdMap.get(varToken)!], - })) - ); + return this.applyReplacements(code, replacements); } replaceScopes(code: string, scopes: Record) { From a1f492a63153e7f3597fc8947f3446f9f8826e96 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Wed, 19 Aug 2026 23:27:14 +0300 Subject: [PATCH 16/51] refactor: remove global state in runScript --- TypeScript/src/antlr4.interpreter.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index f7ccad3..b328d83 100644 --- a/TypeScript/src/antlr4.interpreter.ts +++ b/TypeScript/src/antlr4.interpreter.ts @@ -36,26 +36,25 @@ export default class Interpreter { } runScript(code: string, varValues: Record = {}) { - this.state = new ArcscriptState( + const state = new ArcscriptState( this.arcscriptVariables, this.elementVisits, this.currentElement, this.emit ); + this.state = state; const { tree } = this.parse(code); - const visitor = new ArcscriptVisitor(this.state); + const visitor = new ArcscriptVisitor(state); - this.state.setVarValues(varValues); + state.setVarValues(varValues); const result = tree.accept(visitor); - - let output = visitor.state.generateOutput(); - output = clearBlockStyle(output); + const output = clearBlockStyle(state.generateOutput()); return { - changes: visitor.state.getChanges(), + changes: state.getChanges(), output, result, }; From 027fa7b67bdadf1cb73f2b93f92a36ea0af29e07 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Wed, 19 Aug 2026 23:41:34 +0300 Subject: [PATCH 17/51] chore: Intepreter property clean ups --- TypeScript/src/antlr4.interpreter.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index b328d83..86cbad0 100644 --- a/TypeScript/src/antlr4.interpreter.ts +++ b/TypeScript/src/antlr4.interpreter.ts @@ -22,11 +22,10 @@ type SourceReplacement = { }; export default class Interpreter { - arcscriptVariables: ArcscriptStateDef; - state: ArcscriptState | null = null; - elementVisits: Record; - currentElement: string; - emit: (event: string, data?: unknown) => void; + private readonly arcscriptVariables: ArcscriptStateDef; + private readonly elementVisits: Record; + private readonly currentElement: string; + private readonly emit: (event: string, data?: unknown) => void; constructor(options: ArcscriptInterpreterOptions = { state: {} }) { this.arcscriptVariables = options.state; @@ -42,7 +41,6 @@ export default class Interpreter { this.currentElement, this.emit ); - this.state = state; const { tree } = this.parse(code); From b03d5b6bc3520c8ab53bbbb486660c42d352069a Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Wed, 19 Aug 2026 23:49:22 +0300 Subject: [PATCH 18/51] chore: re-arrange methods by visibility --- TypeScript/src/antlr4.interpreter.ts | 68 ++++++++++++++-------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index 86cbad0..ba8c823 100644 --- a/TypeScript/src/antlr4.interpreter.ts +++ b/TypeScript/src/antlr4.interpreter.ts @@ -58,16 +58,6 @@ export default class Interpreter { }; } - private createLexer(code: string) { - const chars = new CharStream(code); - const lexer = new ArcscriptLexer(chars); - const errorListener = new ErrorListener(); - lexer.removeErrorListeners(); - lexer.addErrorListener(errorListener); - - return { chars, lexer, errorListener }; - } - parse(code: string) { const { chars, lexer, errorListener } = this.createLexer(code); const tokens = new antlr4.CommonTokenStream(lexer); @@ -91,30 +81,6 @@ export default class Interpreter { }; } - private parseTokens(code: string) { - const { lexer } = this.createLexer(code); - - return { - tokenTypeNames: lexer.getSymbolicNames(), - allTokens: lexer.getAllTokens(), - }; - } - - private applyReplacements( - code: string, - replacements: SourceReplacement[] - ) { - return [...replacements] - .sort((a, b) => b.start - a.start) - .reduce( - (updatedCode, replacement) => - updatedCode.slice(0, replacement.start) + - replacement.text + - updatedCode.slice(replacement.end), - code - ); - } - replaceVariables(code: string, variables: Record) { const { tokenTypeNames, allTokens } = this.parseTokens(code); const stateVars = Object.values(this.arcscriptVariables); @@ -208,4 +174,38 @@ export default class Interpreter { [scope]: replacement, }); } + + private createLexer(code: string) { + const chars = new CharStream(code); + const lexer = new ArcscriptLexer(chars); + const errorListener = new ErrorListener(); + lexer.removeErrorListeners(); + lexer.addErrorListener(errorListener); + + return { chars, lexer, errorListener }; + } + + private parseTokens(code: string) { + const { lexer } = this.createLexer(code); + + return { + tokenTypeNames: lexer.getSymbolicNames(), + allTokens: lexer.getAllTokens(), + }; + } + + private applyReplacements( + code: string, + replacements: SourceReplacement[] + ) { + return [...replacements] + .sort((a, b) => b.start - a.start) + .reduce( + (updatedCode, replacement) => + updatedCode.slice(0, replacement.start) + + replacement.text + + updatedCode.slice(replacement.end), + code + ); + } } From 02ff1c40a02d9560d6036071357ec7cd6173dc68 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Wed, 19 Aug 2026 23:55:10 +0300 Subject: [PATCH 19/51] chore: privatize replaceScopes() --- .../src/__tests__/antlr4.interpreter.test.ts | 15 +++++++++------ TypeScript/src/antlr4.interpreter.ts | 14 +++++++------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/TypeScript/src/__tests__/antlr4.interpreter.test.ts b/TypeScript/src/__tests__/antlr4.interpreter.test.ts index a1161d8..8f55d96 100644 --- a/TypeScript/src/__tests__/antlr4.interpreter.test.ts +++ b/TypeScript/src/__tests__/antlr4.interpreter.test.ts @@ -325,16 +325,19 @@ describe('Replace scopes', () => { ); }); - test('supports replacing multiple scopes in one pass', () => { + test('supports replacing multiple scopes through the public API', () => { const interpreter = new Interpreter({ state: (replaceVariableTests as unknown as TestSuite).initialVars, }); - const result = interpreter.replaceScopes( + const componentScopeReplaced = interpreter.replaceScope( '
comp1.x = boardOne.xyz + x
', - { - comp1: 'comp2', - boardOne: 'boardTwo', - } + 'comp1', + 'comp2' + ); + const result = interpreter.replaceScope( + componentScopeReplaced, + 'boardOne', + 'boardTwo' ); expect(result).toBe('
comp2.x = boardTwo.xyz + x
'); diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index ba8c823..df30f16 100644 --- a/TypeScript/src/antlr4.interpreter.ts +++ b/TypeScript/src/antlr4.interpreter.ts @@ -129,7 +129,13 @@ export default class Interpreter { return this.applyReplacements(code, replacements); } - replaceScopes(code: string, scopes: Record) { + replaceScope(code: string, scope: string, replacement: string) { + return this.replaceScopes(code, { + [scope]: replacement, + }); + } + + private replaceScopes(code: string, scopes: Record) { const { tokenTypeNames, allTokens } = this.parseTokens(code); const stateVars = Object.values(this.arcscriptVariables); @@ -169,12 +175,6 @@ export default class Interpreter { ); } - replaceScope(code: string, scope: string, replacement: string) { - return this.replaceScopes(code, { - [scope]: replacement, - }); - } - private createLexer(code: string) { const chars = new CharStream(code); const lexer = new ArcscriptLexer(chars); From ca805abe672629362adf02f1485629b0ec6ff64a Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 00:00:39 +0300 Subject: [PATCH 20/51] fix: readme stale links --- README.md | 12 ++++++------ TypeScript/README.md | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8e82f34..56c4342 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Arcscript Interpreters -This repository includes the supported interpreters for **arcscript**, the scripting language of [Arcweave](https://arcweave.com), for [C++](Cpp), [C#](CSharp) and [JavaScript](JavaScript). +This repository includes the supported interpreters for **arcscript**, the scripting language of [Arcweave](https://arcweave.com), for [C++](Cpp), [C#](CSharp) and [TypeScript](TypeScript). The interpreters are built using [ANTLR 4](https://github.com/antlr/antlr4), a parser generator for reading, processing, executing, or translating structured text or binary files. @@ -8,7 +8,7 @@ The interpreters are built using [ANTLR 4](https://github.com/antlr/antlr4), a p ### Why a parser generator? -In order to support updating game data from Arcweave that use arcscript during runtime in game engines, we had to find a way to interpret it without having to rebuild the game. This lead us to use a parser generator. ANTLR 4 is a great option for that, since it can create parsers for multiple languages, including C++ (which we use for unreal), C# (for Unity and Godot) and JavaScript (for our frontend). +In order to support updating game data from Arcweave that use arcscript during runtime in game engines, we had to find a way to interpret it without having to rebuild the game. This lead us to use a parser generator. ANTLR 4 is a great option for that, since it can create parsers for multiple languages, including C++ (which we use for unreal), C# (for Unity and Godot) and TypeScript (for our frontend). ### Our Implementation @@ -22,7 +22,7 @@ In folder [`grammar`](grammar) you can find the files [`ArcscriptLexer.g4`](gram - Java JRE to run the antlr4 tool - python3 -In order to build all Parsers for C++, C# and JavaScript, run the [`generate.sh`](generate.sh) shell script. +In order to build all Parsers for C++, C# and TypeScript, run the [`generate.sh`](generate.sh) shell script. ```bash $ sh generate.sh @@ -36,11 +36,11 @@ This script: ## Languages -### JavaScript +### TypeScript -We are using the JavaScript parser to interpret arcscript in our arcweave app. We have also created a package, [`@arcweave/arcscript`](https://www.npmjs.com/package/@arcweave/arcscript) using the generated files in [JavaScript](JavaScript), along with a custom ANTLR Visitor to parse the generated Parse Tree from ANTLR. +We are using the TypeScript parser to interpret arcscript in our Arcweave app. We have also created a package, [`@arcweave/arcscript`](https://www.npmjs.com/package/@arcweave/arcscript), using the generated files in [TypeScript](TypeScript), along with a custom ANTLR Visitor to parse the generated Parse Tree from ANTLR. -You can find more info in [JavaScript](JavaScript) folder. +You can find more info in the [TypeScript](TypeScript) folder. ### C# diff --git a/TypeScript/README.md b/TypeScript/README.md index 59a903a..6ed2f02 100644 --- a/TypeScript/README.md +++ b/TypeScript/README.md @@ -1,11 +1,11 @@ ![NPM Version](https://img.shields.io/npm/v/%40arcweave%2Farcscript) -![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/arcweave/arcscript-interpreters/JavaScript.yml) +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/arcweave/arcscript-interpreters/TypeScript.yml) -# JS Arcscript Interpreter +# TypeScript Arcscript Interpreter ## Table of contents -- [JS Arcscript Interpreter](#js-arcscript-interpreter) +- [TypeScript Arcscript Interpreter](#typescript-arcscript-interpreter) - [Table of contents](#table-of-contents) - [Getting Started](#getting-started) - [Installation](#installation) @@ -54,7 +54,7 @@ TODO: Write API ## Contributing -Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. +To contribute to the TypeScript interpreter: 1. Fork it! 2. Create your feature branch: `git checkout -b my-new-feature` @@ -69,4 +69,4 @@ TODO: Write credits ## License -TODO: Write license \ No newline at end of file +TODO: Write license From 11e11d2bea904e81622f3971996883bbc43bb86c Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 00:45:05 +0300 Subject: [PATCH 21/51] chore: update typescript readme --- TypeScript/README.md | 253 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 213 insertions(+), 40 deletions(-) diff --git a/TypeScript/README.md b/TypeScript/README.md index 6ed2f02..3a413cf 100644 --- a/TypeScript/README.md +++ b/TypeScript/README.md @@ -3,70 +3,243 @@ # TypeScript Arcscript Interpreter -## Table of contents +`@arcweave/arcscript` parses and executes Arcscript embedded in the HTML generated by the Arcweave editor. It can evaluate conditions, update variable state, produce rendered HTML, and rewrite variable or scope references. -- [TypeScript Arcscript Interpreter](#typescript-arcscript-interpreter) - - [Table of contents](#table-of-contents) - - [Getting Started](#getting-started) - - [Installation](#installation) - - [Usage](#usage) - - [Building the app](#building-the-app) - - [Running the tests](#running-the-tests) - - [API](#api) - - [Contributing](#contributing) - - [Credits](#credits) - - [License](#license) +## Installation + +```sh +npm install @arcweave/arcscript +``` -## Getting Started +The package expects browser DOM APIs, including `document` and `DOMParser`. Node.js consumers must provide a compatible DOM implementation. -These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system. +## Quick Start -## Installation +Arcscript code must be provided as an HTML fragment containing `
` elements.
 
-To include `@arcweave/arcscript` in your project, you can use npm:
+```ts
+import { Interpreter } from '@arcweave/arcscript';
 
-```sh
-$ npm install -S @arcweave/arcscript
+const interpreter = new Interpreter({
+  state: {
+    health: {
+      id: 'health',
+      name: 'health',
+      type: 'integer',
+      defaultValue: 100,
+    },
+  },
+});
+
+const code = [
+  '
health -= 10
', + '
show(health)
', +].join(''); + +const { changes, output } = interpreter.runScript(code); + +console.log(changes); +// { health: 90 } + +console.log(output); +//

90

``` -## Usage +## Variable State + +The `state` option is a record keyed by variable ID. Each key must match the variable's `id` property. + +```ts +const interpreter = new Interpreter({ + state: { + playerName: { + id: 'playerName', + name: 'name', + type: 'string', + defaultValue: 'Ada', + }, + playerHealth: { + id: 'playerHealth', + name: 'health', + type: 'integer', + defaultValue: 100, + value: 75, + scope: 'player', + }, + }, +}); +``` -### Building the app +| Property | Required | Description | +| --- | --- | --- | +| `id` | Yes | Stable variable ID. It must match the containing record key. | +| `name` | Yes | Name referenced by Arcscript. | +| `type` | Yes | `string`, `integer`, `float`, or `boolean`. | +| `defaultValue` | Yes | Initial value and the value restored by `reset()`. | +| `value` | No | Current value that overrides `defaultValue` during initialization. | +| `scope` | No | Scope qualifier. Omitted, `undefined`, or `null` means unscoped/global. | + +Any string is treated as a named scope. In particular, `scope: "global"` defines a literal scope named `global`; it does not define an unscoped variable. + +### Scoped Variables + +```ts +const interpreter = new Interpreter({ + state: { + playerHealth: { + id: 'playerHealth', + name: 'health', + type: 'integer', + defaultValue: 100, + scope: 'player', + }, + }, +}); + +const { changes } = interpreter.runScript( + '
player.health -= 10
' +); + +console.log(changes); +// { playerHealth: 90 } +``` -```sh -$ pnpm install -$ pnpm run build +## Persisting Values Between Runs + +Each `runScript()` call creates fresh runtime state. Pass previous changes as the second argument when later runs should continue from those values. The following example uses the interpreter from Quick Start. + +```ts +const firstRun = interpreter.runScript( + '
health -= 10
' +); + +const secondRun = interpreter.runScript( + '
show(health)
', + firstRun.changes +); + +console.log(secondRun.output); +//

90

``` -This task will create a distribution version of the project -inside your local `dist/` folder +Values supplied as overrides are marked as changed and are included in the returned `changes` object. -### Running the tests +## Evaluating Conditions -```sh -$ pnpm test +When the input is a condition, the evaluation result is available through `result.condition`. The following example uses the interpreter from Quick Start. + +```ts +const { result } = interpreter.runScript( + '
health > 0
' +); + +console.log(result.condition); +// true ``` +## Error Handling + +The package exports `ParseError`, `RuntimeError`, and `FatalError`. + +```ts +import { + FatalError, + ParseError, + RuntimeError, +} from '@arcweave/arcscript'; + +try { + interpreter.runScript( + '
unknownVariable = 1
' + ); +} catch (error) { + if (error instanceof ParseError) { + console.error('Invalid Arcscript:', error.message); + } else if (error instanceof RuntimeError) { + console.error('Arcscript execution failed:', error.message); + } else if (error instanceof FatalError) { + console.error('Fatal interpreter error:', error.message); + } else { + throw error; + } +} +``` + +## Rewriting References + +Variable replacements are keyed by variable ID, not by Arcscript variable name. + +```ts +const code = '
player.health -= 10
'; + +const renamedVariable = interpreter.replaceVariables(code, { + playerHealth: 'hitPoints', +}); + +const renamedScope = interpreter.replaceScope( + renamedVariable, + 'player', + 'enemy' +); +``` + +Rewriting operates on Arcscript identifier tokens and does not replace matching text inside string literals. ## API -TODO: Write API +### `new Interpreter(options?)` -## Contributing +Creates an interpreter. Calling `new Interpreter()` is valid and creates an interpreter with an empty variable state. + +| Option | Description | +| --- | --- | +| `state` | Variable definitions keyed by ID. Required when an options object is supplied. | +| `elementVisits` | Visit counts used by the `visits()` function. | +| `currentElement` | Current element ID used by element-aware functions. | +| `eventHandler` | Callback for interpreter events, such as `resetVisits`. | + +### `runScript(code, values?)` + +Executes a script or evaluates a condition. + +It returns: + +- `changes`: variable values changed by assignments, resets, or supplied overrides. +- `output`: rendered HTML produced by the script. +- `result`: the visitor result. Conditions expose a boolean `condition` property. -To contribute to the TypeScript interpreter: +### `parse(code)` -1. Fork it! -2. Create your feature branch: `git checkout -b my-new-feature` -3. Add your changes: `git add .` -4. Commit your changes: `git commit -am 'Add some feature'` -5. Push to the branch: `git push origin my-new-feature` -6. Submit a pull request +Parses Arcscript and returns the ANTLR character stream, lexer, token stream, parser, and parse tree. This is a low-level API tied to the generated ANTLR implementation and may change between major versions. -## Credits +### `replaceVariables(code, replacements)` + +Replaces referenced variable names. The replacement record is keyed by variable ID. + +### `replaceScope(code, scope, replacement)` + +Replaces a scope qualifier for variables known to the interpreter state. + +## Development + +This project uses the version of pnpm declared in `package.json`. + +```sh +pnpm install +pnpm build +pnpm test +``` + +The build writes compiled files and declarations to `dist/`. + +## Contributing -TODO: Write credits +1. Fork the repository. +2. Create a feature branch. +3. Add or update tests for behavioral changes. +4. Run the build and test suite. +5. Open a pull request. ## License -TODO: Write license +ISC From dc0a5f923bf7834ebbd2a69a0ea00f2b167b4c70 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 07:50:20 +0300 Subject: [PATCH 22/51] chore: roll() cleanup --- TypeScript/src/ArcscriptFunctions.ts | 9 +-- .../src/__tests__/ArcscriptFunctions.test.ts | 62 +++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 TypeScript/src/__tests__/ArcscriptFunctions.test.ts diff --git a/TypeScript/src/ArcscriptFunctions.ts b/TypeScript/src/ArcscriptFunctions.ts index cb6d1c5..6e0974c 100644 --- a/TypeScript/src/ArcscriptFunctions.ts +++ b/TypeScript/src/ArcscriptFunctions.ts @@ -60,7 +60,7 @@ export default class ArcscriptFunctions { roll(...args: ArgumentTypes): number { // Default value for the number of rolls is 1 const maxRoll = args[0]; - const rolls = args[1] || 1; + const rolls = args[1] ?? 1; this.assertPositiveInteger('roll', maxRoll); this.assertPositiveInteger('roll', rolls); @@ -198,12 +198,7 @@ export default class ArcscriptFunctions { name: string, arg: VarValue | MentionResult | ArcscriptVariable ) { - if (typeof arg !== 'number' || Number.isNaN(arg)) { - throw new RuntimeError( - `Invalid argument ${arg} in function ${name}. Expected number (integer)` - ); - } - if (arg <= 0) { + if (typeof arg !== 'number' || !Number.isInteger(arg) || arg <= 0) { throw new RuntimeError( `Invalid argument ${arg} in function ${name}. Expected positive integer` ); diff --git a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts new file mode 100644 index 0000000..3f2efb4 --- /dev/null +++ b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import ArcscriptFunctions from '../ArcscriptFunctions.js'; +import ArcscriptState from '../ArcscriptState.js'; +import { RuntimeError } from '../errors/index.js'; + +function createFunctions(): ArcscriptFunctions { + const state = new ArcscriptState({}, {}, '', () => {}); + return new ArcscriptFunctions(state); +} + +describe('roll', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('defaults to one roll', () => { + const random = vi.spyOn(Math, 'random').mockReturnValue(0); + + expect(createFunctions().roll(6)).toBe(1); + expect(random).toHaveBeenCalledTimes(1); + }); + + test('returns the sum of the requested number of rolls', () => { + const random = vi + .spyOn(Math, 'random') + .mockReturnValueOnce(0) + .mockReturnValueOnce(0.5) + .mockReturnValueOnce(0.9999); + + expect(createFunctions().roll(6, 3)).toBe(11); + expect(random).toHaveBeenCalledTimes(3); + }); + + test.each([ + ['maximum roll', [0]], + ['maximum roll', [-1]], + ['number of rolls', [6, 0]], + ['number of rolls', [6, -1]], + ])('rejects a non-positive %s', (_description, args) => { + expect(() => createFunctions().roll(...args)).toThrow(RuntimeError); + expect(() => createFunctions().roll(...args)).toThrow( + 'Expected positive integer' + ); + }); + + test.each([ + ['maximum roll', [2.5]], + ['number of rolls', [6, 1.5]], + ])('rejects a fractional %s', (_description, args) => { + expect(() => createFunctions().roll(...args)).toThrow(RuntimeError); + expect(() => createFunctions().roll(...args)).toThrow( + 'Expected positive integer' + ); + }); + + test('rejects non-number arguments', () => { + expect(() => createFunctions().roll('6')).toThrow(RuntimeError); + expect(() => createFunctions().roll('6')).toThrow( + 'Expected positive integer' + ); + }); +}); From 4eea1fa7cb82aad1a862c822ff8e71dd8f75f228 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 08:07:52 +0300 Subject: [PATCH 23/51] fix: harden visits() --- TypeScript/src/ArcscriptFunctions.ts | 15 ++++- .../src/__tests__/ArcscriptFunctions.test.ts | 66 ++++++++++++++++++- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/TypeScript/src/ArcscriptFunctions.ts b/TypeScript/src/ArcscriptFunctions.ts index 6e0974c..f0c9ce0 100644 --- a/TypeScript/src/ArcscriptFunctions.ts +++ b/TypeScript/src/ArcscriptFunctions.ts @@ -150,23 +150,32 @@ export default class ArcscriptFunctions { visits(...args: ArgumentTypes): number { let elementId = this.state.currentElement; if (args.length > 0) { - const mention = args[0] as MentionResult; + const mention = args[0]; if ( typeof mention !== 'object' || + mention === null || + !('attrs' in mention) || + typeof mention.attrs !== 'object' || + mention.attrs === null || typeof mention.attrs['data-id'] !== 'string' ) { throw new RuntimeError( `Invalid argument ${mention} in function visits. Expected an element mention` ); } - if (!(mention.attrs['data-id'] in this.state.elementVisits)) { + if ( + !Object.prototype.hasOwnProperty.call( + this.state.elementVisits, + mention.attrs['data-id'] + ) + ) { throw new RuntimeError( `Invalid mention id: ${mention.attrs['data-id']}` ); } elementId = mention.attrs['data-id']; } - return this.state.elementVisits[elementId]; + return this.state.elementVisits[elementId] ?? 0; } resetVisits(): void { diff --git a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts index 3f2efb4..d81646d 100644 --- a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts +++ b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts @@ -2,12 +2,26 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import ArcscriptFunctions from '../ArcscriptFunctions.js'; import ArcscriptState from '../ArcscriptState.js'; import { RuntimeError } from '../errors/index.js'; +import { MentionResult } from '../types.js'; -function createFunctions(): ArcscriptFunctions { - const state = new ArcscriptState({}, {}, '', () => {}); +function createFunctions( + elementVisits: Record = {}, + currentElement = '' +): ArcscriptFunctions { + const state = new ArcscriptState({}, elementVisits, currentElement, () => {}); return new ArcscriptFunctions(state); } +function createMention(elementId: string): MentionResult { + return { + attrs: { + 'data-id': elementId, + 'data-type': 'element', + }, + label: '', + }; +} + describe('roll', () => { afterEach(() => { vi.restoreAllMocks(); @@ -60,3 +74,51 @@ describe('roll', () => { ); }); }); + +describe('visits', () => { + test('returns visits for the current element', () => { + expect(createFunctions({ current: 3 }, 'current').visits()).toBe(3); + }); + + test('returns zero when the current element has no recorded visits', () => { + expect(createFunctions({}, 'current').visits()).toBe(0); + }); + + test('returns visits for a mentioned element', () => { + const functions = createFunctions({ current: 3, mentioned: 5 }, 'current'); + + expect(functions.visits(createMention('mentioned'))).toBe(5); + }); + + test.each([null, {}, { attrs: null }, { attrs: {} }])( + 'rejects malformed mention argument %#', + argument => { + expect(() => functionsWithInvalidArgument(argument)).toThrow( + RuntimeError + ); + expect(() => functionsWithInvalidArgument(argument)).toThrow( + 'Expected an element mention' + ); + } + ); + + test('rejects an unknown mentioned element', () => { + const functions = createFunctions({ known: 1 }); + + expect(() => functions.visits(createMention('unknown'))).toThrow( + 'Invalid mention id: unknown' + ); + }); + + test('does not accept inherited visit properties as element ids', () => { + const functions = createFunctions({}); + + expect(() => functions.visits(createMention('toString'))).toThrow( + 'Invalid mention id: toString' + ); + }); +}); + +function functionsWithInvalidArgument(argument: unknown): number { + return createFunctions().visits(argument as MentionResult); +} From 48c610685c5d29dd99a818e4a9ab433fd34b2460 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 08:20:14 +0300 Subject: [PATCH 24/51] fix: add escaped backslash support in show() --- TypeScript/src/ArcscriptFunctions.ts | 43 +++++++---------- .../src/__tests__/ArcscriptFunctions.test.ts | 48 ++++++++++++++++++- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/TypeScript/src/ArcscriptFunctions.ts b/TypeScript/src/ArcscriptFunctions.ts index f0c9ce0..2ae7ec8 100644 --- a/TypeScript/src/ArcscriptFunctions.ts +++ b/TypeScript/src/ArcscriptFunctions.ts @@ -22,6 +22,19 @@ export type ArcscriptNonVoidFunctionKeys = Exclude< type ArgumentTypes = (VarValue | MentionResult | ArcscriptVariable)[]; +const ESCAPE_SEQUENCES: Record = { + a: '\x07', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + "'": "'", + '"': '"', + '\\': '\\', +}; + export default class ArcscriptFunctions { private state: ArcscriptState; @@ -76,31 +89,11 @@ export default class ArcscriptFunctions { } show(...args: ArgumentTypes): void { - let result = args.join(''); - result = result.replace(/\\([abfnrtv'"])/g, (match, p1) => { - switch (p1) { - case 'a': - return '\x07'; - case 'b': - return '\b'; - case 'f': - return '\f'; - case 'n': - return '\n'; - case 'r': - return '\r'; - case 't': - return '\t'; - case 'v': - return '\v'; - case "'": - return "'"; - case '"': - return '"'; - default: - return match; - } - }); + const result = args + .join('') + .replace(/\\([abfnrtv'"\\])/g, (_match, escape: string) => { + return ESCAPE_SEQUENCES[escape]; + }); this.state.pushOutput(`

${result}

`, true); } diff --git a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts index d81646d..1415d07 100644 --- a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts +++ b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts @@ -4,12 +4,22 @@ import ArcscriptState from '../ArcscriptState.js'; import { RuntimeError } from '../errors/index.js'; import { MentionResult } from '../types.js'; +function createFunctionContext( + elementVisits: Record = {}, + currentElement = '' +) { + const state = new ArcscriptState({}, elementVisits, currentElement, () => {}); + return { + functions: new ArcscriptFunctions(state), + state, + }; +} + function createFunctions( elementVisits: Record = {}, currentElement = '' ): ArcscriptFunctions { - const state = new ArcscriptState({}, elementVisits, currentElement, () => {}); - return new ArcscriptFunctions(state); + return createFunctionContext(elementVisits, currentElement).functions; } function createMention(elementId: string): MentionResult { @@ -75,6 +85,40 @@ describe('roll', () => { }); }); +describe('show', () => { + test('concatenates arguments without separators', () => { + const { functions, state } = createFunctionContext(); + + functions.show('Value: ', 3, true); + + expect(state.generateOutput()).toBe('

Value: 3true

'); + }); + + test('replaces supported escape sequences', () => { + const { functions, state } = createFunctionContext(); + + functions.show(String.raw`\a\b\f\n\r\t\v\'\"\\`); + + expect(state.outputs[0].output).toBe(`

${'\x07\b\f\n\r\t\v\'"\\'}

`); + }); + + test('does not interpret an escape after an escaped backslash', () => { + const { functions, state } = createFunctionContext(); + + functions.show(String.raw`\\n`); + + expect(state.generateOutput()).toBe(String.raw`

\n

`); + }); + + test('preserves unsupported escape sequences', () => { + const { functions, state } = createFunctionContext(); + + functions.show(String.raw`\x`); + + expect(state.generateOutput()).toBe(String.raw`

\x

`); + }); +}); + describe('visits', () => { test('returns visits for the current element', () => { expect(createFunctions({ current: 3 }, 'current').visits()).toBe(3); From 3b6fb64d25b2636a73b0c9349ea3ffc54197ee7e Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 08:27:41 +0300 Subject: [PATCH 25/51] fix: make reset atomic --- TypeScript/src/ArcscriptFunctions.ts | 35 ++++++++++--------- .../src/__tests__/ArcscriptFunctions.test.ts | 24 +++++++++++++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/TypeScript/src/ArcscriptFunctions.ts b/TypeScript/src/ArcscriptFunctions.ts index 2ae7ec8..00502cc 100644 --- a/TypeScript/src/ArcscriptFunctions.ts +++ b/TypeScript/src/ArcscriptFunctions.ts @@ -98,25 +98,14 @@ export default class ArcscriptFunctions { } reset(...args: ArgumentTypes): void { - args.forEach(variable => { - if (!(variable instanceof ArcscriptVariable)) { - throw new RuntimeError( - `Invalid argument ${variable} in function reset. Expected a variable` - ); - } - variable.reset(); - }); + const variables = this.getVariableArguments('reset', args); + variables.forEach(variable => variable.reset()); } resetAll(...args: ArgumentTypes): void { - const except = args.map(variable => { - if (!(variable instanceof ArcscriptVariable)) { - throw new RuntimeError( - `Invalid argument ${variable} in function resetAll. Expected a variable` - ); - } - return variable.id; - }); + const except = this.getVariableArguments('resetAll', args).map( + variable => variable.id + ); const variablesToReset = Object.values(this.state.variables).filter( v => !except.includes(v.id) ); @@ -175,6 +164,20 @@ export default class ArcscriptFunctions { this.state.resetVisits(); } + private getVariableArguments( + name: string, + args: ArgumentTypes + ): ArcscriptVariable[] { + return args.map(variable => { + if (!(variable instanceof ArcscriptVariable)) { + throw new RuntimeError( + `Invalid argument ${variable} in function ${name}. Expected a variable` + ); + } + return variable; + }); + } + /** * Checks if the function argument is a number * @param {string} name The function name diff --git a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts index 1415d07..e5eb2a9 100644 --- a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts +++ b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts @@ -119,6 +119,30 @@ describe('show', () => { }); }); +describe('reset', () => { + test('validates every argument before resetting variables', () => { + const state = new ArcscriptState( + { + first: { + id: 'first', + name: 'first', + type: 'integer', + defaultValue: 1, + value: 10, + }, + }, + {}, + '', + () => {} + ); + const functions = new ArcscriptFunctions(state); + const first = state.variables.first; + + expect(() => functions.reset(first, 'invalid')).toThrow(RuntimeError); + expect(first.getValue()).toBe(10); + }); +}); + describe('visits', () => { test('returns visits for the current element', () => { expect(createFunctions({ current: 3 }, 'current').visits()).toBe(3); From 827b18b6eb2acacb03a8383de71a630bdc0381f2 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 08:34:03 +0300 Subject: [PATCH 26/51] refactor: simplify number assertions --- TypeScript/src/ArcscriptFunctions.ts | 60 ++++++++++++++-------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/TypeScript/src/ArcscriptFunctions.ts b/TypeScript/src/ArcscriptFunctions.ts index 00502cc..ba55319 100644 --- a/TypeScript/src/ArcscriptFunctions.ts +++ b/TypeScript/src/ArcscriptFunctions.ts @@ -20,7 +20,8 @@ export type ArcscriptNonVoidFunctionKeys = Exclude< ArcscriptVoidFunctionKeys >; -type ArgumentTypes = (VarValue | MentionResult | ArcscriptVariable)[]; +type ArgumentType = VarValue | MentionResult | ArcscriptVariable; +type ArgumentTypes = ArgumentType[]; const ESCAPE_SEQUENCES: Record = { a: '\x07', @@ -43,27 +44,27 @@ export default class ArcscriptFunctions { } sqrt(...args: ArgumentTypes): number { - this.assertNumber('sqrt', args[0]); - const n = args[0] as number; - const result = Math.sqrt(n); + const value = args[0]; + this.assertNumber('sqrt', value); + const result = Math.sqrt(value); if (Number.isNaN(result)) { throw new RuntimeError( - `Invalid call to function sqrt with argument: ${n}` + `Invalid call to function sqrt with argument: ${value}` ); } return result; } sqr(...args: ArgumentTypes): number { - this.assertNumber('sqr', args[0]); - const n = args[0] as number; - return n * n; + const value = args[0]; + this.assertNumber('sqr', value); + return value * value; } abs(...args: ArgumentTypes): number { - this.assertNumber('abs', args[0]); - const n = args[0] as number; - return Math.abs(n); + const value = args[0]; + this.assertNumber('abs', value); + return Math.abs(value); } random(): number { @@ -77,13 +78,11 @@ export default class ArcscriptFunctions { this.assertPositiveInteger('roll', maxRoll); this.assertPositiveInteger('roll', rolls); - const maxRollNum = maxRoll as number; - const rollsNum = rolls as number; // Perform several dice rolls let rollSum = 0; - for (let i = 0; i < rollsNum; i += 1) { - rollSum += Math.floor(Math.random() * maxRollNum) + 1; + for (let i = 0; i < rolls; i += 1) { + rollSum += Math.floor(Math.random() * maxRoll) + 1; } return rollSum; } @@ -113,20 +112,19 @@ export default class ArcscriptFunctions { } round(...args: ArgumentTypes): number { - const num = args[0]; - this.assertNumber('round', num); - const n = num as number; - return Math.round(n); + const value = args[0]; + this.assertNumber('round', value); + return Math.round(value); } min(...args: ArgumentTypes): number { - args.forEach(arg => this.assertNumber('min', arg)); - return Math.min(...(args as number[])); + this.assertNumbers('min', args); + return Math.min(...args); } max(...args: ArgumentTypes): number { - args.forEach(arg => this.assertNumber('max', arg)); - return Math.max(...(args as number[])); + this.assertNumbers('max', args); + return Math.max(...args); } visits(...args: ArgumentTypes): number { @@ -183,10 +181,7 @@ export default class ArcscriptFunctions { * @param {string} name The function name * @param {VarValue} arg The argument to check */ - private assertNumber( - name: string, - arg: VarValue | MentionResult | ArcscriptVariable - ) { + private assertNumber(name: string, arg: ArgumentType): asserts arg is number { if (typeof arg !== 'number' || Number.isNaN(arg)) { throw new RuntimeError( `Invalid argument ${arg} in function ${name}. Expected number (integer or float)` @@ -194,6 +189,13 @@ export default class ArcscriptFunctions { } } + private assertNumbers( + name: string, + args: ArgumentTypes + ): asserts args is number[] { + args.forEach(arg => this.assertNumber(name, arg)); + } + /** * Checks if the function argument is a positive integer * @param {string} name The function name @@ -201,8 +203,8 @@ export default class ArcscriptFunctions { */ private assertPositiveInteger( name: string, - arg: VarValue | MentionResult | ArcscriptVariable - ) { + arg: ArgumentType + ): asserts arg is number { if (typeof arg !== 'number' || !Number.isInteger(arg) || arg <= 0) { throw new RuntimeError( `Invalid argument ${arg} in function ${name}. Expected positive integer` From a6a684c97a004cfcd3ec63bc08d783bce963a93c Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 09:03:27 +0300 Subject: [PATCH 27/51] chore: extract mention validation --- TypeScript/src/ArcscriptFunctions.ts | 39 ++++++++++--------- .../src/__tests__/ArcscriptFunctions.test.ts | 24 ++++++------ 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/TypeScript/src/ArcscriptFunctions.ts b/TypeScript/src/ArcscriptFunctions.ts index ba55319..bd6b838 100644 --- a/TypeScript/src/ArcscriptFunctions.ts +++ b/TypeScript/src/ArcscriptFunctions.ts @@ -130,30 +130,16 @@ export default class ArcscriptFunctions { visits(...args: ArgumentTypes): number { let elementId = this.state.currentElement; if (args.length > 0) { - const mention = args[0]; - if ( - typeof mention !== 'object' || - mention === null || - !('attrs' in mention) || - typeof mention.attrs !== 'object' || - mention.attrs === null || - typeof mention.attrs['data-id'] !== 'string' - ) { - throw new RuntimeError( - `Invalid argument ${mention} in function visits. Expected an element mention` - ); - } + const mentionId = this.getElementMentionId(args[0]); if ( !Object.prototype.hasOwnProperty.call( this.state.elementVisits, - mention.attrs['data-id'] + mentionId ) ) { - throw new RuntimeError( - `Invalid mention id: ${mention.attrs['data-id']}` - ); + throw new RuntimeError(`Invalid mention id: ${mentionId}`); } - elementId = mention.attrs['data-id']; + elementId = mentionId; } return this.state.elementVisits[elementId] ?? 0; } @@ -162,6 +148,23 @@ export default class ArcscriptFunctions { this.state.resetVisits(); } + private getElementMentionId(mention: ArgumentType): string { + if ( + typeof mention !== 'object' || + mention === null || + !('attrs' in mention) || + typeof mention.attrs !== 'object' || + mention.attrs === null || + mention.attrs['data-type'] !== 'element' || + typeof mention.attrs['data-id'] !== 'string' + ) { + throw new RuntimeError( + `Invalid argument ${mention} in function visits. Expected an element mention` + ); + } + return mention.attrs['data-id']; + } + private getVariableArguments( name: string, args: ArgumentTypes diff --git a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts index e5eb2a9..33b00cf 100644 --- a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts +++ b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts @@ -158,17 +158,19 @@ describe('visits', () => { expect(functions.visits(createMention('mentioned'))).toBe(5); }); - test.each([null, {}, { attrs: null }, { attrs: {} }])( - 'rejects malformed mention argument %#', - argument => { - expect(() => functionsWithInvalidArgument(argument)).toThrow( - RuntimeError - ); - expect(() => functionsWithInvalidArgument(argument)).toThrow( - 'Expected an element mention' - ); - } - ); + test.each([ + null, + {}, + { attrs: null }, + { attrs: {} }, + { attrs: { 'data-id': 'known' } }, + { attrs: { 'data-id': 'known', 'data-type': 'component' } }, + ])('rejects malformed mention argument %#', argument => { + expect(() => functionsWithInvalidArgument(argument)).toThrow(RuntimeError); + expect(() => functionsWithInvalidArgument(argument)).toThrow( + 'Expected an element mention' + ); + }); test('rejects an unknown mentioned element', () => { const functions = createFunctions({ known: 1 }); From 2b04a4b39955103663b39961c074e3c6eced1d09 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 09:05:50 +0300 Subject: [PATCH 28/51] chore: use a Set in resetAll --- TypeScript/src/ArcscriptFunctions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TypeScript/src/ArcscriptFunctions.ts b/TypeScript/src/ArcscriptFunctions.ts index bd6b838..7800625 100644 --- a/TypeScript/src/ArcscriptFunctions.ts +++ b/TypeScript/src/ArcscriptFunctions.ts @@ -102,11 +102,11 @@ export default class ArcscriptFunctions { } resetAll(...args: ArgumentTypes): void { - const except = this.getVariableArguments('resetAll', args).map( - variable => variable.id + const except = new Set( + this.getVariableArguments('resetAll', args).map(variable => variable.id) ); const variablesToReset = Object.values(this.state.variables).filter( - v => !except.includes(v.id) + variable => !except.has(variable.id) ); variablesToReset.forEach(variable => variable.reset()); } From 94c051718fbb4853d8cfa4e4196890ff52a42642 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 09:08:55 +0300 Subject: [PATCH 29/51] chore: tighten immutability and exports --- TypeScript/src/ArcscriptFunctions.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/TypeScript/src/ArcscriptFunctions.ts b/TypeScript/src/ArcscriptFunctions.ts index 7800625..5864583 100644 --- a/TypeScript/src/ArcscriptFunctions.ts +++ b/TypeScript/src/ArcscriptFunctions.ts @@ -3,7 +3,7 @@ import ArcscriptState from './ArcscriptState.js'; import { MentionResult, VarValue } from './types.js'; import ArcscriptVariable from './ArcscriptVariable.js'; -export type FunctionName = keyof ArcscriptFunctions; +type FunctionName = keyof ArcscriptFunctions; type VoidFunctionKeys = { [K in keyof T]: T[K] extends (...args: ArgumentTypes) => void @@ -13,7 +13,7 @@ type VoidFunctionKeys = { : never; }[keyof T]; -export type ArcscriptVoidFunctionKeys = VoidFunctionKeys; +type ArcscriptVoidFunctionKeys = VoidFunctionKeys; export type ArcscriptNonVoidFunctionKeys = Exclude< FunctionName, @@ -23,7 +23,7 @@ export type ArcscriptNonVoidFunctionKeys = Exclude< type ArgumentType = VarValue | MentionResult | ArcscriptVariable; type ArgumentTypes = ArgumentType[]; -const ESCAPE_SEQUENCES: Record = { +const ESCAPE_SEQUENCES: Readonly> = { a: '\x07', b: '\b', f: '\f', @@ -37,7 +37,7 @@ const ESCAPE_SEQUENCES: Record = { }; export default class ArcscriptFunctions { - private state: ArcscriptState; + private readonly state: ArcscriptState; constructor(state: ArcscriptState) { this.state = state; From a9bc8d64bd6232e2011f93651703f262a9d5cda2 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 09:11:31 +0300 Subject: [PATCH 30/51] chore: remove stale comments --- TypeScript/src/ArcscriptFunctions.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/TypeScript/src/ArcscriptFunctions.ts b/TypeScript/src/ArcscriptFunctions.ts index 5864583..bc80697 100644 --- a/TypeScript/src/ArcscriptFunctions.ts +++ b/TypeScript/src/ArcscriptFunctions.ts @@ -179,11 +179,6 @@ export default class ArcscriptFunctions { }); } - /** - * Checks if the function argument is a number - * @param {string} name The function name - * @param {VarValue} arg The argument to check - */ private assertNumber(name: string, arg: ArgumentType): asserts arg is number { if (typeof arg !== 'number' || Number.isNaN(arg)) { throw new RuntimeError( @@ -199,11 +194,6 @@ export default class ArcscriptFunctions { args.forEach(arg => this.assertNumber(name, arg)); } - /** - * Checks if the function argument is a positive integer - * @param {string} name The function name - * @param {VarValue} arg The argument to check - */ private assertPositiveInteger( name: string, arg: ArgumentType From 7bd658632cbdf0cbb803ee3873df434732426a9a Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 09:28:31 +0300 Subject: [PATCH 31/51] chore: state - readonly and private members --- TypeScript/src/ArcscriptState.ts | 20 +++++++++---------- .../src/__tests__/ArcscriptFunctions.test.ts | 6 +++++- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/TypeScript/src/ArcscriptState.ts b/TypeScript/src/ArcscriptState.ts index 31e0b2d..45e6429 100644 --- a/TypeScript/src/ArcscriptState.ts +++ b/TypeScript/src/ArcscriptState.ts @@ -64,17 +64,17 @@ type OutputObject = { }; export default class ArcscriptState { - variables: Record; + readonly variables: Record; - elementVisits: Record; - currentElement: string; - outputs: OutputObject[]; - conditionDepth: number; - emit: (event: string, data?: unknown) => void; - outputDoc: Document; - rootElement: HTMLElement; - inBlockquote: boolean; - insertBlockquote: boolean; + readonly elementVisits: Record; + readonly currentElement: string; + private readonly outputs: OutputObject[]; + private conditionDepth: number; + private readonly emit: (event: string, data?: unknown) => void; + private readonly outputDoc: Document; + private readonly rootElement: HTMLElement; + private inBlockquote: boolean; + private insertBlockquote: boolean; constructor( arcscriptVariables: ArcscriptStateDef, diff --git a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts index 33b00cf..79c0ab4 100644 --- a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts +++ b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts @@ -96,10 +96,14 @@ describe('show', () => { test('replaces supported escape sequences', () => { const { functions, state } = createFunctionContext(); + const pushOutput = vi.spyOn(state, 'pushOutput'); functions.show(String.raw`\a\b\f\n\r\t\v\'\"\\`); - expect(state.outputs[0].output).toBe(`

${'\x07\b\f\n\r\t\v\'"\\'}

`); + expect(pushOutput).toHaveBeenCalledWith( + `

${'\x07\b\f\n\r\t\v\'"\\'}

`, + true + ); }); test('does not interpret an escape after an escaped backslash', () => { From 536d8520d8675f290aea72ef390ea227436dd224 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 09:32:54 +0300 Subject: [PATCH 32/51] refactor: create functions to access state members --- TypeScript/src/ArcscriptFunctions.ts | 27 +++++++------------ TypeScript/src/ArcscriptState.ts | 26 +++++++++++++++--- .../src/__tests__/ArcscriptFunctions.test.ts | 2 +- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/TypeScript/src/ArcscriptFunctions.ts b/TypeScript/src/ArcscriptFunctions.ts index bc80697..73f3616 100644 --- a/TypeScript/src/ArcscriptFunctions.ts +++ b/TypeScript/src/ArcscriptFunctions.ts @@ -105,10 +105,7 @@ export default class ArcscriptFunctions { const except = new Set( this.getVariableArguments('resetAll', args).map(variable => variable.id) ); - const variablesToReset = Object.values(this.state.variables).filter( - variable => !except.has(variable.id) - ); - variablesToReset.forEach(variable => variable.reset()); + this.state.resetVariablesExcept(except); } round(...args: ArgumentTypes): number { @@ -128,20 +125,16 @@ export default class ArcscriptFunctions { } visits(...args: ArgumentTypes): number { - let elementId = this.state.currentElement; - if (args.length > 0) { - const mentionId = this.getElementMentionId(args[0]); - if ( - !Object.prototype.hasOwnProperty.call( - this.state.elementVisits, - mentionId - ) - ) { - throw new RuntimeError(`Invalid mention id: ${mentionId}`); - } - elementId = mentionId; + if (args.length === 0) { + return this.state.getCurrentElementVisitCount(); + } + + const mentionId = this.getElementMentionId(args[0]); + const visitCount = this.state.getElementVisitCount(mentionId); + if (visitCount === undefined) { + throw new RuntimeError(`Invalid mention id: ${mentionId}`); } - return this.state.elementVisits[elementId] ?? 0; + return visitCount; } resetVisits(): void { diff --git a/TypeScript/src/ArcscriptState.ts b/TypeScript/src/ArcscriptState.ts index 45e6429..a85a45a 100644 --- a/TypeScript/src/ArcscriptState.ts +++ b/TypeScript/src/ArcscriptState.ts @@ -64,10 +64,9 @@ type OutputObject = { }; export default class ArcscriptState { - readonly variables: Record; - - readonly elementVisits: Record; - readonly currentElement: string; + private readonly variables: Record; + private readonly elementVisits: Record; + private readonly currentElement: string; private readonly outputs: OutputObject[]; private conditionDepth: number; private readonly emit: (event: string, data?: unknown) => void; @@ -127,6 +126,25 @@ export default class ArcscriptState { return variable; } + resetVariablesExcept(excludedIds: ReadonlySet): void { + Object.values(this.variables).forEach(variable => { + if (!excludedIds.has(variable.id)) { + variable.reset(); + } + }); + } + + getCurrentElementVisitCount(): number { + return this.getElementVisitCount(this.currentElement) ?? 0; + } + + getElementVisitCount(elementId: string): number | undefined { + if (!hasProperty(this.elementVisits, elementId)) { + return undefined; + } + return this.elementVisits[elementId]; + } + setVarValues(values: Record) { Object.entries(values).forEach(([id, value]) => { if (this.variables[id]) { diff --git a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts index 79c0ab4..c0810d7 100644 --- a/TypeScript/src/__tests__/ArcscriptFunctions.test.ts +++ b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts @@ -140,7 +140,7 @@ describe('reset', () => { () => {} ); const functions = new ArcscriptFunctions(state); - const first = state.variables.first; + const first = state.getVar('first'); expect(() => functions.reset(first, 'invalid')).toThrow(RuntimeError); expect(first.getValue()).toBe(10); From cc92fe96b2d5cf958dc1c1e07ba520312a2ae8bc Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 09:38:31 +0300 Subject: [PATCH 33/51] refactor: decompose ArcscriptState output handling --- TypeScript/src/ArcscriptState.ts | 166 +++++++++++++++++-------------- 1 file changed, 91 insertions(+), 75 deletions(-) diff --git a/TypeScript/src/ArcscriptState.ts b/TypeScript/src/ArcscriptState.ts index a85a45a..2658f38 100644 --- a/TypeScript/src/ArcscriptState.ts +++ b/TypeScript/src/ArcscriptState.ts @@ -173,95 +173,28 @@ export default class ArcscriptState { * @param {boolean} fromScript If the output comes from a script */ pushOutput(output: string, fromScript: boolean = false) { - let previousOutput = null; - if (this.outputs.length > 0) { - previousOutput = this.outputs[this.outputs.length - 1]; - } - - let outputNode = new DOMParser().parseFromString(output, 'text/html').body - .firstElementChild; + const previousOutput = this.outputs[this.outputs.length - 1]; + const outputNode = this.parseOutputNode(output); if (!outputNode) { return; } - this.outputs.push({ - output, - index: this.conditionDepth, - fromScript, - inBlockquote: this.inBlockquote, - isScript: false, - }); + this.recordOutput(output, fromScript); // If this is the first output to be inserted if (!this.rootElement.innerHTML) { - if (this.insertBlockquote) { - const newNode = this.outputDoc.createElement('blockquote'); - newNode.appendChild(outputNode); - outputNode = newNode; - this.insertBlockquote = false; - } - this.rootElement.appendChild(outputNode); + this.appendToRoot(outputNode); } // If current output is coming from a script, we are merging it with the previous output else if (fromScript) { - if (this.insertBlockquote) { - const newNode = this.outputDoc.createElement('blockquote'); - newNode.appendChild(outputNode); - outputNode = newNode; - this.insertBlockquote = false; - this.rootElement.appendChild(outputNode); - } else if (outputNode.innerHTML) { - const children = - this.outputDoc.body.querySelectorAll('div p:last-child'); - if (children[children.length - 1].innerHTML === '') { - children[children.length - 1].innerHTML = outputNode.innerHTML; - } else { - children[children.length - 1].innerHTML += ` ${outputNode.innerHTML}`; - } - } + this.appendScriptOutput(outputNode); } // If the previous output was from a script, the node was a script or // the condition depth is different, merge if the nodes are of the same type - else if ( - previousOutput && - (previousOutput.fromScript || - previousOutput.isScript || - previousOutput.index !== this.conditionDepth) - ) { - const nodeName = this.inBlockquote ? 'BLOCKQUOTE' : 'P'; - const previousNode = this.rootElement.lastElementChild; - if (previousNode && previousNode.nodeName === nodeName) { - if (outputNode.innerHTML) { - const children = - this.rootElement.querySelectorAll('div p:last-child'); - if (children[children.length - 1].innerHTML === '') { - children[children.length - 1].innerHTML = outputNode.innerHTML; - } else { - children[children.length - 1].innerHTML += - ` ${outputNode.innerHTML}`; - } - } - } else { - if (this.insertBlockquote) { - const newNode = this.outputDoc.createElement('blockquote'); - newNode.appendChild(outputNode); - outputNode = newNode; - this.insertBlockquote = false; - } - this.rootElement.appendChild(outputNode); - } + else if (this.shouldMergeWithPreviousOutput(previousOutput)) { + this.appendAfterPreviousOutput(outputNode); } else if (this.inBlockquote) { - if (this.insertBlockquote) { - const newNode = this.outputDoc.createElement('blockquote'); - newNode.appendChild(outputNode); - this.rootElement.appendChild(newNode); - - this.insertBlockquote = false; - } else { - this.outputDoc - .querySelector('blockquote:last-child') - ?.appendChild(outputNode); - } + this.appendBlockquoteOutput(outputNode); } else { this.rootElement.appendChild(outputNode); } @@ -310,4 +243,87 @@ export default class ArcscriptState { }); this.emit('resetVisits', {}); } + + private parseOutputNode(output: string): Element | null { + return new DOMParser().parseFromString(output, 'text/html').body + .firstElementChild; + } + + private recordOutput(output: string, fromScript: boolean): void { + this.outputs.push({ + output, + index: this.conditionDepth, + fromScript, + inBlockquote: this.inBlockquote, + isScript: false, + }); + } + + private appendToRoot(outputNode: Element): void { + this.rootElement.appendChild(this.wrapInBlockquote(outputNode)); + } + + private wrapInBlockquote(outputNode: Element): Element { + if (!this.insertBlockquote) { + return outputNode; + } + const blockquote = this.outputDoc.createElement('blockquote'); + blockquote.appendChild(outputNode); + return blockquote; + } + + private appendScriptOutput(outputNode: Element): void { + if (this.insertBlockquote) { + this.appendToRoot(outputNode); + return; + } + this.mergeWithLastParagraph(outputNode, this.outputDoc.body); + } + + private shouldMergeWithPreviousOutput( + previousOutput: OutputObject | undefined + ): boolean { + return Boolean( + previousOutput && + (previousOutput.fromScript || + previousOutput.isScript || + previousOutput.index !== this.conditionDepth) + ); + } + + private appendAfterPreviousOutput(outputNode: Element): void { + const expectedNodeName = this.inBlockquote ? 'BLOCKQUOTE' : 'P'; + const previousNode = this.rootElement.lastElementChild; + if (previousNode?.nodeName === expectedNodeName) { + this.mergeWithLastParagraph(outputNode, this.rootElement); + return; + } + this.appendToRoot(outputNode); + } + + private mergeWithLastParagraph( + outputNode: Element, + container: ParentNode + ): void { + if (!outputNode.innerHTML) { + return; + } + const paragraphs = container.querySelectorAll('div p:last-child'); + const lastParagraph = paragraphs[paragraphs.length - 1]; + if (lastParagraph.innerHTML === '') { + lastParagraph.innerHTML = outputNode.innerHTML; + } else { + lastParagraph.innerHTML += ` ${outputNode.innerHTML}`; + } + } + + private appendBlockquoteOutput(outputNode: Element): void { + if (this.insertBlockquote) { + this.appendToRoot(outputNode); + return; + } + this.outputDoc + .querySelector('blockquote:last-child') + ?.appendChild(outputNode); + } } From d72b5ae537e8e1aed5772271368b01ed0683bbbc Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 09:41:45 +0300 Subject: [PATCH 34/51] chore: privatize initialize variables --- TypeScript/src/ArcscriptState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TypeScript/src/ArcscriptState.ts b/TypeScript/src/ArcscriptState.ts index 2658f38..029d1d7 100644 --- a/TypeScript/src/ArcscriptState.ts +++ b/TypeScript/src/ArcscriptState.ts @@ -97,7 +97,7 @@ export default class ArcscriptState { this.insertBlockquote = false; } - initializeVariables(arcscriptVariables: ArcscriptStateDef) { + private initializeVariables(arcscriptVariables: ArcscriptStateDef) { validateStateDef(arcscriptVariables); const variables: Record = {}; Object.entries(arcscriptVariables).forEach(([id, varDef]) => { From 89fff7089c49bd143dcfbc413a0ff3a30a252d7c Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 09:51:22 +0300 Subject: [PATCH 35/51] fix: harden ArcscriptState variable records --- TypeScript/src/ArcscriptState.ts | 18 ++++----- .../src/__tests__/ArcscriptState.test.ts | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 TypeScript/src/__tests__/ArcscriptState.test.ts diff --git a/TypeScript/src/ArcscriptState.ts b/TypeScript/src/ArcscriptState.ts index 029d1d7..a27d61d 100644 --- a/TypeScript/src/ArcscriptState.ts +++ b/TypeScript/src/ArcscriptState.ts @@ -99,7 +99,7 @@ export default class ArcscriptState { private initializeVariables(arcscriptVariables: ArcscriptStateDef) { validateStateDef(arcscriptVariables); - const variables: Record = {}; + const variables = Object.create(null) as Record; Object.entries(arcscriptVariables).forEach(([id, varDef]) => { variables[id] = new ArcscriptVariable({ id, @@ -147,22 +147,18 @@ export default class ArcscriptState { setVarValues(values: Record) { Object.entries(values).forEach(([id, value]) => { - if (this.variables[id]) { + if (hasProperty(this.variables, id)) { this.variables[id].setValue(value); } }); } getChanges() { - const changes: Record = {}; - Object.entries(this.variables).forEach( - ([id, variable]: [string, ArcscriptVariable]) => { - if (variable.changed) { - changes[id] = variable.getValue(); - } - } - ); - return changes; + return Object.fromEntries( + Object.entries(this.variables) + .filter(([, variable]) => variable.changed) + .map(([id, variable]) => [id, variable.getValue()]) + ) as Record; } /** diff --git a/TypeScript/src/__tests__/ArcscriptState.test.ts b/TypeScript/src/__tests__/ArcscriptState.test.ts new file mode 100644 index 0000000..9017331 --- /dev/null +++ b/TypeScript/src/__tests__/ArcscriptState.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'vitest'; +import ArcscriptState from '../ArcscriptState.js'; +import { ArcscriptStateDef } from '../types.js'; + +function createState(stateDef: ArcscriptStateDef = {}): ArcscriptState { + return new ArcscriptState(stateDef, {}, '', () => {}); +} + +describe('variable record keys', () => { + test.each(['__proto__', 'constructor', 'toString'])( + 'supports the variable id %s', + id => { + const stateDef = Object.fromEntries([ + [ + id, + { + id, + name: 'variable', + type: 'integer', + defaultValue: 1, + }, + ], + ]) as ArcscriptStateDef; + const state = createState(stateDef); + + state.setVarValues(Object.fromEntries([[id, 2]])); + + expect(state.getVar('variable').id).toBe(id); + expect(Object.hasOwn(state.getChanges(), id)).toBe(true); + expect(state.getChanges()[id]).toBe(2); + } + ); + + test('ignores override keys that are only inherited record properties', () => { + const state = createState(); + + expect(() => state.setVarValues({ toString: 2 })).not.toThrow(); + expect(state.getChanges()).toEqual({}); + }); +}); From 8c620af5abc3a7bf075d0f5eb09663217a2a01ae Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 09:55:46 +0300 Subject: [PATCH 36/51] refactor: simplify ArcscriptState output metadata --- TypeScript/src/ArcscriptState.ts | 49 +++++++++++++------------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/TypeScript/src/ArcscriptState.ts b/TypeScript/src/ArcscriptState.ts index a27d61d..6b3c81c 100644 --- a/TypeScript/src/ArcscriptState.ts +++ b/TypeScript/src/ArcscriptState.ts @@ -55,19 +55,15 @@ function validateStateDef(stateDef: ArcscriptStateDef) { }); } -type OutputObject = { - output?: string; - index?: number; - fromScript?: boolean; - inBlockquote?: boolean; - isScript: boolean; -}; +type OutputEntry = + | { isScript: true } + | { isScript: false; conditionDepth: number; fromScript: boolean }; export default class ArcscriptState { private readonly variables: Record; private readonly elementVisits: Record; private readonly currentElement: string; - private readonly outputs: OutputObject[]; + private readonly outputs: OutputEntry[]; private conditionDepth: number; private readonly emit: (event: string, data?: unknown) => void; private readonly outputDoc: Document; @@ -161,13 +157,7 @@ export default class ArcscriptState { ) as Record; } - /** - * Adds an output to the array. The array has info on the current condition depth, - * the type of the output pushed (blockquote or paragraph), and if comes from a script - * (i.e. using 'show' function) - * @param {string} output The output - * @param {boolean} fromScript If the output comes from a script - */ + /** Adds rendered output and records metadata used for subsequent merging. */ pushOutput(output: string, fromScript: boolean = false) { const previousOutput = this.outputs[this.outputs.length - 1]; const outputNode = this.parseOutputNode(output); @@ -175,7 +165,7 @@ export default class ArcscriptState { return; } - this.recordOutput(output, fromScript); + this.recordOutput(fromScript); // If this is the first output to be inserted if (!this.rootElement.innerHTML) { @@ -198,10 +188,7 @@ export default class ArcscriptState { this.insertBlockquote = false; } - /** - * Adds to the outputs the existanse of a script "generateOutput" will be able - * to recognize when to concatenate paragraphs - */ + /** Records a script boundary used when merging subsequent output. */ addScript() { this.outputs.push({ isScript: true, @@ -245,12 +232,10 @@ export default class ArcscriptState { .firstElementChild; } - private recordOutput(output: string, fromScript: boolean): void { + private recordOutput(fromScript: boolean): void { this.outputs.push({ - output, - index: this.conditionDepth, + conditionDepth: this.conditionDepth, fromScript, - inBlockquote: this.inBlockquote, isScript: false, }); } @@ -277,13 +262,17 @@ export default class ArcscriptState { } private shouldMergeWithPreviousOutput( - previousOutput: OutputObject | undefined + previousOutput: OutputEntry | undefined ): boolean { - return Boolean( - previousOutput && - (previousOutput.fromScript || - previousOutput.isScript || - previousOutput.index !== this.conditionDepth) + if (!previousOutput) { + return false; + } + if (previousOutput.isScript) { + return true; + } + return ( + previousOutput.fromScript || + previousOutput.conditionDepth !== this.conditionDepth ); } From a1efa266ca58f4c974de0f9d4c189feb5ef535cb Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 10:02:18 +0300 Subject: [PATCH 37/51] refactor: make ArcscriptVariable identity immutable --- TypeScript/src/ArcscriptVariable.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/TypeScript/src/ArcscriptVariable.ts b/TypeScript/src/ArcscriptVariable.ts index 11e2f9b..a1b427f 100644 --- a/TypeScript/src/ArcscriptVariable.ts +++ b/TypeScript/src/ArcscriptVariable.ts @@ -10,13 +10,13 @@ type ArcscriptVariableParams = { }; export default class ArcscriptVariable { - id: string; - name: string; + readonly id: string; + readonly name: string; type: string; value: VarValue; - defaultValue: VarValue; + readonly defaultValue: VarValue; changed = false; - scope: string | null; + readonly scope: string | null; constructor({ id, From 68c531b40484b00b427d7785cca5a2748847c42c Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 10:04:46 +0300 Subject: [PATCH 38/51] refactor: privatize variable value --- TypeScript/src/ArcscriptState.ts | 2 +- TypeScript/src/ArcscriptVariable.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/TypeScript/src/ArcscriptState.ts b/TypeScript/src/ArcscriptState.ts index 6b3c81c..bf02dfe 100644 --- a/TypeScript/src/ArcscriptState.ts +++ b/TypeScript/src/ArcscriptState.ts @@ -152,7 +152,7 @@ export default class ArcscriptState { getChanges() { return Object.fromEntries( Object.entries(this.variables) - .filter(([, variable]) => variable.changed) + .filter(([, variable]) => variable.hasChanged()) .map(([id, variable]) => [id, variable.getValue()]) ) as Record; } diff --git a/TypeScript/src/ArcscriptVariable.ts b/TypeScript/src/ArcscriptVariable.ts index a1b427f..af41edc 100644 --- a/TypeScript/src/ArcscriptVariable.ts +++ b/TypeScript/src/ArcscriptVariable.ts @@ -13,9 +13,9 @@ export default class ArcscriptVariable { readonly id: string; readonly name: string; type: string; - value: VarValue; + private value: VarValue; readonly defaultValue: VarValue; - changed = false; + private changed = false; readonly scope: string | null; constructor({ @@ -49,6 +49,10 @@ export default class ArcscriptVariable { return this.value; } + hasChanged() { + return this.changed; + } + setValue(newValue: VarValue) { this.value = newValue; this.changed = true; From 8faf0e46fb573f57687788c56b5819689f28801f Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 10:06:49 +0300 Subject: [PATCH 39/51] chore: Remove the duplicated ArcscriptVariableParams type --- TypeScript/src/ArcscriptVariable.ts | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/TypeScript/src/ArcscriptVariable.ts b/TypeScript/src/ArcscriptVariable.ts index af41edc..b899ab8 100644 --- a/TypeScript/src/ArcscriptVariable.ts +++ b/TypeScript/src/ArcscriptVariable.ts @@ -1,31 +1,15 @@ -import { VarValue } from './types.js'; - -type ArcscriptVariableParams = { - id: string; - name: string; - type: string; - defaultValue: VarValue; - value?: VarValue; - scope?: string | null; -}; +import { VarDef, VarValue } from './types.js'; export default class ArcscriptVariable { readonly id: string; readonly name: string; - type: string; + type: VarDef['type']; private value: VarValue; readonly defaultValue: VarValue; private changed = false; readonly scope: string | null; - constructor({ - id, - name, - defaultValue, - type, - value, - scope, - }: ArcscriptVariableParams) { + constructor({ id, name, defaultValue, type, value, scope }: VarDef) { this.id = id; this.name = name; this.type = type; From 4443d76a4219d7c97c0898caa26f3e23a1ce35ca Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 10:09:09 +0300 Subject: [PATCH 40/51] refactor: Remove the unused runtime type field --- TypeScript/src/ArcscriptVariable.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/TypeScript/src/ArcscriptVariable.ts b/TypeScript/src/ArcscriptVariable.ts index b899ab8..8327cbc 100644 --- a/TypeScript/src/ArcscriptVariable.ts +++ b/TypeScript/src/ArcscriptVariable.ts @@ -3,16 +3,14 @@ import { VarDef, VarValue } from './types.js'; export default class ArcscriptVariable { readonly id: string; readonly name: string; - type: VarDef['type']; private value: VarValue; readonly defaultValue: VarValue; private changed = false; readonly scope: string | null; - constructor({ id, name, defaultValue, type, value, scope }: VarDef) { + constructor({ id, name, defaultValue, value, scope }: VarDef) { this.id = id; this.name = name; - this.type = type; this.defaultValue = defaultValue; this.scope = scope || null; @@ -41,8 +39,4 @@ export default class ArcscriptVariable { this.value = newValue; this.changed = true; } - - getType() { - return this.type; - } } From 32b77308d723905def16fda13cf57625e08c0be1 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 10:12:53 +0300 Subject: [PATCH 41/51] fix: add return types to methods --- TypeScript/src/ArcscriptVariable.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/TypeScript/src/ArcscriptVariable.ts b/TypeScript/src/ArcscriptVariable.ts index 8327cbc..32ea4b1 100644 --- a/TypeScript/src/ArcscriptVariable.ts +++ b/TypeScript/src/ArcscriptVariable.ts @@ -4,7 +4,7 @@ export default class ArcscriptVariable { readonly id: string; readonly name: string; private value: VarValue; - readonly defaultValue: VarValue; + private readonly defaultValue: VarValue; private changed = false; readonly scope: string | null; @@ -22,20 +22,20 @@ export default class ArcscriptVariable { } } - reset() { + reset(): void { this.value = this.defaultValue; this.changed = true; } - getValue() { + getValue(): VarValue { return this.value; } - hasChanged() { + hasChanged(): boolean { return this.changed; } - setValue(newValue: VarValue) { + setValue(newValue: VarValue): void { this.value = newValue; this.changed = true; } From a8b24f54f63fcb9b605cd6f45e7376828102228c Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 10:34:41 +0300 Subject: [PATCH 42/51] fix: Implemented boolean coercion for multiplicative expressions --- TypeScript/src/ArcscriptVisitor.ts | 4 ++-- TypeScript/src/__tests__/runtimeErrors.json | 10 +++++++- TypeScript/src/__tests__/valid.json | 26 ++++++++++++++++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/TypeScript/src/ArcscriptVisitor.ts b/TypeScript/src/ArcscriptVisitor.ts index 86987f8..ea7917c 100644 --- a/TypeScript/src/ArcscriptVisitor.ts +++ b/TypeScript/src/ArcscriptVisitor.ts @@ -311,8 +311,8 @@ export default class ArcscriptVisitor extends ArcscriptParserVisitor { if (typeof left === 'string' || typeof right === 'string') { throw new RuntimeError('Invalid operation with string'); } - const leftValue = new BigNumber(left as number); - const rightValue = new BigNumber(right as number); + const leftValue = new BigNumber(Number(left)); + const rightValue = new BigNumber(Number(right)); if (ctx.MUL()) { return leftValue.multipliedBy(rightValue).toNumber(); diff --git a/TypeScript/src/__tests__/runtimeErrors.json b/TypeScript/src/__tests__/runtimeErrors.json index 4f6b72e..3c33f9d 100644 --- a/TypeScript/src/__tests__/runtimeErrors.json +++ b/TypeScript/src/__tests__/runtimeErrors.json @@ -52,6 +52,10 @@ "code": "
x = 3 / 0
", "error": "runtime" }, + { + "code": "
x = 3 / false
", + "error": "runtime" + }, { "code": "
x = roll(\"2\")
", "error": "runtime" @@ -72,6 +76,10 @@ "code": "
z = 10 % 0
", "error": "runtime" }, + { + "code": "
z = 10 % false
", + "error": "runtime" + }, { "code": "
x %= 0
", "error": "runtime" @@ -81,4 +89,4 @@ "error": "runtime" } ] -} \ No newline at end of file +} diff --git a/TypeScript/src/__tests__/valid.json b/TypeScript/src/__tests__/valid.json index 6929780..6929e04 100644 --- a/TypeScript/src/__tests__/valid.json +++ b/TypeScript/src/__tests__/valid.json @@ -99,6 +99,30 @@ "var1": 8 } }, + { + "code": "
x = true * true
", + "changes": { + "var1": 1 + } + }, + { + "code": "
x = false * 5
", + "changes": { + "var1": 0 + } + }, + { + "code": "
x = true / true
", + "changes": { + "var1": 1 + } + }, + { + "code": "
x = true % 2
", + "changes": { + "var1": 1 + } + }, { "code": "
x = 10 % 3
", "changes": { @@ -554,4 +578,4 @@ } } ] -} \ No newline at end of file +} From f97957950f90d77f846996ba2a7840ca0dedf07d Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 10:45:30 +0300 Subject: [PATCH 43/51] refactor: implement typed dispatch for arcscript functions --- TypeScript/src/ArcscriptFunctions.ts | 17 ++++------------- TypeScript/src/ArcscriptVisitor.ts | 5 ++--- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/TypeScript/src/ArcscriptFunctions.ts b/TypeScript/src/ArcscriptFunctions.ts index 73f3616..94f497f 100644 --- a/TypeScript/src/ArcscriptFunctions.ts +++ b/TypeScript/src/ArcscriptFunctions.ts @@ -3,22 +3,13 @@ import ArcscriptState from './ArcscriptState.js'; import { MentionResult, VarValue } from './types.js'; import ArcscriptVariable from './ArcscriptVariable.js'; -type FunctionName = keyof ArcscriptFunctions; - -type VoidFunctionKeys = { - [K in keyof T]: T[K] extends (...args: ArgumentTypes) => void - ? ReturnType extends void - ? K - : never +type FunctionKeys = { + [Key in keyof T]: T[Key] extends (...args: infer _Arguments) => unknown + ? Key : never; }[keyof T]; -type ArcscriptVoidFunctionKeys = VoidFunctionKeys; - -export type ArcscriptNonVoidFunctionKeys = Exclude< - FunctionName, - ArcscriptVoidFunctionKeys ->; +export type ArcscriptFunctionName = FunctionKeys; type ArgumentType = VarValue | MentionResult | ArcscriptVariable; type ArgumentTypes = ArgumentType[]; diff --git a/TypeScript/src/ArcscriptVisitor.ts b/TypeScript/src/ArcscriptVisitor.ts index ea7917c..b152098 100644 --- a/TypeScript/src/ArcscriptVisitor.ts +++ b/TypeScript/src/ArcscriptVisitor.ts @@ -1,7 +1,7 @@ import BigNumber from 'bignumber.js'; import ArcscriptParserVisitor from './Generated/ArcscriptParserVisitor.js'; import ArcscriptFunctions, { - ArcscriptNonVoidFunctionKeys, + ArcscriptFunctionName, } from './ArcscriptFunctions.js'; import ArcscriptState from './ArcscriptState.js'; import { RuntimeError } from './errors/index.js'; @@ -414,7 +414,7 @@ export default class ArcscriptVisitor extends ArcscriptParserVisitor { visitFunction_call = (ctx: Function_callContext) => { let argument_list: (VarValue | MentionResult | ArcscriptVariable)[] = []; - const function_name = ctx.FNAME().getText() as ArcscriptNonVoidFunctionKeys; + const function_name = ctx.FNAME().getText() as ArcscriptFunctionName; if (ctx.argument_list()) { argument_list = this.visitArgument_list(ctx.argument_list()); } @@ -440,7 +440,6 @@ export default class ArcscriptVisitor extends ArcscriptParserVisitor { } } - // TODO: remove non void function keys return this.functions[function_name](...argument_list); }; From b3f2ff4067f4fc4dbad69f53ec991b28cbed5343 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 10:49:08 +0300 Subject: [PATCH 44/51] chore: privatize member properties in ArcscriptVisitor --- TypeScript/src/ArcscriptVisitor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TypeScript/src/ArcscriptVisitor.ts b/TypeScript/src/ArcscriptVisitor.ts index b152098..5d353d4 100644 --- a/TypeScript/src/ArcscriptVisitor.ts +++ b/TypeScript/src/ArcscriptVisitor.ts @@ -45,8 +45,8 @@ import ArcscriptParserBase from './Generated/ArcscriptParserBase.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export default class ArcscriptVisitor extends ArcscriptParserVisitor { - state: ArcscriptState; - functions: ArcscriptFunctions; + private readonly state: ArcscriptState; + private readonly functions: ArcscriptFunctions; constructor(state: ArcscriptState) { super(); From a2cee79c9b64a80ba01363630c8a164831dc99b0 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 10:52:03 +0300 Subject: [PATCH 45/51] fix: Remove redundant instanceof ArcscriptVariable checks --- TypeScript/src/ArcscriptVisitor.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/TypeScript/src/ArcscriptVisitor.ts b/TypeScript/src/ArcscriptVisitor.ts index 5d353d4..dc60200 100644 --- a/TypeScript/src/ArcscriptVisitor.ts +++ b/TypeScript/src/ArcscriptVisitor.ts @@ -419,24 +419,14 @@ export default class ArcscriptVisitor extends ArcscriptParserVisitor { argument_list = this.visitArgument_list(ctx.argument_list()); } if (ctx.identifier_list()) { - argument_list = this.visitIdentifier_list(ctx.identifier_list()); + const variables = this.visitIdentifier_list(ctx.identifier_list()); if ( ArcscriptParserBase.arcscriptFunctionsInfo[function_name].argType === 'variable' ) { - argument_list = argument_list.map(variable => { - if (variable instanceof ArcscriptVariable) { - return variable; - } - throw new RuntimeError('Expected a variable'); - }); + argument_list = variables; } else { - argument_list = argument_list.map(variable => { - if (variable instanceof ArcscriptVariable) { - return variable.getValue(); - } - throw new RuntimeError('Expected a variable'); - }); + argument_list = variables.map(variable => variable.getValue()); } } From 59107d37220866ba5a36a74dae8c4815120410ba Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 12:03:33 +0300 Subject: [PATCH 46/51] fix: update c++ tests --- .github/workflows/cpp-build-test.yml | 7 +- Cpp/CMakeLists.txt | 32 +++++++++ Cpp/README.md | 14 +++- Cpp/demo/ArcscriptTest.cpp | 102 +++++++++++++++++---------- Cpp/demo/tests/valid.json | 23 +++--- 5 files changed, 122 insertions(+), 56 deletions(-) diff --git a/.github/workflows/cpp-build-test.yml b/.github/workflows/cpp-build-test.yml index 1fd2c0e..dee3c92 100644 --- a/.github/workflows/cpp-build-test.yml +++ b/.github/workflows/cpp-build-test.yml @@ -21,8 +21,6 @@ jobs: matrix: os: [macos-latest, windows-2022] fail-fast: false - env: - DEMO_DIR: demo/${{ matrix.os == 'windows-2022' && 'Win32/Release' || 'Mac' }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -57,10 +55,7 @@ jobs: run: cmake --build Cpp/build --config Release - name: Run tests - run: | - cd Cpp/build/${{ env.DEMO_DIR }} - ls - ./ArcscriptTest + run: ctest --test-dir Cpp/build --build-config Release --output-on-failure - name: Upload native library artifact uses: actions/upload-artifact@v4 diff --git a/Cpp/CMakeLists.txt b/Cpp/CMakeLists.txt index d437ecc..730c07f 100755 --- a/Cpp/CMakeLists.txt +++ b/Cpp/CMakeLists.txt @@ -7,6 +7,9 @@ message(STATUS "CMake version: ${CMAKE_VERSION}") set(PROJECT_VERSION "0.1.0") project(ArcscriptTranspiler VERSION ${PROJECT_VERSION}) +include(CTest) +option(WITH_TEST "Build the Arcscript C++ test suite" OFF) + list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake) message(STATUS "Generator: ${CMAKE_GENERATOR}") @@ -120,6 +123,7 @@ add_custom_command(TARGET ArcscriptTranspiler POST_BUILD WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) if (WITH_TEST) + enable_testing() add_executable(ArcscriptTest ${CMAKE_CURRENT_SOURCE_DIR}/demo/ArcscriptTest.cpp) include(FetchContent) @@ -144,6 +148,34 @@ if (WITH_TEST) OUTPUT_NAME ArcscriptTest ) + set(ARCSCRIPT_TEST_FIXTURES + valid + member + conditions + stringConcat + runtimeErrors + parseErrors + ) + + foreach(FIXTURE_NAME IN LISTS ARCSCRIPT_TEST_FIXTURES) + add_test( + NAME "Arcscript.${FIXTURE_NAME}" + COMMAND ArcscriptTest + --fixture + "${CMAKE_CURRENT_SOURCE_DIR}/demo/tests/${FIXTURE_NAME}.json" + ) + set_tests_properties("Arcscript.${FIXTURE_NAME}" PROPERTIES + LABELS "arcscript;fixtures" + TIMEOUT 30 + ) + endforeach() + + add_test(NAME Arcscript.NativeApi COMMAND ArcscriptTest --native) + set_tests_properties(Arcscript.NativeApi PROPERTIES + LABELS "arcscript;native" + TIMEOUT 30 + ) + file(GLOB TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/demo/tests/*.json") if (NOT EXISTS "$/tests") diff --git a/Cpp/README.md b/Cpp/README.md index 2418ce5..08bf00b 100644 --- a/Cpp/README.md +++ b/Cpp/README.md @@ -17,4 +17,16 @@ cmake .. cmake --build . ``` -2. The generated libraries will be located in the `build/Debug` or `build/Release` directory, depending on your build configuration. The folder also includes the `include` directory with the header files needed to use the transpiler. \ No newline at end of file +2. The generated libraries will be located in the `build/Debug` or `build/Release` directory, depending on your build configuration. The folder also includes the `include` directory with the header files needed to use the transpiler. + +## Running Tests + +Configure the project with the C++ test suite enabled, build it, and run CTest: + +```bash +cmake -S . -B build -DWITH_TEST=ON +cmake --build build --config Release +ctest --test-dir build --build-config Release --output-on-failure +``` + +CTest runs each shared JSON fixture independently, plus native API tests for reset behavior and variable validation. diff --git a/Cpp/demo/ArcscriptTest.cpp b/Cpp/demo/ArcscriptTest.cpp index a623296..15d9453 100644 --- a/Cpp/demo/ArcscriptTest.cpp +++ b/Cpp/demo/ArcscriptTest.cpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include #include #include "ArcscriptTranspiler.h" @@ -68,6 +70,19 @@ UVariable* getInitialVars(json initialVarsJson) { return initVars; } +void freeInitialVars(UVariable* initVars, size_t initVarLen) { + for (size_t i = 0; i < initVarLen; i++) { + free(const_cast(initVars[i].id)); + free(const_cast(initVars[i].name)); + free(const_cast(initVars[i].scope)); + if (initVars[i].type == VariableType::AW_STRING) { + free(const_cast(initVars[i].string_val)); + free(const_cast(initVars[i].default_string_val)); + } + } + delete[] initVars; +} + std::map getExpectedChanges(json changes) { std::map expectedChanges; @@ -183,24 +198,25 @@ std::string test(json testCase, size_t caseIndex, UVariable* initVars, size_t in if (hasError) { errorOutput << "Test case " << caseIndex << " failed: \"" << code << "\"" << std::endl; errorOutput << "Expected error of type: " << errorType << " but no error thrown." << std::endl; + deallocateOutput(result); free(const_cast(code)); return errorOutput.str(); } - if (testCase.contains("output")) - { - std::string output = testCase["output"].get(); - if (output != result->output) - { - errorOutput << "Different Text Output" << std::endl; - errorOutput << "EXPECTED:\t\"" << output << "\"" << std::endl << "ACTUAL:\t\t\"" << result->output << "\"" << std::endl; - } + const std::string expectedOutput = testCase.value("output", ""); + if (expectedOutput != result->output) { + errorOutput << "Different Text Output" << std::endl; + errorOutput << "EXPECTED:\t\"" << expectedOutput << "\"" << std::endl << "ACTUAL:\t\t\"" << result->output << "\"" << std::endl; } if (testCase.contains("changes")) { - json changes = testCase["changes"]; + const json& expectedChanges = testCase["changes"]; + if (result->changesLen != expectedChanges.size()) { + errorOutput << "Variable change count mismatch: expected " << expectedChanges.size() + << ", got " << result->changesLen << std::endl; + } - for (json::iterator it = changes.begin(); it != changes.end(); ++it) { + for (json::const_iterator it = expectedChanges.begin(); it != expectedChanges.end(); ++it) { const std::string& expectedChangeKey = it.key(); const json& expectedChangeValue = it.value(); UVariableChange change; @@ -280,6 +296,11 @@ std::string test(json testCase, size_t caseIndex, UVariable* initVars, size_t in int testFile(const std::filesystem::path& path, int testIndex = -1) { std::ifstream f(path); + if (!f.is_open()) { + std::cerr << "Fixture file not found: " << path << std::endl; + return 1; + } + json data = json::parse(f); std::cout << "Testing file: " << path; @@ -294,9 +315,11 @@ int testFile(const std::filesystem::path& path, int testIndex = -1) { if (!errorOutput.empty()) { std::cout << errorOutput << std::endl; + freeInitialVars(initVars, initVarLen); return 1; } + freeInitialVars(initVars, initVarLen); return 0; } @@ -320,15 +343,7 @@ int testFile(const std::filesystem::path& path, int testIndex = -1) { - for (int j = 0; j < initVarLen; j++) { - free(const_cast(initVars[j].id)); - free(const_cast(initVars[j].name)); - if (initVars[j].type == VariableType::AW_STRING) { - free(const_cast(initVars[j].string_val)); - free(const_cast(initVars[j].default_string_val)); - } - } - delete[] initVars; + freeInitialVars(initVars, initVarLen); if (fileError) { return 1; @@ -495,10 +510,31 @@ int testLegacyAggregatePreservesScopeAndRejectsMissingDefault() { -int main(int argc, char* argv[]) -{ - // Create an array of the test path files - std::vector testPaths = { +int testNativeApi() { + bool hasErrors = false; + hasErrors = testResetAcrossInvocations("
reset(score)
") != 0 || hasErrors; + hasErrors = testResetAcrossInvocations("
resetAll()
") != 0 || hasErrors; + hasErrors = testMissingDefaultRejected() != 0 || hasErrors; + hasErrors = testNullStringDefaultRejected() != 0 || hasErrors; + hasErrors = testNativeMissingCurrentRejected() != 0 || hasErrors; + hasErrors = testNativeMissingDefaultRejected() != 0 || hasErrors; + hasErrors = testLegacyAggregatePreservesScopeAndRejectsMissingDefault() != 0 || hasErrors; + return hasErrors ? 1 : 0; +} + +int main(int argc, char* argv[]) { + if (argc == 3 && std::string(argv[1]) == "--fixture") { + return testFile(argv[2]); + } + if (argc == 2 && std::string(argv[1]) == "--native") { + return testNativeApi(); + } + if (argc != 1) { + std::cerr << "Usage: ArcscriptTest [--fixture | --native]" << std::endl; + return 1; + } + + const std::vector testPaths = { "./tests/valid.json", "./tests/member.json", "./tests/conditions.json", @@ -509,22 +545,12 @@ int main(int argc, char* argv[]) bool hasErrors = false; for (const auto& path : testPaths) { if (!std::filesystem::exists(path)) { - std::cout << "File not found: " << path << std::endl; - continue; - } - auto result = testFile(path); - if (result != 0) { + std::cerr << "Fixture file not found: " << path << std::endl; hasErrors = true; + continue; } + hasErrors = testFile(path) != 0 || hasErrors; } - hasErrors = testResetAcrossInvocations("
reset(score)
") != 0 || hasErrors; - hasErrors = testResetAcrossInvocations("
resetAll()
") != 0 || hasErrors; - hasErrors = testMissingDefaultRejected() != 0 || hasErrors; - hasErrors = testNullStringDefaultRejected() != 0 || hasErrors; - hasErrors = testNativeMissingCurrentRejected() != 0 || hasErrors; - hasErrors = testNativeMissingDefaultRejected() != 0 || hasErrors; - hasErrors = testLegacyAggregatePreservesScopeAndRejectsMissingDefault() != 0 || hasErrors; - if (hasErrors) { - return 1; - } + hasErrors = testNativeApi() != 0 || hasErrors; + return hasErrors ? 1 : 0; } diff --git a/Cpp/demo/tests/valid.json b/Cpp/demo/tests/valid.json index d50ed3b..fd7e945 100644 --- a/Cpp/demo/tests/valid.json +++ b/Cpp/demo/tests/valid.json @@ -312,20 +312,16 @@ } }, { - "code": "
x = random()
", - "changes": {} + "code": "
x = random()
" }, { - "code": "
x = roll(6)
", - "changes": {} + "code": "
x = roll(6)
" }, { - "code": "
x = roll(6, 5)
", - "changes": {} + "code": "
x = roll(6, 5)
" }, { - "code": "
x = roll(y)
", - "changes": {} + "code": "
x = roll(y)
" }, { "code": "
x = round(2.65)
", @@ -412,7 +408,9 @@ }, { "code": "

How are you?

x = 1

I am fine, thank you.

", - "changes": {}, + "changes": { + "var1": 1 + }, "output": "

How are you?

I am fine, thank you.

" }, { @@ -536,7 +534,10 @@ }, { "code": "
w = \"String with \\\"quotes\\\" \\t tabs and \\n new lines\"
show(w)
", - "output": "

String with \"quotes\" \t tabs and \n new lines

" + "output": "

String with \"quotes\" \t tabs and \n new lines

", + "changes": { + "var4": "String with \\\"quotes\\\" \\t tabs and \\n new lines" + } } ] -} \ No newline at end of file +} From 8929402ec92c1ac51b3f621ab5cb925c7e28e08f Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 12:21:20 +0300 Subject: [PATCH 47/51] fix: update c++ readme on build --- Cpp/README.md | 325 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 308 insertions(+), 17 deletions(-) diff --git a/Cpp/README.md b/Cpp/README.md index 08bf00b..3ab1a81 100644 --- a/Cpp/README.md +++ b/Cpp/README.md @@ -1,32 +1,323 @@ -# Arcscript C++ Transpiler +# Arcscript C++ Interpreter + +The C++ interpreter executes Arcscript embedded in the HTML generated by the Arcweave editor. It can evaluate conditions, update variable state, render output, track visits, and emit runtime events. ## Prerequisites -- CMake 3.10 or higher -- A C++ compiler that supports C++17 or higher +- CMake 3.10 or newer +- A compiler with C++17 support +- Java 11 or newer for running the ANTLR generator +- Python 3 for transforming the C++ grammar +- Network access when CMake downloads the ANTLR C++ runtime and test dependencies + +## Building + +Generate the ANTLR sources from the repository root, then configure an out-of-source build. These commands remain compatible with the project's minimum CMake version: + +```bash +sh generate.sh +mkdir -p Cpp/build +cd Cpp/build +cmake .. -DCMAKE_BUILD_TYPE=Release +cmake --build . --config Release +``` + +The shared library is written under `Cpp/build/lib/`. Public headers are copied to `Cpp/build/lib/include`. + +On Windows, run `generate.ps1` from the repository root instead of `generate.sh`. Visual Studio and other multi-configuration generators select Release at build time, so `--config Release` is required even when `CMAKE_BUILD_TYPE` is ignored. + +With CMake 3.13 or newer, the equivalent root-directory commands are: + +```bash +cmake -S Cpp -B Cpp/build -DCMAKE_BUILD_TYPE=Release +cmake --build Cpp/build --config Release +``` + +## Native C++ API + +Use `Arcweave::ArcscriptTranspiler` when integrating directly into a C++ application. + +```cpp +#include +#include +#include +#include + +#include "ArcscriptTranspiler.h" + +int main() { + using namespace Arcweave; + + Variable health; + health.id = "health-id"; + health.name = "health"; + health.type = VariableType::AW_INTEGER; + health.value = 75; + health.defaultValue = 100; + + std::map variables = { + {health.id, health}, + }; + + ArcscriptTranspiler interpreter( + "current-element-id", + variables, + {}, + [](const char* eventName) { + std::cout << "Arcscript event: " << eventName << '\n'; + } + ); -## Building the Project + const TranspilerOutput result = interpreter.runScript( + "
health -= 10
" + "
show(health)
" + ); -### Windows & Linux + std::cout << result.output << '\n'; + std::cout << std::any_cast(result.changes.at("health-id")) << '\n'; +} +``` + +The example renders `

65

` and records `65` under the stable variable ID `health-id`. + +### Variables + +Each `Variable` requires the following values: + +| Field | Description | +| --- | --- | +| `id` | Stable ID used as the key in returned changes. | +| `name` | Name referenced by Arcscript. | +| `type` | `AW_STRING`, `AW_INTEGER`, `AW_DOUBLE`, or `AW_BOOLEAN`. | +| `value` | Current runtime value. | +| `defaultValue` | Value restored by `reset()` and `resetAll()`. | +| `scope` | Optional component or board scope. An empty string means global. | + +Both `value` and `defaultValue` must contain a value. The interpreter throws `RuntimeErrorException` during initialization when either is missing. + +### Scoped Variables + +Set `scope` to the component or board identifier used in Arcscript: + +```cpp +Variable componentHealth; +componentHealth.id = "player-health-id"; +componentHealth.name = "health"; +componentHealth.type = VariableType::AW_INTEGER; +componentHealth.value = 40; +componentHealth.defaultValue = 50; +componentHealth.scope = "player"; + +std::map variables = { + {componentHealth.id, componentHealth}, +}; -1. After generating the ANTLR4 parser files from the top root folder of this repository, run the following commands to build the project: +Arcweave::ArcscriptTranspiler interpreter( + "", + variables, + {}, + [](const char*) {} +); + +const auto result = interpreter.runScript( + "
player.health = 25
" +); +``` + +Scoped variables are referenced as `.`. Scope identifiers must be unique wherever the interpreter receives variables. + +### Current and Default Values + +Current and default values are intentionally separate. This allows state from an earlier invocation to be supplied while preserving the original reset value: + +```cpp +Variable score; +score.id = "score-id"; +score.name = "score"; +score.type = VariableType::AW_INTEGER; +score.value = 25; +score.defaultValue = 10; + +Arcweave::ArcscriptTranspiler interpreter( + "", + {{score.id, score}}, + {}, + [](const char*) {} +); + +const auto result = interpreter.runScript( + "
reset(score)
" +); + +const int resetValue = std::any_cast(result.changes.at("score-id")); +// resetValue == 10 +``` - ```bash - mkdir build - cd build - cmake .. - cmake --build . - ``` -2. The generated libraries will be located in the `build/Debug` or `build/Release` directory, depending on your build configuration. The folder also includes the `include` directory with the header files needed to use the transpiler. +Apply returned changes to your application state before the next interpreter invocation. The interpreter does not persist returned changes into the caller's variable collection. + +### Conditions + +Condition inputs return a boolean in `TranspilerOutput::result`: + +```cpp +const auto result = interpreter.runScript( + "
player.health > 0
" +); + +if (result.type == Arcweave::InputType::CONDITION) { + const bool condition = std::any_cast(result.result); +} +``` + +### Visits and Events + +Pass visit counts by element ID. The current element ID is used by `visits()` when no mention argument is supplied. + +```cpp +std::map visits = { + {"current-element-id", 3}, + {"other-element-id", 7}, +}; + +Arcweave::ArcscriptTranspiler interpreter( + "current-element-id", + variables, + visits, + [](const char* eventName) { + if (std::string(eventName) == "resetVisits") { + // Reset the application's persisted visit counters. + } + } +); +``` + +`resetVisits()` updates the interpreter's visit state and emits the `resetVisits` event. The callback receives the event name only. + +### Error Handling + +Invalid Arcscript and runtime failures use separate exception types: + +```cpp +const std::string code = "
health = 0
"; + +try { + const auto result = interpreter.runScript(code); +} catch (const Arcweave::ParseErrorException& error) { + std::cerr << "Invalid Arcscript: " << error.what() << '\n'; +} catch (const Arcweave::RuntimeErrorException& error) { + std::cerr << "Arcscript execution failed: " << error.what() << '\n'; +} +``` + +## Exported Bridge API + +`runScriptExport()` provides flat input and output structures for integrations that load the shared library. Although the function uses C linkage, its callback parameter is `std::function`, so the current interface is intended for C++ callers and compatible native bindings rather than direct C callers. + +```cpp +#include + +#include "ArcscriptTranspiler.h" + +int main() { + using namespace Arcweave; + + UVariable health; + health.id = "health-id"; + health.name = "health"; + health.type = VariableType::AW_INTEGER; + health.int_val = 75; + health.default_int_val = 100; + health.has_default_value = true; + + UTranspilerOutput* result = nullptr; + + try { + result = runScriptExport( + "
health -= 10
", + "current-element-id", + &health, + 1, + nullptr, + 0, + [](const char*) {} + ); + + for (size_t i = 0; i < result->changesLen; i++) { + const UVariableChange& change = result->changes[i]; + if (change.type == VariableType::AW_INTEGER) { + std::cout << change.varId << ": " << change.int_result << '\n'; + } + } + } catch (const ParseErrorException& error) { + std::cerr << "Invalid Arcscript: " << error.what() << '\n'; + } catch (const RuntimeErrorException& error) { + std::cerr << "Arcscript execution failed: " << error.what() << '\n'; + } + + if (result != nullptr) { + deallocateOutput(result); + } +} +``` + +### Bridge Variable Fields + +Set the current and default field matching `UVariable::type`: + +| Type | Current value | Default value | +| --- | --- | --- | +| `AW_STRING` | `string_val` | `default_string_val` | +| `AW_INTEGER` | `int_val` | `default_int_val` | +| `AW_DOUBLE` | `double_val` | `default_double_val` | +| `AW_BOOLEAN` | `bool_val` | `default_bool_val` | + +Set `has_default_value` to `true` after assigning the matching default field. Set `scope` to a component or board identifier, or leave it as `nullptr` for a global variable. + +### Reading Bridge Results + +Read the field matching each `UVariableChange::type`: + +```cpp +switch (change.type) { + case Arcweave::VariableType::AW_STRING: + std::cout << change.string_result; + break; + case Arcweave::VariableType::AW_INTEGER: + std::cout << change.int_result; + break; + case Arcweave::VariableType::AW_DOUBLE: + std::cout << change.double_result; + break; + case Arcweave::VariableType::AW_BOOLEAN: + std::cout << change.bool_result; + break; + default: + break; +} +``` + +For condition input, check `result->type == Arcweave::InputType::CONDITION` and read `result->conditionResult`. + +### Memory Ownership + +- Input strings, variable arrays, and visit arrays remain owned by the caller and must stay valid for the duration of `runScriptExport()`. +- The returned `UTranspilerOutput`, output string, change array, variable IDs, and changed string values are allocated by the library. +- Call `deallocateOutput(result)` exactly once for every non-null result. +- Do not separately free fields owned by `UTranspilerOutput`. +- Calling `deallocateOutput(nullptr)` is not supported, so guard nullable result pointers when an exception can occur. ## Running Tests Configure the project with the C++ test suite enabled, build it, and run CTest: ```bash -cmake -S . -B build -DWITH_TEST=ON -cmake --build build --config Release -ctest --test-dir build --build-config Release --output-on-failure +mkdir -p Cpp/build +cd Cpp/build +cmake .. -DCMAKE_BUILD_TYPE=Release -DWITH_TEST=ON +cmake --build . --config Release +ctest -C Release --output-on-failure ``` -CTest runs each shared JSON fixture independently, plus native API tests for reset behavior and variable validation. +The `-C Release` option is needed by multi-configuration generators and is harmless for single-configuration generators. With CMake 3.20 or newer, CTest may instead be run from the repository root with `ctest --test-dir Cpp/build -C Release --output-on-failure`. + +CTest runs the valid-script, member-variable, condition, string-concatenation, runtime-error, and parse-error fixtures independently. It also runs native API tests for reset behavior, current/default values, and variable validation. From 13bd405803a4a3e96eac9f28aaae5b772c1d43b1 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 12:56:57 +0300 Subject: [PATCH 48/51] chore: add contributing and license section in C++ interpreter readme --- Cpp/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Cpp/README.md b/Cpp/README.md index 3ab1a81..78e0abd 100644 --- a/Cpp/README.md +++ b/Cpp/README.md @@ -321,3 +321,17 @@ ctest -C Release --output-on-failure The `-C Release` option is needed by multi-configuration generators and is harmless for single-configuration generators. With CMake 3.20 or newer, CTest may instead be run from the repository root with `ctest --test-dir Cpp/build -C Release --output-on-failure`. CTest runs the valid-script, member-variable, condition, string-concatenation, runtime-error, and parse-error fixtures independently. It also runs native API tests for reset behavior, current/default values, and variable validation. + +## Contributing + +1. Generate the ANTLR sources from the repository root. +2. Configure the C++ project with `WITH_TEST=ON`. +3. Add or update JSON fixtures and native API tests for behavioral changes. +4. Build the project and run the complete CTest suite. +5. Open a pull request describing the change and its test coverage. + +Keep changes focused and preserve behavior across the C++, C#, and TypeScript interpreters where the implementations share Arcscript semantics. + +## License + +This project is licensed under the terms in [`LICENSE.txt`](../LICENSE.txt). From 8a10577f1fb9c79b421438588dd3f2a3a3c6814b Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 13:19:52 +0300 Subject: [PATCH 49/51] chore: add c# readme --- CSharp/README.md | 278 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 +- 2 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 CSharp/README.md diff --git a/CSharp/README.md b/CSharp/README.md new file mode 100644 index 0000000..1195668 --- /dev/null +++ b/CSharp/README.md @@ -0,0 +1,278 @@ +# Arcscript C# Interpreter + +The C# interpreter executes Arcscript embedded in the HTML generated by the Arcweave editor. It can evaluate conditions, update global and scoped variables, render output, read visit counts, and emit runtime events. + +## Prerequisites + +- .NET 6 SDK or newer +- Java 11 or newer for running the ANTLR generator +- Python 3 when using the repository-level generation scripts + +The project restores the ANTLR C# runtime and test dependencies through NuGet. + +## Building + +Generate the ANTLR sources from the repository root, then restore and build the C# project: + +```bash +sh generate.sh +dotnet restore CSharp/CSharp.csproj +dotnet build CSharp/CSharp.csproj --configuration Release +``` + +On Windows, run `generate.ps1` instead of `generate.sh`. + +The current `CSharp.csproj` contains both the interpreter and its NUnit test suite. Applications can reference the project while developing in this repository, or integrate the interpreter sources and the `Antlr4.Runtime.Standard` dependency into their own project structure. + +## Quick Start + +Create the Arcweave project model, construct an `AwInterpreter`, and pass Arcscript as an HTML fragment containing `
` elements:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using Arcweave.Interpreter;
+using Arcweave.Interpreter.INodes;
+using Arcweave.Project;
+
+var health = new Variable("health-id", "health", 100)
+{
+    Value = 75,
+};
+
+var project = new Arcweave.Project.Project(
+    new List(),
+    new List { health }
+);
+
+var interpreter = new AwInterpreter(project);
+var result = interpreter.RunScript(
+    "
health -= 10
" + + "
show(health)
" +); + +Console.WriteLine(result.Output); +Console.WriteLine(result.Changes["health-id"]); +``` + +The example renders `

65

` and records `65` under the stable variable ID `health-id`. + +## Project Model + +`AwInterpreter` receives an implementation of `IProject`. The provided `Arcweave.Project.Project` model contains: + +| Collection | Description | +| --- | --- | +| `Variables` | Global Arcscript variables. | +| `Boards` | Boards containing elements, variables, and attributes. | +| `Components` | Components containing variables and attributes. | + +The standard constructors use `List` collections: + +```csharp +var project = new Arcweave.Project.Project( + boards, + globalVariables, + components +); +``` + +The components argument is optional and defaults to an empty list. + +## Variables + +Create variables with a stable ID, an Arcscript name, and a non-null default value: + +```csharp +var score = new Variable("score-id", "score", 10); +``` + +The runtime type is inferred from the constructor value. The interpreter currently supports the variable value types used by Arcscript: `string`, `int`, `double`, and `bool`. + +### Current and Default Values + +The constructor value becomes both `Value` and `DefaultValue`. To restore persisted state while preserving the original reset value, assign `Value` after construction: + +```csharp +var score = new Variable("score-id", "score", 10) +{ + Value = 25, +}; + +var project = new Arcweave.Project.Project( + new List(), + new List { score } +); + +var result = new AwInterpreter(project).RunScript( + "
reset(score)
" +); + +var resetValue = (int)result.Changes["score-id"]; +// resetValue == 10 +``` + +`reset()` and `resetAll()` return changes keyed by variable ID. They do not mutate the caller's `Variable.Value` property automatically. + +### Applying Changes Between Runs + +Apply returned changes to the project model before creating state for a later invocation: + +```csharp +var firstResult = interpreter.RunScript( + "
health -= 10
" +); + +var variablesById = new Dictionary(); +foreach (var variable in project.GetAllVariables()) +{ + variablesById[variable.Id] = variable; +} + +foreach (var change in firstResult.Changes) +{ + variablesById[change.Key].Value = change.Value; +} + +var secondResult = interpreter.RunScript( + "
show(health)
" +); +``` + +## Board and Component Variables + +Board and component variables use the container's `CustomId` as their Arcscript scope: + +```csharp +var board = new Board( + "board-id", + new List(), + "inventory" +); +board.AddVariable(new Variable("board-gold-id", "gold", 20)); + +var component = new Component("player"); +component.AddVariable(new Variable("player-health-id", "health", 100)); + +var project = new Arcweave.Project.Project( + new List { board }, + new List(), + new List { component } +); + +var result = new AwInterpreter(project).RunScript( + "
inventory.gold += 5
" + + "
player.health -= 10
" +); +``` + +The returned changes use `board-gold-id` and `player-health-id`, not the Arcscript names. Scope identifiers should be unique across boards and components supplied to one project. + +## Interpreter Results + +`AwInterpreter.RunScript()` returns `AwInterpreter.TranspilerOutput`: + +| Property | Description | +| --- | --- | +| `Output` | Rendered HTML produced by content and `show()`. | +| `Changes` | Variable updates keyed by stable variable ID. | +| `Result` | The evaluated result, including boolean condition results. | + +### Conditions + +For condition input, cast `Result` to `bool`: + +```csharp +var result = interpreter.RunScript( + "
health > 0
" +); + +var isAlive = (bool)result.Result; +``` + +## Visits and Events + +Add `Element` instances to boards and set their persisted visit counts. Pass the current element ID to `AwInterpreter` so `visits()` can read it without an argument: + +```csharp +var currentElement = new Element("current-element-id") +{ + Visits = 3, +}; + +var board = new Board( + "board-id", + new List { currentElement }, + "main" +); + +var project = new Arcweave.Project.Project( + new List { board }, + new List() +); + +var interpreter = new AwInterpreter( + project, + "current-element-id", + eventName => + { + if (eventName == "resetVisits") + { + Console.WriteLine("Visit counters were reset."); + } + } +); + +var result = interpreter.RunScript( + "
show(visits())
" +); +``` + +`resetVisits()` sets every project element's `Visits` value to zero and emits the `resetVisits` event. + +## Error Handling + +Parsing and runtime failures use separate exception types: + +```csharp +try +{ + var result = interpreter.RunScript(code); +} +catch (ParseException error) +{ + Console.Error.WriteLine($"Invalid Arcscript: {error.Message}"); +} +catch (RuntimeException error) +{ + Console.Error.WriteLine($"Arcscript execution failed: {error.Message}"); +} +``` + +## Godot Integration + +The source contains conditional `GODOT` implementations for Godot object and collection types. The standard `CSharp.csproj` builds and tests the non-Godot path; it does not reference the Godot SDK or validate a downstream Godot project. + +Godot integrations should define `GODOT`, reference the matching Godot assemblies, and run their own build and runtime tests after importing the interpreter sources. + +## Running Tests + +Run the NUnit suite from the repository root: + +```bash +dotnet test CSharp/CSharp.csproj --configuration Release +``` + +The suite covers valid scripts, member variables, conditions, string operations, parse errors, runtime errors, output rendering, visits, events, reset behavior, and board/component variable integration. + +## Contributing + +1. Generate the ANTLR sources from the repository root. +2. Add or update JSON fixtures and NUnit tests for behavioral changes. +3. Run the Release build and complete C# test suite. +4. Preserve shared Arcscript behavior across the C#, C++, and TypeScript interpreters where applicable. +5. Open a pull request describing the change and its test coverage. + +## License + +This project is licensed under the terms in [`LICENSE.txt`](../LICENSE.txt). diff --git a/README.md b/README.md index 56c4342..473731f 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ You can find more info in the [TypeScript](TypeScript) folder. ### C# -Use the C# code along with the antlr4 NuGet package in your project. +Use the C# interpreter with the ANTLR C# runtime in .NET and Godot integrations. -Find more info in the [CSharp](CSharp) folder. +See the [C# interpreter guide](CSharp/README.md) for build instructions, project-model examples, scoped variables, errors, events, and tests. ### C++ From cdf777b9780d3555c1f643386b012dd578c02735 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 13:33:22 +0300 Subject: [PATCH 50/51] chore: align licenses --- README.md | 138 ++++++++++++++++++++++++++++++++-------- TypeScript/README.md | 2 +- TypeScript/package.json | 2 +- 3 files changed, 112 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 473731f..0f500ee 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,142 @@ # Arcscript Interpreters -This repository includes the supported interpreters for **arcscript**, the scripting language of [Arcweave](https://arcweave.com), for [C++](Cpp), [C#](CSharp) and [TypeScript](TypeScript). +This repository contains the supported C++, C#, and TypeScript interpreters for **Arcscript**, the scripting language used by [Arcweave](https://arcweave.com/). -The interpreters are built using [ANTLR 4](https://github.com/antlr/antlr4), a parser generator for reading, processing, executing, or translating structured text or binary files. +All three implementations are generated from the same grammar and provide the runtime behavior needed to evaluate conditions, update variables, render script output, and integrate Arcscript into applications and game engines. -## How it's made +## Implementations -### Why a parser generator? +| Implementation | Runtime | Primary use | Guide | +| --- | --- | --- | --- | +| TypeScript | ES2022 with browser DOM APIs | Arcweave web applications and JavaScript/TypeScript consumers | [TypeScript guide](TypeScript/README.md) | +| C# | .NET 6 | .NET, Unity, and Godot integrations | [C# guide](CSharp/README.md) | +| C++ | C++17 and CMake 3.10+ | Native applications and engine integrations | [C++ guide](Cpp/README.md) | -In order to support updating game data from Arcweave that use arcscript during runtime in game engines, we had to find a way to interpret it without having to rebuild the game. This lead us to use a parser generator. ANTLR 4 is a great option for that, since it can create parsers for multiple languages, including C++ (which we use for unreal), C# (for Unity and Godot) and TypeScript (for our frontend). +The TypeScript implementation is also distributed as [`@arcweave/arcscript`](https://www.npmjs.com/package/@arcweave/arcscript). -### Our Implementation +## Why ANTLR 4? -In folder [`grammar`](grammar) you can find the files [`ArcscriptLexer.g4`](grammar/ArcscriptLexer.g4) and [`ArcscriptParser.g4`](grammar/ArcscriptParser.g4), the Grammar files for the Lexer and the Parser respectively. +Arcweave projects can be updated while an application or game is running. Arcscript therefore needs to be interpreted at runtime without rebuilding the host application whenever project content changes. -### Build +[ANTLR 4](https://github.com/antlr/antlr4) provides a shared grammar-driven approach for this requirement. The lexer and parser rules are defined once and then generated for every supported target language. This gives the repository several advantages: -#### Prerequisites +- **Consistent syntax:** C++, C#, and TypeScript parse the same Arcscript language. +- **Runtime portability:** Generated parsers can be embedded in web applications, .NET projects, and native engines. +- **Centralized grammar changes:** Language syntax is maintained in one lexer and parser definition rather than reimplemented manually. +- **Comparable behavior:** Parallel fixtures and tests can verify that the interpreters preserve shared Arcscript semantics. -- curl -- Java JRE to run the antlr4 tool -- python3 +The grammar sources are: -In order to build all Parsers for C++, C# and TypeScript, run the [`generate.sh`](generate.sh) shell script. +- [`grammar/ArcscriptLexer.g4`](grammar/ArcscriptLexer.g4) +- [`grammar/ArcscriptParser.g4`](grammar/ArcscriptParser.g4) + +Each implementation adds its own parser base, visitor, runtime state, functions, error handling, and host-language integration around the generated ANTLR code. + +## Repository Layout + +| Path | Contents | +| --- | --- | +| [`grammar`](grammar) | Shared Arcscript lexer and parser grammars, plus generation helpers. | +| [`TypeScript`](TypeScript/README.md) | TypeScript interpreter, package configuration, and Vitest suite. | +| [`CSharp`](CSharp/README.md) | C# interpreter, project model, and NUnit suite. | +| [`Cpp`](Cpp/README.md) | C++ interpreter, exported bridge API, and CTest suite. | +| [`.github/workflows`](.github/workflows) | Continuous-integration workflows for each implementation. | + +## Generating Parsers + +Parser generation is separate from building or testing the interpreters. The generation scripts regenerate the TypeScript, C#, and C++ ANTLR sources from the shared grammar. + +### Prerequisites + +- Java 11 or newer +- Python 3 +- `curl` on Unix-like systems when `antlr4.jar` is missing +- PowerShell on Windows + +Run one of the following commands from the repository root. + +**Linux and macOS:** ```bash -$ sh generate.sh +sh generate.sh ``` -This script: +**Windows:** + +```powershell +.\generate.ps1 +``` -1. Will download the `antlr4.jar` file to create the Parsers with -2. Generate the parsers for each language +If `antlr4.jar` is not present, the scripts download the configured ANTLR tool before generating the parser sources. +## Building and Testing -## Languages +The following commands provide a repository-level quick reference. See each implementation guide for complete setup, API examples, and platform-specific details. ### TypeScript -We are using the TypeScript parser to interpret arcscript in our Arcweave app. We have also created a package, [`@arcweave/arcscript`](https://www.npmjs.com/package/@arcweave/arcscript), using the generated files in [TypeScript](TypeScript), along with a custom ANTLR Visitor to parse the generated Parse Tree from ANTLR. +```bash +cd TypeScript +pnpm install +pnpm build +pnpm test --run +``` -You can find more info in the [TypeScript](TypeScript) folder. +The package declares the supported pnpm version in `TypeScript/package.json`. ### C# -Use the C# interpreter with the ANTLR C# runtime in .NET and Godot integrations. - -See the [C# interpreter guide](CSharp/README.md) for build instructions, project-model examples, scoped variables, errors, events, and tests. +```bash +dotnet restore CSharp/CSharp.csproj +dotnet build CSharp/CSharp.csproj --configuration Release --no-restore +dotnet test CSharp/CSharp.csproj --configuration Release --no-build +``` ### C++ -Building the C++ solution will create the include headers and the shared libaries (.dll for Windows and .dylib for macOS) to be included in your project -Find more info in the [Cpp](Cpp) folder. +These commands remain compatible with the project's CMake 3.10 minimum: + +```bash +mkdir -p Cpp/build +cd Cpp/build +cmake .. -DCMAKE_BUILD_TYPE=Release -DWITH_TEST=ON +cmake --build . --config Release +ctest -C Release --output-on-failure +``` + +On Windows, create `Cpp/build`, run CMake from that directory, and select the Release configuration with `--config Release`. + +## Cross-Language Behavior + +The interpreters share the Arcscript grammar and maintain parallel fixture categories for: + +- valid scripts and assignments +- global and scoped member variables +- conditions and expressions +- string operations and rendered output +- visits and runtime events +- parse and runtime errors +- variable reset behavior + +Behavioral changes should be reviewed across all affected implementations. When a feature is intended to behave consistently in C++, C#, and TypeScript, update the corresponding fixtures or tests in each language rather than validating only one runtime. + +Implementation-specific APIs may differ because each host language has different state, ownership, error, and integration requirements. The language guides document those differences. + +## Contributing + +1. Generate the parser sources after changing the grammar. +2. Keep changes focused within the affected implementation or shared grammar. +3. Add or update tests for every behavioral change. +4. Run the relevant language suites before opening a pull request. +5. Verify cross-language parity when modifying shared Arcscript semantics. +6. Document public API, build, or integration changes in the relevant language guide. + +## License + +This project is licensed under the terms in [`LICENSE.txt`](LICENSE.txt). -## Links & References +## References - [Arcweave](https://arcweave.com/) -- [Arcscript Documentation](https://arcweave.com/docs/1.0/arcscript) -- https://www.antlr.org/ +- [Arcscript documentation](https://arcweave.com/docs/1.0/arcscript) +- [ANTLR 4](https://www.antlr.org/) diff --git a/TypeScript/README.md b/TypeScript/README.md index 3a413cf..3a626b3 100644 --- a/TypeScript/README.md +++ b/TypeScript/README.md @@ -242,4 +242,4 @@ The build writes compiled files and declarations to `dist/`. ## License -ISC +This project is licensed under the terms in [`LICENSE.txt`](../LICENSE.txt). diff --git a/TypeScript/package.json b/TypeScript/package.json index b6e1087..93a7b46 100644 --- a/TypeScript/package.json +++ b/TypeScript/package.json @@ -30,7 +30,7 @@ }, "homepage": "https://github.com/arcweave/arcscript-interpreters#readme", "author": "Arcweave", - "license": "ISC", + "license": "BSD-3-Clause", "packageManager": "pnpm@10.12.2", "files": [ "dist" From 3b9ccd8febc4e735596c5ac82da94e7ef910eaf6 Mon Sep 17 00:00:00 2001 From: Panayotis Kalaitzoglou Date: Thu, 20 Aug 2026 16:14:06 +0300 Subject: [PATCH 51/51] fix: C# report scripts as conditions --- CSharp/Interpreter/ArcscriptTranspiler.cs | 4 ++-- CSharp/UnitTests.cs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CSharp/Interpreter/ArcscriptTranspiler.cs b/CSharp/Interpreter/ArcscriptTranspiler.cs index 3f4c2dd..889fea2 100644 --- a/CSharp/Interpreter/ArcscriptTranspiler.cs +++ b/CSharp/Interpreter/ArcscriptTranspiler.cs @@ -83,7 +83,7 @@ public TranspilerOutput RunScript(string code) // List outputs = visitor.state.outputs; var outputResult = visitor.state.Outputs.GetText(); - var isCondition = tree.script() != null; + var isCondition = tree.condition() != null; return new TranspilerOutput(outputResult, visitor.state.VariableChanges, result, isCondition); } @@ -112,4 +112,4 @@ public TranspilerOutput(string output, Dictionary changes, objec } } } -} \ No newline at end of file +} diff --git a/CSharp/UnitTests.cs b/CSharp/UnitTests.cs index 3287dfe..5d9eb5b 100644 --- a/CSharp/UnitTests.cs +++ b/CSharp/UnitTests.cs @@ -239,6 +239,23 @@ public void ConditionTests(Project.Project project, TestCase testCase, Dictionar Assert.That(output.Result, Is.EqualTo(testCase.result)); } + [Test] + public void ReportsWhetherInputIsACondition() + { + var project = new Project.Project( + new List(), + new List(), + new List() + ); + var interpreter = new AwInterpreter(project); + + var scriptOutput = interpreter.RunScript("

Hello

"); + var conditionOutput = interpreter.RunScript("
true
"); + + Assert.That(scriptOutput.IsCondition, Is.False); + Assert.That(conditionOutput.IsCondition, Is.True); + } + [Test] [TestCaseSource(nameof(GetStringConcatTestData))] public void StringConcatTests(Project.Project project, TestCase testCase, Dictionary initialVars)