Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4c46e2f
Initial plan
Copilot Jan 1, 2026
ab6c1ad
Add Roslyn infrastructure and basic node implementations
Copilot Jan 1, 2026
daea747
Refactor to eliminate unnecessary helper methods, use SyntaxFactory d…
Copilot Jan 1, 2026
742caa8
Add Roslyn compilation service and more node implementations
Copilot Jan 1, 2026
f059c49
Fix Entry node parameter handling and test simple Add method compilation
Copilot Jan 1, 2026
c2cd4f2
Fix inline node auxiliary statement ordering and implement ArrayGet, …
Copilot Jan 1, 2026
23ad200
Implement MethodCall, Cast, IsNull, IsNotNull nodes - 43 tests passing
Copilot Jan 1, 2026
5c644bc
Implement WriteLine, SetVariableValueNode, New, SetPropertyOrField, G…
Copilot Jan 1, 2026
6748a2e
Fix executable generation and implement TryCatchNode - ALL TESTS PASS…
Copilot Jan 1, 2026
27c7b31
Refactor: Extract HasMainMethod helper to avoid code duplication
Copilot Jan 1, 2026
da98003
Remove obsolete tests and add comprehensive test coverage
Copilot Jan 1, 2026
a597d1d
ignore generate feature classes
snakex64 Jan 1, 2026
6f07188
Remove feature files without step definitions - fix E2E test errors
Copilot Jan 1, 2026
50a1708
Implement step definitions for all E2E feature files - fix test infra…
Copilot Jan 1, 2026
d6cc939
Code review fixes: Add missing using directives and extract magic num…
Copilot Jan 1, 2026
414244c
Implement comprehensive E2E test automation - all scenarios fully fun…
Copilot Jan 1, 2026
96aeba6
Implement proper validations and assertions in all E2E test step defi…
Copilot Jan 1, 2026
a070166
Code review fixes: Remove ConfigureAwait and fix plural form
Copilot Jan 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@
/src/NodeDev.Blazor.MAUI/obj
/src/NodeDev.Blazor.MAUI/bin/Release/net9.0-windows10.0.19041.0/win-x64
/src/NodeDev.Blazor.Server/AppOptions.json

