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) {