-
Notifications
You must be signed in to change notification settings - Fork 0
Stand in for watched files when the client lacks them #652
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3224edb
564ea9e
74c6394
833bf13
5c11ec0
4a64849
39943ae
06dc420
63c640b
a0145d3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } |
| 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( | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| ) { | ||
| } | ||
| } | ||
| 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']); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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 | ||
| { | ||
|
|
@@ -67,6 +70,7 @@ public function __construct( | |
| private readonly LifecycleHandler $lifecycleHandler, | ||
| array $handlers, | ||
| private readonly MessageScopedInterface $messageScope, | ||
| private readonly ?BeforeMessageInterface $beforeMessage = null, | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]; | ||
| } | ||
|
|
@@ -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( | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
||
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.