From 521fb61e4496c1be58817a4859b78637206d9382 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 9 Jun 2026 14:53:15 -0400 Subject: [PATCH] feat(adminui): tag-path completion inside ctx.GetTag/SetVirtualTag literals --- .../ScriptAnalysis/ScriptAnalysisService.cs | 32 ++++++++++++++- .../ScriptAnalysis/TagPathCompletionTests.cs | 41 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/TagPathCompletionTests.cs 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 8fd2194f..3654f9d2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -29,8 +29,13 @@ public sealed class ScriptAnalysisService OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release, allowUnsafe: false, warningLevel: 4, nullableContextOptions: NullableContextOptions.Enable); + private readonly IScriptTagCatalog? _catalog; private readonly ILogger? _logger; - public ScriptAnalysisService(ILogger? logger = null) => _logger = logger; + public ScriptAnalysisService(IScriptTagCatalog? catalog = null, ILogger? logger = null) + { + _catalog = catalog; + _logger = logger; + } // Mirrors ScriptEvaluator's wrapper EXACTLY (usings from the sandbox + the // Run(ScriptGlobals) shape), except the analysis return type is @@ -154,7 +159,12 @@ public sealed class ScriptAnalysisService // 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("…")). + if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix)) + { + var paths = await _catalog.GetPathsAsync(pathPrefix, CancellationToken.None); + return new CompletionsResponse( + paths.Select(p => new CompletionItem(p, p, "tag path", "Field")).ToList()); + } var dot = TryGetDotMembers(token, model); if (dot != null) return new CompletionsResponse(dot); @@ -188,6 +198,24 @@ public sealed class ScriptAnalysisService .Select(ToCompletionItem).Take(200).ToList(); } + private static bool TryGetTagPathLiteral(SyntaxToken token, out string prefix) + { + prefix = ""; + var literal = token.Parent as LiteralExpressionSyntax + ?? token.GetPreviousToken().Parent as LiteralExpressionSyntax; + if (literal is null || !literal.IsKind(SyntaxKind.StringLiteralExpression)) return false; + if (literal.Parent is not ArgumentSyntax arg) return false; + if (arg.Parent is not ArgumentListSyntax argList) return false; + if (argList.Parent is not InvocationExpressionSyntax inv) return false; + if (inv.Expression is not MemberAccessExpressionSyntax ma) return false; + var method = ma.Name.Identifier.ValueText; + if (method is not ("GetTag" or "SetVirtualTag")) return false; + // only the FIRST argument is the path + if (argList.Arguments.Count > 0 && argList.Arguments[0] != arg) return false; + prefix = literal.Token.ValueText ?? ""; + return true; + } + private static CompletionItem ToCompletionItem(ISymbol symbol) { var kind = symbol.Kind switch diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/TagPathCompletionTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/TagPathCompletionTests.cs new file mode 100644 index 00000000..f3e6aded --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/TagPathCompletionTests.cs @@ -0,0 +1,41 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; + +public sealed class TagPathCompletionTests +{ + private sealed class FakeCatalog : IScriptTagCatalog + { + public Task> GetPathsAsync(string? filter, CancellationToken ct) + => Task.FromResult>(new[] { "Motor.Speed", "Motor.Temp" }); + } + + private static readonly ScriptAnalysisService Svc = new(new FakeCatalog()); + + private static async Task> Labels(string code, int line, int col) + => (await Svc.CompleteAsync(new CompletionsRequest(code, line, col))).Items.Select(i => i.Label).ToList(); + + [Fact] public async Task Empty_literal_in_GetTag_offers_tag_paths() + { + // caret between the quotes of ctx.GetTag("") — verify/adjust the column so the caret is inside the literal. + (await Labels("""ctx.GetTag("")""", 1, 13)).ShouldContain("Motor.Speed"); + } + + [Fact] public async Task Partial_literal_in_GetTag_offers_tag_paths() + { + (await Labels("""ctx.GetTag("Mot")""", 1, 15)).ShouldContain("Motor.Speed"); + } + + [Fact] public async Task SetVirtualTag_literal_also_offers_tag_paths() + { + (await Labels("""ctx.SetVirtualTag("")""", 1, 20)).ShouldContain("Motor.Speed"); + } + + [Fact] public async Task Outside_a_tag_literal_does_not_offer_tag_paths() + { + // a string literal NOT inside GetTag/SetVirtualTag → no tag-path suggestions + (await Labels("""var s = "";""", 1, 10)).ShouldNotContain("Motor.Speed"); + } +}