Skip to content

Commit 60a322a

Browse files
author
Zhe Yu
committed
feat(cli): add vectorise subcommand to LSP server
1 parent a805099 commit 60a322a

4 files changed

Lines changed: 215 additions & 27 deletions

File tree

docs/cli.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -690,9 +690,8 @@ Note that:
690690
1. For easier parsing, `--pipe` is assumed to be enabled in LSP mode;
691691
2. At the time this only work with vectorcode setup that uses a **standalone
692692
ChromaDB server**, which is not difficult to setup using docker;
693-
3. At the time this only work with `query` subcommand. I will consider adding
694-
support for other subcommand but first I need to figure out how to properly
695-
manage `project_root` across different requests if they change.
693+
3. The LSP server supports `vectorise`, `query` and `ls` subcommands. The other
694+
subcommands may be added in the future.
696695

697696
### MCP Server
698697

src/vectorcode/lsp_main.py

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99

1010
import shtab
1111

12+
from vectorcode.subcommands.vectorise import (
13+
chunked_add,
14+
exclude_paths_by_spec,
15+
find_exclude_specs,
16+
load_files_from_include,
17+
)
18+
1219
try: # pragma: nocover
1320
from lsprotocol import types
1421
from pygls.exceptions import (
@@ -29,6 +36,7 @@
2936
Config,
3037
cleanup_path,
3138
config_logging,
39+
expand_globs,
3240
find_project_root,
3341
get_project_config,
3442
parse_cli_args,
@@ -86,14 +94,6 @@ async def execute_command(ls: LanguageServer, args: list[str]):
8694
logger.info("Received command arguments: %s", args)
8795
parsed_args = await parse_cli_args(args)
8896
logger.info("Parsed command arguments: %s", parsed_args)
89-
if parsed_args.action not in {CliAction.query, CliAction.ls}:
90-
error_message = (
91-
f"Unsupported vectorcode subcommand: {str(parsed_args.action)}"
92-
)
93-
logger.error(
94-
error_message,
95-
)
96-
raise JsonRpcInvalidRequest(error_message)
9797
if parsed_args.project_root is None:
9898
if DEFAULT_PROJECT_ROOT is not None:
9999
parsed_args.project_root = DEFAULT_PROJECT_ROOT
@@ -168,6 +168,67 @@ async def execute_command(ls: LanguageServer, args: list[str]):
168168
)
169169
logger.info(f"Retrieved {len(projects)} project(s).")
170170
return projects
171+
case CliAction.vectorise:
172+
if collection is None:
173+
raise ValueError("Failed to find the correct collection.")
174+
ls.progress.begin(
175+
progress_token,
176+
types.WorkDoneProgressBegin(
177+
title="VectorCode", message="Vectorising files...", percentage=0
178+
),
179+
)
180+
files = await expand_globs(
181+
final_configs.files
182+
or load_files_from_include(str(final_configs.project_root)),
183+
recursive=final_configs.recursive,
184+
include_hidden=final_configs.include_hidden,
185+
)
186+
if not final_configs.force:
187+
for spec in find_exclude_specs(final_configs):
188+
if os.path.isfile(spec):
189+
logger.info(f"Loading ignore specs from {spec}.")
190+
files = exclude_paths_by_spec((str(i) for i in files), spec)
191+
stats = {"add": 0, "update": 0, "removed": 0}
192+
collection_lock = asyncio.Lock()
193+
stats_lock = asyncio.Lock()
194+
max_batch_size = await client.get_max_batch_size()
195+
semaphore = asyncio.Semaphore(os.cpu_count() or 1)
196+
tasks = [
197+
asyncio.create_task(
198+
chunked_add(
199+
str(file),
200+
collection,
201+
collection_lock,
202+
stats,
203+
stats_lock,
204+
final_configs,
205+
max_batch_size,
206+
semaphore,
207+
)
208+
)
209+
for file in files
210+
]
211+
for i, task in enumerate(asyncio.as_completed(tasks), start=1):
212+
await task
213+
ls.progress.report(
214+
progress_token,
215+
types.WorkDoneProgressReport(
216+
message="Vectorising files...",
217+
percentage=int(100 * i / len(tasks)),
218+
),
219+
)
220+
ls.progress.end(
221+
progress_token,
222+
types.WorkDoneProgressEnd(
223+
message=f"Vectorised {stats['add'] + stats['update']} files."
224+
),
225+
)
226+
case _ as c:
227+
error_message = f"Unsupported vectorcode subcommand: {str(c)}"
228+
logger.error(
229+
error_message,
230+
)
231+
raise JsonRpcInvalidRequest(error_message)
171232
except Exception as e:
172233
if isinstance(e, JsonRpcException):
173234
# pygls exception. raise it as is.

