diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs index 15da96f1..8fd2194f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -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 CompleteAsync(CompletionsRequest req) - => Task.FromResult(new CompletionsResponse(Array.Empty())); // Tasks 4,6 + public async Task CompleteAsync(CompletionsRequest req) + { + if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty()); + 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()); + } + } + + private static List? 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 diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs new file mode 100644 index 00000000..63b319da --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs @@ -0,0 +1,29 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; + +public sealed class CompletionTests +{ + private static readonly ScriptAnalysisService Svc = new(); + private static async Task> Complete(string code, int line, int col) + => (await Svc.CompleteAsync(new CompletionsRequest(code, line, col))).Items; + + [Fact] public async Task Dot_after_ctx_offers_context_members() + { + // caret immediately after "ctx." — column is 1-based; "ctx." is 4 chars so col 5 is just past the dot. + var labels = (await Complete("ctx.", 1, 5)).Select(i => i.Label).ToList(); + labels.ShouldContain("GetTag"); + labels.ShouldContain("SetVirtualTag"); + labels.ShouldContain("Now"); + labels.ShouldContain("Logger"); + } + + [Fact] public async Task Scope_completion_includes_the_ctx_local() + { + // a partial identifier on its own line; scope completion should surface the `ctx` local. + var labels = (await Complete("var x = c", 1, 10)).Select(i => i.Label).ToList(); + labels.ShouldContain("ctx"); + } +}