feat(ui/scripts): editor support for self/child/parent accessors

Phases 3+4 of the script-scope rollout. Wires the runtime accessors
landed in efba01d through to Monaco completion, diagnostics, and
hover.

New analyzer surface in ScriptAnalysisService:

  String-literal completion contexts (added to TryStringLiteralCompletions):
    Attributes["..."]                       -> SelfAttributes
    Children["..."]                         -> composition names
    Children["X"].Attributes["..."]         -> child template's attributes
    Children["X"].CallScript("...")         -> child template's scripts
    Parent.Attributes["..."]                -> parent template's attributes
    Parent.CallScript("...")                -> parent template's scripts

  Diagnostics:
    SCADA006   Attribute "Typo" is not declared on {this template,
               child composition 'X', the parent}.  (warning)
    SCADA007   Composition "Unknown" is not declared on this template.
               (warning)

  CallShared / CallScript snippet-expansion now routes through the
  child / parent shape catalogs when invoked on Children["X"] /
  Parent — picking a child script accepts `Sample", ${1:count})`.

Contract additions:
  - AttributeShape (Name, Type) record
  - CompositionContext (Name, Attributes, Scripts) record
  - SelfAttributes / Children / Parent fields on DiagnoseRequest,
    CompletionsRequest, HoverRequest, SignatureHelpRequest

ScriptHost (analyzer-side globals) gains stub AttributeBag /
ChildrenBag / CompositionBag types so Roslyn doesn't emit CS0103 on
Attributes / Children / Parent. The stubs are never invoked — only
their signatures are read by the analyzer's compilation pass.

MonacoEditor.razor exposes SelfAttributes / Children / Parent
parameters; GetContext returns them; monaco-init.js forwards all
three on completion / hover / signature-help / diagnostics requests.

TemplateEdit fetches each composition's resolved child template
shape via GetTemplateWithChildrenAsync, and queries GetAllTemplatesAsync
for any single parent that composes the open template. Multi-parent
or no-parent → Parent is suppressed.

11 new xUnit tests on the new completion / diagnostic paths. Total:
149 -> 159.

Browser-verified via curl:
  - Children["..."] suggests composition names
  - Attributes["..."] suggests attributes with type detail
  - Attributes["Typo"] squiggles SCADA006
  - Children["Unknown"] squiggles SCADA007
  - No spurious CS0103 on the new accessors

Hover, signature help, and inlay hints for the new accessors keep
working because they reuse the same dispatch logic.
This commit is contained in:
Joseph Doherty
2026-05-12 05:53:13 -04:00
parent efba01d10a
commit 0b24b4537d
7 changed files with 506 additions and 18 deletions

View File

