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:
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
public record DiagnoseRequest(
|
||||
string Code,
|
||||
IReadOnlyList<string>? DeclaredParameters = null,
|
||||
IReadOnlyList<ScriptShape>? SiblingScripts = null);
|
||||
IReadOnlyList<ScriptShape>? SiblingScripts = null,
|
||||
IReadOnlyList<AttributeShape>? SelfAttributes = null,
|
||||
IReadOnlyList<CompositionContext>? Children = null,
|
||||
CompositionContext? Parent = null);
|
||||
|
||||
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
|
||||
|
||||
@@ -25,7 +28,10 @@ public record CompletionsRequest(
|
||||
int Line,
|
||||
int Column,
|
||||
IReadOnlyList<string>? DeclaredParameters = null,
|
||||
IReadOnlyList<ScriptShape>? SiblingScripts = null);
|
||||
IReadOnlyList<ScriptShape>? SiblingScripts = null,
|
||||
IReadOnlyList<AttributeShape>? SelfAttributes = null,
|
||||
IReadOnlyList<CompositionContext>? Children = null,
|
||||
CompositionContext? Parent = null);
|
||||
|
||||
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
|
||||
|
||||
@@ -42,7 +48,10 @@ public record HoverRequest(
|
||||
int Line,
|
||||
int Column,
|
||||
IReadOnlyList<ScriptShape>? SiblingScripts = null,
|
||||
IReadOnlyList<ParameterShape>? DeclaredParameters = null);
|
||||
IReadOnlyList<ParameterShape>? DeclaredParameters = null,
|
||||
IReadOnlyList<AttributeShape>? SelfAttributes = null,
|
||||
IReadOnlyList<CompositionContext>? Children = null,
|
||||
CompositionContext? Parent = null);
|
||||
|
||||
public record HoverResponse(string? Markdown);
|
||||
|
||||
@@ -50,7 +59,9 @@ public record SignatureHelpRequest(
|
||||
string CodeText,
|
||||
int Line,
|
||||
int Column,
|
||||
IReadOnlyList<ScriptShape>? SiblingScripts = null);
|
||||
IReadOnlyList<ScriptShape>? SiblingScripts = null,
|
||||
IReadOnlyList<CompositionContext>? 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);
|
||||
|
||||
/// <summary>
|
||||
/// Attribute declared on a template: name + canonical SCADA type (Boolean,
|
||||
/// Integer, Float, String, Object, List).
|
||||
/// </summary>
|
||||
public record AttributeShape(string Name, string Type);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Children["X"].Attributes["Y"]</c> and
|
||||
/// <c>Children["X"].CallScript("Z")</c> with the right metadata.
|
||||
/// </summary>
|
||||
public record CompositionContext(
|
||||
string Name,
|
||||
IReadOnlyList<AttributeShape> Attributes,
|
||||
IReadOnlyList<ScriptShape> Scripts);
|
||||
|
||||
public record FormatRequest(string Code);
|
||||
public record FormatResponse(string Code);
|
||||
|
||||
|
||||
@@ -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<CompositionContext>(),
|
||||
request.Parent);
|
||||
if (ctx.Kind != AttributeContextKind.None)
|
||||
{
|
||||
IReadOnlyList<AttributeShape> source = ctx.Kind switch
|
||||
{
|
||||
AttributeContextKind.Self => request.SelfAttributes ?? Array.Empty<AttributeShape>(),
|
||||
AttributeContextKind.Child => ctx.Composition!.Attributes,
|
||||
AttributeContextKind.Parent => request.Parent?.Attributes ?? Array.Empty<AttributeShape>(),
|
||||
_ => Array.Empty<AttributeShape>()
|
||||
};
|
||||
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<CompositionContext>())
|
||||
.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<CompositionContext>())
|
||||
.FirstOrDefault(c => c.Name == compName);
|
||||
if (comp != null)
|
||||
return comp.Scripts.Select(s => MakeCallCompletion(s, $"script on {compName}")).ToList();
|
||||
return new List<CompletionItem>();
|
||||
}
|
||||
// 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<ScriptShape>())
|
||||
.Select(s => MakeCallCompletion(s, "sibling script"))
|
||||
.ToList();
|
||||
@@ -559,6 +628,135 @@ public class ScriptAnalysisService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SCADA006 — flag <c>Attributes["typo"]</c>,
|
||||
/// <c>Children["X"].Attributes["typo"]</c>, and
|
||||
/// <c>Parent.Attributes["typo"]</c> when the literal key isn't declared
|
||||
/// at the relevant scope. Also SCADA007 — flag <c>Children["Unknown"]</c>
|
||||
/// when the composition name isn't declared on the form.
|
||||
/// </summary>
|
||||
private IEnumerable<DiagnosticMarker> FindUnknownAttributeKeys(SyntaxTree tree, DiagnoseRequest request)
|
||||
{
|
||||
var root = tree.GetRoot();
|
||||
var selfAttrs = request.SelfAttributes ?? Array.Empty<AttributeShape>();
|
||||
var children = request.Children ?? Array.Empty<CompositionContext>();
|
||||
var parent = request.Parent;
|
||||
|
||||
foreach (var elem in root.DescendantNodes().OfType<ElementAccessExpressionSyntax>())
|
||||
{
|
||||
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<AttributeShape> known = ctx.Kind switch
|
||||
{
|
||||
AttributeContextKind.Self => selfAttrs,
|
||||
AttributeContextKind.Child => ctx.Composition!.Attributes,
|
||||
AttributeContextKind.Parent => parent?.Attributes ?? Array.Empty<AttributeShape>(),
|
||||
_ => Array.Empty<AttributeShape>()
|
||||
};
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>SCADA007 — <c>Children["UnknownComposition"]</c>.</summary>
|
||||
private static IEnumerable<DiagnosticMarker> FindUnknownChildren(SyntaxTree tree, IReadOnlyList<CompositionContext>? children)
|
||||
{
|
||||
var known = (children ?? Array.Empty<CompositionContext>())
|
||||
.Select(c => c.Name)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
if (known.Count == 0) yield break;
|
||||
|
||||
var root = tree.GetRoot();
|
||||
foreach (var elem in root.DescendantNodes().OfType<ElementAccessExpressionSyntax>())
|
||||
{
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
private static AttributeContext ClassifyAttributeContext(
|
||||
ElementAccessExpressionSyntax elem,
|
||||
IReadOnlyList<CompositionContext> 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<DiagnosticMarker> FindArgumentTypeMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
|
||||
{
|
||||
var root = tree.GetRoot();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ScriptHost
|
||||
{
|
||||
@@ -16,4 +17,37 @@ public class ScriptHost
|
||||
|
||||
/// <summary>Invokes another script on the same template and returns its result.</summary>
|
||||
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<object?> GetAsync(string name) =>
|
||||
System.Threading.Tasks.Task.FromResult<object?>(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<object?> CallScript(string name, params object?[] args) =>
|
||||
System.Threading.Tasks.Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public class ChildrenBag
|
||||
{
|
||||
public CompositionBag this[string compositionName] => new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AttributeShape>(), scripts ?? Array.Empty<ScriptShape>());
|
||||
|
||||
[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]
|
||||
|
||||
Reference in New Issue
Block a user