diff --git a/YesNt.Interpreter.Tests/AddStatementTests.cs b/YesNt.Interpreter.Tests/AddStatementTests.cs index 6b4f6cd..bbfe7d6 100644 --- a/YesNt.Interpreter.Tests/AddStatementTests.cs +++ b/YesNt.Interpreter.Tests/AddStatementTests.cs @@ -192,6 +192,81 @@ public class AddStatementTests YesNtAssert.ContainsTerminationMessage(lines, "Invalid statement"); } + [TestMethod] + public void RemoveStatementCausesInvalidStatementTest() + { + List lines = + [ + "sleep 100" + ]; + + YesNtAssert.ContainsTerminationMessageWithSetup(lines, "Invalid statement", interpreter => + { + interpreter.RemoveStatement("sleep"); + }); + } + + [TestMethod] + public void DisabledStatementIsIgnoredTest() + { + List lines = + [ + "var x = hello", + "${x}" + ]; + + YesNtAssert.ContainsTerminationMessageWithSetup(lines, "Variable \"x\" not found", interpreter => + { + interpreter.DisableStatement("var"); + }); + } + + [TestMethod] + public void DisabledStatementCanBeReenabledTest() + { + List lines = + [ + "var result = overwritten", + "${result}" + ]; + + YesNtAssert.IsLastLineEqualWithSetup(lines, "overwritten", setup: interpreter => + { + interpreter.DisableStatement("var"); + interpreter.EnableStatement("var"); + }); + } + + [TestMethod] + public void DisableNonExistentStatementIsNoOpTest() + { + List lines = + [ + "var x = ok", + "${x}" + ]; + + YesNtAssert.IsLastLineEqualWithSetup(lines, "ok", setup: interpreter => + { + interpreter.DisableStatement("nonexistent_keyword"); + }); + } + + [TestMethod] + public void EnableNonDisabledStatementIsNoOpTest() + { + List lines = + [ + "var x = ok", + "${x}" + ]; + + YesNtAssert.IsLastLineEqualWithSetup(lines, "ok", setup: interpreter => + { + interpreter.EnableStatement("var"); + }); + } + [TestMethod] public void AddStatementWithHighPriorityRunsBeforeNormalTest() { @@ -217,4 +292,46 @@ public class AddStatementTests Assert.IsTrue(highPriorityOrder < normalPriorityOrder, "High priority statement should execute before Normal priority"); } + + [TestMethod] + public void AddStatementAloneDoesNotReplaceBuiltinTest() + { + // Adding a custom 'var' handler without RemoveStatement first means BOTH handlers fire. + // The built-in still sets the variable, so ${x} resolves normally. + List lines = + [ + "var x = original", + "${x}" + ]; + + bool customHandlerCalled = false; + + YesNtAssert.IsLastLineEqualWithSetup(lines, "original", setup: interpreter => + { + interpreter.AddStatement("var", SearchMode.StartOfLine, SpaceAround.End, _ => + { + customHandlerCalled = true; + }); + }); + + Assert.IsTrue(customHandlerCalled, "Custom handler should have fired alongside the built-in"); + } + + [TestMethod] + public void RemoveThenAddStatementReplacesBuiltinTest() + { + // RemoveStatement + AddStatement correctly replaces the built-in. + // The built-in 'var' is gone, so only the custom handler fires. + List lines = + [ + "var x = original", + "${x}" + ]; + + YesNtAssert.ContainsTerminationMessageWithSetup(lines, "Variable \"x\" not found", interpreter => + { + interpreter.RemoveStatement("var"); + interpreter.AddStatement("var", SearchMode.StartOfLine, SpaceAround.End, _ => { }); + }); + } } diff --git a/YesNt.Interpreter.Tests/YesNtAssert.cs b/YesNt.Interpreter.Tests/YesNtAssert.cs index 422f286..d860166 100644 --- a/YesNt.Interpreter.Tests/YesNtAssert.cs +++ b/YesNt.Interpreter.Tests/YesNtAssert.cs @@ -79,6 +79,12 @@ internal static class YesNtAssert StringAssert.Contains(debugOutput, expectedFragment); } + public static void ContainsTerminationMessageWithSetup(List lines, string expectedMessageFragment, Action setup, int timeout = 1000) + { + (_, string debugOutput) = ExecuteAndCapture(lines, timeout, setup); + StringAssert.Contains(debugOutput, expectedMessageFragment); + } + public static string? GetLastLineWithSetup(List lines, Action setup, int timeout = 1000) { (DebugEventArgs? debugEventArgs, _) = ExecuteAndCapture(lines, timeout, setup); diff --git a/YesNt.Interpreter/Runtime/YesNtInterpreter.cs b/YesNt.Interpreter/Runtime/YesNtInterpreter.cs index d84632f..f9d719d 100644 --- a/YesNt.Interpreter/Runtime/YesNtInterpreter.cs +++ b/YesNt.Interpreter/Runtime/YesNtInterpreter.cs @@ -44,6 +44,7 @@ public class YesNtInterpreter private readonly RuntimeInformation runtimeInfo = new RuntimeInformation(); private Dictionary> statements; private readonly List> staticStatements; + private readonly Dictionary>>> disabledStatements = new(); /// /// Gets a read-only snapshot of all currently registered statements. @@ -83,7 +84,10 @@ public class YesNtInterpreter /// /// Registers a custom statement using a pre-built . - /// If a statement with the same attribute key already exists it will be replaced. + /// If a statement with the same attribute key (identical field values) already exists it will be replaced; + /// otherwise a new entry is added. Built-in statements use distinct attribute instances, so passing a + /// newly constructed attribute with the same name will add a second handler rather than replacing + /// the built-in. Use first to replace a built-in keyword. /// The statement list is re-sorted by priority after insertion. /// /// The attribute describing the keyword, search mode, and priority. @@ -125,6 +129,71 @@ public class YesNtInterpreter AddStatement(new StatementAttribute(name, searchMode, spaceAround, consoleColor), handler); } + /// + /// Permanently removes all built-in or custom statements that match . + /// After removal, any script line that would have matched triggers an "Invalid statement" error. + /// + /// The keyword of the statement(s) to remove. + public void RemoveStatement(string name) + { + foreach (StatementAttribute key in statements.Keys.Where(k => k.Name == name).ToList()) + { + statements.Remove(key); + } + + disabledStatements.Remove(name); + } + + /// + /// Disables all statements matching by replacing their handlers with + /// a no-op. The keyword still matches (so no "Invalid statement" error is raised), but the + /// statement has no effect. Use to restore original behaviour. + /// + /// The keyword of the statement(s) to disable. + public void DisableStatement(string name) + { + if (disabledStatements.ContainsKey(name)) + { + return; + } + + List>> matching = + statements.Where(kv => kv.Key.Name == name).ToList(); + + if (matching.Count == 0) + { + return; + } + + disabledStatements[name] = matching; + + foreach (KeyValuePair> kv in matching) + { + statements[kv.Key] = _ => { }; + } + } + + /// + /// Re-enables statements previously disabled with , + /// restoring their original handlers. + /// Has no effect if the statement is not currently disabled. + /// + /// The keyword of the statement(s) to re-enable. + public void EnableStatement(string name) + { + if (!disabledStatements.TryGetValue(name, out List>> saved)) + { + return; + } + + foreach (KeyValuePair> kv in saved) + { + statements[kv.Key] = kv.Value; + } + + disabledStatements.Remove(name); + } + /// /// Requests a graceful stop of the currently executing script. /// The interpreter will terminate at the next line boundary. diff --git a/docs/library-api.md b/docs/library-api.md index 4b0f163..cfa4247 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -12,9 +12,10 @@ to embed the YesNt interpreter and run scripts programmatically. 3. [Running an in-memory script](#running-an-in-memory-script) 4. [Capturing output (debug mode)](#capturing-output-debug-mode) 5. [Adding custom statements](#adding-custom-statements) -6. [Stopping a script](#stopping-a-script) -7. [Reading registered statements](#reading-registered-statements) -8. [API reference](#api-reference) +6. [Removing and disabling built-in statements](#removing-and-disabling-built-in-statements) +7. [Stopping a script](#stopping-a-script) +8. [Reading registered statements](#reading-registered-statements) +9. [API reference](#api-reference) --- @@ -167,6 +168,76 @@ Use `StatementAttribute.Priority` to control ordering relative to built-in state --- +## Removing and disabling built-in statements + +Use these methods to restrict which built-in keywords are available — useful for sandboxing +or replacing a built-in with a custom implementation. + +### `RemoveStatement` — permanent removal + +Removes all handlers for the given keyword. Any script line that would have matched the +keyword now triggers an **"Invalid statement"** error. + +```csharp +var interpreter = new YesNtInterpreter(); + +// Prevent scripts from launching external processes. +interpreter.RemoveStatement("exec"); + +interpreter.Execute(new List { "exec notepad" }); +// Terminates with: Invalid statement +``` + +### `DisableStatement` — silent no-op + +Disables all handlers for the keyword. The keyword still **matches** (so no error is raised), +but has no effect. Use `EnableStatement` to restore the original behaviour. + +```csharp +var interpreter = new YesNtInterpreter(); + +// Make sleep a no-op so tests don't actually wait. +interpreter.DisableStatement("sleep"); + +interpreter.Execute(new List +{ + "sleep 10000", // does nothing + "var x = done", + "print_line ${x}", // prints: done +}); +``` + +### `EnableStatement` — restore a disabled statement + +Restores the original handlers saved when `DisableStatement` was called. +Has no effect if the statement is not currently disabled. + +```csharp +interpreter.DisableStatement("sleep"); +// ... configure other things ... +interpreter.EnableStatement("sleep"); // sleep works normally again +``` + +### Replacing a built-in statement + +Call `RemoveStatement` to remove the built-in handlers, then `AddStatement` to install your own. +Simply calling `AddStatement` with the same keyword name will **not** replace the built-in — +it will add a second handler that fires alongside the original. + +```csharp +// Replace the built-in 'exec' with a sandboxed version that only allows 'echo'. +interpreter.RemoveStatement("exec"); +interpreter.AddStatement("exec", SearchMode.StartOfLine, SpaceAround.End, args => +{ + if (args.Trim() != "echo") + throw new InvalidOperationException("exec is restricted"); + + System.Diagnostics.Process.Start("cmd", "/c echo (sandboxed)"); +}); +``` + +--- + ## Stopping a script ```csharp @@ -253,6 +324,15 @@ public void AddStatement(StatementAttribute attribute, Action handler); public void AddStatement(string name, SearchMode searchMode, SpaceAround spaceAround, Action handler); public void AddStatement(string name, SearchMode searchMode, SpaceAround spaceAround, ConsoleColor color, Action handler); +// Remove a built-in or custom statement permanently +public void RemoveStatement(string name); + +// Disable a statement (silent no-op; reversible) +public void DisableStatement(string name); + +// Re-enable a previously disabled statement +public void EnableStatement(string name); + // Request graceful stop public void Stop(); ```