b5e5824a56
Core changes:
- Added claw-code/src/token_budget.py for projected prompt size, chat-framing overhead, output reserve, and soft/hard input limits.
- Wired preflight prompt-length validation and auto-compact/context collapse into claw-code/src/agent_runtime.py.
- Extended claw-code/src/compact.py so compaction reports usage back to the runtime.
- Added inspection surfaces in claw-code/src/agent_slash_commands.py and claw-code/src/main.py:
- /token-budget and /budget
- token-budget
- Hardened claw-code/src/tokenizer_runtime.py so arbitrary simple model names fall back cleanly instead of trying a slow Transformers
lookup.
- Exported the new helpers in claw-code/src/__init__.py.
Docs and tracking:
- Updated claw-code/PARITY_CHECKLIST.md to mark prompt-length validation, token-budget calculation, and auto-compact/context collapse as
done.
- Updated claw-code/README.md and claw-code/TESTING_GUIDE.md with the new commands and behavior.
Tests:
- Added claw-code/tests/test_token_budget.py.
- Updated claw-code/tests/test_agent_runtime.py, claw-code/tests/test_agent_slash_commands.py, claw-code/tests/test_main.py, and claw-code/
tests/test_agent_context_usage.py.
- Verified with:
- /data/fs201059/aa17626/miniconda3/bin/python3 -m compileall src tests
- /data/fs201059/aa17626/miniconda3/bin/python3 -m unittest -v tests.test_token_budget
tests.test_agent_runtime.AgentRuntimeTests.test_agent_rejects_prompt_before_backend_when_preflight_input_budget_is_exceeded
tests.test_agent_runtime.AgentRuntimeTests.test_agent_auto_compacts_context_before_next_model_call tests.test_agent_slash_commands
tests.test_main tests.test_compact tests.test_tokenizer_runtime tests.test_agent_context_usage
- Result: 71 tests, OK
1109 lines
42 KiB
Python
1109 lines
42 KiB
Python
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
LSP_MANIFEST_FILES = (
|
|
Path('.claw-lsp.json'),
|
|
Path('.claude/lsp.json'),
|
|
)
|
|
DEFAULT_SUPPORTED_EXTENSIONS = (
|
|
'.py',
|
|
'.pyi',
|
|
'.js',
|
|
'.jsx',
|
|
'.ts',
|
|
'.tsx',
|
|
'.json',
|
|
)
|
|
DEFAULT_IGNORED_DIRECTORIES = (
|
|
'.git',
|
|
'.hg',
|
|
'.svn',
|
|
'.port_sessions',
|
|
'__pycache__',
|
|
'node_modules',
|
|
'dist',
|
|
'build',
|
|
'.venv',
|
|
'venv',
|
|
)
|
|
DEFAULT_MAX_INDEXED_FILES = 500
|
|
DEFAULT_MAX_FILE_BYTES = 1_500_000
|
|
WORD_PATTERN = re.compile(r'[A-Za-z_][A-Za-z0-9_]*')
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LSPSymbol:
|
|
name: str
|
|
kind: str
|
|
path: str
|
|
line: int
|
|
character: int
|
|
end_line: int
|
|
end_character: int
|
|
container_name: str | None = None
|
|
signature: str | None = None
|
|
documentation: str | None = None
|
|
|
|
@property
|
|
def symbol_id(self) -> str:
|
|
return f'{self.path}:{self.line}:{self.character}:{self.kind}:{self.name}'
|
|
|
|
def contains(self, line: int, character: int) -> bool:
|
|
if line < self.line or line > self.end_line:
|
|
return False
|
|
if line == self.line and character < self.character:
|
|
return False
|
|
if line == self.end_line and character > self.end_character:
|
|
return False
|
|
return True
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LSPReference:
|
|
name: str
|
|
path: str
|
|
line: int
|
|
character: int
|
|
line_text: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LSPDiagnostic:
|
|
path: str
|
|
severity: str
|
|
message: str
|
|
line: int
|
|
character: int
|
|
code: str | None = None
|
|
source: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LSPCallEdge:
|
|
caller_symbol_id: str
|
|
caller_name: str
|
|
callee_name: str
|
|
path: str
|
|
line: int
|
|
character: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class IndexedFile:
|
|
path: Path
|
|
language: str
|
|
text: str
|
|
symbols: tuple[LSPSymbol, ...] = ()
|
|
diagnostics: tuple[LSPDiagnostic, ...] = ()
|
|
call_edges: tuple[LSPCallEdge, ...] = ()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LSPQueryResult:
|
|
operation: str
|
|
content: str
|
|
result_count: int = 0
|
|
file_count: int = 0
|
|
symbol_name: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class LSPRuntime:
|
|
cwd: Path
|
|
additional_working_directories: tuple[Path, ...] = ()
|
|
manifests: tuple[str, ...] = ()
|
|
supported_extensions: tuple[str, ...] = DEFAULT_SUPPORTED_EXTENSIONS
|
|
ignored_directories: tuple[str, ...] = DEFAULT_IGNORED_DIRECTORIES
|
|
max_indexed_files: int = DEFAULT_MAX_INDEXED_FILES
|
|
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES
|
|
_cache: dict[str, tuple[int, int, IndexedFile]] = field(default_factory=dict, init=False, repr=False)
|
|
|
|
@classmethod
|
|
def from_workspace(
|
|
cls,
|
|
cwd: Path,
|
|
additional_working_directories: tuple[str, ...] = (),
|
|
) -> 'LSPRuntime':
|
|
resolved_cwd = cwd.resolve()
|
|
resolved_dirs = tuple(Path(path).resolve() for path in additional_working_directories)
|
|
manifest_paths = _discover_manifest_paths(resolved_cwd, resolved_dirs)
|
|
supported_extensions = set(DEFAULT_SUPPORTED_EXTENSIONS)
|
|
ignored_directories = set(DEFAULT_IGNORED_DIRECTORIES)
|
|
max_indexed_files = DEFAULT_MAX_INDEXED_FILES
|
|
max_file_bytes = DEFAULT_MAX_FILE_BYTES
|
|
for manifest_path in manifest_paths:
|
|
payload = _load_manifest_payload(manifest_path)
|
|
raw_extensions = payload.get('includeExtensions')
|
|
if isinstance(raw_extensions, list):
|
|
supported_extensions.update(
|
|
extension
|
|
for extension in raw_extensions
|
|
if isinstance(extension, str) and extension.strip()
|
|
)
|
|
raw_ignored = payload.get('excludeDirs')
|
|
if isinstance(raw_ignored, list):
|
|
ignored_directories.update(
|
|
item
|
|
for item in raw_ignored
|
|
if isinstance(item, str) and item.strip()
|
|
)
|
|
raw_max_files = payload.get('maxIndexedFiles')
|
|
if isinstance(raw_max_files, int) and raw_max_files > 0:
|
|
max_indexed_files = raw_max_files
|
|
raw_max_bytes = payload.get('maxFileBytes')
|
|
if isinstance(raw_max_bytes, int) and raw_max_bytes > 0:
|
|
max_file_bytes = raw_max_bytes
|
|
return cls(
|
|
cwd=resolved_cwd,
|
|
additional_working_directories=resolved_dirs,
|
|
manifests=tuple(str(path) for path in manifest_paths),
|
|
supported_extensions=tuple(sorted(supported_extensions)),
|
|
ignored_directories=tuple(sorted(ignored_directories)),
|
|
max_indexed_files=max_indexed_files,
|
|
max_file_bytes=max_file_bytes,
|
|
)
|
|
|
|
def has_lsp_support(self) -> bool:
|
|
return bool(self._workspace_files(limit=1))
|
|
|
|
def render_summary(self) -> str:
|
|
files = self._workspace_files(limit=self.max_indexed_files)
|
|
language_counts: dict[str, int] = {}
|
|
for path in files:
|
|
language = _language_for_extension(path.suffix)
|
|
language_counts[language] = language_counts.get(language, 0) + 1
|
|
lines = [
|
|
f'Heuristic LSP manifests: {len(self.manifests)}',
|
|
f'LSP roots: {len(self._roots())}',
|
|
f'Indexed candidate files: {len(files)}',
|
|
f'Supported extensions: {", ".join(self.supported_extensions)}',
|
|
]
|
|
if language_counts:
|
|
lines.append('- Languages:')
|
|
for language, count in sorted(language_counts.items()):
|
|
lines.append(f' - {language}: {count}')
|
|
return '\n'.join(lines)
|
|
|
|
def render_document_symbols(self, file_path: str) -> str:
|
|
indexed = self._indexed_from_user_path(file_path)
|
|
lines = ['# LSP Document Symbols', '', f'path={self._display_path(indexed.path)}']
|
|
if not indexed.symbols:
|
|
lines.extend(['', 'No document symbols found.'])
|
|
return '\n'.join(lines)
|
|
lines.append(f'symbol_count={len(indexed.symbols)}')
|
|
lines.append('')
|
|
for symbol in indexed.symbols:
|
|
detail = f'{symbol.kind} {symbol.name} @ {self._display_path(Path(symbol.path))}:{symbol.line}:{symbol.character}'
|
|
if symbol.container_name:
|
|
detail += f' ; container={symbol.container_name}'
|
|
lines.append(f'- {detail}')
|
|
return '\n'.join(lines)
|
|
|
|
def render_workspace_symbols(
|
|
self,
|
|
query: str,
|
|
*,
|
|
max_results: int = 50,
|
|
) -> str:
|
|
results = self.workspace_symbols(query, max_results=max_results)
|
|
lines = ['# LSP Workspace Symbols', '', f'query={query}', f'result_count={len(results)}', '']
|
|
if not results:
|
|
lines.append('No workspace symbols found.')
|
|
return '\n'.join(lines)
|
|
for symbol in results:
|
|
lines.append(
|
|
f'- {symbol.kind} {symbol.name} @ {self._display_path(Path(symbol.path))}:{symbol.line}:{symbol.character}'
|
|
)
|
|
return '\n'.join(lines)
|
|
|
|
def render_definition(
|
|
self,
|
|
file_path: str,
|
|
line: int,
|
|
character: int,
|
|
*,
|
|
max_results: int = 20,
|
|
) -> str:
|
|
indexed = self._indexed_from_user_path(file_path)
|
|
symbol_name = self._symbol_name_at_position(indexed, line, character)
|
|
definitions = self.go_to_definition(file_path, line, character, max_results=max_results)
|
|
lines = ['# LSP Definition', '']
|
|
if symbol_name:
|
|
lines.append(f'symbol={symbol_name}')
|
|
lines.append(f'result_count={len(definitions)}')
|
|
lines.append('')
|
|
if not definitions:
|
|
lines.append('No definitions found.')
|
|
return '\n'.join(lines)
|
|
for symbol in definitions:
|
|
lines.append(
|
|
f'- {symbol.kind} {symbol.name} @ {self._display_path(Path(symbol.path))}:{symbol.line}:{symbol.character}'
|
|
)
|
|
return '\n'.join(lines)
|
|
|
|
def render_references(
|
|
self,
|
|
file_path: str,
|
|
line: int,
|
|
character: int,
|
|
*,
|
|
max_results: int = 50,
|
|
) -> str:
|
|
indexed = self._indexed_from_user_path(file_path)
|
|
symbol_name = self._symbol_name_at_position(indexed, line, character)
|
|
references = self.find_references(file_path, line, character, max_results=max_results)
|
|
lines = ['# LSP References', '']
|
|
if symbol_name:
|
|
lines.append(f'symbol={symbol_name}')
|
|
lines.append(f'result_count={len(references)}')
|
|
lines.append(f'file_count={len({reference.path for reference in references})}')
|
|
lines.append('')
|
|
if not references:
|
|
lines.append('No references found.')
|
|
return '\n'.join(lines)
|
|
for reference in references:
|
|
lines.append(
|
|
f'- {self._display_path(Path(reference.path))}:{reference.line}:{reference.character} :: {reference.line_text.strip()}'
|
|
)
|
|
return '\n'.join(lines)
|
|
|
|
def render_hover(self, file_path: str, line: int, character: int) -> str:
|
|
hover = self.hover(file_path, line, character)
|
|
lines = ['# LSP Hover', '']
|
|
if hover is None:
|
|
lines.append('No hover information found.')
|
|
return '\n'.join(lines)
|
|
lines.append(f'symbol={hover.name}')
|
|
lines.append(f'kind={hover.kind}')
|
|
lines.append(
|
|
f'location={self._display_path(Path(hover.path))}:{hover.line}:{hover.character}'
|
|
)
|
|
if hover.signature:
|
|
lines.append(f'signature={hover.signature}')
|
|
if hover.documentation:
|
|
lines.extend(['', hover.documentation])
|
|
return '\n'.join(lines)
|
|
|
|
def render_prepare_call_hierarchy(self, file_path: str, line: int, character: int) -> str:
|
|
symbol = self.prepare_call_hierarchy(file_path, line, character)
|
|
lines = ['# LSP Call Hierarchy', '']
|
|
if symbol is None:
|
|
lines.append('No callable symbol found at that position.')
|
|
return '\n'.join(lines)
|
|
incoming = self.incoming_calls(file_path, line, character, max_results=100)
|
|
outgoing = self.outgoing_calls(file_path, line, character, max_results=100)
|
|
lines.append(f'symbol={symbol.name}')
|
|
lines.append(f'kind={symbol.kind}')
|
|
lines.append(
|
|
f'location={self._display_path(Path(symbol.path))}:{symbol.line}:{symbol.character}'
|
|
)
|
|
lines.append(f'incoming_calls={len(incoming)}')
|
|
lines.append(f'outgoing_calls={len(outgoing)}')
|
|
return '\n'.join(lines)
|
|
|
|
def render_incoming_calls(
|
|
self,
|
|
file_path: str,
|
|
line: int,
|
|
character: int,
|
|
*,
|
|
max_results: int = 50,
|
|
) -> str:
|
|
symbol = self.prepare_call_hierarchy(file_path, line, character)
|
|
calls = self.incoming_calls(file_path, line, character, max_results=max_results)
|
|
lines = ['# LSP Incoming Calls', '']
|
|
if symbol is not None:
|
|
lines.append(f'symbol={symbol.name}')
|
|
lines.append(f'result_count={len(calls)}')
|
|
lines.append('')
|
|
if not calls:
|
|
lines.append('No incoming calls found.')
|
|
return '\n'.join(lines)
|
|
for call in calls:
|
|
lines.append(
|
|
f'- {call.name} @ {self._display_path(Path(call.path))}:{call.line}:{call.character}'
|
|
)
|
|
return '\n'.join(lines)
|
|
|
|
def render_outgoing_calls(
|
|
self,
|
|
file_path: str,
|
|
line: int,
|
|
character: int,
|
|
*,
|
|
max_results: int = 50,
|
|
) -> str:
|
|
symbol = self.prepare_call_hierarchy(file_path, line, character)
|
|
calls = self.outgoing_calls(file_path, line, character, max_results=max_results)
|
|
lines = ['# LSP Outgoing Calls', '']
|
|
if symbol is not None:
|
|
lines.append(f'symbol={symbol.name}')
|
|
lines.append(f'result_count={len(calls)}')
|
|
lines.append('')
|
|
if not calls:
|
|
lines.append('No outgoing calls found.')
|
|
return '\n'.join(lines)
|
|
for call in calls:
|
|
lines.append(
|
|
f'- {call.name} @ {self._display_path(Path(call.path))}:{call.line}:{call.character}'
|
|
)
|
|
return '\n'.join(lines)
|
|
|
|
def render_diagnostics(self, file_path: str | None = None) -> str:
|
|
diagnostics = self.diagnostics(file_path=file_path)
|
|
lines = ['# LSP Diagnostics', '']
|
|
if file_path is not None:
|
|
lines.append(f'path={self._display_path(self.resolve_path(file_path))}')
|
|
lines.append(f'diagnostic_count={len(diagnostics)}')
|
|
lines.append('')
|
|
if not diagnostics:
|
|
lines.append('No diagnostics found.')
|
|
return '\n'.join(lines)
|
|
for diagnostic in diagnostics:
|
|
code = f' [{diagnostic.code}]' if diagnostic.code else ''
|
|
source = f' ({diagnostic.source})' if diagnostic.source else ''
|
|
lines.append(
|
|
f'- {diagnostic.severity.upper()} {self._display_path(Path(diagnostic.path))}:{diagnostic.line}:{diagnostic.character} {diagnostic.message}{code}{source}'
|
|
)
|
|
return '\n'.join(lines)
|
|
|
|
def query(
|
|
self,
|
|
operation: str,
|
|
*,
|
|
file_path: str,
|
|
line: int,
|
|
character: int,
|
|
query: str | None = None,
|
|
max_results: int = 50,
|
|
) -> LSPQueryResult:
|
|
if operation == 'documentSymbol':
|
|
indexed = self._indexed_from_user_path(file_path)
|
|
content = self.render_document_symbols(file_path)
|
|
return LSPQueryResult(
|
|
operation=operation,
|
|
content=content,
|
|
result_count=len(indexed.symbols),
|
|
file_count=1 if indexed.symbols else 0,
|
|
)
|
|
if operation == 'workspaceSymbol':
|
|
indexed = self._indexed_from_user_path(file_path)
|
|
symbol_name = query or self._symbol_name_at_position(indexed, line, character)
|
|
if not symbol_name:
|
|
raise KeyError('No symbol name available for workspaceSymbol query')
|
|
results = self.workspace_symbols(symbol_name, max_results=max_results)
|
|
return LSPQueryResult(
|
|
operation=operation,
|
|
content=self.render_workspace_symbols(symbol_name, max_results=max_results),
|
|
result_count=len(results),
|
|
file_count=len({result.path for result in results}),
|
|
symbol_name=symbol_name,
|
|
)
|
|
if operation == 'goToDefinition':
|
|
definitions = self.go_to_definition(file_path, line, character, max_results=max_results)
|
|
return LSPQueryResult(
|
|
operation=operation,
|
|
content=self.render_definition(file_path, line, character, max_results=max_results),
|
|
result_count=len(definitions),
|
|
file_count=len({definition.path for definition in definitions}),
|
|
symbol_name=self._symbol_name_at_position(self._indexed_from_user_path(file_path), line, character),
|
|
)
|
|
if operation == 'findReferences':
|
|
references = self.find_references(file_path, line, character, max_results=max_results)
|
|
return LSPQueryResult(
|
|
operation=operation,
|
|
content=self.render_references(file_path, line, character, max_results=max_results),
|
|
result_count=len(references),
|
|
file_count=len({reference.path for reference in references}),
|
|
symbol_name=self._symbol_name_at_position(self._indexed_from_user_path(file_path), line, character),
|
|
)
|
|
if operation == 'hover':
|
|
hover = self.hover(file_path, line, character)
|
|
return LSPQueryResult(
|
|
operation=operation,
|
|
content=self.render_hover(file_path, line, character),
|
|
result_count=1 if hover is not None else 0,
|
|
file_count=1 if hover is not None else 0,
|
|
symbol_name=hover.name if hover is not None else None,
|
|
)
|
|
if operation == 'goToImplementation':
|
|
definitions = self.go_to_definition(file_path, line, character, max_results=max_results)
|
|
content = self.render_definition(file_path, line, character, max_results=max_results).replace(
|
|
'# LSP Definition',
|
|
'# LSP Implementation',
|
|
)
|
|
return LSPQueryResult(
|
|
operation=operation,
|
|
content=content,
|
|
result_count=len(definitions),
|
|
file_count=len({definition.path for definition in definitions}),
|
|
symbol_name=self._symbol_name_at_position(self._indexed_from_user_path(file_path), line, character),
|
|
)
|
|
if operation == 'prepareCallHierarchy':
|
|
symbol = self.prepare_call_hierarchy(file_path, line, character)
|
|
return LSPQueryResult(
|
|
operation=operation,
|
|
content=self.render_prepare_call_hierarchy(file_path, line, character),
|
|
result_count=1 if symbol is not None else 0,
|
|
file_count=1 if symbol is not None else 0,
|
|
symbol_name=symbol.name if symbol is not None else None,
|
|
)
|
|
if operation == 'incomingCalls':
|
|
calls = self.incoming_calls(file_path, line, character, max_results=max_results)
|
|
return LSPQueryResult(
|
|
operation=operation,
|
|
content=self.render_incoming_calls(file_path, line, character, max_results=max_results),
|
|
result_count=len(calls),
|
|
file_count=len({call.path for call in calls}),
|
|
symbol_name=self._symbol_name_at_position(self._indexed_from_user_path(file_path), line, character),
|
|
)
|
|
if operation == 'outgoingCalls':
|
|
calls = self.outgoing_calls(file_path, line, character, max_results=max_results)
|
|
return LSPQueryResult(
|
|
operation=operation,
|
|
content=self.render_outgoing_calls(file_path, line, character, max_results=max_results),
|
|
result_count=len(calls),
|
|
file_count=len({call.path for call in calls}),
|
|
symbol_name=self._symbol_name_at_position(self._indexed_from_user_path(file_path), line, character),
|
|
)
|
|
raise KeyError(operation)
|
|
|
|
def workspace_symbols(self, query: str, *, max_results: int = 50) -> tuple[LSPSymbol, ...]:
|
|
needle = query.strip().lower()
|
|
if not needle:
|
|
return ()
|
|
results: list[LSPSymbol] = []
|
|
for indexed in self._workspace_indexes():
|
|
for symbol in indexed.symbols:
|
|
if needle in symbol.name.lower():
|
|
results.append(symbol)
|
|
results.sort(key=lambda item: (item.name.lower(), item.path, item.line, item.character))
|
|
return tuple(results[:max_results])
|
|
|
|
def go_to_definition(
|
|
self,
|
|
file_path: str,
|
|
line: int,
|
|
character: int,
|
|
*,
|
|
max_results: int = 20,
|
|
) -> tuple[LSPSymbol, ...]:
|
|
indexed = self._indexed_from_user_path(file_path)
|
|
symbol_name = self._symbol_name_at_position(indexed, line, character)
|
|
if not symbol_name:
|
|
return ()
|
|
results: list[LSPSymbol] = []
|
|
for candidate in self._workspace_indexes():
|
|
for symbol in candidate.symbols:
|
|
if symbol.name == symbol_name:
|
|
results.append(symbol)
|
|
results.sort(
|
|
key=lambda item: (
|
|
0 if Path(item.path) == indexed.path else 1,
|
|
abs(item.line - line),
|
|
item.path,
|
|
item.character,
|
|
)
|
|
)
|
|
deduped: list[LSPSymbol] = []
|
|
seen: set[str] = set()
|
|
for symbol in results:
|
|
if symbol.symbol_id in seen:
|
|
continue
|
|
seen.add(symbol.symbol_id)
|
|
deduped.append(symbol)
|
|
return tuple(deduped[:max_results])
|
|
|
|
def find_references(
|
|
self,
|
|
file_path: str,
|
|
line: int,
|
|
character: int,
|
|
*,
|
|
max_results: int = 50,
|
|
) -> tuple[LSPReference, ...]:
|
|
indexed = self._indexed_from_user_path(file_path)
|
|
symbol_name = self._symbol_name_at_position(indexed, line, character)
|
|
if not symbol_name:
|
|
return ()
|
|
references: list[LSPReference] = []
|
|
pattern = re.compile(rf'\b{re.escape(symbol_name)}\b')
|
|
for candidate in self._workspace_indexes():
|
|
for line_number, line_text in enumerate(candidate.text.splitlines(), start=1):
|
|
for match in pattern.finditer(line_text):
|
|
references.append(
|
|
LSPReference(
|
|
name=symbol_name,
|
|
path=str(candidate.path),
|
|
line=line_number,
|
|
character=match.start() + 1,
|
|
line_text=line_text,
|
|
)
|
|
)
|
|
if len(references) >= max_results:
|
|
return tuple(references)
|
|
return tuple(references)
|
|
|
|
def hover(self, file_path: str, line: int, character: int) -> LSPSymbol | None:
|
|
indexed = self._indexed_from_user_path(file_path)
|
|
for symbol in indexed.symbols:
|
|
if symbol.contains(line, character):
|
|
return symbol
|
|
definitions = self.go_to_definition(file_path, line, character, max_results=1)
|
|
return definitions[0] if definitions else None
|
|
|
|
def prepare_call_hierarchy(
|
|
self,
|
|
file_path: str,
|
|
line: int,
|
|
character: int,
|
|
) -> LSPSymbol | None:
|
|
indexed = self._indexed_from_user_path(file_path)
|
|
callable_symbols = [
|
|
symbol
|
|
for symbol in indexed.symbols
|
|
if symbol.kind in {'function', 'async_function', 'method'}
|
|
]
|
|
containing = [
|
|
symbol
|
|
for symbol in callable_symbols
|
|
if symbol.contains(line, character)
|
|
]
|
|
if containing:
|
|
containing.sort(key=lambda item: (item.end_line - item.line, item.end_character - item.character))
|
|
return containing[0]
|
|
definitions = self.go_to_definition(file_path, line, character, max_results=1)
|
|
if definitions and definitions[0].kind in {'function', 'async_function', 'method'}:
|
|
return definitions[0]
|
|
return None
|
|
|
|
def incoming_calls(
|
|
self,
|
|
file_path: str,
|
|
line: int,
|
|
character: int,
|
|
*,
|
|
max_results: int = 50,
|
|
) -> tuple[LSPSymbol, ...]:
|
|
target = self.prepare_call_hierarchy(file_path, line, character)
|
|
if target is None:
|
|
return ()
|
|
workspace_indexes = self._workspace_indexes()
|
|
by_symbol_id = {
|
|
symbol.symbol_id: symbol
|
|
for indexed in workspace_indexes
|
|
for symbol in indexed.symbols
|
|
}
|
|
results: list[LSPSymbol] = []
|
|
seen: set[str] = set()
|
|
for indexed in workspace_indexes:
|
|
for edge in indexed.call_edges:
|
|
if edge.callee_name != target.name:
|
|
continue
|
|
caller = by_symbol_id.get(edge.caller_symbol_id)
|
|
if caller is None or caller.symbol_id in seen:
|
|
continue
|
|
seen.add(caller.symbol_id)
|
|
results.append(caller)
|
|
if len(results) >= max_results:
|
|
return tuple(results)
|
|
return tuple(results)
|
|
|
|
def outgoing_calls(
|
|
self,
|
|
file_path: str,
|
|
line: int,
|
|
character: int,
|
|
*,
|
|
max_results: int = 50,
|
|
) -> tuple[LSPSymbol, ...]:
|
|
target = self.prepare_call_hierarchy(file_path, line, character)
|
|
if target is None:
|
|
return ()
|
|
definitions_by_name: dict[str, list[LSPSymbol]] = {}
|
|
for indexed in self._workspace_indexes():
|
|
for symbol in indexed.symbols:
|
|
definitions_by_name.setdefault(symbol.name, []).append(symbol)
|
|
matches: list[LSPSymbol] = []
|
|
seen: set[str] = set()
|
|
for indexed in self._workspace_indexes():
|
|
for edge in indexed.call_edges:
|
|
if edge.caller_symbol_id != target.symbol_id:
|
|
continue
|
|
for symbol in definitions_by_name.get(edge.callee_name, []):
|
|
if symbol.symbol_id in seen:
|
|
continue
|
|
seen.add(symbol.symbol_id)
|
|
matches.append(symbol)
|
|
if len(matches) >= max_results:
|
|
return tuple(matches)
|
|
return tuple(matches)
|
|
|
|
def diagnostics(self, *, file_path: str | None = None) -> tuple[LSPDiagnostic, ...]:
|
|
if file_path is not None:
|
|
indexed = self._indexed_from_user_path(file_path)
|
|
return indexed.diagnostics
|
|
diagnostics: list[LSPDiagnostic] = []
|
|
for indexed in self._workspace_indexes():
|
|
diagnostics.extend(indexed.diagnostics)
|
|
diagnostics.sort(key=lambda item: (item.path, item.line, item.character))
|
|
return tuple(diagnostics)
|
|
|
|
def resolve_path(self, raw_path: str) -> Path:
|
|
expanded = Path(raw_path).expanduser()
|
|
candidate = expanded if expanded.is_absolute() else self.cwd / expanded
|
|
resolved = candidate.resolve(strict=True)
|
|
if not self._is_under_roots(resolved):
|
|
raise ValueError(f'Path {raw_path!r} escapes the configured LSP roots')
|
|
if not resolved.is_file():
|
|
raise FileNotFoundError(raw_path)
|
|
return resolved
|
|
|
|
def _indexed_from_user_path(self, raw_path: str) -> IndexedFile:
|
|
try:
|
|
path = self.resolve_path(raw_path)
|
|
except FileNotFoundError as exc:
|
|
raise KeyError(f'Unknown file: {raw_path}') from exc
|
|
except ValueError as exc:
|
|
raise KeyError(str(exc)) from exc
|
|
return self._index_file(path)
|
|
|
|
def _display_path(self, path: Path) -> str:
|
|
resolved = path.resolve()
|
|
for root in self._roots():
|
|
try:
|
|
return str(resolved.relative_to(root))
|
|
except ValueError:
|
|
continue
|
|
return str(resolved)
|
|
|
|
def _roots(self) -> tuple[Path, ...]:
|
|
roots = [self.cwd, *self.additional_working_directories]
|
|
seen: set[Path] = set()
|
|
normalized: list[Path] = []
|
|
for root in roots:
|
|
resolved = root.resolve()
|
|
if resolved in seen or not resolved.exists():
|
|
continue
|
|
seen.add(resolved)
|
|
normalized.append(resolved)
|
|
return tuple(normalized)
|
|
|
|
def _is_under_roots(self, path: Path) -> bool:
|
|
for root in self._roots():
|
|
try:
|
|
path.relative_to(root)
|
|
return True
|
|
except ValueError:
|
|
continue
|
|
return False
|
|
|
|
def _workspace_files(self, *, limit: int | None = None) -> tuple[Path, ...]:
|
|
max_items = self.max_indexed_files if limit is None else min(limit, self.max_indexed_files)
|
|
found: list[Path] = []
|
|
seen: set[Path] = set()
|
|
ignored = set(self.ignored_directories)
|
|
supported = set(self.supported_extensions)
|
|
for root in self._roots():
|
|
for current_root, dir_names, file_names in os.walk(root):
|
|
dir_names[:] = [
|
|
name
|
|
for name in dir_names
|
|
if name not in ignored and not name.startswith('.pytest_cache')
|
|
]
|
|
current_path = Path(current_root)
|
|
for name in sorted(file_names):
|
|
candidate = (current_path / name).resolve()
|
|
if candidate in seen or candidate.suffix not in supported:
|
|
continue
|
|
try:
|
|
size = candidate.stat().st_size
|
|
except OSError:
|
|
continue
|
|
if size > self.max_file_bytes:
|
|
continue
|
|
seen.add(candidate)
|
|
found.append(candidate)
|
|
if len(found) >= max_items:
|
|
return tuple(found)
|
|
return tuple(found)
|
|
|
|
def _workspace_indexes(self) -> tuple[IndexedFile, ...]:
|
|
return tuple(self._index_file(path) for path in self._workspace_files())
|
|
|
|
def _index_file(self, path: Path) -> IndexedFile:
|
|
stat = path.stat()
|
|
cache_key = str(path)
|
|
cached = self._cache.get(cache_key)
|
|
if cached is not None:
|
|
cached_mtime, cached_size, indexed = cached
|
|
if cached_mtime == stat.st_mtime_ns and cached_size == stat.st_size:
|
|
return indexed
|
|
text = path.read_text(encoding='utf-8', errors='replace')
|
|
suffix = path.suffix.lower()
|
|
if suffix in {'.py', '.pyi'}:
|
|
indexed = _index_python_file(path, text)
|
|
elif suffix == '.json':
|
|
indexed = _index_json_file(path, text)
|
|
else:
|
|
indexed = _index_generic_code_file(path, text)
|
|
self._cache[cache_key] = (stat.st_mtime_ns, stat.st_size, indexed)
|
|
return indexed
|
|
|
|
def _symbol_name_at_position(self, indexed: IndexedFile, line: int, character: int) -> str | None:
|
|
word = _word_at(indexed.text, line, character)
|
|
if word:
|
|
return word
|
|
for symbol in indexed.symbols:
|
|
if symbol.contains(line, character):
|
|
return symbol.name
|
|
return None
|
|
|
|
|
|
def _discover_manifest_paths(cwd: Path, additional_dirs: tuple[Path, ...]) -> tuple[Path, ...]:
|
|
directories = [cwd, *additional_dirs]
|
|
seen: set[Path] = set()
|
|
found: list[Path] = []
|
|
for directory in directories:
|
|
for filename in LSP_MANIFEST_FILES:
|
|
candidate = (directory / filename).resolve()
|
|
if candidate in seen or not candidate.exists():
|
|
continue
|
|
seen.add(candidate)
|
|
found.append(candidate)
|
|
return tuple(found)
|
|
|
|
|
|
def _load_manifest_payload(path: Path) -> dict[str, Any]:
|
|
try:
|
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
|
|
def _index_python_file(path: Path, text: str) -> IndexedFile:
|
|
diagnostics: list[LSPDiagnostic] = []
|
|
symbols: list[LSPSymbol] = []
|
|
call_edges: list[LSPCallEdge] = []
|
|
try:
|
|
tree = ast.parse(text, filename=str(path))
|
|
except SyntaxError as exc:
|
|
diagnostics.append(
|
|
LSPDiagnostic(
|
|
path=str(path),
|
|
severity='error',
|
|
message=exc.msg,
|
|
line=exc.lineno or 1,
|
|
character=(exc.offset or 1),
|
|
code='syntax-error',
|
|
source='python',
|
|
)
|
|
)
|
|
fallback = _index_generic_code_file(path, text)
|
|
return IndexedFile(
|
|
path=path,
|
|
language='python',
|
|
text=text,
|
|
symbols=fallback.symbols,
|
|
diagnostics=tuple(diagnostics),
|
|
call_edges=(),
|
|
)
|
|
|
|
class Visitor(ast.NodeVisitor):
|
|
def __init__(self) -> None:
|
|
self.container_stack: list[LSPSymbol] = []
|
|
self.callable_stack: list[LSPSymbol] = []
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> Any:
|
|
symbol = LSPSymbol(
|
|
name=node.name,
|
|
kind='class',
|
|
path=str(path),
|
|
line=node.lineno,
|
|
character=node.col_offset + 1,
|
|
end_line=getattr(node, 'end_lineno', node.lineno),
|
|
end_character=getattr(node, 'end_col_offset', node.col_offset) + 1,
|
|
container_name=self.container_stack[-1].name if self.container_stack else None,
|
|
documentation=_first_doc_line(ast.get_docstring(node)),
|
|
)
|
|
symbols.append(symbol)
|
|
self.container_stack.append(symbol)
|
|
self.generic_visit(node)
|
|
self.container_stack.pop()
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
|
|
kind = 'method' if self.container_stack and self.container_stack[-1].kind == 'class' else 'function'
|
|
symbol = LSPSymbol(
|
|
name=node.name,
|
|
kind=kind,
|
|
path=str(path),
|
|
line=node.lineno,
|
|
character=node.col_offset + 1,
|
|
end_line=getattr(node, 'end_lineno', node.lineno),
|
|
end_character=getattr(node, 'end_col_offset', node.col_offset) + 1,
|
|
container_name=self.container_stack[-1].name if self.container_stack else None,
|
|
signature=_python_signature(node),
|
|
documentation=_first_doc_line(ast.get_docstring(node)),
|
|
)
|
|
symbols.append(symbol)
|
|
self.container_stack.append(symbol)
|
|
self.callable_stack.append(symbol)
|
|
self.generic_visit(node)
|
|
self.callable_stack.pop()
|
|
self.container_stack.pop()
|
|
|
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
|
|
kind = 'method' if self.container_stack and self.container_stack[-1].kind == 'class' else 'async_function'
|
|
symbol = LSPSymbol(
|
|
name=node.name,
|
|
kind=kind,
|
|
path=str(path),
|
|
line=node.lineno,
|
|
character=node.col_offset + 1,
|
|
end_line=getattr(node, 'end_lineno', node.lineno),
|
|
end_character=getattr(node, 'end_col_offset', node.col_offset) + 1,
|
|
container_name=self.container_stack[-1].name if self.container_stack else None,
|
|
signature='async ' + _python_signature(node),
|
|
documentation=_first_doc_line(ast.get_docstring(node)),
|
|
)
|
|
symbols.append(symbol)
|
|
self.container_stack.append(symbol)
|
|
self.callable_stack.append(symbol)
|
|
self.generic_visit(node)
|
|
self.callable_stack.pop()
|
|
self.container_stack.pop()
|
|
|
|
def visit_Assign(self, node: ast.Assign) -> Any:
|
|
if self.callable_stack:
|
|
self.generic_visit(node)
|
|
return
|
|
for target in node.targets:
|
|
if isinstance(target, ast.Name):
|
|
symbols.append(
|
|
LSPSymbol(
|
|
name=target.id,
|
|
kind='variable',
|
|
path=str(path),
|
|
line=target.lineno,
|
|
character=target.col_offset + 1,
|
|
end_line=getattr(target, 'end_lineno', target.lineno),
|
|
end_character=getattr(target, 'end_col_offset', target.col_offset) + 1,
|
|
container_name=self.container_stack[-1].name if self.container_stack else None,
|
|
)
|
|
)
|
|
self.generic_visit(node)
|
|
|
|
def visit_AnnAssign(self, node: ast.AnnAssign) -> Any:
|
|
if self.callable_stack:
|
|
self.generic_visit(node)
|
|
return
|
|
target = node.target
|
|
if isinstance(target, ast.Name):
|
|
symbols.append(
|
|
LSPSymbol(
|
|
name=target.id,
|
|
kind='variable',
|
|
path=str(path),
|
|
line=target.lineno,
|
|
character=target.col_offset + 1,
|
|
end_line=getattr(target, 'end_lineno', target.lineno),
|
|
end_character=getattr(target, 'end_col_offset', target.col_offset) + 1,
|
|
container_name=self.container_stack[-1].name if self.container_stack else None,
|
|
)
|
|
)
|
|
self.generic_visit(node)
|
|
|
|
def visit_Call(self, node: ast.Call) -> Any:
|
|
if self.callable_stack:
|
|
callee_name = _python_called_name(node.func)
|
|
if callee_name:
|
|
caller = self.callable_stack[-1]
|
|
call_edges.append(
|
|
LSPCallEdge(
|
|
caller_symbol_id=caller.symbol_id,
|
|
caller_name=caller.name,
|
|
callee_name=callee_name,
|
|
path=str(path),
|
|
line=getattr(node, 'lineno', caller.line),
|
|
character=getattr(node, 'col_offset', 0) + 1,
|
|
)
|
|
)
|
|
self.generic_visit(node)
|
|
|
|
Visitor().visit(tree)
|
|
return IndexedFile(
|
|
path=path,
|
|
language='python',
|
|
text=text,
|
|
symbols=tuple(symbols),
|
|
diagnostics=tuple(diagnostics),
|
|
call_edges=tuple(call_edges),
|
|
)
|
|
|
|
|
|
def _index_json_file(path: Path, text: str) -> IndexedFile:
|
|
diagnostics: list[LSPDiagnostic] = []
|
|
symbols: list[LSPSymbol] = []
|
|
try:
|
|
payload = json.loads(text)
|
|
except json.JSONDecodeError as exc:
|
|
diagnostics.append(
|
|
LSPDiagnostic(
|
|
path=str(path),
|
|
severity='error',
|
|
message=exc.msg,
|
|
line=exc.lineno,
|
|
character=exc.colno,
|
|
code='json-error',
|
|
source='json',
|
|
)
|
|
)
|
|
return IndexedFile(
|
|
path=path,
|
|
language='json',
|
|
text=text,
|
|
symbols=(),
|
|
diagnostics=tuple(diagnostics),
|
|
call_edges=(),
|
|
)
|
|
if isinstance(payload, dict):
|
|
for key in payload.keys():
|
|
line_number, character = _find_string_literal(text, key)
|
|
if line_number is None or character is None:
|
|
line_number, character = 1, 1
|
|
symbols.append(
|
|
LSPSymbol(
|
|
name=str(key),
|
|
kind='property',
|
|
path=str(path),
|
|
line=line_number,
|
|
character=character,
|
|
end_line=line_number,
|
|
end_character=character + len(str(key)),
|
|
)
|
|
)
|
|
return IndexedFile(
|
|
path=path,
|
|
language='json',
|
|
text=text,
|
|
symbols=tuple(symbols),
|
|
diagnostics=tuple(diagnostics),
|
|
call_edges=(),
|
|
)
|
|
|
|
|
|
def _index_generic_code_file(path: Path, text: str) -> IndexedFile:
|
|
symbols: list[LSPSymbol] = []
|
|
patterns = (
|
|
('class', re.compile(r'^\s*(?:export\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)')),
|
|
('function', re.compile(r'^\s*(?:export\s+)?function\s+([A-Za-z_][A-Za-z0-9_]*)')),
|
|
('function', re.compile(r'^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_][A-Za-z0-9_]*)\s*=>')),
|
|
('variable', re.compile(r'^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\b')),
|
|
('function', re.compile(r'^\s*def\s+([A-Za-z_][A-Za-z0-9_]*)\b')),
|
|
('class', re.compile(r'^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)\b')),
|
|
('interface', re.compile(r'^\s*(?:export\s+)?interface\s+([A-Za-z_][A-Za-z0-9_]*)')),
|
|
('type', re.compile(r'^\s*(?:export\s+)?type\s+([A-Za-z_][A-Za-z0-9_]*)')),
|
|
)
|
|
for line_number, line_text in enumerate(text.splitlines(), start=1):
|
|
for kind, pattern in patterns:
|
|
match = pattern.search(line_text)
|
|
if match is None:
|
|
continue
|
|
name = match.group(1)
|
|
symbols.append(
|
|
LSPSymbol(
|
|
name=name,
|
|
kind=kind,
|
|
path=str(path),
|
|
line=line_number,
|
|
character=match.start(1) + 1,
|
|
end_line=line_number,
|
|
end_character=match.end(1) + 1,
|
|
)
|
|
)
|
|
break
|
|
return IndexedFile(
|
|
path=path,
|
|
language=_language_for_extension(path.suffix),
|
|
text=text,
|
|
symbols=tuple(symbols),
|
|
diagnostics=(),
|
|
call_edges=(),
|
|
)
|
|
|
|
|
|
def _python_signature(node: ast.AST) -> str:
|
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
return ''
|
|
argument_names = [arg.arg for arg in node.args.args]
|
|
if node.args.vararg is not None:
|
|
argument_names.append('*' + node.args.vararg.arg)
|
|
if node.args.kwarg is not None:
|
|
argument_names.append('**' + node.args.kwarg.arg)
|
|
return f'{node.name}({", ".join(argument_names)})'
|
|
|
|
|
|
def _python_called_name(node: ast.AST) -> str | None:
|
|
if isinstance(node, ast.Name):
|
|
return node.id
|
|
if isinstance(node, ast.Attribute):
|
|
return node.attr
|
|
return None
|
|
|
|
|
|
def _word_at(text: str, line: int, character: int) -> str | None:
|
|
lines = text.splitlines()
|
|
if line < 1 or line > len(lines):
|
|
return None
|
|
line_text = lines[line - 1]
|
|
if not line_text:
|
|
return None
|
|
position = max(0, min(len(line_text) - 1, character - 1))
|
|
for match in WORD_PATTERN.finditer(line_text):
|
|
if match.start() <= position < match.end():
|
|
return match.group(0)
|
|
if position > 0:
|
|
for match in WORD_PATTERN.finditer(line_text):
|
|
if match.start() < position <= match.end():
|
|
return match.group(0)
|
|
return None
|
|
|
|
|
|
def _find_string_literal(text: str, value: str) -> tuple[int | None, int | None]:
|
|
quoted = json.dumps(value)
|
|
needle = quoted[1:-1]
|
|
for line_number, line_text in enumerate(text.splitlines(), start=1):
|
|
column = line_text.find(f'"{needle}"')
|
|
if column >= 0:
|
|
return line_number, column + 2
|
|
return None, None
|
|
|
|
|
|
def _first_doc_line(text: str | None) -> str | None:
|
|
if not text:
|
|
return None
|
|
first = text.strip().splitlines()[0].strip()
|
|
return first or None
|
|
|
|
|
|
def _language_for_extension(extension: str) -> str:
|
|
lowered = extension.lower()
|
|
if lowered in {'.py', '.pyi'}:
|
|
return 'python'
|
|
if lowered in {'.js', '.jsx'}:
|
|
return 'javascript'
|
|
if lowered in {'.ts', '.tsx'}:
|
|
return 'typescript'
|
|
if lowered == '.json':
|
|
return 'json'
|
|
return lowered.lstrip('.') or 'text'
|