Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7342dcb
Test one source for a document's declarations
Firehed Sep 29, 2026
f64376b
DeclaredSymbol answers whether it declares a name
Firehed Sep 29, 2026
1c9d8da
Add DeclarationSourceInterface and its parsing implementation
Firehed Sep 29, 2026
1761107
Backends take declarations from DeclarationSourceInterface
Firehed Sep 29, 2026
52d867c
Test declarations are remembered per document
Firehed Sep 29, 2026
2439ce5
CacheFactory can bound the in-memory cache
Firehed Sep 29, 2026
46e9806
Add CachingDeclarationSource keyed by file and text
Firehed Sep 29, 2026
ebd61ea
Test two names in one file cost one parse
Firehed Sep 29, 2026
26effce
Remember declarations per document across messages
Firehed Sep 29, 2026
1c52348
Test a class removed in an open buffer stops resolving
Firehed Sep 29, 2026
bc2955e
Remove the name-keyed CachingSymbolSource
Firehed Sep 29, 2026
9a95146
TextDocument indexes line starts on first use
Firehed Sep 29, 2026
2a396ac
Remove the test of the deleted CachingSymbolSource
Firehed Sep 29, 2026
c24d846
Test the invalidation fan-out through its own members
Firehed Sep 29, 2026
11fdf81
Expect the sync path to parse a text once
Firehed Sep 29, 2026
b3ea6b0
Allowlist drops the deleted CachingSymbolSource
Firehed Sep 29, 2026
438f0ae
Describe how declarations are remembered
Firehed Sep 29, 2026
17124c1
Switch new params on cacheFactory to use named arguments
Firehed Sep 29, 2026
a9032ae
Name the declaration cache cap by what constrains it
Firehed Sep 29, 2026
d46d7cd
Drop unused DocumentManager::isOpen
Firehed Sep 29, 2026
afa7542
Drop DeclarationSymbolInfoFactory::fromDeclarations
Firehed Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ While a document is open the server must not read it from disk ([LSP] `textDocum

A service may hold an index it derives from its whole source and rebuilds on invalidation.
A service never remembers the answer to a query.
Answers are remembered by one PSR-16 decorator per backend, applied in wiring, never around open documents.
What a document declares is remembered by one PSR-16 decorator on `DeclarationSourceInterface`, applied in wiring.
Its key is the document's path and text together, so an entry describes one state of one file and is never invalidated.
The cache's size limit evicts.

#### Invalidation

Expand Down
1 change: 0 additions & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,6 @@ parameters:
- src/Resolution/ExpressionResolver.php # resolveConstFetch: the candidate-loop caller
- src/Resolution/TypeSource/NativeTypeSource.php # identity-keyed type authority (issue #519)
- src/Knowledge/CompositeSymbolSource.php # composite delegates to backends
- src/Knowledge/CachingSymbolSource.php # decorator delegates to the wrapped source
- tests/*
-
method: 'Firehed\PhpLsp\Domain\ResolvedSymbolInterface::format()'
Expand Down
13 changes: 11 additions & 2 deletions src/Cache/CacheFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,20 @@
*/
final class CacheFactory
{
public static function inMemory(): CacheInterface
/**
* @param int $maxItems The least recently used entry is dropped past this
* count; zero is unbounded
*/
public static function inMemory(int $maxItems = 0): CacheInterface
{
// ArrayAdapter's second argument disables its copy-on-read (named
// `storeSerialized` in symfony/cache 7, `deepClone` in 8), so a hit
// returns the cached instance rather than a clone.
return new Psr16Cache(new ArrayAdapter(0, false));
return new Psr16Cache(new ArrayAdapter(
defaultLifetime: 0,
deepClone: false,
maxLifetime: 0,
maxItems: $maxItems,
));
}
}
5 changes: 0 additions & 5 deletions src/Document/DocumentManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,6 @@ public function get(string $uri): ?TextDocument
return $this->documents[FileUri::toPath($uri)] ?? null;
}

public function isOpen(string $uri): bool
{
return $this->get($uri) !== null;
}

public function read(string $uri): ?TextDocument
{
return $this->get($uri);
Expand Down
2 changes: 0 additions & 2 deletions src/Document/DocumentManagerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,4 @@ public function update(string $uri, string $content, int $version): void;
public function close(string $uri): void;

public function get(string $uri): ?TextDocument;

public function isOpen(string $uri): bool;
}
32 changes: 21 additions & 11 deletions src/Document/TextDocument.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

final class TextDocument
{
/** @var list<int> Byte offsets of line starts */
private array $lineOffsets;
/** @var list<int>|null */
private ?array $offsets = null;

public function __construct(
public readonly string $uri,
Expand All @@ -18,7 +18,6 @@ public function __construct(
private readonly string $content,
private readonly PositionEncoding $encoding = PositionEncoding::Utf16,
) {
$this->lineOffsets = $this->computeLineOffsets();
}

public function getContent(): string
Expand All @@ -33,13 +32,13 @@ public function withContent(string $content, int $version): self

public function getLine(int $line): string
{
if ($line < 0 || $line >= count($this->lineOffsets)) {
if ($line < 0 || $line >= count($this->lineOffsets())) {
return '';
}

$start = $this->lineOffsets[$line];
$end = $line + 1 < count($this->lineOffsets)
? $this->lineOffsets[$line + 1] - 1 // -1 to exclude newline
$start = $this->lineOffsets()[$line];
$end = $line + 1 < count($this->lineOffsets())
? $this->lineOffsets()[$line + 1] - 1 // -1 to exclude newline
: strlen($this->content);

return substr($this->content, $start, $end - $start);
Expand All @@ -61,14 +60,14 @@ public function textBeforeCursor(int $line, int $character): string

public function offsetAt(int $line, int $character): int
{
if ($line < 0 || $line >= count($this->lineOffsets)) {
if ($line < 0 || $line >= count($this->lineOffsets())) {
return 0;
}

// The negotiated encoding measures `character`; the interior is bytes.
// Converting against the line content also clamps an over-long column
// to the line's byte length, so no separate line-end clamp is needed.
return $this->lineOffsets[$line]
return $this->lineOffsets()[$line]
+ $this->encoding->characterToByteOffset($this->getLine($line), $character);
}

Expand All @@ -80,7 +79,7 @@ public function positionAt(int $offset): array
$offset = max(0, min($offset, strlen($this->content)));

$line = 0;
foreach ($this->lineOffsets as $i => $lineOffset) {
foreach ($this->lineOffsets() as $i => $lineOffset) {
if ($lineOffset > $offset) {
break;
}
Expand All @@ -91,11 +90,22 @@ public function positionAt(int $offset): array
'line' => $line,
'character' => $this->encoding->byteToCharacterOffset(
$this->getLine($line),
$offset - $this->lineOffsets[$line],
$offset - $this->lineOffsets()[$line],
),
];
}

/**
* Byte offsets of line starts. Derived on first use: a document read only
* for what it declares is never asked for a position.
*
* @return list<int>
*/
private function lineOffsets(): array
{
return $this->offsets ??= $this->computeLineOffsets();
}

/**
* @return list<int>
*/
Expand Down
5 changes: 5 additions & 0 deletions src/Domain/DeclaredSymbol.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ public function __construct(
public SymbolInfoInterface $info,
) {
}

public function declares(QualifiedName $name, NameKind $kind): bool
{
return $this->kind->keyFor($this->name) === $kind->keyFor($name);
}
}
12 changes: 5 additions & 7 deletions src/Knowledge/AutoloadFilesBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use Firehed\PhpLsp\Document\DocumentSourceInterface;
use Firehed\PhpLsp\Domain\ComposerAutoloadMap;
use Firehed\PhpLsp\Domain\FileUri;
use Firehed\PhpLsp\Parser\SyntaxSource\SyntaxSourceInterface;

/**
* The {@see SymbolSourceInterface} over Composer's `autoload.files` set — the one
Expand Down Expand Up @@ -41,10 +40,8 @@ final class AutoloadFilesBackend implements SymbolSourceInterface, Invalidatable

public function __construct(
private readonly ComposerAutoloadMapReader $mapReader,
private readonly DeclarationSymbolInfoFactory $infoFactory,
private readonly DeclarationScanner $scanner,
private readonly DocumentSourceInterface $reader,
private readonly SyntaxSourceInterface $parser,
private readonly DocumentSourceInterface $documents,
private readonly DeclarationSourceInterface $declarations,
) {
$this->buildIndex($this->mapReader->current());
}
Expand All @@ -69,8 +66,9 @@ private function buildIndex(ComposerAutoloadMap $map): void
{
$this->clearAllSymbols();
foreach ($map->autoloadFiles() as $path) {
$declarations = $this->scanner->scanFile($path, $this->reader, $this->parser);
$this->setSymbolsFor(FileUri::fromPath($path), ...$this->infoFactory->allIn($declarations, $path));
$document = $this->documents->read($path);
$declared = $document === null ? [] : $this->declarations->declarationsIn($document);
$this->setSymbolsFor(FileUri::fromPath($path), ...$declared);
}
$this->mapAtBuild = $map;
}
Expand Down
3 changes: 1 addition & 2 deletions src/Knowledge/BuiltinBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@
* open-document and disk backends, so a name either of them can resolve never
* reaches reflection (RFC 1 §5.3).
*
* Built-ins are fixed for a given target environment, so a {@see CachingSymbolSource}
* in front of this backend remembers them (RFC 1 §5.3). This backend is reflection-backed and therefore describes the
* This backend is reflection-backed and therefore describes the
* *server's* runtime, not the project's target — a known §4.7 gap deferred to Step 5
* (Plan 0002 §5); the interim treats every reflected built-in as available.
*
Expand Down
38 changes: 38 additions & 0 deletions src/Knowledge/CachingDeclarationSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Firehed\PhpLsp\Knowledge;

use Firehed\PhpLsp\Cache\CacheKey;
use Firehed\PhpLsp\Document\TextDocument;
use Firehed\PhpLsp\Domain\DeclaredSymbol;
use Firehed\PhpLsp\Domain\FileUri;
use Psr\SimpleCache\CacheInterface;

/**
* Keyed by the file and its text together, so an entry describes exactly one
* state of one file and never goes stale: changed text is a different key.
*/
final readonly class CachingDeclarationSource implements DeclarationSourceInterface
{
public function __construct(
private DeclarationSourceInterface $inner,
private CacheInterface $cache,
) {
}

public function declarationsIn(TextDocument $document): array
{
$key = CacheKey::from(FileUri::toPath($document->uri) . "\0" . $document->getContent());

/** @var list<DeclaredSymbol>|null $declared */
$declared = $this->cache->get($key);
if ($declared === null) {
$declared = $this->inner->declarationsIn($document);
$this->cache->set($key, $declared);
}

return $declared;
}
}
Loading
Loading