Files
clice/tests/integration/test_index.py
ykiko e24eff6c16 refactor: pull-based compilation for document lifecycle (#385)
## Summary

Replace the push-based compilation model with a pull-based (lazy) model
where compilation is driven entirely by feature requests.

### Server core (`master_server.cpp/h`)
- **Remove** `schedule_build()`, `run_build_drain()`, debounce timers,
and `DocumentState` flags (`build_running`, `build_requested`,
`drain_scheduled`)
- **Remove** `debounce_ms` config field
- `didOpen`/`didChange` only update `DocumentState` and mark `ast_dirty`
— no compilation triggered
- `didSave` marks dependent docs dirty via `CompileGraph::update()`,
invalidates PCH hashes, marks **all** open documents `ast_dirty` (header
saves), and queues background indexing
- **Implement** `ensure_compiled(path_id)` — the pull-based entry point
called by `forward_stateful()`/`forward_stateless()` before every
feature request:
  1. Fast-path if `!ast_dirty`
  2. Compile C++20 module deps via `compile_graph->compile_deps()`
  3. Build/reuse PCH via `ensure_pch()` (only attach on success)
  4. Send `CompileParams` to stateful worker
  5. Publish diagnostics, clear dirty, schedule indexing
  6. Generation mismatch → return `false`, keep dirty for retry
- `forward_stateless()` now also calls `compile_graph->compile_deps()`
before stateless requests (completion/signatureHelp)
- Move module-implementation-unit implicit dependency handling into
`resolve_fn` (was duplicated in `run_build_drain` and `ensure_compiled`)

### CompileGraph (`compile_graph.cpp/h`)
- **Add** `compile_deps(path_id)` — compiles all transitive module
dependencies but NOT the file itself (used for plain .cpp files that
`import` modules)
- Unify `compile`/`compile_deps` via `compile_impl(path_id, ancestors,
dispatch_self)` parameter
- `compile_deps` compiles dependencies concurrently via `when_all`
- Extract `finish()` lambda to deduplicate `compiling=false;
completion->set()` cleanup across all exit paths
- Use `std::ranges::remove` instead of legacy `std::remove`

### Test infrastructure (`conftest.py`)
- `open_and_wait()` now sends a hover request to trigger
`ensure_compiled()` (pull-based model requires a feature request to
compile)
- Fix URI handling: send percent-encoded URI on the wire, normalize for
internal lookups, store diagnostics under both raw and normalized URI
keys
- Add `_normalize_uri()` helper using `urllib.parse.unquote`

### Integration tests
- Update all tests for pull-based model: no more waiting on `didOpen`
diagnostics
- `_wait_for_index()` sends hover to trigger compilation before polling
`workspace/symbol`
- `test_hover_save_close` simplified — hover directly triggers
compilation
- `test_save_recompile` and `test_pch_*` wait for fresh diagnostics
after hover-triggered recompilation

### Unit tests (`compile_graph_tests.cpp`)
- Extract `compiled`/`graph` as TEST_SUITE members with
`std::optional<CompileGraph>`
- Extract `execute(callback)` helper to deduplicate event_loop
boilerplate
- Add 8 new `compile_deps` tests: no-deps, single dep, chain, diamond,
failure, plain-cpp, concurrent dedup, resolve-once
- Remove redundant `inline` on file-scope helpers

## Test plan
- [x] Unit tests: 426 passed, 5 skipped
- [x] Smoke tests: 1/1 passed
- [x] Integration tests: 69 passed, 0 failed, no hangs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:35:17 +08:00

294 lines
11 KiB
Python

"""Integration tests for index-based LSP features: GoToDefinition, FindReferences,
CallHierarchy, TypeHierarchy, and WorkspaceSymbol."""
import asyncio
import pytest
from lsprotocol.types import (
CallHierarchyIncomingCallsParams,
CallHierarchyOutgoingCallsParams,
CallHierarchyPrepareParams,
DefinitionParams,
DidCloseTextDocumentParams,
Position,
ReferenceContext,
ReferenceParams,
TextDocumentIdentifier,
TypeHierarchyPrepareParams,
TypeHierarchySubtypesParams,
TypeHierarchySupertypesParams,
WorkspaceSymbolParams,
)
def _doc(uri: str) -> TextDocumentIdentifier:
return TextDocumentIdentifier(uri=uri)
async def _wait_for_index(client, uri, timeout=30):
"""Trigger compilation via a hover request, then poll workspace/symbol until
indexing is ready (symbols appear)."""
from lsprotocol.types import HoverParams
# Send a hover request to trigger ensure_compiled() → compilation → indexing
await client.text_document_hover_async(
HoverParams(
text_document=_doc(uri),
position=Position(line=0, character=0),
)
)
for _ in range(timeout):
result = await client.workspace_symbol_async(WorkspaceSymbolParams(query="add"))
if result and any(s.name == "add" for s in result):
return True
await asyncio.sleep(1)
return False
# ---------------------------------------------------------------------------
# GoToDefinition
# ---------------------------------------------------------------------------
@pytest.mark.workspace("index_features")
async def test_goto_definition(client, workspace):
"""Test GoToDefinition navigates from a call site to the function definition."""
uri, _ = await client.open_and_wait(workspace / "main.cpp")
assert await _wait_for_index(client, uri), "Index not ready after 30s"
# 'add' call on line 24 (0-indexed), column 12
result = await client.text_document_definition_async(
DefinitionParams(
text_document=_doc(uri),
position=Position(line=24, character=12),
)
)
assert result is not None
locs = result if isinstance(result, list) else [result]
assert len(locs) > 0, f"GoToDefinition returned empty list, result={result}"
# Definition should point to line 18 where 'int add(...)' is declared
assert any(loc.range.start.line == 18 for loc in locs), (
f"Expected line 18, got locations:"
f" {[(loc.uri, loc.range.start.line, loc.range.start.character) for loc in locs]}"
)
client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri)))
# ---------------------------------------------------------------------------
# FindReferences
# ---------------------------------------------------------------------------
@pytest.mark.workspace("index_features")
async def test_find_references(client, workspace):
"""Test FindReferences returns all usages of global_var."""
uri, _ = await client.open_and_wait(workspace / "main.cpp")
assert await _wait_for_index(client, uri), "Index not ready after 30s"
# global_var definition on line 30 (0-indexed), column 4
result = await client.text_document_references_async(
ReferenceParams(
text_document=_doc(uri),
position=Position(line=30, character=4),
context=ReferenceContext(include_declaration=True),
)
)
assert result is not None, "FindReferences returned None"
# global_var is declared on line 30 and used on lines 33 and 37
assert len(result) >= 3, (
f"Expected >=3 refs, got {len(result)}:"
f" {[(r.uri, r.range.start.line) for r in result]}"
)
client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri)))
# ---------------------------------------------------------------------------
# CallHierarchy
# ---------------------------------------------------------------------------
@pytest.mark.workspace("index_features")
async def test_call_hierarchy_prepare(client, workspace):
"""Test prepareCallHierarchy returns a CallHierarchyItem for 'add'."""
uri, _ = await client.open_and_wait(workspace / "main.cpp")
assert await _wait_for_index(client, uri), "Index not ready after 30s"
# 'add' definition at line 18 (0-indexed), column 4
result = await client.text_document_prepare_call_hierarchy_async(
CallHierarchyPrepareParams(
text_document=_doc(uri),
position=Position(line=18, character=4),
)
)
assert result is not None, "prepareCallHierarchy returned None"
assert len(result) > 0, f"prepareCallHierarchy returned empty, result={result}"
assert result[0].name == "add", f"Expected 'add', got '{result[0].name}'"
client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri)))
@pytest.mark.workspace("index_features")
async def test_call_hierarchy_incoming(client, workspace):
"""Test incomingCalls shows compute() calls add()."""
uri, _ = await client.open_and_wait(workspace / "main.cpp")
assert await _wait_for_index(client, uri), "Index not ready after 30s"
# Prepare call hierarchy for 'add' at line 18 (0-indexed), column 4
items = await client.text_document_prepare_call_hierarchy_async(
CallHierarchyPrepareParams(
text_document=_doc(uri),
position=Position(line=18, character=4),
)
)
assert items and len(items) > 0, f"prepareCallHierarchy returned {items}"
incoming = await client.call_hierarchy_incoming_calls_async(
CallHierarchyIncomingCallsParams(item=items[0])
)
assert incoming is not None, "incomingCalls returned None"
caller_names = [call.from_.name for call in incoming]
assert "compute" in caller_names, (
f"Expected 'compute' in callers, got {caller_names}"
)
client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri)))
@pytest.mark.workspace("index_features")
async def test_call_hierarchy_outgoing(client, workspace):
"""Test outgoingCalls shows compute() calls add()."""
uri, _ = await client.open_and_wait(workspace / "main.cpp")
assert await _wait_for_index(client, uri), "Index not ready after 30s"
# Prepare call hierarchy for 'compute' at line 23 (0-indexed), column 4
items = await client.text_document_prepare_call_hierarchy_async(
CallHierarchyPrepareParams(
text_document=_doc(uri),
position=Position(line=23, character=4),
)
)
assert items and len(items) > 0, f"prepareCallHierarchy returned {items}"
outgoing = await client.call_hierarchy_outgoing_calls_async(
CallHierarchyOutgoingCallsParams(item=items[0])
)
assert outgoing is not None, "outgoingCalls returned None"
callee_names = [call.to.name for call in outgoing]
assert "add" in callee_names, f"Expected 'add' in callees, got {callee_names}"
client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri)))
# ---------------------------------------------------------------------------
# TypeHierarchy
# ---------------------------------------------------------------------------
@pytest.mark.workspace("index_features")
async def test_type_hierarchy_prepare(client, workspace):
"""Test prepareTypeHierarchy returns a TypeHierarchyItem for 'Dog'."""
uri, _ = await client.open_and_wait(workspace / "main.cpp")
assert await _wait_for_index(client, uri), "Index not ready after 30s"
# 'Dog' at line 8 (0-indexed), column 7
result = await client.text_document_prepare_type_hierarchy_async(
TypeHierarchyPrepareParams(
text_document=_doc(uri),
position=Position(line=8, character=7),
)
)
assert result is not None, "prepareTypeHierarchy returned None"
assert len(result) > 0, f"prepareTypeHierarchy returned empty"
assert result[0].name == "Dog", f"Expected 'Dog', got '{result[0].name}'"
client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri)))
@pytest.mark.workspace("index_features")
async def test_type_hierarchy_supertypes(client, workspace):
"""Test supertypes of Dog includes Animal."""
uri, _ = await client.open_and_wait(workspace / "main.cpp")
assert await _wait_for_index(client, uri), "Index not ready after 30s"
# 'Dog' at line 8 (0-indexed), column 7
items = await client.text_document_prepare_type_hierarchy_async(
TypeHierarchyPrepareParams(
text_document=_doc(uri),
position=Position(line=8, character=7),
)
)
assert items and len(items) > 0, f"prepareTypeHierarchy returned {items}"
supertypes = await client.type_hierarchy_supertypes_async(
TypeHierarchySupertypesParams(item=items[0])
)
assert supertypes is not None, "supertypes returned None"
supertype_names = [t.name for t in supertypes]
assert "Animal" in supertype_names, (
f"Expected 'Animal' in supertypes, got {supertype_names}"
)
client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri)))
@pytest.mark.workspace("index_features")
async def test_type_hierarchy_subtypes(client, workspace):
"""Test subtypes of Animal includes Dog and Cat."""
uri, _ = await client.open_and_wait(workspace / "main.cpp")
assert await _wait_for_index(client, uri), "Index not ready after 30s"
# 'Animal' at line 1, column 7
items = await client.text_document_prepare_type_hierarchy_async(
TypeHierarchyPrepareParams(
text_document=_doc(uri),
position=Position(line=1, character=7),
)
)
assert items and len(items) > 0, f"prepareTypeHierarchy returned {items}"
subtypes = await client.type_hierarchy_subtypes_async(
TypeHierarchySubtypesParams(item=items[0])
)
assert subtypes is not None, "subtypes returned None"
subtype_names = [t.name for t in subtypes]
assert "Dog" in subtype_names, f"Expected 'Dog' in subtypes, got {subtype_names}"
assert "Cat" in subtype_names, f"Expected 'Cat' in subtypes, got {subtype_names}"
client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri)))
# ---------------------------------------------------------------------------
# WorkspaceSymbol
# ---------------------------------------------------------------------------
@pytest.mark.workspace("index_features")
async def test_workspace_symbol(client, workspace):
"""Test workspace/symbol finds symbols by query string."""
uri, _ = await client.open_and_wait(workspace / "main.cpp")
assert await _wait_for_index(client, uri), "Index not ready after 30s"
result = await client.workspace_symbol_async(WorkspaceSymbolParams(query="add"))
assert result is not None
names = [s.name for s in result]
assert "add" in names
client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri)))
@pytest.mark.workspace("index_features")
async def test_workspace_symbol_class(client, workspace):
"""Test workspace/symbol finds class symbols."""
uri, _ = await client.open_and_wait(workspace / "main.cpp")
assert await _wait_for_index(client, uri), "Index not ready after 30s"
result = await client.workspace_symbol_async(WorkspaceSymbolParams(query="Animal"))
assert result is not None
names = [s.name for s in result]
assert "Animal" in names
client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri)))