diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 97fb15b52..652343619 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -458,6 +458,20 @@ across live-text eviction and are cleared by `didClose`, so an evicted newer version cannot be replaced by a stale change. Other providers return empty arrays or null when the database cannot answer safely instead of inventing language-server analysis. +Before URI/path resolution, live-document access, or query-snapshot refresh, +one shared coordinate validator checks every supported position/range-bearing +method. `definition`, `declaration`, `references`, `hover`, `completion`, and +`documentHighlight` require one position; `inlayHint` requires an ordered range; +and each optional range supplied by `didChange` is validated before live text +can change. Lines and characters must be JSON integers in the LSP `uinteger` +domain (0 through 2,147,483,647), and range starts must not follow range ends. +Malformed, missing, negative, or overflowing request coordinates return +`-32602` (`Invalid params`); invalid notification coordinates are ignored +without mutating document state. Structural validation does not turn a valid +document no-match into a protocol error: provider lookup retains UTF-16 +coordinates and its existing end-of-line/EOF policy, while missing, unreadable, +or out-of-range documents still produce the provider's conservative empty/null +result. `LspServer` uses one lock-protected lifecycle state machine across ordinary dispatch, the cancellation fast path, and queue-overload responses. Its phases are before-initialize, initializing, running, shutdown, and exited. Only the @@ -3720,6 +3734,18 @@ indexed symbol に fallback する。numeric document-version tombstone は live 後も上限付きで保持し、`didClose` で消去するため、evict 済みの新しい version を stale change が 置き換えることはない。それ以外の provider は database が安全に答えられない場合、 language-server analysis を作り上げず、空配列または null を返す。 +URI / path 解決、live document へのアクセス、query snapshot の refresh より前に、1つの共通 +coordinate validator ですべての対応済み position / range method を検証する。 +`definition`、`declaration`、`references`、`hover`、`completion`、 +`documentHighlight` は1つの position を必須とし、`inlayHint` は順序どおりの range を必須とする。 +また、`didChange` に任意の range が指定された場合は、live text を変更する前に各 range を検証する。 +line と character は LSP の `uinteger` 範囲(0〜2,147,483,647)の JSON integer でなければならず、 +range の start が end より後であってはならない。request の coordinate が malformed、欠落、負数、 +overflow の場合は `-32602`(`Invalid params`)を返す。notification の coordinate が不正な場合は +document state を変更せず無視する。構造検証によって、正当な document の no-match を protocol +error に変えてはならない。provider lookup は UTF-16 coordinate と既存の行末 / EOF policy を維持し、 +document が欠落、読み取り不能、または範囲外の場合は、引き続き各 provider の保守的な空 / null +result を返す。 `LspServer` は通常 dispatch、cancellation fast path、queue-overload response の全経路で、 1 つの lock 保護された lifecycle state machine を使う。phase は before-initialize、 initializing、running、shutdown、exited である。最初の `initialize` request だけが初期化へ diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 568ae9e83..251313372 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -529,6 +529,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding keeps explicit constructors split across partial declarations in one fixture. Preserve the separate `definition` / `goto --kind function` assertions and the grouped `--kind class --group-partials` assertion so callable constructor results cannot merge back into logical type-family results. - `LspServerTests.cs` LSP JSON-RPC behavior and indexed editor semantics. Reference parity coverage must compare LSP locations with the matching CLI candidate bundle, including overload identity and both `includeDeclaration` states. Keep document/workspace identifier ranges and explicit-versus-inferred inlay hints in one source-semantics fixture so persisted-column anchoring, source confirmation, and hint suppression cannot drift independently. + Coordinate-validation coverage keeps every supported position provider in one negative/malformed/overflow matrix, with missing-document precedence and missing/reversed inlay ranges in the same read-only fixture. Keep UTF-16 and EOF success controls together, but retain invalid ranged-`didChange` as a separate mutable-state fixture so a rejected notification cannot replace accepted live text (#4869). C# semantic-token coverage decodes LSP delta coordinates and pairs the same keyword/modifier, namespace/type, method/property/event/enum-member, parameter/variable/field, generic/record-struct, explicit-interface-member, attribute, using-declaration, accessor-local, numeric member-access, escaped/contextual-identifier, interpolation, comma-separated declaration, and declaration-kind fixture with `QueryCommandRunnerTests.RunExcerpt_JsonCSharpSemanticKindsMatchLspClassifier_Issue4852`. Keep both surfaces on the shared source classifier, retain ordering/non-overlap assertions, preserve the narrow excerpt check whose parameter/type-parameter declarations are outside the returned range, and retain `RunExcerpt_JsonCSharpSemanticTokensUseVisibleRangeBudget_Issue4852` so earlier tokens cannot empty a late excerpt while indexed-source reconstruction remains line/character bounded. `HandleMessage_SemanticTokens_FallsBackToIndexedCSharpSymbolsWhenSourceReadFails_Issue4852` keeps indexed declaration tokens available when bounded source loading fails. Constructor-navigation coverage keeps explicit zero- and one-argument constructors in separate partial files, including a caller beside the zero-argument declaration, plus an implicit partial constructor, a positional record, and an ordinary partial-type reference in one fixture. The adversarial fixtures add static constructors, finalizers, primary-plus-secondary constructors, coexisting generic arities, same-arity overloads, value-type default construction beside explicit constructors, enums, and delegates. Assert both definition and declaration requests so source-site identity, invocation-arity filtering, stable implicit representatives, constructor-only ambiguity, preserved non-class construction, and intentional type-family expansion remain separate. Document-symbol hierarchy coverage keeps positional and body record members in one same-line fixture, asserts the complete root-to-member shape and deterministic sibling order, and retains the separate same-range top-level controls so order-independent parent resolution cannot over-nest unrelated symbols. Keep a same-line duplicate-container fixture with distinct positional members so selection-column disambiguation cannot move an earlier member beneath a later same-named container. @@ -1441,6 +1442,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" は partial declaration の別ファイルに分かれた明示 constructor を 1 つの fixture にまとめます。callable constructor result が logical type-family result に再び混在しないよう、`definition` / `goto --kind function` の検証と、`--kind class --group-partials` による grouped type の検証を分離したまま維持してください。 - `LspServerTests.cs` LSP の JSON-RPC 挙動と indexed editor semantics のテスト。reference parity coverage では、overload identity と `includeDeclaration` の両状態を含め、LSP location と対応する CLI candidate bundle を比較してください。document/workspace の identifier range と explicit/inferred inlay hint は 1 つの source-semantics fixture にまとめ、保存済み column の anchoring、source 上の確認、hint 抑制が別々に drift しないようにします。 + coordinate validation coverage は、対応済みの全 position provider を1つの負数 / malformed / overflow matrix にまとめ、missing document より検証を優先することと、inlay range の欠落 / 逆順も同じ read-only fixture で検証します。UTF-16 と EOF の成功 control は一緒に維持し、不正な range 付き `didChange` は mutable state の独立 fixture として残して、拒否された notification が受理済み live text を置き換えられないことを検証してください(#4869)。 C# semantic-token coverage は LSP の delta 座標を decode し、同じ keyword/modifier、namespace/type、method/property/event/enum-member、parameter/variable/field、generic/record struct、explicit interface member、attribute、using declaration、accessor local、numeric member access、escaped/contextual identifier、interpolation、comma-separated declaration、declaration kind の fixture を `QueryCommandRunnerTests.RunExcerpt_JsonCSharpSemanticKindsMatchLspClassifier_Issue4852` と対にします。両 surface が shared source classifier を使い続け、順序と非 overlap の assertion、parameter / type-parameter の declaration が返却範囲外にある narrow excerpt の検証、および indexed-source reconstruction を line / character 上限内に保ちながら手前の token によって file 後半の excerpt が空にならないことを保証する `RunExcerpt_JsonCSharpSemanticTokensUseVisibleRangeBudget_Issue4852` を維持してください。`HandleMessage_SemanticTokens_FallsBackToIndexedCSharpSymbolsWhenSourceReadFails_Issue4852` は bounded source loading が失敗しても indexed declaration token を利用できる状態を維持します。 constructor-navigation coverage は、0 引数と 1 引数の明示 constructor を別々の partial file に置き、0 引数 declaration と同じ file の caller、暗黙 partial constructor、positional record、通常の partial-type reference と同じ fixture にまとめます。adversarial fixture には static constructor、finalizer、primary / secondary constructor の併存、同名 type の generic arity 違い、同一 arity overload、明示 constructor と併存する value type の default construction、enum、delegate も含めます。source-site identity、invocation-arity filtering、暗黙 constructor の安定した代表 declaration、constructor だけの ambiguity、class 以外の construction の維持、意図的な type-family expansion が混在しないよう、definition と declaration の両 request を assert してください。 document-symbol hierarchy coverage は positional member と body member を同一行の record fixture にまとめ、root から member までの完全な形状と決定的な sibling 順序を assert します。順序非依存の親解決が無関係な symbol を過剰に nest しないよう、same-range top-level の control は独立したまま維持してください。selection column による曖昧性解消で前の member が行内で後にある同名 container の配下へ移動しないよう、異なる positional member を持つ同一行の duplicate-container fixture も維持してください。 diff --git a/changelog.d/unreleased/4869.fixed.md b/changelog.d/unreleased/4869.fixed.md new file mode 100644 index 000000000..84fc895e6 --- /dev/null +++ b/changelog.d/unreleased/4869.fixed.md @@ -0,0 +1,22 @@ +--- +category: fixed +issues: + - 4869 +affected: + - src/CodeIndex/Lsp/LspServer.PositionValidation.cs + - src/CodeIndex/Lsp/LspServer.cs + - src/CodeIndex/Lsp/LspServer.Protocol.cs + - src/CodeIndex/Lsp/LspServer.PositionResolution.cs + - src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **LSP requests now reject invalid positions and ranges as InvalidParams (#4869)** — position-based providers and inlay hints now validate non-negative integer coordinates, overflow, and range ordering before document or index access, while invalid ranged document changes cannot mutate live text and valid UTF-16/EOF behavior remains unchanged. + +## 日本語 + +- **LSP request が不正な position / range を InvalidParams として拒否するようになりました (#4869)** — position-based provider と inlay hint は document / index へアクセスする前に、非負整数の coordinate、overflow、range 順序を検証します。不正な range 付き document change は live text を変更できず、正当な UTF-16 / EOF 挙動は維持されます。 diff --git a/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs b/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs index c9738fad1..53286c6a4 100644 --- a/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs +++ b/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs @@ -182,19 +182,19 @@ private List BuildIndexedSemanticTokens(IndexedDocumentContext do private JsonArray InlayHint(JsonElement root) { + var requestedRange = ReadRequiredLspRange(root, "params", "range"); if (!TryResolveIndexedDocument(root, out var document)) return []; var array = new JsonArray(); var lineCache = new Dictionary(); - var hasRange = TryReadInlayHintRange(root, out var startLine, out _, out var endLine, out _); foreach (var symbol in GetDocumentSymbols( document.IndexedPath, MaxDocumentSymbols, - hasRange ? startLine + 1 : null, - hasRange ? endLine + 1 : null) + ToOneBasedLspLine(requestedRange.Start.Line), + ToOneBasedLspLine(requestedRange.End.Line)) .Where(symbol => !string.IsNullOrWhiteSpace(symbol.ReturnType)) - .Where(symbol => IsInlayHintInRequestedRange(root, document, symbol, lineCache)) + .Where(symbol => IsInlayHintInRequestedRange(requestedRange, document, symbol, lineCache)) .Where(symbol => !HasExplicitTypeBeforeSymbol(document, symbol, lineCache)) .Take(MaxInlayHintItems)) { @@ -204,17 +204,20 @@ private JsonArray InlayHint(JsonElement root) } private bool IsInlayHintInRequestedRange( - JsonElement root, + LspRange requestedRange, IndexedDocumentContext document, SymbolResult symbol, Dictionary lineCache) { - if (!TryReadInlayHintRange(root, out var startLine, out var startCharacter, out var endLine, out var endCharacter)) - return true; - var line = Math.Max(symbol.Line, 1) - 1; var character = FindSymbolStartCharacter(document.ResolvedPath, symbol, lineCache) + symbol.Name.Length; - return IsPositionInRange(line, character, startLine, startCharacter, endLine, endCharacter); + return IsPositionInRange( + line, + character, + requestedRange.Start.Line, + requestedRange.Start.Character, + requestedRange.End.Line, + requestedRange.End.Character); } private bool HasExplicitTypeBeforeSymbol( @@ -242,38 +245,6 @@ private bool HasExplicitTypeBeforeSymbol( return typeEnd <= symbolStart && sourceLine.AsSpan(typeEnd, symbolStart - typeEnd).Trim().IsEmpty; } - private static bool TryReadLspPosition(JsonElement range, string propertyName, out int line, out int character) - { - line = 0; - character = 0; - return range.ValueKind == JsonValueKind.Object && - range.TryGetProperty(propertyName, out var position) && - position.ValueKind == JsonValueKind.Object && - position.TryGetProperty("line", out var lineElement) && - lineElement.TryGetInt32(out line) && - line >= 0 && - position.TryGetProperty("character", out var characterElement) && - characterElement.TryGetInt32(out character) && - character >= 0; - } - - private static bool TryReadInlayHintRange( - JsonElement root, - out int startLine, - out int startCharacter, - out int endLine, - out int endCharacter) - { - startLine = 0; - startCharacter = 0; - endLine = 0; - endCharacter = 0; - return root.TryGetProperty("params", out var paramsElement) && - paramsElement.TryGetProperty("range", out var range) && - TryReadLspPosition(range, "start", out startLine, out startCharacter) && - TryReadLspPosition(range, "end", out endLine, out endCharacter); - } - private static bool IsPositionInRange( int line, int character, @@ -284,9 +255,6 @@ private static bool IsPositionInRange( => ComparePosition(line, character, startLine, startCharacter) >= 0 && ComparePosition(line, character, endLine, endCharacter) < 0; - private static int ComparePosition(int leftLine, int leftCharacter, int rightLine, int rightCharacter) - => leftLine != rightLine ? leftLine.CompareTo(rightLine) : leftCharacter.CompareTo(rightCharacter); - private static JsonObject CompletionList(JsonArray items) => new() { ["isIncomplete"] = false, diff --git a/src/CodeIndex/Lsp/LspServer.PositionResolution.cs b/src/CodeIndex/Lsp/LspServer.PositionResolution.cs index 742c8bf03..c689eef6e 100644 --- a/src/CodeIndex/Lsp/LspServer.PositionResolution.cs +++ b/src/CodeIndex/Lsp/LspServer.PositionResolution.cs @@ -310,13 +310,9 @@ private bool TryExtractPositionToken(JsonElement root, out PositionTokenContext context = default; failureReason = null; var path = GetDocumentPath(root); - var line = GetInt32(root, "params", "position", "line"); - var character = GetInt32(root, "params", "position", "character"); - if (line < 0 || character < 0) - { - failureReason = FailureInvalidPosition; - return false; - } + var position = ReadRequiredLspPosition(root, "params", "position"); + var line = position.Line; + var character = position.Character; if (!TryResolveDocumentPath(path, out var resolvedPath, out var projectRelativePath, out var workspaceRoot, out failureReason)) return false; diff --git a/src/CodeIndex/Lsp/LspServer.PositionValidation.cs b/src/CodeIndex/Lsp/LspServer.PositionValidation.cs new file mode 100644 index 000000000..1918f0a31 --- /dev/null +++ b/src/CodeIndex/Lsp/LspServer.PositionValidation.cs @@ -0,0 +1,102 @@ +using System.Text.Json; + +namespace CodeIndex.Lsp; + +internal sealed partial class LspServer : IDisposable +{ + private readonly record struct LspPosition(int Line, int Character); + private readonly record struct LspRange(LspPosition Start, LspPosition End); + + private static void ValidateCoordinateParameters(string method, JsonElement root) + { + switch (method) + { + case "textDocument/definition": + case "textDocument/declaration": + case "textDocument/references": + case "textDocument/hover": + case "textDocument/completion": + case "textDocument/documentHighlight": + _ = ReadRequiredLspPosition(root, "params", "position"); + break; + case "textDocument/inlayHint": + _ = ReadRequiredLspRange(root, "params", "range"); + break; + case "textDocument/didChange": + ValidateContentChangeRanges(root); + break; + } + } + + private static LspPosition ReadRequiredLspPosition(JsonElement root, params string[] path) + { + if (!TryGet(root, out var position, path)) + throw new ArgumentException("LSP position is required."); + return ReadRequiredLspPosition(position); + } + + private static LspPosition ReadRequiredLspPosition(JsonElement position) + { + if (position.ValueKind != JsonValueKind.Object + || !position.TryGetProperty("line", out var lineElement) + || lineElement.ValueKind != JsonValueKind.Number + || !lineElement.TryGetInt32(out var line) + || line < 0 + || !position.TryGetProperty("character", out var characterElement) + || characterElement.ValueKind != JsonValueKind.Number + || !characterElement.TryGetInt32(out var character) + || character < 0) + { + throw new ArgumentException("LSP position must contain non-negative integer line and character values."); + } + + return new LspPosition(line, character); + } + + private static LspRange ReadRequiredLspRange(JsonElement root, params string[] path) + { + if (!TryGet(root, out var range, path)) + throw new ArgumentException("LSP range is required."); + return ReadRequiredLspRange(range); + } + + private static LspRange ReadRequiredLspRange(JsonElement range) + { + if (range.ValueKind != JsonValueKind.Object + || !range.TryGetProperty("start", out var startElement) + || !range.TryGetProperty("end", out var endElement)) + { + throw new ArgumentException("LSP range must contain start and end positions."); + } + + var start = ReadRequiredLspPosition(startElement); + var end = ReadRequiredLspPosition(endElement); + if (ComparePosition(start.Line, start.Character, end.Line, end.Character) > 0) + throw new ArgumentException("LSP range start must not follow its end."); + return new LspRange(start, end); + } + + private static void ValidateContentChangeRanges(JsonElement root) + { + if (!TryGet(root, out var changes, "params", "contentChanges") + || changes.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (var change in changes.EnumerateArray()) + { + if (change.ValueKind == JsonValueKind.Object + && change.TryGetProperty("range", out var range)) + { + _ = ReadRequiredLspRange(range); + } + } + } + + private static int ComparePosition(int leftLine, int leftCharacter, int rightLine, int rightCharacter) + => leftLine != rightLine ? leftLine.CompareTo(rightLine) : leftCharacter.CompareTo(rightCharacter); + + private static int ToOneBasedLspLine(int line) + => line == int.MaxValue ? int.MaxValue : line + 1; +} diff --git a/src/CodeIndex/Lsp/LspServer.Protocol.cs b/src/CodeIndex/Lsp/LspServer.Protocol.cs index c3be3320d..51ac007c2 100644 --- a/src/CodeIndex/Lsp/LspServer.Protocol.cs +++ b/src/CodeIndex/Lsp/LspServer.Protocol.cs @@ -189,13 +189,6 @@ private static string GetTextDocumentUri(JsonElement root) return Math.Clamp(limit, 0, maxLimit); } - private static int GetInt32(JsonElement root, params string[] path) - { - if (!TryGet(root, out var value, path) || value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var result)) - return -1; - return result; - } - private static bool TryGet(JsonElement root, out JsonElement value, params string[] path) { value = root; diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index c5fad8e58..ba5a1be9e 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -439,6 +439,7 @@ await messages.Writer try { BeforeSessionDispatchForTesting?.Invoke(method); + ValidateCoordinateParameters(method, root); RefreshOwnedQuerySnapshot(); using var activity = StartLspRequestActivity(method); var response = method switch diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 14c627f9e..ce01717df 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -960,7 +960,9 @@ public class App Assert.NotNull(semanticTokens); Assert.NotEmpty(semanticTokens!["result"]!["data"]!.AsArray()); - var inlayHints = HandleInitializedMessage(server, CreateTextDocumentRequest("textDocument/inlayHint", sourcePath, 35367)); + var inlayHints = HandleInitializedMessage( + server, + CreateInlayHintRequest(sourcePath, 35367, 0, 0, 4, 0)); Assert.NotNull(inlayHints); Assert.True(inlayHints!["error"] is null, inlayHints["error"]?.ToJsonString()); Assert.Empty(inlayHints!["result"]!.AsArray()); @@ -971,6 +973,232 @@ public class App } } + [Fact] + public void HandleMessage_PositionAndRangeCoordinatesRejectInvalidParams_Issue4869() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_coordinate_validation_4869"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + const string source = "class App\n{\n void Run() { }\n}\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", source); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var server = new LspServer( + new DbReader(db), + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot); + + var requestId = 48690; + foreach (var method in new[] + { + "textDocument/definition", + "textDocument/declaration", + "textDocument/references", + "textDocument/hover", + "textDocument/completion", + "textDocument/documentHighlight", + }) + { + AssertInvalidParams(HandleInitializedMessage( + server, + CreatePositionRequest(method, sourcePath, requestId++, -1, 0))); + AssertInvalidParams(HandleInitializedMessage( + server, + CreatePositionRequest(method, sourcePath, requestId++, 0, -1))); + } + + var missingPath = Path.Combine(projectRoot, "missing.cs"); + AssertInvalidParams(HandleInitializedMessage( + server, + CreatePositionRequest("textDocument/definition", missingPath, requestId++, -1, 0))); + var missingDocument = HandleInitializedMessage( + server, + CreatePositionRequest("textDocument/definition", missingPath, requestId++, 0, 0)); + Assert.NotNull(missingDocument); + Assert.Null(missingDocument!["error"]); + Assert.Empty(missingDocument["result"]!.AsArray()); + + foreach (var malformedPosition in new[] + { + new JsonObject { ["line"] = 2147483648L, ["character"] = 0 }, + new JsonObject { ["line"] = 0, ["character"] = 2147483648L }, + new JsonObject { ["line"] = "0", ["character"] = 0 }, + new JsonObject { ["line"] = 0, ["character"] = 0.5 }, + new JsonObject { ["line"] = 0 }, + }) + { + AssertInvalidParams(HandleInitializedMessage( + server, + CreatePositionRequest( + "textDocument/definition", + sourcePath, + requestId++, + malformedPosition))); + } + + AssertInvalidParams(HandleInitializedMessage( + server, + CreateTextDocumentRequest("textDocument/inlayHint", sourcePath, requestId++))); + AssertInvalidParams(HandleInitializedMessage( + server, + CreateInlayHintRequest(sourcePath, requestId++, -1, 0, 0, 0))); + AssertInvalidParams(HandleInitializedMessage( + server, + CreateInlayHintRequest(sourcePath, requestId++, 2, 0, 1, 0))); + AssertInvalidParams(HandleInitializedMessage( + server, + CreateInlayHintRequest( + sourcePath, + requestId++, + new JsonObject + { + ["start"] = new JsonObject { ["line"] = 0, ["character"] = 0 }, + ["end"] = new JsonObject { ["line"] = 2147483648L, ["character"] = 0 }, + }))); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_PositionValidationPreservesUtf16AndEofPolicies_Issue4869() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_coordinate_boundaries_4869"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + var source = string.Join('\n', + [ + "class App", + "{", + " void Target() { }", + " void Call() { var emoji = \"😀\"; Target(); }", + "}", + string.Empty, + ]); + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", source); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var server = new LspServer( + new DbReader(db), + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot); + + var eofDefinition = HandleInitializedMessage( + server, + CreateDefinitionRequest(sourcePath, 486920, 0, "class App".Length)); + Assert.NotNull(eofDefinition); + Assert.NotEmpty(eofDefinition!["result"]!.AsArray()); + + var unicodeLine = source.Split('\n')[3]; + var unicodeDefinition = HandleInitializedMessage( + server, + CreateDefinitionRequest( + sourcePath, + 486921, + 3, + unicodeLine.IndexOf("Target", StringComparison.Ordinal))); + Assert.NotNull(unicodeDefinition); + Assert.NotEmpty(unicodeDefinition!["result"]!.AsArray()); + + var eofRange = HandleInitializedMessage( + server, + CreateInlayHintRequest(sourcePath, 486922, 5, 0, 5, 0)); + Assert.NotNull(eofRange); + Assert.Null(eofRange!["error"]); + Assert.Empty(eofRange["result"]!.AsArray()); + + var maximumPosition = HandleInitializedMessage( + server, + CreateDefinitionRequest(sourcePath, 486923, int.MaxValue, int.MaxValue)); + Assert.NotNull(maximumPosition); + Assert.Null(maximumPosition!["error"]); + Assert.Empty(maximumPosition["result"]!.AsArray()); + + var maximumRange = HandleInitializedMessage( + server, + CreateInlayHintRequest( + sourcePath, + 486924, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue)); + Assert.NotNull(maximumRange); + Assert.Null(maximumRange!["error"]); + Assert.Empty(maximumRange["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_DidChangeRejectsInvalidRangeBeforeMutatingLiveText_Issue4869() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_change_range_4869"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + const string source = "class Original\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", source); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var server = new LspServer( + new DbReader(db), + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot); + InitializeSession(server); + Assert.Null(server.HandleMessage(CreateDidOpenRequest(sourcePath, source, version: 1))); + + var invalidChange = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + method = "textDocument/didChange", + @params = new + { + textDocument = new + { + uri = new Uri(sourcePath).AbsoluteUri, + version = 2, + }, + contentChanges = new[] + { + new + { + range = new + { + start = new { line = -1, character = 0 }, + end = new { line = 0, character = 0 }, + }, + text = "class Replaced\n", + }, + }, + }, + }); + + Assert.Null(server.HandleMessage(invalidChange)); + var originalDefinition = server.HandleMessage( + CreateDefinitionRequest(sourcePath, 486930, 0, "class ".Length)); + Assert.NotNull(originalDefinition); + Assert.NotEmpty(originalDefinition!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_SemanticTokens_ClassifiesCSharpKeywordsModifiersAndDeclarations_Issue4444() { @@ -4643,17 +4871,32 @@ private static string CreateInitializeRequestWithWorkspaceFolder(string workspac }); private static string CreatePositionRequest(string method, string sourcePath, int id, int line, int character) => - JsonSerializer.Serialize(new - { - jsonrpc = "2.0", - id, + CreatePositionRequest( method, - @params = new + sourcePath, + id, + new JsonObject { - textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, - position = new { line, character }, + ["line"] = line, + ["character"] = character, + }); + + private static string CreatePositionRequest( + string method, + string sourcePath, + int id, + JsonObject position) => + new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["method"] = method, + ["params"] = new JsonObject + { + ["textDocument"] = new JsonObject { ["uri"] = new Uri(sourcePath).AbsoluteUri }, + ["position"] = position, }, - }); + }.ToJsonString(); private static string CreateTextDocumentRequest(string method, string sourcePath, int id) => JsonSerializer.Serialize(new @@ -4674,21 +4917,30 @@ private static string CreateInlayHintRequest( int startCharacter, int endLine, int endCharacter) => - JsonSerializer.Serialize(new - { - jsonrpc = "2.0", + CreateInlayHintRequest( + sourcePath, id, - method = "textDocument/inlayHint", - @params = new + new JsonObject { - textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, - range = new - { - start = new { line = startLine, character = startCharacter }, - end = new { line = endLine, character = endCharacter }, - }, + ["start"] = new JsonObject { ["line"] = startLine, ["character"] = startCharacter }, + ["end"] = new JsonObject { ["line"] = endLine, ["character"] = endCharacter }, + }); + + private static string CreateInlayHintRequest( + string sourcePath, + int id, + JsonObject range) => + new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["method"] = "textDocument/inlayHint", + ["params"] = new JsonObject + { + ["textDocument"] = new JsonObject { ["uri"] = new Uri(sourcePath).AbsoluteUri }, + ["range"] = range, }, - }); + }.ToJsonString(); private static string CreateDidOpenRequest(string sourcePath, string text, int version) => JsonSerializer.Serialize(new @@ -4786,6 +5038,15 @@ private static int CharacterOf(string source, int line, string value) return lines[line].IndexOf(value, StringComparison.Ordinal); } + private static void AssertInvalidParams(JsonObject? response) + { + Assert.NotNull(response); + Assert.True( + response!["error"]!["code"]!.GetValue() == -32602, + response.ToJsonString()); + Assert.Equal("Invalid params", response["error"]!["message"]!.GetValue()); + } + private static JsonObject? HandleInitializedMessage(LspServer server, string payload) { lock (server)