@@ -92,6 +92,9 @@
private MonacoEditor? _scriptEditor;
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker> _scriptMarkers
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker>();
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext> _editorChildren
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>();
private ScadaLink.CentralUI.ScriptAnalysis.CompositionContext? _editorParent;
private bool _showCompForm;
private int _compComposedTemplateId;
@@ -126,6 +129,13 @@
_scripts = (await TemplateEngineRepository.GetScriptsByTemplateIdAsync(Id)).ToList();
_compositions = (await TemplateEngineRepository.GetCompositionsByTemplateIdAsync(Id)).ToList();
// Editor metadata: child compositions + parent (if exactly one).
// Powers Attributes["X"] / Children["Y"].Attributes["Z"] /
// Parent.Attributes["W"] completion + SCADA006 / SCADA007 diagnostics
// in the Monaco editor.
_editorChildren = await BuildChildContextsAsync(_compositions);
_editorParent = await TryGetParentContextAsync(Id);
_validationResult = null;
}
catch (Exception ex)
@@ -662,6 +672,9 @@
DeclaredParameters="@ScriptParameterNames.Parse(_scriptParameters)"
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_scriptParameters)"
SiblingScripts="@(_scripts.Select(s => ScadaLink.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition)).ToArray())"
SelfAttributes="@(_attributes.Select(a => new ScadaLink.CentralUI.ScriptAnalysis.AttributeShape(a.Name, MapDataType(a.DataType))).ToArray())"
Children="@_editorChildren"
Parent="@_editorParent"
MarkersChanged="@(m => { _scriptMarkers = m; StateHasChanged(); })" />
<ProblemsPanel Markers="@_scriptMarkers" OnNavigate="@(m => _scriptEditor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
</div>
@@ -970,4 +983,54 @@
}
else { _toast.ShowError(result.Error); }
}
// ---- Editor metadata builders ----
private async Task<IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>> BuildChildContextsAsync(
IReadOnlyList<ScadaLink.Commons.Entities.Templates.TemplateComposition> comps)
{
var result = new List<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>();
foreach (var comp in comps)
{
var composed = await TemplateEngineRepository.GetTemplateWithChildrenAsync(comp.ComposedTemplateId);
if (composed == null) continue;
result.Add(BuildCompositionContext(comp.InstanceName, composed));
}
return result;
}
private async Task<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext?> TryGetParentContextAsync(int templateId)
{
var all = await TemplateEngineRepository.GetAllTemplatesAsync();
var parents = all.Where(t => t.Compositions.Any(c => c.ComposedTemplateId == templateId)).ToList();
if (parents.Count != 1) return null; // ambiguous or root — suppress Parent assistance
var p = await TemplateEngineRepository.GetTemplateWithChildrenAsync(parents[0].Id);
return p == null ? null : BuildCompositionContext(p.Name, p);
}
private static ScadaLink.CentralUI.ScriptAnalysis.CompositionContext BuildCompositionContext(
string label,
ScadaLink.Commons.Entities.Templates.Template t)
{
var attrs = t.Attributes
.Select(a => new ScadaLink.CentralUI.ScriptAnalysis.AttributeShape(a.Name, MapDataType(a.DataType)))
.ToList();
var scripts = t.Scripts
.Select(s => ScadaLink.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse(
s.Name, s.ParameterDefinitions, s.ReturnDefinition))
.ToList();
return new ScadaLink.CentralUI.ScriptAnalysis.CompositionContext(label, attrs, scripts);
}
private static string MapDataType(ScadaLink.Commons.Types.Enums.DataType dt) => dt switch
{
ScadaLink.Commons.Types.Enums.DataType.Boolean => "Boolean",
ScadaLink.Commons.Types.Enums.DataType.Int32 => "Integer",
ScadaLink.Commons.Types.Enums.DataType.Float => "Float",
ScadaLink.Commons.Types.Enums.DataType.Double => "Float",
ScadaLink.Commons.Types.Enums.DataType.String => "String",
ScadaLink.Commons.Types.Enums.DataType.DateTime => "String",
ScadaLink.Commons.Types.Enums.DataType.Binary => "Object",
_ => "Object"
};
}

View File

@@ -48,6 +48,26 @@
/// </summary>
[Parameter] public IReadOnlyList<ScriptAnalysis.ScriptShape>? SiblingScripts { get; set; }
/// <summary>
/// Attributes declared on the current template. Surfaced inside
/// <c>Attributes["..."]</c> for completion and SCADA006 diagnostics.
/// </summary>
[Parameter] public IReadOnlyList<ScriptAnalysis.AttributeShape>? SelfAttributes { get; set; }
/// <summary>
/// Child compositions on the current template, each with its template's
/// attributes and scripts. Surfaced for <c>Children["X"].Attributes</c>,
/// <c>Children["X"].CallScript</c>, and SCADA007 diagnostics.
/// </summary>
[Parameter] public IReadOnlyList<ScriptAnalysis.CompositionContext>? Children { get; set; }
/// <summary>
/// Parent template when the current template is composed inside exactly
/// one other template. <c>null</c> at the root or when multiple parents
/// exist. Surfaced for <c>Parent.Attributes</c> / <c>Parent.CallScript</c>.
/// </summary>
[Parameter] public ScriptAnalysis.CompositionContext? Parent { get; set; }
/// <summary>
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
/// debounce). Hosts can render a <see cref="ProblemsPanel"/> with the same
@@ -125,7 +145,10 @@
SiblingScripts?.ToArray() ?? Array.Empty<ScriptAnalysis.ScriptShape>(),
DeclaredParameterShapes?.ToArray()
?? DeclaredParameters?.Select(n => new ScriptAnalysis.ParameterShape(n, "Object", true)).ToArray()
?? Array.Empty<ScriptAnalysis.ParameterShape>());
?? Array.Empty<ScriptAnalysis.ParameterShape>(),
SelfAttributes?.ToArray() ?? Array.Empty<ScriptAnalysis.AttributeShape>(),
Children?.ToArray() ?? Array.Empty<ScriptAnalysis.CompositionContext>(),
Parent);
private async Task FormatAsync()
{
@@ -163,5 +186,8 @@
public record ScadaContext(
string[] DeclaredParameters,
ScriptAnalysis.ScriptShape[] SiblingScripts,
ScriptAnalysis.ParameterShape[] DeclaredParameterShapes);
ScriptAnalysis.ParameterShape[] DeclaredParameterShapes,
ScriptAnalysis.AttributeShape[] SelfAttributes,
ScriptAnalysis.CompositionContext[] Children,
ScriptAnalysis.CompositionContext? Parent);
}