< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Languages.VBNetDelegateBuilder
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Languages/VBNetDelegateBuilder.cs
Tag: Kestrun/Kestrun@9d3a582b2d63930269564a7591aa77ef297cadeb
Line coverage
94%
Covered lines: 197
Uncovered lines: 11
Coverable lines: 208
Total lines: 505
Line coverage: 94.7%
Branch coverage
83%
Covered branches: 104
Total branches: 124
Branch coverage: 83.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Build(...)68.75%1616100%
GetVbReturnType(...)100%44100%
Compile(...)82.14%282897.5%
GetStartLineOrThrow(...)75%4485.71%
BuildMetadataReferences(...)83.33%66100%
CollectDynamicImports(...)91.66%1212100%
AddTypeNamespaces(...)100%1010100%
SafeHasLocation(...)100%2260%
LogWarnings(...)90%1010100%
ThrowIfErrors(...)16.66%18630%
LoadDelegateFromAssembly(...)100%11100%
BuildWrappedSource(...)96.15%2626100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Languages/VBNetDelegateBuilder.cs

#LineLine coverage
 1using System.Collections.Immutable;
 2using System.Reflection;
 3using System.Text;
 4using Microsoft.CodeAnalysis.Scripting;
 5using Microsoft.CodeAnalysis.VisualBasic;
 6using Serilog.Events;
 7using Microsoft.CodeAnalysis;
 8using Kestrun.Utilities;
 9using System.Security.Claims;
 10using Kestrun.Logging;
 11using Kestrun.SharedState;
 12
 13namespace Kestrun.Languages;
 14
 15
 16internal static class VBNetDelegateBuilder
 17{
 18    /// <summary>
 19    /// The marker that indicates where user code starts in the VB.NET script.
 20    /// This is used to ensure that the user code is correctly placed within the generated module.
 21    /// </summary>
 22    private const string StartMarker = "' ---- User code starts here ----";
 23
 24    /// <summary>
 25    /// Builds a VB.NET delegate for Kestrun routes.
 26    /// </summary>
 27    /// <remarks>
 28    /// This method uses the Roslyn compiler to compile the provided VB.NET code into a delegate.
 29    /// </remarks>
 30    /// <param name="code">The VB.NET code to compile.</param>
 31    /// <param name="log">The logger to use for logging compilation errors and warnings.</param>
 32    /// <param name="args">The arguments to pass to the script.</param>
 33    /// <param name="extraImports">Optional additional namespaces to import in the script.</param>
 34    /// <param name="extraRefs">Optional additional assemblies to reference in the script.</param>
 35    /// <param name="languageVersion">The VB.NET language version to use for compilation.</param>
 36    /// <returns>A delegate that takes CsGlobals and returns a Task.</returns>
 37    /// <exception cref="CompilationErrorException">Thrown if the compilation fails with errors.</exception>
 38    /// <remarks>
 39    /// This method uses the Roslyn compiler to compile the provided VB.NET code into a delegate.
 40    /// </remarks>
 41    internal static RequestDelegate Build(
 42        string code, Serilog.ILogger log, Dictionary<string, object?>? args, string[]? extraImports,
 43        Assembly[]? extraRefs, LanguageVersion languageVersion = LanguageVersion.VisualBasic16_9)
 44    {
 345        if (log.IsEnabled(LogEventLevel.Debug))
 46        {
 247            log.Debug("Building VB.NET delegate, script length={Length}, imports={ImportsCount}, refs={RefsCount}, lang=
 248               code.Length, extraImports?.Length ?? 0, extraRefs?.Length ?? 0, languageVersion);
 49        }
 50
 51        // Validate inputs
 352        if (string.IsNullOrWhiteSpace(code))
 53        {
 154            throw new ArgumentNullException(nameof(code), "VB.NET code cannot be null or whitespace.");
 55        }
 56        // 1. Compile the VB.NET code into a script
 57        //    - Use VisualBasicScript.Create() to create a script with the provided code
 58        //    - Use ScriptOptions to specify imports, references, and language version
 59        //    - Inject the provided arguments into the globals
 260        var script = Compile<bool>(code, log, extraImports, extraRefs, null, languageVersion);
 61
 62        // 2. Build the per-request delegate
 63        //    - This delegate will be executed for each request
 64        //    - It will create a KestrunContext and CsGlobals, then execute the script with these globals
 65        //    - The script can access the request context and shared state store
 266        if (log.IsEnabled(LogEventLevel.Debug))
 67        {
 168            log.Debug("C# delegate built successfully, script length={Length}, imports={ImportsCount}, refs={RefsCount},
 169                code?.Length, extraImports?.Length ?? 0, extraRefs?.Length ?? 0, languageVersion);
 70        }
 71
 272        return async ctx =>
 273        {
 274            try
 275            {
 276                if (log.IsEnabled(LogEventLevel.Debug))
 277                {
 178                    log.Debug("Preparing execution for C# script at {Path}", ctx.Request.Path);
 279                }
 280
 281                var (Globals, Response, Context) = await DelegateBuilder.PrepareExecutionAsync(ctx, log, args).Configure
 282
 283
 284
 285                // Execute the script with the current context and shared state
 286                if (log.IsEnabled(LogEventLevel.Debug))
 287                {
 188                    log.DebugSanitized("Executing VB.NET script for {Path}", ctx.Request.Path);
 289                }
 290
 291                _ = await script(Globals).ConfigureAwait(false);
 292                if (log.IsEnabled(LogEventLevel.Debug))
 293                {
 194                    log.DebugSanitized("VB.NET script executed successfully for {Path}", ctx.Request.Path);
 295                }
 296
 297                // Apply the response to the Kestrun context
 298                await DelegateBuilder.ApplyResponseAsync(ctx, Response, log).ConfigureAwait(false);
 299            }
 2100            finally
 2101            {
 2102                await ctx.Response.CompleteAsync().ConfigureAwait(false);
 2103            }
 4104        };
 105    }
 106
 107
 108
 109
 110    // Decide the VB return type string that matches TResult
 111    private static string GetVbReturnType(Type t)
 112    {
 10113        if (t == typeof(bool))
 114        {
 6115            return "Boolean";
 116        }
 117
 4118        if (t == typeof(IEnumerable<Claim>))
 119        {
 3120            return "System.Collections.Generic.IEnumerable(Of System.Security.Claims.Claim)";
 121        }
 122
 123        // Fallback so it still compiles even for object / string / etc.
 1124        return "Object";
 125    }
 126
 127    /// <summary>
 128    /// Compiles the provided VB.NET code into a delegate that can be executed with CsGlobals.
 129    /// </summary>
 130    /// <param name="code">The VB.NET code to compile.</param>
 131    /// <param name="log">The logger to use for logging compilation errors and warnings.</param>
 132    /// <param name="extraImports">Optional additional namespaces to import in the script.</param>
 133    /// <param name="extraRefs">Optional additional assemblies to reference in the script.</param>
 134    /// <param name="locals">Optional local variables to provide to the script.</param>
 135    /// <param name="languageVersion">The VB.NET language version to use for compilation.</param>
 136    /// <returns>A delegate that takes CsGlobals and returns a Task.</returns>
 137    /// <exception cref="CompilationErrorException">Thrown if the compilation fails with errors.</exception>
 138    /// <remarks>
 139    /// This method uses the Roslyn compiler to compile the provided VB.NET code into a delegate.
 140    /// </remarks>
 141    internal static Func<CsGlobals, Task<TResult>> Compile<TResult>(
 142            string? code, Serilog.ILogger log, string[]? extraImports,
 143            Assembly[]? extraRefs, IReadOnlyDictionary<string, object?>? locals, LanguageVersion languageVersion
 144        )
 145    {
 10146        if (log.IsEnabled(LogEventLevel.Debug))
 147        {
 7148            log.Debug("Building VB.NET delegate, script length={Length}, imports={ImportsCount}, refs={RefsCount}, lang=
 7149               code?.Length, extraImports?.Length ?? 0, extraRefs?.Length ?? 0, languageVersion);
 150        }
 151
 152        // Validate inputs
 10153        if (string.IsNullOrWhiteSpace(code))
 154        {
 0155            throw new ArgumentNullException(nameof(code), "VB.NET code cannot be null or whitespace.");
 156        }
 157
 10158        extraImports ??= [];
 10159        extraImports = [.. extraImports, "System.Collections.Generic", "System.Linq", "System.Security.Claims"];
 160
 161        // Discover dynamic namespaces from globals + locals similar to C# path
 10162        var dynamicImports = CollectDynamicImports(locals);
 10163        if (dynamicImports.Count > 0)
 164        {
 9165            var mergedImports = extraImports.Concat(dynamicImports)
 9166                .Distinct(StringComparer.Ordinal)
 9167                .ToArray();
 9168            if (log.IsEnabled(LogEventLevel.Debug) && mergedImports.Length != extraImports.Length)
 169            {
 6170                log.Debug("Added {Count} dynamic VB imports from globals/locals.", mergedImports.Length - extraImports.L
 171            }
 9172            extraImports = mergedImports;
 173        }
 174
 175        // 🔧 1.  Build a real VB file around the user snippet
 10176        var source = BuildWrappedSource(code, extraImports, vbReturnType: GetVbReturnType(typeof(TResult)),
 10177            locals: locals);
 178
 179        // Prepares the source code for compilation.
 10180        var startLine = GetStartLineOrThrow(source, log);
 181
 182        // Parse the source code into a syntax tree
 183        // This will allow us to analyze and compile the code
 10184        var tree = VisualBasicSyntaxTree.ParseText(
 10185                       source,
 10186                       new VisualBasicParseOptions(LanguageVersion.VisualBasic16));
 187
 188        // 🔧 2.  References = everything already loaded  +  extras
 10189        var refs = BuildMetadataReferences(extraRefs);
 190        // 🔧 3.  Normal DLL compilation
 10191        var compilation = VisualBasicCompilation.Create(
 10192                 assemblyName: $"RouteScript_{Guid.NewGuid():N}",
 10193                 syntaxTrees: [tree],
 10194                 references: refs,
 10195                 options: new VisualBasicCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
 196
 10197        using var ms = new MemoryStream();
 10198        var emitResult = compilation.Emit(ms) ?? throw new InvalidOperationException("Failed to compile VB.NET script.")
 199        // 🔧 4.  Log the compilation result
 10200        if (log.IsEnabled(LogEventLevel.Debug))
 201        {
 7202            log.Debug("VB.NET script compilation completed, assembly size={Size} bytes", ms.Length);
 203        }
 204
 205        // 🔧 5. Handle diagnostics
 10206        ThrowIfErrors(emitResult.Diagnostics, startLine, log);
 207        // Log any warnings from the compilation process
 10208        LogWarnings(emitResult.Diagnostics, startLine, log);
 209
 210        // If there are no errors, log a debug message
 10211        if (emitResult.Success && log.IsEnabled(LogEventLevel.Debug))
 212        {
 7213            log.Debug("VB.NET script compiled successfully with no errors.");
 214        }
 215
 216        // If there are no errors, proceed to load the assembly and create the delegate
 10217        if (log.IsEnabled(LogEventLevel.Debug))
 218        {
 7219            log.Debug("VB.NET script compiled successfully, loading assembly...");
 220        }
 221
 10222        ms.Position = 0;
 10223        return LoadDelegateFromAssembly<TResult>(ms.ToArray());
 10224    }
 225
 226    /// <summary>
 227    /// Prepares the source code for compilation.
 228    /// </summary>
 229    /// <param name="source">The source code to prepare.</param>
 230    /// <param name="log">The logger instance.</param>
 231    /// <returns>The prepared source code.</returns>
 232    /// <exception cref="ArgumentException">Thrown when the source code is invalid.</exception>
 233    private static int GetStartLineOrThrow(string source, Serilog.ILogger log)
 234    {
 10235        var startIndex = source.IndexOf(StartMarker, StringComparison.Ordinal);
 10236        if (startIndex < 0)
 237        {
 0238            throw new ArgumentException($"VB.NET code must contain the marker '{StartMarker}' to indicate where user cod
 239        }
 240
 10241        var startLine = CcUtilities.GetLineNumber(source, startIndex);
 10242        if (log.IsEnabled(LogEventLevel.Debug))
 243        {
 7244            log.Debug("VB.NET script starts at line {LineNumber}", startLine);
 245        }
 246
 10247        return startLine;
 248    }
 249
 250    /// <summary>
 251    /// Prepares the metadata references for the VB.NET script.
 252    /// </summary>
 253    /// <param name="extraRefs">The extra references to include.</param>
 254    /// <returns>An enumerable of metadata references.</returns>
 255    private static IEnumerable<MetadataReference> BuildMetadataReferences(Assembly[]? extraRefs)
 256    {
 257        // NOTE: Some tests create throwaway assemblies in temp folders and then delete the folder.
 258        // On Windows the delete often fails (file still locked) so Assembly.Location continues to exist.
 259        // On Linux the delete succeeds; the Assembly remains loaded but its Location now points to a
 260        // non-existent path.  Roslyn's MetadataReference.CreateFromFile will throw FileNotFoundException
 261        // in that scenario.  We therefore skip any loaded assemblies whose Location no longer exists.
 10262        var baseRefs = AppDomain.CurrentDomain.GetAssemblies()
 2562263            .Where(a => !a.IsDynamic && SafeHasLocation(a))
 2299264            .Select(a => MetadataReference.CreateFromFile(a.Location));
 265
 10266        var extras = extraRefs?.Select(r => MetadataReference.CreateFromFile(r.Location))
 10267                     ?? Enumerable.Empty<MetadataReference>();
 268
 269        // Always include the VB runtime explicitly then add our common baseline references.
 10270        return baseRefs
 10271            .Concat(extras)
 10272            .Append(MetadataReference.CreateFromFile(typeof(Microsoft.VisualBasic.Constants).Assembly.Location))
 10273            .Concat(DelegateBuilder.BuildBaselineReferences());
 274    }
 275
 276    /// <summary>
 277    /// Collects dynamic imports from the types of the provided locals and shared globals.
 278    /// </summary>
 279    /// <param name="locals">The local variables to inspect.</param>
 280    /// <returns>A set of unique namespace strings.</returns>
 281    private static HashSet<string> CollectDynamicImports(IReadOnlyDictionary<string, object?>? locals)
 282    {
 10283        var imports = new HashSet<string>(StringComparer.Ordinal);
 284        // Merge globals + locals (locals override) just for namespace harvesting
 10285        var globals = SharedStateStore.Snapshot();
 96286        foreach (var g in globals)
 287        {
 38288            AddTypeNamespaces(g.Value?.GetType(), imports);
 289        }
 10290        if (locals is { Count: > 0 })
 291        {
 30292            foreach (var l in locals)
 293            {
 9294                AddTypeNamespaces(l.Value?.GetType(), imports);
 295            }
 296        }
 10297        return imports;
 298    }
 299
 300    private static void AddTypeNamespaces(Type? t, HashSet<string> set)
 301    {
 56302        if (t == null)
 303        {
 30304            return;
 305        }
 26306        if (!string.IsNullOrEmpty(t.Namespace))
 307        {
 26308            _ = set.Add(t.Namespace!);
 309        }
 26310        if (t.IsGenericType)
 311        {
 22312            foreach (var ga in t.GetGenericArguments())
 313            {
 7314                AddTypeNamespaces(ga, set);
 315            }
 316        }
 26317        if (t.IsArray)
 318        {
 2319            AddTypeNamespaces(t.GetElementType(), set);
 320        }
 26321    }
 322    private static bool SafeHasLocation(Assembly a)
 323    {
 324        try
 325        {
 2537326            var loc = a.Location; // may throw for some dynamic contexts
 2537327            return !string.IsNullOrEmpty(loc) && File.Exists(loc);
 328        }
 0329        catch
 330        {
 0331            return false;
 332        }
 2537333    }
 334    /// <summary>
 335    /// Logs any warnings from the compilation process.
 336    /// </summary>
 337    /// <param name="diagnostics">The diagnostics to check.</param>
 338    /// <param name="startLine">The starting line number.</param>
 339    /// <param name="log">The logger instance.</param>
 340    private static void LogWarnings(ImmutableArray<Diagnostic> diagnostics, int startLine, Serilog.ILogger log)
 341    {
 82342        var warnings = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Warning).ToArray();
 343        // If there are no warnings, log a debug message
 10344        if (warnings.Length == 0)
 345        {
 8346            if (log.IsEnabled(LogEventLevel.Debug))
 347            {
 6348                log.Debug("VB.NET script compiled successfully with no warnings.");
 349            }
 350
 8351            return;
 352        }
 353
 2354        log.Warning($"VBNet script compilation completed with {warnings.Length} warning(s):");
 8355        foreach (var warning in warnings)
 356        {
 2357            var location = warning.Location.IsInSource
 2358                ? $" at line {warning.Location.GetLineSpan().StartLinePosition.Line - startLine + 1}"
 2359                : "";
 2360            log.Warning($"  Warning [{warning.Id}]: {warning.GetMessage()}{location}");
 361        }
 2362        if (log.IsEnabled(LogEventLevel.Debug))
 363        {
 1364            log.Debug("VB.NET script compiled with warnings: {Count}", warnings.Length);
 365        }
 2366    }
 367
 368    /// <summary>
 369    /// Throws an exception if there are compilation errors.
 370    /// </summary>
 371    /// <param name="diagnostics">The diagnostics to check.</param>
 372    /// <param name="startLine">The starting line number.</param>
 373    /// <param name="log">The logger instance.</param>
 374    private static void ThrowIfErrors(ImmutableArray<Diagnostic> diagnostics, int startLine, Serilog.ILogger log)
 375    {
 82376        var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToArray();
 10377        if (errors.Length == 0)
 378        {
 10379            return;
 380        }
 381
 0382        log.Error($"VBNet script compilation completed with {errors.Length} error(s):");
 0383        foreach (var error in errors)
 384        {
 0385            var location = error.Location.IsInSource
 0386                ? $" at line {error.Location.GetLineSpan().StartLinePosition.Line - startLine + 1}"
 0387                : "";
 0388            log.Error($"  Error [{error.Id}]: {error.GetMessage()}{location}");
 389        }
 0390        throw new CompilationErrorException("VBNet route code compilation failed", diagnostics);
 391    }
 392
 393    /// <summary>
 394    /// Loads a delegate from the provided assembly bytes.
 395    /// </summary>
 396    /// <typeparam name="TResult">The type of the result.</typeparam>
 397    /// <param name="asmBytes">The assembly bytes.</param>
 398    /// <returns>A delegate that can be invoked with the specified globals.</returns>
 399    private static Func<CsGlobals, Task<TResult>> LoadDelegateFromAssembly<TResult>(byte[] asmBytes)
 400    {
 10401        var asm = Assembly.Load(asmBytes);
 10402        var runMethod = asm.GetType("RouteScript")!
 10403                           .GetMethod("Run", BindingFlags.Public | BindingFlags.Static)!;
 404
 10405        var delegateType = typeof(Func<,>).MakeGenericType(
 10406            typeof(CsGlobals),
 10407            typeof(Task<>).MakeGenericType(typeof(TResult)));
 408
 10409        return (Func<CsGlobals, Task<TResult>>)runMethod.CreateDelegate(delegateType);
 410    }
 411
 412    /// <summary>
 413    /// Builds the wrapped source code for the VB.NET script.
 414    /// </summary>
 415    /// <param name="code">The user-provided code to wrap.</param>
 416    /// <param name="extraImports">Additional imports to include.</param>
 417    /// <param name="vbReturnType">The return type of the VB.NET function.</param>
 418    /// <param name="locals">Local variables to bind to the script.</param>
 419    /// <returns>The wrapped source code.</returns>
 420    private static string BuildWrappedSource(string? code, IEnumerable<string>? extraImports,
 421    string vbReturnType, IReadOnlyDictionary<string, object?>? locals = null
 422       )
 423    {
 10424        var sb = new StringBuilder();
 425
 426        // common + caller-supplied Imports
 10427        var builtIns = new[] {
 10428        "System", "System.Threading.Tasks",
 10429        "Kestrun", "Kestrun.Models",
 10430          "Microsoft.VisualBasic",
 10431          "Kestrun.Languages"
 10432        };
 433
 200434        foreach (var ns in builtIns.Concat(extraImports ?? [])
 10435                                   .Distinct(StringComparer.Ordinal))
 436        {
 90437            _ = sb.AppendLine($"Imports {ns}");
 438        }
 439
 10440        _ = sb.AppendLine($"""
 10441                Public Module RouteScript
 10442                    Public Async Function Run(g As CsGlobals) As Task(Of {vbReturnType})
 10443                        Await Task.Yield() ' placeholder await
 10444                        Dim Request  = g.Context?.Request
 10445                        Dim Response = g.Context?.Response
 10446                        Dim Context  = g.Context
 10447        """);
 448
 449        // only emit these _when_ you called Compile with locals:
 10450        if (locals?.ContainsKey("username") ?? false)
 451        {
 1452            _ = sb.AppendLine("""
 1453        ' only bind creds if someone passed them in
 1454                        Dim username As String = CStr(g.Locals("username"))
 1455        """);
 456        }
 457
 10458        if (locals?.ContainsKey("password") ?? false)
 459        {
 1460            _ = sb.AppendLine("""
 1461                        Dim password As String = CStr(g.Locals("password"))
 1462        """);
 463        }
 464
 10465        if (locals?.ContainsKey("providedKey") == true)
 466        {
 2467            _ = sb.AppendLine("""
 2468        ' only bind keys if someone passed them in
 2469                        Dim providedKey As String = CStr(g.Locals("providedKey"))
 2470        """);
 471        }
 472
 10473        if (locals?.ContainsKey("providedKeyBytes") == true)
 474        {
 2475            _ = sb.AppendLine("""
 2476                        Dim providedKeyBytes As Byte() = CType(g.Locals("providedKeyBytes"), Byte())
 2477        """);
 478        }
 479
 10480        if (locals?.ContainsKey("identity") == true)
 481        {
 3482            _ = sb.AppendLine("""
 3483                        Dim identity As String = CStr(g.Locals("identity"))
 3484        """);
 485        }
 486
 487        // add the Marker for user code
 10488        _ = sb.AppendLine(StartMarker);
 489        // ---- User code starts here ----
 490
 10491        if (!string.IsNullOrEmpty(code))
 492        {
 493            // indent the user snippet so VB is happy
 10494            _ = sb.AppendLine(string.Join(
 10495                Environment.NewLine,
 21496                code.Split('\n').Select(l => "        " + l.TrimEnd('\r'))));
 497        }
 10498        _ = sb.AppendLine("""
 10499
 10500                End Function
 10501            End Module
 10502    """);
 10503        return sb.ToString();
 504    }
 505}