feat(adminui): script-analysis contracts, wrapper seam, endpoints + DI

This commit is contained in:
Joseph Doherty
2026-06-09 14:11:43 -04:00
parent 9afb2d230e
commit b54a6ad29f
5 changed files with 127 additions and 0 deletions
@@ -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;
/// <summary>
/// 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 <c>CompiledScript.Run(globals)</c> shape
/// that <see cref="ScriptEvaluator{TContext, TResult}"/> compiles, so diagnostics,
/// completions, hover, and signature help all see the exact symbol surface a published
/// script gets. Built against the <see cref="ScriptSandbox"/> allow-list for the
/// <see cref="VirtualTagContext"/> context.
/// </summary>
/// <remarks>
/// Public methods are placeholders returning empty results — later tasks fill in each
/// capability via TDD. The shared <see cref="Analyze"/> seam + the offset helpers are
/// the load-bearing part every later capability reuses; keep them even while unused.
/// </remarks>
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<VirtualTagContext>) 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<global::{typeof(VirtualTagContext).FullName}>";
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<DiagnosticMarker>()); // Task 3
public Task<CompletionsResponse> CompleteAsync(CompletionsRequest req)
=> Task.FromResult(new CompletionsResponse(Array.Empty<CompletionItem>())); // 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<InlayHint>()); // Task 8 (stays empty)
}