Skip to content

Commit 5e39439

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

4 files changed

Lines changed: 177 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: 123 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,124 @@ 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.lsp_main.get_collection", return_value=mock_collection),
398+
patch(
399+
"vectorcode.lsp_main.list_collection_files", return_value=dummy_files
400+
) as mock_list_collection_files,
401+
):
402+
mock_parse_cli_args.return_value = mock_config
403+
404+
mock_config.merge_from = AsyncMock(return_value=mock_config)
405+
406+
result = await execute_command(mock_language_server, ["files", "ls"])
407+
408+
assert result == dummy_files
409+
mock_language_server.progress.create_async.assert_called_once()
410+
411+
mock_list_collection_files.assert_called_once_with(mock_collection)
412+
# For 'ls' action, progress.begin/end are not explicitly called in the lsp_main,
413+
# but create_async is called before the match statement.
414+
mock_language_server.progress.begin.assert_not_called()
415+
mock_language_server.progress.end.assert_not_called()
416+
417+
418+
@pytest.mark.asyncio
419+
async def test_execute_command_files_rm(mock_language_server, mock_config: Config):
420+
mock_config.action = CliAction.files
421+
mock_config.files_action = FilesAction.rm
422+
mock_config.project_root = "/test/project"
423+
mock_config.rm_paths = ["file_to_remove.py", "another_file.txt"]
424+
425+
expanded_paths = [
426+
"/test/project/file_to_remove.py",
427+
"/test/project/another_file.txt",
428+
]
429+
mock_client = AsyncMock()
430+
mock_collection = AsyncMock()
431+
432+
with (
433+
patch(
434+
"vectorcode.lsp_main.parse_cli_args", new_callable=AsyncMock
435+
) as mock_parse_cli_args,
436+
patch(
437+
"vectorcode.lsp_main.ClientManager._create_client", return_value=mock_client
438+
),
439+
patch("vectorcode.lsp_main.get_collection", return_value=mock_collection),
440+
patch(
441+
"os.path.isfile",
442+
side_effect=lambda x: x in expanded_paths or x in mock_config.rm_paths,
443+
),
444+
patch(
445+
"vectorcode.lsp_main.expand_path",
446+
side_effect=lambda p, *args: os.path.join(mock_config.project_root, p),
447+
),
448+
):
449+
mock_parse_cli_args.return_value = mock_config
450+
451+
mock_config.merge_from = AsyncMock(return_value=mock_config)
452+
453+
await execute_command(
454+
mock_language_server,
455+
["files", "rm", "file_to_remove.py", "another_file.txt"],
456+
)
457+
458+
mock_collection.delete.assert_called_once_with(
459+
where={"path": {"$in": expanded_paths}}
460+
)
461+
462+
463+
@pytest.mark.asyncio
464+
async def test_execute_command_files_rm_no_files_to_remove(
465+
mock_language_server, mock_config: Config
466+
):
467+
mock_config.action = CliAction.files
468+
mock_config.files_action = FilesAction.rm
469+
mock_config.project_root = "/test/project"
470+
mock_config.rm_paths = ["non_existent_file.py"]
471+
472+
mock_client = AsyncMock()
473+
mock_collection = AsyncMock()
474+
475+
with (
476+
patch(
477+
"vectorcode.lsp_main.parse_cli_args", new_callable=AsyncMock
478+
) as mock_parse_cli_args,
479+
patch(
480+
"vectorcode.lsp_main.ClientManager._create_client", return_value=mock_client
481+
),
482+
patch("vectorcode.lsp_main.get_collection", return_value=mock_collection),
483+
patch("os.path.isfile", return_value=False),
484+
patch(
485+
"vectorcode.lsp_main.expand_path",
486+
side_effect=lambda p, *args: os.path.join(mock_config.project_root, p),
487+
),
488+
):
489+
mock_parse_cli_args.return_value = mock_config
490+
491+
mock_config.merge_from = AsyncMock(return_value=mock_config)
492+
493+
result = await execute_command(
494+
mock_language_server, ["files", "rm", "non_existent_file.py"]
495+
)
496+
497+
assert result is None
498+
mock_collection.delete.assert_not_called()

0 commit comments

Comments
 (0)