Skip to content

Commit dfa7291

Browse files
author
Zhe Yu
committed
feat(cli): add files subcommand to the LSP server
1 parent 7010307 commit dfa7291

4 files changed

Lines changed: 180 additions & 24 deletions

File tree

src/vectorcode/common.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -229,13 +229,15 @@ def verify_ef(collection: AsyncCollection, configs: Config):
229229

230230

231231
async def list_collection_files(collection: AsyncCollection) -> list[str]:
232-
return list(
233-
set(
234-
str(c.get("path", None))
235-
for c in (await collection.get(include=[IncludeEnum.metadatas])).get(
236-
"metadatas"
232+
return sorted(
233+
list(
234+
set(
235+
str(c.get("path", None))
236+
for c in (await collection.get(include=[IncludeEnum.metadatas])).get(
237+
"metadatas"
238+
)
239+
or []
237240
)
238-
or []
239241
)
240242
)
241243

src/vectorcode/lsp_main.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
import time
77
import traceback
88
import uuid
9+
from typing import cast
910

1011
import shtab
12+
from chromadb.types import Where
1113

1214
from vectorcode.subcommands.vectorise import (
1315
VectoriseStats,
@@ -35,14 +37,16 @@
3537
from vectorcode import __version__
3638
from vectorcode.cli_utils import (
3739
CliAction,
40+
FilesAction,
3841
cleanup_path,
3942
config_logging,
4043
expand_globs,
44+
expand_path,
4145
find_project_root,
4246
get_project_config,
4347
parse_cli_args,
4448
)
45-
from vectorcode.common import ClientManager, get_collection
49+
from vectorcode.common import ClientManager, get_collection, list_collection_files
4650
from vectorcode.subcommands.ls import get_collection_list
4751
from vectorcode.subcommands.query import build_query_results
4852

@@ -105,7 +109,11 @@ async def execute_command(ls: LanguageServer, args: list[str]):
105109
async with ClientManager().get_client(final_configs) as client:
106110
progress_token = str(uuid.uuid4())
107111

108-
if final_configs.action in {CliAction.vectorise, CliAction.query}:
112+
if final_configs.action in {
113+
CliAction.vectorise,
114+
CliAction.query,
115+
CliAction.files,
116+
}:
109117
collection = await get_collection(
110118
client=client,
111119
configs=final_configs,
@@ -222,6 +230,40 @@ async def execute_command(ls: LanguageServer, args: list[str]):
222230
),
223231
)
224232
return stats.to_dict()
233+
case CliAction.files:
234+
if collection is None:
235+
return
236+
match final_configs.files_action:
237+
case FilesAction.ls:
238+
return await list_collection_files(collection)
239+
case FilesAction.rm:
240+
to_be_removed = list(
241+
str(expand_path(p, True))
242+
for p in final_configs.rm_paths
243+
if os.path.isfile(p)
244+
)
245+
if len(to_be_removed) == 0:
246+
return
247+
ls.progress.begin(
248+
progress_token,
249+
types.WorkDoneProgressBegin(
250+
title="VectorCode",
251+
message=f"Removing {len(to_be_removed)} file(s).",
252+
),
253+
)
254+
await collection.delete(
255+
where=cast(
256+
Where,
257+
{"path": {"$in": to_be_removed}},
258+
)
259+
)
260+
ls.progress.begin(
261+
progress_token,
262+
types.WorkDoneProgressBegin(
263+
title="VectorCode",
264+
message="Removal finished.",
265+
),
266+
)
225267
case _ as c: # pragma: nocover
226268
error_message = f"Unsupported vectorcode subcommand: {str(c)}"
227269
logger.error(

src/vectorcode/subcommands/files/ls.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,20 @@
11
import json
22
import logging
33

4-
from chromadb.api.models.AsyncCollection import AsyncCollection
5-
from chromadb.api.types import IncludeEnum
6-
74
from vectorcode.cli_utils import Config
8-
from vectorcode.common import ClientManager, get_collection
5+
from vectorcode.common import ClientManager, get_collection, list_collection_files
96

107
logger = logging.getLogger(name=__name__)
118

129

13-
async def list_files(collection: AsyncCollection) -> list[str]:
14-
meta = (await collection.get(include=[IncludeEnum.metadatas])).get("metadatas")
15-
if meta is None:
16-
logger.warning("Failed to fetch metadatas from the database.")
17-
return []
18-
paths: list[str] = list(set(str(m.get("path")) for m in meta))
19-
paths.sort()
20-
return paths
21-
22-
2310
async def ls(configs: Config) -> int:
2411
async with ClientManager().get_client(configs=configs) as client:
2512
try:
2613
collection = await get_collection(client, configs, False)
2714
except ValueError:
2815
logger.error(f"There's no existing collection at {configs.project_root}.")
2916
return 1
30-
paths = await list_files(collection)
17+
paths = await list_collection_files(collection)
3118
if configs.pipe:
3219
print(json.dumps(list(paths)))
3320
else:

tests/test_lsp.py

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from contextlib import asynccontextmanager
23
from unittest.mock import AsyncMock, MagicMock, patch
34

@@ -6,7 +7,7 @@
67
from pygls.server import LanguageServer
78

89
from vectorcode import __version__
9-
from vectorcode.cli_utils import CliAction, Config, QueryInclude
10+
from vectorcode.cli_utils import CliAction, Config, FilesAction, QueryInclude
1011
from vectorcode.lsp_main import (
1112
execute_command,
1213
lsp_start,
@@ -374,3 +375,127 @@ async def test_execute_command_no_default_project_root(
374375
with pytest.raises((AssertionError, JsonRpcInternalError)):
375376
await execute_command(mock_language_server, ["query", "test"])
376377
DEFAULT_PROJECT_ROOT = None # Reset the global variable
378+
379+
380+
@pytest.mark.asyncio
381+
async def test_execute_command_files_ls(mock_language_server, mock_config: Config):
382+
mock_config.action = CliAction.files
383+
mock_config.files_action = FilesAction.ls
384+
mock_config.project_root = "/test/project"
385+
386+
dummy_files = ["/test/project/file1.py", "/test/project/file2.txt"]
387+
mock_client = AsyncMock()
388+
mock_collection = AsyncMock()
389+
390+
with (
391+
patch(
392+
"vectorcode.lsp_main.parse_cli_args", new_callable=AsyncMock
393+
) as mock_parse_cli_args,
394+
patch(
395+
"vectorcode.lsp_main.ClientManager._create_client", return_value=mock_client
396+
),
397+
patch("vectorcode.common.try_server", return_value=True),
398+
patch("vectorcode.lsp_main.get_collection", return_value=mock_collection),
399+
patch(
400+
"vectorcode.lsp_main.list_collection_files", return_value=dummy_files
401+
) as mock_list_collection_files,
402+
):
403+
mock_parse_cli_args.return_value = mock_config
404+
405+
mock_config.merge_from = AsyncMock(return_value=mock_config)
406+
407+
result = await execute_command(mock_language_server, ["files", "ls"])
408+
409+
assert result == dummy_files
410+
mock_language_server.progress.create_async.assert_called_once()
411+
412+
mock_list_collection_files.assert_called_once_with(mock_collection)
413+
# For 'ls' action, progress.begin/end are not explicitly called in the lsp_main,
414+
# but create_async is called before the match statement.
415+
mock_language_server.progress.begin.assert_not_called()
416+
mock_language_server.progress.end.assert_not_called()
417+
418+
419+
@pytest.mark.asyncio
420+
async def test_execute_command_files_rm(mock_language_server, mock_config: Config):
421+
mock_config.action = CliAction.files
422+
mock_config.files_action = FilesAction.rm
423+
mock_config.project_root = "/test/project"
424+
mock_config.rm_paths = ["file_to_remove.py", "another_file.txt"]
425+
426+
expanded_paths = [
427+
"/test/project/file_to_remove.py",
428+
"/test/project/another_file.txt",
429+
]
430+
mock_client = AsyncMock()
431+
mock_collection = AsyncMock()
432+
433+
with (
434+
patch(
435+
"vectorcode.lsp_main.parse_cli_args", new_callable=AsyncMock
436+
) as mock_parse_cli_args,
437+
patch(
438+
"vectorcode.lsp_main.ClientManager._create_client", return_value=mock_client
439+
),
440+
patch("vectorcode.common.try_server", return_value=True),
441+
patch("vectorcode.lsp_main.get_collection", return_value=mock_collection),
442+
patch(
443+
"os.path.isfile",
444+
side_effect=lambda x: x in expanded_paths or x in mock_config.rm_paths,
445+
),
446+
patch(
447+
"vectorcode.lsp_main.expand_path",
448+
side_effect=lambda p, *args: os.path.join(mock_config.project_root, p),
449+
),
450+
):
451+
mock_parse_cli_args.return_value = mock_config
452+
453+
mock_config.merge_from = AsyncMock(return_value=mock_config)
454+
455+
await execute_command(
456+
mock_language_server,
457+
["files", "rm", "file_to_remove.py", "another_file.txt"],
458+
)
459+
460+
mock_collection.delete.assert_called_once_with(
461+
where={"path": {"$in": expanded_paths}}
462+
)
463+
464+
465+
@pytest.mark.asyncio
466+
async def test_execute_command_files_rm_no_files_to_remove(
467+
mock_language_server, mock_config: Config
468+
):
469+
mock_config.action = CliAction.files
470+
mock_config.files_action = FilesAction.rm
471+
mock_config.project_root = "/test/project"
472+
mock_config.rm_paths = ["non_existent_file.py"]
473+
474+
mock_client = AsyncMock()
475+
mock_collection = AsyncMock()
476+
477+
with (
478+
patch(
479+
"vectorcode.lsp_main.parse_cli_args", new_callable=AsyncMock
480+
) as mock_parse_cli_args,
481+
patch(
482+
"vectorcode.lsp_main.ClientManager._create_client", return_value=mock_client
483+
),
484+
patch("vectorcode.common.try_server", return_value=True),
485+
patch("vectorcode.lsp_main.get_collection", return_value=mock_collection),
486+
patch("os.path.isfile", return_value=False),
487+
patch(
488+
"vectorcode.lsp_main.expand_path",
489+
side_effect=lambda p, *args: os.path.join(mock_config.project_root, p),
490+
),
491+
):
492+
mock_parse_cli_args.return_value = mock_config
493+
494+
mock_config.merge_from = AsyncMock(return_value=mock_config)
495+
496+
result = await execute_command(
497+
mock_language_server, ["files", "rm", "non_existent_file.py"]
498+
)
499+
500+
assert result is None
501+
mock_collection.delete.assert_not_called()

0 commit comments

Comments
 (0)