Skip to content

Commit f214e0f

Browse files
authored
Merge pull request #63 from 74nu5/fix/lsp-review5
Enter for-loops when collecting references, and pick the segment under the cursor
2 parents 2fe8bdc + 95ef8ff commit f214e0f

4 files changed

Lines changed: 200 additions & 17 deletions

File tree

src/Settex.LanguageServer/SettexHoverHandler.cs

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ public SettexHoverHandler(SettexWorkspace workspace, ILogger<SettexHoverHandler>
105105
{
106106
// Le chemin complet inclut les blocs imbriqués traversés, donc
107107
// survoler "Port" dans `Server { Port = … }` cible bien "Server.Port".
108-
var (pathSegments, envName, isObjectHeader) = assignmentInfo.Value;
108+
var (pathSegments, envName, isObjectHeader, segmentIndex) = assignmentInfo.Value;
109109

110110
// Vérifier que le mot sous le curseur est bien le path (ou une partie du path)
111111
var isOnPath = pathSegments.Any(segment => segment == word);
@@ -114,8 +114,14 @@ public SettexHoverHandler(SettexWorkspace workspace, ILogger<SettexHoverHandler>
114114
{
115115
var path = string.Join(".", pathSegments);
116116

117-
// Détecter si le mot survolé est un segment d'objet (pas le dernier segment)
118-
var wordIndex = pathSegments.IndexOf(word);
117+
// Quel segment est sous le curseur — déduit de la colonne, pas d'un
118+
// IndexOf : sur `A { B { A = 1 } }` le chemin est A.B.A, et chercher
119+
// la première occurrence de "A" renvoyait toujours 0, si bien que
120+
// survoler l'affectation la plus interne affichait l'objet A.
121+
var wordIndex = segmentIndex >= 0 && segmentIndex < pathSegments.Count
122+
? segmentIndex
123+
: pathSegments.IndexOf(word);
124+
119125
var isObjectSegment = isObjectHeader || (wordIndex >= 0 && wordIndex < pathSegments.Count - 1);
120126

121127
if (isObjectSegment)
@@ -548,7 +554,7 @@ private static string EscapeString(string str)
548554
/// <summary>
549555
/// Trouve une assignation à la position donnée et retourne l'assignation + l'environnement (null si dans base).
550556
/// </summary>
551-
private static (List<string> Path, string? EnvName, bool IsObjectHeader)? FindAssignmentAtPosition(
557+
private static (List<string> Path, string? EnvName, bool IsObjectHeader, int SegmentIndex)? FindAssignmentAtPosition(
552558
Core.Parser.Ast.FileNode ast,
553559
Position position,
554560
string? documentFilePath = null)
@@ -574,15 +580,15 @@ private static (List<string> Path, string? EnvName, bool IsObjectHeader)? FindAs
574580
var found = FindAssignmentInStatements(settings.Block.Statements, new List<string>(), line, column);
575581
if (found != null)
576582
{
577-
return (found.Value.Path, null, found.Value.IsObjectHeader); // Base, pas d'environnement
583+
return (found.Value.Path, null, found.Value.IsObjectHeader, found.Value.SegmentIndex); // Base, pas d'environnement
578584
}
579585
}
580586
else if (stmt is Core.Parser.Ast.EnvBlockNode env)
581587
{
582588
var found = FindAssignmentInStatements(env.SettingsBlock.Block.Statements, new List<string>(), line, column);
583589
if (found != null)
584590
{
585-
return (found.Value.Path, env.EnvironmentName, found.Value.IsObjectHeader); // Dans un environnement
591+
return (found.Value.Path, env.EnvironmentName, found.Value.IsObjectHeader, found.Value.SegmentIndex); // Dans un environnement
586592
}
587593
}
588594
}
@@ -597,7 +603,7 @@ private static (List<string> Path, string? EnvName, bool IsObjectHeader)? FindAs
597603
/// (<c>Server.Port</c> et non <c>Port</c>) — c'est ce chemin que l'overlay
598604
/// recherche dans la configuration évaluée.
599605
/// </summary>
600-
private static (List<string> Path, bool IsObjectHeader)? FindAssignmentInStatements(
606+
private static (List<string> Path, bool IsObjectHeader, int SegmentIndex)? FindAssignmentInStatements(
601607
IReadOnlyList<Core.Parser.Ast.IStatement> statements,
602608
List<string> prefix,
603609
int line,
@@ -611,7 +617,8 @@ private static (List<string> Path, bool IsObjectHeader)? FindAssignmentInStateme
611617
{
612618
var fullPath = new List<string>(prefix);
613619
fullPath.AddRange(assignment.Path.Segments);
614-
return (fullPath, false);
620+
621+
return (fullPath, false, HoveredSegmentIndex(assignment, prefix.Count, line, column));
615622
}
616623
}
617624
else if (stmt is Core.Parser.Ast.NestedBlockNode nested)
@@ -630,14 +637,50 @@ private static (List<string> Path, bool IsObjectHeader)? FindAssignmentInStateme
630637
// ways. Checked after the body so an inner match still wins.
631638
if (IsPositionOnBlockName(nested, line, column))
632639
{
633-
return (nestedPrefix, true);
640+
// A header names the block itself: the last segment of its path.
641+
return (nestedPrefix, true, nestedPrefix.Count - 1);
634642
}
635643
}
636644
}
637645

638646
return null;
639647
}
640648

649+
/// <summary>
650+
/// Index, dans le chemin complet, du segment réellement sous le curseur.
651+
/// L'affectation démarre sur son chemin, donc les segments occupent des colonnes
652+
/// consécutives séparées par un point ; on les parcourt pour trouver celui qui
653+
/// contient la colonne. Renvoie -1 si le curseur est au-delà du chemin (sur la
654+
/// valeur, par exemple), auquel cas l'appelant retombe sur son ancien calcul.
655+
/// </summary>
656+
private static int HoveredSegmentIndex(
657+
Core.Parser.Ast.AssignmentNode assignment,
658+
int prefixLength,
659+
int line,
660+
int column)
661+
{
662+
if (line != assignment.Location.Line)
663+
{
664+
return -1;
665+
}
666+
667+
var segmentStart = assignment.Location.Column;
668+
669+
for (var i = 0; i < assignment.Path.Segments.Count; i++)
670+
{
671+
var segmentEnd = segmentStart + assignment.Path.Segments[i].Length;
672+
673+
if (column >= segmentStart && column < segmentEnd)
674+
{
675+
return prefixLength + i;
676+
}
677+
678+
segmentStart = segmentEnd + 1; // le point
679+
}
680+
681+
return -1;
682+
}
683+
641684
/// <summary>
642685
/// Vérifie si une position est sur le <strong>nom</strong> d'un bloc imbriqué,
643686
/// c'est-à-dire sur son en-tête et non dans son corps. Le nœud démarre sur son

src/Settex.LanguageServer/SettexReferencesHandler.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,9 +354,25 @@ private static void FindReferencesInExpression(
354354
case Core.Parser.Ast.ArrayNode array:
355355
foreach (var element in array.Elements)
356356
{
357-
if (element is Core.Parser.Ast.IExpression expr)
357+
switch (element)
358358
{
359-
FindReferencesInExpression(expr, name, references);
359+
// A ForNode is an IArrayElement but not an IExpression, so
360+
// filtering on IExpression dropped it silently — neither its
361+
// body nor the collection it walks was ever visited, and a
362+
// variable used only inside a loop reported zero references.
363+
case Core.Parser.Ast.ForNode forNode:
364+
FindReferencesInExpression(forNode.Collection, name, references);
365+
366+
foreach (var stmt in forNode.Body.Statements)
367+
{
368+
FindReferencesInBlockStatement(stmt, name, references);
369+
}
370+
371+
break;
372+
373+
case Core.Parser.Ast.IExpression expr:
374+
FindReferencesInExpression(expr, name, references);
375+
break;
360376
}
361377
}
362378
break;

src/Settex.LanguageServer/SettexTextDocumentSyncHandler.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,18 +82,22 @@ public override Task<Unit> Handle(DidCloseTextDocumentParams request, Cancellati
8282

8383
this.logger.LogTrace("Closed: {Uri}", uri);
8484

85-
// Documents that included this one fall back to the on-disk copy.
86-
foreach (var affected in this.workspace.DidClose(uri))
87-
{
88-
this.PublishDiagnostics(affected.Uri, affected);
89-
}
85+
var affectedDocuments = this.workspace.DidClose(uri);
9086

91-
// Efface les diagnostics
87+
// Clear this document's diagnostics first. Republishing the dependents came
88+
// before, inside the same guard, so a failure there was swallowed and left the
89+
// closed file underlined in the Problems panel with no way to clear it.
9290
this.languageServer.TextDocument.PublishDiagnostics(new PublishDiagnosticsParams
9391
{
9492
Uri = request.TextDocument.Uri,
9593
Diagnostics = new Container<Diagnostic>()
9694
});
95+
96+
// Documents that included this one fall back to the on-disk copy.
97+
foreach (var affected in affectedDocuments)
98+
{
99+
this.PublishDiagnostics(affected.Uri, affected);
100+
}
97101
});
98102

99103
public override Task<Unit> Handle(DidSaveTextDocumentParams request, CancellationToken cancellationToken)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
using Microsoft.Extensions.Logging.Abstractions;
2+
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
3+
4+
namespace Settex.LanguageServer.Tests;
5+
6+
/// <summary>
7+
/// Two lookups that answered from the wrong place: references never entered a
8+
/// <c>for</c> loop, and hover picked a path segment by name rather than by position.
9+
/// </summary>
10+
public sealed class LoopReferenceAndSegmentTests
11+
{
12+
[Test]
13+
public async Task References_ToAVariableUsedOnlyInsideALoop_AreFoundAsync()
14+
{
15+
// ForNode is an IArrayElement but not an IExpression, and the collector filtered
16+
// on IExpression — so neither the loop body nor the collection it walks was ever
17+
// visited, and this returned nothing at all.
18+
const string source = """
19+
let svcs = [1, 2]
20+
settings {
21+
Items = [ for s in svcs { item { Value = s } } ]
22+
}
23+
""";
24+
25+
var (handler, uri) = CreateReferences(source);
26+
27+
// Cursor on the declaration of `svcs` (0-based line 0, column 4).
28+
var locations = await handler.Handle(Request(uri, 0, 4), CancellationToken.None);
29+
30+
await Assert.That(locations).IsNotNull();
31+
32+
// The declaration plus its use inside the loop header.
33+
await Assert.That(locations!.Count()).IsGreaterThanOrEqualTo(2);
34+
}
35+
36+
[Test]
37+
public async Task References_ToAVariableUsedInALoopBody_AreFoundAsync()
38+
{
39+
const string source = """
40+
let prefix = "p"
41+
settings {
42+
Items = [ for s in [1] { item { Name = prefix } } ]
43+
Other = prefix
44+
}
45+
""";
46+
47+
var (handler, uri) = CreateReferences(source);
48+
49+
var locations = await handler.Handle(Request(uri, 0, 4), CancellationToken.None);
50+
51+
await Assert.That(locations).IsNotNull();
52+
53+
// Declaration, the use in the loop body, and the one outside it.
54+
await Assert.That(locations!.Count()).IsGreaterThanOrEqualTo(3);
55+
}
56+
57+
[Test]
58+
public async Task Hover_OnARepeatedPathSegment_UsesTheOneUnderTheCursorAsync()
59+
{
60+
// The path here is A.B.A. Picking the segment by IndexOf always returned 0, so
61+
// hovering the innermost assignment showed the overlay for the outer object A
62+
// instead of the value of A.B.A.
63+
const string source = "settings {\n A {\n B {\n A = 1\n }\n }\n}";
64+
var handler = CreateHover(source, out var uri);
65+
66+
// Cursor on the inner "A" (0-based line 3, column 12).
67+
var hover = await handler.Handle(HoverRequest(uri, 3, 12), CancellationToken.None);
68+
69+
await Assert.That(hover).IsNotNull();
70+
71+
var content = hover!.Contents.MarkupContent!.Value;
72+
73+
await Assert.That(content).Contains("A.B.A");
74+
}
75+
76+
[Test]
77+
public async Task Hover_OnAnObjectSegmentOfADottedPath_StillShowsTheObjectAsync()
78+
{
79+
// The guard: deriving the index from the column must not break the ordinary
80+
// case the old IndexOf handled correctly.
81+
const string source = "settings {\n Server.Port = 8080\n}";
82+
var handler = CreateHover(source, out var uri);
83+
84+
// Cursor on "Server" (0-based line 1, column 4).
85+
var hover = await handler.Handle(HoverRequest(uri, 1, 4), CancellationToken.None);
86+
87+
await Assert.That(hover).IsNotNull();
88+
}
89+
90+
private static (SettexReferencesHandler Handler, string Uri) CreateReferences(string source)
91+
{
92+
var workspace = new SettexWorkspace();
93+
var uri = "untitled:loop-refs";
94+
workspace.DidOpen(uri, source);
95+
96+
return (new SettexReferencesHandler(workspace, NullLogger<SettexReferencesHandler>.Instance), uri);
97+
}
98+
99+
private static SettexHoverHandler CreateHover(string source, out string uri)
100+
{
101+
var workspace = new SettexWorkspace();
102+
uri = "untitled:segment-hover";
103+
workspace.DidOpen(uri, source);
104+
105+
return new SettexHoverHandler(workspace, NullLogger<SettexHoverHandler>.Instance);
106+
}
107+
108+
private static ReferenceParams Request(string uri, int line, int character) => new()
109+
{
110+
TextDocument = new TextDocumentIdentifier { Uri = uri },
111+
Position = new Position(line, character),
112+
Context = new ReferenceContext { IncludeDeclaration = true },
113+
};
114+
115+
private static HoverParams HoverRequest(string uri, int line, int character) => new()
116+
{
117+
TextDocument = new TextDocumentIdentifier { Uri = uri },
118+
Position = new Position(line, character),
119+
};
120+
}

0 commit comments

Comments
 (0)