From 0b24b4537dc97cdf79259cf021c45b278df8b66c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 05:53:13 -0400 Subject: [PATCH] feat(ui/scripts): editor support for self/child/parent accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Pages/Design/TemplateEdit.razor | 63 ++++++ .../Components/Shared/MonacoEditor.razor | 30 ++- .../ScriptAnalysis/ScriptAnalysisContracts.cs | 37 +++- .../ScriptAnalysis/ScriptAnalysisService.cs | 204 +++++++++++++++++- .../ScriptAnalysis/ScriptHost.cs | 42 +++- .../wwwroot/js/monaco-init.js | 26 ++- .../ScriptAnalysisServiceTests.cs | 122 +++++++++++ 7 files changed, 506 insertions(+), 18 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor index 267e4f0..36af7a1 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -92,6 +92,9 @@ private MonacoEditor? _scriptEditor; private IReadOnlyList _scriptMarkers = Array.Empty(); + private IReadOnlyList _editorChildren + = Array.Empty(); + 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(); })" /> @@ -970,4 +983,54 @@ } else { _toast.ShowError(result.Error); } } + + // ---- Editor metadata builders ---- + + private async Task> BuildChildContextsAsync( + IReadOnlyList comps) + { + var result = new List(); + 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 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" + }; } diff --git a/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor b/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor index 115409d..be491c4 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor +++ b/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor @@ -48,6 +48,26 @@ /// [Parameter] public IReadOnlyList? SiblingScripts { get; set; } + /// + /// Attributes declared on the current template. Surfaced inside + /// Attributes["..."] for completion and SCADA006 diagnostics. + /// + [Parameter] public IReadOnlyList? SelfAttributes { get; set; } + + /// + /// Child compositions on the current template, each with its template's + /// attributes and scripts. Surfaced for Children["X"].Attributes, + /// Children["X"].CallScript, and SCADA007 diagnostics. + /// + [Parameter] public IReadOnlyList? Children { get; set; } + + /// + /// Parent template when the current template is composed inside exactly + /// one other template. null at the root or when multiple parents + /// exist. Surfaced for Parent.Attributes / Parent.CallScript. + /// + [Parameter] public ScriptAnalysis.CompositionContext? Parent { get; set; } + /// /// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic /// debounce). Hosts can render a with the same @@ -125,7 +145,10 @@ SiblingScripts?.ToArray() ?? Array.Empty(), DeclaredParameterShapes?.ToArray() ?? DeclaredParameters?.Select(n => new ScriptAnalysis.ParameterShape(n, "Object", true)).ToArray() - ?? Array.Empty()); + ?? Array.Empty(), + SelfAttributes?.ToArray() ?? Array.Empty(), + Children?.ToArray() ?? Array.Empty(), + 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); } diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs index 46c55f6..cb2b9ae 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs @@ -3,7 +3,10 @@ namespace ScadaLink.CentralUI.ScriptAnalysis; public record DiagnoseRequest( string Code, IReadOnlyList? DeclaredParameters = null, - IReadOnlyList? SiblingScripts = null); + IReadOnlyList? SiblingScripts = null, + IReadOnlyList? SelfAttributes = null, + IReadOnlyList? Children = null, + CompositionContext? Parent = null); public record DiagnoseResponse(IReadOnlyList Markers); @@ -25,7 +28,10 @@ public record CompletionsRequest( int Line, int Column, IReadOnlyList? DeclaredParameters = null, - IReadOnlyList? SiblingScripts = null); + IReadOnlyList? SiblingScripts = null, + IReadOnlyList? SelfAttributes = null, + IReadOnlyList? Children = null, + CompositionContext? Parent = null); public record CompletionsResponse(IReadOnlyList Items); @@ -42,7 +48,10 @@ public record HoverRequest( int Line, int Column, IReadOnlyList? SiblingScripts = null, - IReadOnlyList? DeclaredParameters = null); + IReadOnlyList? DeclaredParameters = null, + IReadOnlyList? SelfAttributes = null, + IReadOnlyList? Children = null, + CompositionContext? Parent = null); public record HoverResponse(string? Markdown); @@ -50,7 +59,9 @@ public record SignatureHelpRequest( string CodeText, int Line, int Column, - IReadOnlyList? SiblingScripts = null); + IReadOnlyList? SiblingScripts = null, + IReadOnlyList? Children = null, + CompositionContext? Parent = null); public record SignatureHelpResponse( string? Label, @@ -72,6 +83,24 @@ public record ScriptShape( public record ParameterShape(string Name, string Type, bool Required); +/// +/// Attribute declared on a template: name + canonical SCADA type (Boolean, +/// Integer, Float, String, Object, List). +/// +public record AttributeShape(string Name, string Type); + +/// +/// One end of a composition relationship — either a child (referenced by +/// composition instance name) or the parent (referenced by template name). +/// The shape carries the attributes and scripts at that scope so the editor +/// can complete Children["X"].Attributes["Y"] and +/// Children["X"].CallScript("Z") with the right metadata. +/// +public record CompositionContext( + string Name, + IReadOnlyList Attributes, + IReadOnlyList Scripts); + public record FormatRequest(string Code); public record FormatResponse(string Code); diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs index e3ecf0f..600c279 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -107,6 +107,8 @@ public class ScriptAnalysisService markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters)); markers.AddRange(FindArgumentCountMismatches(tree, request.SiblingScripts)); markers.AddRange(FindArgumentTypeMismatches(tree, request.SiblingScripts)); + markers.AddRange(FindUnknownAttributeKeys(tree, request)); + markers.AddRange(FindUnknownChildren(tree, request.Children)); } return Cache(cacheKey, new DiagnoseResponse(markers)); @@ -205,11 +207,48 @@ public class ScriptAnalysisService .ToList(); } - // CallShared("...") / CallScript("...") + // Attributes["..."] / Children["X"].Attributes["..."] / Parent.Attributes["..."] + if (owner is ElementAccessExpressionSyntax attrElem) + { + var ctx = ClassifyAttributeContext( + attrElem, + request.Children ?? Array.Empty(), + request.Parent); + if (ctx.Kind != AttributeContextKind.None) + { + IReadOnlyList source = ctx.Kind switch + { + AttributeContextKind.Self => request.SelfAttributes ?? Array.Empty(), + AttributeContextKind.Child => ctx.Composition!.Attributes, + AttributeContextKind.Parent => request.Parent?.Attributes ?? Array.Empty(), + _ => Array.Empty() + }; + var label = ctx.Kind switch + { + AttributeContextKind.Self => "attribute", + AttributeContextKind.Child => $"attribute on {ctx.Composition!.Name}", + AttributeContextKind.Parent => "parent attribute", + _ => "attribute" + }; + return source.Select(a => + new CompletionItem(a.Name, a.Name, $"{label}: {a.Type}", "Field")).ToList(); + } + + // Children["..."] → suggest composition names + if (attrElem.Expression is IdentifierNameSyntax cid && cid.Identifier.ValueText == "Children") + { + return (request.Children ?? Array.Empty()) + .Select(c => new CompletionItem(c.Name, c.Name, "composition", "Class")) + .ToList(); + } + } + + // CallShared("...") / CallScript("...") / Children["X"].CallScript("...") / Parent.CallScript("...") if (owner is InvocationExpressionSyntax inv) { - var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText - ?? (inv.Expression as MemberAccessExpressionSyntax)?.Name.Identifier.ValueText; + var calleeIdName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; + var calleeMa = inv.Expression as MemberAccessExpressionSyntax; + var calleeName = calleeIdName ?? calleeMa?.Name.Identifier.ValueText; if (calleeName == "CallShared") { @@ -219,6 +258,36 @@ public class ScriptAnalysisService if (calleeName == "CallScript") { + // Children["X"].CallScript("..." or Parent.CallScript("... + if (calleeMa != null) + { + // Children["X"].CallScript + if (calleeMa.Expression is ElementAccessExpressionSyntax childElem + && childElem.Expression is IdentifierNameSyntax cid + && cid.Identifier.ValueText == "Children" + && childElem.ArgumentList.Arguments.Count == 1 + && childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit + && cLit.IsKind(SyntaxKind.StringLiteralExpression)) + { + var compName = cLit.Token.ValueText; + var comp = (request.Children ?? Array.Empty()) + .FirstOrDefault(c => c.Name == compName); + if (comp != null) + return comp.Scripts.Select(s => MakeCallCompletion(s, $"script on {compName}")).ToList(); + return new List(); + } + // Parent.CallScript + if (calleeMa.Expression is IdentifierNameSyntax pid + && pid.Identifier.ValueText == "Parent" + && request.Parent != null) + { + return request.Parent.Scripts + .Select(s => MakeCallCompletion(s, "parent script")) + .ToList(); + } + } + + // Plain CallScript("...") — siblings return (request.SiblingScripts ?? Array.Empty()) .Select(s => MakeCallCompletion(s, "sibling script")) .ToList(); @@ -559,6 +628,135 @@ public class ScriptAnalysisService } } + /// + /// SCADA006 — flag Attributes["typo"], + /// Children["X"].Attributes["typo"], and + /// Parent.Attributes["typo"] when the literal key isn't declared + /// at the relevant scope. Also SCADA007 — flag Children["Unknown"] + /// when the composition name isn't declared on the form. + /// + private IEnumerable FindUnknownAttributeKeys(SyntaxTree tree, DiagnoseRequest request) + { + var root = tree.GetRoot(); + var selfAttrs = request.SelfAttributes ?? Array.Empty(); + var children = request.Children ?? Array.Empty(); + var parent = request.Parent; + + foreach (var elem in root.DescendantNodes().OfType()) + { + if (elem.ArgumentList.Arguments.Count != 1) continue; + if (elem.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax lit) continue; + if (!lit.IsKind(SyntaxKind.StringLiteralExpression)) continue; + var key = lit.Token.ValueText; + if (string.IsNullOrEmpty(key)) continue; + + var ctx = ClassifyAttributeContext(elem, children, parent); + if (ctx.Kind == AttributeContextKind.None) continue; + + IReadOnlyList known = ctx.Kind switch + { + AttributeContextKind.Self => selfAttrs, + AttributeContextKind.Child => ctx.Composition!.Attributes, + AttributeContextKind.Parent => parent?.Attributes ?? Array.Empty(), + _ => Array.Empty() + }; + if (known.Count == 0) continue; // No metadata — don't false-alarm. + if (known.Any(a => a.Name == key)) continue; + + var span = lit.GetLocation().GetLineSpan().Span; + var scopeLabel = ctx.Kind switch + { + AttributeContextKind.Self => "this template", + AttributeContextKind.Child => $"child composition '{ctx.Composition!.Name}'", + AttributeContextKind.Parent => "the parent", + _ => "unknown" + }; + yield return new DiagnosticMarker( + Severity: 4, + StartLineNumber: span.Start.Line + 1, + StartColumn: span.Start.Character + 1, + EndLineNumber: span.End.Line + 1, + EndColumn: span.End.Character + 1, + Message: $"Attribute '{key}' is not declared on {scopeLabel}.", + Code: "SCADA006"); + } + } + + /// SCADA007 — Children["UnknownComposition"]. + private static IEnumerable FindUnknownChildren(SyntaxTree tree, IReadOnlyList? children) + { + var known = (children ?? Array.Empty()) + .Select(c => c.Name) + .ToHashSet(StringComparer.Ordinal); + if (known.Count == 0) yield break; + + var root = tree.GetRoot(); + foreach (var elem in root.DescendantNodes().OfType()) + { + if (elem.Expression is not IdentifierNameSyntax id || id.Identifier.ValueText != "Children") + continue; + if (elem.ArgumentList.Arguments.Count != 1) continue; + if (elem.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax lit) continue; + if (!lit.IsKind(SyntaxKind.StringLiteralExpression)) continue; + var key = lit.Token.ValueText; + if (string.IsNullOrEmpty(key) || known.Contains(key)) continue; + + var span = lit.GetLocation().GetLineSpan().Span; + yield return new DiagnosticMarker( + Severity: 4, + StartLineNumber: span.Start.Line + 1, + StartColumn: span.Start.Character + 1, + EndLineNumber: span.End.Line + 1, + EndColumn: span.End.Character + 1, + Message: $"Composition '{key}' is not declared on this template.", + Code: "SCADA007"); + } + } + + private enum AttributeContextKind { None, Self, Child, Parent } + private record AttributeContext(AttributeContextKind Kind, CompositionContext? Composition); + + /// + /// Classifies an element-access expression as one of the scope-aware + /// attribute contexts. Recognized shapes: + /// Attributes["..."] Self + /// Children["X"].Attributes["..."] Child (composition X) + /// Parent.Attributes["..."] Parent + /// + private static AttributeContext ClassifyAttributeContext( + ElementAccessExpressionSyntax elem, + IReadOnlyList children, + CompositionContext? parent) + { + // Attributes[".."] + if (elem.Expression is IdentifierNameSyntax id && id.Identifier.ValueText == "Attributes") + return new(AttributeContextKind.Self, null); + + // (something).Attributes[".."] + if (elem.Expression is MemberAccessExpressionSyntax ma && ma.Name.Identifier.ValueText == "Attributes") + { + // Children["X"].Attributes[".."] + if (ma.Expression is ElementAccessExpressionSyntax childElem + && childElem.Expression is IdentifierNameSyntax cid + && cid.Identifier.ValueText == "Children" + && childElem.ArgumentList.Arguments.Count == 1 + && childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit + && cLit.IsKind(SyntaxKind.StringLiteralExpression)) + { + var compName = cLit.Token.ValueText; + var comp = children.FirstOrDefault(c => c.Name == compName); + if (comp != null) return new(AttributeContextKind.Child, comp); + return new(AttributeContextKind.None, null); + } + + // Parent.Attributes[".."] + if (ma.Expression is IdentifierNameSyntax pid && pid.Identifier.ValueText == "Parent") + return parent != null ? new(AttributeContextKind.Parent, parent) : new(AttributeContextKind.None, null); + } + + return new(AttributeContextKind.None, null); + } + private IEnumerable FindArgumentTypeMismatches(SyntaxTree tree, IReadOnlyList? siblings) { var root = tree.GetRoot(); diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptHost.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptHost.cs index 8cffa29..9a98951 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptHost.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptHost.cs @@ -1,10 +1,11 @@ namespace ScadaLink.CentralUI.ScriptAnalysis; /// -/// Globals type seen by user scripts. Mirrors the surface the runtime exposes -/// today: Parameters bag plus CallShared / CallScript stubs. The methods here -/// are never invoked — Roslyn only reads their signatures to know what's in -/// scope while compiling for diagnostics + completions. +/// Globals type seen by user scripts during analysis. Mirrors the surface +/// the runtime exposes (see ScadaLink.SiteRuntime.Scripts.ScriptGlobals). +/// The methods and indexers here are never invoked — Roslyn only reads +/// their signatures to know what's in scope while compiling for diagnostics +/// and completions. /// public class ScriptHost { @@ -16,4 +17,37 @@ public class ScriptHost /// Invokes another script on the same template and returns its result. public object? CallScript(string name, params object?[] args) => null; + + // Scope-aware accessors. SCADA-specific completion + diagnostics live in + // ScriptAnalysisService; these stubs exist so the bare Roslyn pass doesn't + // produce CS0103 errors on Attributes / Children / Parent. + + public AttributeBag Attributes { get; } = new(); + public ChildrenBag Children { get; } = new(); + public CompositionBag? Parent { get; } = new(); + + public class AttributeBag + { + public object? this[string name] + { + get => null; + set { /* no-op for analyzer */ } + } + public System.Threading.Tasks.Task GetAsync(string name) => + System.Threading.Tasks.Task.FromResult(null); + public System.Threading.Tasks.Task SetAsync(string name, object? value) => + System.Threading.Tasks.Task.CompletedTask; + } + + public class CompositionBag + { + public AttributeBag Attributes { get; } = new(); + public System.Threading.Tasks.Task CallScript(string name, params object?[] args) => + System.Threading.Tasks.Task.FromResult(null); + } + + public class ChildrenBag + { + public CompositionBag this[string compositionName] => new(); + } } diff --git a/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js b/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js index 8dc86f3..a3230a4 100644 --- a/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js +++ b/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js @@ -38,7 +38,10 @@ // Look up the SCADA context for a model by walking the editors map. Blazor // JS interop serializes records as PascalCase; we normalize to camelCase. async function lookupContext(model) { - const empty = { declaredParameters: [], siblingScripts: [], declaredParameterShapes: [] }; + const empty = { + declaredParameters: [], siblingScripts: [], declaredParameterShapes: [], + selfAttributes: [], children: [], parent: null + }; for (const key in editors) { if (editors[key].editor.getModel() === model) { try { @@ -47,7 +50,10 @@ return { declaredParameters: got.DeclaredParameters || got.declaredParameters || [], siblingScripts: got.SiblingScripts || got.siblingScripts || [], - declaredParameterShapes: got.DeclaredParameterShapes || got.declaredParameterShapes || [] + declaredParameterShapes: got.DeclaredParameterShapes || got.declaredParameterShapes || [], + selfAttributes: got.SelfAttributes || got.selfAttributes || [], + children: got.Children || got.children || [], + parent: got.Parent || got.parent || null }; } } catch (e) { /* fall through */ } @@ -73,7 +79,10 @@ line: position.lineNumber, column: position.column, declaredParameters: ctx.declaredParameters, - siblingScripts: ctx.siblingScripts + siblingScripts: ctx.siblingScripts, + selfAttributes: ctx.selfAttributes, + children: ctx.children, + parent: ctx.parent }) }); if (!resp.ok) return { suggestions: [] }; @@ -141,7 +150,10 @@ line: position.lineNumber, column: position.column, siblingScripts: ctx.siblingScripts, - declaredParameters: ctx.declaredParameterShapes + declaredParameters: ctx.declaredParameterShapes, + selfAttributes: ctx.selfAttributes, + children: ctx.children, + parent: ctx.parent }) }); if (!resp.ok) return null; @@ -186,6 +198,8 @@ code: model.getValue(), siblingScripts: ctx.siblingScripts }) + // Note: inlay hints don't yet read children/parent shapes + // because they only label CallShared/CallScript args today. }); if (!resp.ok) return { hints: [], dispose: function () {} }; const data = await resp.json(); @@ -219,7 +233,9 @@ codeText: model.getValue(), line: position.lineNumber, column: position.column, - siblingScripts: ctx.siblingScripts + siblingScripts: ctx.siblingScripts, + children: ctx.children, + parent: ctx.parent }) }); if (!resp.ok) return null; diff --git a/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs index 67fb93f..4030530 100644 --- a/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs @@ -382,6 +382,128 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA005"); } + // ── Self / Children / Parent attribute completions ──────────────────── + + private static AttributeShape Attr(string name, string type = "String") => new(name, type); + private static CompositionContext Comp(string name, AttributeShape[]? attrs = null, ScriptShape[]? scripts = null) + => new(name, attrs ?? Array.Empty(), scripts ?? Array.Empty()); + + [Fact] + public async Task SelfAttribute_Literal_ReturnsSelfAttributeNames() + { + var req = new CompletionsRequest( + CodeText: "var x = Attributes[\"", + Line: 1, + Column: 21, + SelfAttributes: new[] { Attr("Temperature"), Attr("Setpoint", "Float") }); + var resp = await _svc.CompleteAsync(req); + Assert.Contains(resp.Items, i => i.Label == "Temperature"); + Assert.Contains(resp.Items, i => i.Label == "Setpoint" && i.Detail.Contains("Float")); + } + + [Fact] + public async Task ChildAttribute_Literal_ReturnsChildAttributeNames() + { + var req = new CompletionsRequest( + CodeText: "var x = Children[\"TempSensor\"].Attributes[\"", + Line: 1, + Column: 44, + Children: new[] { Comp("TempSensor", attrs: new[] { Attr("Temperature"), Attr("Humidity") }) }); + var resp = await _svc.CompleteAsync(req); + Assert.Contains(resp.Items, i => i.Label == "Temperature"); + Assert.Contains(resp.Items, i => i.Label == "Humidity"); + } + + [Fact] + public async Task ParentAttribute_Literal_ReturnsParentAttributeNames() + { + var req = new CompletionsRequest( + CodeText: "var x = Parent.Attributes[\"", + Line: 1, + Column: 28, + Parent: Comp("Motor", attrs: new[] { Attr("SpeedRPM") })); + var resp = await _svc.CompleteAsync(req); + Assert.Contains(resp.Items, i => i.Label == "SpeedRPM"); + } + + [Fact] + public async Task ChildrenLiteral_ReturnsCompositionNames() + { + var req = new CompletionsRequest( + CodeText: "var x = Children[\"", + Line: 1, + Column: 19, + Children: new[] { Comp("TempSensor"), Comp("PressureSensor") }); + var resp = await _svc.CompleteAsync(req); + Assert.Contains(resp.Items, i => i.Label == "TempSensor" && i.Detail == "composition"); + Assert.Contains(resp.Items, i => i.Label == "PressureSensor"); + } + + [Fact] + public void UnknownSelfAttribute_RaisesSCADA006() + { + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var x = Attributes[\"Typo\"];", + SelfAttributes: new[] { Attr("Temperature") })); + Assert.Contains(resp.Markers, m => m.Code == "SCADA006" && m.Message.Contains("Typo")); + } + + [Fact] + public void KnownSelfAttribute_NoMarker() + { + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var x = Attributes[\"Temperature\"];", + SelfAttributes: new[] { Attr("Temperature") })); + Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA006"); + } + + [Fact] + public void UnknownChildAttribute_RaisesSCADA006() + { + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var x = Children[\"TempSensor\"].Attributes[\"Typo\"];", + Children: new[] { Comp("TempSensor", attrs: new[] { Attr("Temperature") }) })); + Assert.Contains(resp.Markers, m => m.Code == "SCADA006" && m.Message.Contains("TempSensor")); + } + + [Fact] + public void UnknownComposition_RaisesSCADA007() + { + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var x = Children[\"Unknown\"].Attributes[\"X\"];", + Children: new[] { Comp("TempSensor") })); + Assert.Contains(resp.Markers, m => m.Code == "SCADA007" && m.Message.Contains("Unknown")); + } + + [Fact] + public async Task ChildrenCallScript_ReturnsChildScripts() + { + var req = new CompletionsRequest( + CodeText: "var x = Children[\"TempSensor\"].CallScript(\"", + Line: 1, + Column: 44, + Children: new[] + { + Comp("TempSensor", scripts: new[] { Shape("Sample", Param("count", "Integer")) }) + }); + var resp = await _svc.CompleteAsync(req); + var sample = Assert.Single(resp.Items, i => i.Label == "Sample"); + Assert.Contains("script on TempSensor", sample.Detail); + Assert.Contains("${1:count}", sample.InsertText); + } + + [Fact] + public async Task ParentCallScript_ReturnsParentScripts() + { + var req = new CompletionsRequest( + CodeText: "var x = Parent.CallScript(\"", + Line: 1, + Column: 28, + Parent: Comp("Motor", scripts: new[] { Shape("Trip") })); + var resp = await _svc.CompleteAsync(req); + Assert.Contains(resp.Items, i => i.Label == "Trip" && i.Detail.Contains("parent script")); + } + // ── Hover on Parameters["name"] ─────────────────────────────────────── [Fact]