Files
lmxopcua/docs/ScriptEditor.md
T

426 lines
21 KiB
Markdown

# 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<VirtualTagContext>) 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<string>` | 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<VirtualTagContext>)` 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 `<MonacoEditor @bind-Value="_form.SourceCode">`. 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 `<MonacoEditor>` 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).
---
## Equipment-relative tag paths (`{{equip}}`)
### Why
Today each VirtualTag bound to a script typically needs its own near-duplicate
script because tag paths are hard-coded absolutes (e.g. `TestMachine_001.Speed`).
The `{{equip}}` token breaks this coupling: point many VirtualTags' `ScriptId` at
a single template script, and each resolves the token to its own equipment's tag
base prefix at deploy time. No schema change is required — sharing a `Script`
record across VirtualTags already works; `{{equip}}` is what makes the shared
script resolve per-equipment.
### Before / after
**Before — one script per machine:**
```csharp
// Script "Calc_TestMachine_001" — hard-coded, cannot reuse
return ctx.GetTag("TestMachine_001.Speed").Value;
```
**After — one shared template:**
```csharp
// Script "Calc_Speed" — works for any machine
return ctx.GetTag("{{equip}}.Speed").Value;
```
`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`.
At deploy, each VirtualTag receives its own expanded copy:
`TestMachine_001.Speed` and `TestMachine_002.Speed` respectively.
### Token rules
- The token is `{{equip}}` (double braces, lowercase).
- It is substituted **only inside `ctx.GetTag(…)` / `ctx.SetVirtualTag(…)` first-argument
string literals** — comments, logger strings, and other code are untouched.
- The operator writes the separator and tail: `ctx.GetTag("{{equip}}.Speed")`,
`ctx.GetTag("{{equip}}.Sub.Field")`, etc.
- The token expands to the equipment's **tag base prefix** — the common
substring-before-the-first-dot of that equipment's configured driver-tag
`FullName` values. Example: tags `TestMachine_001.Speed` and
`TestMachine_001.Temp` → base `TestMachine_001`.
### Validation requirement
Saving a VirtualTag that is bound to a `{{equip}}`-using script is rejected in
the AdminUI if the equipment does not have at least one configured driver tag, or
if its tags span multiple object prefixes (i.e. the common prefix is ambiguous or
absent). The rejection message is surfaced as a clear validation error on the save
form. This check is enforced eagerly so that an unresolved `{{equip}}` token —
which would leave a path that resolves to nothing at runtime (Bad quality) — can
never reach the deployed artifact.
### Editor support
In the Monaco script editor (ScriptEdit page and the `/uns` virtual-tag modal's
inline script panel):
- **Hover** — hovering a `{{equip}}` path literal shows an
*"Equipment-relative path — resolved at deploy"* note.
- **Completions** — typing `{{equip}}.` inside a `GetTag`/`SetVirtualTag` literal
offers completion of attribute leaf names (the part after the first dot of known
tag references in the catalog).
### Maintainer note
Substitution runs at the two compose seams —
`Phase7Composer.Compose` and `DeploymentArtifact.BuildEquipmentVirtualTagPlans`
— via the shared `ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths` helper,
**before** dependency extraction. The runtime, the static change-trigger
dependency graph, and the literal-only path rule are therefore all unchanged:
by the time they see the script, `{{equip}}` has been replaced with a concrete
tag-base prefix and the path is a normal string literal.
---
## 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).