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..dee3c92 100644 --- a/.github/workflows/cpp-build-test.yml +++ b/.github/workflows/cpp-build-test.yml @@ -6,31 +6,36 @@ 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: 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' }} 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: 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) @@ -38,19 +43,24 @@ 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-2022' + 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 -DCMAKE_BUILD_TYPE=Release -DWITH_TEST=true - name: Build - run: | - cd Cpp/build - cmake --build . + 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 + with: + name: arcscript-cpp-${{ matrix.os }}-${{ github.sha }} + path: Cpp/build/lib/** + if-no-files-found: error + retention-days: 14 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" 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/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/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/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/CSharp/UnitTests.cs b/CSharp/UnitTests.cs index 09f29d2..5d9eb5b 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; } @@ -225,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) @@ -280,6 +311,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/CMakeLists.txt b/Cpp/CMakeLists.txt index 7bf26c3..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") @@ -162,7 +194,6 @@ if (WITH_TEST) "$/${DLL_NAME}" "$/${LIBRARY_NAME}" "$/${PROJECT_NAME}.exp" - "$/${PROJECT_NAME}.pdb" ) else() set(ARCSCRIPT_TRANSPILER_LIB_FILES @@ -180,4 +211,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) diff --git a/Cpp/README.md b/Cpp/README.md index 2418ce5..78e0abd 100644 --- a/Cpp/README.md +++ b/Cpp/README.md @@ -1,20 +1,337 @@ -# 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'; + } + ); + + const TranspilerOutput result = interpreter.runScript( + "
health -= 10
" + "
show(health)
" + ); + + 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}, +}; + +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 +``` + +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 +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 +``` + +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 -## Building the Project +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. -### Windows & Linux +Keep changes focused and preserve behavior across the C++, C#, and TypeScript interpreters where the implementations share Arcscript semantics. -1. After generating the ANTLR4 parser files from the top root folder of this repository, run the following commands to build the project: +## License - ```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. \ No newline at end of file +This project is licensed under the terms in [`LICENSE.txt`](../LICENSE.txt). diff --git a/Cpp/demo/ArcscriptTest.cpp b/Cpp/demo/ArcscriptTest.cpp index 00e8f20..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" @@ -28,6 +30,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 +50,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; } @@ -63,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; @@ -178,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; @@ -275,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; @@ -289,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; } @@ -315,14 +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)); - } - } - delete[] initVars; + freeInitialVars(initVars, initVarLen); if (fileError) { return 1; @@ -331,12 +352,189 @@ 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; -int main(int argc, char* argv[]) -{ - // Create an array of the test path files - std::vector testPaths = { + 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 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", @@ -347,15 +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; } - 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 +} 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/README.md b/README.md index 8e82f34..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 [JavaScript](JavaScript). +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 JavaScript (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 JavaScript, 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:** -1. Will download the `antlr4.jar` file to create the Parsers with -2. Generate the parsers for each language +```powershell +.\generate.ps1 +``` +If `antlr4.jar` is not present, the scripts download the configured ANTLR tool before generating the parser sources. -## Languages +## Building and Testing -### JavaScript +The following commands provide a repository-level quick reference. See each implementation guide for complete setup, API examples, and platform-specific details. -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. +### TypeScript -You can find more info in [JavaScript](JavaScript) folder. +```bash +cd TypeScript +pnpm install +pnpm build +pnpm test --run +``` -### C# +The package declares the supported pnpm version in `TypeScript/package.json`. -Use the C# code along with the antlr4 NuGet package in your project. +### C# -Find more info in the [CSharp](CSharp) folder. +```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 59a903a..3a626b3 100644 --- a/TypeScript/README.md +++ b/TypeScript/README.md @@ -1,72 +1,245 @@ ![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 +`@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. -- [JS Arcscript Interpreter](#js-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. -Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. +### `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 \ No newline at end of file +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" diff --git a/TypeScript/src/ArcscriptFunctions.ts b/TypeScript/src/ArcscriptFunctions.ts index cb6d1c5..94f497f 100644 --- a/TypeScript/src/ArcscriptFunctions.ts +++ b/TypeScript/src/ArcscriptFunctions.ts @@ -3,54 +3,59 @@ import ArcscriptState from './ArcscriptState.js'; import { MentionResult, VarValue } from './types.js'; import ArcscriptVariable from './ArcscriptVariable.js'; -export 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]; -export type ArcscriptVoidFunctionKeys = VoidFunctionKeys; +export type ArcscriptFunctionName = FunctionKeys; -export type ArcscriptNonVoidFunctionKeys = Exclude< - FunctionName, - ArcscriptVoidFunctionKeys ->; +type ArgumentType = VarValue | MentionResult | ArcscriptVariable; +type ArgumentTypes = ArgumentType[]; -type ArgumentTypes = (VarValue | MentionResult | ArcscriptVariable)[]; +const ESCAPE_SEQUENCES: Readonly> = { + a: '\x07', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + "'": "'", + '"': '"', + '\\': '\\', +}; export default class ArcscriptFunctions { - private state: ArcscriptState; + private readonly state: ArcscriptState; constructor(state: ArcscriptState) { this.state = state; } 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 { @@ -60,150 +65,124 @@ 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); - 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; } 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); } 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 variablesToReset = Object.values(this.state.variables).filter( - v => !except.includes(v.id) + const except = new Set( + this.getVariableArguments('resetAll', args).map(variable => variable.id) ); - variablesToReset.forEach(variable => variable.reset()); + this.state.resetVariablesExcept(except); } 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 { - let elementId = this.state.currentElement; - if (args.length > 0) { - const mention = args[0] as MentionResult; - if ( - typeof mention !== 'object' || - 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)) { - throw new RuntimeError( - `Invalid mention id: ${mention.attrs['data-id']}` - ); - } - elementId = mention.attrs['data-id']; + 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]; + return visitCount; } resetVisits(): void { this.state.resetVisits(); } - /** - * 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: VarValue | MentionResult | ArcscriptVariable - ) { - if (typeof arg !== 'number' || Number.isNaN(arg)) { + 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 ${arg} in function ${name}. Expected number (integer or float)` + `Invalid argument ${mention} in function visits. Expected an element mention` ); } + return mention.attrs['data-id']; } - /** - * Checks if the function argument is a positive integer - * @param {string} name The function name - * @param {VarValue} arg The argument to check - */ - private assertPositiveInteger( + private getVariableArguments( name: string, - arg: VarValue | MentionResult | ArcscriptVariable - ) { + 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; + }); + } + + 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)` + `Invalid argument ${arg} in function ${name}. Expected number (integer or float)` ); } - if (arg <= 0) { + } + + private assertNumbers( + name: string, + args: ArgumentTypes + ): asserts args is number[] { + args.forEach(arg => this.assertNumber(name, arg)); + } + + private assertPositiveInteger( + name: string, + 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` ); diff --git a/TypeScript/src/ArcscriptState.ts b/TypeScript/src/ArcscriptState.ts index 0169f43..bf02dfe 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) { @@ -23,9 +24,18 @@ 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) { + 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 +44,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) { @@ -42,26 +55,21 @@ 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 { - variables: Record; - - elementVisits: Record; - currentElement: string; - outputs: OutputObject[]; - conditionDepth: number; - emit: (event: string, data?: unknown) => void; - outputDoc: Document; - rootElement: HTMLElement; - inBlockquote: boolean; - insertBlockquote: boolean; + private readonly variables: Record; + private readonly elementVisits: Record; + private readonly currentElement: string; + private readonly outputs: OutputEntry[]; + 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, @@ -85,9 +93,9 @@ export default class ArcscriptState { this.insertBlockquote = false; } - initializeVariables(arcscriptVariables: ArcscriptStateDef) { + 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, @@ -103,10 +111,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`); @@ -114,123 +122,65 @@ 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]) { + 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.hasChanged()) + .map(([id, variable]) => [id, variable.getValue()]) + ) 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) { - 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(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); } @@ -238,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, @@ -279,4 +226,89 @@ export default class ArcscriptState { }); this.emit('resetVisits', {}); } + + private parseOutputNode(output: string): Element | null { + return new DOMParser().parseFromString(output, 'text/html').body + .firstElementChild; + } + + private recordOutput(fromScript: boolean): void { + this.outputs.push({ + conditionDepth: this.conditionDepth, + fromScript, + 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: OutputEntry | undefined + ): boolean { + if (!previousOutput) { + return false; + } + if (previousOutput.isScript) { + return true; + } + return ( + previousOutput.fromScript || + previousOutput.conditionDepth !== 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); + } } diff --git a/TypeScript/src/ArcscriptVariable.ts b/TypeScript/src/ArcscriptVariable.ts index 11e2f9b..32ea4b1 100644 --- a/TypeScript/src/ArcscriptVariable.ts +++ b/TypeScript/src/ArcscriptVariable.ts @@ -1,34 +1,16 @@ -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 { - id: string; - name: string; - type: string; - value: VarValue; - defaultValue: VarValue; - changed = false; - scope: string | null; - - constructor({ - id, - name, - defaultValue, - type, - value, - scope, - }: ArcscriptVariableParams) { + readonly id: string; + readonly name: string; + private value: VarValue; + private readonly defaultValue: VarValue; + private changed = false; + readonly scope: string | null; + + constructor({ id, name, defaultValue, value, scope }: VarDef) { this.id = id; this.name = name; - this.type = type; this.defaultValue = defaultValue; this.scope = scope || null; @@ -40,21 +22,21 @@ export default class ArcscriptVariable { } } - reset() { + reset(): void { this.value = this.defaultValue; this.changed = true; } - getValue() { + getValue(): VarValue { return this.value; } - setValue(newValue: VarValue) { - this.value = newValue; - this.changed = true; + hasChanged(): boolean { + return this.changed; } - getType() { - return this.type; + setValue(newValue: VarValue): void { + this.value = newValue; + this.changed = true; } } diff --git a/TypeScript/src/ArcscriptVisitor.ts b/TypeScript/src/ArcscriptVisitor.ts index 86987f8..dc60200 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'; @@ -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(); @@ -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(); @@ -414,33 +414,22 @@ 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()); } 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()); } } - // TODO: remove non void function keys return this.functions[function_name](...argument_list); }; 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__/ArcscriptFunctions.test.ts b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts new file mode 100644 index 0000000..c0810d7 --- /dev/null +++ b/TypeScript/src/__tests__/ArcscriptFunctions.test.ts @@ -0,0 +1,198 @@ +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 createFunctionContext( + elementVisits: Record = {}, + currentElement = '' +) { + const state = new ArcscriptState({}, elementVisits, currentElement, () => {}); + return { + functions: new ArcscriptFunctions(state), + state, + }; +} + +function createFunctions( + elementVisits: Record = {}, + currentElement = '' +): ArcscriptFunctions { + return createFunctionContext(elementVisits, currentElement).functions; +} + +function createMention(elementId: string): MentionResult { + return { + attrs: { + 'data-id': elementId, + 'data-type': 'element', + }, + label: '', + }; +} + +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' + ); + }); +}); + +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(); + const pushOutput = vi.spyOn(state, 'pushOutput'); + + functions.show(String.raw`\a\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', () => { + 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('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.getVar('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); + }); + + 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: {} }, + { 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 }); + + 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); +} 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({}); + }); +}); diff --git a/TypeScript/src/__tests__/antlr4.interpreter.test.ts b/TypeScript/src/__tests__/antlr4.interpreter.test.ts index fa27e82..8f55d96 100644 --- a/TypeScript/src/__tests__/antlr4.interpreter.test.ts +++ b/TypeScript/src/__tests__/antlr4.interpreter.test.ts @@ -105,6 +105,94 @@ 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: 'global', + }, + globalHealth: { + id: 'globalHealth', + name: 'health', + type: 'integer', + defaultValue: 20, + }, + }; + + 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, " ", global.health)
health = 21
global.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'); + }); + + 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', () => { test.each(stringTests.cases as unknown as TestCase[])( 'Tests script: $code', @@ -189,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', () => { @@ -207,21 +325,22 @@ 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' ); - - expect(result).toBe( - '
comp2.x = boardTwo.xyz + x
' + const result = interpreter.replaceScope( + componentScopeReplaced, + 'boardOne', + 'boardTwo' ); + + expect(result).toBe('
comp2.x = boardTwo.xyz + x
'); }); }); 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 +} diff --git a/TypeScript/src/antlr4.interpreter.ts b/TypeScript/src/antlr4.interpreter.ts index 2aaf52e..df30f16 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; @@ -14,26 +15,27 @@ 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; - elementVisits: Record; - currentElement: string; - variableOffsets: { start: number; end: number }[]; - emit: (event: string, data?: unknown) => void; - - constructor(options = {} as ArcscriptInterpreterOptions) { + 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; - this.elementVisits = options.elementVisits || {}; - this.currentElement = options.currentElement || ''; - this.variableOffsets = []; - this.emit = options.eventHandler || (() => {}); + this.elementVisits = options.elementVisits ?? {}; + this.currentElement = options.currentElement ?? ''; + this.emit = options.eventHandler ?? (() => {}); } runScript(code: string, varValues: Record = {}) { - this.variableOffsets = []; - - this.state = new ArcscriptState( + const state = new ArcscriptState( this.arcscriptVariables, this.elementVisits, this.currentElement, @@ -42,28 +44,22 @@ export default class Interpreter { 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, }; } parse(code: string) { - const chars = new CharStream(code); - const lexer = new ArcscriptLexer(chars); - const errorListener = new ErrorListener(); - lexer.removeErrorListeners(); - lexer.addErrorListener(errorListener); + const { chars, lexer, errorListener } = this.createLexer(code); const tokens = new antlr4.CommonTokenStream(lexer); const parser = new ArcscriptParser(tokens); parser.setOptions({ @@ -75,7 +71,6 @@ export default class Interpreter { parser.removeErrorListeners(); parser.addErrorListener(errorListener); const tree = parser.input(); - // const visitor = new ArcscriptVisitor(); return { chars, @@ -86,26 +81,10 @@ 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); - - return { - tokenTypeNames: lexer.getSymbolicNames(), - allTokens: lexer.getAllTokens(), - }; - } - replaceVariables(code: string, variables: Record) { 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'; - }; + const replacements: SourceReplacement[] = []; allTokens.forEach((token, index) => { if (tokenTypeNames[token.type] !== 'IDENTIFIER') { @@ -138,30 +117,25 @@ 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 f = variableTokens - .filter(varToken => - tokenIdMap.has(varToken) - ? Object.keys(variables).includes(tokenIdMap.get(varToken)!) - : false - ) - .sort((a, b) => b.start - a.start); - let newCode = code; - f.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 this.applyReplacements(code, replacements); + } + + replaceScope(code: string, scope: string, replacement: string) { + return this.replaceScopes(code, { + [scope]: replacement, }); - return newCode; } - replaceScopes(code: string, scopes: Record) { + private replaceScopes(code: string, scopes: Record) { const { tokenTypeNames, allTokens } = this.parseTokens(code); const stateVars = Object.values(this.arcscriptVariables); @@ -189,23 +163,49 @@ 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 this.applyReplacements( + code, + targetScopeTokens.map(scopeToken => ({ + start: scopeToken.start, + end: scopeToken.start + scopeToken.text.length, + text: scopes[scopeToken.text], + })) + ); + } + + private createLexer(code: string) { + const chars = new CharStream(code); + const lexer = new ArcscriptLexer(chars); + const errorListener = new ErrorListener(); + lexer.removeErrorListeners(); + lexer.addErrorListener(errorListener); - return newCode; + return { chars, lexer, errorListener }; } - replaceScope(code: string, scope: string, replacement: string) { - return this.replaceScopes(code, { - [scope]: replacement, - }); + 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 + ); } } diff --git a/TypeScript/src/scope.ts b/TypeScript/src/scope.ts new file mode 100644 index 0000000..fca89c8 --- /dev/null +++ b/TypeScript/src/scope.ts @@ -0,0 +1,3 @@ +export function isGlobalScope(scope: string | null | undefined): boolean { + return scope === undefined || scope === null; +}