From 198ddc106258568783c2f31061075aad10c31059 Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:05:22 -0700 Subject: [PATCH 1/2] Normalize Windows drive letter case in workspace document URIs Windows drive letters are case-insensitive and clients may send either `file:///C:/foo` (RFC 8089) or `file:///c:/foo` for the same file. `uris.from_fs_path` lower-cases the drive letter, but `Workspace._docs` stored URIs verbatim, so the two spellings became distinct dictionary keys. A document opened under one spelling was then invisible under the other, and lookups silently fell back to reading stale content from disk instead of the in-memory version. Add `uris.normalize` and use it for every `_docs` key, so a document is found regardless of the drive letter case the client used. Non-drive paths are unchanged. --- pylsp/uris.py | 14 ++++++++++++++ pylsp/workspace.py | 24 ++++++++++++------------ test/test_uris.py | 13 +++++++++++++ test/test_workspace.py | 11 +++++++++++ 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/pylsp/uris.py b/pylsp/uris.py index 8ebd8e31..2b185953 100644 --- a/pylsp/uris.py +++ b/pylsp/uris.py @@ -85,6 +85,20 @@ def from_fs_path(path): return urlunparse((scheme, netloc, path, params, query, fragment)) +def normalize(uri): + """Return a canonical form of the given URI. + + Windows drive letters are case-insensitive, so clients may send either + ``file:///C:/foo`` or ``file:///c:/foo`` for the same file. Both forms + normalize to the lower-case one used by :func:`from_fs_path`, so that a + URI can be used as a stable dictionary key. + """ + scheme, netloc, path, params, query, fragment = urlparse(uri) + if RE_DRIVE_LETTER_PATH.match(path): + path = path[0] + path[1].lower() + path[2:] + return urlunparse((scheme, netloc, path, params, query, fragment)) + + def uri_with( uri, scheme=None, netloc=None, path=None, params=None, query=None, fragment=None ): diff --git a/pylsp/workspace.py b/pylsp/workspace.py index 290b95ee..42f8a4eb 100644 --- a/pylsp/workspace.py +++ b/pylsp/workspace.py @@ -112,23 +112,23 @@ def get_document(self, doc_uri): See https://github.com/Microsoft/language-server-protocol/issues/177 """ - return self._docs.get(doc_uri) or self._create_document(doc_uri) + return self._docs.get(uris.normalize(doc_uri)) or self._create_document(doc_uri) def get_cell_document(self, doc_uri): - return self._docs.get(doc_uri) + return self._docs.get(uris.normalize(doc_uri)) def get_maybe_document(self, doc_uri): - return self._docs.get(doc_uri) + return self._docs.get(uris.normalize(doc_uri)) def put_document(self, doc_uri, source, version=None) -> None: - self._docs[doc_uri] = self._create_document( + self._docs[uris.normalize(doc_uri)] = self._create_document( doc_uri, source=source, version=version ) def put_notebook_document( self, doc_uri, notebook_type, cells, version=None, metadata=None ) -> None: - self._docs[doc_uri] = self._create_notebook_document( + self._docs[uris.normalize(doc_uri)] = self._create_notebook_document( doc_uri, notebook_type, cells, version, metadata ) @@ -144,27 +144,27 @@ def temp_document(self, source, path=None) -> None: self.rm_document(uri) def add_notebook_cells(self, doc_uri, cells, start) -> None: - self._docs[doc_uri].add_cells(cells, start) + self._docs[uris.normalize(doc_uri)].add_cells(cells, start) def remove_notebook_cells(self, doc_uri, start, delete_count) -> None: - self._docs[doc_uri].remove_cells(start, delete_count) + self._docs[uris.normalize(doc_uri)].remove_cells(start, delete_count) def update_notebook_metadata(self, doc_uri, metadata) -> None: - self._docs[doc_uri].metadata = metadata + self._docs[uris.normalize(doc_uri)].metadata = metadata def put_cell_document( self, doc_uri, notebook_uri, language_id, source, version=None ) -> None: - self._docs[doc_uri] = self._create_cell_document( + self._docs[uris.normalize(doc_uri)] = self._create_cell_document( doc_uri, notebook_uri, language_id, source, version ) def rm_document(self, doc_uri) -> None: - self._docs.pop(doc_uri) + self._docs.pop(uris.normalize(doc_uri)) def update_document(self, doc_uri, change, version=None) -> None: - self._docs[doc_uri].apply_change(change) - self._docs[doc_uri].version = version + self._docs[uris.normalize(doc_uri)].apply_change(change) + self._docs[uris.normalize(doc_uri)].version = version def update_config(self, settings): self._config.update((settings or {}).get("pylsp", {})) diff --git a/test/test_uris.py b/test/test_uris.py index 41c7f54d..c5f0be51 100644 --- a/test/test_uris.py +++ b/test/test_uris.py @@ -70,3 +70,16 @@ def test_win_from_fs_path(path, uri) -> None: ) def test_uri_with(uri, kwargs, new_uri) -> None: assert uris.uri_with(uri, **kwargs) == new_uri + + +@pytest.mark.parametrize( + "uri,normalized", + [ + ("file:///C:/far/boo", "file:///c:/far/boo"), + ("file:///c:/far/boo", "file:///c:/far/boo"), + ("file:///C:/far/space%20%3Fboo", "file:///c:/far/space%20%3Fboo"), + ("file:///foo/bar", "file:///foo/bar"), + ], +) +def test_normalize(uri, normalized) -> None: + assert uris.normalize(uri) == normalized diff --git a/test/test_workspace.py b/test/test_workspace.py index 41bac398..dde1b242 100644 --- a/test/test_workspace.py +++ b/test/test_workspace.py @@ -430,3 +430,14 @@ class DummyError(Exception): {"kind": "begin", "title": "some_title"}, {"kind": "end"}, ] + + +def test_put_document_normalizes_drive_letter_case(pylsp) -> None: + """A drive letter is case-insensitive, so both spellings are one document.""" + upper_uri = "file:///C:/far/boo.py" + lower_uri = "file:///c:/far/boo.py" + + pylsp.workspace.put_document(upper_uri, "content") + + assert pylsp.workspace.get_maybe_document(lower_uri) is not None + assert pylsp.workspace.get_document(lower_uri).source == "content" From 6174437b0827093e2572be712ddb2ea7c0d9f7b5 Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:55:22 -0700 Subject: [PATCH 2/2] test: compare against normalized URIs when inspecting _docs keys Workspace._docs is now keyed by uris.normalize(doc_uri), so on Windows a client-sent 'file:///C:/...' URI is stored as 'file:///c:/...'. The multiple-workspaces tests inspect _docs directly with the raw message URI, which no longer matches on Windows. Normalize the expected key in those assertions; behaviour on other platforms is unchanged. --- test/test_workspace.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/test_workspace.py b/test/test_workspace.py index dde1b242..e0bb2783 100644 --- a/test/test_workspace.py +++ b/test/test_workspace.py @@ -100,8 +100,8 @@ def test_multiple_workspaces_from_initialize(pylsp_w_workspace_folders) -> None: msg1 = {"uri": path_as_uri(str(file1)), "version": 1, "text": "import os"} pylsp.m_text_document__did_open(textDocument=msg1) - assert msg1["uri"] in pylsp.workspace._docs - assert msg1["uri"] in pylsp.workspaces[folders_uris[0]]._docs + assert uris.normalize(msg1["uri"]) in pylsp.workspace._docs + assert uris.normalize(msg1["uri"]) in pylsp.workspaces[folders_uris[0]]._docs # Create file in the second workspace folder. file2 = workspace_folders[1].join("file2.py") @@ -109,8 +109,8 @@ def test_multiple_workspaces_from_initialize(pylsp_w_workspace_folders) -> None: msg2 = {"uri": path_as_uri(str(file2)), "version": 1, "text": "import sys"} pylsp.m_text_document__did_open(textDocument=msg2) - assert msg2["uri"] not in pylsp.workspace._docs - assert msg2["uri"] in pylsp.workspaces[folders_uris[1]]._docs + assert uris.normalize(msg2["uri"]) not in pylsp.workspace._docs + assert uris.normalize(msg2["uri"]) in pylsp.workspaces[folders_uris[1]]._docs def test_multiple_workspaces(tmpdir, pylsp) -> None: @@ -124,7 +124,7 @@ def test_multiple_workspaces(tmpdir, pylsp) -> None: msg = {"uri": path_as_uri(str(file1)), "version": 1, "text": "import os"} pylsp.m_text_document__did_open(textDocument=msg) - assert msg["uri"] in pylsp.workspace._docs + assert uris.normalize(msg["uri"]) in pylsp.workspace._docs added_workspaces = [ {"uri": path_as_uri(str(x))} for x in (workspace1_dir, workspace2_dir) @@ -136,14 +136,14 @@ def test_multiple_workspaces(tmpdir, pylsp) -> None: assert workspace["uri"] in pylsp.workspaces workspace1_uri = added_workspaces[0]["uri"] - assert msg["uri"] not in pylsp.workspace._docs - assert msg["uri"] in pylsp.workspaces[workspace1_uri]._docs + assert uris.normalize(msg["uri"]) not in pylsp.workspace._docs + assert uris.normalize(msg["uri"]) in pylsp.workspaces[workspace1_uri]._docs msg = {"uri": path_as_uri(str(file2)), "version": 1, "text": "import sys"} pylsp.m_text_document__did_open(textDocument=msg) workspace2_uri = added_workspaces[1]["uri"] - assert msg["uri"] in pylsp.workspaces[workspace2_uri]._docs + assert uris.normalize(msg["uri"]) in pylsp.workspaces[workspace2_uri]._docs event = {"added": [], "removed": [added_workspaces[0]]} pylsp.m_workspace__did_change_workspace_folders(event)