Repository: sublimelsp/LSP

Issue:
On Windows, the drive letter in server responsed file URIs are lowercase.
**Describe the bug**

I tried both intelephense and pyright, they both returned lowercased drive letter thus I suspect it's a standard. (or maybe VSCode's LSP lib does it)

https://user-images.githubusercontent.com/6594915/123961095-96286c80-d9e2-11eb-8ada-0da9af754a55.mp4

In "Goto Definition...", this causes ST to open a file whose drive letter is in lowercase. And that may cause various mysterious problem sometimes... Or maybe, this should be fixed in ST core.

**To Reproduce**
Steps to reproduce the behavior:
1. Install LSP-intelephense with a Windows build ST
2. Open a PHP project
3. Make sure the definition file is not opened in a tab already
4. Do "Goto Definition"
5. The newly opened tab should have a lower drive letter

**Expected behavior**

The drive letter should be uppercase.

**Environment (please complete the following information):**
- OS: Win10 21H1 x64
- Sublime Text version: 4109
- LSP version: 4070-1.6.1
- Language servers used: intelephense, pyright

**Additional context**

This is a Windows-only issue as it's case-insensitive.



Discussion:

Resolving patch:
diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py
--- a/plugin/core/sessions.py
+++ b/plugin/core/sessions.py
@@ -35,6 +35,7 @@
 from .types import method_to_capability
 from .types import SettingsRegistration
 from .typing import Callable, cast, Dict, Any, Optional, List, Tuple, Generator, Type, Protocol, Mapping, Union
+from .url import parse_uri
 from .version import __version__
 from .views import COMPLETION_KINDS
 from .views import extract_variables
@@ -924,10 +925,28 @@ def session_buffers_async(self) -> Generator[SessionBufferProtocol, None, None]:
         yield from self._session_buffers
 
     def get_session_buffer_for_uri_async(self, uri: DocumentUri) -> Optional[SessionBufferProtocol]:
-        for sb in self.session_buffers_async():
-            if sb.get_uri() == uri:
-                return sb
-        return None
+        scheme, parsed = parse_uri(uri)
+        if scheme == "file":
+
+            def compare_by_samefile(sb: Optional[SessionBufferProtocol]) -> bool:
+                if not sb:
+                    return False
+                candidate = sb.get_uri()
+                if not isinstance(candidate, str):
+                    return False
+                candidate_isfile, candidate_path = parse_uri(candidate)
+                if not candidate_isfile:
+                    return False
+                return os.path.samefile(parsed, candidate_path)
+
+            predicate = compare_by_samefile
+        else:
+
+            def compare_by_string(sb: Optional[SessionBufferProtocol]) -> bool:
+                return sb.get_uri() == parsed if sb else False
+
+            predicate = compare_by_string
+        return next(filter(predicate, self.session_buffers_async()), None)
 
     # --- capability observers -----------------------------------------------------------------------------------------
 
diff --git a/plugin/core/url.py b/plugin/core/url.py
--- a/plugin/core/url.py
+++ b/plugin/core/url.py
@@ -1,4 +1,4 @@
-from .typing import Any
+from .typing import Any, Tuple
 from urllib.parse import quote
 from urllib.parse import urljoin
 from urllib.parse import urlparse
@@ -47,6 +47,21 @@ def uri_to_filename(uri: str) -> str:
         return url2pathname(parsed.path)
 
 
+def parse_uri(uri: str) -> Tuple[str, str]:
+    """
+    Parses an URI into a tuple where the first element is the URI scheme. The
+    second element is the local filesystem path if the URI is a file URI,
+    otherwise the second element is the original URI.
+    """
+    parsed = urlparse(uri)
+    if parsed.scheme == "file":
+        if os.name == 'nt':
+            # TODO: this is wrong for UNC paths
+            return parsed.scheme, url2pathname(parsed.path).strip('\\')
+        return parsed.scheme, url2pathname(parsed.path)
+    return parsed.scheme, uri
+
+
 def _to_resource_uri(path: str, prefix: str) -> str:
     """
     Terrible hacks from ST core leak into packages as well.


Test patch:
diff --git a/tests/test_session.py b/tests/test_session.py
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -1,17 +1,21 @@
 from LSP.plugin.core.collections import DottedDict
+from LSP.plugin.core.protocol import Diagnostic
 from LSP.plugin.core.protocol import Error
-from LSP.plugin.core.protocol import TextDocumentSyncKindFull, TextDocumentSyncKindNone, TextDocumentSyncKindIncremental
+from LSP.plugin.core.protocol import TextDocumentSyncKindFull
+from LSP.plugin.core.protocol import TextDocumentSyncKindIncremental
+from LSP.plugin.core.protocol import TextDocumentSyncKindNone
 from LSP.plugin.core.protocol import WorkspaceFolder
-from LSP.plugin.core.sessions import Logger
 from LSP.plugin.core.sessions import get_initialize_params
+from LSP.plugin.core.sessions import Logger
 from LSP.plugin.core.sessions import Manager
 from LSP.plugin.core.sessions import Session
 from LSP.plugin.core.types import ClientConfig
-from LSP.plugin.core.typing import Any, Optional, Generator
+from LSP.plugin.core.typing import Any, Optional, Generator, List, Dict
 from test_mocks import TEST_CONFIG
 import sublime
 import unittest
 import unittest.mock
+import weakref
 
 
 class MockManager(Manager):
@@ -68,6 +72,41 @@ def incoming_notification(self, method: str, params: Any, unhandled: bool) -> No
         pass
 
 
+class MockSessionBuffer:
+
+    def __init__(self, session: Session, mock_uri: str, mock_language_id: str) -> None:
+        self.session = session
+        self.session_views = weakref.WeakSet()
+        self.mock_uri = mock_uri
+        self.mock_language_id = mock_language_id
+
+    def get_uri(self) -> Optional[str]:
+        return self.mock_uri
+
+    def get_language_id(self) -> Optional[str]:
+        return self.mock_language_id
+
+    def register_capability_async(
+        self,
+        registration_id: str,
+        capability_path: str,
+        registration_path: str,
+        options: Dict[str, Any]
+    ) -> None:
+        pass
+
+    def unregister_capability_async(
+        self,
+        registration_id: str,
+        capability_path: str,
+        registration_path: str
+    ) -> None:
+        pass
+
+    def on_diagnostics_async(self, raw_diagnostics: List[Diagnostic], version: Optional[int]) -> None:
+        pass
+
+
 class SessionTest(unittest.TestCase):
 
     def test_experimental_capabilities(self) -> None:
@@ -197,3 +236,19 @@ def test_document_sync_capabilities(self) -> None:
         self.assertEqual(session.text_sync_kind(), TextDocumentSyncKindIncremental)
         self.assertFalse(session.should_notify_will_save())
         self.assertEqual(session.should_notify_did_save(), (False, False))
+
+    def test_get_session_buffer_for_uri_with_nonfiles(self) -> None:
+        manager = MockManager(sublime.active_window())
+        session = Session(manager=manager, logger=MockLogger(), workspace_folders=[], config=TEST_CONFIG,
+                          plugin_class=None)
+        original = MockSessionBuffer(session, "some-scheme://whatever", "somelang")
+        session.register_session_buffer_async(original)
+        sb = session.get_session_buffer_for_uri_async("some-scheme://whatever")
+        self.assertIsNotNone(sb)
+        assert sb
+        self.assertEqual(sb.get_language_id(), "somelang")
+        self.assertEqual(sb.get_uri(), "some-scheme://whatever")
+
+    def test_get_session_buffer_for_uri_with_files(self) -> None:
+        # todo: write windows-only test
+        pass