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

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

New analyzer surface in ScriptAnalysisService:

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

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

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

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

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

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

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

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

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

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