diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 1de32072..4d671e44 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -47,6 +47,9 @@ public static class EndpointRouteBuilderExtensions services.AddSingleton(); services.AddSingleton(); + // Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...). + services.AddScoped(); + return services; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs new file mode 100644 index 00000000..62f962a4 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs @@ -0,0 +1,24 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +public record DiagnoseRequest(string Code); +public record DiagnoseResponse(IReadOnlyList Markers); +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, int InsertTextRules = 0); + +public record HoverRequest(string CodeText, int Line, int Column); +public record HoverResponse(string? Markdown); + +public record SignatureHelpRequest(string CodeText, int Line, int Column); +public record SignatureHelpResponse(string? Label, IReadOnlyList? Parameters, int ActiveParameter); +public record SignatureHelpParameter(string Label, string? Documentation); + +public record FormatRequest(string Code); +public record FormatResponse(string Code); + +public record InlayHintsRequest(string Code); +public record InlayHintsResponse(IReadOnlyList Hints); +public record InlayHint(int Line, int Column, string Label); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs new file mode 100644 index 00000000..d683d4ce --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +public static class ScriptAnalysisEndpoints +{ + public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/script-analysis").RequireAuthorization("FleetAdmin"); + group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r))); + group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r))); + group.MapPost("/hover", (HoverRequest r, ScriptAnalysisService s) => Results.Ok(s.Hover(r))); + group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r))); + group.MapPost("/format", (FormatRequest r, ScriptAnalysisService s) => Results.Ok(s.Format(r))); + group.MapPost("/inlay-hints", (InlayHintsRequest r, ScriptAnalysisService s) => Results.Ok(s.InlayHints(r))); + return endpoints; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs new file mode 100644 index 00000000..b55ff712 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -0,0 +1,77 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; +using ZB.MOM.WW.OtOpcUa.Core.VirtualTags; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +/// +/// Roslyn-backed analysis for the Monaco virtual-tag script editor. Re-seats the +/// scadabridge analysis surface on OtOpcUa's REAL evaluator wrapper: the user's +/// source is wrapped in the same synthesized CompiledScript.Run(globals) shape +/// that compiles, so diagnostics, +/// completions, hover, and signature help all see the exact symbol surface a published +/// script gets. Built against the allow-list for the +/// context. +/// +/// +/// Public methods are placeholders returning empty results — later tasks fill in each +/// capability via TDD. The shared seam + the offset helpers are +/// the load-bearing part every later capability reuses; keep them even while unused. +/// +public sealed class ScriptAnalysisService +{ + private static readonly SandboxConfig Sandbox = ScriptSandbox.Build(typeof(VirtualTagContext)); + private static readonly string Preamble = BuildPreamble(); + private static readonly CSharpCompilationOptions CompileOptions = new( + OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release, + allowUnsafe: false, warningLevel: 4, nullableContextOptions: NullableContextOptions.Enable); + + // Mirrors ScriptEvaluator's wrapper EXACTLY (usings from the sandbox + the + // Run(ScriptGlobals) shape), except the analysis return type is + // `object` so a shared script stays valid regardless of any virtual tag's DataType. + private static string BuildPreamble() + { + var usings = string.Join("\n", Sandbox.Imports.Select(i => $"using {i};")); + var globalsType = $"global::{typeof(ScriptGlobals<>).Namespace}.ScriptGlobals"; + return usings + "\n\n" + + "namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled;\n" + + "public static class CompiledScript\n{\n" + + $" public static object Run({globalsType} globals)\n {{\n" + + " var ctx = globals.ctx;\n#line 1\n"; + } + + // userSource is appended after Preamble, then the method/class are closed. + private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(string userSource) + { + var prefix = Preamble; + var full = prefix + (userSource ?? "") + "\n }\n}\n"; + var tree = CSharpSyntaxTree.ParseText(full); + var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions); + return (tree, compilation, compilation.GetSemanticModel(tree), prefix.Length); + } + + // editor (line,col) in USER-source coords -> physical offset in the wrapped document + private static int OffsetInWrapped(string userSource, int line, int column, int preambleLength) + => preambleLength + Math.Clamp(PositionToOffset(userSource ?? "", line, column), 0, (userSource ?? "").Length); + + private static int PositionToOffset(string code, int line, int column) + { + int offset = 0, curLine = 1, curCol = 1; + for (int i = 0; i < code.Length; i++) + { + if (curLine == line && curCol == column) return offset; + if (code[i] == '\n') { curLine++; curCol = 1; } else { curCol++; } + offset = i + 1; + } + return code.Length; + } + + public DiagnoseResponse Diagnose(DiagnoseRequest req) => new(Array.Empty()); // Task 3 + public Task CompleteAsync(CompletionsRequest req) + => Task.FromResult(new CompletionsResponse(Array.Empty())); // Tasks 4,6 + public HoverResponse Hover(HoverRequest req) => new((string?)null); // Task 7 + public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req) => new(null, null, 0); // Task 7 + public FormatResponse Format(FormatRequest req) => new(req.Code); // Task 8 + public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty()); // Task 8 (stays empty) +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 00c1622c..8f14a66c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI; using ZB.MOM.WW.OtOpcUa.AdminUI.Clients; using ZB.MOM.WW.OtOpcUa.AdminUI.Components; using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; +using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.ControlPlane; @@ -183,6 +184,7 @@ if (hasAdmin) app.UseAntiforgery(); app.MapOtOpcUaAuth(); app.MapAdminUI(); + app.MapScriptAnalysisEndpoints(); app.MapOtOpcUaHubs(); // Headless deploy trigger for CI/scripts (API-key gated; disabled until Security:DeployApiKey set). app.MapOtOpcUaDeployApi(app.Configuration);