src/vectorcode/subcommands/vectorise.py

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,15 @@ def show_stats(configs: Config, stats):
141141
)
142142

143143

144-
def exclude_paths_by_spec(paths: Iterable[str], specs: pathspec.PathSpec) -> list[str]:
144+
def exclude_paths_by_spec(
145+
paths: Iterable[str], specs: pathspec.PathSpec | str
146+
) -> list[str]:
145147
"""
146148
Files matched by the specs will be excluded.
147149
"""
150+
if isinstance(specs, str):
151+
with open(specs) as fin:
152+
specs = pathspec.GitIgnoreSpec.from_lines(fin.readlines())
148153
return [path for path in paths if not specs.match_file(path)]
149154

150155

@@ -180,6 +185,25 @@ def load_files_from_include(project_root: str) -> list[str]:
180185
return []
181186

182187

188+
def find_exclude_specs(configs: Config) -> list[str]:
189+
"""
190+
Load a list of paths to exclude specs.
191+
Can be `.gitignore` or local/global `vectorcode.exclude`
192+
"""
193+
gitignore_path = os.path.join(str(configs.project_root), ".gitignore")
194+
specs = [
195+
gitignore_path,
196+
]
197+
exclude_spec_path = os.path.join(
198+
str(configs.project_root), ".vectorcode", "vectorcode.exclude"
199+
)
200+
if os.path.isfile(exclude_spec_path):
201+
specs.append(exclude_spec_path)
202+
elif os.path.isfile(GLOBAL_EXCLUDE_SPEC):
203+
specs.append(GLOBAL_EXCLUDE_SPEC)
204+
return specs
205+
206+
183207
async def vectorise(configs: Config) -> int:
184208
assert configs.project_root is not None
185209
client = await get_client(configs)
@@ -198,23 +222,10 @@ async def vectorise(configs: Config) -> int:
198222
)
199223

200224
if not configs.force:
201-
gitignore_path = os.path.join(str(configs.project_root), ".gitignore")
202-
specs = [
203-
gitignore_path,
204-
]
205-
exclude_spec_path = os.path.join(
206-
configs.project_root, ".vectorcode", "vectorcode.exclude"
207-
)
208-
if os.path.isfile(exclude_spec_path):
209-
specs.append(exclude_spec_path)
210-
elif os.path.isfile(GLOBAL_EXCLUDE_SPEC):
211-
specs.append(GLOBAL_EXCLUDE_SPEC)
212-
for spec_path in specs:
225+
for spec_path in find_exclude_specs(configs):
213226
if os.path.isfile(spec_path):
214227
logger.info(f"Loading ignore specs from {spec_path}.")
215-
with open(spec_path) as fin:
216-
spec = pathspec.GitIgnoreSpec.from_lines(fin.readlines())
217-
files = exclude_paths_by_spec((str(i) for i in files), spec)
228+
files = exclude_paths_by_spec((str(i) for i in files), spec_path)
218229
else: # pragma: nocover
219230
logger.info("Ignoring exclude specs.")
220231

tests/test_lsp.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,123 @@ async def test_execute_command_ls(mock_language_server, mock_config):
217217
mock_language_server.progress.end.assert_called()
218218

219219

