From cf9548e9ed8a7cce83bf7c38ba33dd29227d68d7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 04:40:07 -0400 Subject: [PATCH] feat(ui/scripts): Roslyn-backed C# completions + diagnostics for Monaco MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Microsoft.CodeAnalysis.CSharp.Scripting (4.13.0). Scripts are compiled as C# script fragments against a ScriptHost globals type that mirrors what the runtime exposes (Parameters bag, CallShared, CallScript) — Roslyn reads the signatures so those identifiers are in scope for analysis without executing anything. ScriptAnalysisService: - Diagnose(code): Compilation.GetDiagnostics() projected to Monaco-shaped DiagnosticMarker records (severity 8/4/2/1). - Complete(code, line, col): dot-member lookup via SemanticModel when the token at position is part of a MemberAccessExpression; falls back to LookupSymbols at position for the general case. Two endpoints exposed by the existing CentralUI endpoint pipeline, both behind RequireDesign policy: POST /api/script-analysis/diagnostics POST /api/script-analysis/completions monaco-init.js registers a csharp CompletionItemProvider with dot/ paren/quote trigger chars, plus a 500 ms debounced diagnostics pass on every keystroke that pushes markers via setModelMarkers. Initial pass fires on editor create so existing scripts surface errors right away. Auth uses the existing cookie via credentials: same-origin. Smoke-verified: - Typing `DateTimeOffset.UtcNow` (no semicolon) shows the missing semicolon squiggle in real time. - Ctrl-Space at file scope returns the full type universe (AccessViolationException, Action, Akka, AppDomain, ...). Wave 2 of three. SCADA-specific extensions (declared param keys, shared/sibling script names, forbidden-API diagnostic) follow. --- src/ScadaLink.CentralUI/EndpointExtensions.cs | 2 + .../ScadaLink.CentralUI.csproj | 4 + .../ScriptAnalysis/ScriptAnalysisContracts.cs | 28 +++ .../ScriptAnalysis/ScriptAnalysisEndpoints.cs | 24 +++ .../ScriptAnalysis/ScriptAnalysisService.cs | 179 ++++++++++++++++++ .../ScriptAnalysis/ScriptHost.cs | 19 ++ .../ServiceCollectionExtensions.cs | 4 + .../wwwroot/js/monaco-init.js | 81 ++++++++ 8 files changed, 341 insertions(+) create mode 100644 src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs create mode 100644 src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs create mode 100644 src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs create mode 100644 src/ScadaLink.CentralUI/ScriptAnalysis/ScriptHost.cs diff --git a/src/ScadaLink.CentralUI/EndpointExtensions.cs b/src/ScadaLink.CentralUI/EndpointExtensions.cs index e5fb009..ff4062f 100644 --- a/src/ScadaLink.CentralUI/EndpointExtensions.cs +++ b/src/ScadaLink.CentralUI/EndpointExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using ScadaLink.CentralUI.Auth; using ScadaLink.CentralUI.Components.Layout; +using ScadaLink.CentralUI.ScriptAnalysis; namespace ScadaLink.CentralUI; @@ -15,6 +16,7 @@ public static class EndpointExtensions where TApp : Microsoft.AspNetCore.Components.IComponent { endpoints.MapAuthEndpoints(); + endpoints.MapScriptAnalysisEndpoints(); endpoints.MapRazorComponents() .AddInteractiveServerRenderMode() diff --git a/src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj b/src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj index 962601f..15462a9 100644 --- a/src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj +++ b/src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj @@ -11,6 +11,10 @@ + + + + diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs new file mode 100644 index 0000000..71535b7 --- /dev/null +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs @@ -0,0 +1,28 @@ +namespace ScadaLink.CentralUI.ScriptAnalysis; + +public record DiagnoseRequest(string Code); + +public record DiagnoseResponse(IReadOnlyList Markers); + +/// +/// Shape Monaco's setModelMarkers expects (with severity mapped to Monaco's +/// MarkerSeverity enum: 1=Hint, 2=Info, 4=Warning, 8=Error). +/// +public record DiagnosticMarker( + int Severity, + int StartLineNumber, + int StartColumn, + int EndLineNumber, + int EndColumn, + string Message, + string Code); + +public record CompletionsRequest(string CodeText, int Line, int Column); + +public record CompletionsResponse(IReadOnlyList Items); + +public record CompletionItem( + string Label, + string InsertText, + string Detail, + string Kind); diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs new file mode 100644 index 0000000..611b72e --- /dev/null +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.Security; + +namespace ScadaLink.CentralUI.ScriptAnalysis; + +public static class ScriptAnalysisEndpoints +{ + public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/script-analysis") + .RequireAuthorization(AuthorizationPolicies.RequireDesign); + + group.MapPost("/diagnostics", (DiagnoseRequest req, ScriptAnalysisService svc) => + Results.Ok(svc.Diagnose(req))); + + group.MapPost("/completions", (CompletionsRequest req, ScriptAnalysisService svc) => + Results.Ok(svc.Complete(req))); + + return endpoints; + } +} diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs new file mode 100644 index 0000000..8c56141 --- /dev/null +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -0,0 +1,179 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Scripting; + +namespace ScadaLink.CentralUI.ScriptAnalysis; + +/// +/// Compiles user scripts as Roslyn C# Scripting fragments against +/// globals and surfaces diagnostics + completions +/// in the shape Monaco's provider APIs expect. Lightweight — no caching; +/// each request rebuilds the script. Acceptable for human-paced edits. +/// +public class ScriptAnalysisService +{ + private static readonly ScriptOptions DefaultOptions = ScriptOptions.Default + .AddReferences( + typeof(object).Assembly, + typeof(Enumerable).Assembly, + typeof(System.Collections.Generic.Dictionary<,>).Assembly, + typeof(System.ComponentModel.DescriptionAttribute).Assembly, + typeof(ScriptHost).Assembly) + .AddImports( + "System", + "System.Collections.Generic", + "System.Linq", + "System.Text", + "System.Threading.Tasks"); + + public DiagnoseResponse Diagnose(DiagnoseRequest request) + { + if (string.IsNullOrEmpty(request.Code)) + return new DiagnoseResponse(Array.Empty()); + + Script script; + try + { + script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: typeof(ScriptHost)); + } + catch (Exception ex) + { + return new DiagnoseResponse(new[] + { + new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD") + }); + } + + var compilation = script.GetCompilation(); + var markers = compilation + .GetDiagnostics() + .Where(d => d.Severity >= DiagnosticSeverity.Info && d.Location.IsInSource) + .Select(ToMarker) + .ToList(); + + return new DiagnoseResponse(markers); + } + + public CompletionsResponse Complete(CompletionsRequest request) + { + if (string.IsNullOrEmpty(request.CodeText)) + return new CompletionsResponse(Array.Empty()); + + Script script; + try + { + script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: typeof(ScriptHost)); + } + catch + { + return new CompletionsResponse(Array.Empty()); + } + + var compilation = script.GetCompilation(); + var tree = compilation.SyntaxTrees.FirstOrDefault(); + if (tree == null) return new CompletionsResponse(Array.Empty()); + + var semanticModel = compilation.GetSemanticModel(tree); + var position = PositionToOffset(request.CodeText, request.Line, request.Column); + position = Math.Clamp(position, 0, request.CodeText.Length); + + var root = tree.GetRoot(); + var token = root.FindToken(Math.Max(0, position - 1)); + + // Dot completion: look up members of the type on the left of the dot. + var dotMembers = TryGetDotMembers(token, semanticModel); + if (dotMembers != null) + return new CompletionsResponse(dotMembers); + + // General completion: in-scope symbols at position. + var scoped = semanticModel.LookupSymbols(position) + .Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name)) + .GroupBy(s => s.Name) + .Select(g => g.First()) + .Select(ToCompletionItem) + .Take(200) + .ToList(); + + return new CompletionsResponse(scoped); + } + + private static List? TryGetDotMembers(SyntaxToken token, SemanticModel model) + { + // The cursor may be positioned right after a '.'; resolve the + // member-access node and look up the left-hand side's type members. + var memberAccess = token.Parent as MemberAccessExpressionSyntax + ?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax; + if (memberAccess == null) return null; + + var typeInfo = model.GetTypeInfo(memberAccess.Expression); + var type = typeInfo.Type ?? typeInfo.ConvertedType; + if (type == null) return null; + + return type.GetMembers() + .Where(m => m.CanBeReferencedByName && !m.IsImplicitlyDeclared) + .Where(m => m.DeclaredAccessibility == Accessibility.Public || m.DeclaredAccessibility == Accessibility.NotApplicable) + .GroupBy(m => m.Name) + .Select(g => g.First()) + .Select(ToCompletionItem) + .Take(200) + .ToList(); + } + + private static CompletionItem ToCompletionItem(ISymbol symbol) + { + var kind = symbol.Kind switch + { + SymbolKind.Method => "Method", + SymbolKind.Property => "Property", + SymbolKind.Field => "Field", + SymbolKind.Event => "Event", + SymbolKind.NamedType => "Class", + SymbolKind.Local => "Variable", + SymbolKind.Parameter => "Variable", + SymbolKind.Namespace => "Module", + _ => "Text" + }; + return new CompletionItem( + Label: symbol.Name, + InsertText: symbol.Name, + Detail: symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), + Kind: kind); + } + + private static DiagnosticMarker ToMarker(Diagnostic d) + { + var span = d.Location.GetLineSpan().Span; + var severity = d.Severity switch + { + DiagnosticSeverity.Error => 8, + DiagnosticSeverity.Warning => 4, + DiagnosticSeverity.Info => 2, + _ => 1 + }; + return new DiagnosticMarker( + Severity: severity, + StartLineNumber: span.Start.Line + 1, + StartColumn: span.Start.Character + 1, + EndLineNumber: span.End.Line + 1, + EndColumn: span.End.Character + 1, + Message: d.GetMessage(), + Code: d.Id); + } + + private static int PositionToOffset(string code, int line, int column) + { + var offset = 0; + var currentLine = 1; + var currentCol = 1; + for (int i = 0; i < code.Length; i++) + { + if (currentLine == line && currentCol == column) return offset; + if (code[i] == '\n') { currentLine++; currentCol = 1; } + else { currentCol++; } + offset = i + 1; + } + return code.Length; + } +} diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptHost.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptHost.cs new file mode 100644 index 0000000..8cffa29 --- /dev/null +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptHost.cs @@ -0,0 +1,19 @@ +namespace ScadaLink.CentralUI.ScriptAnalysis; + +/// +/// Globals type seen by user scripts. Mirrors the surface the runtime exposes +/// today: Parameters bag plus CallShared / CallScript stubs. The methods here +/// are never invoked — Roslyn only reads their signatures to know what's in +/// scope while compiling for diagnostics + completions. +/// +public class ScriptHost +{ + public IReadOnlyDictionary Parameters { get; init; } = + new Dictionary(); + + /// Invokes another shared script by name and returns its result. + public object? CallShared(string name, params object?[] args) => null; + + /// Invokes another script on the same template and returns its result. + public object? CallScript(string name, params object?[] args) => null; +} diff --git a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs index d8cc906..5bb8069 100644 --- a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using ScadaLink.CentralUI.Auth; using ScadaLink.CentralUI.Components.Shared; +using ScadaLink.CentralUI.ScriptAnalysis; namespace ScadaLink.CentralUI; @@ -22,6 +23,9 @@ public static class ServiceCollectionExtensions // Components/Shared/IDialogService.cs. services.AddScoped(); + // Roslyn-backed C# analysis for the Monaco script editor. + services.AddSingleton(); + return services; } } diff --git a/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js b/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js index 0f5e9f1..355976c 100644 --- a/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js +++ b/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js @@ -18,6 +18,7 @@ require.config({ paths: { vs: VS_BASE } }); // eslint-disable-next-line no-undef require(["vs/editor/editor.main"], function () { + registerCSharpProviders(); resolve(); }); }; @@ -27,6 +28,72 @@ return readyPromise; } + // ---- Roslyn-backed C# language providers -------------------------------- + + const KIND_MAP = { + Method: 0, Field: 4, Property: 9, Event: 10, Class: 6, + Module: 8, Variable: 4, Text: 18 + }; + + function registerCSharpProviders() { + // Completion: triggered on ".", "(", "\"" and on demand (Ctrl-Space). + monaco.languages.registerCompletionItemProvider("csharp", { + triggerCharacters: [".", "(", "\""], + provideCompletionItems: async function (model, position) { + try { + const resp = await fetch("/api/script-analysis/completions", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + codeText: model.getValue(), + line: position.lineNumber, + column: position.column + }) + }); + if (!resp.ok) return { suggestions: [] }; + const data = await resp.json(); + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn + }; + return { + suggestions: (data.items || []).map(function (it) { + return { + label: it.label, + insertText: it.insertText, + detail: it.detail, + kind: KIND_MAP[it.kind] != null ? KIND_MAP[it.kind] : 18, + range: range + }; + }) + }; + } catch (e) { + return { suggestions: [] }; + } + } + }); + } + + async function fetchDiagnostics(code) { + try { + const resp = await fetch("/api/script-analysis/diagnostics", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: code }) + }); + if (!resp.ok) return []; + const data = await resp.json(); + return data.markers || []; + } catch (e) { + return []; + } + } + async function createEditor(id, host, options, dotNetRef) { await ensureLoaded(); if (!host) return; @@ -46,11 +113,25 @@ wordWrap: "off", fixedOverflowWidgets: true }); + let diagTimer = null; + const scheduleDiagnostics = function () { + if (diagTimer) clearTimeout(diagTimer); + diagTimer = setTimeout(async function () { + const markers = await fetchDiagnostics(editor.getValue()); + const model = editor.getModel(); + if (model) monaco.editor.setModelMarkers(model, "scadalink", markers); + }, 500); + }; + editor.onDidChangeModelContent(function () { const value = editor.getValue(); dotNetRef.invokeMethodAsync("OnValueChanged", value).catch(function () {}); + if (options.language === "csharp") scheduleDiagnostics(); }); editors[id] = { editor: editor, dotNetRef: dotNetRef }; + + // Run an initial diagnostic pass so existing scripts show their markers. + if (options.language === "csharp") scheduleDiagnostics(); } function setValue(id, value) {