feat(adminui): scope + dot-member script completions

This commit is contained in:
Joseph Doherty
2026-06-09 14:33:20 -04:00
parent 6a9b052fc7
commit 93f5a745a3
2 changed files with 87 additions and 2 deletions
@@ -1,5 +1,6 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
@@ -141,8 +142,63 @@ public sealed class ScriptAnalysisService
if (code[i] == '\n') { line++; col = 1; } else col++;
return (line, col);
}
public Task<CompletionsResponse> CompleteAsync(CompletionsRequest req)
=> Task.FromResult(new CompletionsResponse(Array.Empty<CompletionItem>())); // Tasks 4,6
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req)
{
if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty<CompletionItem>());
try
{
var code = Normalize(req.CodeText);
var (tree, _, model, preambleLength) = Analyze(code);
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
var root = await tree.GetRootAsync();
// position is the offset just AFTER the caret; -1 finds the token the caret sits at/just-after (e.g. the dot in "ctx.").
var token = root.FindToken(Math.Max(0, position - 1));
// Task 6 inserts tag-path string-literal completion here (inside ctx.GetTag("…")/ctx.SetVirtualTag("…")).
var dot = TryGetDotMembers(token, model);
if (dot != null) return new CompletionsResponse(dot);
var scoped = model.LookupSymbols(position)
.Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
.GroupBy(s => s.Name).Select(g => g.First())
.Select(ToCompletionItem).Take(200).ToList();
return new CompletionsResponse(scoped);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Script completion failed; returning none.");
return new CompletionsResponse(Array.Empty<CompletionItem>());
}
}
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
{
var memberAccess = token.Parent as MemberAccessExpressionSyntax
?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax;
if (memberAccess == null) return null;
var typeInfo = model.GetTypeInfo(memberAccess.Expression);
var type = typeInfo.Type ?? typeInfo.ConvertedType;
if (type == null) return null;
return type.GetMembers()
.Where(m => m.CanBeReferencedByName && !m.IsImplicitlyDeclared)
// NotApplicable covers members (e.g. some BCL/special members) that carry no explicit accessibility but are referenceable.
.Where(m => m.DeclaredAccessibility == Accessibility.Public || m.DeclaredAccessibility == Accessibility.NotApplicable)
.GroupBy(m => m.Name).Select(g => g.First())
.Select(ToCompletionItem).Take(200).ToList();
}
private static CompletionItem ToCompletionItem(ISymbol symbol)
{
var kind = symbol.Kind switch
{
SymbolKind.Method => "Method", SymbolKind.Property => "Property", SymbolKind.Field => "Field",
SymbolKind.Event => "Event", SymbolKind.NamedType => "Class", SymbolKind.Local => "Variable",
SymbolKind.Parameter => "Variable", SymbolKind.Namespace => "Module", _ => "Text"
};
return new CompletionItem(symbol.Name, symbol.Name,
symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), kind);
}
public HoverResponse Hover(HoverRequest req) => new((string?)null); // Task 7
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req) => new(null, null, 0); // Task 7
public FormatResponse Format(FormatRequest req) => new(req.Code); // Task 8