From 608944a10c00e9ef219462013cc8b0d61f58bc8e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 10:14:54 +0900 Subject: [PATCH 1/4] Bound LSP message framing (#2825) --- changelog.d/unreleased/2825.fixed.md | 16 ++++++++++++ src/CodeIndex/Lsp/LspServer.cs | 17 +++++++++++-- tests/CodeIndex.Tests/LspServerTests.cs | 33 +++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/2825.fixed.md diff --git a/changelog.d/unreleased/2825.fixed.md b/changelog.d/unreleased/2825.fixed.md new file mode 100644 index 0000000000..fb47eea1b0 --- /dev/null +++ b/changelog.d/unreleased/2825.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2825 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs +--- + +## English + +- **LSP message framing now rejects oversized frames and header lines (#2825)** — `cdidx lsp` now caps `Content-Length` frames and individual header lines before renting payload buffers. + +## 日本語 + +- **LSP message framing が過大な frame と header line を拒否するようになりました (#2825)** — `cdidx lsp` は payload buffer を確保する前に `Content-Length` frame と個別 header line の上限を適用します。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index c633e61afc..3c9b9549a4 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -10,6 +11,8 @@ namespace CodeIndex.Lsp; internal sealed class LspServer : IDisposable { private const int DefaultLimit = 50; + internal const int MaxLspFrameBytes = 8 * 1024 * 1024; + internal const int MaxLspHeaderLineBytes = 8 * 1024; private readonly DbReader _reader; private readonly string _version; private readonly JsonSerializerOptions _jsonOptions; @@ -333,9 +336,15 @@ internal static bool TryReadMessage(Stream input, out string payload) continue; var name = line[..colon].Trim(); var value = line[(colon + 1)..].Trim(); - if (string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase) - && int.TryParse(value, out var parsed)) + if (string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase)) { + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) + || parsed < 0 + || parsed > MaxLspFrameBytes) + { + return false; + } + contentLength = parsed; } } @@ -383,7 +392,11 @@ internal static void WriteMessage(Stream output, string payload) if (value == '\n') break; if (value != '\r') + { + if (bytes.Count >= MaxLspHeaderLineBytes) + return null; bytes.Add((byte)value); + } } return Encoding.ASCII.GetString(bytes.ToArray()); } diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index a547fc694b..525facda66 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -27,6 +27,39 @@ public void TryReadMessage_ReadsContentLengthFramedPayload() Assert.Equal(payload, actual); } + [Fact] + public void TryReadMessage_AcceptsHeaderLineAtMaxLength() + { + const string payload = "{}"; + var maxLengthHeader = "X-" + new string('A', LspServer.MaxLspHeaderLineBytes - 2); + var bytes = Encoding.UTF8.GetBytes($"{maxLengthHeader}\r\nContent-Length: {payload.Length}\r\n\r\n{payload}"); + using var stream = new MemoryStream(bytes); + + Assert.True(LspServer.TryReadMessage(stream, out var actual)); + Assert.Equal(payload, actual); + } + + [Fact] + public void TryReadMessage_RejectsHeaderLineOverMaxLength() + { + var oversizedHeader = "X-" + new string('A', LspServer.MaxLspHeaderLineBytes - 1); + var bytes = Encoding.UTF8.GetBytes($"{oversizedHeader}\r\nContent-Length: 2\r\n\r\n{{}}"); + using var stream = new MemoryStream(bytes); + + Assert.False(LspServer.TryReadMessage(stream, out var actual)); + Assert.Equal(string.Empty, actual); + } + + [Fact] + public void TryReadMessage_RejectsFrameOverMaxLength() + { + var bytes = Encoding.UTF8.GetBytes($"Content-Length: {LspServer.MaxLspFrameBytes + 1}\r\n\r\n"); + using var stream = new MemoryStream(bytes); + + Assert.False(LspServer.TryReadMessage(stream, out var actual)); + Assert.Equal(string.Empty, actual); + } + [Fact] public void HandleMessage_Initialize_AdvertisesCoreCapabilities() { From a70899ace281571fd3ec66ed37f7eb3018e25e8f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 10:21:13 +0900 Subject: [PATCH 2/4] Restrict LSP position file reads (#2826) --- changelog.d/unreleased/2826.fixed.md | 16 +++ src/CodeIndex/Lsp/LspServer.cs | 146 ++++++++++++++++++++++-- tests/CodeIndex.Tests/LspServerTests.cs | 112 ++++++++++++++++++ 3 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 changelog.d/unreleased/2826.fixed.md diff --git a/changelog.d/unreleased/2826.fixed.md b/changelog.d/unreleased/2826.fixed.md new file mode 100644 index 0000000000..7ed1ef9951 --- /dev/null +++ b/changelog.d/unreleased/2826.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2826 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs +--- + +## English + +- **LSP position requests now stay inside indexed project files (#2826)** — `definition` and `references` now ignore unindexed, outside-root, or oversized documents and read only the requested line instead of materializing the whole file. + +## 日本語 + +- **LSP position request が indexed project file 内に制限されました (#2826)** — `definition` / `references` は未 index、project root 外、または過大な document を無視し、ファイル全体を materialize せず要求行だけを読み取ります。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 3c9b9549a4..8eda3205f8 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -13,6 +13,7 @@ internal sealed class LspServer : IDisposable private const int DefaultLimit = 50; internal const int MaxLspFrameBytes = 8 * 1024 * 1024; internal const int MaxLspHeaderLineBytes = 8 * 1024; + internal const int MaxPositionDocumentBytes = 4 * 1024 * 1024; private readonly DbReader _reader; private readonly string _version; private readonly JsonSerializerOptions _jsonOptions; @@ -150,15 +151,50 @@ private JsonArray References(JsonElement root) if (line < 0 || character < 0) return null; - var resolved = Path.IsPathRooted(path) ? path : Path.GetFullPath(path); - if (!File.Exists(resolved)) + if (!TryResolveDocumentPath(path, out var resolvedPath, out var projectRelativePath)) return null; - var lines = File.ReadAllLines(resolved); - if (line >= lines.Length) + var indexedPath = ResolveIndexedPath(path, resolvedPath, projectRelativePath); + if (indexedPath == null || !TryResolveIndexedFilePath(indexedPath, out var indexedFullPath)) return null; - return ExtractTokenAtUtf16Position(lines[line], character); + if (!string.Equals(resolvedPath, indexedFullPath, PathStringComparison)) + return null; + + if (!TryReadPositionLine(indexedFullPath, line, out var sourceLine)) + return null; + + return ExtractTokenAtUtf16Position(sourceLine, character); + } + + private static bool TryReadPositionLine(string path, int targetLine, out string sourceLine) + { + sourceLine = string.Empty; + try + { + using var stream = File.OpenRead(path); + if (stream.Length > MaxPositionDocumentBytes) + return false; + + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + for (var currentLine = 0; currentLine <= targetLine; currentLine++) + { + var line = reader.ReadLine(); + if (line == null) + return false; + if (currentLine == targetLine) + { + sourceLine = line; + return true; + } + } + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + return false; + } + + return false; } internal static string? ExtractTokenAtUtf16Position(string line, int character) @@ -184,27 +220,121 @@ private JsonArray References(JsonElement root) private static bool IsTokenChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '@'; - private static bool MatchesDocumentPath(string indexedPath, string documentPath) + private bool MatchesDocumentPath(string indexedPath, string documentPath, string? projectRelativePath) { + var normalizedIndexed = indexedPath.Replace('\\', '/'); + if (_projectRoot != null) + { + if (Path.IsPathRooted(indexedPath) + && TryResolveIndexedFilePath(indexedPath, out var indexedFullPath) + && TryGetProjectRelativePath(indexedFullPath, out var indexedRelativePath) + && indexedRelativePath != null) + { + normalizedIndexed = indexedRelativePath.Replace('\\', '/'); + } + + return projectRelativePath != null + && string.Equals(normalizedIndexed, projectRelativePath.Replace('\\', '/'), PathStringComparison); + } + if (string.Equals(indexedPath, documentPath, StringComparison.Ordinal)) return true; - var normalizedIndexed = indexedPath.Replace('\\', '/'); var normalizedDocument = documentPath.Replace('\\', '/'); return normalizedDocument.EndsWith("/" + normalizedIndexed, StringComparison.Ordinal); } private string? ResolveIndexedPath(string documentPath) + { + if (!TryResolveDocumentPath(documentPath, out var resolvedPath, out var projectRelativePath)) + return null; + + return ResolveIndexedPath(documentPath, resolvedPath, projectRelativePath); + } + + private string? ResolveIndexedPath(string documentPath, string resolvedPath, string? projectRelativePath) { var fileName = Path.GetFileName(documentPath); + if (string.IsNullOrEmpty(fileName)) + fileName = Path.GetFileName(resolvedPath); + if (string.IsNullOrEmpty(fileName)) + return null; + var files = _reader.ListFiles(fileName, 1000); var matches = files - .Where(file => MatchesDocumentPath(file.Path, documentPath)) + .Where(file => MatchesDocumentPath(file.Path, documentPath, projectRelativePath)) .Take(2) .ToList(); return matches.Count == 1 ? matches[0].Path : null; } + private bool TryResolveDocumentPath(string documentPath, out string resolvedPath, out string? projectRelativePath) + { + resolvedPath = string.Empty; + projectRelativePath = null; + try + { + resolvedPath = Path.IsPathRooted(documentPath) + ? Path.GetFullPath(documentPath) + : Path.GetFullPath(documentPath, _projectRoot ?? Environment.CurrentDirectory); + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + return false; + } + + if (_projectRoot == null) + return true; + + return TryGetProjectRelativePath(resolvedPath, out projectRelativePath); + } + + private bool TryResolveIndexedFilePath(string indexedPath, out string resolvedPath) + { + resolvedPath = string.Empty; + try + { + resolvedPath = Path.IsPathRooted(indexedPath) + ? Path.GetFullPath(indexedPath) + : Path.GetFullPath(indexedPath, _projectRoot ?? Environment.CurrentDirectory); + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + return false; + } + } + + private bool TryGetProjectRelativePath(string resolvedPath, out string? relativePath) + { + relativePath = null; + if (_projectRoot == null) + return false; + + try + { + var relative = Path.GetRelativePath(Path.GetFullPath(_projectRoot), resolvedPath); + if (relative == "." + || relative == ".." + || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) + || Path.IsPathRooted(relative)) + { + return false; + } + + relativePath = relative; + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + return false; + } + } + + private static StringComparison PathStringComparison => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + private JsonObject ToWorkspaceSymbol(SymbolResult symbol) => new() { ["name"] = symbol.Name, diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 525facda66..08122fa87f 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -200,4 +200,116 @@ public void HandleMessage_Definition_ReturnsLocationForTokenAtPosition() TestProjectHelper.DeleteDirectory(projectRoot); } } + + [Fact] + public void HandleMessage_Definition_ReturnsEmptyForUnindexedDocument() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_unindexed"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var indexedPath = Path.Combine(projectRoot, "indexed.cs"); + var indexedSource = "class Indexed { void Needle() { } }\n"; + File.WriteAllText(indexedPath, indexedSource); + TestProjectHelper.InsertIndexedFile(dbPath, "indexed.cs", "csharp", indexedSource); + var unindexedPath = Path.Combine(projectRoot, "unindexed.cs"); + var unindexedSource = "class Unindexed { void Call() { Needle(); } }\n"; + File.WriteAllText(unindexedPath, unindexedSource); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + unindexedPath, + 4, + 0, + unindexedSource.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_Definition_ReturnsEmptyForOutsideProjectDocument() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_project_root"); + var outsideRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_outside"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var indexedPath = Path.Combine(projectRoot, "app.cs"); + var indexedSource = "class Indexed { void Needle() { } }\n"; + File.WriteAllText(indexedPath, indexedSource); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", indexedSource); + var outsidePath = Path.Combine(outsideRoot, "app.cs"); + var outsideSource = "class Outside { void Call() { Needle(); } }\n"; + File.WriteAllText(outsidePath, outsideSource); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + outsidePath, + 5, + 0, + outsideSource.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(outsideRoot); + } + } + + [Fact] + public void HandleMessage_Definition_ReturnsEmptyForOversizedIndexedDocument() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_oversized"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "huge.cs"); + var indexedSource = "class App { void Needle() { } }\n"; + TestProjectHelper.InsertIndexedFile(dbPath, "huge.cs", "csharp", indexedSource); + var oversizedSource = "class App { void Call() { Needle(); } }\n" + new string('x', LspServer.MaxPositionDocumentBytes); + File.WriteAllText(sourcePath, oversizedSource); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + sourcePath, + 6, + 0, + oversizedSource.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + private static string CreateDefinitionRequest(string sourcePath, int id, int line, int character) => + JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id, + method = "textDocument/definition", + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + position = new { line, character }, + }, + }); } From 87997270a5903317722226b5a8429f4f3ad2da52 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 11:02:47 +0900 Subject: [PATCH 3/4] Use exact LSP indexed file lookup (#2826) --- src/CodeIndex/Lsp/LspServer.cs | 8 +++++ tests/CodeIndex.Tests/LspServerTests.cs | 41 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 8eda3205f8..439d9076bb 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -254,6 +254,14 @@ private bool MatchesDocumentPath(string indexedPath, string documentPath, string private string? ResolveIndexedPath(string documentPath, string resolvedPath, string? projectRelativePath) { + if (projectRelativePath != null) + { + var exactPath = projectRelativePath.Replace('\\', '/'); + var exactFile = _reader.GetFileByPath(exactPath); + if (exactFile != null) + return exactFile.Path; + } + var fileName = Path.GetFileName(documentPath); if (string.IsNullOrEmpty(fileName)) fileName = Path.GetFileName(resolvedPath); diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 08122fa87f..ec99803643 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -300,6 +300,47 @@ public void HandleMessage_Definition_ReturnsEmptyForOversizedIndexedDocument() } } + [Fact] + public void HandleMessage_Definition_ResolvesIndexedDocumentBeyondBasenameCandidateCap() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_many_basenames"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + for (var i = 0; i < 1001; i++) + { + TestProjectHelper.InsertIndexedFile( + dbPath, + $"src/{i:D4}/index.cs", + "csharp", + $"class Filler{i} {{ }}\n"); + } + + var targetRelativePath = "src/zzzz/index.cs"; + var sourcePath = Path.Combine(projectRoot, "src", "zzzz", "index.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + var source = "class Target { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, targetRelativePath, "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + sourcePath, + 7, + 0, + source.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.NotEmpty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + private static string CreateDefinitionRequest(string sourcePath, int id, int line, int character) => JsonSerializer.Serialize(new { From f6d3364e293f2825b478b84711e764a7a0c187c8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 11:14:53 +0900 Subject: [PATCH 4/4] Honor LSP workspace path casing (#2826) --- src/CodeIndex/Cli/ProgramRunner.cs | 9 +++- src/CodeIndex/Lsp/LspServer.cs | 10 ++-- tests/CodeIndex.Tests/LspServerTests.cs | 66 +++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 6a439d799f..74eb32dcde 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -1569,7 +1569,14 @@ private static int RunLsp(string[] cmdArgs, string appVersion, JsonSerializerOpt } db.TryMigrateForRead(); - using var server = new LspServer(new DbReader(db), appVersion, jsonOptions, db.GetMetaString(DbContext.IndexedProjectRootMetaKey)); + var indexedProjectRoot = db.GetMetaString(DbContext.IndexedProjectRootMetaKey); + if (!string.IsNullOrWhiteSpace(indexedProjectRoot) + && bool.TryParse(db.GetMetaString(DbContext.WorkspacePathCaseSensitiveMetaKey), out var pathCaseSensitive)) + { + PathCasing.SeedFromWorkspace(indexedProjectRoot, ignoreCase: !pathCaseSensitive); + } + + using var server = new LspServer(new DbReader(db), appVersion, jsonOptions, indexedProjectRoot); server.Run(Console.OpenStandardInput(), Console.OpenStandardOutput()); return CommandExitCodes.Success; } diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 439d9076bb..5a360ceb57 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Models; @@ -18,6 +19,7 @@ internal sealed class LspServer : IDisposable private readonly string _version; private readonly JsonSerializerOptions _jsonOptions; private readonly string? _projectRoot; + private readonly StringComparison _pathStringComparison; private bool _shutdownRequested; public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOptions, string? projectRoot = null) @@ -26,6 +28,7 @@ public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOpti _version = version; _jsonOptions = jsonOptions; _projectRoot = string.IsNullOrWhiteSpace(projectRoot) ? null : projectRoot; + _pathStringComparison = PathCasing.ComparisonFor(_projectRoot ?? Environment.CurrentDirectory); } public void Run(Stream input, Stream output) @@ -158,7 +161,7 @@ private JsonArray References(JsonElement root) if (indexedPath == null || !TryResolveIndexedFilePath(indexedPath, out var indexedFullPath)) return null; - if (!string.Equals(resolvedPath, indexedFullPath, PathStringComparison)) + if (!string.Equals(resolvedPath, indexedFullPath, _pathStringComparison)) return null; if (!TryReadPositionLine(indexedFullPath, line, out var sourceLine)) @@ -234,7 +237,7 @@ private bool MatchesDocumentPath(string indexedPath, string documentPath, string } return projectRelativePath != null - && string.Equals(normalizedIndexed, projectRelativePath.Replace('\\', '/'), PathStringComparison); + && string.Equals(normalizedIndexed, projectRelativePath.Replace('\\', '/'), _pathStringComparison); } if (string.Equals(indexedPath, documentPath, StringComparison.Ordinal)) @@ -340,9 +343,6 @@ private bool TryGetProjectRelativePath(string resolvedPath, out string? relative } } - private static StringComparison PathStringComparison => - OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - private JsonObject ToWorkspaceSymbol(SymbolResult symbol) => new() { ["name"] = symbol.Name, diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index ec99803643..34149cdd0d 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -300,6 +300,72 @@ public void HandleMessage_Definition_ReturnsEmptyForOversizedIndexedDocument() } } + [Fact] + public void HandleMessage_Definition_HonorsCaseInsensitiveWorkspaceCasing() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_case_insensitive"); + try + { + PathCasing.SeedFromWorkspace(projectRoot, ignoreCase: true); + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "src", "Foo.cs"); + var requestPath = Path.Combine(projectRoot, "src", "foo.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + var source = "class App { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Foo.cs", "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + requestPath, + 8, + 0, + source.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.NotEmpty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_Definition_RejectsCaseVariantWhenWorkspaceCaseSensitive() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_case_sensitive"); + try + { + PathCasing.SeedFromWorkspace(projectRoot, ignoreCase: false); + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "src", "Foo.cs"); + var requestPath = Path.Combine(projectRoot, "src", "foo.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + var source = "class App { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Foo.cs", "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + requestPath, + 9, + 0, + source.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_Definition_ResolvesIndexedDocumentBeyondBasenameCandidateCap() {