220+
@pytest.mark.asyncio
221+
async def test_execute_command_vectorise(mock_language_server, mock_config: Config):
222+
mock_config.action = CliAction.vectorise # Set action to vectorise
223+
mock_config.project_root = "/test/project" # Ensure project_root is set
224+
mock_config.files = None # Simulate no files explicitly passed, so load_files_from_include is called
225+
mock_config.recursive = True
226+
mock_config.include_hidden = False
227+
mock_config.force = False # To test exclude_paths_by_spec path
228+
229+
# Files that load_files_from_include will return and expand_globs will process
230+
dummy_initial_files = ["file_a.py", "file_b.txt"]
231+
# Files after expand_globs
232+
dummy_expanded_files = ["/test/project/file_a.py", "/test/project/file_b.txt"]
233+
234+
# Mock dependencies
235+
with (
236+
patch(
237+
"vectorcode.lsp_main.parse_cli_args", new_callable=AsyncMock
238+
) as mock_parse_cli_args,
239+
patch(
240+
"vectorcode.lsp_main.get_client", new_callable=AsyncMock
241+
) as mock_get_client,
242+
patch(
243+
"vectorcode.lsp_main.get_collection", new_callable=AsyncMock
244+
) as mock_get_collection,
245+
patch(
246+
"vectorcode.lsp_main.expand_globs", new_callable=AsyncMock
247+
) as mock_expand_globs,
248+
patch(
249+
"vectorcode.lsp_main.find_exclude_specs", return_value=[]
250+
) as mock_find_exclude_specs,
251+
patch(
252+
"vectorcode.lsp_main.exclude_paths_by_spec",
253+
side_effect=lambda files, spec: files,
254+
) as mock_exclude_paths_by_spec,
255+
patch(
256+
"vectorcode.lsp_main.chunked_add", new_callable=AsyncMock
257+
) as mock_chunked_add,
258+
patch("vectorcode.lsp_main.try_server", return_value=True),
259+
patch("vectorcode.lsp_main.cached_project_configs", {}),
260+
patch(
261+
"vectorcode.lsp_main.load_files_from_include",
262+
return_value=dummy_initial_files,
263+
) as mock_load_files_from_include,
264+
patch("os.cpu_count", return_value=1), # For asyncio.Semaphore
265+
patch(
266+
"vectorcode.lsp_main.make_caches", new_callable=AsyncMock
267+
), # Mock make_caches to avoid actual file system ops
268+
):
269+
from unittest.mock import ANY
270+
271+
from lsprotocol import types
272+
273+
from vectorcode.lsp_main import cached_project_configs
274+
275+
cached_project_configs.clear()
276+
cached_project_configs["/test/project"] = mock_config # Add config to cache
277+
278+
# Set return values for mocks
279+
mock_parse_cli_args.return_value = mock_config
280+
mock_client = AsyncMock()
281+
mock_get_client.return_value = mock_client
282+
mock_collection = MagicMock()
283+
mock_get_collection.return_value = mock_collection
284+
mock_client.get_max_batch_size.return_value = 100 # Mock batch size
285+
286+
mock_expand_globs.return_value = (
287+
dummy_expanded_files # What expand_globs should return
288+
)
289+
290+
# Mock merge_from as it's called
291+
mock_config.merge_from = AsyncMock(return_value=mock_config)
292+
293+
# Execute the command
294+
await execute_command(mock_language_server, ["vectorise", "/test/project"])
295+
296+
# Assertions
297+
mock_language_server.progress.create_async.assert_called_once()
298+
mock_language_server.progress.begin.assert_called_once_with(
299+
ANY, # progress_token
300+
types.WorkDoneProgressBegin(
301+
title="VectorCode", message="Vectorising files...", percentage=0
302+
),
303+
)
304+
305+
mock_load_files_from_include.assert_called_once_with(
306+
str(mock_config.project_root)
307+
)
308+
mock_expand_globs.assert_called_once_with(
309+
dummy_initial_files, # Should be the result of load_files_from_include
310+
recursive=mock_config.recursive,
311+
include_hidden=mock_config.include_hidden,
312+
)
313+
mock_find_exclude_specs.assert_called_once_with(mock_config)
314+
mock_exclude_paths_by_spec.assert_not_called() # Because mock_find_exclude_specs returns empty list (no specs to exclude by)
315+
mock_client.get_max_batch_size.assert_called_once()
316+
317+
# Check chunked_add calls
318+
assert mock_chunked_add.call_count == len(dummy_expanded_files)
319+
for file_path in dummy_expanded_files:
320+
mock_chunked_add.assert_any_call(
321+
file_path,
322+
mock_collection,
323+
ANY, # asyncio.Lock object
324+
ANY, # stats dict
325+
ANY, # stats_lock
326+
mock_config,
327+
100, # max_batch_size
328+
ANY, # semaphore
329+
)
330+
# Check progress report calls
331+
assert mock_language_server.progress.report.call_count == len(
332+
dummy_expanded_files
333+
)
334+
mock_language_server.progress.end.assert_called_once()
335+
336+
220337
@pytest.mark.asyncio
221338
async def test_execute_command_unsupported_action(
222339
mock_language_server, mock_config, capsys

0 commit comments

Comments
 (0)