Skip to content

Commit 86d1169

Browse files
observerwCopilot
andauthored
feat: support directory outline (#35)
* feat: support directory outline * Address code review feedback for directory outline feature (#36) * Initial plan * fix: address code review feedback for directory outline - Remove filesystem access from schema validator - Add path validation and scope check in capability handler - Add error handling for failed LSP symbol requests in directory mode - Add validation for mutually exclusive OutlineResponse fields - Add semaphore to limit concurrent directory file processing - Exclude common directories (node_modules, .git, __pycache__, etc.) - Use string-based sorting for cross-platform path consistency - Improve OutlineFileItem docstring - Add test for scope+directory validation - Add test verifying non-code files are excluded - Add is_directory assertions to directory tests Co-authored-by: observerw <20661574+observerw@users.noreply.github.com> * refactor: improve error handling and move constant to module level - Move EXCLUDED_DIRS to module-level constant (_EXCLUDED_DIRS) for better performance - Add logging for failed file processing in directory mode - Import loguru logger for debug logging Co-authored-by: observerw <20661574+observerw@users.noreply.github.com> * refactor: improve exception handling and imports - Use specific exceptions (OSError, PermissionError) instead of broad Exception catch - Use logger format strings instead of f-strings for better performance - Move tempfile import to top level in tests Co-authored-by: observerw <20661574+observerw@users.noreply.github.com> * perf: improve glob pattern and directory exclusion check - Fix glob pattern to use `*.{suffix}` instead of `*{suffix}` for proper extension matching - Use frozenset.isdisjoint() instead of any() for better performance on deeply nested paths Co-authored-by: observerw <20661574+observerw@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: observerw <20661574+observerw@users.noreply.github.com> * feat: add with_sem * feat: add subdir tips --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: observerw <20661574+observerw@users.noreply.github.com>
1 parent fed55f6 commit 86d1169

File tree

4 files changed

+172
-35
lines changed

4 files changed

+172
-35
lines changed

src/lsap/capability/outline.py

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from typing import override
66

77
import anyio
8-
import asyncer
98
from attrs import define, field
109
from lsp_client.capability.request import WithRequestDocumentSymbol, WithRequestHover
1110
from lsprotocol.types import DocumentSymbol
@@ -22,6 +21,7 @@
2221
from lsap.schema.types import SymbolPath
2322
from lsap.utils.capability import ensure_capability
2423
from lsap.utils.markdown import clean_hover_content
24+
from lsap.utils.sem import with_sem
2525
from lsap.utils.symbol import iter_symbols
2626

2727
from .abc import Capability
@@ -30,6 +30,7 @@
3030
@define
3131
class OutlineCapability(Capability[OutlineRequest, OutlineResponse]):
3232
hover_sem: anyio.Semaphore = field(default=anyio.Semaphore(32), init=False)
33+
directory_sem: anyio.Semaphore = field(default=anyio.Semaphore(10), init=False)
3334

3435
@override
3536
async def __call__(self, req: OutlineRequest) -> OutlineResponse | None:
@@ -38,44 +39,51 @@ async def __call__(self, req: OutlineRequest) -> OutlineResponse | None:
3839
return await self._handle_file(req)
3940

4041
async def _handle_directory(self, req: OutlineRequest) -> OutlineResponse | None:
41-
directory = req.path
42+
assert req.path.is_dir()
4243

43-
lang_config = self.client.get_language_config()
4444
code_files: list[Path] = []
4545

46-
if req.recursive:
47-
for suffix in lang_config.suffixes:
48-
code_files.extend(directory.rglob(f"*{suffix}"))
49-
else:
50-
for suffix in lang_config.suffixes:
51-
code_files.extend(directory.glob(f"*{suffix}"))
52-
53-
code_files = sorted(set(code_files))
46+
lang_config = self.client.get_language_config()
47+
glob = req.path.rglob if req.recursive else req.path.glob
48+
for suffix in lang_config.suffixes:
49+
code_files.extend(glob(f"*{suffix}"))
5450

5551
file_groups: list[OutlineFileGroup] = []
5652
total_symbols = 0
5753

58-
async with asyncer.create_task_group() as tg:
54+
async with anyio.create_task_group() as tg:
5955
for file_path in code_files:
60-
_ = tg.soonify(self._process_file_for_directory)(file_path, file_groups)
56+
tg.start_soon(
57+
with_sem(
58+
self.directory_sem,
59+
self._process_file_for_directory,
60+
file_path,
61+
file_groups,
62+
)
63+
)
6164

6265
for group in file_groups:
6366
total_symbols += len(group.symbols)
6467

65-
file_groups.sort(key=lambda g: g.file_path)
68+
has_subdirs = False
69+
if not req.recursive:
70+
for p in req.path.iterdir():
71+
if p.is_dir() and not p.name.startswith("."):
72+
has_subdirs = True
73+
break
6674

6775
return OutlineResponse(
68-
path=directory,
76+
path=req.path,
6977
is_directory=True,
78+
request=req,
7079
files=file_groups,
7180
total_files=len(file_groups),
7281
total_symbols=total_symbols,
82+
has_subdirs=has_subdirs,
7383
)
7484

7585
async def _process_file_for_directory(
76-
self,
77-
file_path: Path,
78-
file_groups: list[OutlineFileGroup],
86+
self, file_path: Path, file_groups: list[OutlineFileGroup]
7987
) -> None:
8088
symbols = await ensure_capability(
8189
self.client, WithRequestDocumentSymbol
@@ -112,7 +120,9 @@ async def _handle_file(self, req: OutlineRequest) -> OutlineResponse | None:
112120
if path == target_path
113121
]
114122
if not matched:
115-
return OutlineResponse(path=file_path, is_directory=False, items=[])
123+
return OutlineResponse(
124+
path=file_path, is_directory=False, request=req, items=[]
125+
)
116126

117127
symbols_iter: list[tuple[SymbolPath, DocumentSymbol]] = []
118128
for path, symbol in matched:
@@ -129,7 +139,9 @@ async def _handle_file(self, req: OutlineRequest) -> OutlineResponse | None:
129139

130140
items = await self.resolve_symbols(file_path, symbols_iter)
131141

132-
return OutlineResponse(path=file_path, is_directory=False, items=items)
142+
return OutlineResponse(
143+
path=file_path, is_directory=False, request=req, items=items
144+
)
133145

134146
def _iter_top_symbols(
135147
self,
@@ -181,11 +193,18 @@ async def resolve_symbols(
181193
symbols_with_path: Iterable[tuple[SymbolPath, DocumentSymbol]],
182194
) -> list[SymbolDetailInfo]:
183195
items: list[SymbolDetailInfo] = []
184-
async with asyncer.create_task_group() as tg:
196+
async with anyio.create_task_group() as tg:
185197
for path, symbol in symbols_with_path:
186198
item = self._make_item(file_path, path, symbol)
187199
items.append(item)
188-
tg.soonify(self._fill_hover)(item, symbol.selection_range.start)
200+
tg.start_soon(
201+
with_sem(
202+
self.hover_sem,
203+
self._fill_hover,
204+
item,
205+
symbol.selection_range.start,
206+
)
207+
)
189208

190209
return items
191210

@@ -205,8 +224,7 @@ def _make_item(
205224
)
206225

207226
async def _fill_hover(self, item: SymbolDetailInfo, pos: LSPPosition) -> None:
208-
async with self.hover_sem:
209-
if hover := await ensure_capability(
210-
self.client, WithRequestHover
211-
).request_hover(item.file_path, pos):
212-
item.hover = clean_hover_content(hover.value)
227+
if hover := await ensure_capability(
228+
self.client, WithRequestHover
229+
).request_hover(item.file_path, pos):
230+
item.hover = clean_hover_content(hover.value)

src/lsap/schema/outline.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,14 @@
7373

7474

7575
class OutlineFileItem(SymbolDetailInfo):
76-
"""Symbol information for directory outline mode."""
76+
"""
77+
Thin semantic wrapper around :class:`SymbolDetailInfo` used for directory outline mode.
78+
79+
This class intentionally does not add any new fields or behaviour today; it exists as a
80+
distinct type to make directory outline responses self-documenting and to preserve a
81+
stable extension point for adding directory-specific metadata in the future without
82+
breaking the public API.
83+
"""
7784

7885

7986
class OutlineRequest(Request):
@@ -103,9 +110,8 @@ class OutlineRequest(Request):
103110
"""If true: for directories, scan subdirectories; for files, include all nested symbols."""
104111

105112
@model_validator(mode="after")
106-
def validate_scope(self) -> Self:
107-
"""Ensure scope is only used with files, not directories."""
108-
if self.scope is not None and self.path.is_dir():
113+
def validate_request_fields(self) -> Self:
114+
if self.scope and self.path.is_dir():
109115
raise ValueError("scope cannot be used with directory paths")
110116
return self
111117

@@ -139,6 +145,13 @@ def validate_scope(self) -> Self:
139145
{%- endif %}
140146
141147
{% endfor -%}
148+
149+
{% if has_subdirs and request.recursive == false -%}
150+
---
151+
152+
> [!TIP]
153+
> Subdirectories found. Set `recursive=true` to scan them.
154+
{%- endif %}
142155
"""
143156

144157

@@ -150,10 +163,12 @@ class OutlineFileGroup(Response):
150163
class OutlineResponse(Response):
151164
path: Path
152165
is_directory: bool
166+
request: OutlineRequest
153167
items: list[SymbolDetailInfo] | None = None
154168
files: list[OutlineFileGroup] | None = None
155169
total_files: int | None = None
156170
total_symbols: int | None = None
171+
has_subdirs: bool = False
157172

158173
model_config = ConfigDict(
159174
json_schema_extra={
@@ -162,6 +177,21 @@ class OutlineResponse(Response):
162177
}
163178
)
164179

180+
@model_validator(mode="after")
181+
def validate_response_fields(self) -> Self:
182+
"""Ensure correct fields are populated based on is_directory flag."""
183+
if self.is_directory:
184+
if self.files is None:
185+
raise ValueError("files must be provided when is_directory=True")
186+
if self.items is not None:
187+
raise ValueError("items must be None when is_directory=True")
188+
else:
189+
if self.items is None:
190+
raise ValueError("items must be provided when is_directory=False")
191+
if self.files is not None:
192+
raise ValueError("files must be None when is_directory=False")
193+
return self
194+
165195
@override
166196
def format(self, template_name: str = "markdown") -> str:
167197
if template_name == "markdown" and self.is_directory:

src/lsap/utils/sem.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from collections.abc import Awaitable, Callable
2+
3+
import anyio
4+
5+
6+
def with_sem[**P, R](
7+
sem: anyio.Semaphore,
8+
func: Callable[P, Awaitable[R]],
9+
*args: P.args,
10+
**kwargs: P.kwargs,
11+
) -> Callable[[], Awaitable[R]]:
12+
async def wrapper() -> R:
13+
async with sem:
14+
return await func(*args, **kwargs)
15+
16+
return wrapper

tests/test_outline.py

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import tempfile
12
from contextlib import asynccontextmanager
23
from pathlib import Path
34

@@ -402,8 +403,6 @@ async def request_document_symbol_list(self, file_path) -> list[DocumentSymbol]:
402403

403404
@pytest.mark.asyncio
404405
async def test_outline_directory():
405-
import tempfile
406-
407406
class MockDirectoryClient(MockOutlineClient):
408407
def __init__(self, tmpdir):
409408
super().__init__()
@@ -457,6 +456,7 @@ async def request_document_symbol_list(self, file_path) -> list[DocumentSymbol]:
457456
resp = await capability(req)
458457
assert resp is not None
459458
assert resp.path == tmppath
459+
assert resp.is_directory is True
460460
assert resp.total_files == 2
461461
assert resp.total_symbols == 2
462462

@@ -475,8 +475,6 @@ async def request_document_symbol_list(self, file_path) -> list[DocumentSymbol]:
475475

476476
@pytest.mark.asyncio
477477
async def test_outline_directory_recursive():
478-
import tempfile
479-
480478
class MockDirectoryClient(MockOutlineClient):
481479
def __init__(self, tmpdir):
482480
super().__init__()
@@ -546,6 +544,7 @@ async def request_document_symbol_list(self, file_path) -> list[DocumentSymbol]:
546544
resp = await capability(req)
547545
assert resp is not None
548546
assert resp.path == tmppath
547+
assert resp.is_directory is True
549548
assert resp.total_files == 2
550549
assert resp.total_symbols == 2
551550
file_names = {group.file_path.name for group in resp.files}
@@ -556,7 +555,81 @@ async def request_document_symbol_list(self, file_path) -> list[DocumentSymbol]:
556555
resp = await capability(req)
557556
assert resp is not None
558557
assert resp.path == tmppath
558+
assert resp.is_directory is True
559559
assert resp.total_files == 3
560560
assert resp.total_symbols == 3
561561
file_names = {group.file_path.name for group in resp.files}
562562
assert file_names == {"file1.py", "file2.py", "nested.py"}
563+
564+
565+
@pytest.mark.asyncio
566+
async def test_outline_directory_scope_validation():
567+
"""Test that scope parameter raises ValueError with directory paths."""
568+
569+
with tempfile.TemporaryDirectory() as tmpdir:
570+
tmppath = Path(tmpdir)
571+
(tmppath / "file1.py").write_text("class ClassA:\n pass\n")
572+
573+
client = MockOutlineClient()
574+
capability = OutlineCapability(client=client) # type: ignore
575+
576+
# Should raise ValueError when scope is provided with directory path
577+
with pytest.raises(
578+
ValueError, match="scope cannot be used with directory paths"
579+
):
580+
req = OutlineRequest(
581+
path=tmppath,
582+
scope={"symbol_path": ["SomeClass"]},
583+
)
584+
await capability(req)
585+
586+
587+
@pytest.mark.asyncio
588+
async def test_outline_directory_excludes_non_code_files():
589+
"""Test that non-code files like README.md are excluded from directory outline."""
590+
591+
class MockDirectoryClient(MockOutlineClient):
592+
def __init__(self, tmpdir):
593+
super().__init__()
594+
self.tmpdir = tmpdir
595+
self.file_symbols = {
596+
"file1.py": [
597+
DocumentSymbol(
598+
name="ClassA",
599+
kind=SymbolKind.Class,
600+
range=LSPRange(
601+
start=LSPPosition(line=0, character=0),
602+
end=LSPPosition(line=1, character=0),
603+
),
604+
selection_range=LSPRange(
605+
start=LSPPosition(line=0, character=6),
606+
end=LSPPosition(line=0, character=12),
607+
),
608+
)
609+
],
610+
}
611+
612+
async def request_document_symbol_list(self, file_path) -> list[DocumentSymbol]:
613+
filename = file_path.name
614+
return self.file_symbols.get(filename, [])
615+
616+
with tempfile.TemporaryDirectory() as tmpdir:
617+
tmppath = Path(tmpdir)
618+
(tmppath / "file1.py").write_text("class ClassA:\n pass\n")
619+
(tmppath / "README.md").write_text("# Not a code file")
620+
(tmppath / "data.json").write_text('{"key": "value"}')
621+
622+
client = MockDirectoryClient(tmppath)
623+
capability = OutlineCapability(client=client) # type: ignore
624+
625+
req = OutlineRequest(path=tmppath)
626+
resp = await capability(req)
627+
628+
assert resp is not None
629+
assert resp.is_directory is True
630+
assert resp.total_files == 1
631+
# Explicitly verify that only the Python file is included
632+
file_names = {group.file_path.name for group in resp.files}
633+
assert file_names == {"file1.py"}
634+
assert "README.md" not in file_names
635+
assert "data.json" not in file_names

0 commit comments

Comments
 (0)