# Monaco Script Editor (Roslyn-backed IntelliSense) The script editor gives AdminUI users a first-class C# authoring experience for virtual-tag scripts: completions, diagnostics, hover, signature help, document formatting, and context-aware tag-path completions — all backed by the same Roslyn compiler the runtime publish gate uses. See the design doc for the original rationale and brainstorming record: [`docs/plans/2026-06-09-monaco-script-editor-design.md`](plans/2026-06-09-monaco-script-editor-design.md). --- ## Overview Virtual-tag scripts are C# expression bodies (see [VirtualTags.md](VirtualTags.md)) compiled at publish time by `ScriptEvaluator` against a restricted `ScriptSandbox`. Before this feature the AdminUI's script page used a CDN Monaco instance wired over a hidden textarea via an `eval` + `Task.Delay(50)` race — no IntelliSense, fragile in air-gapped environments, and the editor accepted code that publish would reject. The replacement: - **Vendored Monaco** served locally — no CDN, air-gap safe. - **Reusable `MonacoEditor.razor`** component wired into both the ScriptEdit page (`/scripts/{id}`) and the virtual-tag inline script panel in the UNS TagModal. - **Full IntelliSense** via a Roslyn backend that analyses the user's source inside the *exact* evaluator wrapper `ScriptEvaluator` compiles, so what the editor accepts/rejects matches publish exactly. --- ## Architecture ``` ┌──────────────────────────── AdminUI (Blazor Server) ───────────────────────────┐ │ │ │ Components/Shared/MonacoEditor.razor ◄── Value/ValueChanged, Height, │ │ │ ReadOnly, ShowToolbar, theme/Format/wrap/minimap toolbar │ │ │ (DotNetObjectReference both ways) │ │ ▼ │ │ wwwroot/js/monaco-init.js ──registers──► completion / hover / signatureHelp / │ │ │ diagnostics / format / inlayHints │ │ │ fetch POST /api/script-analysis/* │ │ ▼ │ │ ScriptAnalysis/ScriptAnalysisEndpoints.cs (minimal-API group, FleetAdmin) │ │ ▼ │ │ ScriptAnalysis/ScriptAnalysisService.cs ── Roslyn over OtOpcUa's real wrapper │ │ ├─ ScriptSandbox.Build(typeof(VirtualTagContext)) (imports + refs) │ │ ├─ CompiledScript.Run(ScriptGlobals) wrapper │ │ ├─ ForbiddenTypeAnalyzer (sandbox-escape squiggles) │ │ ├─ DependencyExtractor (dynamic-path squiggles) │ │ └─ IScriptTagCatalog (tag-path string-literal completions) │ │ ▼ │ │ wwwroot/lib/monaco/vs/… (vendored Monaco, loaded by monaco-init.js) │ └─────────────────────────────────────────────────────────────────────────────────┘ │ references ▼ Core.Scripting + Scripting.Abstractions (ScriptSandbox, VirtualTagContext, ScriptGlobals<>, ForbiddenTypeAnalyzer, DependencyExtractor) Configuration (OtOpcUaConfigDbContext → tag / virtual-tag paths for catalog) ``` ### MonacoEditor.razor `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor` A reusable Blazor Server component. Key parameters: | Parameter | Type | Notes | |-----------|------|-------| | `Value` | `string` | Two-way bound script source | | `ValueChanged` | `EventCallback` | Fires on every model change | | `Height` | `string` | CSS height (default `400px`) | | `ReadOnly` | `bool` | Disables editing; IntelliSense still works | | `ShowToolbar` | `bool` | Shows the theme/Format/wrap/minimap toolbar | The component creates the Monaco editor in `OnAfterRenderAsync`, registers a `DotNetObjectReference` so JS can call back into Blazor on value change, and uses a GUID-keyed container so multiple instances on the same page coexist safely. External `Value` changes are pushed to the editor via `window.MonacoBlazor.setValue`. ### monaco-init.js `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js` Exposes `window.MonacoBlazor` with `createEditor / setValue / getValue / setMarkers / setEditorOption / format / dispose`. Registers the six Monaco language providers for the `csharp` language at load time; each provider POSTs to the corresponding `/api/script-analysis/*` endpoint. Diagnostics are debounced (~500 ms) to avoid firing on every keystroke. ### ScriptAnalysisService `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` The analysis seam is the private `Analyze(userSource)` method, which: 1. Builds the wrapped document — the user's source is appended after the OtOpcUa evaluator preamble (usings from `ScriptSandbox.Build`, the `CompiledScript.Run(ScriptGlobals)` wrapper, `var ctx = globals.ctx;`, then `#line 1` so Roslyn reports user-source coordinates for diagnostics). Return type is `object` so one shared script is valid regardless of any virtual tag's `DataType`. 2. Parses the wrapped document as a `CSharpSyntaxTree` and creates a `CSharpCompilation` against the `ScriptSandbox` reference set. 3. Returns the `SemanticModel`, `SyntaxTree`, and the preamble byte offset (used by every capability to map editor coordinates to wrapped-document offsets). --- ## HTTP Endpoints All six endpoints live under the `/api/script-analysis` group, registered by `ScriptAnalysisEndpoints.MapScriptAnalysisEndpoints()` (called from `EndpointRouteBuilderExtensions.AddAdminUI` / `Host/Program.cs`) and gated by the `FleetAdmin` authorization policy (requires the `Administrator` role). | Route | Request DTO | Response DTO | Purpose | |-------|-------------|--------------|---------| | `POST /api/script-analysis/diagnostics` | `DiagnoseRequest(Code)` | `DiagnoseResponse(Markers)` | Roslyn + ForbiddenType + dynamic-path error markers | | `POST /api/script-analysis/completions` | `CompletionsRequest(CodeText, Line, Column)` | `CompletionsResponse(Items)` | Scope, dot-member, and tag-path completions | | `POST /api/script-analysis/hover` | `HoverRequest(CodeText, Line, Column)` | `HoverResponse(Markdown?)` | Type + XML doc markdown | | `POST /api/script-analysis/signature-help` | `SignatureHelpRequest(CodeText, Line, Column)` | `SignatureHelpResponse(Label?, Parameters?, ActiveParameter)` | Parameter help | | `POST /api/script-analysis/format` | `FormatRequest(Code)` | `FormatResponse(Code)` | `NormalizeWhitespace()` formatting | | `POST /api/script-analysis/inlay-hints` | `InlayHintsRequest(Code)` | `InlayHintsResponse(Hints)` | Inline type hints (stub — returns empty) | DTO definitions live in `ScriptAnalysis/ScriptAnalysisContracts.cs`. `DiagnosticMarker` fields: `Severity` (Monaco values: 8 = error, 4 = warning, 2 = info, 1 = hint), `StartLineNumber`, `StartColumn`, `EndLineNumber`, `EndColumn`, `Message`, `Code`. `CompletionItem` fields: `Label`, `InsertText`, `Detail`, `Kind` (Monaco kind string: `"Method"`, `"Property"`, `"Field"`, `"Variable"`, `"Class"`, etc.), `InsertTextRules` (0 for plain text, 4 for snippet). --- ## Diagnostics Composition The diagnostics endpoint combines three sources and returns **errors only**: ### 1. Roslyn compile diagnostics `compilation.GetDiagnostics()` filtered to `DiagnosticSeverity.Error`. Warnings are intentionally excluded: the canonical passthrough pattern `return ctx.GetTag("X").Value;` triggers nullable warning CS8605 under `NullableContextOptions.Enable` but compiles and publishes fine — flagging it in the editor would be misleading noise that does not reflect publish reality. Line numbers are read from `GetMappedLineSpan()` (not `GetLineSpan()`). `GetMappedLineSpan` honours the `#line 1` directive in the preamble, so it returns user-source coordinates directly. Diagnostics whose `SourceSpan.Start < preambleLength` are dropped — they are phantom wrapper diagnostics (e.g. `CS0161` "not all code paths return a value" on the synthesized `Run` method body when the user has not yet typed `return`) and are not the user's fault. ### 2. ForbiddenTypeAnalyzer `ForbiddenTypeAnalyzer.Analyze(compilation)` walks the wrapped syntax tree and resolves every referenced symbol against the sandbox deny-list (namespace prefixes `System.IO`, `System.Net`, `System.Diagnostics`, `System.Reflection`, `System.Threading.Tasks`, etc., plus type-granular denials like `System.Environment` and `System.Threading.Thread`). Each violation is mapped via `tree.GetMappedLineSpan(span)` to user-source coordinates and emitted as an error marker with code `OTSCRIPT_FORBIDDEN`. ### 3. DependencyExtractor `DependencyExtractor.Extract(code).Rejections` enumerates every `ctx.GetTag` / `ctx.SetVirtualTag` call whose first argument is NOT a string literal (variable, concatenation, interpolation). Each rejection carries a `TextSpan` in user-source coordinates, emitted as code `OTSCRIPT_DYNPATH`. This is the exact same check publish enforces — the editor shows the squiggle where the literal must appear. --- ## Tag-Path Completion When the caret is inside the first string-literal argument of a `GetTag("…")` or `SetVirtualTag("…")` invocation, the completion provider delegates to `IScriptTagCatalog` instead of the semantic model. ### IScriptTagCatalog contract `ScriptAnalysis/IScriptTagCatalog.cs` `GetPathsAsync(string? filter, CancellationToken)` returns the distinct **runtime-resolvable keys** a script may pass to `ctx.GetTag` / `ctx.SetVirtualTag`, optionally filtered by a case-insensitive `StartsWith` prefix. The concrete implementation `ScriptTagCatalog` reads `Tag` + `VirtualTag` rows from `OtOpcUaConfigDbContext` and projects them as follows: | Tag category | Resolvable key emitted | |---|---| | Equipment driver tag (`EquipmentId != null`) | Driver `FullName` extracted from `Tag.TagConfig` JSON — the verified `DependencyMux` key | | SystemPlatform / Galaxy tag (`EquipmentId == null`) | MXAccess dot-ref: `FolderPath.Name` when a folder is set, else `Name` | | Virtual tag | Leaf `Name` (best-effort) | **Why only resolvable keys?** The live runtime resolves a `ctx.GetTag("X")` literal against `DriverInstanceActor.AttributeValuePublished.FullReference`, which is the `FullName` field from `Tag.TagConfig` (see `Phase7Composer.ExtractTagFullName` + `EquipmentNodeWalker.ExtractFullName`). The UNS-path engine (`Core.VirtualTags.VirtualTagEngine`, keyed by the slash-joined `Enterprise/Site/Area/Line/Equipment/TagName` browse path) is dormant — it is not wired into the host — so UNS browse paths never resolve at runtime and are intentionally NOT offered as completions. The catalog is scoped per Blazor circuit; each call creates and disposes its own `DbContext` via the pooled factory (same pattern as `UnsTreeService`). Results are bounded to 200 entries to keep the completion list responsive on large fleets. ### Literal gate `TryGetTagPathLiteral` identifies the tag-path context by climbing from the syntax token under the caret to: `LiteralExpressionSyntax` → `ArgumentSyntax` (first argument only) → `ArgumentListSyntax` → `InvocationExpressionSyntax` → `MemberAccessExpressionSyntax`. The method name check is `method is "GetTag" or "SetVirtualTag"` — it matches by method name only, not by receiver type, so `anything.GetTag("…")` would also trigger tag-path completion. This is harmless in the sandbox (no other `GetTag` methods exist in the allowed reference set) and keeps the detection simple. --- ## Vendoring / Air-gap Safety Monaco is served from `_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/monaco/vs/` (the Blazor static-asset path for the AdminUI RCL). The loader script is at `_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js`. No CDN is contacted at runtime. The legacy CDN loader (`monaco-loader.js`) and the `eval`/`Task.Delay` injection previously in `ScriptEdit.razor` have been removed. --- ## Wire-in Points ### ScriptEdit page (`/scripts/{id}`) `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor` Hosts ``. On save, SHA-256 is recomputed from the editor value and stored in `Script.SourceHash` — unchanged from the pre-Monaco save path. ### VirtualTagModal (`/uns` TagModal for virtual tags) `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor` When the selected script has a `ScriptId`, an inline "Script source" panel renders `` bound to the script source. The panel shows a **"Used by N virtual tag(s)"** notice (populated from `IUnsTreeService.CountVirtualTagsUsingScriptAsync`). A dedicated **Save script** button calls `IUnsTreeService.UpdateScriptSourceAsync`, which performs a `RowVersion`-guarded update of the `Script` row — a separate concurrency unit from the virtual-tag Create/Save operation. Creating a brand-new script inline from the modal is a follow-up (not v1). Three `IUnsTreeService` methods back the inline panel: `GetScriptSourceAsync` / `CountVirtualTagsUsingScriptAsync` / `UpdateScriptSourceAsync`. --- ## How to Extend ### Adding a new completion source 1. Implement a new case in `ScriptAnalysisService.CompleteAsync`. The `Analyze(code)` seam gives you the `SemanticModel`, `SyntaxTree`, and preamble offset. 2. Add the corresponding request/response DTOs to `ScriptAnalysisContracts.cs` if a new endpoint is needed, or fold into an existing endpoint with an extra field. 3. Register the new Monaco provider in `monaco-init.js` using the same `fetch('/api/script-analysis/…', { method: 'POST', … })` pattern. ### Adding a new analysis check (diagnostics) Extend `Diagnose` in `ScriptAnalysisService`. The existing three-source pattern (Roslyn → `ForbiddenTypeAnalyzer` → `DependencyExtractor`) is additive: add a fourth source and append to `markers`. Keep severity at 8 (Monaco error) for anything that blocks publish; use 4 (warning) for advisory-only hints. ### Changing the tag-path catalog source Replace or extend the `ScriptTagCatalog` implementation of `IScriptTagCatalog`. Register the replacement in `EndpointRouteBuilderExtensions.AddAdminUI` (or inject via the DI container as the `IScriptTagCatalog` scoped service). --- ## Known Limitations / Follow-ups - **Tag-path completion shows resolvable keys only.** Surfacing the UNS browse path as a completion *detail* (a non-inserted hint shown alongside the resolvable key) for discoverability is a tracked follow-up. - **Literal gate matches by method name, not receiver.** `anything.GetTag("…")` would also trigger tag-path completion (not just `ctx.GetTag("…")`). Harmless in the sandbox today — no other `GetTag` methods exist in the allowed reference set — but tighter binding to `ctx` / `VirtualTagContext` is a follow-up. - **Analysis runs synchronous Roslyn (CPU-bound).** Each endpoint call builds a `CSharpCompilation` synchronously. This is intentional: the endpoints are admin-only, client-side debounced (~500 ms), and `Task.Run` offload adds unnecessary complexity for infrequent admin use. If concurrency becomes a concern under heavy admin usage, wrapping each `Analyze` call in `Task.Run` is the correct fix. - **InlayHints is a stub.** `/api/script-analysis/inlay-hints` returns an empty list. Filling it in (parameter name hints from the semantic model) is a follow-up task. - **Language providers register globally at Monaco load.** The six providers are registered for the `csharp` language on the global Monaco instance (loaded once per page in `App.razor`). There is no impact today because only `Administrator`-reachable pages host a `MonacoEditor`, but if a read-only surface ever embeds the editor the providers will fire (harmlessly — they just get 401 responses from the `FleetAdmin`-gated endpoints and return empty results). - **Creating a new script from the VirtualTagModal inline panel** is deferred. The current inline panel loads and saves an existing `Script` row; creating a brand-new script requires a separate create flow and is a follow-up. --- ## Testing Unit tests live in `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/`: | File | Covers | |------|--------| | `DiagnoseTests.cs` | Clean script (no markers), Roslyn error, ForbiddenTypeAnalyzer violation, dynamic-path rejection, `#line` mapping | | `CompletionTests.cs` | Scope completions, dot-member completions after `ctx.` | | `TagPathCompletionTests.cs` | Tag-path literal completions inside `GetTag`/`SetVirtualTag` via a fake `IScriptTagCatalog` | | `HoverSignatureTests.cs` | Hover markdown, signature-help parameter tracking | | `FormatTests.cs` | `NormalizeWhitespace` round-trip | | `ScriptTagCatalogTests.cs` | Equipment-tag `FullName`, Galaxy dot-ref, virtual-tag leaf `Name`, filter prefix, MaxResults bound | `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ScriptSourceServiceTests.cs` covers `GetScriptSourceAsync` / `CountVirtualTagsUsingScriptAsync` / `UpdateScriptSourceAsync` via the in-memory EF pattern. No bUnit tests exist for `MonacoEditor.razor` or `VirtualTagModal.razor` — the AdminUI has no bUnit dependency. Razor/JS correctness was proven by live docker-dev `/run` (ScriptEdit + UNS TagModal with Monaco, diagnostics, hover, tag-path completions, Format, inline script save).