Skip to content

Commit 0b38c8e

Browse files
Copilotedvilme
andauthored
Add unit tests for get_cwd() variable substitution (#262)
* Initial plan * Expand get_cwd() to resolve all file-related VS Code variables Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> * Add unit tests for get_cwd() variable substitutions Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> * Inline LSP mock setup into test_get_cwd.py and remove conftest.py Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com>
1 parent 49de257 commit 0b38c8e

2 files changed

Lines changed: 257 additions & 10 deletions

File tree

bundled/tool/lsp_server.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -292,17 +292,57 @@ def on_shutdown(_params: Optional[Any] = None) -> None:
292292
def get_cwd(settings: dict, document: Optional[workspace.Document]) -> str:
293293
"""Returns the working directory for running the tool.
294294
295-
Resolves ``${fileDirname}`` to the directory of the current document.
296-
If no document is available, falls back to the workspace root.
297-
298-
Examples of supported patterns: ``${fileDirname}``, ``${fileDirname}/subdir``.
295+
Resolves the following VS Code file-related variable substitutions when
296+
a document is available:
297+
298+
- ``${file}`` – absolute path of the current document.
299+
- ``${fileBasename}`` – file name with extension (e.g. ``foo.py``).
300+
- ``${fileBasenameNoExtension}`` – file name without extension (e.g. ``foo``).
301+
- ``${fileExtname}`` – file extension including the dot (e.g. ``.py``).
302+
- ``${fileDirname}`` – directory containing the current document.
303+
- ``${fileDirnameBasename}`` – name of the directory containing the document.
304+
- ``${relativeFile}`` – document path relative to the workspace root.
305+
- ``${relativeFileDirname}`` – document directory relative to the workspace root.
306+
- ``${fileWorkspaceFolder}`` – workspace root folder for the document.
307+
308+
Variables that do not depend on the document (``${workspaceFolder}``,
309+
``${userHome}``, ``${cwd}``) are pre-resolved by the TypeScript client.
310+
311+
If no document is available and the value contains any unresolvable
312+
file-variable, the workspace root is returned as a fallback.
313+
314+
See https://code.visualstudio.com/docs/reference/variables-reference
299315
"""
300316
cwd = settings.get("cwd", settings["workspaceFS"])
301-
if "${fileDirname}" in cwd:
302-
if document and document.path:
303-
cwd = cwd.replace("${fileDirname}", os.path.dirname(document.path))
304-
else:
305-
cwd = settings["workspaceFS"]
317+
318+
workspace_fs = settings["workspaceFS"]
319+
320+
if document and document.path:
321+
file_path = document.path
322+
file_dir = os.path.dirname(file_path)
323+
file_basename = os.path.basename(file_path)
324+
file_stem, file_ext = os.path.splitext(file_basename)
325+
326+
substitutions = {
327+
"${file}": file_path,
328+
"${fileBasename}": file_basename,
329+
"${fileBasenameNoExtension}": file_stem,
330+
"${fileExtname}": file_ext,
331+
"${fileDirname}": file_dir,
332+
"${fileDirnameBasename}": os.path.basename(file_dir),
333+
"${relativeFile}": os.path.relpath(file_path, workspace_fs),
334+
"${relativeFileDirname}": os.path.relpath(file_dir, workspace_fs),
335+
"${fileWorkspaceFolder}": workspace_fs,
336+
}
337+
338+
for token, value in substitutions.items():
339+
cwd = cwd.replace(token, value)
340+
else:
341+
# Without a document we cannot resolve file-related variables.
342+
# Fall back to workspace root if any remain.
343+
if "${file" in cwd or "${relativeFile" in cwd:
344+
cwd = workspace_fs
345+
306346
return cwd
307347

308348

@@ -410,7 +450,7 @@ def _run_tool_on_document(
410450
settings = copy.deepcopy(_get_settings_by_document(document))
411451

412452
code_workspace = settings["workspaceFS"]
413-
# Pass document so get_cwd can resolve ${fileDirname} to this file's directory.
453+
# Pass document so get_cwd can resolve file-related variables for this document.
414454
cwd = get_cwd(settings, document)
415455

416456
use_path = False
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
"""Unit tests for the get_cwd() helper in lsp_server."""
4+
import os
5+
import pathlib
6+
import sys
7+
import types
8+
9+
# ---------------------------------------------------------------------------
10+
# Stub out bundled LSP dependencies so lsp_server can be imported without the
11+
# full VS Code extension environment.
12+
# ---------------------------------------------------------------------------
13+
def _setup_mocks():
14+
class _MockLS:
15+
def __init__(self, **kwargs):
16+
pass
17+
18+
def feature(self, *args, **kwargs):
19+
return lambda f: f
20+
21+
def command(self, *args, **kwargs):
22+
return lambda f: f
23+
24+
def show_message_log(self, *args, **kwargs):
25+
pass
26+
27+
def show_message(self, *args, **kwargs):
28+
pass
29+
30+
mock_server = types.ModuleType("pygls.server")
31+
mock_server.LanguageServer = _MockLS
32+
33+
mock_workspace = types.ModuleType("pygls.workspace")
34+
mock_workspace.Document = type("Document", (), {"path": None})
35+
36+
mock_uris = types.ModuleType("pygls.uris")
37+
mock_uris.from_fs_path = lambda p: "file://" + p
38+
39+
mock_lsp = types.ModuleType("lsprotocol.types")
40+
for _name in [
41+
"TEXT_DOCUMENT_DID_OPEN", "TEXT_DOCUMENT_DID_SAVE", "TEXT_DOCUMENT_DID_CLOSE",
42+
"TEXT_DOCUMENT_FORMATTING", "INITIALIZE", "EXIT", "SHUTDOWN",
43+
]:
44+
setattr(mock_lsp, _name, _name)
45+
for _name in [
46+
"Diagnostic", "DiagnosticSeverity", "DidCloseTextDocumentParams",
47+
"DidOpenTextDocumentParams", "DidSaveTextDocumentParams",
48+
"DocumentFormattingParams", "InitializeParams", "Position", "Range", "TextEdit",
49+
]:
50+
setattr(mock_lsp, _name, type(_name, (), {}))
51+
mock_lsp.MessageType = type("MessageType", (), {"Log": 4, "Error": 1, "Warning": 2, "Info": 3})
52+
53+
for _mod_name, _mod in [
54+
("pygls", types.ModuleType("pygls")),
55+
("pygls.server", mock_server),
56+
("pygls.workspace", mock_workspace),
57+
("pygls.uris", mock_uris),
58+
("lsprotocol", types.ModuleType("lsprotocol")),
59+
("lsprotocol.types", mock_lsp),
60+
("lsp_jsonrpc", types.ModuleType("lsp_jsonrpc")),
61+
("lsp_utils", types.ModuleType("lsp_utils")),
62+
]:
63+
if _mod_name not in sys.modules:
64+
sys.modules[_mod_name] = _mod
65+
66+
tool_dir = str(pathlib.Path(__file__).parents[3] / "bundled" / "tool")
67+
if tool_dir not in sys.path:
68+
sys.path.insert(0, tool_dir)
69+
70+
71+
_setup_mocks()
72+
73+
import lsp_server # noqa: E402
74+
75+
WORKSPACE = "/home/user/myproject"
76+
77+
78+
def _make_settings(cwd=None):
79+
s = {"workspaceFS": WORKSPACE}
80+
if cwd is not None:
81+
s["cwd"] = cwd
82+
return s
83+
84+
85+
def _make_doc(path):
86+
doc = types.SimpleNamespace(path=path)
87+
return doc
88+
89+
90+
# ---------------------------------------------------------------------------
91+
# No-document (fallback) cases
92+
# ---------------------------------------------------------------------------
93+
94+
95+
def test_no_cwd_no_document_returns_workspace():
96+
"""When neither cwd nor document is provided, return workspaceFS."""
97+
settings = _make_settings()
98+
assert lsp_server.get_cwd(settings, None) == WORKSPACE
99+
100+
101+
def test_plain_cwd_no_document_returned_unchanged():
102+
"""A cwd without variables is returned as-is even without a document."""
103+
settings = _make_settings(cwd="/custom/path")
104+
assert lsp_server.get_cwd(settings, None) == "/custom/path"
105+
106+
107+
def test_file_variable_no_document_falls_back_to_workspace():
108+
"""Unresolvable ${file*} variable with no document falls back to workspaceFS."""
109+
for token in [
110+
"${file}",
111+
"${fileBasename}",
112+
"${fileBasenameNoExtension}",
113+
"${fileExtname}",
114+
"${fileDirname}",
115+
"${fileDirnameBasename}",
116+
"${fileWorkspaceFolder}",
117+
]:
118+
settings = _make_settings(cwd=token + "/extra")
119+
assert lsp_server.get_cwd(settings, None) == WORKSPACE, f"Failed for {token}"
120+
121+
122+
def test_relative_file_variable_no_document_falls_back_to_workspace():
123+
"""Unresolvable ${relativeFile*} variable with no document falls back to workspaceFS."""
124+
for token in ["${relativeFile}", "${relativeFileDirname}"]:
125+
settings = _make_settings(cwd=token)
126+
assert lsp_server.get_cwd(settings, None) == WORKSPACE, f"Failed for {token}"
127+
128+
129+
# ---------------------------------------------------------------------------
130+
# With document
131+
# ---------------------------------------------------------------------------
132+
133+
DOC_PATH = "/home/user/myproject/src/foo.py"
134+
DOC = _make_doc(DOC_PATH)
135+
136+
137+
def test_file_resolved():
138+
settings = _make_settings(cwd="${file}")
139+
assert lsp_server.get_cwd(settings, DOC) == DOC_PATH
140+
141+
142+
def test_file_basename_resolved():
143+
settings = _make_settings(cwd="${fileBasename}")
144+
assert lsp_server.get_cwd(settings, DOC) == "foo.py"
145+
146+
147+
def test_file_basename_no_extension_resolved():
148+
settings = _make_settings(cwd="${fileBasenameNoExtension}")
149+
assert lsp_server.get_cwd(settings, DOC) == "foo"
150+
151+
152+
def test_file_extname_resolved():
153+
settings = _make_settings(cwd="${fileExtname}")
154+
assert lsp_server.get_cwd(settings, DOC) == ".py"
155+
156+
157+
def test_file_dirname_resolved():
158+
settings = _make_settings(cwd="${fileDirname}")
159+
assert lsp_server.get_cwd(settings, DOC) == "/home/user/myproject/src"
160+
161+
162+
def test_file_dirname_basename_resolved():
163+
settings = _make_settings(cwd="${fileDirnameBasename}")
164+
assert lsp_server.get_cwd(settings, DOC) == "src"
165+
166+
167+
def test_relative_file_resolved():
168+
settings = _make_settings(cwd="${relativeFile}")
169+
assert lsp_server.get_cwd(settings, DOC) == os.path.relpath(DOC_PATH, WORKSPACE)
170+
171+
172+
def test_relative_file_dirname_resolved():
173+
settings = _make_settings(cwd="${relativeFileDirname}")
174+
assert lsp_server.get_cwd(settings, DOC) == os.path.relpath(
175+
"/home/user/myproject/src", WORKSPACE
176+
)
177+
178+
179+
def test_file_workspace_folder_resolved():
180+
settings = _make_settings(cwd="${fileWorkspaceFolder}")
181+
assert lsp_server.get_cwd(settings, DOC) == WORKSPACE
182+
183+
184+
def test_composite_pattern_resolved():
185+
"""Variables embedded inside a longer path are substituted correctly."""
186+
settings = _make_settings(cwd="${fileDirname}/subdir")
187+
assert lsp_server.get_cwd(settings, DOC) == "/home/user/myproject/src/subdir"
188+
189+
190+
def test_multiple_variables_in_one_cwd():
191+
"""Multiple different variables in the same cwd string are all resolved."""
192+
settings = _make_settings(cwd="${fileDirname}/${fileBasename}")
193+
result = lsp_server.get_cwd(settings, DOC)
194+
assert result == "/home/user/myproject/src/foo.py"
195+
196+
197+
def test_no_variable_in_cwd_unchanged():
198+
"""A cwd with no variables is returned unchanged even when a document exists."""
199+
settings = _make_settings(cwd="/static/path")
200+
assert lsp_server.get_cwd(settings, DOC) == "/static/path"
201+
202+
203+
def test_document_with_no_path_falls_back_to_workspace():
204+
"""A document object whose path is falsy triggers the fallback."""
205+
doc = types.SimpleNamespace(path="")
206+
settings = _make_settings(cwd="${fileDirname}")
207+
assert lsp_server.get_cwd(settings, doc) == WORKSPACE

0 commit comments

Comments
 (0)