/src/NodeDev.EndToEndTests/Features/*.feature.cs
245 changes: 245 additions & 0 deletions src/NodeDev.Core/Class/RoslynNodeClassCompiler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.CodeAnalysis.Text;
using NodeDev.Core.Class;
using NodeDev.Core.CodeGeneration;
using System.Reflection;
using System.Runtime.Loader;
using System.Text;
using SF = Microsoft.CodeAnalysis.CSharp.SyntaxFactory;

namespace NodeDev.Core.Class;

/// <summary>
/// Roslyn-based class compiler for NodeDev projects
/// </summary>
public class RoslynNodeClassCompiler
{
private readonly Project _project;
private readonly BuildOptions _options;

public RoslynNodeClassCompiler(Project project, BuildOptions options)
{
_project = project;
_options = options;
}

/// <summary>
/// Compiles the project classes using Roslyn
/// </summary>
public CompilationResult Compile()
{
// Generate the compilation unit (full source code)
var compilationUnit = GenerateCompilationUnit();

// Normalize whitespace for proper debugging
compilationUnit = (CompilationUnitSyntax)compilationUnit.NormalizeWhitespace();

// Convert to source text
var sourceText = compilationUnit.ToFullString();

// Create syntax tree with embedded text for debugging
var syntaxTree = CSharpSyntaxTree.ParseText(
sourceText,
new CSharpParseOptions(LanguageVersion.Latest),
path: $"NodeDev_{_project.Id}.cs",
encoding: Encoding.UTF8);

// Add references to required assemblies
var references = GetMetadataReferences();

// Determine output kind - if there's a Program.Main method, create an executable
var outputKind = _project.HasMainMethod()
? OutputKind.ConsoleApplication
: OutputKind.DynamicallyLinkedLibrary;

// Create compilation
var assemblyName = $"NodeProject_{_project.Id.ToString().Replace('-', '_')}";
var compilation = CSharpCompilation.Create(
assemblyName,
syntaxTrees: new[] { syntaxTree },
references: references,
options: new CSharpCompilationOptions(
outputKind,
optimizationLevel: _options.BuildExpressionOptions.RaiseNodeExecutedEvents
? OptimizationLevel.Debug
: OptimizationLevel.Release,
platform: Platform.AnyCpu,
allowUnsafe: false));

// Emit to memory
using var peStream = new MemoryStream();
using var pdbStream = new MemoryStream();

// Embed source text for debugging
var embeddedTexts = new[] { EmbeddedText.FromSource(syntaxTree.FilePath, SourceText.From(sourceText, Encoding.UTF8)) };

var emitOptions = new EmitOptions(
debugInformationFormat: DebugInformationFormat.PortablePdb,
pdbFilePath: $"{assemblyName}.pdb");

var emitResult = compilation.Emit(
peStream,
pdbStream,
embeddedTexts: embeddedTexts,
options: emitOptions);

if (!emitResult.Success)
{
var errors = emitResult.Diagnostics
.Where(d => d.Severity == DiagnosticSeverity.Error)
.Select(d => $"{d.Id}: {d.GetMessage()}")
.ToList();

throw new CompilationException($"Compilation failed:\n{string.Join("\n", errors)}", errors, sourceText);
}

// Load the assembly
peStream.Seek(0, SeekOrigin.Begin);
pdbStream.Seek(0, SeekOrigin.Begin);

var assembly = Assembly.Load(peStream.ToArray(), pdbStream.ToArray());

return new CompilationResult(assembly, sourceText, peStream.ToArray(), pdbStream.ToArray());
}

/// <summary>
/// Generates the full compilation unit with all classes
/// </summary>
private CompilationUnitSyntax GenerateCompilationUnit()
{
var namespaceDeclarations = new List<MemberDeclarationSyntax>();

// Group classes by namespace
var classGroups = _project.Classes.GroupBy(c => c.Namespace);

foreach (var group in classGroups)
{
var classDeclarations = new List<MemberDeclarationSyntax>();

foreach (var nodeClass in group)
{
classDeclarations.Add(GenerateClass(nodeClass));
}

// Create namespace declaration
var namespaceDecl = SF.FileScopedNamespaceDeclaration(SF.ParseName(group.Key))
.WithMembers(SF.List(classDeclarations));

namespaceDeclarations.Add(namespaceDecl);
}

// Create compilation unit with usings
var compilationUnit = SF.CompilationUnit()
.WithUsings(SF.List(new[]
{
SF.UsingDirective(SF.ParseName("System")),
SF.UsingDirective(SF.ParseName("System.Collections.Generic")),
SF.UsingDirective(SF.ParseName("System.Linq")),
}))
.WithMembers(SF.List(namespaceDeclarations));

return compilationUnit;
}

/// <summary>
/// Generates a class declaration
/// </summary>
private ClassDeclarationSyntax GenerateClass(NodeClass nodeClass)
{
var members = new List<MemberDeclarationSyntax>();

// Generate properties
foreach (var property in nodeClass.Properties)
{
members.Add(GenerateProperty(property));
}

// Generate methods
foreach (var method in nodeClass.Methods)
{
members.Add(GenerateMethod(method));
}

// Create class declaration
var classDecl = SF.ClassDeclaration(nodeClass.Name)
.WithModifiers(SF.TokenList(SF.Token(SyntaxKind.PublicKeyword)))
.WithMembers(SF.List(members));

return classDecl;
}

/// <summary>
/// Generates a property declaration
/// </summary>
private PropertyDeclarationSyntax GenerateProperty(NodeClassProperty property)
{
var propertyType = RoslynHelpers.GetTypeSyntax(property.PropertyType);

var propertyDecl = SF.PropertyDeclaration(propertyType, property.Name)
.WithModifiers(SF.TokenList(SF.Token(SyntaxKind.PublicKeyword)))
.WithAccessorList(SF.AccessorList(SF.List(new[]
{
SF.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
.WithSemicolonToken(SF.Token(SyntaxKind.SemicolonToken)),
SF.AccessorDeclaration(SyntaxKind.SetAccessorDeclaration)
.WithSemicolonToken(SF.Token(SyntaxKind.SemicolonToken))
})));

return propertyDecl;
}

/// <summary>
/// Generates a method declaration
/// </summary>
private MethodDeclarationSyntax GenerateMethod(NodeClassMethod method)
{
var builder = new RoslynGraphBuilder(method.Graph, _options.BuildExpressionOptions.RaiseNodeExecutedEvents);
return builder.BuildMethod();
}

/// <summary>
/// Gets metadata references for compilation
/// </summary>
private List<MetadataReference> GetMetadataReferences()
{
var references = new List<MetadataReference>();

// Add core runtime assemblies
references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location));
references.Add(MetadataReference.CreateFromFile(typeof(Console).Assembly.Location));
references.Add(MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location));

// Add System.Runtime
var systemRuntimeAssembly = Assembly.Load("System.Runtime");
references.Add(MetadataReference.CreateFromFile(systemRuntimeAssembly.Location));

// Add System.Collections
var collectionsAssembly = Assembly.Load("System.Collections");
references.Add(MetadataReference.CreateFromFile(collectionsAssembly.Location));

return references;
}

/// <summary>
/// Result of a Roslyn compilation
/// </summary>
public record CompilationResult(Assembly Assembly, string SourceCode, byte[] PEBytes, byte[] PDBBytes);

/// <summary>
/// Exception thrown when compilation fails
/// </summary>
public class CompilationException : Exception
{
public List<string> Errors { get; }
public string SourceCode { get; }

public CompilationException(string message, List<string> errors, string sourceCode) : base(message)
{
Errors = errors;
SourceCode = sourceCode;
}
}
}
118 changes: 118 additions & 0 deletions src/NodeDev.Core/CodeGeneration/GenerationContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using NodeDev.Core.Connections;

namespace NodeDev.Core.CodeGeneration;

/// <summary>
/// Context for generating Roslyn syntax from a node graph.
/// Manages symbol tables, variable names, and auxiliary statements.
/// </summary>
public class GenerationContext
{
private readonly Dictionary<string, string> _connectionToVariableName = new();
private readonly List<StatementSyntax> _auxiliaryStatements = new();
private readonly HashSet<string> _usedVariableNames = new();
private int _uniqueCounter = 0;

public GenerationContext(bool isDebug)
{
IsDebug = isDebug;
}

/// <summary>
/// Whether to generate debug-friendly code (e.g., with event calls for stepping)
/// </summary>
public bool IsDebug { get; }

/// <summary>
/// Gets the variable name for a connection, or null if not yet registered
/// </summary>
public string? GetVariableName(Connection connection)
{
_connectionToVariableName.TryGetValue(connection.Id, out var name);
return name;
}

/// <summary>
/// Registers a variable name for a connection
/// </summary>
public void RegisterVariableName(Connection connection, string variableName)
{
_connectionToVariableName[connection.Id] = variableName;
}

/// <summary>
/// Generates a unique variable name based on a hint
/// </summary>
public string GetUniqueName(string hint)
{
// Sanitize the hint to make it a valid C# identifier
var sanitized = SanitizeIdentifier(hint);

// If the name is already unique, return it
if (_usedVariableNames.Add(sanitized))
return sanitized;

// Otherwise, append a counter until we find a unique name
string uniqueName;
do
{
uniqueName = $"{sanitized}_{_uniqueCounter++}";
} while (!_usedVariableNames.Add(uniqueName));

return uniqueName;
}

/// <summary>
/// Adds an auxiliary statement that needs to be emitted before the current operation
/// </summary>
public void AddAuxiliaryStatement(StatementSyntax statement)
{
_auxiliaryStatements.Add(statement);
}

/// <summary>
/// Gets all auxiliary statements and clears the buffer
/// </summary>
public List<StatementSyntax> GetAndClearAuxiliaryStatements()
{
var statements = new List<StatementSyntax>(_auxiliaryStatements);
_auxiliaryStatements.Clear();
return statements;
}

/// <summary>
/// Gets all auxiliary statements without clearing
/// </summary>
public IReadOnlyList<StatementSyntax> GetAuxiliaryStatements() => _auxiliaryStatements.AsReadOnly();

private static string SanitizeIdentifier(string hint)
{
if (string.IsNullOrEmpty(hint))
return "var";

// Remove invalid characters
var chars = hint.ToCharArray();
for (int i = 0; i < chars.Length; i++)
{
if (!char.IsLetterOrDigit(chars[i]) && chars[i] != '_')
chars[i] = '_';
}

var result = new string(chars);

// Ensure it starts with a letter or underscore
if (!char.IsLetter(result[0]) && result[0] != '_')
result = "_" + result;

// Avoid C# keywords
if (SyntaxFacts.GetKeywordKind(result) != SyntaxKind.None ||
SyntaxFacts.GetContextualKeywordKind(result) != SyntaxKind.None)
{
result = "@" + result;
}

return result;
}
}
Loading