elsa-core/src/modules/Elsa.Dsl.ElsaScript/Parser/ElsaScriptParser.cs

631 lines
23 KiB
C#
Raw Normal View History

Add Elsa Script DSL (#7076) * Update packages.yml * Update elsa-server-and-studio.yml * Update elsa-server.yml * Update elsa-studio.yml (#6715) * Update ListWorkflowDefinitionsRequest.cs (#6761) Remove unnecessary line breaks * Correct namespace and import for `ConfigureEngineWithVariableTypes`. * Resolves build issues, update package versions and restructure project references - Updated multiple package versions in `Directory.Packages.props` for better dependency management, including `BenchmarkDotNet`, `FastEndpoints`, and `Microsoft.Extensions.Http.Resilience`. - Minor version upgrade for `System.Formats.Asn1` in `_build.csproj`. - Replaced project reference to `Elsa.csproj` with `Elsa.IO.Http.csproj` in `Elsa.ServerAndStudio.Web.csproj`, enhancing modularity. - Added new using directive for `Elsa.IO.Http.Features` in `Program.cs` to support new HTTP functionalities. * Remove unused project references from Elsa.sln These changes indicate that the associated projects or dependencies are no longer needed or have been replaced by other components in the solution. * Rename copilot-setup-steps.yml.yml to copilot-setup-steps.yml * Update RawStringContent encoding in JsonContentFactory (#6786) * Update RawStringContent encoding in JsonContentFactory Modified the instantiation of `RawStringContent` to use a new `UTF8Encoding` instance with `encoderShouldEmitUTF8Identifier` set to `false`, affecting the handling of the UTF-8 byte order mark (BOM) in serialized JSON content. Fixes a bug with content length being different than expected. * Refactor JsonContentFactory to reuse UTF8Encoding Introduced a private static readonly field `_utf8Encoding` in the `JsonContentFactory` class to improve code readability and performance. This change replaces the instantiation of `UTF8Encoding` in the `CreateHttpContent` method, allowing for the reuse of the same encoding instance. --------- Co-authored-by: Max Brooks <Max@compyl.com> * Enhance thread safety with ConcurrentDictionary usage (#6760) * Enhance thread safety with ConcurrentDictionary usage Replaced `IDictionary` with `ConcurrentDictionary` for both `_scheduledTasks` and `_scheduledTaskKeys` to improve thread safety in a multi-threaded environment. Updated methods `RegisterScheduledTask`, `RemoveScheduledTask`, and `RemoveScheduledTasks` to utilize the `Remove` method of `ConcurrentDictionary`, ensuring safe and efficient removal of scheduled tasks. * Refactor task registration and removal logic Updated `RegisterScheduledTask` to use `AddOrUpdate` for streamlined task management. This change simplifies the addition and updating of scheduled tasks by consolidating logic into a single operation. Introduced `RemoveScheduledTask` method to handle task removal by name, improving code organization and clarity. * Improve task removal handling in LocalScheduler Modified the `LocalScheduler` class to enhance the removal process of scheduled tasks from the `_scheduledTaskKeys` collection. The removal operation now captures the result in a variable and includes a conditional check to log a warning if the task was not found, improving error handling and debugging capabilities. * Refactor task removal in LocalScheduler Updated the removal process for scheduled tasks in `_scheduledTasks`. The new implementation collects all corresponding keys and attempts to remove them individually, logging warnings for any failures. This enhances error handling and provides better debugging information. --------- Co-authored-by: Max Brooks <Max@compyl.com> * Add IAsyncEnumerable check to ItemSourceActivityExecutionContextExtensions.GetItemSource (#6897) * Use FullName in WorkflowDictionary (#6923) * Fixed ParentWorkflowInstanceId not being set (#7029) Co-authored-by: Peter Klooster <peter.klooster@autotaalglas.nl> * Remove unused solution projects and update package references - Deleted several project references from `Elsa.sln` to clean up the solution. - Updated `Directory.Packages.props` for consistency and alignment with the latest package versions. * Simplify CI pipeline by removing `Test` step from `Compile+Test+Pack` process. * Initial plan * Add ElsaScript DSL module with parser and compiler Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Add integration tests for ElsaScript DSL Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Add comprehensive documentation for ElsaScript DSL Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Refactor workflow activity instantiation logic - Removed `ActivityFactory` and its related interfaces and extensions. - Introduced `ActivityActivator` for handling activity creation. - Extended AST with support for comprehensive workflow structures: - Added nodes for flowcharts, if/else, loops, and variable declarations. - Updated `IElsaScriptCompiler` to use asynchronous methods. - Expanded `ElsaScriptParser` to simplify syntax for `UseNode` and argument parsing. - Adjusted compiler and parser for compatibility with new workflow AST model. * Refactor test method names for clarity and add new compiler and parser tests - Updated method names in `CompilerTests` and `ParserTests` for better readability and description of test intent. - Added tests for compiler and parser: - Support for workflows without the `workflow` keyword. * Refactor `ElsaScriptParser` to improve statement parsing and introduce a tokenizer - Added `TokenizeStatements` method to split source into statements for enhanced parsing accuracy. - Updated logic to process statements instead of raw lines, reducing parsing complexity and improving reliability. - Improved handling of workflow and statement parsing, including edge cases with braces, parentheses, and string literals. * Introduce ElsaScript support for BlobStorage workflow provider - Added the `Elsa.WorkflowProviders.BlobStorage.ElsaScript` module to enable ElsaScript-based workflow definitions for BlobStorage. - Implemented `ElsaScriptBlobWorkflowFormatHandler` for parsing ElsaScript workflows stored in BlobStorage. - Extended `ElsaScriptParser` to leverage Parlot for improved DSL parsing. - Introduced `IBlobWorkflowFormatHandler` to centralize workflow format handling and parsing. - Updated `Elsa.Server.Web` to reference the new module and include an ElsaScript "Hello World" example workflow. * Refactor ElsaScript services, update logging, and improve workflow handling - Changed `ElsaScriptCompiler` service registration from `Singleton` to `Scoped` for better dependency management. - Enhanced the "Hello World" example workflow and added `CopyToOutputDirectory` configuration. - Removed unused namespaces and adjusted references in multiple projects to improve maintainability. - Updated logging levels in `appsettings.json` to reduce unnecessary debug output. - Improved `PolymorphicObjectConverter` by removing redundant dependencies. - Added missing references to enhance feature support and ensure compatibility. * Refactor activity instantiation and improve argument handling in `ElsaScriptCompiler` - Added support for positional arguments with constructor matching logic. - Refactored `InstantiateActivityUsingConstructor` to enhance activity creation. - Updated `ActivityDescriptor` and related types to include `ClrType` for streamlined activity resolution. - Simplified `TypedActivityProvider` by annotating it with `[UsedImplicitly]`. - Adjusted `ElsaScriptParser` to remove unnecessary options from string literal definitions. * Add HTTP-enabled "Hello World" workflow and support for additional HTTP activity constructors - Introduced a new ElsaScript example workflow `hello-world-http.elsa` with an HTTP endpoint and response. - Enhanced `HttpEndpoint` and `WriteHttpResponse` activities with additional constructors for improved flexibility. - Updated project to include the new workflow in the output directory. * Enhance `ElsaScriptParser` with a custom parser to handle nested raw expressions for ElsaScript workflows - Introduced `RawExpressionParser` to parse raw text after `=>` up to a matching closing parenthesis. - Updated `elsaExpressionWithLang` and `elsaExpressionWithoutLang` to use `RawExpressionParser`. - Trimmed whitespace in parsed expressions. - Added integration and parser tests for complex workflows with variables and expressions. - Updated example workflow `hello-world-http.elsa` to demonstrate expression usage. - Added `Elsa.Http` module reference to enable HTTP-based activities. * Update "Hello World" workflow to simplify naming and enhance response logic - Renamed workflow from `HelloWorldHttpDsl2` to `HelloWorldHttpDsl`. - Updated HTTP endpoint path to `/hello-world-dsl` for consistency. - Improved response logic by utilizing `getMessage()` JavaScript function. * Add support for `OriginalSource` in workflow materialization and enhance ElsaScript materializer - Introduced `OriginalSource` property in `WorkflowDefinition` and `MaterializedWorkflow` for preserving original source representation (e.g., ElsaScript, JSON, YAML). - Added `ElsaScriptWorkflowMaterializer` implementation to materialize workflows directly from ElsaScript source. - Updated `DefaultWorkflowDefinitionStorePopulator` to determine `StringData` or `OriginalSource` based on materialized workflow format. - Enhanced `WorkflowDefinitionMapper` to support symmetric round-tripping with `OriginalSource`. - Registered `ElsaScriptWorkflowMaterializer` in `ElsaScriptFeature` for dependency injection. - Updated `JsonBlobWorkflowFormatHandler` and added `OriginalSource` support for round-trip preservation. - Simplified `ElsaScriptParser` by aligning variable and parser naming. * Update V3_6 migrations for PostgreSQL, MySQL, and Oracle databases and associated designer files. * Handle disposal and race conditions in `ScheduledCronTask` - Added `_disposed` flag to prevent accessing disposed resources. - Updated `_executionSemaphore` and `_scopeFactory` logic to safely handle `ObjectDisposedException`. - Enhanced task scheduling and timer disposal with additional safeguards against race conditions. - Modified tests to ensure proper disposal and logging behavior when handling edge cases. * Add support for metadata in ElsaScript workflows and enhance parser and compiler functionality - Introduced metadata syntax in ElsaScript workflows (e.g., `DisplayName`, `Description`, `Version`) to enable metadata-driven behavior. - Enhanced `ElsaScriptCompiler` to process metadata and properly integrate it into `Workflow` objects. - Updated `ElsaScriptParser` to parse program-level AST with support for multiple workflows and global use statements. - Refactored tests to validate metadata parsing and ensure backward compatibility with existing workflows. - Added new test cases to cover scenarios like metadata parsing, compilation, and multi-workflow programs. * Add support for `foreach` loops in ElsaScript and remove `let` keyword - Introduced `foreach` loop syntax in `ElsaScriptParser` and `ElsaScriptCompiler`, enabling iteration over collections with optional variable declaration. - Updated `ForNode` and `ForEachNode` to include a `DeclaresVariable` flag for improved variable handling. - Removed support for the `let` keyword in variable declarations, streamlining syntax to use `var` and `const` only. - Enhanced `for` loop syntax to support optional `var` declaration and block or single-statement bodies. - Refactored test cases to validate `foreach` and `for` loop enhancements and ensure backward compatibility. * Simplify ElsaScript workflow syntax by removing redundant quotes in workflow identifiers and updating `for` loop syntax for clarity and consistency. * Remove redundant quotes from workflow identifiers in integration tests * Simplify Elsa scripts and improve error handling - Removed redundant braces in workflow declarations for streamlined syntax. - Enhanced logging in `JsonBlobWorkflowFormatHandler` and `ElsaScriptBlobWorkflowFormatHandler` to warn on parsing errors and provide context. - Updated configuration to log errors for `Elsa.Workflows.ActivityRegistry`. - Refined "Hello World" and "For Loop" workflows for clarity and added improved loop handling. * Refine Elsa workflows and update compiler logic - Simplified "Hello World" workflow by adding braces and improving consistency. - Adjusted "For Loop" workflow to rename and clarify logic, including expression updates and variable handling. - Fixed compiler mapping of `"cs"` to `"CSharp"` for better clarity. - Enhanced "Hello World HTTP" workflow to correctly reference `variables.message` in expressions. * Add flowchart support in ElsaScript parser, compiler, and integration tests - Introduced `flowchart` syntax in `ElsaScriptParser` to support flowchart-based workflows. - Updated `ElsaScriptCompiler` to compile `flowchart` nodes with labeled activities, connections, entry points, and variables. - Added integration tests for parsing and compiling empty and simple flowcharts. - Enhanced `FlowchartNode` and `LabeledActivityNode` for better representation of flowchart structures. - Improved error handling and logging for invalid flowchart configurations. * Add tests for compiling and parsing flowcharts with nodes, connections, and block nodes in ElsaScript - Added integration tests for compiling and validating flowchart structures, including activities, connections, and entry points. - Implemented parser tests for parsing flowcharts with node connections and block nodes. - Updated project files to include new workflow examples for testing. * Add Parlot package and update project file in integration tests - Added `Parlot` package version `0.0.27` to `Directory.Packages.props`. - Updated integration test project file to include a new `Include` directive for better targeting. * Update Parlot package to version 1.5.2 in Directory.Packages.props * Remove `elsa-server-and-studio.yml` workflow and update solution file - Deleted `elsa-server-and-studio.yml` workflow as it's no longer needed. - Updated `Elsa.sln` to remove reference to the deleted workflow. * Remove `elsa-studio.yml` workflow and update solution and packages - Deleted `elsa-studio.yml` workflow as it's no longer used. - Updated `Elsa.sln` to remove reference to the deleted workflow. - Changed `base_version` in `packages.yml` from `3.7.0` to `3.6.0`. * Downgrade Docker image in `elsa-server.yml` workflow from `v3.7.0-preview` to `v3.6.0-preview` * Update Docker image tag in `elsa-server.yml` workflow from `v3.6.0-preview` to `v3.6-preview` * Add logging support to `LocalScheduler` and replace `Debug.WriteLine` with `ILogger` * Remove unused `System.Collections.Generic` and `Elsa.Extensions` imports in `LocalScheduler` - Cleaned up unnecessary using directives to improve code readability and maintainability. - Minor whitespace adjustment for consistent formatting. * Remove unnecessary whitespace in `LocalScheduler` for consistent formatting * Improve exception handling in blob workflow format handlers - Updated exception handling in `ElsaScriptBlobWorkflowFormatHandler` and `JsonBlobWorkflowFormatHandler` to gracefully catch and log all exceptions during workflow parsing. - Adjusted comments to clarify behavior for invalid user-provided files, ensuring the workflow loading process is not disrupted. * Refactor blob workflow format handlers to use `SupportedExtensions` for improved file filtering - Added `SupportedExtensions` property to all blob format handlers to optimize blob storage browsing. - Simplified `CanHandle` logic by removing extension checks, leveraging `SupportedExtensions` for initial filtering. - Updated comments for clarity and consistency across handlers. * Refactor `DefaultWorkflowDefinitionStorePopulator` to simplify `stringData` assignment logic and improve readability * Remove outdated comment in `CompilerTests` about skipped tests * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor `ElsaScriptCompiler` to streamline type conversion logic, improve language mapping, and enhance asynchronous flowchart compilation * [WIP] Update ParseError printing based on feedback (#7082) * Initial plan * Fix ParseError formatting to use Message and Position properties Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Replace `as` casts with direct casts in ParserTests for null safety (#7083) * Initial plan * Replace 'as' casts with direct casts in ParserTests for better null safety Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Fix Oracle column types for OriginalSource and other large text fields (#7079) * Initial plan * Fix Oracle OriginalSource and StringData column types to handle large data Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Refactor tests to replace type checks with `Assert.IsType` for improved clarity and type safety * Initial plan (#7080) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> * Add `Parlot` package reference and update solution structure by removing and reorganizing projects and workflows. * Set default expression language to "JavaScript" in `ElsaScriptCompiler`. * Add integration test to verify default expression language resets between ElsaScript compilations * Simplify UTF-8 encoding in JsonContentFactory (#7081) * Initial plan * Remove explicit UTF8Encoding in JsonContentFactory and use Encoding.UTF8 Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Fix test to use Encoding.UTF8.GetByteCount for multi-byte character support Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --------- Co-authored-by: Ender <37611092+zengande@users.noreply.github.com> Co-authored-by: Matt <knibbsy10@live.com> Co-authored-by: Max Brooks <45081361+MaxBrooks114@users.noreply.github.com> Co-authored-by: Max Brooks <Max@compyl.com> Co-authored-by: FuJa0815 <30809803+FuJa0815@users.noreply.github.com> Co-authored-by: Peter Klooster <crashkonijn@gmail.com> Co-authored-by: Peter Klooster <peter.klooster@autotaalglas.nl> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-25 18:57:50 +00:00
using Elsa.Dsl.ElsaScript.Ast;
using Elsa.Dsl.ElsaScript.Contracts;
using Parlot;
using Parlot.Fluent;
namespace Elsa.Dsl.ElsaScript.Parser;
/// <summary>
/// ElsaScript parser using Parlot for robust parsing.
/// </summary>
public class ElsaScriptParser : IElsaScriptParser
{
private static readonly Parser<ProgramNode> ProgramParser;
static ElsaScriptParser()
{
// Keywords
var useKeyword = Terms.Text("use");
var workflowKeyword = Terms.Text("workflow");
var expressionsKeyword = Terms.Text("expressions");
var listenKeyword = Terms.Text("listen");
var varKeyword = Terms.Text("var");
var constKeyword = Terms.Text("const");
var forKeyword = Terms.Text("for");
var foreachKeyword = Terms.Text("foreach");
var inKeyword = Terms.Text("in");
var toKeyword = Terms.Text("to");
var throughKeyword = Terms.Text("through");
var stepKeyword = Terms.Text("step");
var flowchartKeyword = Terms.Text("flowchart");
var entryKeyword = Terms.Text("entry");
// Basic tokens
var identifier = Terms.Identifier();
var stringLiteral = Terms.String();
var integerLiteral = Terms.Integer();
var decimalLiteral = Terms.Decimal();
// Punctuation
var semicolon = Terms.Char(';');
var comma = Terms.Char(',');
var colon = Terms.Char(':');
var leftParen = Terms.Char('(');
var rightParen = Terms.Char(')');
var leftBrace = Terms.Char('{');
var rightBrace = Terms.Char('}');
var leftBracket = Terms.Char('[');
var rightBracket = Terms.Char(']');
var dot = Terms.Char('.');
var arrow = Terms.Text("=>");
var rightArrow = Terms.Text("->");
var equals = Terms.Char('=');
// Deferred parsers for recursive structures
var expression = Deferred<ExpressionNode>();
var statement = Deferred<StatementNode>();
// Expression parsers
var booleanLiteral = Terms.Text("true").Or(Terms.Text("false"))
.Then<ExpressionNode>(x => new LiteralNode { Value = x.ToString() == "true" });
var numberLiteral = decimalLiteral
.Then<ExpressionNode>(x => new LiteralNode { Value = x });
var intLiteral = integerLiteral
.Then<ExpressionNode>(x => new LiteralNode { Value = (long)x });
var stringExpr = stringLiteral
.Then<ExpressionNode>(x => new LiteralNode { Value = x.ToString() });
var identifierExpr = identifier
.Then<ExpressionNode>(x => new IdentifierNode { Name = x.ToString() });
// Array literal: [expr, expr, ...]
var commaSeparatedExpression = expression.And(ZeroOrOne(comma)).Then(x => x.Item1);
var arrayLiteral = Between(leftBracket, ZeroOrMany(commaSeparatedExpression), rightBracket)
.Then<ExpressionNode>(elements => new ArrayLiteralNode { Elements = elements.ToList() });
// Elsa expression: lang => <raw text until matching )>
// We need to capture raw text after => up to the closing parenthesis
// This supports nested parentheses by counting depth
// Use a custom scanner-based parser wrapped in RawExpressionParser
var rawExpressionText = new RawExpressionParser();
var elsaExpressionWithLang = identifier
.And(arrow)
.And(rawExpressionText)
.Then<ExpressionNode>(x => new ElsaExpressionNode
{
Language = x.Item1.ToString(),
Expression = x.Item3.ToString().Trim()
});
var elsaExpressionWithoutLang = arrow
.And(rawExpressionText)
.Then<ExpressionNode>(x => new ElsaExpressionNode
{
Language = null,
Expression = x.Item2.ToString().Trim()
});
var elsaExpression = elsaExpressionWithLang.Or(elsaExpressionWithoutLang);
// Expression priority: try most specific first
expression.Parser = elsaExpression
.Or(arrayLiteral)
.Or(booleanLiteral)
.Or(numberLiteral)
.Or(intLiteral)
.Or(stringExpr)
.Or(identifierExpr);
// Argument parser: name: value or just value
var namedArgument = identifier
.And(colon)
.And(expression)
.Then(x => new ArgumentNode { Name = x.Item1.ToString(), Value = x.Item3 });
var positionalArgument = expression
.Then(x => new ArgumentNode { Value = x });
var argument = namedArgument.Or(positionalArgument);
var commaSeparatedArgument = argument.And(ZeroOrOne(comma)).Then(x => x.Item1);
var arguments = ZeroOrMany(commaSeparatedArgument);
// Activity invocation: ActivityName(args) - with or without arguments
var activityInvocationWithArgs = identifier
.And(leftParen)
.And(arguments)
.And(rightParen)
.Then(x => new ActivityInvocationNode
{
ActivityName = x.Item1.ToString(),
Arguments = x.Item3.ToList()
});
var activityInvocationNoArgs = identifier
.And(leftParen)
.And(rightParen)
.Then(x => new ActivityInvocationNode
{
ActivityName = x.Item1.ToString(),
Arguments = []
});
var activityInvocation = activityInvocationWithArgs.Or(activityInvocationNoArgs);
// Variable declaration: var/const name = expr
var variableKindParser = varKeyword.Or(constKeyword);
var variableDeclaration = variableKindParser
.And(identifier)
.And(equals)
.And(expression)
.Then<StatementNode>(x =>
{
// x is a flat tuple (kind, identifier, equals, expression)
var kind = x.Item1.ToString() switch
{
"var" => VariableKind.Var,
"const" => VariableKind.Const,
_ => VariableKind.Var
};
return new VariableDeclarationNode
{
Kind = kind,
Name = x.Item2.ToString(),
Value = x.Item4
};
});
// Listen statement: listen ActivityName(args)
var listenStatement = listenKeyword
.And(activityInvocation)
.Then<StatementNode>(x => new ListenNode { Activity = x.Item2 });
// Statement: variable declaration, listen, or activity invocation
var activityStatement = activityInvocation
.Then<StatementNode>(x => x);
// Declare deferred for loop, foreach, and flowchart parsers
var forStatement = Deferred<StatementNode>();
var foreachStatement = Deferred<StatementNode>();
var flowchartStatement = Deferred<StatementNode>();
statement.Parser = variableDeclaration
.Or(listenStatement)
.Or(forStatement)
.Or(foreachStatement)
.Or(flowchartStatement)
.Or(activityStatement);
// Statement with optional semicolon
var statementWithSemicolon = statement.And(ZeroOrOne(semicolon)).Then(x => x.Item1);
// For loop statement: for (var i = 0 to 10 step 1) { body } or for (i = 0 to 10) statement
// Must be defined after statementWithSemicolon
var rangeOperator = toKeyword.Or(throughKeyword);
// For body can be either a block or a single statement
var forBlockBody = Between(leftBrace, ZeroOrMany(statementWithSemicolon), rightBrace)
.Then(statements => (StatementNode)(statements.Count == 1
? statements.First()
: new BlockNode { Statements = statements.ToList() }));
var forSingleStatementBody = statement;
var forBody = forBlockBody.Or(forSingleStatementBody);
// For header with optional var: (var i = start to/through end step stepValue)
// or (i = start to/through end step stepValue)
// Step clause is optional
var optionalVarKeyword = ZeroOrOne(varKeyword);
var optionalStepClause = ZeroOrOne(stepKeyword.And(expression).Then(x => x.Item2));
var forHeader = Between(leftParen,
optionalVarKeyword
.And(identifier)
.And(equals)
.And(expression)
.And(rangeOperator)
.And(expression)
.And(optionalStepClause)
.Then(x => (
HasVar: x.Item1 != null,
VarName: x.Item2.ToString(),
Start: x.Item4,
RangeOp: x.Item5.ToString(),
End: x.Item6,
Step: x.Item7
)),
rightParen);
var forStatementParser = forKeyword
.And(forHeader)
.And(forBody)
.Then<StatementNode>(result =>
{
var header = result.Item2;
var body = result.Item3;
// Default step to 1 if not specified
var stepExpr = header.Step ?? new LiteralNode { Value = 1 };
return new ForNode
{
DeclaresVariable = header.HasVar,
VariableName = header.VarName,
Start = header.Start,
End = header.End,
Step = stepExpr,
IsInclusive = header.RangeOp == "through",
Body = body
};
});
forStatement.Parser = forStatementParser;
// ForEach statement: foreach (var item in collection) { body } or foreach (item in collection) statement
// Must be defined after statementWithSemicolon
// ForEach body can be either a block or a single statement
var foreachBlockBody = Between(leftBrace, ZeroOrMany(statementWithSemicolon), rightBrace)
.Then(statements => (StatementNode)(statements.Count == 1
? statements.First()
: new BlockNode { Statements = statements.ToList() }));
var foreachSingleStatementBody = statement;
var foreachBody = foreachBlockBody.Or(foreachSingleStatementBody);
// ForEach header with optional var: (var item in collection) or (item in collection)
var foreachOptionalVarKeyword = ZeroOrOne(varKeyword);
var foreachHeader = Between(leftParen,
foreachOptionalVarKeyword
.And(identifier)
.And(inKeyword)
.And(expression)
.Then(x => (
HasVar: x.Item1 != null,
VarName: x.Item2.ToString(),
Collection: x.Item4
)),
rightParen);
var foreachStatementParser = foreachKeyword
.And(foreachHeader)
.And(foreachBody)
.Then<StatementNode>(result =>
{
var header = result.Item2;
var body = result.Item3;
return new ForEachNode
{
DeclaresVariable = header.HasVar,
VariableName = header.VarName,
Collection = header.Collection,
Body = body
};
});
foreachStatement.Parser = foreachStatementParser;
// Flowchart statement: flowchart { [variables] [nodes] [connections] [entry] }
// Node declaration: label: statement;
// Entry declaration: entry label;
// Connection declaration: source -> target; or source.Outcome -> target;
// Flowchart body element can be:
// 1. Variable declaration
// 2. Node declaration (label: statement)
// 3. Entry declaration (entry label)
// 4. Connection declaration (source -> target or source.Outcome -> target)
// Node declaration: label: activityInvocation; or label: { block }
// Note: We use activityInvocation directly (not statement) to avoid circular dependency
// since statement includes flowchart which would include node declarations
var nodeBlock = Between(leftBrace, ZeroOrMany(statementWithSemicolon), rightBrace)
.Then<StatementNode>(statements => statements.Count == 1
? statements.First()
: new BlockNode { Statements = statements.ToList() });
var nodeActivityStatement = activityInvocation.Then<StatementNode>(s => s);
var nodeDeclaration = identifier
.And(colon)
.And(nodeBlock.Or(nodeActivityStatement))
.And(ZeroOrOne(semicolon))
.Then(x => new LabeledActivityNode
{
Label = x.Item1.ToString(),
Activity = x.Item3
});
// Entry declaration: entry label;
var entryDeclaration = entryKeyword
.And(identifier)
.And(ZeroOrOne(semicolon))
.Then(x => x.Item2.ToString());
// Connection declaration: source -> target; or source.Outcome -> target;
// Source can be: identifier or identifier.identifier (with outcome)
var optionalOutcome = ZeroOrOne(dot.And(identifier).Then(x => x.Item2.ToString()));
var connectionSource = identifier
.And(optionalOutcome)
.Then(x => (
SourceLabel: x.Item1.ToString(),
Outcome: x.Item2
));
var connectionTarget = identifier;
var connectionDeclaration = connectionSource
.And(rightArrow)
.And(connectionTarget)
.And(ZeroOrOne(semicolon))
.Then(x => new ConnectionNode
{
Source = x.Item1.SourceLabel,
Outcome = x.Item1.Outcome,
Target = x.Item3.ToString()
});
// Flowchart body element type - try each parser in order
var flowchartBodyElement = variableDeclaration.Then<object>(v => v)
.Or(entryDeclaration.Then<object>(e => e))
.Or(nodeDeclaration.Then<object>(n => n))
.Or(connectionDeclaration.Then<object>(c => c));
var flowchartBody = Between(leftBrace, ZeroOrMany(flowchartBodyElement), rightBrace);
var flowchartStatementParser = flowchartKeyword
.And(flowchartBody)
.Then<StatementNode>(result =>
{
var bodyElements = result.Item2;
var variables = new List<VariableDeclarationNode>();
var nodes = new List<LabeledActivityNode>();
var connections = new List<ConnectionNode>();
string? entryPoint = null;
foreach (var element in bodyElements)
{
if (element is VariableDeclarationNode varDecl)
variables.Add(varDecl);
else if (element is LabeledActivityNode node)
nodes.Add(node);
else if (element is ConnectionNode conn)
connections.Add(conn);
else if (element is string entry)
entryPoint = entry;
}
return new FlowchartNode
{
Variables = variables,
Activities = nodes,
Connections = connections,
EntryPoint = entryPoint
};
});
flowchartStatement.Parser = flowchartStatementParser;
// Use statement: use Namespace; or use expressions lang;
var namespaceUse = identifier
.And(ZeroOrMany(dot.And(identifier)))
.Then(x =>
{
var ns = x.Item1.ToString();
foreach (var part in x.Item2)
{
ns += "." + part.Item2.ToString();
}
return new UseNode { Type = UseType.Namespace, Value = ns };
});
var expressionUse = expressionsKeyword
.And(identifier)
.Then(x => new UseNode { Type = UseType.Expressions, Value = x.Item2.ToString() });
var useStatement = useKeyword
.And(expressionUse.Or(namespaceUse))
.And(ZeroOrOne(semicolon))
.Then(x => x.Item2);
// Workflow metadata: name: value
var metadataEntry = identifier
.And(colon)
.And(expression)
.Then(x => (Name: x.Item1.ToString(), Value: EvaluateConstantExpressionStatic(x.Item3)));
var commaSeparatedMetadata = metadataEntry.And(ZeroOrOne(comma)).Then(x => x.Item1);
var metadataList = ZeroOrMany(commaSeparatedMetadata);
// Workflow declaration: workflow Identifier [(metadata)] { [use statements] [statements] }
var workflowMetadata = Between(leftParen, metadataList, rightParen);
// Workflow body can contain use statements and regular statements
var workflowUseStatement = useStatement;
var workflowBodyElement = Deferred<object>();
workflowBodyElement.Parser = workflowUseStatement
.Then<object>(u => u)
.Or(statementWithSemicolon.Then<object>(s => s));
var workflowBody = Between(leftBrace, ZeroOrMany(workflowBodyElement), rightBrace);
var workflowWithMetadata = workflowKeyword
.And(identifier)
.And(workflowMetadata)
.Then(x => (WorkflowId: x.Item2.ToString(), Metadata: x.Item3));
var workflowWithoutMetadata = workflowKeyword
.And(identifier)
.Then(x => (WorkflowId: x.Item2.ToString(), Metadata: (IReadOnlyList<(string Name, object Value)>?)null));
var workflowHeader = workflowWithMetadata.Or(workflowWithoutMetadata);
var workflowDeclaration = workflowHeader
.And(workflowBody)
.Then(x =>
{
var header = x.Item1;
var bodyElements = x.Item2;
var metadataDict = new Dictionary<string, object>();
if (header.Metadata != null)
{
foreach (var entry in header.Metadata)
{
metadataDict[entry.Name] = entry.Value;
}
}
// Separate use statements from regular statements in body
var workflowUses = new List<UseNode>();
var statements = new List<StatementNode>();
foreach (var element in bodyElements)
{
if (element is UseNode useNode)
workflowUses.Add(useNode);
else if (element is StatementNode stmt)
statements.Add(stmt);
}
return new WorkflowNode
{
Id = header.WorkflowId,
Metadata = metadataDict,
UseStatements = workflowUses,
Body = statements
};
});
// Program with single workflow: [global use statements] [workflow declaration]
var programWithWorkflow = ZeroOrMany(useStatement)
.And(workflowDeclaration)
.Then(x =>
{
var globalUses = x.Item1.Select(u => (UseNode)u).ToList();
var workflow = x.Item2;
return new ProgramNode
{
GlobalUseStatements = globalUses,
Workflows = new List<WorkflowNode> { workflow }
};
});
// Fallback: raw statements without workflow keyword (backward compatibility)
// Only match if there are actual statements (OneOrMany)
var programWithStatements = ZeroOrMany(useStatement)
.And(OneOrMany(statementWithSemicolon))
.Then(x =>
{
var globalUses = x.Item1.Select(u => (UseNode)u).ToList();
var statements = x.Item2.ToList();
return new ProgramNode
{
GlobalUseStatements = globalUses,
Workflows = new List<WorkflowNode>
{
new WorkflowNode
{
Id = "DefaultWorkflow",
UseStatements = new List<UseNode>(),
Body = statements
}
}
};
});
var programParser = programWithWorkflow.Or(programWithStatements);
ProgramParser = programParser;
}
/// <inheritdoc />
public ProgramNode Parse(string source)
{
if (!ProgramParser.TryParse(source, out var result, out var error))
{
var errorMessage = error != null
? $"{error.Message} at {error.Position}"
: "Unknown parse error";
throw new ParseException($"Failed to parse ElsaScript: {errorMessage}");
}
return result;
}
/// <summary>
/// Static helper to evaluate constant expressions during parsing.
/// </summary>
private static object EvaluateConstantExpressionStatic(ExpressionNode exprNode)
{
return exprNode switch
{
LiteralNode literal => literal.Value ?? string.Empty,
IdentifierNode identifier => identifier.Name,
_ => string.Empty
};
}
}
/// <summary>
/// Exception thrown when parsing fails.
/// </summary>
public class ParseException : Exception
{
public ParseException(string message) : base(message)
{
}
}
/// <summary>
/// Custom parser that captures raw text after => until the matching closing parenthesis.
/// Supports nested parentheses.
/// </summary>
internal sealed class RawExpressionParser : Parser<TextSpan>
{
public override bool Parse(ParseContext context, ref ParseResult<TextSpan> result)
{
context.EnterParser(this);
var scanner = context.Scanner;
var start = scanner.Cursor.Offset;
var depth = 0;
while (!scanner.Cursor.Eof)
{
var ch = scanner.Cursor.Current;
if (ch == '(')
{
depth++;
scanner.Cursor.Advance();
}
else if (ch == ')')
{
if (depth == 0)
{
// This is the closing paren for the activity invocation
break;
}
depth--;
scanner.Cursor.Advance();
}
else
{
scanner.Cursor.Advance();
}
}
var length = scanner.Cursor.Offset - start;
if (length == 0)
{
context.ExitParser(this);
return false;
}
var text = new TextSpan(scanner.Buffer, start, length);
result.Set(start, scanner.Cursor.Offset, text);
context.ExitParser(this);
return true;
}
}