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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user