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 MonacoEditor? _scriptEditor;
|
||||||
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker> _scriptMarkers
|
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker> _scriptMarkers
|
||||||
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker>();
|
= 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 bool _showCompForm;
|
||||||
private int _compComposedTemplateId;
|
private int _compComposedTemplateId;
|
||||||
@@ -126,6 +129,13 @@
|
|||||||
_scripts = (await TemplateEngineRepository.GetScriptsByTemplateIdAsync(Id)).ToList();
|
_scripts = (await TemplateEngineRepository.GetScriptsByTemplateIdAsync(Id)).ToList();
|
||||||
_compositions = (await TemplateEngineRepository.GetCompositionsByTemplateIdAsync(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;
|
_validationResult = null;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -662,6 +672,9 @@
|
|||||||
DeclaredParameters="@ScriptParameterNames.Parse(_scriptParameters)"
|
DeclaredParameters="@ScriptParameterNames.Parse(_scriptParameters)"
|
||||||
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_scriptParameters)"
|
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_scriptParameters)"
|
||||||
SiblingScripts="@(_scripts.Select(s => ScadaLink.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition)).ToArray())"
|
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(); })" />
|
MarkersChanged="@(m => { _scriptMarkers = m; StateHasChanged(); })" />
|
||||||
<ProblemsPanel Markers="@_scriptMarkers" OnNavigate="@(m => _scriptEditor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
<ProblemsPanel Markers="@_scriptMarkers" OnNavigate="@(m => _scriptEditor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
||||||
</div>
|
</div>
|
||||||
@@ -970,4 +983,54 @@
|
|||||||
}
|
}
|
||||||
else { _toast.ShowError(result.Error); }
|
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>
|
/// </summary>
|
||||||
[Parameter] public IReadOnlyList<ScriptAnalysis.ScriptShape>? SiblingScripts { get; set; }
|
[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>
|
/// <summary>
|
||||||
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
|
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
|
||||||
/// debounce). Hosts can render a <see cref="ProblemsPanel"/> with the same
|
/// debounce). Hosts can render a <see cref="ProblemsPanel"/> with the same
|
||||||
@@ -125,7 +145,10 @@
|
|||||||
SiblingScripts?.ToArray() ?? Array.Empty<ScriptAnalysis.ScriptShape>(),
|
SiblingScripts?.ToArray() ?? Array.Empty<ScriptAnalysis.ScriptShape>(),
|
||||||
DeclaredParameterShapes?.ToArray()
|
DeclaredParameterShapes?.ToArray()
|
||||||
?? DeclaredParameters?.Select(n => new ScriptAnalysis.ParameterShape(n, "Object", true)).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()
|
private async Task FormatAsync()
|
||||||
{
|
{
|
||||||
@@ -163,5 +186,8 @@
|
|||||||
public record ScadaContext(
|
public record ScadaContext(
|
||||||
string[] DeclaredParameters,
|
string[] DeclaredParameters,
|
||||||
ScriptAnalysis.ScriptShape[] SiblingScripts,
|
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(
|
public record DiagnoseRequest(
|
||||||
string Code,
|
string Code,
|
||||||
IReadOnlyList<string>? DeclaredParameters = null,
|
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);
|
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
|
||||||
|
|
||||||
@@ -25,7 +28,10 @@ public record CompletionsRequest(
|
|||||||
int Line,
|
int Line,
|
||||||
int Column,
|
int Column,
|
||||||
IReadOnlyList<string>? DeclaredParameters = null,
|
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);
|
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
|
||||||
|
|
||||||
@@ -42,7 +48,10 @@ public record HoverRequest(
|
|||||||
int Line,
|
int Line,
|
||||||
int Column,
|
int Column,
|
||||||
IReadOnlyList<ScriptShape>? SiblingScripts = null,
|
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);
|
public record HoverResponse(string? Markdown);
|
||||||
|
|
||||||
@@ -50,7 +59,9 @@ public record SignatureHelpRequest(
|
|||||||
string CodeText,
|
string CodeText,
|
||||||
int Line,
|
int Line,
|
||||||
int Column,
|
int Column,
|
||||||
IReadOnlyList<ScriptShape>? SiblingScripts = null);
|
IReadOnlyList<ScriptShape>? SiblingScripts = null,
|
||||||
|
IReadOnlyList<CompositionContext>? Children = null,
|
||||||
|
CompositionContext? Parent = null);
|
||||||
|
|
||||||
public record SignatureHelpResponse(
|
public record SignatureHelpResponse(
|
||||||
string? Label,
|
string? Label,
|
||||||
@@ -72,6 +83,24 @@ public record ScriptShape(
|
|||||||
|
|
||||||
public record ParameterShape(string Name, string Type, bool Required);
|
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 FormatRequest(string Code);
|
||||||
public record FormatResponse(string Code);
|
public record FormatResponse(string Code);
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ public class ScriptAnalysisService
|
|||||||
markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters));
|
markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters));
|
||||||
markers.AddRange(FindArgumentCountMismatches(tree, request.SiblingScripts));
|
markers.AddRange(FindArgumentCountMismatches(tree, request.SiblingScripts));
|
||||||
markers.AddRange(FindArgumentTypeMismatches(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));
|
return Cache(cacheKey, new DiagnoseResponse(markers));
|
||||||
@@ -205,11 +207,48 @@ public class ScriptAnalysisService
|
|||||||
.ToList();
|
.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)
|
if (owner is InvocationExpressionSyntax inv)
|
||||||
{
|
{
|
||||||
var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText
|
var calleeIdName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
|
||||||
?? (inv.Expression as MemberAccessExpressionSyntax)?.Name.Identifier.ValueText;
|
var calleeMa = inv.Expression as MemberAccessExpressionSyntax;
|
||||||
|
var calleeName = calleeIdName ?? calleeMa?.Name.Identifier.ValueText;
|
||||||
|
|
||||||
if (calleeName == "CallShared")
|
if (calleeName == "CallShared")
|
||||||
{
|
{
|
||||||
@@ -219,6 +258,36 @@ public class ScriptAnalysisService
|
|||||||
|
|
||||||
if (calleeName == "CallScript")
|
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>())
|
return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
|
||||||
.Select(s => MakeCallCompletion(s, "sibling script"))
|
.Select(s => MakeCallCompletion(s, "sibling script"))
|
||||||
.ToList();
|
.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)
|
private IEnumerable<DiagnosticMarker> FindArgumentTypeMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
|
||||||
{
|
{
|
||||||
var root = tree.GetRoot();
|
var root = tree.GetRoot();
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Globals type seen by user scripts. Mirrors the surface the runtime exposes
|
/// Globals type seen by user scripts during analysis. Mirrors the surface
|
||||||
/// today: Parameters bag plus CallShared / CallScript stubs. The methods here
|
/// the runtime exposes (see ScadaLink.SiteRuntime.Scripts.ScriptGlobals).
|
||||||
/// are never invoked — Roslyn only reads their signatures to know what's in
|
/// The methods and indexers here are never invoked — Roslyn only reads
|
||||||
/// scope while compiling for diagnostics + completions.
|
/// their signatures to know what's in scope while compiling for diagnostics
|
||||||
|
/// and completions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ScriptHost
|
public class ScriptHost
|
||||||
{
|
{
|
||||||
@@ -16,4 +17,37 @@ public class ScriptHost
|
|||||||
|
|
||||||
/// <summary>Invokes another script on the same template and returns its result.</summary>
|
/// <summary>Invokes another script on the same template and returns its result.</summary>
|
||||||
public object? CallScript(string name, params object?[] args) => null;
|
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
|
// Look up the SCADA context for a model by walking the editors map. Blazor
|
||||||
// JS interop serializes records as PascalCase; we normalize to camelCase.
|
// JS interop serializes records as PascalCase; we normalize to camelCase.
|
||||||
async function lookupContext(model) {
|
async function lookupContext(model) {
|
||||||
const empty = { declaredParameters: [], siblingScripts: [], declaredParameterShapes: [] };
|
const empty = {
|
||||||
|
declaredParameters: [], siblingScripts: [], declaredParameterShapes: [],
|
||||||
|
selfAttributes: [], children: [], parent: null
|
||||||
|
};
|
||||||
for (const key in editors) {
|
for (const key in editors) {
|
||||||
if (editors[key].editor.getModel() === model) {
|
if (editors[key].editor.getModel() === model) {
|
||||||
try {
|
try {
|
||||||
@@ -47,7 +50,10 @@
|
|||||||
return {
|
return {
|
||||||
declaredParameters: got.DeclaredParameters || got.declaredParameters || [],
|
declaredParameters: got.DeclaredParameters || got.declaredParameters || [],
|
||||||
siblingScripts: got.SiblingScripts || got.siblingScripts || [],
|
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 */ }
|
} catch (e) { /* fall through */ }
|
||||||
@@ -73,7 +79,10 @@
|
|||||||
line: position.lineNumber,
|
line: position.lineNumber,
|
||||||
column: position.column,
|
column: position.column,
|
||||||
declaredParameters: ctx.declaredParameters,
|
declaredParameters: ctx.declaredParameters,
|
||||||
siblingScripts: ctx.siblingScripts
|
siblingScripts: ctx.siblingScripts,
|
||||||
|
selfAttributes: ctx.selfAttributes,
|
||||||
|
children: ctx.children,
|
||||||
|
parent: ctx.parent
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!resp.ok) return { suggestions: [] };
|
if (!resp.ok) return { suggestions: [] };
|
||||||
@@ -141,7 +150,10 @@
|
|||||||
line: position.lineNumber,
|
line: position.lineNumber,
|
||||||
column: position.column,
|
column: position.column,
|
||||||
siblingScripts: ctx.siblingScripts,
|
siblingScripts: ctx.siblingScripts,
|
||||||
declaredParameters: ctx.declaredParameterShapes
|
declaredParameters: ctx.declaredParameterShapes,
|
||||||
|
selfAttributes: ctx.selfAttributes,
|
||||||
|
children: ctx.children,
|
||||||
|
parent: ctx.parent
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!resp.ok) return null;
|
if (!resp.ok) return null;
|
||||||
@@ -186,6 +198,8 @@
|
|||||||
code: model.getValue(),
|
code: model.getValue(),
|
||||||
siblingScripts: ctx.siblingScripts
|
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 () {} };
|
if (!resp.ok) return { hints: [], dispose: function () {} };
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
@@ -219,7 +233,9 @@
|
|||||||
codeText: model.getValue(),
|
codeText: model.getValue(),
|
||||||
line: position.lineNumber,
|
line: position.lineNumber,
|
||||||
column: position.column,
|
column: position.column,
|
||||||
siblingScripts: ctx.siblingScripts
|
siblingScripts: ctx.siblingScripts,
|
||||||
|
children: ctx.children,
|
||||||
|
parent: ctx.parent
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!resp.ok) return null;
|
if (!resp.ok) return null;
|
||||||
|
|||||||
@@ -382,6 +382,128 @@ public class ScriptAnalysisServiceTests
|
|||||||
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA005");
|
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"] ───────────────────────────────────────
|
// ── Hover on Parameters["name"] ───────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user