Skip to content
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,12 @@ The cache's size limit evicts.

#### Invalidation

The server never decides on its own that an answer is stale.
The editor is the only source of change events.
A change to a file's text needs no event, because the text is read when it is asked for.
An index over which files exist does need one.
Two events invalidate: `workspace/didChangeWatchedFiles` for a path, and closing a document that was open.
Both flow through `InvalidatableInterface::invalidate`, which fans out to every invalidatable in the wiring.
An open buffer is not an invalidation; it wins by composite order while it is open.
A client may not support `workspace/didChangeWatchedFiles`.
`PollingFileWatcher` stands in for that client only, and reports the same events through the same fan-out.
Built-ins are never invalidated until the target environment can change.

## Handling Design or Specification Tensions
Expand Down
13 changes: 13 additions & 0 deletions deptrac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ deptrac:
- name: Knowledge
collectors:
- { type: directory, value: src/Knowledge/.* }
- name: Message
collectors:
- type: classLike
value: ^Firehed\\PhpLsp\\BeforeMessageInterface$
- name: Parser
collectors:
- { type: directory, value: src/Parser/.* }
Expand All @@ -62,13 +66,16 @@ deptrac:
- Completion
- Document
- Domain
- Filesystem
- Handler
- Knowledge
- Message
- Parser
- Protocol
- Repository
- Resolution
- Transport
- Watch
Handler:
- Cache # InvalidatableInterface: watched-file changes and close-after-edit drop cached on-disk state
- Capability
Expand Down Expand Up @@ -99,6 +106,12 @@ deptrac:
- Parser
- Repository
- Watch # WatchedPathsSourceInterface: holders name the paths they depend on
Watch:
- Cache # InvalidatableInterface: the stand-in reports through the same fan-out
- Capability
- Domain
- Filesystem
- Message
Repository:
- Document # FileUri, the path/URI conversion authority
- Domain
Expand Down
6 changes: 6 additions & 0 deletions docs/vim-ale.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ call ale#linter#Define('php', {
\})
```

## Changes on Disk

ALE's own LSP client does not send `workspace/didChangeWatchedFiles`.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claim should specify a version/commit/etc since ALE may fix this in the future.

php-lsp notices this from the capabilities ALE declares and looks at the disk itself before each message.
A class created, deleted, or regenerated by `composer install` is seen on the next request.

## Verifying the Connection

1. Open a PHP file
Expand Down
4 changes: 3 additions & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -267,10 +267,11 @@ parameters:
- 'disk_total_space()'
- 'parse_ini_file()'
- 'move_uploaded_file()'
message: 'filesystem access is confined: SourceFileReader reads source, PhpDirectoryReader lists directories, ComposerAutoloadMap and ComposerMapBackend read Composer metadata, transport owns its streams'
message: 'filesystem access is confined: SourceFileReader reads source, PhpDirectoryReader lists directories, StatReader reads modification times, ComposerAutoloadMap and ComposerMapBackend read Composer metadata, transport owns its streams'
allowIn:
- src/Document/SourceFileReader.php
- src/Filesystem/PhpDirectoryReader.php
- src/Filesystem/StatReader.php
- src/Domain/ComposerAutoloadMap.php
- src/Knowledge/ComposerMapBackend.php
- src/Transport/*
Expand Down Expand Up @@ -320,6 +321,7 @@ parameters:
- src/Handler/DidChangeWatchedFilesHandler.php # the watched-file trigger
- src/Handler/TextDocumentSyncHandler.php # the close-after-edit trigger
- src/Knowledge/CompositeInvalidatable.php # the fan-out itself
- src/Watch/PollingFileWatcher.php # the trigger for a client without watched-file support
- tests/*
-
method:
Expand Down
13 changes: 13 additions & 0 deletions src/BeforeMessageInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Firehed\PhpLsp;

interface BeforeMessageInterface
{
/**
* Called once for each message the server is about to handle.
*/
public function beforeMessage(): void;
}
14 changes: 14 additions & 0 deletions src/Filesystem/PathStamp.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Firehed\PhpLsp\Filesystem;

final readonly class PathStamp
{
public function __construct(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought the initial design had agreed on using a cheap hash rather than just time and size.

public int $modifiedAt,
public int $size,
) {
}
}
17 changes: 17 additions & 0 deletions src/Filesystem/StatReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Firehed\PhpLsp\Filesystem;

final class StatReader
{
public function stamp(string $path): ?PathStamp
{
// PHP remembers stat results for the life of the process.
clearstatcache(true, $path);
$stat = @stat($path);

return $stat === false ? null : new PathStamp($stat['mtime'], $stat['size']);
}
}
3 changes: 3 additions & 0 deletions src/Knowledge/KnowledgeStack.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Firehed\PhpLsp\Document\DocumentSourceInterface;
use Firehed\PhpLsp\Filesystem\PhpDirectoryReader;
use Firehed\PhpLsp\Parser\SyntaxSource\SyntaxSourceInterface;
use Firehed\PhpLsp\Watch\WatchedPathsSourceInterface;

/**
* Assembles the symbol-knowledge tier: the {@see SymbolSourceInterface} read composite
Expand All @@ -31,6 +32,7 @@ public function __construct(
public SymbolSourceInterface $source,
public SymbolSinkInterface $sink,
public InvalidatableInterface $invalidator,
public WatchedPathsSourceInterface $watched,
) {
}

Expand Down Expand Up @@ -63,6 +65,7 @@ public static function forProject(
new CompositeSymbolSource($openDocuments, $autoloadFiles, $composerMap, new BuiltinBackend()),
$openDocuments,
new CompositeInvalidatable($mapReader, $composerMap, $autoloadFiles),
new CompositeWatchedPaths($mapReader, $composerMap, $autoloadFiles),
);
}
}
17 changes: 15 additions & 2 deletions src/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use Firehed\PhpLsp\Completion\VariableCandidates;
use Firehed\PhpLsp\Document\DocumentManagerInterface;
use Firehed\PhpLsp\Document\DocumentSourceInterface;
use Firehed\PhpLsp\Filesystem\PhpDirectoryReader;
use Firehed\PhpLsp\Filesystem\StatReader;
use Firehed\PhpLsp\Handler\CompletionHandler;
use Firehed\PhpLsp\Handler\DefinitionHandler;
use Firehed\PhpLsp\Handler\DidChangeWatchedFilesHandler;
Expand Down Expand Up @@ -47,6 +49,7 @@
use Firehed\PhpLsp\Transport\EndOfStream;
use Firehed\PhpLsp\Transport\MalformedFrame;
use Firehed\PhpLsp\Transport\TransportInterface;
use Firehed\PhpLsp\Watch\PollingFileWatcher;

final class Server
{
Expand All @@ -67,6 +70,7 @@ public function __construct(
private readonly LifecycleHandler $lifecycleHandler,
array $handlers,
private readonly MessageScopedInterface $messageScope,
private readonly ?BeforeMessageInterface $beforeMessage = null,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this being optional seems like a recipe for bugs.

) {
$this->handlers = [$lifecycleHandler, ...$handlers];
}
Expand Down Expand Up @@ -137,7 +141,14 @@ public static function forProject(
// is no static server capability for them), gated on the client declaring
// support; the events invalidate cached workspace state (RFC 1 搂5.2, 搂5.3).
$watchedFilesRegistrar = new WatchedFilesRegistrar(new TransportClientConnection($transport));
$lifecycleHandler = new LifecycleHandler($negotiator, [$watchedFilesRegistrar]);
$fileWatcher = new PollingFileWatcher(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setup should happen in the DI container if possible. May defer if it turns into a wiring nightmare.

$knowledge->watched,
$invalidator,
new PhpDirectoryReader(),
new StatReader(),
time(...),
);
$lifecycleHandler = new LifecycleHandler($negotiator, [$watchedFilesRegistrar, $fileWatcher]);

$handlers = [
new TextDocumentSyncHandler(
Expand Down Expand Up @@ -173,7 +184,7 @@ public static function forProject(
),
];

return new self($transport, $lifecycleHandler, $handlers, $parser);
return new self($transport, $lifecycleHandler, $handlers, $parser, $fileWatcher);
}

public function run(): int
Expand Down Expand Up @@ -204,6 +215,8 @@ public function run(): int

if ($error === null) {
try {
$this->beforeMessage?->beforeMessage();

// Inside the try because `supports()` is part of the
// handler contract: a failure selecting a handler is a
// handler failure, and must be answered rather than
Expand Down
167 changes: 167 additions & 0 deletions src/Watch/PollingFileWatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

declare(strict_types=1);

namespace Firehed\PhpLsp\Watch;

use Closure;
use Firehed\PhpLsp\BeforeMessageInterface;
use Firehed\PhpLsp\Cache\InvalidatableInterface;
use Firehed\PhpLsp\Capability\InitializedListenerInterface;
use Firehed\PhpLsp\Capability\SessionCapabilities;
use Firehed\PhpLsp\Domain\FileUri;
use Firehed\PhpLsp\Filesystem\PhpDirectoryReader;
use Firehed\PhpLsp\Filesystem\StatReader;

/**
* Stands in for `workspace/didChangeWatchedFiles` with a client that cannot
* send it. [LSP] makes that notification the way a server learns of changes on
* disk, and makes supporting it optional for the client; with a client that
* does not, nothing else tells the server a file changed.
*
* It reports what the client would have: one event per file created, deleted,
* or changed, through the same {@see InvalidatableInterface} the notification's
* handler calls. It does nothing for a client that declared support.
*/
final class PollingFileWatcher implements BeforeMessageInterface, InitializedListenerInterface
{
private bool $standingIn = false;

/** @var array<string, Snapshot> File -> how it last looked */
private array $files = [];

/** @var array<string, array<string, Snapshot>> Root -> directory at or beneath it -> how it last looked */
private array $roots = [];

/**
* @param Closure(): int $now Unix time
*/
public function __construct(
private readonly WatchedPathsSourceInterface $watched,
private readonly InvalidatableInterface $invalidator,
private readonly PhpDirectoryReader $directories,
private readonly StatReader $stat,
private readonly Closure $now,
) {
}

public function beforeMessage(): void
{
if (!$this->standingIn) {
return;
}

$paths = $this->watched->watchedPaths();
$now = ($this->now)();
$changed = [
...$this->changedAmong($paths->files, $now),
...$this->changedUnder($paths->roots, $now),
];

foreach (array_unique($changed) as $path) {
$this->invalidator->invalidate(FileUri::fromPath($path));
}
}

public function onInitialized(SessionCapabilities $capabilities): void
{
$this->standingIn = !$capabilities->watchedFilesDynamicRegistration;
}

/**
* @param list<string> $files
* @return list<string>
*/
private function changedAmong(array $files, int $now): array
{
$changed = [];
$snapshots = [];
foreach ($files as $file) {
$stamp = $this->stat->stamp($file);
if (array_key_exists($file, $this->files) && $this->files[$file]->mayDifferFrom($stamp)) {
$changed[] = $file;
}
$snapshots[$file] = new Snapshot($stamp, $now);
}
$this->files = $snapshots;

return $changed;
}

/**
* @param list<string> $roots
* @return list<string>
*/
private function changedUnder(array $roots, int $now): array
{
$this->roots = array_intersect_key($this->roots, array_flip($roots));

$changed = [];
foreach ($roots as $root) {
if (!array_key_exists($root, $this->roots)) {
$this->roots[$root] = $this->snapshotsUnder($root, $now);
continue;
}
foreach ($this->roots[$root] as $directory => $before) {
$changed = [...$changed, ...$this->changedIn($root, $directory, $before, $now)];
}
}

return $changed;
}

/**
* @return list<string>
*/
private function changedIn(string $root, string $directory, Snapshot $before, int $now): array
{
$stamp = $this->stat->stamp($directory);
if (!$before->mayDifferFrom($stamp)) {
return [];
}

$listing = $this->directories->read($directory);
if ($listing === null) {
// The root stays watched so its return is seen; anything beneath it
// is found again through its parent.
if ($directory === $root) {
$this->roots[$root][$directory] = new Snapshot(null, $now);
} else {
unset($this->roots[$root][$directory]);
}

return $before->files;
}

$this->roots[$root][$directory] = new Snapshot($stamp, $now, $listing->files);
$changed = [
...array_diff($listing->files, $before->files),
...array_diff($before->files, $listing->files),
];

foreach ($listing->directories as $child) {
if (array_key_exists($child, $this->roots[$root])) {
continue;
}
foreach ($this->snapshotsUnder($child, $now) as $path => $snapshot) {
$this->roots[$root][$path] = $snapshot;
$changed = [...$changed, ...$snapshot->files];
}
}

return $changed;
}

/**
* @return array<string, Snapshot> The directory and every directory beneath it
*/
private function snapshotsUnder(string $directory, int $now): array
{
$snapshots = [$directory => new Snapshot($this->stat->stamp($directory), $now)];
foreach ($this->directories->walk($directory) as $listing) {
$snapshots[$listing->path] = new Snapshot($this->stat->stamp($listing->path), $now, $listing->files);
}

return $snapshots;
}
}
Loading
Loading