From 2ef3ef0722bc7aa83491bbedeee1e5c25fd79dcf Mon Sep 17 00:00:00 2001
From: dantleech <dan.t.leech@gmail.com>
Date: Sat, 2 Jan 2021 22:04:35 +0000
Subject: [PATCH 01/19] Add common README sections (#14)

---
 README.md | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/README.md b/README.md
index 6a8ecce1..914da576 100644
--- a/README.md
+++ b/README.md
@@ -5,3 +5,16 @@ Text Document
 
 Collection of value objects for representing and referencing text documents
 (principally source code) for use with [Phpactor](https://phpactor/phpactor).
+
+Contributing
+------------
+
+This package is open source and welcomes contributions! Feel free to open a
+pull request on this repository.
+
+Support
+-------
+
+- Create an issue on the main [Phpactor](https://github.com/phpactor/phpactor) repository.
+- Join the `#phpactor` channel on the Slack [Symfony Devs](https://symfony.com/slack-invite) channel.
+

From cdb3b2489f09e3634bd3d36280de7bed954bf1ce Mon Sep 17 00:00:00 2001
From: dantleech <dan.t.leech@gmail.com>
Date: Wed, 6 Jan 2021 22:10:03 +0000
Subject: [PATCH 02/19] Update gitignore (#15)

---
 .gitignore | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.gitignore b/.gitignore
index a0239343..be1dd7e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@
 /.php_cs.cache
 /stubs
 .phpunit.result.cache
+/.vscode
\ No newline at end of file

From b8170622968e2acc98c8f90f629e4e8fa8f3ea1a Mon Sep 17 00:00:00 2001
From: dantleech <dan.t.leech@gmail.com>
Date: Sat, 9 Jan 2021 16:34:22 +0000
Subject: [PATCH 03/19] Added Workspace API (#16)

* Added Workspace API

* Fix typo

* Compat with 7.3
---
 lib/Exception/CouldNotSaveDocument.php      |  9 +++
 lib/Workspace.php                           | 23 ++++++
 lib/Workspace/ChainWorkspace.php            | 58 +++++++++++++++
 lib/Workspace/InMemoryWorkspace.php         | 55 ++++++++++++++
 phpunit.xml.dist                            |  8 ---
 tests/Unit/Workspace/ChainWorkspaceTest.php | 79 +++++++++++++++++++++
 6 files changed, 224 insertions(+), 8 deletions(-)
 create mode 100644 lib/Exception/CouldNotSaveDocument.php
 create mode 100644 lib/Workspace.php
 create mode 100644 lib/Workspace/ChainWorkspace.php
 create mode 100644 lib/Workspace/InMemoryWorkspace.php
 create mode 100644 tests/Unit/Workspace/ChainWorkspaceTest.php

diff --git a/lib/Exception/CouldNotSaveDocument.php b/lib/Exception/CouldNotSaveDocument.php
new file mode 100644
index 00000000..86d5efa2
--- /dev/null
+++ b/lib/Exception/CouldNotSaveDocument.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Phpactor\TextDocument\Exception;
+
+use RuntimeException;
+
+class CouldNotSaveDocument extends RuntimeException
+{
+}
diff --git a/lib/Workspace.php b/lib/Workspace.php
new file mode 100644
index 00000000..8bfae068
--- /dev/null
+++ b/lib/Workspace.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use Phpactor\TextDocument\Exception\CouldNotSaveDocument;
+
+/**
+ * Represents a workspace to which documents can be persisted and retrieved
+ */
+interface Workspace
+{
+    /**
+     * Retrieve text document by URI, returns NULL if not found
+     */
+    public function get(TextDocumentUri $uri): ?TextDocument;
+
+    /**
+     * Save a text document to the workspace.
+     *
+     * @throws CouldNotSaveDocument
+     */
+    public function save(TextDocument $document): void;
+}
diff --git a/lib/Workspace/ChainWorkspace.php b/lib/Workspace/ChainWorkspace.php
new file mode 100644
index 00000000..408d2983
--- /dev/null
+++ b/lib/Workspace/ChainWorkspace.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Phpactor\TextDocument\Workspace;
+
+use Phpactor\TextDocument\TextDocument;
+use Phpactor\TextDocument\TextDocumentUri;
+use Phpactor\TextDocument\Workspace;
+
+/**
+ * Combine multiple workspace.
+ *
+ * The behavior is slightly different for the two methods:
+ *
+ * - `get`: Return the document from the first workspace that has it.
+ * - `save`: Save the document in ALL workspaces.
+ */
+class ChainWorkspace implements Workspace
+{
+    /**
+     * @var Workspace[]
+     */
+    private $workspaces;
+
+    /**
+     * @param Workspace[] $workspaces
+     */
+    public function __construct(array $workspaces)
+    {
+        $this->workspaces = $workspaces;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function get(TextDocumentUri $uri): ?TextDocument
+    {
+        foreach ($this->workspaces as $workspace) {
+            $document = $workspace->get($uri);
+            if (null === $document) {
+                continue;
+            }
+
+            return $document;
+        }
+
+        return null;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function save(TextDocument $document): void
+    {
+        foreach ($this->workspaces as $workspace) {
+            $workspace->save($document);
+        }
+    }
+}
diff --git a/lib/Workspace/InMemoryWorkspace.php b/lib/Workspace/InMemoryWorkspace.php
new file mode 100644
index 00000000..b4377ffc
--- /dev/null
+++ b/lib/Workspace/InMemoryWorkspace.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Phpactor\TextDocument\Workspace;
+
+use Phpactor\TextDocument\TextDocument;
+use Phpactor\TextDocument\TextDocumentUri;
+use Phpactor\TextDocument\Workspace;
+
+final class InMemoryWorkspace implements Workspace
+{
+    /**
+     * @var array<string, TextDocument>
+     */
+    private $documents = [];
+
+    /**
+     * @param array<string, TextDocument> $textDocuments
+     */
+    private function __construct(array $textDocuments)
+    {
+        $this->documents = $textDocuments;
+    }
+
+    public function get(TextDocumentUri $uri): ?TextDocument
+    {
+        if (isset($this->documents[$uri->__toString()])) {
+            return $this->documents[$uri->__toString()];
+        }
+
+        return null;
+    }
+
+    /**
+     * @param TextDocument[] $textDocuments
+     */
+    public static function fromTextDocuments(array $textDocuments): self
+    {
+        return new self((array)array_combine(array_map(function (TextDocument $document) {
+            return $document->uri()->__toString();
+        }, $textDocuments), array_values($textDocuments)));
+    }
+
+    public static function new(): self
+    {
+        return new self([]);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function save(TextDocument $document): void
+    {
+        $this->documents[$document->uri()->__toString()] = $document;
+    }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 7f280a19..dc91c922 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,14 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!-- http://www.phpunit.de/manual/current/en/appendixes.configuration.html -->
 <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" colors="true" bootstrap="vendor/autoload.php" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
-  <coverage includeUncoveredFiles="true">
-    <include>
-      <directory>.</directory>
-    </include>
-    <exclude>
-      <directory>vendor/</directory>
-    </exclude>
-  </coverage>
   <testsuites>
     <testsuite name="Worse Completion">
       <directory>./tests</directory>
diff --git a/tests/Unit/Workspace/ChainWorkspaceTest.php b/tests/Unit/Workspace/ChainWorkspaceTest.php
new file mode 100644
index 00000000..a388354c
--- /dev/null
+++ b/tests/Unit/Workspace/ChainWorkspaceTest.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit\Workspace;
+
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\TextDocumentBuilder;
+use Phpactor\TextDocument\TextDocumentUri;
+use Phpactor\TextDocument\Workspace;
+use Phpactor\TextDocument\Workspace\ChainWorkspace;
+use Phpactor\TextDocument\Workspace\InMemoryWorkspace;
+
+class ChainWorkspaceTest extends TestCase
+{
+    public function testGetReturnsNullIfNoWorkspaces(): void
+    {
+        self::assertNull(
+            $this->createWorkspace()->get(
+                TextDocumentUri::fromString('file:///foobar')
+            )
+        );
+    }
+
+    public function testReturnsTextDocument(): void
+    {
+        $document = TextDocumentBuilder::create('foobar')->uri('/path/to/foo')->build();
+
+        self::assertSame(
+            $document,
+            $this->createWorkspace([
+                InMemoryWorkspace::fromTextDocuments([
+                    $document
+                ])
+            ])->get(TextDocumentUri::fromString('file:///path/to/foo'))
+        );
+    }
+
+    public function testReturnsTextDocumentFromFirstWorkspace(): void
+    {
+        $document1 = TextDocumentBuilder::create('one')->uri('/path/to/foo')->build();
+        $document2 = TextDocumentBuilder::create('two')->uri('/path/to/foo')->build();
+
+        self::assertSame(
+            $document1,
+            $this->createWorkspace([
+                InMemoryWorkspace::fromTextDocuments([
+                    $document1
+                ]),
+                InMemoryWorkspace::fromTextDocuments([
+                    $document2
+                ])
+            ])->get(TextDocumentUri::fromString('file:///path/to/foo'))
+        );
+    }
+
+    public function testSavesNonExistingDocumentToAllWorkspaces(): void
+    {
+        $document1 = TextDocumentBuilder::create('one')->uri('/path/to/foo')->build();
+
+        $workspace1 = InMemoryWorkspace::new();
+        $workspace2 = InMemoryWorkspace::new();
+
+        $chain = $this->createWorkspace([
+            $workspace1,
+            $workspace2,
+        ]);
+
+        $chain->save($document1);
+        self::assertSame($document1, $workspace1->get($document1->uri()));
+        self::assertSame($document1, $workspace2->get($document1->uri()));
+    }
+
+    /**
+     * @param Workspace[] $workspaces
+     */
+    private function createWorkspace(array $workspaces = []): ChainWorkspace
+    {
+        return new ChainWorkspace($workspaces);
+    }
+}

From c47eaf4a41465853e75600309f838ecdb59781c3 Mon Sep 17 00:00:00 2001
From: dantleech <dan.t.leech@gmail.com>
Date: Sat, 9 Jan 2021 20:40:14 +0000
Subject: [PATCH 04/19] Refactor to single-purpose document-locator (#17)

---
 lib/TextDocumentLocator.php                   | 15 +++++
 .../ChainDocumentLocator.php                  | 42 ++++++++++++++
 .../InMemoryDocumentLocator.php}              | 22 +++----
 lib/Workspace.php                             | 23 --------
 lib/Workspace/ChainWorkspace.php              | 58 -------------------
 .../ChainDocumentLocatorTest.php}             | 41 +++++--------
 6 files changed, 79 insertions(+), 122 deletions(-)
 create mode 100644 lib/TextDocumentLocator.php
 create mode 100644 lib/TextDocumentLocator/ChainDocumentLocator.php
 rename lib/{Workspace/InMemoryWorkspace.php => TextDocumentLocator/InMemoryDocumentLocator.php} (68%)
 delete mode 100644 lib/Workspace.php
 delete mode 100644 lib/Workspace/ChainWorkspace.php
 rename tests/Unit/{Workspace/ChainWorkspaceTest.php => TextDocumentLocator/ChainDocumentLocatorTest.php} (55%)

diff --git a/lib/TextDocumentLocator.php b/lib/TextDocumentLocator.php
new file mode 100644
index 00000000..0bde2aac
--- /dev/null
+++ b/lib/TextDocumentLocator.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use Phpactor\TextDocument\Exception\TextDocumentNotFound;
+
+interface TextDocumentLocator
+{
+    /**
+     * Retrieve text document by URI, returns NULL if not found
+     *
+     * @throws TextDocumentNotFound
+     */
+    public function get(TextDocumentUri $uri): TextDocument;
+}
diff --git a/lib/TextDocumentLocator/ChainDocumentLocator.php b/lib/TextDocumentLocator/ChainDocumentLocator.php
new file mode 100644
index 00000000..4e7302df
--- /dev/null
+++ b/lib/TextDocumentLocator/ChainDocumentLocator.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Phpactor\TextDocument\TextDocumentLocator;
+
+use Phpactor\TextDocument\Exception\TextDocumentNotFound;
+use Phpactor\TextDocument\TextDocument;
+use Phpactor\TextDocument\TextDocumentUri;
+use Phpactor\TextDocument\TextDocumentLocator;
+
+class ChainDocumentLocator implements TextDocumentLocator
+{
+    /**
+     * @var TextDocumentLocator[]
+     */
+    private $locators;
+
+    /**
+     * @param TextDocumentLocator[] $locators
+     */
+    public function __construct(array $locators)
+    {
+        $this->locators = $locators;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function get(TextDocumentUri $uri): TextDocument
+    {
+        foreach ($this->locators as $workspace) {
+            try {
+                return $workspace->get($uri);
+            } catch (TextDocumentNotFound $notFound) {
+            }
+        }
+
+        throw new TextDocumentNotFound(sprintf(
+            'Could not find document "%s"',
+            $uri->__toString()
+        ));
+    }
+}
diff --git a/lib/Workspace/InMemoryWorkspace.php b/lib/TextDocumentLocator/InMemoryDocumentLocator.php
similarity index 68%
rename from lib/Workspace/InMemoryWorkspace.php
rename to lib/TextDocumentLocator/InMemoryDocumentLocator.php
index b4377ffc..a9d1dba3 100644
--- a/lib/Workspace/InMemoryWorkspace.php
+++ b/lib/TextDocumentLocator/InMemoryDocumentLocator.php
@@ -1,12 +1,13 @@
 <?php
 
-namespace Phpactor\TextDocument\Workspace;
+namespace Phpactor\TextDocument\TextDocumentLocator;
 
+use Phpactor\TextDocument\Exception\TextDocumentNotFound;
 use Phpactor\TextDocument\TextDocument;
 use Phpactor\TextDocument\TextDocumentUri;
-use Phpactor\TextDocument\Workspace;
+use Phpactor\TextDocument\TextDocumentLocator;
 
-final class InMemoryWorkspace implements Workspace
+final class InMemoryDocumentLocator implements TextDocumentLocator
 {
     /**
      * @var array<string, TextDocument>
@@ -21,13 +22,16 @@ private function __construct(array $textDocuments)
         $this->documents = $textDocuments;
     }
 
-    public function get(TextDocumentUri $uri): ?TextDocument
+    public function get(TextDocumentUri $uri): TextDocument
     {
         if (isset($this->documents[$uri->__toString()])) {
             return $this->documents[$uri->__toString()];
         }
 
-        return null;
+        throw new TextDocumentNotFound(sprintf(
+            'Could not find document "%s"',
+            $uri->__toString()
+        ));
     }
 
     /**
@@ -44,12 +48,4 @@ public static function new(): self
     {
         return new self([]);
     }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function save(TextDocument $document): void
-    {
-        $this->documents[$document->uri()->__toString()] = $document;
-    }
 }
diff --git a/lib/Workspace.php b/lib/Workspace.php
deleted file mode 100644
index 8bfae068..00000000
--- a/lib/Workspace.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-use Phpactor\TextDocument\Exception\CouldNotSaveDocument;
-
-/**
- * Represents a workspace to which documents can be persisted and retrieved
- */
-interface Workspace
-{
-    /**
-     * Retrieve text document by URI, returns NULL if not found
-     */
-    public function get(TextDocumentUri $uri): ?TextDocument;
-
-    /**
-     * Save a text document to the workspace.
-     *
-     * @throws CouldNotSaveDocument
-     */
-    public function save(TextDocument $document): void;
-}
diff --git a/lib/Workspace/ChainWorkspace.php b/lib/Workspace/ChainWorkspace.php
deleted file mode 100644
index 408d2983..00000000
--- a/lib/Workspace/ChainWorkspace.php
+++ /dev/null
@@ -1,58 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Workspace;
-
-use Phpactor\TextDocument\TextDocument;
-use Phpactor\TextDocument\TextDocumentUri;
-use Phpactor\TextDocument\Workspace;
-
-/**
- * Combine multiple workspace.
- *
- * The behavior is slightly different for the two methods:
- *
- * - `get`: Return the document from the first workspace that has it.
- * - `save`: Save the document in ALL workspaces.
- */
-class ChainWorkspace implements Workspace
-{
-    /**
-     * @var Workspace[]
-     */
-    private $workspaces;
-
-    /**
-     * @param Workspace[] $workspaces
-     */
-    public function __construct(array $workspaces)
-    {
-        $this->workspaces = $workspaces;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function get(TextDocumentUri $uri): ?TextDocument
-    {
-        foreach ($this->workspaces as $workspace) {
-            $document = $workspace->get($uri);
-            if (null === $document) {
-                continue;
-            }
-
-            return $document;
-        }
-
-        return null;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function save(TextDocument $document): void
-    {
-        foreach ($this->workspaces as $workspace) {
-            $workspace->save($document);
-        }
-    }
-}
diff --git a/tests/Unit/Workspace/ChainWorkspaceTest.php b/tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php
similarity index 55%
rename from tests/Unit/Workspace/ChainWorkspaceTest.php
rename to tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php
index a388354c..2baa30f6 100644
--- a/tests/Unit/Workspace/ChainWorkspaceTest.php
+++ b/tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php
@@ -1,18 +1,20 @@
 <?php
 
-namespace Phpactor\TextDocument\Tests\Unit\Workspace;
+namespace Phpactor\TextDocument\Tests\Unit\TextDocumentLocator;
 
 use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\Exception\TextDocumentNotFound;
 use Phpactor\TextDocument\TextDocumentBuilder;
 use Phpactor\TextDocument\TextDocumentUri;
-use Phpactor\TextDocument\Workspace;
-use Phpactor\TextDocument\Workspace\ChainWorkspace;
-use Phpactor\TextDocument\Workspace\InMemoryWorkspace;
+use Phpactor\TextDocument\TextDocumentLocator\ChainDocumentLocator;
+use Phpactor\TextDocument\TextDocumentLocator\InMemoryDocumentLocator;
 
-class ChainWorkspaceTest extends TestCase
+class ChainDocumentLocatorTest extends TestCase
 {
-    public function testGetReturnsNullIfNoWorkspaces(): void
+    public function testThrowsExceptionWhenNotFound(): void
     {
+        $this->expectException(TextDocumentNotFound::class);
+
         self::assertNull(
             $this->createWorkspace()->get(
                 TextDocumentUri::fromString('file:///foobar')
@@ -27,7 +29,7 @@ public function testReturnsTextDocument(): void
         self::assertSame(
             $document,
             $this->createWorkspace([
-                InMemoryWorkspace::fromTextDocuments([
+                InMemoryDocumentLocator::fromTextDocuments([
                     $document
                 ])
             ])->get(TextDocumentUri::fromString('file:///path/to/foo'))
@@ -42,38 +44,21 @@ public function testReturnsTextDocumentFromFirstWorkspace(): void
         self::assertSame(
             $document1,
             $this->createWorkspace([
-                InMemoryWorkspace::fromTextDocuments([
+                InMemoryDocumentLocator::fromTextDocuments([
                     $document1
                 ]),
-                InMemoryWorkspace::fromTextDocuments([
+                InMemoryDocumentLocator::fromTextDocuments([
                     $document2
                 ])
             ])->get(TextDocumentUri::fromString('file:///path/to/foo'))
         );
     }
 
-    public function testSavesNonExistingDocumentToAllWorkspaces(): void
-    {
-        $document1 = TextDocumentBuilder::create('one')->uri('/path/to/foo')->build();
-
-        $workspace1 = InMemoryWorkspace::new();
-        $workspace2 = InMemoryWorkspace::new();
-
-        $chain = $this->createWorkspace([
-            $workspace1,
-            $workspace2,
-        ]);
-
-        $chain->save($document1);
-        self::assertSame($document1, $workspace1->get($document1->uri()));
-        self::assertSame($document1, $workspace2->get($document1->uri()));
-    }
-
     /**
      * @param Workspace[] $workspaces
      */
-    private function createWorkspace(array $workspaces = []): ChainWorkspace
+    private function createWorkspace(array $workspaces = []): ChainDocumentLocator
     {
-        return new ChainWorkspace($workspaces);
+        return new ChainDocumentLocator($workspaces);
     }
 }

From a9d007d685fe6a5bb3bce19907145ac285171e71 Mon Sep 17 00:00:00 2001
From: Daniel Leech <dan.t.leech@gmail.com>
Date: Sat, 9 Jan 2021 20:44:03 +0000
Subject: [PATCH 05/19] Added exception factory method

---
 lib/Exception/TextDocumentNotFound.php              | 9 ++++++++-
 lib/TextDocumentLocator/ChainDocumentLocator.php    | 5 +----
 lib/TextDocumentLocator/InMemoryDocumentLocator.php | 5 +----
 3 files changed, 10 insertions(+), 9 deletions(-)

diff --git a/lib/Exception/TextDocumentNotFound.php b/lib/Exception/TextDocumentNotFound.php
index 5bdf3ae4..163205b1 100644
--- a/lib/Exception/TextDocumentNotFound.php
+++ b/lib/Exception/TextDocumentNotFound.php
@@ -2,8 +2,15 @@
 
 namespace Phpactor\TextDocument\Exception;
 
+use Phpactor\TextDocument\TextDocumentUri;
 use RuntimeException;
 
-class TextDocumentNotFound extends RuntimeException
+final class TextDocumentNotFound extends RuntimeException
 {
+    public static function fromUri(TextDocumentUri $uri): self
+    {
+        return new self(sprintf(
+            'Text document "%s" not found', $uri
+        ));
+    }
 }
diff --git a/lib/TextDocumentLocator/ChainDocumentLocator.php b/lib/TextDocumentLocator/ChainDocumentLocator.php
index 4e7302df..79cea28e 100644
--- a/lib/TextDocumentLocator/ChainDocumentLocator.php
+++ b/lib/TextDocumentLocator/ChainDocumentLocator.php
@@ -34,9 +34,6 @@ public function get(TextDocumentUri $uri): TextDocument
             }
         }
 
-        throw new TextDocumentNotFound(sprintf(
-            'Could not find document "%s"',
-            $uri->__toString()
-        ));
+        throw TextDocumentNotFound::fromUri($uri);
     }
 }
diff --git a/lib/TextDocumentLocator/InMemoryDocumentLocator.php b/lib/TextDocumentLocator/InMemoryDocumentLocator.php
index a9d1dba3..f5516015 100644
--- a/lib/TextDocumentLocator/InMemoryDocumentLocator.php
+++ b/lib/TextDocumentLocator/InMemoryDocumentLocator.php
@@ -28,10 +28,7 @@ public function get(TextDocumentUri $uri): TextDocument
             return $this->documents[$uri->__toString()];
         }
 
-        throw new TextDocumentNotFound(sprintf(
-            'Could not find document "%s"',
-            $uri->__toString()
-        ));
+        throw TextDocumentNotFound::fromUri($uri);
     }
 
     /**

From 0f75fb53e9f9633ade9a48eedc3f73fbed2c9145 Mon Sep 17 00:00:00 2001
From: Daniel Leech <dan.t.leech@gmail.com>
Date: Mon, 18 Jan 2021 21:47:14 +0000
Subject: [PATCH 06/19] Exclude all chars that are not a PHP class name char

---
 composer.json                                       |  3 +--
 lib/Exception/TextDocumentNotFound.php              |  3 ++-
 lib/TextDocumentLocator/InMemoryDocumentLocator.php |  3 ++-
 lib/Util/WordAtOffset.php                           |  5 ++++-
 tests/Unit/Util/WordAtOffsetTest.php                | 13 +++++++++++++
 5 files changed, 22 insertions(+), 5 deletions(-)

diff --git a/composer.json b/composer.json
index 5a663a4f..dc4d2089 100644
--- a/composer.json
+++ b/composer.json
@@ -41,8 +41,7 @@
         "integrate": [
             "./vendor/bin/php-cs-fixer fix",
             "./vendor/bin/phpstan analyse lib -c phpstan.neon",
-            "./vendor/bin/phpunit",
-            "./vendor/bin/infection"
+            "./vendor/bin/phpunit"
         ]
     }
 }
diff --git a/lib/Exception/TextDocumentNotFound.php b/lib/Exception/TextDocumentNotFound.php
index 163205b1..a9e02fa4 100644
--- a/lib/Exception/TextDocumentNotFound.php
+++ b/lib/Exception/TextDocumentNotFound.php
@@ -10,7 +10,8 @@ final class TextDocumentNotFound extends RuntimeException
     public static function fromUri(TextDocumentUri $uri): self
     {
         return new self(sprintf(
-            'Text document "%s" not found', $uri
+            'Text document "%s" not found',
+            $uri
         ));
     }
 }
diff --git a/lib/TextDocumentLocator/InMemoryDocumentLocator.php b/lib/TextDocumentLocator/InMemoryDocumentLocator.php
index f5516015..2b2c1da7 100644
--- a/lib/TextDocumentLocator/InMemoryDocumentLocator.php
+++ b/lib/TextDocumentLocator/InMemoryDocumentLocator.php
@@ -36,7 +36,8 @@ public function get(TextDocumentUri $uri): TextDocument
      */
     public static function fromTextDocuments(array $textDocuments): self
     {
-        return new self((array)array_combine(array_map(function (TextDocument $document) {
+        /** @phpstan-ignore-next-line */
+        return new self((array)array_combine(array_map(function (TextDocument $document): string {
             return $document->uri()->__toString();
         }, $textDocuments), array_values($textDocuments)));
     }
diff --git a/lib/Util/WordAtOffset.php b/lib/Util/WordAtOffset.php
index 8950d06e..81cfa05e 100644
--- a/lib/Util/WordAtOffset.php
+++ b/lib/Util/WordAtOffset.php
@@ -8,7 +8,10 @@
 final class WordAtOffset
 {
     const SPLIT_WORD = '\s|;|\\\|%|\(|\)|\[|\]|:|\r|\r\n|\n';
-    const SPLIT_QUALIFIED_PHP_NAME = '\?|\s|;|,|@|=|\||%|\(|\)|\[|\]|:|\r|\r\n|\n|<|>';
+
+    // see https://www.php.net/manual/en/language.oop5.basic.php
+    const SPLIT_PHP_NAME = '[^a-zA-Z0-9_\x80-\xff]';
+    const SPLIT_QUALIFIED_PHP_NAME = '[^a-zA-Z0-9_\x80-\xff\\\]';
 
     /**
      * @var string
diff --git a/tests/Unit/Util/WordAtOffsetTest.php b/tests/Unit/Util/WordAtOffsetTest.php
index 5a519d60..4bd6a55b 100644
--- a/tests/Unit/Util/WordAtOffsetTest.php
+++ b/tests/Unit/Util/WordAtOffsetTest.php
@@ -96,5 +96,18 @@ public function provideWordAtOffset()
             'Request',
             WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
         ];
+        yield 'constant' => [
+            <<<'EOT'
+    /**
+     * @SWG\Post(
+     *     @SWG\Response(
+     *         response=Resp<>onse::HTTP_OK,
+     *         description="Reset password sent successfully"
+     * )
+     */
+EOT
+            , 'Response',
+            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
+        ];
     }
 }

From ee5a9f1f157780f38c8106c412ef4eea1759f53c Mon Sep 17 00:00:00 2001
From: dantleech <dan.t.leech@gmail.com>
Date: Sun, 24 Jan 2021 17:48:36 +0000
Subject: [PATCH 07/19] Update coding standard (#18)

---
 .github/workflows/ci.yml                  |  2 +-
 .php_cs.dist                              |  7 ++++-
 lib/Locations.php                         | 14 ++++-----
 lib/TextDocumentLanguage.php              | 10 +++----
 lib/TextDocumentUri.php                   | 10 +++----
 lib/Util/LineAtOffset.php                 |  9 +++---
 tests/Unit/LineColTest.php                |  8 ++---
 tests/Unit/LocationTest.php               |  4 +--
 tests/Unit/TextDocumentBuilderTest.php    |  8 ++---
 tests/Unit/TextDocumentLanguageTest.php   |  4 +--
 tests/Unit/TextEditsTest.php              |  2 +-
 tests/Unit/Util/LineAtOffsetTest.php      | 36 +++++++++++------------
 tests/Unit/Util/LineColFromOffsetTest.php | 12 ++++----
 tests/Unit/Util/WordAtOffsetTest.php      | 32 ++++++++++----------
 14 files changed, 81 insertions(+), 77 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 312b1c94..b30fc14d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -43,7 +43,7 @@ jobs:
 
             -
                 name: "Run friendsofphp/php-cs-fixer"
-                run: "vendor/bin/php-cs-fixer fix --dry-run --diff --verbose"
+                run: "vendor/bin/php-cs-fixer fix --dry-run --diff --verbose --allow-risky=yes"
     phpstan:
         name: "PHPStan (${{ matrix.php-version }})"
 
diff --git a/.php_cs.dist b/.php_cs.dist
index a3d5fa36..2820b890 100644
--- a/.php_cs.dist
+++ b/.php_cs.dist
@@ -4,7 +4,7 @@ $finder = PhpCsFixer\Finder::create()
     ->in('lib')
     ->in('tests')
     ->exclude([
-        'tests/Workspace'
+        'tests/Workspace',
     ])
 ;
 
@@ -13,6 +13,11 @@ return PhpCsFixer\Config::create()
         '@PSR2' => true,
         'no_unused_imports' => true,
         'array_syntax' => ['syntax' => 'short'],
+        'void_return' => true,
+        'ordered_class_elements' => true,
+        'single_quote' => true,
+        'heredoc_indentation' => true,
+        'global_namespace_import' => true,
     ])
     ->setFinder($finder)
 ;
diff --git a/lib/Locations.php b/lib/Locations.php
index 74b232c9..c304085b 100644
--- a/lib/Locations.php
+++ b/lib/Locations.php
@@ -45,13 +45,6 @@ public function append(Locations $locations): self
         return new self($newLocations);
     }
 
-    private function add(Location $location): self
-    {
-        $this->locations[] = $location;
-
-        return $this;
-    }
-
     public function count(): int
     {
         return count($this->locations);
@@ -83,4 +76,11 @@ public function sorted(): self
 
         return new self($sortedLocations);
     }
+
+    private function add(Location $location): self
+    {
+        $this->locations[] = $location;
+
+        return $this;
+    }
 }
diff --git a/lib/TextDocumentLanguage.php b/lib/TextDocumentLanguage.php
index e49160ab..b08af197 100644
--- a/lib/TextDocumentLanguage.php
+++ b/lib/TextDocumentLanguage.php
@@ -17,6 +17,11 @@ private function __construct(string $language)
         $this->language = $language;
     }
 
+    public function __toString(): string
+    {
+        return $this->language;
+    }
+
     public static function fromString(string $language): self
     {
         return new self($language);
@@ -49,9 +54,4 @@ public function isPhp(): bool
     {
         return $this->is(self::LANGUAGE_PHP);
     }
-
-    public function __toString(): string
-    {
-        return $this->language;
-    }
 }
diff --git a/lib/TextDocumentUri.php b/lib/TextDocumentUri.php
index 4fcd505e..64fe078b 100644
--- a/lib/TextDocumentUri.php
+++ b/lib/TextDocumentUri.php
@@ -23,6 +23,11 @@ final private function __construct(string $scheme, string $path)
         $this->path = $path;
     }
 
+    public function __toString(): string
+    {
+        return sprintf('%s://%s', $this->scheme, $this->path);
+    }
+
     public static function fromString(string $uri): self
     {
         $components = parse_url($uri);
@@ -61,11 +66,6 @@ public static function fromString(string $uri): self
         );
     }
 
-    public function __toString(): string
-    {
-        return sprintf('%s://%s', $this->scheme, $this->path);
-    }
-
     public function path(): string
     {
         return $this->path;
diff --git a/lib/Util/LineAtOffset.php b/lib/Util/LineAtOffset.php
index 937a093a..05f7f75e 100644
--- a/lib/Util/LineAtOffset.php
+++ b/lib/Util/LineAtOffset.php
@@ -8,11 +8,6 @@
 
 final class LineAtOffset
 {
-    public static function lineAtByteOffset(string $text, ByteOffset $offset): string
-    {
-        return (new self())->__invoke($text, $offset->toInt());
-    }
-
     public function __invoke(string $text, int $byteOffset): string
     {
         $lines = preg_split("{(\r\n|\n|\r)}", $text, -1, PREG_SPLIT_DELIM_CAPTURE);
@@ -42,4 +37,8 @@ public function __invoke(string $text, int $byteOffset): string
             strlen($text)
         ));
     }
+    public static function lineAtByteOffset(string $text, ByteOffset $offset): string
+    {
+        return (new self())->__invoke($text, $offset->toInt());
+    }
 }
diff --git a/tests/Unit/LineColTest.php b/tests/Unit/LineColTest.php
index 95102a75..e14521c9 100644
--- a/tests/Unit/LineColTest.php
+++ b/tests/Unit/LineColTest.php
@@ -43,17 +43,17 @@ public function provideConvertLineColToOffset(): Generator
         ];
 
         yield 'multi-byte 1' => [
-            "ᅑa",
+            'ᅑa',
             new LineCol(1, 2),
             3,
-            "ᅑ",
+            'ᅑ',
         ];
 
         yield 'multi-byte 2' => [
-            "ᅑacd",
+            'ᅑacd',
             new LineCol(1, 3),
             4,
-            "ᅑa"
+            'ᅑa'
         ];
 
         yield 'multi-byte 3' => [
diff --git a/tests/Unit/LocationTest.php b/tests/Unit/LocationTest.php
index 0adfa415..86b5ecfc 100644
--- a/tests/Unit/LocationTest.php
+++ b/tests/Unit/LocationTest.php
@@ -7,13 +7,13 @@
 
 class LocationTest extends TestCase
 {
-    public function testProvidesAccessToUri()
+    public function testProvidesAccessToUri(): void
     {
         $location = Location::fromPathAndOffset('/path/to.php', 123);
         $this->assertEquals('file:///path/to.php', $location->uri()->__toString());
     }
 
-    public function testProvidesAccessToByteOffset()
+    public function testProvidesAccessToByteOffset(): void
     {
         $location = Location::fromPathAndOffset('/path/to.php', 123);
         $this->assertEquals(123, $location->offset()->toInt());
diff --git a/tests/Unit/TextDocumentBuilderTest.php b/tests/Unit/TextDocumentBuilderTest.php
index af1ab745..e598a12b 100644
--- a/tests/Unit/TextDocumentBuilderTest.php
+++ b/tests/Unit/TextDocumentBuilderTest.php
@@ -11,7 +11,7 @@ class TextDocumentBuilderTest extends TestCase
     const EXAMPLE_TEXT = 'hello world';
     const EXAMPLE_URI = 'file:///path/to';
 
-    public function testCreate()
+    public function testCreate(): void
     {
         $doc = TextDocumentBuilder::create(self::EXAMPLE_TEXT)->language('php')->uri(self::EXAMPLE_URI)->build();
         $this->assertEquals(self::EXAMPLE_URI, $doc->uri()->__toString());
@@ -19,14 +19,14 @@ public function testCreate()
         $this->assertEquals('php', $doc->language());
     }
 
-    public function testFromUri()
+    public function testFromUri(): void
     {
         $doc = TextDocumentBuilder::fromUri('file://' . __FILE__)->build();
         $this->assertEquals('file://' . __FILE__, $doc->uri());
         $this->assertEquals(file_get_contents(__FILE__), $doc->__toString());
     }
 
-    public function testFromTextDocument()
+    public function testFromTextDocument(): void
     {
         $doc = TextDocumentBuilder::fromTextDocument(
             TextDocumentBuilder::create('foobar')
@@ -39,7 +39,7 @@ public function testFromTextDocument()
         $this->assertEquals('foo', $doc->language()->__toString());
     }
 
-    public function testExceptionOnNotExists()
+    public function testExceptionOnNotExists(): void
     {
         $this->expectException(TextDocumentNotFound::class);
         TextDocumentBuilder::fromUri('file:///no-existy');
diff --git a/tests/Unit/TextDocumentLanguageTest.php b/tests/Unit/TextDocumentLanguageTest.php
index 1bd8546a..67c9c90e 100644
--- a/tests/Unit/TextDocumentLanguageTest.php
+++ b/tests/Unit/TextDocumentLanguageTest.php
@@ -7,7 +7,7 @@
 
 class TextDocumentLanguageTest extends TestCase
 {
-    public function testCreate()
+    public function testCreate(): void
     {
         $language = TextDocumentLanguage::fromString('php');
         $this->assertEquals('php', (string) $language);
@@ -20,7 +20,7 @@ public function testCreate()
         $this->assertFalse($language->in(['c', 'cobol']));
     }
 
-    public function testCreateUndefined()
+    public function testCreateUndefined(): void
     {
         $language = TextDocumentLanguage::undefined();
         $this->assertFalse($language->isDefined());
diff --git a/tests/Unit/TextEditsTest.php b/tests/Unit/TextEditsTest.php
index d1c91e3d..c3a62325 100644
--- a/tests/Unit/TextEditsTest.php
+++ b/tests/Unit/TextEditsTest.php
@@ -12,7 +12,7 @@ class TextEditsTest extends TestCase
     /**
      * @dataProvider provideMerge
      */
-    public function testMerge(array $edits1, array $edits2, array $expectedEdits)
+    public function testMerge(array $edits1, array $edits2, array $expectedEdits): void
     {
         self::assertEquals(
             TextEdits::fromTextEdits($expectedEdits),
diff --git a/tests/Unit/Util/LineAtOffsetTest.php b/tests/Unit/Util/LineAtOffsetTest.php
index 954fb9be..5f84e06d 100644
--- a/tests/Unit/Util/LineAtOffsetTest.php
+++ b/tests/Unit/Util/LineAtOffsetTest.php
@@ -12,7 +12,7 @@ class LineAtOffsetTest extends TestCase
     /**
      * @dataProvider provideLineAtOffset
      */
-    public function testLineAtOffset(string $text, string $expectedWord)
+    public function testLineAtOffset(string $text, string $expectedWord): void
     {
         [ $text, $offset ] = ExtractOffset::fromSource($text);
 
@@ -35,44 +35,44 @@ public function provideLineAtOffset()
         ];
         yield 'offset is newline' => [
             "hello this is\n<>",
-            "hello this is",
+            'hello this is',
         ];
         yield [
             <<<'EOT'
-<?php
+                <?php
 
-Hello
-Thi<>s is my line
+                Hello
+                Thi<>s is my line
 
-Thanks
-EOT
+                Thanks
+                EOT
             , 'This is my line',
         ];
         yield 'multibyte 1' => [
             <<<'EOT'
-<?php
+                <?php
 
-转注字 / 轉注字
-转<>注字 / 轉注字
+                转注字 / 轉注字
+                转<>注字 / 轉注字
 
-Thanks
-EOT
+                Thanks
+                EOT
             , '转注字 / 轉注字',
         ];
         yield 'multibyte 2' => [
             <<<'EOT'
-<?php
+                <?php
 
-转注字 / 轉注字
-转注字 / 轉注字<>
+                转注字 / 轉注字
+                转注字 / 轉注字<>
 
-Thanks
-EOT
+                Thanks
+                EOT
             , '转注字 / 轉注字',
         ];
     }
 
-    public function testOutOfRange()
+    public function testOutOfRange(): void
     {
         $this->expectException(OutOfBoundsException::class);
         (new LineAtOffset())('a', 2);
diff --git a/tests/Unit/Util/LineColFromOffsetTest.php b/tests/Unit/Util/LineColFromOffsetTest.php
index a3953231..36f3622d 100644
--- a/tests/Unit/Util/LineColFromOffsetTest.php
+++ b/tests/Unit/Util/LineColFromOffsetTest.php
@@ -68,11 +68,11 @@ public function provideLineColFromOffset(): Generator
 
         yield '4 lines with special chars' => [
             <<<'EOT'
-h转llo
-goodbye
-h转llo
-goodbye
-EOT
+                h转llo
+                goodbye
+                h转llo
+                goodbye
+                EOT
         ,
             26,
             4,
@@ -80,7 +80,7 @@ public function provideLineColFromOffset(): Generator
         ];
     }
 
-    public function testExceptionWhenOutOfBounds()
+    public function testExceptionWhenOutOfBounds(): void
     {
         $this->expectException(OutOfBoundsException::class);
         $lineCol = (new LineColFromOffset())('asd', 10);
diff --git a/tests/Unit/Util/WordAtOffsetTest.php b/tests/Unit/Util/WordAtOffsetTest.php
index 4bd6a55b..f8be329a 100644
--- a/tests/Unit/Util/WordAtOffsetTest.php
+++ b/tests/Unit/Util/WordAtOffsetTest.php
@@ -11,7 +11,7 @@ class WordAtOffsetTest extends TestCase
     /**
      * @dataProvider provideWordAtOffset
      */
-    public function testWordAtOffset(string $text, string $expectedWord, $split = WordAtOffset::SPLIT_WORD)
+    public function testWordAtOffset(string $text, string $expectedWord, $split = WordAtOffset::SPLIT_WORD): void
     {
         [ $text, $offset ] = ExtractOffset::fromSource($text);
 
@@ -54,7 +54,7 @@ public function provideWordAtOffset()
             ' ',
         ];
         yield [
-            "Reque<>st;",
+            'Reque<>st;',
             'Request',
         ];
         yield [
@@ -67,45 +67,45 @@ public function provideWordAtOffset()
             WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
         ];
         yield 'nullable type' => [
-            "?Reque<>st;",
+            '?Reque<>st;',
             'Request',
             WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
         ];
         yield 'trailing comma' => [
-            "Reque<>st,",
+            'Reque<>st,',
             'Request',
             WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
         ];
         yield 'pipe type separator' => [
-            "Reque<>st|null,",
+            'Reque<>st|null,',
             'Request',
             WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
         ];
         yield 'annotations' => [
-            "@Reque<>st",
+            '@Reque<>st',
             'Request',
             WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
         ];
         yield 'subannotations (removing equal)' => [
-             "* input=Re<>quest::class",
+             '* input=Re<>quest::class',
             'Request',
             WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
         ];
         yield 'templated type' => [
-             "array<Re<>quest>",
+             'array<Re<>quest>',
             'Request',
             WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
         ];
         yield 'constant' => [
             <<<'EOT'
-    /**
-     * @SWG\Post(
-     *     @SWG\Response(
-     *         response=Resp<>onse::HTTP_OK,
-     *         description="Reset password sent successfully"
-     * )
-     */
-EOT
+                    /**
+                     * @SWG\Post(
+                     *     @SWG\Response(
+                     *         response=Resp<>onse::HTTP_OK,
+                     *         description="Reset password sent successfully"
+                     * )
+                     */
+                EOT
             , 'Response',
             WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
         ];

From 771a95fd01b6ba2b6469fd7b1996de8136363f2f Mon Sep 17 00:00:00 2001
From: dantleech <dan.t.leech@gmail.com>
Date: Sun, 31 Jan 2021 16:45:14 +0000
Subject: [PATCH 08/19] Maestro adds support for PHP 8.0 (#13)

---
 composer.json | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/composer.json b/composer.json
index dc4d2089..0b76fb01 100644
--- a/composer.json
+++ b/composer.json
@@ -9,13 +9,13 @@
         }
     ],
     "require": {
-        "php": "^7.3",
+        "php": "^7.3 || ^8.0",
         "webmozart/path-util": "^2.3"
     },
     "require-dev": {
         "ergebnis/composer-normalize": "^2.0",
         "friendsofphp/php-cs-fixer": "^2.17",
-        "phpactor/test-utils": "^1.0",
+        "phpactor/test-utils": "~1.1.3",
         "phpstan/phpstan": "~0.12.0",
         "phpunit/phpunit": "^9.0",
         "phpspec/prophecy-phpunit": "^2.0"

From ea6fa8fbb0b23d923d35cd38c98f327aa7fdbed6 Mon Sep 17 00:00:00 2001
From: dantleech <dan.t.leech@gmail.com>
Date: Sun, 31 Jan 2021 16:48:53 +0000
Subject: [PATCH 09/19] Maestro adds support for PHP 8.0 (#19)

---
 .github/workflows/ci.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b30fc14d..1b5084f4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -87,6 +87,7 @@ jobs:
                 php-version:
                     - '7.3'
                     - '7.4'
+                    - '8.0'
 
         steps:
             -

From 3a6b82e332643748957a35cb41f9e5ca22259ddf Mon Sep 17 00:00:00 2001
From: Daniel Leech <dan.t.leech@gmail.com>
Date: Sat, 6 Feb 2021 14:38:53 +0000
Subject: [PATCH 10/19] Update deps

---
 .gitignore | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index be1dd7e8..f4c067ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,5 @@
 /.php_cs.cache
 /stubs
 .phpunit.result.cache
-/.vscode
\ No newline at end of file
+/.vscode
+.php_cs.cache
\ No newline at end of file

From a33a75ef1d5522b16d086ac6771a808649442166 Mon Sep 17 00:00:00 2001
From: Daniel Leech <dan.t.leech@gmail.com>
Date: Sat, 10 Apr 2021 12:44:37 +0100
Subject: [PATCH 11/19] Add uitlity to get lincol range for line

---
 lib/LineColRange.php                        | 31 +++++++++
 lib/Util/LineColRangeForLine.php            | 47 +++++++++++++
 tests/Unit/Util/LineColRangeForLineTest.php | 77 +++++++++++++++++++++
 3 files changed, 155 insertions(+)
 create mode 100644 lib/LineColRange.php
 create mode 100644 lib/Util/LineColRangeForLine.php
 create mode 100644 tests/Unit/Util/LineColRangeForLineTest.php

diff --git a/lib/LineColRange.php b/lib/LineColRange.php
new file mode 100644
index 00000000..462a9de9
--- /dev/null
+++ b/lib/LineColRange.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+final class LineColRange
+{
+    /**
+     * @var LineCol
+     */
+    private $start;
+    /**
+     * @var LineCol
+     */
+    private $end;
+
+    public function __construct(LineCol $start, LineCol $end)
+    {
+        $this->start = $start;
+        $this->end = $end;
+    }
+
+    public function start(): LineCol
+    {
+        return $this->start;
+    }
+
+    public function end(): LineCol
+    {
+        return $this->end;
+    }
+}
diff --git a/lib/Util/LineColRangeForLine.php b/lib/Util/LineColRangeForLine.php
new file mode 100644
index 00000000..986084ef
--- /dev/null
+++ b/lib/Util/LineColRangeForLine.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Phpactor\TextDocument\Util;
+
+use Phpactor\TextDocument\LineCol;
+use Phpactor\TextDocument\LineColRange;
+use RuntimeException;
+
+final class LineColRangeForLine
+{
+    public function rangeFromLine(string $text, int $lineNo): LineColRange
+    {
+        $lines = preg_split("{(\r\n|\n|\r)}", $text, -1);
+
+        if (false === $lines) {
+            throw new RuntimeException(
+                'Failed to preg-split text into lines'
+            );
+        }
+
+        // if out of range, let the caller deal with it
+        if (!isset($lines[$lineNo - 1])) {
+            return new LineColRange(
+                new LineCol($lineNo, 1),
+                new LineCol($lineNo, 1)
+            );
+        }
+
+        $line = $lines[$lineNo - 1];
+
+        // if an empty line, return the first char
+        if (!$line) {
+            return new LineColRange(
+                new LineCol($lineNo, 1),
+                new LineCol($lineNo, 1)
+            );
+        }
+
+        $start = mb_strpos($line, trim($line));
+        $end = $start + mb_strlen(trim($line));
+
+        return new LineColRange(
+            new LineCol($lineNo, $start + 1),
+            new LineCol($lineNo, $end)
+        );
+    }
+}
diff --git a/tests/Unit/Util/LineColRangeForLineTest.php b/tests/Unit/Util/LineColRangeForLineTest.php
new file mode 100644
index 00000000..b993e918
--- /dev/null
+++ b/tests/Unit/Util/LineColRangeForLineTest.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit\Util;
+
+use Generator;
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\LineCol;
+use Phpactor\TextDocument\LineColRange;
+use Phpactor\TextDocument\Util\LineColRangeForLine;
+
+class LineColRangeForLineTest extends TestCase
+{
+    /**
+     * @dataProvider provideRangeForLine
+     */
+    public function testRangeForLine(string $text, int $lineNo, LineColRange $expected)
+    {
+        self::assertEquals($expected, (new LineColRangeForLine())->rangeFromLine($text, $lineNo));
+    }
+
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideRangeForLine(): Generator
+    {
+        yield [
+            'one',
+            1,
+            new LineColRange(
+                new LineCol(1, 1),
+                new LineCol(1, 3),
+            )
+        ];
+        yield [
+            '  one',
+            1,
+            new LineColRange(
+                new LineCol(1, 3),
+                new LineCol(1, 5),
+            )
+        ];
+        yield [
+            '  one  ',
+            1,
+            new LineColRange(
+                new LineCol(1, 3),
+                new LineCol(1, 5),
+            )
+        ];
+        yield [
+            "  one  \n  two  \n",
+            2,
+            new LineColRange(
+                new LineCol(2, 3),
+                new LineCol(2, 5),
+            )
+        ];
+
+        yield 'empty line' => [
+            "  one  \n  two  \n",
+            3,
+            new LineColRange(
+                new LineCol(3, 1),
+                new LineCol(3, 1),
+            )
+        ];
+
+        yield 'out of range' => [
+            "  one  \n  two  \n",
+            4,
+            new LineColRange(
+                new LineCol(4, 1),
+                new LineCol(4, 1),
+            )
+        ];
+    }
+}

From 327990092b8e3bb16dd381638fe890224395a300 Mon Sep 17 00:00:00 2001
From: Daniel Leech <dan.t.leech@gmail.com>
Date: Sat, 10 Apr 2021 12:45:27 +0100
Subject: [PATCH 12/19] Add void

---
 composer.json                               | 2 +-
 tests/Unit/Util/LineColRangeForLineTest.php | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/composer.json b/composer.json
index 0b76fb01..ab6388be 100644
--- a/composer.json
+++ b/composer.json
@@ -39,7 +39,7 @@
     "prefer-stable": true,
     "scripts": {
         "integrate": [
-            "./vendor/bin/php-cs-fixer fix",
+            "./vendor/bin/php-cs-fixer fix --allow-risky=yes",
             "./vendor/bin/phpstan analyse lib -c phpstan.neon",
             "./vendor/bin/phpunit"
         ]
diff --git a/tests/Unit/Util/LineColRangeForLineTest.php b/tests/Unit/Util/LineColRangeForLineTest.php
index b993e918..00d4ee8b 100644
--- a/tests/Unit/Util/LineColRangeForLineTest.php
+++ b/tests/Unit/Util/LineColRangeForLineTest.php
@@ -13,7 +13,7 @@ class LineColRangeForLineTest extends TestCase
     /**
      * @dataProvider provideRangeForLine
      */
-    public function testRangeForLine(string $text, int $lineNo, LineColRange $expected)
+    public function testRangeForLine(string $text, int $lineNo, LineColRange $expected): void
     {
         self::assertEquals($expected, (new LineColRangeForLine())->rangeFromLine($text, $lineNo));
     }

From 3a8faef4e108c123b6ae27b0fcfeed476f912b57 Mon Sep 17 00:00:00 2001
From: Vladislav Kosev <vladislav.kosev@outlook.com>
Date: Sun, 25 Apr 2021 10:55:22 +0300
Subject: [PATCH 13/19] Text document edits (#20)

---
 .php_cs.dist              |  1 +
 lib/TextDocumentEdits.php | 45 +++++++++++++++++++++++++++++++++++++++
 lib/WorkspaceEdits.php    | 30 ++++++++++++++++++++++++++
 3 files changed, 76 insertions(+)
 create mode 100644 lib/TextDocumentEdits.php
 create mode 100644 lib/WorkspaceEdits.php

diff --git a/.php_cs.dist b/.php_cs.dist
index 2820b890..5cbd7d7a 100644
--- a/.php_cs.dist
+++ b/.php_cs.dist
@@ -9,6 +9,7 @@ $finder = PhpCsFixer\Finder::create()
 ;
 
 return PhpCsFixer\Config::create()
+    ->setRiskyAllowed(true)
     ->setRules([
         '@PSR2' => true,
         'no_unused_imports' => true,
diff --git a/lib/TextDocumentEdits.php b/lib/TextDocumentEdits.php
new file mode 100644
index 00000000..d415bc95
--- /dev/null
+++ b/lib/TextDocumentEdits.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use Iterator;
+use IteratorAggregate;
+
+/**
+ * @implements IteratorAggregate<int, TextEdit>
+ */
+class TextDocumentEdits implements IteratorAggregate
+{
+    /**
+     * @var TextEdits
+     */
+    private $textEdits;
+    /**
+     * @var TextDocumentUri
+     */
+    private $uri;
+
+
+    public function __construct(TextDocumentUri $uri, TextEdits $textEdits)
+    {
+        $this->textEdits = $textEdits;
+        $this->uri = $uri;
+    }
+    
+    public function uri(): TextDocumentUri
+    {
+        return $this->uri;
+    }
+
+    public function textEdits(): TextEdits
+    {
+        return $this->textEdits;
+    }
+    /**
+     * @return Iterator<TextEdit>
+     */
+    public function getIterator(): Iterator
+    {
+        return $this->textEdits->getIterator();
+    }
+}
diff --git a/lib/WorkspaceEdits.php b/lib/WorkspaceEdits.php
new file mode 100644
index 00000000..e4d86f9c
--- /dev/null
+++ b/lib/WorkspaceEdits.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use ArrayIterator;
+use Iterator;
+use IteratorAggregate;
+
+/**
+ * @implements IteratorAggregate<int, TextDocumentEdits>
+ */
+class WorkspaceEdits implements IteratorAggregate
+{
+    /**
+     * @var TextDocumentEdits[]
+     */
+    private $documentEdits;
+
+    public function __construct(TextDocumentEdits ...$documentEdits)
+    {
+        $this->documentEdits = $documentEdits;
+    }
+    /**
+     * @return Iterator<TextDocumentEdits>
+     */
+    public function getIterator(): Iterator
+    {
+        return new ArrayIterator($this->documentEdits);
+    }
+}

From 962070cbf33a63e87e30ba6c7617f1aa80fa2be4 Mon Sep 17 00:00:00 2001
From: Daniel Leech <dan.t.leech@gmail.com>
Date: Mon, 24 May 2021 08:37:23 +0100
Subject: [PATCH 14/19] Do not throw exception on out of range

Assume max values
---
 composer.json                             |  3 ++-
 lib/LineCol.php                           | 12 +++++-------
 tests/Unit/LineColTest.php                |  7 +++++++
 tests/Unit/Util/LineColFromOffsetTest.php | 13 +++++++++----
 4 files changed, 23 insertions(+), 12 deletions(-)

diff --git a/composer.json b/composer.json
index ab6388be..a7b7ce34 100644
--- a/composer.json
+++ b/composer.json
@@ -18,7 +18,8 @@
         "phpactor/test-utils": "~1.1.3",
         "phpstan/phpstan": "~0.12.0",
         "phpunit/phpunit": "^9.0",
-        "phpspec/prophecy-phpunit": "^2.0"
+        "phpspec/prophecy-phpunit": "^2.0",
+        "symfony/var-dumper": "^5.2"
     },
     "extra": {
         "branch-alias": {
diff --git a/lib/LineCol.php b/lib/LineCol.php
index 5b6313d2..2e0ada5f 100644
--- a/lib/LineCol.php
+++ b/lib/LineCol.php
@@ -79,17 +79,15 @@ public function toByteOffset(string $text): ByteOffset
             $offset += strlen((string)$lineOrDelim);
         }
 
-        throw new OutOfBoundsException(sprintf(
-            'Position %s:%s is larger than text length %s: %s',
-            $this->line(),
-            $this->col(),
-            strlen($text),
-            $text
-        ));
+        return ByteOffset::fromInt(strlen($text));
     }
 
     public static function fromByteOffset(string $text, ByteOffset $byteOffset): self
     {
+        if ($byteOffset->toInt() > strlen($text)) {
+            $byteOffset = ByteOffset::fromInt(strlen($text));
+        }
+
         $lines = preg_split('{(' . self::NEWLINE_PATTERN . ')}', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
 
         if (false === $lines) {
diff --git a/tests/Unit/LineColTest.php b/tests/Unit/LineColTest.php
index e14521c9..c7c1f5df 100644
--- a/tests/Unit/LineColTest.php
+++ b/tests/Unit/LineColTest.php
@@ -63,4 +63,11 @@ public function provideConvertLineColToOffset(): Generator
             "ᅑ\na"
         ];
     }
+
+    public function testOutOfBoundsToByteOffset(): void
+    {
+        $lineCol = new LineCol(10, 20);
+        assert($lineCol instanceof LineCol);
+        self::assertEquals(13, $lineCol->toByteOffset("foobar\nbarfoo")->toInt());
+    }
 }
diff --git a/tests/Unit/Util/LineColFromOffsetTest.php b/tests/Unit/Util/LineColFromOffsetTest.php
index 36f3622d..5d0b4209 100644
--- a/tests/Unit/Util/LineColFromOffsetTest.php
+++ b/tests/Unit/Util/LineColFromOffsetTest.php
@@ -3,7 +3,6 @@
 namespace Phpactor\TextDocument\Tests\Unit\Util;
 
 use Generator;
-use OutOfBoundsException;
 use PHPUnit\Framework\TestCase;
 use Phpactor\TextDocument\LineCol;
 use Phpactor\TextDocument\Util\LineColFromOffset;
@@ -80,9 +79,15 @@ public function provideLineColFromOffset(): Generator
         ];
     }
 
-    public function testExceptionWhenOutOfBounds(): void
+    public function testOutOfBoundsLineCol(): void
     {
-        $this->expectException(OutOfBoundsException::class);
-        $lineCol = (new LineColFromOffset())('asd', 10);
+        $lineCol = (new LineColFromOffset())(<<<'EOT'
+            foo
+            bar
+            EOT
+        , 10);
+        assert($lineCol instanceof LineCol);
+        self::assertEquals(2, $lineCol->line());
+        self::assertEquals(4, $lineCol->col());
     }
 }

From b67e728197e9fde10b3678540f401d1a01cc2a5f Mon Sep 17 00:00:00 2001
From: Daniel Leech <daniel@dantleech.com>
Date: Fri, 26 Jan 2024 16:21:37 +0000
Subject: [PATCH 15/19] Update package

---
 .php_cs.dist                                  |  25 ---
 composer.json                                 |  19 ++-
 lib/ByteOffset.php                            |  51 -------
 lib/ByteOffsetRange.php                       |  44 ------
 lib/Exception/CouldNotSaveDocument.php        |   9 --
 lib/Exception/InvalidByteOffset.php           |   9 --
 lib/Exception/InvalidUriException.php         |   9 --
 lib/Exception/TextDocumentNotFound.php        |  17 ---
 lib/LineCol.php                               | 143 ------------------
 lib/LineColRange.php                          |  31 ----
 lib/Location.php                              |  40 -----
 lib/Locations.php                             |  86 -----------
 lib/StandardTextDocument.php                  |  54 -------
 lib/TextDocument.php                          |  25 ---
 lib/TextDocumentBuilder.php                   | 112 --------------
 lib/TextDocumentEdits.php                     |  45 ------
 lib/TextDocumentLanguage.php                  |  57 -------
 lib/TextDocumentLocator.php                   |  15 --
 .../ChainDocumentLocator.php                  |  39 -----
 .../InMemoryDocumentLocator.php               |  49 ------
 lib/TextDocumentUri.php                       |  78 ----------
 lib/TextEdit.php                              |  68 ---------
 lib/TextEdits.php                             | 120 ---------------
 lib/Util/LineAtOffset.php                     |  44 ------
 lib/Util/LineColFromOffset.php                |  17 ---
 lib/Util/LineColRangeForLine.php              |  47 ------
 lib/Util/WordAtOffset.php                     |  51 -------
 lib/WorkspaceEdits.php                        |  30 ----
 phpstan.neon                                  |   3 +-
 tests/Unit/EfficientLineColsTest.php          |  77 ++++++++++
 tests/Unit/LocationTest.php                   |   7 +-
 tests/Unit/LocationsTest.php                  |  54 ++++---
 tests/Unit/TextDocumentBuilderTest.php        |   1 +
 .../ChainDocumentLocatorTest.php              |  10 +-
 tests/Unit/TextDocumentUriTest.php            |  18 ++-
 tests/Unit/TextEditDiffTest.php               |  93 ++++++++++++
 tests/Unit/TextEditsTest.php                  |   6 +-
 tests/Unit/Util/LineAtOffsetTest.php          |   9 +-
 tests/Unit/Util/LineColFromOffsetTest.php     |  93 ------------
 tests/Unit/Util/WordAtOffsetTest.php          |   9 +-
 40 files changed, 259 insertions(+), 1455 deletions(-)
 delete mode 100644 .php_cs.dist
 delete mode 100644 lib/ByteOffset.php
 delete mode 100644 lib/ByteOffsetRange.php
 delete mode 100644 lib/Exception/CouldNotSaveDocument.php
 delete mode 100644 lib/Exception/InvalidByteOffset.php
 delete mode 100644 lib/Exception/InvalidUriException.php
 delete mode 100644 lib/Exception/TextDocumentNotFound.php
 delete mode 100644 lib/LineCol.php
 delete mode 100644 lib/LineColRange.php
 delete mode 100644 lib/Location.php
 delete mode 100644 lib/Locations.php
 delete mode 100644 lib/StandardTextDocument.php
 delete mode 100644 lib/TextDocument.php
 delete mode 100644 lib/TextDocumentBuilder.php
 delete mode 100644 lib/TextDocumentEdits.php
 delete mode 100644 lib/TextDocumentLanguage.php
 delete mode 100644 lib/TextDocumentLocator.php
 delete mode 100644 lib/TextDocumentLocator/ChainDocumentLocator.php
 delete mode 100644 lib/TextDocumentLocator/InMemoryDocumentLocator.php
 delete mode 100644 lib/TextDocumentUri.php
 delete mode 100644 lib/TextEdit.php
 delete mode 100644 lib/TextEdits.php
 delete mode 100644 lib/Util/LineAtOffset.php
 delete mode 100644 lib/Util/LineColFromOffset.php
 delete mode 100644 lib/Util/LineColRangeForLine.php
 delete mode 100644 lib/Util/WordAtOffset.php
 delete mode 100644 lib/WorkspaceEdits.php
 create mode 100644 tests/Unit/EfficientLineColsTest.php
 create mode 100644 tests/Unit/TextEditDiffTest.php
 delete mode 100644 tests/Unit/Util/LineColFromOffsetTest.php

diff --git a/.php_cs.dist b/.php_cs.dist
deleted file mode 100644
index 5cbd7d7a..00000000
--- a/.php_cs.dist
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-
-$finder = PhpCsFixer\Finder::create()
-    ->in('lib')
-    ->in('tests')
-    ->exclude([
-        'tests/Workspace',
-    ])
-;
-
-return PhpCsFixer\Config::create()
-    ->setRiskyAllowed(true)
-    ->setRules([
-        '@PSR2' => true,
-        'no_unused_imports' => true,
-        'array_syntax' => ['syntax' => 'short'],
-        'void_return' => true,
-        'ordered_class_elements' => true,
-        'single_quote' => true,
-        'heredoc_indentation' => true,
-        'global_namespace_import' => true,
-    ])
-    ->setFinder($finder)
-;
-
diff --git a/composer.json b/composer.json
index a7b7ce34..dbf44293 100644
--- a/composer.json
+++ b/composer.json
@@ -9,14 +9,14 @@
         }
     ],
     "require": {
-        "php": "^7.3 || ^8.0",
-        "webmozart/path-util": "^2.3"
+        "php": "^8.1",
+        "symfony/filesystem": "^5.0|^6.0"
     },
     "require-dev": {
         "ergebnis/composer-normalize": "^2.0",
-        "friendsofphp/php-cs-fixer": "^2.17",
-        "phpactor/test-utils": "~1.1.3",
-        "phpstan/phpstan": "~0.12.0",
+        "friendsofphp/php-cs-fixer": "^3.30",
+        "phpactor/test-utils": "^1.1.4",
+        "phpstan/phpstan": "^1.0",
         "phpunit/phpunit": "^9.0",
         "phpspec/prophecy-phpunit": "^2.0",
         "symfony/var-dumper": "^5.2"
@@ -28,7 +28,7 @@
     },
     "autoload": {
         "psr-4": {
-            "Phpactor\\TextDocument\\": "lib/"
+            "Phpactor\\TextDocument\\": "src/"
         }
     },
     "autoload-dev": {
@@ -41,8 +41,13 @@
     "scripts": {
         "integrate": [
             "./vendor/bin/php-cs-fixer fix --allow-risky=yes",
-            "./vendor/bin/phpstan analyse lib -c phpstan.neon",
+            "./vendor/bin/phpstan analyse src -c phpstan.neon",
             "./vendor/bin/phpunit"
         ]
+    },
+    "config": {
+        "allow-plugins": {
+            "ergebnis/composer-normalize": true
+        }
     }
 }
diff --git a/lib/ByteOffset.php b/lib/ByteOffset.php
deleted file mode 100644
index 2bc2603e..00000000
--- a/lib/ByteOffset.php
+++ /dev/null
@@ -1,51 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-use Phpactor\TextDocument\Exception\InvalidByteOffset;
-
-class ByteOffset
-{
-    /**
-     * @var int
-     */
-    private $offset;
-
-    private function __construct(int $offset)
-    {
-        if ($offset < 0) {
-            throw new InvalidByteOffset(sprintf(
-                'Offset must be greater than or equal to zero, got "%s"',
-                $offset
-            ));
-        }
-        $this->offset = $offset;
-    }
-
-    public static function fromInt(int $offset): self
-    {
-        return new self($offset);
-    }
-
-    /**
-     * @param int|ByteOffset $offset
-     */
-    public static function fromIntOrByteOffset($offset): self
-    {
-        if ($offset instanceof ByteOffset) {
-            return $offset;
-        }
-
-        return self::fromInt($offset);
-    }
-
-    public function toInt(): int
-    {
-        return $this->offset;
-    }
-
-    public function add(int $amount): self
-    {
-        return new self($this->offset + $amount);
-    }
-}
diff --git a/lib/ByteOffsetRange.php b/lib/ByteOffsetRange.php
deleted file mode 100644
index 7883dbd2..00000000
--- a/lib/ByteOffsetRange.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-class ByteOffsetRange
-{
-    /**
-     * @var ByteOffset
-     */
-    private $start;
-    /**
-     * @var ByteOffset
-     */
-    private $end;
-
-    public function __construct(ByteOffset $start, ByteOffset $end)
-    {
-        $this->start = $start;
-        $this->end = $end;
-    }
-
-    public static function fromInts(int $start, int $end): self
-    {
-        return new self(
-            ByteOffset::fromInt($start),
-            ByteOffset::fromInt($end)
-        );
-    }
-
-    public static function fromByteOffsets(ByteOffset $start, ByteOffset $end): self
-    {
-        return new self($start, $end);
-    }
-
-    public function start(): ByteOffset
-    {
-        return $this->start;
-    }
-
-    public function end(): ByteOffset
-    {
-        return $this->end;
-    }
-}
diff --git a/lib/Exception/CouldNotSaveDocument.php b/lib/Exception/CouldNotSaveDocument.php
deleted file mode 100644
index 86d5efa2..00000000
--- a/lib/Exception/CouldNotSaveDocument.php
+++ /dev/null
@@ -1,9 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Exception;
-
-use RuntimeException;
-
-class CouldNotSaveDocument extends RuntimeException
-{
-}
diff --git a/lib/Exception/InvalidByteOffset.php b/lib/Exception/InvalidByteOffset.php
deleted file mode 100644
index 250492b9..00000000
--- a/lib/Exception/InvalidByteOffset.php
+++ /dev/null
@@ -1,9 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Exception;
-
-use RuntimeException;
-
-class InvalidByteOffset extends RuntimeException
-{
-}
diff --git a/lib/Exception/InvalidUriException.php b/lib/Exception/InvalidUriException.php
deleted file mode 100644
index 4803a46b..00000000
--- a/lib/Exception/InvalidUriException.php
+++ /dev/null
@@ -1,9 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Exception;
-
-use RuntimeException;
-
-class InvalidUriException extends RuntimeException
-{
-}
diff --git a/lib/Exception/TextDocumentNotFound.php b/lib/Exception/TextDocumentNotFound.php
deleted file mode 100644
index a9e02fa4..00000000
--- a/lib/Exception/TextDocumentNotFound.php
+++ /dev/null
@@ -1,17 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Exception;
-
-use Phpactor\TextDocument\TextDocumentUri;
-use RuntimeException;
-
-final class TextDocumentNotFound extends RuntimeException
-{
-    public static function fromUri(TextDocumentUri $uri): self
-    {
-        return new self(sprintf(
-            'Text document "%s" not found',
-            $uri
-        ));
-    }
-}
diff --git a/lib/LineCol.php b/lib/LineCol.php
deleted file mode 100644
index 2e0ada5f..00000000
--- a/lib/LineCol.php
+++ /dev/null
@@ -1,143 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-use OutOfBoundsException;
-use RuntimeException;
-
-/**
- * Value object for line / column position.
- *
- * Lines and columns start with 1.
- *
- * The "a" in "abcd" would have line "1" and column "1".
- */
-final class LineCol
-{
-    private const NEWLINE_PATTERN = '\\r\\n|\\n|\\r';
-
-    /**
-     * @var int
-     */
-    private $line;
-
-    /**
-     * @var int
-     */
-    private $col;
-
-    public function __construct(int $line, int $col)
-    {
-        if ($line < 1) {
-            throw new RuntimeException(sprintf(
-                'Line number cannot be less than 1 (got "%s")',
-                $line
-            ));
-        }
-        if ($col < 1) {
-            throw new RuntimeException(sprintf(
-                'Col number cannot be less than 1 (got "%s")',
-                $col
-            ));
-        }
-        $this->line = $line;
-        $this->col = $col;
-    }
-
-    public function toByteOffset(string $text): ByteOffset
-    {
-        $linesAndDelims = (array)preg_split('{(' . self::NEWLINE_PATTERN . ')}', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
-
-        if (count($linesAndDelims) === 0) {
-            return ByteOffset::fromInt(
-                strlen((string)reset($linesAndDelims))
-            );
-        }
-
-        $lineNb = 1;
-        $offset = 0;
-        foreach ($linesAndDelims as $lineOrDelim) {
-            $lineOrDelim = (string)$lineOrDelim;
-
-            if ((bool)preg_match('{(' . self::NEWLINE_PATTERN . ')}', (string)$lineOrDelim)) {
-                $lineNb++;
-                $offset += strlen($lineOrDelim);
-                continue;
-            }
-
-            if ($lineNb === $this->line()) {
-                $lineSection = mb_substr(
-                    $lineOrDelim,
-                    0,
-                    $this->col() - 1
-                );
-                return ByteOffset::fromInt(
-                    $offset + (int)strlen($lineSection)
-                );
-            }
-
-            $offset += strlen((string)$lineOrDelim);
-        }
-
-        return ByteOffset::fromInt(strlen($text));
-    }
-
-    public static function fromByteOffset(string $text, ByteOffset $byteOffset): self
-    {
-        if ($byteOffset->toInt() > strlen($text)) {
-            $byteOffset = ByteOffset::fromInt(strlen($text));
-        }
-
-        $lines = preg_split('{(' . self::NEWLINE_PATTERN . ')}', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
-
-        if (false === $lines) {
-            throw new RuntimeException(
-                'Failed to preg-split text into lines'
-            );
-        }
-
-        $offset = 0;
-        $lineNb = 0;
-        foreach ($lines as $lineOrDelim) {
-            $lineOrDelim = (string)$lineOrDelim;
-
-            if ((bool)preg_match('{(' . self::NEWLINE_PATTERN . ')}', (string)$lineOrDelim)) {
-                $offset += strlen($lineOrDelim);
-                continue;
-            }
-            $lineNb++;
-
-            $start = $offset;
-            $end = $offset + strlen($lineOrDelim);
-
-            // if the offset is in line...
-            if ($byteOffset->toInt() >= $start && $byteOffset->toInt() <= $end) {
-                $section = substr(
-                    $lineOrDelim,
-                    0,
-                    $byteOffset->toInt() - $start
-                );
-
-                return new self($lineNb, mb_strlen($section) + 1);
-            }
-
-            $offset = $end;
-        }
-
-        throw new OutOfBoundsException(sprintf(
-            'Byte offset %s is larger than text length %s',
-            $byteOffset->toInt(),
-            strlen($text)
-        ));
-    }
-
-    public function col(): int
-    {
-        return $this->col;
-    }
-
-    public function line(): int
-    {
-        return $this->line;
-    }
-}
diff --git a/lib/LineColRange.php b/lib/LineColRange.php
deleted file mode 100644
index 462a9de9..00000000
--- a/lib/LineColRange.php
+++ /dev/null
@@ -1,31 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-final class LineColRange
-{
-    /**
-     * @var LineCol
-     */
-    private $start;
-    /**
-     * @var LineCol
-     */
-    private $end;
-
-    public function __construct(LineCol $start, LineCol $end)
-    {
-        $this->start = $start;
-        $this->end = $end;
-    }
-
-    public function start(): LineCol
-    {
-        return $this->start;
-    }
-
-    public function end(): LineCol
-    {
-        return $this->end;
-    }
-}
diff --git a/lib/Location.php b/lib/Location.php
deleted file mode 100644
index 58531c07..00000000
--- a/lib/Location.php
+++ /dev/null
@@ -1,40 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-class Location
-{
-    /**
-     * @var TextDocumentUri
-     */
-    private $uri;
-
-    /**
-     * @var ByteOffset
-     */
-    private $offset;
-
-    public function __construct(TextDocumentUri $uri, ByteOffset $offset)
-    {
-        $this->uri = $uri;
-        $this->offset = $offset;
-    }
-
-    public static function fromPathAndOffset(string $string, int $int): Location
-    {
-        return new self(
-            TextDocumentUri::fromString($string),
-            ByteOffset::fromInt($int)
-        );
-    }
-
-    public function uri(): TextDocumentUri
-    {
-        return $this->uri;
-    }
-
-    public function offset(): ByteOffset
-    {
-        return $this->offset;
-    }
-}
diff --git a/lib/Locations.php b/lib/Locations.php
deleted file mode 100644
index c304085b..00000000
--- a/lib/Locations.php
+++ /dev/null
@@ -1,86 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-use ArrayIterator;
-use Countable;
-use IteratorAggregate;
-use RuntimeException;
-
-/**
- * @implements IteratorAggregate<Location>
- */
-final class Locations implements IteratorAggregate, Countable
-{
-    /**
-     * @var Location[]
-     */
-    private $locations = [];
-
-    /**
-     * @param iterable<Location> $locations
-     */
-    public function __construct(iterable $locations)
-    {
-        foreach ($locations as $location) {
-            $this->add($location);
-        }
-    }
-
-    /**
-     * @return ArrayIterator<int,Location>
-     */
-    public function getIterator(): ArrayIterator
-    {
-        return new ArrayIterator($this->locations);
-    }
-
-    public function append(Locations $locations): self
-    {
-        $newLocations = $this->locations;
-        foreach ($locations as $location) {
-            $newLocations[] = $location;
-        }
-
-        return new self($newLocations);
-    }
-
-    public function count(): int
-    {
-        return count($this->locations);
-    }
-
-    public function first(): Location
-    {
-        if (count($this->locations) === 0) {
-            throw new RuntimeException(
-                'There are no locations in this collection'
-            );
-        }
-
-        return reset($this->locations);
-    }
-
-    public function sorted(): self
-    {
-        $sortedLocations = $this->locations;
-
-        usort($sortedLocations, function (Location $first, Location $second) {
-            $order = strcmp((string) $first->uri(), (string) $second->uri());
-            if (0 !== $order) {
-                return $order;
-            }
-
-            return $first->offset()->toInt() - $second->offset()->toInt();
-        });
-
-        return new self($sortedLocations);
-    }
-
-    private function add(Location $location): self
-    {
-        $this->locations[] = $location;
-
-        return $this;
-    }
-}
diff --git a/lib/StandardTextDocument.php b/lib/StandardTextDocument.php
deleted file mode 100644
index a532f596..00000000
--- a/lib/StandardTextDocument.php
+++ /dev/null
@@ -1,54 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-class StandardTextDocument implements TextDocument
-{
-    /**
-     * @var string
-     */
-    private $text;
-
-    /**
-     * @var TextDocumentUri|null
-     */
-    private $uri;
-
-    /**
-     * @var TextDocumentLanguage
-     */
-    private $language;
-
-    public function __construct(
-        TextDocumentLanguage $language,
-        string $text,
-        ?TextDocumentUri $uri = null
-    ) {
-        $this->text = $text;
-        $this->uri = $uri;
-        $this->language = $language;
-    }
-    /**
-     * {@inheritDoc}
-     */
-    public function __toString()
-    {
-        return $this->text;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function uri(): ?TextDocumentUri
-    {
-        return $this->uri;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function language(): TextDocumentLanguage
-    {
-        return $this->language;
-    }
-}
diff --git a/lib/TextDocument.php b/lib/TextDocument.php
deleted file mode 100644
index c231b14a..00000000
--- a/lib/TextDocument.php
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-/**
- * Represents source code or other text documents.
- */
-interface TextDocument
-{
-    /**
-     * Return the document as a string
-     */
-    public function __toString();
-
-    /**
-     * Return the URI to the document or NULL if the document has not been
-     * persisted yet.
-     */
-    public function uri(): ?TextDocumentUri;
-
-    /**
-     * Return language value object for the text document.
-     */
-    public function language(): TextDocumentLanguage;
-}
diff --git a/lib/TextDocumentBuilder.php b/lib/TextDocumentBuilder.php
deleted file mode 100644
index 45818a37..00000000
--- a/lib/TextDocumentBuilder.php
+++ /dev/null
@@ -1,112 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-use Phpactor\TextDocument\Exception\TextDocumentNotFound;
-use RuntimeException;
-use Webmozart\PathUtil\Path;
-
-final class TextDocumentBuilder
-{
-    /**
-     * @var TextDocumentUri|null
-     */
-    private $uri;
-
-    /**
-     * @var TextDocumentLanguage|null
-     */
-    private $language;
-
-    /**
-     * @var string
-     */
-    private $text;
-
-    private function __construct(string $text)
-    {
-        $this->text = $text;
-    }
-
-    public static function create(string $text): self
-    {
-        return new self($text);
-    }
-
-    public static function fromUri(string $uri, ?string $language = null): self
-    {
-        $uri = TextDocumentUri::fromString($uri);
-
-        if (!file_exists($uri)) {
-            throw new TextDocumentNotFound(sprintf(
-                'Text Document not found at URI "%s"',
-                $uri
-            ));
-        }
-
-        if (!is_readable($uri)) {
-            throw new RuntimeException(sprintf(
-                'Could not read file at URI "%s"',
-                $uri
-            ));
-        }
-
-        if (null === $language) {
-            $language = Path::getExtension((string) $uri);
-        }
-
-        $contents = file_get_contents($uri);
-
-        if (false === $contents) {
-            throw new RuntimeException(sprintf(
-                'Could not read file at URI "%s"',
-                $uri
-            ));
-        }
-
-        $new = new self($contents);
-        $new->uri = $uri;
-        $new->language = TextDocumentLanguage::fromString($language);
-
-        return $new;
-    }
-
-    public static function fromTextDocument(TextDocument $document): self
-    {
-        $new = new self($document->__toString());
-        $new->uri = $document->uri();
-        $new->language = $document->language();
-
-        return $new;
-    }
-
-    public function uri(string $uri): self
-    {
-        $this->uri = TextDocumentUri::fromString($uri);
-
-        return $this;
-    }
-
-    public function language(string $language): self
-    {
-        $this->language = TextDocumentLanguage::fromString($language);
-
-        return $this;
-    }
-
-    public function text(string $text): self
-    {
-        $this->text = $text;
-
-        return $this;
-    }
-
-    public function build(): TextDocument
-    {
-        return new StandardTextDocument(
-            $this->language ?? TextDocumentLanguage::undefined(),
-            $this->text,
-            $this->uri
-        );
-    }
-}
diff --git a/lib/TextDocumentEdits.php b/lib/TextDocumentEdits.php
deleted file mode 100644
index d415bc95..00000000
--- a/lib/TextDocumentEdits.php
+++ /dev/null
@@ -1,45 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-use Iterator;
-use IteratorAggregate;
-
-/**
- * @implements IteratorAggregate<int, TextEdit>
- */
-class TextDocumentEdits implements IteratorAggregate
-{
-    /**
-     * @var TextEdits
-     */
-    private $textEdits;
-    /**
-     * @var TextDocumentUri
-     */
-    private $uri;
-
-
-    public function __construct(TextDocumentUri $uri, TextEdits $textEdits)
-    {
-        $this->textEdits = $textEdits;
-        $this->uri = $uri;
-    }
-    
-    public function uri(): TextDocumentUri
-    {
-        return $this->uri;
-    }
-
-    public function textEdits(): TextEdits
-    {
-        return $this->textEdits;
-    }
-    /**
-     * @return Iterator<TextEdit>
-     */
-    public function getIterator(): Iterator
-    {
-        return $this->textEdits->getIterator();
-    }
-}
diff --git a/lib/TextDocumentLanguage.php b/lib/TextDocumentLanguage.php
deleted file mode 100644
index b08af197..00000000
--- a/lib/TextDocumentLanguage.php
+++ /dev/null
@@ -1,57 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-class TextDocumentLanguage
-{
-    const LANGUAGE_UNDEFINED = 'undefined';
-    const LANGUAGE_PHP = 'php';
-
-    /**
-     * @var string
-     */
-    private $language;
-
-    private function __construct(string $language)
-    {
-        $this->language = $language;
-    }
-
-    public function __toString(): string
-    {
-        return $this->language;
-    }
-
-    public static function fromString(string $language): self
-    {
-        return new self($language);
-    }
-
-    public static function undefined(): self
-    {
-        return new self(self::LANGUAGE_UNDEFINED);
-    }
-
-    public function is(string $language): bool
-    {
-        return $this->language === strtolower($language);
-    }
-
-    /**
-     * @param array<string> $languages
-     */
-    public function in(array $languages): bool
-    {
-        return in_array($this->language, $languages);
-    }
-
-    public function isDefined(): bool
-    {
-        return !$this->is(self::LANGUAGE_UNDEFINED);
-    }
-
-    public function isPhp(): bool
-    {
-        return $this->is(self::LANGUAGE_PHP);
-    }
-}
diff --git a/lib/TextDocumentLocator.php b/lib/TextDocumentLocator.php
deleted file mode 100644
index 0bde2aac..00000000
--- a/lib/TextDocumentLocator.php
+++ /dev/null
@@ -1,15 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-use Phpactor\TextDocument\Exception\TextDocumentNotFound;
-
-interface TextDocumentLocator
-{
-    /**
-     * Retrieve text document by URI, returns NULL if not found
-     *
-     * @throws TextDocumentNotFound
-     */
-    public function get(TextDocumentUri $uri): TextDocument;
-}
diff --git a/lib/TextDocumentLocator/ChainDocumentLocator.php b/lib/TextDocumentLocator/ChainDocumentLocator.php
deleted file mode 100644
index 79cea28e..00000000
--- a/lib/TextDocumentLocator/ChainDocumentLocator.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\TextDocumentLocator;
-
-use Phpactor\TextDocument\Exception\TextDocumentNotFound;
-use Phpactor\TextDocument\TextDocument;
-use Phpactor\TextDocument\TextDocumentUri;
-use Phpactor\TextDocument\TextDocumentLocator;
-
-class ChainDocumentLocator implements TextDocumentLocator
-{
-    /**
-     * @var TextDocumentLocator[]
-     */
-    private $locators;
-
-    /**
-     * @param TextDocumentLocator[] $locators
-     */
-    public function __construct(array $locators)
-    {
-        $this->locators = $locators;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function get(TextDocumentUri $uri): TextDocument
-    {
-        foreach ($this->locators as $workspace) {
-            try {
-                return $workspace->get($uri);
-            } catch (TextDocumentNotFound $notFound) {
-            }
-        }
-
-        throw TextDocumentNotFound::fromUri($uri);
-    }
-}
diff --git a/lib/TextDocumentLocator/InMemoryDocumentLocator.php b/lib/TextDocumentLocator/InMemoryDocumentLocator.php
deleted file mode 100644
index 2b2c1da7..00000000
--- a/lib/TextDocumentLocator/InMemoryDocumentLocator.php
+++ /dev/null
@@ -1,49 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\TextDocumentLocator;
-
-use Phpactor\TextDocument\Exception\TextDocumentNotFound;
-use Phpactor\TextDocument\TextDocument;
-use Phpactor\TextDocument\TextDocumentUri;
-use Phpactor\TextDocument\TextDocumentLocator;
-
-final class InMemoryDocumentLocator implements TextDocumentLocator
-{
-    /**
-     * @var array<string, TextDocument>
-     */
-    private $documents = [];
-
-    /**
-     * @param array<string, TextDocument> $textDocuments
-     */
-    private function __construct(array $textDocuments)
-    {
-        $this->documents = $textDocuments;
-    }
-
-    public function get(TextDocumentUri $uri): TextDocument
-    {
-        if (isset($this->documents[$uri->__toString()])) {
-            return $this->documents[$uri->__toString()];
-        }
-
-        throw TextDocumentNotFound::fromUri($uri);
-    }
-
-    /**
-     * @param TextDocument[] $textDocuments
-     */
-    public static function fromTextDocuments(array $textDocuments): self
-    {
-        /** @phpstan-ignore-next-line */
-        return new self((array)array_combine(array_map(function (TextDocument $document): string {
-            return $document->uri()->__toString();
-        }, $textDocuments), array_values($textDocuments)));
-    }
-
-    public static function new(): self
-    {
-        return new self([]);
-    }
-}
diff --git a/lib/TextDocumentUri.php b/lib/TextDocumentUri.php
deleted file mode 100644
index 64fe078b..00000000
--- a/lib/TextDocumentUri.php
+++ /dev/null
@@ -1,78 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-use Phpactor\TextDocument\Exception\InvalidUriException;
-use Webmozart\PathUtil\Path;
-
-class TextDocumentUri
-{
-    /**
-     * @var string
-     */
-    private $scheme;
-
-    /**
-     * @var string
-     */
-    private $path;
-
-    final private function __construct(string $scheme, string $path)
-    {
-        $this->scheme = $scheme;
-        $this->path = $path;
-    }
-
-    public function __toString(): string
-    {
-        return sprintf('%s://%s', $this->scheme, $this->path);
-    }
-
-    public static function fromString(string $uri): self
-    {
-        $components = parse_url($uri);
-
-        if (false === $components) {
-            throw new InvalidUriException(sprintf(
-                'Could not parse_url "%s"',
-                $uri
-            ));
-        }
-
-        if (!isset($components['path'])) {
-            throw new InvalidUriException(sprintf(
-                'URI "%s" has no path component',
-                $uri
-            ));
-        }
-
-        if (isset($components['scheme']) && $components['scheme'] !== 'file') {
-            throw new InvalidUriException(sprintf(
-                'Only "file://" scheme is supported, got "%s"',
-                $components['scheme']
-            ));
-        }
-
-        if (false === Path::isAbsolute($uri)) {
-            throw new InvalidUriException(sprintf(
-                'URI must be absolute, got "%s"',
-                $uri
-            ));
-        }
-
-        return new self(
-            'file',
-            $components['path']
-        );
-    }
-
-    public function path(): string
-    {
-        return $this->path;
-    }
-
-    public function scheme(): string
-    {
-        return $this->scheme;
-    }
-}
diff --git a/lib/TextEdit.php b/lib/TextEdit.php
deleted file mode 100644
index 5635e5ed..00000000
--- a/lib/TextEdit.php
+++ /dev/null
@@ -1,68 +0,0 @@
-<?php
-
-
-namespace Phpactor\TextDocument;
-
-use OutOfRangeException;
-
-class TextEdit
-{
-    /**
-     * @var ByteOffset
-     */
-    private $start;
-
-    /**
-     * @var int
-     */
-    private $length;
-
-    /**
-     * @var string
-     */
-    private $replacement;
-
-    private function __construct(ByteOffset $start, int $length, string $content)
-    {
-        if ($length < 0) {
-            throw new OutOfRangeException(sprintf(
-                'Text edit length cannot be less than 0, got "%s" (start: %s, content: %s)',
-                $length,
-                $start->toInt(),
-                $content
-            ));
-        }
-
-        $this->start = $start;
-        $this->length = $length;
-        $this->replacement = $content;
-    }
-
-    /**
-     * @param int|ByteOffset $start
-     */
-    public static function create($start, int $length, string $replacement): self
-    {
-        return new self(ByteOffset::fromIntOrByteOffset($start), $length, $replacement);
-    }
-
-    public function end(): ByteOffset
-    {
-        return $this->start->add($this->length);
-    }
-
-    public function start(): ByteOffset
-    {
-        return $this->start;
-    }
-
-    public function length(): int
-    {
-        return $this->length;
-    }
-
-    public function replacement(): string
-    {
-        return $this->replacement;
-    }
-}
diff --git a/lib/TextEdits.php b/lib/TextEdits.php
deleted file mode 100644
index b400f0fe..00000000
--- a/lib/TextEdits.php
+++ /dev/null
@@ -1,120 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-use ArrayIterator;
-use Iterator;
-use IteratorAggregate;
-use OutOfBoundsException;
-
-/**
- * @implements IteratorAggregate<int, TextEdit>
- */
-class TextEdits implements IteratorAggregate
-{
-    /**
-     * @var TextEdit[]
-     */
-    private $textEdits;
-
-    public function __construct(TextEdit ...$textEdits)
-    {
-        usort($textEdits, function (TextEdit $a, TextEdit $b) {
-            return $a->start() <=> $b->start();
-        });
-        $this->textEdits = $textEdits;
-    }
-
-    public static function one(TextEdit $textEdit): self
-    {
-        return new self($textEdit);
-    }
-
-    /**
-     * @return Iterator<TextEdit>
-     */
-    public function getIterator(): Iterator
-    {
-        return new ArrayIterator($this->textEdits);
-    }
-
-    public static function none(): self
-    {
-        return new self();
-    }
-
-    /**
-     * @param array<TextEdit> $textEdits
-     */
-    public static function fromTextEdits(array $textEdits): self
-    {
-        return new self(...$textEdits);
-    }
-
-    /**
-     * Merge one set of edits into this set.
-     *
-     * Edits from this set are ordered before those of the merged edits.
-     */
-    public function merge(TextEdits $edits): self
-    {
-        return new self(...array_merge($this->textEdits, $edits->textEdits));
-    }
-
-    /**
-     * Apply this set of edits to the given text
-     */
-    public function apply(string $text): string
-    {
-        $prevEditStart = PHP_INT_MAX;
-
-        for ($i = \count($this->textEdits) - 1; $i >= 0; $i--) {
-            $edit = $this->textEdits[$i];
-            assert($edit instanceof TextEdit);
-
-            if ($prevEditStart < $edit->start()->toInt() || $prevEditStart < $edit->end()->toInt()) {
-                throw new OutOfBoundsException(sprintf(
-                    "Overlapping text edit:\n%s",
-                    self::renderDebugTextEdits($edit, $this->textEdits)
-                ));
-            }
-
-            if ($edit->end()->toInt() > \strlen($text)) {
-                throw new OutOfBoundsException(sprintf(
-                    'Text edit end (%s) exceeds length of text (%s): %s',
-                    $edit->end()->toInt(),
-                    $edit->replacement(),
-                    self::renderDebugTextEdits($edit, $this->textEdits)
-                ));
-            }
-
-            $prevEditStart = $edit->start()->toInt();
-            $head = \substr($text, 0, $edit->start()->toInt());
-            $tail = \substr($text, $edit->end()->toInt());
-            $text = $head . $edit->replacement() . $tail;
-        }
-
-        return $text;
-    }
-
-    public function add(TextEdit $textEdit): self
-    {
-        return new self(...array_merge($this->textEdits, [$textEdit]));
-    }
-
-    /**
-     * @param array<TextEdit> $edits
-     */
-    private static function renderDebugTextEdits(TextEdit $edit, array $edits): string
-    {
-        return implode("\n", array_map(function (TextEdit $otherEdit) use ($edit) {
-            return sprintf(
-                '%s%s %s "%s"',
-                $edit === $otherEdit ? '> ' : '  ',
-                $otherEdit->start()->toInt(),
-                $otherEdit->end()->toInt(),
-                str_replace("\n", '\n', $otherEdit->replacement())
-            );
-        }, $edits));
-    }
-}
diff --git a/lib/Util/LineAtOffset.php b/lib/Util/LineAtOffset.php
deleted file mode 100644
index 05f7f75e..00000000
--- a/lib/Util/LineAtOffset.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Util;
-
-use OutOfBoundsException;
-use Phpactor\TextDocument\ByteOffset;
-use RuntimeException;
-
-final class LineAtOffset
-{
-    public function __invoke(string $text, int $byteOffset): string
-    {
-        $lines = preg_split("{(\r\n|\n|\r)}", $text, -1, PREG_SPLIT_DELIM_CAPTURE);
-        if (false === $lines) {
-            throw new RuntimeException(
-                'Failed to preg-split text into lines'
-            );
-        }
-
-        $start = 0;
-        $lastLine = '';
-        foreach ($lines as $line) {
-            $end = $start + strlen($line);
-            if ($byteOffset >= $start && $byteOffset <= $end) {
-                if (preg_match('{^(\r\n|\n|\r)$}', $line)) {
-                    return $lastLine;
-                }
-                return $line;
-            }
-            $lastLine = $line;
-            $start = $end;
-        }
-
-        throw new OutOfBoundsException(sprintf(
-            'Byte offset %s is larger than text length %s',
-            $byteOffset,
-            strlen($text)
-        ));
-    }
-    public static function lineAtByteOffset(string $text, ByteOffset $offset): string
-    {
-        return (new self())->__invoke($text, $offset->toInt());
-    }
-}
diff --git a/lib/Util/LineColFromOffset.php b/lib/Util/LineColFromOffset.php
deleted file mode 100644
index f9068aa8..00000000
--- a/lib/Util/LineColFromOffset.php
+++ /dev/null
@@ -1,17 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Util;
-
-use Phpactor\TextDocument\ByteOffset;
-use Phpactor\TextDocument\LineCol;
-
-class LineColFromOffset
-{
-    /**
-     * @deprecated Use LineCol Value object
-     */
-    public function __invoke(string $document, int $byteOffset): LineCol
-    {
-        return LineCol::fromByteOffset($document, ByteOffset::fromInt($byteOffset));
-    }
-}
diff --git a/lib/Util/LineColRangeForLine.php b/lib/Util/LineColRangeForLine.php
deleted file mode 100644
index 986084ef..00000000
--- a/lib/Util/LineColRangeForLine.php
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Util;
-
-use Phpactor\TextDocument\LineCol;
-use Phpactor\TextDocument\LineColRange;
-use RuntimeException;
-
-final class LineColRangeForLine
-{
-    public function rangeFromLine(string $text, int $lineNo): LineColRange
-    {
-        $lines = preg_split("{(\r\n|\n|\r)}", $text, -1);
-
-        if (false === $lines) {
-            throw new RuntimeException(
-                'Failed to preg-split text into lines'
-            );
-        }
-
-        // if out of range, let the caller deal with it
-        if (!isset($lines[$lineNo - 1])) {
-            return new LineColRange(
-                new LineCol($lineNo, 1),
-                new LineCol($lineNo, 1)
-            );
-        }
-
-        $line = $lines[$lineNo - 1];
-
-        // if an empty line, return the first char
-        if (!$line) {
-            return new LineColRange(
-                new LineCol($lineNo, 1),
-                new LineCol($lineNo, 1)
-            );
-        }
-
-        $start = mb_strpos($line, trim($line));
-        $end = $start + mb_strlen(trim($line));
-
-        return new LineColRange(
-            new LineCol($lineNo, $start + 1),
-            new LineCol($lineNo, $end)
-        );
-    }
-}
diff --git a/lib/Util/WordAtOffset.php b/lib/Util/WordAtOffset.php
deleted file mode 100644
index 81cfa05e..00000000
--- a/lib/Util/WordAtOffset.php
+++ /dev/null
@@ -1,51 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Util;
-
-use OutOfBoundsException;
-use RuntimeException;
-
-final class WordAtOffset
-{
-    const SPLIT_WORD = '\s|;|\\\|%|\(|\)|\[|\]|:|\r|\r\n|\n';
-
-    // see https://www.php.net/manual/en/language.oop5.basic.php
-    const SPLIT_PHP_NAME = '[^a-zA-Z0-9_\x80-\xff]';
-    const SPLIT_QUALIFIED_PHP_NAME = '[^a-zA-Z0-9_\x80-\xff\\\]';
-
-    /**
-     * @var string
-     */
-    private $splitPattern;
-
-    public function __construct(string $splitPattern = self::SPLIT_WORD)
-    {
-        $this->splitPattern = $splitPattern;
-    }
-
-    public function __invoke(string $text, int $byteOffset): string
-    {
-        $chunks = preg_split('{(' . $this->splitPattern . ')}', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
-
-        if (false === $chunks) {
-            throw new RuntimeException(
-                'Failed to preg-split text into chunks'
-            );
-        }
-
-        $start = 1;
-        foreach ($chunks as $chunk) {
-            $end = $start + strlen($chunk);
-            if ($byteOffset >= $start && $byteOffset < $end) {
-                return $chunk;
-            }
-            $start = $end;
-        }
-
-        throw new OutOfBoundsException(sprintf(
-            'Byte offset %s is larger than text length %s',
-            $byteOffset,
-            strlen($text)
-        ));
-    }
-}
diff --git a/lib/WorkspaceEdits.php b/lib/WorkspaceEdits.php
deleted file mode 100644
index e4d86f9c..00000000
--- a/lib/WorkspaceEdits.php
+++ /dev/null
@@ -1,30 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument;
-
-use ArrayIterator;
-use Iterator;
-use IteratorAggregate;
-
-/**
- * @implements IteratorAggregate<int, TextDocumentEdits>
- */
-class WorkspaceEdits implements IteratorAggregate
-{
-    /**
-     * @var TextDocumentEdits[]
-     */
-    private $documentEdits;
-
-    public function __construct(TextDocumentEdits ...$documentEdits)
-    {
-        $this->documentEdits = $documentEdits;
-    }
-    /**
-     * @return Iterator<TextDocumentEdits>
-     */
-    public function getIterator(): Iterator
-    {
-        return new ArrayIterator($this->documentEdits);
-    }
-}
diff --git a/phpstan.neon b/phpstan.neon
index b7400ac5..1d5823a9 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,5 +1,4 @@
 parameters:
     level: 7
-    inferPrivatePropertyTypeFromConstructor: true
     paths:
-        - lib
+        - src
diff --git a/tests/Unit/EfficientLineColsTest.php b/tests/Unit/EfficientLineColsTest.php
new file mode 100644
index 00000000..252459f7
--- /dev/null
+++ b/tests/Unit/EfficientLineColsTest.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use Closure;
+use Generator;
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\EfficientLineCols;
+
+class EfficientLineColsTest extends TestCase
+{
+    /**
+     * @dataProvider provideConvertOffsetsToLineCol
+     * @param list<int> $offsets
+     */
+    public function testFromByteOffsets(array $offsets, string $text, Closure $assertion): void
+    {
+        $converter = EfficientLineCols::fromByteOffsetInts($text, $offsets);
+        $assertion($converter);
+    }
+
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideConvertOffsetsToLineCol(): Generator
+    {
+        yield [
+            [],
+            '',
+            function (EfficientLineCols $lineCols): void {
+                self::assertInstanceOf(EfficientLineCols::class, $lineCols);
+            }
+        ];
+        yield [
+            [2],
+            '01234',
+            function (EfficientLineCols $lineCols): void {
+                self::assertEquals(3, $lineCols->get(2)->col());
+                self::assertEquals(1, $lineCols->get(2)->line());
+            }
+        ];
+        yield [
+            [2, 3, 0, 10],
+            "01234\n5678",
+            function (EfficientLineCols $lineCols): void {
+                self::assertEquals(3, $lineCols->get(2)->col());
+                self::assertEquals(1, $lineCols->get(2)->line());
+                self::assertEquals(2, $lineCols->get(10)->line());
+                self::assertEquals(5, $lineCols->get(10)->col());
+            }
+        ];
+    }
+
+    /**
+     * @dataProvider provideConvertOffsetsToLineColAsOffset
+     * @param list<int> $offsets
+     */
+    public function testFromByteOffsetsAsOffset(array $offsets, string $text, Closure $assertion): void
+    {
+        $converter = EfficientLineCols::fromByteOffsetInts($text, $offsets, true);
+        $assertion($converter);
+    }
+
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideConvertOffsetsToLineColAsOffset(): Generator
+    {
+        yield 'cat is 4 bytes' => [
+            [5],
+            'a😸bc',
+            function (EfficientLineCols $lineCols): void {
+                self::assertEquals(6, $lineCols->get(5)->col());
+            }
+        ];
+    }
+}
diff --git a/tests/Unit/LocationTest.php b/tests/Unit/LocationTest.php
index 86b5ecfc..82d81117 100644
--- a/tests/Unit/LocationTest.php
+++ b/tests/Unit/LocationTest.php
@@ -9,13 +9,14 @@ class LocationTest extends TestCase
 {
     public function testProvidesAccessToUri(): void
     {
-        $location = Location::fromPathAndOffset('/path/to.php', 123);
+        $location = Location::fromPathAndOffsets('/path/to.php', 123, 234);
         $this->assertEquals('file:///path/to.php', $location->uri()->__toString());
     }
 
     public function testProvidesAccessToByteOffset(): void
     {
-        $location = Location::fromPathAndOffset('/path/to.php', 123);
-        $this->assertEquals(123, $location->offset()->toInt());
+        $location = Location::fromPathAndOffsets('/path/to.php', 123, 455);
+        $this->assertEquals(123, $location->range()->start()->toInt());
+        $this->assertEquals(455, $location->range()->end()->toInt());
     }
 }
diff --git a/tests/Unit/LocationsTest.php b/tests/Unit/LocationsTest.php
index c225aa62..b5b6599f 100644
--- a/tests/Unit/LocationsTest.php
+++ b/tests/Unit/LocationsTest.php
@@ -2,6 +2,7 @@
 
 namespace Phpactor\TextDocument\Tests\Unit;
 
+use Generator;
 use PHPUnit\Framework\TestCase;
 use Phpactor\TextDocument\Location;
 use Phpactor\TextDocument\Locations;
@@ -12,8 +13,8 @@ class LocationsTest extends TestCase
     public function testContainsLocations(): void
     {
         $locations = new Locations([
-            Location::fromPathAndOffset('/path/to.php', 12),
-            Location::fromPathAndOffset('/path/to.php', 13)
+            Location::fromPathAndOffsets('/path/to.php', 12, 124),
+            Location::fromPathAndOffsets('/path/to.php', 13, 14)
         ]);
         $this->assertCount(2, $locations);
     }
@@ -21,32 +22,33 @@ public function testContainsLocations(): void
     public function testIsCountable(): void
     {
         $locations = new Locations([
-            Location::fromPathAndOffset('/path/to.php', 12),
-            Location::fromPathAndOffset('/path/to.php', 13)
+            Location::fromPathAndOffsets('/path/to.php', 12, 12),
+            Location::fromPathAndOffsets('/path/to.php', 13, 13)
         ]);
+
         $this->assertEquals(2, $locations->count());
     }
 
     public function testExceptionIfFirstNotAvailable(): void
     {
         $this->expectException(RuntimeException::class);
-        $locations = new Locations([
-        ]);
+
+        $locations = new Locations([]);
         $locations->first();
     }
 
     public function testAppendLocations(): void
     {
         $locations = new Locations([
-            Location::fromPathAndOffset('/path/to.php', 12),
+            Location::fromPathAndOffsets('/path/to.php', 12, 19),
         ]);
         $locations = $locations->append(new Locations([
-            Location::fromPathAndOffset('/path/to.php', 13),
+            Location::fromPathAndOffsets('/path/to.php', 13, 40),
         ]));
 
         self::assertEquals(new Locations([
-            Location::fromPathAndOffset('/path/to.php', 12),
-            Location::fromPathAndOffset('/path/to.php', 13)
+            Location::fromPathAndOffsets('/path/to.php', 12, 19),
+            Location::fromPathAndOffsets('/path/to.php', 13, 40)
         ]), $locations);
     }
 
@@ -64,22 +66,34 @@ public function testSortLocations(
         $sortedLocations = $locations->sorted();
 
         $this->assertNotSame($locations, $sortedLocations);
-        $this->assertEquals($sortedLocationsArray, iterator_to_array($sortedLocations));
+        $this->assertCount(count($unsortedLocationsArray), $sortedLocations);
+
+        foreach(iterator_to_array($sortedLocations) as $index => $sortedLocation) {
+            $expectedLocation = $sortedLocationsArray[$index];
+
+            self::assertEquals($sortedLocation, new Location($expectedLocation->uri(), $expectedLocation->range()));
+        }
     }
 
     /**
-     * @return iterable<array>
+     * @return Generator<mixed>
      */
-    public function provideUnsortedLocations(): iterable
+    public function provideUnsortedLocations(): Generator
     {
-        yield '2 files and 3 references' => [[
-            Location::fromPathAndOffset('/path/to.php', 15),
-            Location::fromPathAndOffset('/path/to.php', 12),
-            Location::fromPathAndOffset('/path/from.php', 13),
+        yield 'Same file is sorted by start position' => [[
+            Location::fromPathAndOffsets('/path/to.php', 30, 50),
+            Location::fromPathAndOffsets('/path/to.php', 12, 24),
+        ], [
+            Location::fromPathAndOffsets('/path/to.php', 12, 24),
+            Location::fromPathAndOffsets('/path/to.php', 30, 50),
+        ]];
+
+        yield 'Sort by file name first' => [[
+            Location::fromPathAndOffsets('/path/to.php', 12, 42),
+            Location::fromPathAndOffsets('/path/from.php', 15, 43),
         ], [
-            Location::fromPathAndOffset('/path/from.php', 13),
-            Location::fromPathAndOffset('/path/to.php', 12),
-            Location::fromPathAndOffset('/path/to.php', 15),
+            Location::fromPathAndOffsets('/path/from.php', 15, 43),
+            Location::fromPathAndOffsets('/path/to.php', 12, 42),
         ]];
     }
 }
diff --git a/tests/Unit/TextDocumentBuilderTest.php b/tests/Unit/TextDocumentBuilderTest.php
index e598a12b..f3dce33f 100644
--- a/tests/Unit/TextDocumentBuilderTest.php
+++ b/tests/Unit/TextDocumentBuilderTest.php
@@ -36,6 +36,7 @@ public function testFromTextDocument(): void
 
         $this->assertEquals('foobar', $doc->__toString());
         $this->assertEquals('file:///foobar/asd', $doc->uri()->__toString());
+        $this->assertEquals('/foobar/asd', $doc->uri()?->path());
         $this->assertEquals('foo', $doc->language()->__toString());
     }
 
diff --git a/tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php b/tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php
index 2baa30f6..a4126bf4 100644
--- a/tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php
+++ b/tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php
@@ -5,6 +5,7 @@
 use PHPUnit\Framework\TestCase;
 use Phpactor\TextDocument\Exception\TextDocumentNotFound;
 use Phpactor\TextDocument\TextDocumentBuilder;
+use Phpactor\TextDocument\TextDocumentLocator;
 use Phpactor\TextDocument\TextDocumentUri;
 use Phpactor\TextDocument\TextDocumentLocator\ChainDocumentLocator;
 use Phpactor\TextDocument\TextDocumentLocator\InMemoryDocumentLocator;
@@ -14,12 +15,7 @@ class ChainDocumentLocatorTest extends TestCase
     public function testThrowsExceptionWhenNotFound(): void
     {
         $this->expectException(TextDocumentNotFound::class);
-
-        self::assertNull(
-            $this->createWorkspace()->get(
-                TextDocumentUri::fromString('file:///foobar')
-            )
-        );
+        $this->createWorkspace()->get(TextDocumentUri::fromString('file:///foobar'));
     }
 
     public function testReturnsTextDocument(): void
@@ -55,7 +51,7 @@ public function testReturnsTextDocumentFromFirstWorkspace(): void
     }
 
     /**
-     * @param Workspace[] $workspaces
+     * @param TextDocumentLocator[] $workspaces
      */
     private function createWorkspace(array $workspaces = []): ChainDocumentLocator
     {
diff --git a/tests/Unit/TextDocumentUriTest.php b/tests/Unit/TextDocumentUriTest.php
index 1bd91afe..c05dfb8e 100644
--- a/tests/Unit/TextDocumentUriTest.php
+++ b/tests/Unit/TextDocumentUriTest.php
@@ -14,6 +14,18 @@ public function testCreate(): void
         $this->assertEquals('file://' . __FILE__, (string) $uri);
     }
 
+    public function testCreateUntitled(): void
+    {
+        $uri = TextDocumentUri::fromString('untitled:Untitled-1');
+        $this->assertEquals('untitled:Untitled-1', (string) $uri);
+    }
+
+    public function testCreatePhar(): void
+    {
+        $uri = TextDocumentUri::fromString('phar://' . __FILE__);
+        $this->assertEquals('phar://' . __FILE__, (string) $uri);
+    }
+
     public function testNormalizesToFileScheme(): void
     {
         $uri = TextDocumentUri::fromString(__FILE__);
@@ -30,14 +42,14 @@ public function testExceptionOnInvalidUri(): void
     {
         $this->expectException(InvalidUriException::class);
         $this->expectExceptionMessage('not parse');
-        TextDocumentUri::fromString('no:///this:isnot/ && !');
+        TextDocumentUri::fromString('');
     }
 
     public function testExceptionOnNoPath(): void
     {
         $this->expectException(InvalidUriException::class);
         $this->expectExceptionMessage('has no path');
-        TextDocumentUri::fromString('file://foo');
+        TextDocumentUri::fromString('file://');
     }
 
     public function testFromPath(): void
@@ -49,7 +61,7 @@ public function testFromPath(): void
     public function testFromHttpUri(): void
     {
         $this->expectException(InvalidUriException::class);
-        $this->expectExceptionMessage('Only "file://" scheme is supported, got "http"');
+        $this->expectExceptionMessage('Only "file", "untitled", "phar" schemes are supported, got "http"');
         $uri = TextDocumentUri::fromString('http://foobar/foobar');
     }
 
diff --git a/tests/Unit/TextEditDiffTest.php b/tests/Unit/TextEditDiffTest.php
new file mode 100644
index 00000000..8382d614
--- /dev/null
+++ b/tests/Unit/TextEditDiffTest.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use Generator;
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\TextEditDiff;
+
+class TextEditDiffTest extends TestCase
+{
+    /**
+     * @dataProvider provideDiff
+     */
+    public function testDiff(string $one, string $two): void
+    {
+        $edits = (new TextEditDiff())->diff($one, $two);
+        self::assertEquals(
+            $two,
+            $edits->apply($one)
+        );
+    }
+
+    /**
+      * @return Generator<string,array{string,string}>
+      */
+    public function provideDiff(): Generator
+    {
+        yield 'add string' => [
+            'foo',
+            'foo bar',
+        ];
+        yield 'remove string' => [
+            'foo bar',
+            'foo',
+        ];
+        yield 'insert string' => [
+            'foo bar',
+            'foo baz boo bar bag',
+        ];
+
+        yield 'first char' => [
+            'i',
+            'b',
+        ];
+
+        yield 'differnet' => [
+            'it little profits',
+            'that an idle king',
+        ];
+
+        yield 'poem' => [
+            implode("\n", [
+                'it little profits that an idle king',
+                'matched with an aged wife',
+            ]),
+            implode("\n", [
+                'by this still hearth',
+                'it little profits that an idle king',
+            ])
+        ];
+
+        yield 'code' => [
+            implode("\n", [
+                '<?php',
+                'class Foobar {',
+                '    private $foobar;',
+                '    /**',
+                '     * @return array<int,foobar>',
+                '     */',
+                '    public function bar() {',
+                '    }',
+                '}'
+            ]),
+            implode("\n", [
+                '<?php',
+                'class Foobar {',
+                '',
+                '    /**',
+                '     * @var Foobars',
+                '     */',
+                '    private $foobar;',
+                '',
+                '    /**',
+                '     * @param array<int,foobar> $bar',
+                '     * @return array<string,foobar>',
+                '     */',
+                '    public function bar(array $bar) {',
+                '    }',
+                '}'
+            ]),
+        ];
+    }
+}
diff --git a/tests/Unit/TextEditsTest.php b/tests/Unit/TextEditsTest.php
index c3a62325..50f0c2e9 100644
--- a/tests/Unit/TextEditsTest.php
+++ b/tests/Unit/TextEditsTest.php
@@ -11,6 +11,9 @@ class TextEditsTest extends TestCase
 {
     /**
      * @dataProvider provideMerge
+     * @param TextEdit[] $edits1
+     * @param TextEdit[] $edits2
+     * @param TextEdit[] $expectedEdits
      */
     public function testMerge(array $edits1, array $edits2, array $expectedEdits): void
     {
@@ -22,7 +25,8 @@ public function testMerge(array $edits1, array $edits2, array $expectedEdits): v
         );
     }
 
-    public function provideMerge()
+
+    public function provideMerge(): Generator
     {
         yield 'empty' => [
             [
diff --git a/tests/Unit/Util/LineAtOffsetTest.php b/tests/Unit/Util/LineAtOffsetTest.php
index 5f84e06d..ec4f023c 100644
--- a/tests/Unit/Util/LineAtOffsetTest.php
+++ b/tests/Unit/Util/LineAtOffsetTest.php
@@ -2,6 +2,7 @@
 
 namespace Phpactor\TextDocument\Tests\Unit\Util;
 
+use Generator;
 use OutOfBoundsException;
 use PHPUnit\Framework\TestCase;
 use Phpactor\TestUtils\ExtractOffset;
@@ -15,11 +16,15 @@ class LineAtOffsetTest extends TestCase
     public function testLineAtOffset(string $text, string $expectedWord): void
     {
         [ $text, $offset ] = ExtractOffset::fromSource($text);
+        $offset = (int) $offset;
 
-        $this->assertEquals($expectedWord, (new LineAtOffset())($text, --$offset));
+        self::assertEquals($expectedWord, (new LineAtOffset())($text, --$offset));
     }
 
-    public function provideLineAtOffset()
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideLineAtOffset(): Generator
     {
         yield [
             'hello thi<>s is',
diff --git a/tests/Unit/Util/LineColFromOffsetTest.php b/tests/Unit/Util/LineColFromOffsetTest.php
deleted file mode 100644
index 5d0b4209..00000000
--- a/tests/Unit/Util/LineColFromOffsetTest.php
+++ /dev/null
@@ -1,93 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit\Util;
-
-use Generator;
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\LineCol;
-use Phpactor\TextDocument\Util\LineColFromOffset;
-
-class LineColFromOffsetTest extends TestCase
-{
-    /**
-     * @dataProvider provideLineColFromOffset
-     */
-    public function testLineColFromOffset(
-        string $document,
-        int $offset,
-        int $expectedLine,
-        int $expectedCol
-    ): void {
-        $lineCol = (new LineColFromOffset())($document, $offset);
-        assert($lineCol instanceof LineCol);
-        $this->assertEquals($expectedLine, $lineCol->line(), 'line no');
-        $this->assertEquals($expectedCol, $lineCol->col(), 'col no');
-        $this->assertEquals($offset, $lineCol->toByteOffset($document)->toInt(), 'reverse to byte offset');
-    }
-
-    /**
-     * @return Generator<mixed>
-     */
-    public function provideLineColFromOffset(): Generator
-    {
-        yield 'hello 1' => [
-            'hello',
-            0,
-            1,
-            1
-        ];
-
-        yield 'hello 2' => [
-            'hello',
-            2,
-            1,
-            3
-        ];
-
-        yield 'multiline' => [
-            "hello\ngoodbye",
-            8,
-            2,
-            3,
-        ];
-
-        yield 'multiline 2' => [
-            "12\n345\n678",
-            4,
-            2,
-            2
-        ];
-
-        yield '2 lines with special chars' => [
-            "h转llo\ngoodbye",
-            10,
-            2,
-            3
-        ];
-
-        yield '4 lines with special chars' => [
-            <<<'EOT'
-                h转llo
-                goodbye
-                h转llo
-                goodbye
-                EOT
-        ,
-            26,
-            4,
-            3
-        ];
-    }
-
-    public function testOutOfBoundsLineCol(): void
-    {
-        $lineCol = (new LineColFromOffset())(<<<'EOT'
-            foo
-            bar
-            EOT
-        , 10);
-        assert($lineCol instanceof LineCol);
-        self::assertEquals(2, $lineCol->line());
-        self::assertEquals(4, $lineCol->col());
-    }
-}
diff --git a/tests/Unit/Util/WordAtOffsetTest.php b/tests/Unit/Util/WordAtOffsetTest.php
index f8be329a..933ba21b 100644
--- a/tests/Unit/Util/WordAtOffsetTest.php
+++ b/tests/Unit/Util/WordAtOffsetTest.php
@@ -2,6 +2,7 @@
 
 namespace Phpactor\TextDocument\Tests\Unit\Util;
 
+use Generator;
 use PHPUnit\Framework\TestCase;
 use Phpactor\TestUtils\ExtractOffset;
 use Phpactor\TextDocument\Util\WordAtOffset;
@@ -11,14 +12,18 @@ class WordAtOffsetTest extends TestCase
     /**
      * @dataProvider provideWordAtOffset
      */
-    public function testWordAtOffset(string $text, string $expectedWord, $split = WordAtOffset::SPLIT_WORD): void
+    public function testWordAtOffset(string $text, string $expectedWord, string $split = WordAtOffset::SPLIT_WORD): void
     {
         [ $text, $offset ] = ExtractOffset::fromSource($text);
+        $offset = (int)$offset;
 
         $this->assertEquals($expectedWord, (new WordAtOffset($split))($text, $offset));
     }
 
-    public function provideWordAtOffset()
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideWordAtOffset(): Generator
     {
         yield [
             'hello thi<>s is',

From 657521d5f3443b425e7d2c5f22d9846e24b28d4f Mon Sep 17 00:00:00 2001
From: Daniel Leech <daniel@dantleech.com>
Date: Fri, 26 Jan 2024 16:22:11 +0000
Subject: [PATCH 16/19] Udpate CS

---
 .gitignore             |  2 +-
 .php-cs-fixer.dist.php | 51 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 52 insertions(+), 1 deletion(-)
 create mode 100644 .php-cs-fixer.dist.php

diff --git a/.gitignore b/.gitignore
index f4c067ba..d4fa257c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,4 @@
 /stubs
 .phpunit.result.cache
 /.vscode
-.php_cs.cache
\ No newline at end of file
+.php-cs-fixer.cache
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 00000000..c892faf6
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,51 @@
+<?php
+
+use PhpCsFixer\Config;
+use PhpCsFixer\Finder;
+
+$finder = Finder::create()
+    ->in('src')
+    ->in('tests')
+    ->exclude([
+        'Workspace',
+        'InternalStubs',
+    ])
+;
+
+return (new Config())
+    ->setRiskyAllowed(true)
+    ->setRules([
+        '@PSR2' => true,
+        'no_unused_imports' => true,
+        'phpdoc_to_property_type' => true,
+        'no_superfluous_phpdoc_tags' => [
+            'remove_inheritdoc' => true,
+            'allow_mixed' => true,
+        ],
+        'class_attributes_separation' => [
+            'elements' => [
+                'const' => 'only_if_meta',
+                'property' => 'one',
+                'trait_import' => 'only_if_meta',
+            ],
+        ],
+        'ordered_class_elements' => true,
+        'no_empty_phpdoc' => true,
+        'phpdoc_trim' => true,
+        'array_syntax' => ['syntax' => 'short'],
+        'list_syntax' => ['syntax' => 'short'],
+        'void_return' => true,
+        'ordered_class_elements' => true,
+        'single_quote' => true,
+        'heredoc_indentation' => true,
+        'global_namespace_import' => true,
+        'no_trailing_whitespace' => true,
+        'no_whitespace_in_blank_line' => true,
+        'new_with_parentheses' => [
+            'anonymous_class' => true,
+            'named_class' => true,
+        ],
+    ])
+    ->setFinder($finder)
+;
+

From e59f40b71d10f60025e256aa2c51b3f0b96243da Mon Sep 17 00:00:00 2001
From: Daniel Leech <daniel@dantleech.com>
Date: Fri, 26 Jan 2024 16:22:15 +0000
Subject: [PATCH 17/19] Add src and bin

---
 bin/update.php                                |  11 +
 src/ByteOffset.php                            |  57 +++++
 src/ByteOffsetRange.php                       |  38 ++++
 src/EfficientLineCols.php                     | 101 +++++++++
 src/Exception/CouldNotSaveDocument.php        |   9 +
 src/Exception/InvalidByteOffset.php           |   9 +
 src/Exception/InvalidUriException.php         |   9 +
 src/Exception/TextDocumentNotFound.php        |  17 ++
 src/FilesystemTextDocumentLocator.php         |  17 ++
 src/LineCol.php                               | 137 ++++++++++++
 src/LineColRange.php                          |  20 ++
 src/Location.php                              |  41 ++++
 src/Locations.php                             |  86 ++++++++
 src/StandardTextDocument.php                  |  42 ++++
 src/Tests/Unit/ByteOffsetTest.php             |  39 ++++
 src/Tests/Unit/EfficientLineColsTest.php      |  77 +++++++
 src/Tests/Unit/LineColTest.php                |  73 +++++++
 src/Tests/Unit/LocationTest.php               |  22 ++
 src/Tests/Unit/LocationsTest.php              |  99 +++++++++
 src/Tests/Unit/TextDocumentBuilderTest.php    |  48 +++++
 src/Tests/Unit/TextDocumentLanguageTest.php   |  28 +++
 .../ChainDocumentLocatorTest.php              |  60 ++++++
 src/Tests/Unit/TextDocumentUriTest.php        |  80 +++++++
 src/Tests/Unit/TextEditDiffTest.php           |  93 ++++++++
 src/Tests/Unit/TextEditTest.php               |  38 ++++
 src/Tests/Unit/TextEditsTest.php              | 198 ++++++++++++++++++
 src/Tests/Unit/Util/LineAtOffsetTest.php      |  85 ++++++++
 .../Unit/Util/LineColRangeForLineTest.php     |  77 +++++++
 src/Tests/Unit/Util/WordAtOffsetTest.php      | 118 +++++++++++
 src/TextDocument.php                          |  27 +++
 src/TextDocumentBuilder.php                   | 122 +++++++++++
 src/TextDocumentEdits.php                     |  41 ++++
 src/TextDocumentLanguage.php                  |  51 +++++
 src/TextDocumentLocator.php                   |  15 ++
 .../ChainDocumentLocator.php                  |  31 +++
 .../InMemoryDocumentLocator.php               |  42 ++++
 src/TextDocumentUri.php                       |  87 ++++++++
 src/TextEdit.php                              |  59 ++++++
 src/TextEditDiff.php                          | 149 +++++++++++++
 src/TextEdits.php                             | 126 +++++++++++
 src/Util/LineAtOffset.php                     |  44 ++++
 src/Util/LineColRangeForLine.php              |  47 +++++
 src/Util/WordAtOffset.php                     |  45 ++++
 src/WorkspaceEdits.php                        |  41 ++++
 44 files changed, 2656 insertions(+)
 create mode 100644 bin/update.php
 create mode 100644 src/ByteOffset.php
 create mode 100644 src/ByteOffsetRange.php
 create mode 100644 src/EfficientLineCols.php
 create mode 100644 src/Exception/CouldNotSaveDocument.php
 create mode 100644 src/Exception/InvalidByteOffset.php
 create mode 100644 src/Exception/InvalidUriException.php
 create mode 100644 src/Exception/TextDocumentNotFound.php
 create mode 100644 src/FilesystemTextDocumentLocator.php
 create mode 100644 src/LineCol.php
 create mode 100644 src/LineColRange.php
 create mode 100644 src/Location.php
 create mode 100644 src/Locations.php
 create mode 100644 src/StandardTextDocument.php
 create mode 100644 src/Tests/Unit/ByteOffsetTest.php
 create mode 100644 src/Tests/Unit/EfficientLineColsTest.php
 create mode 100644 src/Tests/Unit/LineColTest.php
 create mode 100644 src/Tests/Unit/LocationTest.php
 create mode 100644 src/Tests/Unit/LocationsTest.php
 create mode 100644 src/Tests/Unit/TextDocumentBuilderTest.php
 create mode 100644 src/Tests/Unit/TextDocumentLanguageTest.php
 create mode 100644 src/Tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php
 create mode 100644 src/Tests/Unit/TextDocumentUriTest.php
 create mode 100644 src/Tests/Unit/TextEditDiffTest.php
 create mode 100644 src/Tests/Unit/TextEditTest.php
 create mode 100644 src/Tests/Unit/TextEditsTest.php
 create mode 100644 src/Tests/Unit/Util/LineAtOffsetTest.php
 create mode 100644 src/Tests/Unit/Util/LineColRangeForLineTest.php
 create mode 100644 src/Tests/Unit/Util/WordAtOffsetTest.php
 create mode 100644 src/TextDocument.php
 create mode 100644 src/TextDocumentBuilder.php
 create mode 100644 src/TextDocumentEdits.php
 create mode 100644 src/TextDocumentLanguage.php
 create mode 100644 src/TextDocumentLocator.php
 create mode 100644 src/TextDocumentLocator/ChainDocumentLocator.php
 create mode 100644 src/TextDocumentLocator/InMemoryDocumentLocator.php
 create mode 100644 src/TextDocumentUri.php
 create mode 100644 src/TextEdit.php
 create mode 100644 src/TextEditDiff.php
 create mode 100644 src/TextEdits.php
 create mode 100644 src/Util/LineAtOffset.php
 create mode 100644 src/Util/LineColRangeForLine.php
 create mode 100644 src/Util/WordAtOffset.php
 create mode 100644 src/WorkspaceEdits.php

diff --git a/bin/update.php b/bin/update.php
new file mode 100644
index 00000000..37f31249
--- /dev/null
+++ b/bin/update.php
@@ -0,0 +1,11 @@
+<?php
+
+use Symfony\Component\Filesystem\Filesystem;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$fs = new Filesystem();
+$fs->remove('src');
+$fs->remove('tests');
+$fs->mirror('../phpactor/lib/TextDocument', 'src');
+$fs->mirror('src/Tests', 'tests');
diff --git a/src/ByteOffset.php b/src/ByteOffset.php
new file mode 100644
index 00000000..9403ef55
--- /dev/null
+++ b/src/ByteOffset.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use Phpactor\TextDocument\Exception\InvalidByteOffset;
+
+class ByteOffset
+{
+    private int $offset;
+
+    private function __construct(int $offset)
+    {
+        if ($offset < 0) {
+            throw new InvalidByteOffset(sprintf(
+                'Offset must be greater than or equal to zero, got "%s"',
+                $offset
+            ));
+        }
+        $this->offset = $offset;
+    }
+
+    public static function fromInt(int $offset): self
+    {
+        return new self($offset);
+    }
+
+    public static function fromUnknown(ByteOffset|int $value): self
+    {
+        if ($value instanceof ByteOffset) {
+            return $value;
+        }
+
+        return self::fromInt($value);
+    }
+
+    /**
+     * @param int|ByteOffset $offset
+     */
+    public static function fromIntOrByteOffset($offset): self
+    {
+        if ($offset instanceof ByteOffset) {
+            return $offset;
+        }
+
+        return self::fromInt($offset);
+    }
+
+    public function toInt(): int
+    {
+        return $this->offset;
+    }
+
+    public function add(int $amount): self
+    {
+        return new self($this->offset + $amount);
+    }
+}
diff --git a/src/ByteOffsetRange.php b/src/ByteOffsetRange.php
new file mode 100644
index 00000000..9ea28996
--- /dev/null
+++ b/src/ByteOffsetRange.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+class ByteOffsetRange
+{
+    public function __construct(private ByteOffset $start, private ByteOffset $end)
+    {
+    }
+
+    public static function fromInts(int $start, int $end): self
+    {
+        return new self(
+            ByteOffset::fromInt($start),
+            ByteOffset::fromInt($end)
+        );
+    }
+
+    public static function fromByteOffset(ByteOffset $start): self
+    {
+        return new self($start, $start);
+    }
+
+    public static function fromByteOffsets(ByteOffset $start, ByteOffset $end): self
+    {
+        return new self($start, $end);
+    }
+
+    public function start(): ByteOffset
+    {
+        return $this->start;
+    }
+
+    public function end(): ByteOffset
+    {
+        return $this->end;
+    }
+}
diff --git a/src/EfficientLineCols.php b/src/EfficientLineCols.php
new file mode 100644
index 00000000..c27398fa
--- /dev/null
+++ b/src/EfficientLineCols.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use OutOfBoundsException;
+use RuntimeException;
+
+/**
+ * Efficiently compute line/col positions for batches of byte offsets.
+ *
+ * This class accepts a list of byte offsets and a text document. It will
+ * iterate over the text document _once_ indexing the line/col position of the
+ * byte offset.
+ */
+final class EfficientLineCols
+{
+    /**
+     * @param array<int,LineCol> $positions
+     */
+    private function __construct(private array $positions)
+    {
+    }
+
+    /**
+     * Initialize the converter with the list of byte offsets to be converted.
+     *
+     * For converting to LSP positions there is a flag to return the character
+     * offset.Note that, like the line/col number, this is 1 based.
+     * @param list<int> $byteOffsetInts
+     */
+    public static function fromByteOffsetInts(
+        string $text,
+        array $byteOffsetInts,
+        bool $charOffset = false
+    ): self {
+        sort($byteOffsetInts);
+
+        $lines = preg_split('{(' . LineCol::NEWLINE_PATTERN . ')}', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
+
+        if (false === $lines) {
+            throw new RuntimeException(
+                'Failed to preg-split text into lines'
+            );
+        }
+
+        $offset = 0;
+        $lineNb = 0;
+        $byteOffset = array_shift($byteOffsetInts);
+        if (null === $byteOffset) {
+            return new EfficientLineCols([]);
+        }
+        $positions = [];
+
+        foreach ($lines as $lineOrDelim) {
+            $lineOrDelim = (string)$lineOrDelim;
+
+            if ((bool)preg_match('{(' . LineCol::NEWLINE_PATTERN . ')}', (string)$lineOrDelim)) {
+                $offset += strlen($lineOrDelim);
+                continue;
+            }
+            $lineNb++;
+
+            $start = $offset;
+            $end = $offset + strlen($lineOrDelim);
+
+            while ($byteOffset >= $start && $byteOffset <= $end) {
+                $section = substr(
+                    $lineOrDelim,
+                    0,
+                    $byteOffset - $start
+                );
+
+                $positions[$byteOffset] = new LineCol(
+                    $lineNb,
+                    $charOffset ? strlen($section) + 1 : mb_strlen($section) + 1
+                );
+                $byteOffset = array_shift($byteOffsetInts);
+                if (null === $byteOffset) {
+                    break;
+                }
+            }
+
+            $offset = $end;
+        }
+
+        /** @var array<int,LineCol> $positions */
+        return new EfficientLineCols($positions);
+    }
+
+    public function get(int $offset): LineCol
+    {
+        if (!isset($this->positions[$offset])) {
+            throw new OutOfBoundsException(sprintf(
+                'Pre-computed position not known for offset: %s',
+                $offset
+            ));
+        }
+
+        return $this->positions[$offset];
+    }
+}
diff --git a/src/Exception/CouldNotSaveDocument.php b/src/Exception/CouldNotSaveDocument.php
new file mode 100644
index 00000000..86d5efa2
--- /dev/null
+++ b/src/Exception/CouldNotSaveDocument.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Phpactor\TextDocument\Exception;
+
+use RuntimeException;
+
+class CouldNotSaveDocument extends RuntimeException
+{
+}
diff --git a/src/Exception/InvalidByteOffset.php b/src/Exception/InvalidByteOffset.php
new file mode 100644
index 00000000..250492b9
--- /dev/null
+++ b/src/Exception/InvalidByteOffset.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Phpactor\TextDocument\Exception;
+
+use RuntimeException;
+
+class InvalidByteOffset extends RuntimeException
+{
+}
diff --git a/src/Exception/InvalidUriException.php b/src/Exception/InvalidUriException.php
new file mode 100644
index 00000000..4803a46b
--- /dev/null
+++ b/src/Exception/InvalidUriException.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Phpactor\TextDocument\Exception;
+
+use RuntimeException;
+
+class InvalidUriException extends RuntimeException
+{
+}
diff --git a/src/Exception/TextDocumentNotFound.php b/src/Exception/TextDocumentNotFound.php
new file mode 100644
index 00000000..a9e02fa4
--- /dev/null
+++ b/src/Exception/TextDocumentNotFound.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Phpactor\TextDocument\Exception;
+
+use Phpactor\TextDocument\TextDocumentUri;
+use RuntimeException;
+
+final class TextDocumentNotFound extends RuntimeException
+{
+    public static function fromUri(TextDocumentUri $uri): self
+    {
+        return new self(sprintf(
+            'Text document "%s" not found',
+            $uri
+        ));
+    }
+}
diff --git a/src/FilesystemTextDocumentLocator.php b/src/FilesystemTextDocumentLocator.php
new file mode 100644
index 00000000..5788b40c
--- /dev/null
+++ b/src/FilesystemTextDocumentLocator.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use Phpactor\TextDocument\Exception\TextDocumentNotFound;
+
+class FilesystemTextDocumentLocator implements TextDocumentLocator
+{
+    public function get(TextDocumentUri $uri): TextDocument
+    {
+        if (!file_exists($uri->path())) {
+            throw TextDocumentNotFound::fromUri($uri);
+        }
+
+        return TextDocumentBuilder::fromUri($uri->__toString())->build();
+    }
+}
diff --git a/src/LineCol.php b/src/LineCol.php
new file mode 100644
index 00000000..616b0dcd
--- /dev/null
+++ b/src/LineCol.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use OutOfBoundsException;
+use RuntimeException;
+
+/**
+ * Value object for line / column position.
+ *
+ * Lines and columns start with 1.
+ *
+ * The "a" in "abcd" would have line "1" and column "1".
+ */
+final class LineCol
+{
+    public const NEWLINE_PATTERN = '\\r\\n|\\n|\\r';
+
+    private int $line;
+
+    private int $col;
+
+    public function __construct(int $line, int $col)
+    {
+        if ($line < 1) {
+            throw new RuntimeException(sprintf(
+                'Line number cannot be less than 1 (got "%s")',
+                $line
+            ));
+        }
+        if ($col < 1) {
+            throw new RuntimeException(sprintf(
+                'Col number cannot be less than 1 (got "%s")',
+                $col
+            ));
+        }
+        $this->line = $line;
+        $this->col = $col;
+    }
+
+    public function toByteOffset(string $text): ByteOffset
+    {
+        $linesAndDelims = (array)preg_split('{(' . self::NEWLINE_PATTERN . ')}', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
+
+        if (count($linesAndDelims) === 0) {
+            return ByteOffset::fromInt(
+                strlen((string)reset($linesAndDelims))
+            );
+        }
+
+        $lineNb = 1;
+        $offset = 0;
+        foreach ($linesAndDelims as $lineOrDelim) {
+            $lineOrDelim = (string)$lineOrDelim;
+
+            if ((bool)preg_match('{(' . self::NEWLINE_PATTERN . ')}', (string)$lineOrDelim)) {
+                $lineNb++;
+                $offset += strlen($lineOrDelim);
+                continue;
+            }
+
+            if ($lineNb === $this->line()) {
+                $lineSection = mb_substr(
+                    $lineOrDelim,
+                    0,
+                    $this->col() - 1
+                );
+                return ByteOffset::fromInt(
+                    $offset + (int)strlen($lineSection)
+                );
+            }
+
+            $offset += strlen((string)$lineOrDelim);
+        }
+
+        return ByteOffset::fromInt(strlen($text));
+    }
+
+    public static function fromByteOffset(string $text, ByteOffset $byteOffset): self
+    {
+        if ($byteOffset->toInt() > strlen($text)) {
+            $byteOffset = ByteOffset::fromInt(strlen($text));
+        }
+
+        $lines = preg_split('{(' . self::NEWLINE_PATTERN . ')}', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
+
+        if (false === $lines) {
+            throw new RuntimeException(
+                'Failed to preg-split text into lines'
+            );
+        }
+
+        $offset = 0;
+        $lineNb = 0;
+        foreach ($lines as $lineOrDelim) {
+            $lineOrDelim = (string)$lineOrDelim;
+
+            if ((bool)preg_match('{(' . self::NEWLINE_PATTERN . ')}', (string)$lineOrDelim)) {
+                $offset += strlen($lineOrDelim);
+                continue;
+            }
+            $lineNb++;
+
+            $start = $offset;
+            $end = $offset + strlen($lineOrDelim);
+
+            // if the offset is in line...
+            if ($byteOffset->toInt() >= $start && $byteOffset->toInt() <= $end) {
+                $section = substr(
+                    $lineOrDelim,
+                    0,
+                    $byteOffset->toInt() - $start
+                );
+
+                return new self($lineNb, mb_strlen($section) + 1);
+            }
+
+            $offset = $end;
+        }
+
+        throw new OutOfBoundsException(sprintf(
+            'Byte offset %s is larger than text length %s',
+            $byteOffset->toInt(),
+            strlen($text)
+        ));
+    }
+
+    public function col(): int
+    {
+        return $this->col;
+    }
+
+    public function line(): int
+    {
+        return $this->line;
+    }
+}
diff --git a/src/LineColRange.php b/src/LineColRange.php
new file mode 100644
index 00000000..1865fb5d
--- /dev/null
+++ b/src/LineColRange.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+final class LineColRange
+{
+    public function __construct(private LineCol $start, private LineCol $end)
+    {
+    }
+
+    public function start(): LineCol
+    {
+        return $this->start;
+    }
+
+    public function end(): LineCol
+    {
+        return $this->end;
+    }
+}
diff --git a/src/Location.php b/src/Location.php
new file mode 100644
index 00000000..6c7afe2d
--- /dev/null
+++ b/src/Location.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+class Location
+{
+    public function __construct(
+        private TextDocumentUri $uri,
+        private ByteOffsetRange $range
+    ) {
+    }
+
+    public static function fromPathAndOffsets(string $path, int $start, int $end): self
+    {
+        return new self(
+            TextDocumentUri::fromString($path),
+            ByteOffsetRange::fromInts($start, $end),
+        );
+    }
+
+    /**
+    * @deprecated Use fromPathAndOffsets instead
+    */
+    public static function fromPathAndOffset(string $path, int $offset): self
+    {
+        return new self(
+            TextDocumentUri::fromString($path),
+            ByteOffsetRange::fromInts($offset, $offset),
+        );
+    }
+
+    public function uri(): TextDocumentUri
+    {
+        return $this->uri;
+    }
+
+    public function range(): ByteOffsetRange
+    {
+        return $this->range;
+    }
+}
diff --git a/src/Locations.php b/src/Locations.php
new file mode 100644
index 00000000..573a3204
--- /dev/null
+++ b/src/Locations.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use ArrayIterator;
+use Countable;
+use IteratorAggregate;
+use RuntimeException;
+
+/**
+ * @implements IteratorAggregate<Location>
+ */
+final class Locations implements IteratorAggregate, Countable
+{
+    /**
+     * @var Location[]
+     */
+    private array $locations = [];
+
+    /**
+     * @param iterable<Location> $locations
+     */
+    public function __construct(iterable $locations)
+    {
+        foreach ($locations as $location) {
+            $this->add($location);
+        }
+    }
+
+    /**
+     * @return ArrayIterator<int,Location>
+     */
+    public function getIterator(): ArrayIterator
+    {
+        return new ArrayIterator($this->locations);
+    }
+
+    public function append(Locations $locations): self
+    {
+        $newLocations = $this->locations;
+        foreach ($locations as $location) {
+            $newLocations[] = $location;
+        }
+
+        return new self($newLocations);
+    }
+
+    public function count(): int
+    {
+        return count($this->locations);
+    }
+
+    public function first(): Location
+    {
+        if (count($this->locations) === 0) {
+            throw new RuntimeException(
+                'There are no locations in this collection'
+            );
+        }
+
+        return reset($this->locations);
+    }
+
+    public function sorted(): self
+    {
+        $sortedLocations = $this->locations;
+
+        usort($sortedLocations, function (Location $first, Location $second) {
+            $order = strcmp((string) $first->uri(), (string) $second->uri());
+            if (0 !== $order) {
+                return $order;
+            }
+
+            return $first->range()->start()->toInt() - $second->range()->start()->toInt();
+        });
+
+        return new self($sortedLocations);
+    }
+
+    private function add(Location $location): self
+    {
+        $this->locations[] = $location;
+
+        return $this;
+    }
+}
diff --git a/src/StandardTextDocument.php b/src/StandardTextDocument.php
new file mode 100644
index 00000000..5ef37a7b
--- /dev/null
+++ b/src/StandardTextDocument.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use RuntimeException;
+
+class StandardTextDocument implements TextDocument
+{
+    public function __construct(
+        private TextDocumentLanguage $language,
+        private string $text,
+        private ?TextDocumentUri $uri = null
+    ) {
+    }
+
+    public function __toString()
+    {
+        return $this->text;
+    }
+
+
+    public function uri(): ?TextDocumentUri
+    {
+        return $this->uri;
+    }
+
+
+    public function language(): TextDocumentLanguage
+    {
+        return $this->language;
+    }
+
+    public function uriOrThrow(): TextDocumentUri
+    {
+        if (null === $this->uri) {
+            throw new RuntimeException(
+                'Document has no URI'
+            );
+        }
+        return $this->uri;
+    }
+}
diff --git a/src/Tests/Unit/ByteOffsetTest.php b/src/Tests/Unit/ByteOffsetTest.php
new file mode 100644
index 00000000..2f45c0d9
--- /dev/null
+++ b/src/Tests/Unit/ByteOffsetTest.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\ByteOffset;
+use Phpactor\TextDocument\Exception\InvalidByteOffset;
+
+class ByteOffsetTest extends TestCase
+{
+    public function testValue(): void
+    {
+        $offset = ByteOffset::fromInt(10);
+        $this->assertEquals(10, $offset->toInt());
+    }
+
+    public function testAdd(): void
+    {
+        $offset = ByteOffset::fromInt(10)->add(10);
+        $this->assertEquals(20, $offset->toInt());
+    }
+
+    public function testExceptionOnLessThanZero1(): void
+    {
+        $this->expectException(InvalidByteOffset::class);
+        ByteOffset::fromInt(-1);
+    }
+
+    public function testExceptionOnLessThanZero2(): void
+    {
+        $this->expectException(InvalidByteOffset::class);
+        ByteOffset::fromInt(-10);
+    }
+
+    public function testByteOffsetIsZero(): void
+    {
+        self::assertEquals(0, ByteOffset::fromInt(0)->toInt());
+    }
+}
diff --git a/src/Tests/Unit/EfficientLineColsTest.php b/src/Tests/Unit/EfficientLineColsTest.php
new file mode 100644
index 00000000..252459f7
--- /dev/null
+++ b/src/Tests/Unit/EfficientLineColsTest.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use Closure;
+use Generator;
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\EfficientLineCols;
+
+class EfficientLineColsTest extends TestCase
+{
+    /**
+     * @dataProvider provideConvertOffsetsToLineCol
+     * @param list<int> $offsets
+     */
+    public function testFromByteOffsets(array $offsets, string $text, Closure $assertion): void
+    {
+        $converter = EfficientLineCols::fromByteOffsetInts($text, $offsets);
+        $assertion($converter);
+    }
+
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideConvertOffsetsToLineCol(): Generator
+    {
+        yield [
+            [],
+            '',
+            function (EfficientLineCols $lineCols): void {
+                self::assertInstanceOf(EfficientLineCols::class, $lineCols);
+            }
+        ];
+        yield [
+            [2],
+            '01234',
+            function (EfficientLineCols $lineCols): void {
+                self::assertEquals(3, $lineCols->get(2)->col());
+                self::assertEquals(1, $lineCols->get(2)->line());
+            }
+        ];
+        yield [
+            [2, 3, 0, 10],
+            "01234\n5678",
+            function (EfficientLineCols $lineCols): void {
+                self::assertEquals(3, $lineCols->get(2)->col());
+                self::assertEquals(1, $lineCols->get(2)->line());
+                self::assertEquals(2, $lineCols->get(10)->line());
+                self::assertEquals(5, $lineCols->get(10)->col());
+            }
+        ];
+    }
+
+    /**
+     * @dataProvider provideConvertOffsetsToLineColAsOffset
+     * @param list<int> $offsets
+     */
+    public function testFromByteOffsetsAsOffset(array $offsets, string $text, Closure $assertion): void
+    {
+        $converter = EfficientLineCols::fromByteOffsetInts($text, $offsets, true);
+        $assertion($converter);
+    }
+
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideConvertOffsetsToLineColAsOffset(): Generator
+    {
+        yield 'cat is 4 bytes' => [
+            [5],
+            'a😸bc',
+            function (EfficientLineCols $lineCols): void {
+                self::assertEquals(6, $lineCols->get(5)->col());
+            }
+        ];
+    }
+}
diff --git a/src/Tests/Unit/LineColTest.php b/src/Tests/Unit/LineColTest.php
new file mode 100644
index 00000000..c7c1f5df
--- /dev/null
+++ b/src/Tests/Unit/LineColTest.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use Generator;
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\LineCol;
+
+class LineColTest extends TestCase
+{
+    /**
+     * @dataProvider provideConvertLineColToOffset
+     */
+    public function testToByteOffset(string $text, LineCol $lineCol, int $expectedOffset, ?string $sanityCheck = null): void
+    {
+        $this->assertEquals($expectedOffset, $lineCol->toByteOffset($text)->toInt());
+        if ($sanityCheck) {
+            self::assertEquals($sanityCheck, substr($text, 0, $lineCol->toByteOffset($text)->toInt()));
+        }
+    }
+
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideConvertLineColToOffset(): Generator
+    {
+        yield [
+            '',
+            new LineCol(1, 1),
+            0,
+        ];
+
+        yield [
+            'a',
+            new LineCol(1, 1),
+            0,
+        ];
+
+        yield 'new line' => [
+            "\na",
+            new LineCol(2, 1),
+            1,
+        ];
+
+        yield 'multi-byte 1' => [
+            'ᅑa',
+            new LineCol(1, 2),
+            3,
+            'ᅑ',
+        ];
+
+        yield 'multi-byte 2' => [
+            'ᅑacd',
+            new LineCol(1, 3),
+            4,
+            'ᅑa'
+        ];
+
+        yield 'multi-byte 3' => [
+            "ᅑ\nacd",
+            new LineCol(2, 2),
+            5,
+            "ᅑ\na"
+        ];
+    }
+
+    public function testOutOfBoundsToByteOffset(): void
+    {
+        $lineCol = new LineCol(10, 20);
+        assert($lineCol instanceof LineCol);
+        self::assertEquals(13, $lineCol->toByteOffset("foobar\nbarfoo")->toInt());
+    }
+}
diff --git a/src/Tests/Unit/LocationTest.php b/src/Tests/Unit/LocationTest.php
new file mode 100644
index 00000000..82d81117
--- /dev/null
+++ b/src/Tests/Unit/LocationTest.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\Location;
+
+class LocationTest extends TestCase
+{
+    public function testProvidesAccessToUri(): void
+    {
+        $location = Location::fromPathAndOffsets('/path/to.php', 123, 234);
+        $this->assertEquals('file:///path/to.php', $location->uri()->__toString());
+    }
+
+    public function testProvidesAccessToByteOffset(): void
+    {
+        $location = Location::fromPathAndOffsets('/path/to.php', 123, 455);
+        $this->assertEquals(123, $location->range()->start()->toInt());
+        $this->assertEquals(455, $location->range()->end()->toInt());
+    }
+}
diff --git a/src/Tests/Unit/LocationsTest.php b/src/Tests/Unit/LocationsTest.php
new file mode 100644
index 00000000..b5b6599f
--- /dev/null
+++ b/src/Tests/Unit/LocationsTest.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use Generator;
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\Location;
+use Phpactor\TextDocument\Locations;
+use RuntimeException;
+
+class LocationsTest extends TestCase
+{
+    public function testContainsLocations(): void
+    {
+        $locations = new Locations([
+            Location::fromPathAndOffsets('/path/to.php', 12, 124),
+            Location::fromPathAndOffsets('/path/to.php', 13, 14)
+        ]);
+        $this->assertCount(2, $locations);
+    }
+
+    public function testIsCountable(): void
+    {
+        $locations = new Locations([
+            Location::fromPathAndOffsets('/path/to.php', 12, 12),
+            Location::fromPathAndOffsets('/path/to.php', 13, 13)
+        ]);
+
+        $this->assertEquals(2, $locations->count());
+    }
+
+    public function testExceptionIfFirstNotAvailable(): void
+    {
+        $this->expectException(RuntimeException::class);
+
+        $locations = new Locations([]);
+        $locations->first();
+    }
+
+    public function testAppendLocations(): void
+    {
+        $locations = new Locations([
+            Location::fromPathAndOffsets('/path/to.php', 12, 19),
+        ]);
+        $locations = $locations->append(new Locations([
+            Location::fromPathAndOffsets('/path/to.php', 13, 40),
+        ]));
+
+        self::assertEquals(new Locations([
+            Location::fromPathAndOffsets('/path/to.php', 12, 19),
+            Location::fromPathAndOffsets('/path/to.php', 13, 40)
+        ]), $locations);
+    }
+
+    /**
+     * @dataProvider provideUnsortedLocations
+     *
+     * @param Location[] $unsortedLocationsArray
+     * @param Location[] $sortedLocationsArray
+     */
+    public function testSortLocations(
+        array $unsortedLocationsArray,
+        array $sortedLocationsArray
+    ): void {
+        $locations = new Locations($unsortedLocationsArray);
+        $sortedLocations = $locations->sorted();
+
+        $this->assertNotSame($locations, $sortedLocations);
+        $this->assertCount(count($unsortedLocationsArray), $sortedLocations);
+
+        foreach(iterator_to_array($sortedLocations) as $index => $sortedLocation) {
+            $expectedLocation = $sortedLocationsArray[$index];
+
+            self::assertEquals($sortedLocation, new Location($expectedLocation->uri(), $expectedLocation->range()));
+        }
+    }
+
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideUnsortedLocations(): Generator
+    {
+        yield 'Same file is sorted by start position' => [[
+            Location::fromPathAndOffsets('/path/to.php', 30, 50),
+            Location::fromPathAndOffsets('/path/to.php', 12, 24),
+        ], [
+            Location::fromPathAndOffsets('/path/to.php', 12, 24),
+            Location::fromPathAndOffsets('/path/to.php', 30, 50),
+        ]];
+
+        yield 'Sort by file name first' => [[
+            Location::fromPathAndOffsets('/path/to.php', 12, 42),
+            Location::fromPathAndOffsets('/path/from.php', 15, 43),
+        ], [
+            Location::fromPathAndOffsets('/path/from.php', 15, 43),
+            Location::fromPathAndOffsets('/path/to.php', 12, 42),
+        ]];
+    }
+}
diff --git a/src/Tests/Unit/TextDocumentBuilderTest.php b/src/Tests/Unit/TextDocumentBuilderTest.php
new file mode 100644
index 00000000..f3dce33f
--- /dev/null
+++ b/src/Tests/Unit/TextDocumentBuilderTest.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\Exception\TextDocumentNotFound;
+use Phpactor\TextDocument\TextDocumentBuilder;
+
+class TextDocumentBuilderTest extends TestCase
+{
+    const EXAMPLE_TEXT = 'hello world';
+    const EXAMPLE_URI = 'file:///path/to';
+
+    public function testCreate(): void
+    {
+        $doc = TextDocumentBuilder::create(self::EXAMPLE_TEXT)->language('php')->uri(self::EXAMPLE_URI)->build();
+        $this->assertEquals(self::EXAMPLE_URI, $doc->uri()->__toString());
+        $this->assertEquals(self::EXAMPLE_TEXT, $doc->__toString());
+        $this->assertEquals('php', $doc->language());
+    }
+
+    public function testFromUri(): void
+    {
+        $doc = TextDocumentBuilder::fromUri('file://' . __FILE__)->build();
+        $this->assertEquals('file://' . __FILE__, $doc->uri());
+        $this->assertEquals(file_get_contents(__FILE__), $doc->__toString());
+    }
+
+    public function testFromTextDocument(): void
+    {
+        $doc = TextDocumentBuilder::fromTextDocument(
+            TextDocumentBuilder::create('foobar')
+                ->uri('file:///foobar/asd')
+                ->language('foo')->build()
+        )->build();
+
+        $this->assertEquals('foobar', $doc->__toString());
+        $this->assertEquals('file:///foobar/asd', $doc->uri()->__toString());
+        $this->assertEquals('/foobar/asd', $doc->uri()?->path());
+        $this->assertEquals('foo', $doc->language()->__toString());
+    }
+
+    public function testExceptionOnNotExists(): void
+    {
+        $this->expectException(TextDocumentNotFound::class);
+        TextDocumentBuilder::fromUri('file:///no-existy');
+    }
+}
diff --git a/src/Tests/Unit/TextDocumentLanguageTest.php b/src/Tests/Unit/TextDocumentLanguageTest.php
new file mode 100644
index 00000000..67c9c90e
--- /dev/null
+++ b/src/Tests/Unit/TextDocumentLanguageTest.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\TextDocumentLanguage;
+
+class TextDocumentLanguageTest extends TestCase
+{
+    public function testCreate(): void
+    {
+        $language = TextDocumentLanguage::fromString('php');
+        $this->assertEquals('php', (string) $language);
+        $this->assertTrue($language->isDefined());
+        $this->assertTrue($language->isPhp());
+        $this->assertTrue($language->is('php'));
+        $this->assertTrue($language->is('PHP'));
+        $this->assertFalse($language->is('french'));
+        $this->assertTrue($language->in(['php', 'cobol']));
+        $this->assertFalse($language->in(['c', 'cobol']));
+    }
+
+    public function testCreateUndefined(): void
+    {
+        $language = TextDocumentLanguage::undefined();
+        $this->assertFalse($language->isDefined());
+    }
+}
diff --git a/src/Tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php b/src/Tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php
new file mode 100644
index 00000000..a4126bf4
--- /dev/null
+++ b/src/Tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit\TextDocumentLocator;
+
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\Exception\TextDocumentNotFound;
+use Phpactor\TextDocument\TextDocumentBuilder;
+use Phpactor\TextDocument\TextDocumentLocator;
+use Phpactor\TextDocument\TextDocumentUri;
+use Phpactor\TextDocument\TextDocumentLocator\ChainDocumentLocator;
+use Phpactor\TextDocument\TextDocumentLocator\InMemoryDocumentLocator;
+
+class ChainDocumentLocatorTest extends TestCase
+{
+    public function testThrowsExceptionWhenNotFound(): void
+    {
+        $this->expectException(TextDocumentNotFound::class);
+        $this->createWorkspace()->get(TextDocumentUri::fromString('file:///foobar'));
+    }
+
+    public function testReturnsTextDocument(): void
+    {
+        $document = TextDocumentBuilder::create('foobar')->uri('/path/to/foo')->build();
+
+        self::assertSame(
+            $document,
+            $this->createWorkspace([
+                InMemoryDocumentLocator::fromTextDocuments([
+                    $document
+                ])
+            ])->get(TextDocumentUri::fromString('file:///path/to/foo'))
+        );
+    }
+
+    public function testReturnsTextDocumentFromFirstWorkspace(): void
+    {
+        $document1 = TextDocumentBuilder::create('one')->uri('/path/to/foo')->build();
+        $document2 = TextDocumentBuilder::create('two')->uri('/path/to/foo')->build();
+
+        self::assertSame(
+            $document1,
+            $this->createWorkspace([
+                InMemoryDocumentLocator::fromTextDocuments([
+                    $document1
+                ]),
+                InMemoryDocumentLocator::fromTextDocuments([
+                    $document2
+                ])
+            ])->get(TextDocumentUri::fromString('file:///path/to/foo'))
+        );
+    }
+
+    /**
+     * @param TextDocumentLocator[] $workspaces
+     */
+    private function createWorkspace(array $workspaces = []): ChainDocumentLocator
+    {
+        return new ChainDocumentLocator($workspaces);
+    }
+}
diff --git a/src/Tests/Unit/TextDocumentUriTest.php b/src/Tests/Unit/TextDocumentUriTest.php
new file mode 100644
index 00000000..c05dfb8e
--- /dev/null
+++ b/src/Tests/Unit/TextDocumentUriTest.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\Exception\InvalidUriException;
+use Phpactor\TextDocument\TextDocumentUri;
+
+class TextDocumentUriTest extends TestCase
+{
+    public function testCreate(): void
+    {
+        $uri = TextDocumentUri::fromString('file://' . __FILE__);
+        $this->assertEquals('file://' . __FILE__, (string) $uri);
+    }
+
+    public function testCreateUntitled(): void
+    {
+        $uri = TextDocumentUri::fromString('untitled:Untitled-1');
+        $this->assertEquals('untitled:Untitled-1', (string) $uri);
+    }
+
+    public function testCreatePhar(): void
+    {
+        $uri = TextDocumentUri::fromString('phar://' . __FILE__);
+        $this->assertEquals('phar://' . __FILE__, (string) $uri);
+    }
+
+    public function testNormalizesToFileScheme(): void
+    {
+        $uri = TextDocumentUri::fromString(__FILE__);
+        $this->assertEquals('file://' . __FILE__, (string) $uri);
+    }
+
+    public function testExceptionOnNonAbsolutePath(): void
+    {
+        $this->expectException(InvalidUriException::class);
+        TextDocumentUri::fromString('i is relative');
+    }
+
+    public function testExceptionOnInvalidUri(): void
+    {
+        $this->expectException(InvalidUriException::class);
+        $this->expectExceptionMessage('not parse');
+        TextDocumentUri::fromString('');
+    }
+
+    public function testExceptionOnNoPath(): void
+    {
+        $this->expectException(InvalidUriException::class);
+        $this->expectExceptionMessage('has no path');
+        TextDocumentUri::fromString('file://');
+    }
+
+    public function testFromPath(): void
+    {
+        $uri = TextDocumentUri::fromString('/foobar');
+        $this->assertEquals('file:///foobar', $uri->__toString());
+    }
+
+    public function testFromHttpUri(): void
+    {
+        $this->expectException(InvalidUriException::class);
+        $this->expectExceptionMessage('Only "file", "untitled", "phar" schemes are supported, got "http"');
+        $uri = TextDocumentUri::fromString('http://foobar/foobar');
+    }
+
+
+    public function testReturnsPath(): void
+    {
+        $uri = TextDocumentUri::fromString('file://' . __FILE__);
+        $this->assertEquals(__FILE__, $uri->path());
+    }
+
+    public function testScheme(): void
+    {
+        $uri = TextDocumentUri::fromString('file://' . __FILE__);
+        $this->assertEquals('file', $uri->scheme());
+    }
+}
diff --git a/src/Tests/Unit/TextEditDiffTest.php b/src/Tests/Unit/TextEditDiffTest.php
new file mode 100644
index 00000000..8382d614
--- /dev/null
+++ b/src/Tests/Unit/TextEditDiffTest.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use Generator;
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\TextEditDiff;
+
+class TextEditDiffTest extends TestCase
+{
+    /**
+     * @dataProvider provideDiff
+     */
+    public function testDiff(string $one, string $two): void
+    {
+        $edits = (new TextEditDiff())->diff($one, $two);
+        self::assertEquals(
+            $two,
+            $edits->apply($one)
+        );
+    }
+
+    /**
+      * @return Generator<string,array{string,string}>
+      */
+    public function provideDiff(): Generator
+    {
+        yield 'add string' => [
+            'foo',
+            'foo bar',
+        ];
+        yield 'remove string' => [
+            'foo bar',
+            'foo',
+        ];
+        yield 'insert string' => [
+            'foo bar',
+            'foo baz boo bar bag',
+        ];
+
+        yield 'first char' => [
+            'i',
+            'b',
+        ];
+
+        yield 'differnet' => [
+            'it little profits',
+            'that an idle king',
+        ];
+
+        yield 'poem' => [
+            implode("\n", [
+                'it little profits that an idle king',
+                'matched with an aged wife',
+            ]),
+            implode("\n", [
+                'by this still hearth',
+                'it little profits that an idle king',
+            ])
+        ];
+
+        yield 'code' => [
+            implode("\n", [
+                '<?php',
+                'class Foobar {',
+                '    private $foobar;',
+                '    /**',
+                '     * @return array<int,foobar>',
+                '     */',
+                '    public function bar() {',
+                '    }',
+                '}'
+            ]),
+            implode("\n", [
+                '<?php',
+                'class Foobar {',
+                '',
+                '    /**',
+                '     * @var Foobars',
+                '     */',
+                '    private $foobar;',
+                '',
+                '    /**',
+                '     * @param array<int,foobar> $bar',
+                '     * @return array<string,foobar>',
+                '     */',
+                '    public function bar(array $bar) {',
+                '    }',
+                '}'
+            ]),
+        ];
+    }
+}
diff --git a/src/Tests/Unit/TextEditTest.php b/src/Tests/Unit/TextEditTest.php
new file mode 100644
index 00000000..b486f56c
--- /dev/null
+++ b/src/Tests/Unit/TextEditTest.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use OutOfRangeException;
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\ByteOffset;
+use Phpactor\TextDocument\TextEdit;
+
+class TextEditTest extends TestCase
+{
+    public function testExceptionIfLengthIsNegative1(): void
+    {
+        $this->expectException(OutOfRangeException::class);
+        TextEdit::create(10, -10, 'asd');
+    }
+
+    public function testExceptionIfLengthIsNegative2(): void
+    {
+        $this->expectException(OutOfRangeException::class);
+        TextEdit::create(10, -1, 'asd');
+    }
+
+    public function testLengthIsZero(): void
+    {
+        self::assertEquals(0, TextEdit::create(10, 0, 'asd')->length());
+    }
+
+    public function testReturnLength(): void
+    {
+        self::assertEquals(1, TextEdit::create(10, 1, 'asd')->length());
+    }
+
+    public function testCreateWithByteOffset(): void
+    {
+        self::assertEquals(1, TextEdit::create(ByteOffset::fromInt(10), 1, 'asd')->length());
+    }
+}
diff --git a/src/Tests/Unit/TextEditsTest.php b/src/Tests/Unit/TextEditsTest.php
new file mode 100644
index 00000000..50f0c2e9
--- /dev/null
+++ b/src/Tests/Unit/TextEditsTest.php
@@ -0,0 +1,198 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit;
+
+use Generator;
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\TextEdit;
+use Phpactor\TextDocument\TextEdits;
+
+class TextEditsTest extends TestCase
+{
+    /**
+     * @dataProvider provideMerge
+     * @param TextEdit[] $edits1
+     * @param TextEdit[] $edits2
+     * @param TextEdit[] $expectedEdits
+     */
+    public function testMerge(array $edits1, array $edits2, array $expectedEdits): void
+    {
+        self::assertEquals(
+            TextEdits::fromTextEdits($expectedEdits),
+            TextEdits::fromTextEdits($edits1)->merge(
+                TextEdits::fromTextEdits($edits2)
+            )
+        );
+    }
+
+
+    public function provideMerge(): Generator
+    {
+        yield 'empty' => [
+            [
+            ],
+            [
+            ],
+            [
+            ],
+        ];
+
+        yield 'empty merge does not affect existing data' => [
+            [
+                TextEdit::create(1, 5, 'foobar'),
+                TextEdit::create(2, 5, 'foobar'),
+            ],
+            [
+            ],
+            [
+                TextEdit::create(1, 5, 'foobar'),
+                TextEdit::create(2, 5, 'foobar'),
+            ],
+        ];
+
+        yield 'original edits are ordered before subsequent edits with same offset' => [
+            [
+                TextEdit::create(1, 5, 'foobar'),
+                TextEdit::create(2, 5, 'foobar'),
+            ],
+            [
+                TextEdit::create(1, 5, 'barfoo'),
+                TextEdit::create(2, 5, 'barfoo'),
+            ],
+            [
+                TextEdit::create(1, 5, 'foobar'),
+                TextEdit::create(1, 5, 'barfoo'),
+                TextEdit::create(2, 5, 'foobar'),
+                TextEdit::create(2, 5, 'barfoo'),
+            ],
+        ];
+
+        yield 'text edits are sorted' => [
+            [
+                TextEdit::create(2, 5, 'foobar'),
+                TextEdit::create(3, 5, 'foobar'),
+            ],
+            [
+                TextEdit::create(1, 5, 'barfoo'),
+                TextEdit::create(2, 5, 'barfoo'),
+            ],
+            [
+                TextEdit::create(1, 5, 'barfoo'),
+                TextEdit::create(2, 5, 'foobar'),
+                TextEdit::create(2, 5, 'barfoo'),
+                TextEdit::create(3, 5, 'foobar'),
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider provideApplyTextEdits
+     */
+    public function testApplyTextEdits(string $source, TextEdits $textEdits, string $expected): void
+    {
+        self::assertEquals(
+            $expected,
+            $textEdits->apply($source)
+        );
+    }
+
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideApplyTextEdits(): Generator
+    {
+        yield 'nothing' => [
+            '',
+            TextEdits::none(),
+            ''
+        ];
+
+        yield 'insert' => [
+            '',
+            TextEdits::one(TextEdit::create(0, 0, 'hello')),
+            'hello'
+        ];
+
+        yield 'delete' => [
+            'delete',
+            TextEdits::one(TextEdit::create(0, 6, '')),
+            ''
+        ];
+
+        yield 'replace' => [
+            'delete',
+            TextEdits::one(TextEdit::create(0, 6, 'foobar')),
+            'foobar'
+        ];
+
+        yield 'multiple edits at same offset' => [
+            'hello ',
+            TextEdits::fromTextEdits([
+                TextEdit::create(6, 0, 'world'),
+                TextEdit::create(6, 0, ' how'),
+                TextEdit::create(6, 0, ' you'),
+                TextEdit::create(6, 0, ' do'),
+            ]),
+            'hello world how you do'
+        ];
+    }
+
+    /**
+     * @dataProvider provideApplyTextEditsErrors
+     */
+    public function testApplyTextEditsErrors(string $source, TextEdits $textEdits, string $expectedMessage): void
+    {
+        $this->expectExceptionMessage($expectedMessage);
+        $textEdits->apply($source);
+    }
+
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideApplyTextEditsErrors(): Generator
+    {
+        yield 'shows debug information' => [
+            'hello      ',
+            TextEdits::fromTextEdits([
+                TextEdit::create(1, 4, 'world'),
+                TextEdit::create(2, 8, ' how'),
+            ]),
+            '> 1 5 "world"',
+        ];
+
+        yield 'overlapping text edits disallowed' => [
+            'hello      ',
+            TextEdits::fromTextEdits([
+                TextEdit::create(1, 4, 'world'),
+                TextEdit::create(2, 8, ' how'),
+            ]),
+            'Overlapping',
+        ];
+
+        yield 'out of bounds text edit' => [
+            'hello',
+            TextEdits::fromTextEdits([
+                TextEdit::create(10, 4, 'world'),
+            ]),
+            'Text edit end',
+        ];
+    }
+
+    public function testCreateOneConstructor(): void
+    {
+        self::assertInstanceOf(TextEdits::class, TextEdits::one(TextEdit::create(10, 10, 'f')));
+    }
+
+    public function testCreateNoneConstructor(): void
+    {
+        self::assertInstanceOf(TextEdits::class, TextEdits::none());
+    }
+
+    public function testAddTextEdit(): void
+    {
+        self::assertEquals(
+            TextEdits::none()->add(TextEdit::create(10, 10, 'asd')),
+            TextEdits::one(TextEdit::create(10, 10, 'asd'))
+        );
+    }
+}
diff --git a/src/Tests/Unit/Util/LineAtOffsetTest.php b/src/Tests/Unit/Util/LineAtOffsetTest.php
new file mode 100644
index 00000000..ec4f023c
--- /dev/null
+++ b/src/Tests/Unit/Util/LineAtOffsetTest.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit\Util;
+
+use Generator;
+use OutOfBoundsException;
+use PHPUnit\Framework\TestCase;
+use Phpactor\TestUtils\ExtractOffset;
+use Phpactor\TextDocument\Util\LineAtOffset;
+
+class LineAtOffsetTest extends TestCase
+{
+    /**
+     * @dataProvider provideLineAtOffset
+     */
+    public function testLineAtOffset(string $text, string $expectedWord): void
+    {
+        [ $text, $offset ] = ExtractOffset::fromSource($text);
+        $offset = (int) $offset;
+
+        self::assertEquals($expectedWord, (new LineAtOffset())($text, --$offset));
+    }
+
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideLineAtOffset(): Generator
+    {
+        yield [
+            'hello thi<>s is',
+            'hello this is',
+        ];
+        yield 'first char' => [
+            'h<>ello this is',
+            'hello this is',
+        ];
+        yield 'last char' => [
+            'hello this is<>',
+            'hello this is',
+        ];
+        yield 'offset is newline' => [
+            "hello this is\n<>",
+            'hello this is',
+        ];
+        yield [
+            <<<'EOT'
+                <?php
+
+                Hello
+                Thi<>s is my line
+
+                Thanks
+                EOT
+            , 'This is my line',
+        ];
+        yield 'multibyte 1' => [
+            <<<'EOT'
+                <?php
+
+                转注字 / 轉注字
+                转<>注字 / 轉注字
+
+                Thanks
+                EOT
+            , '转注字 / 轉注字',
+        ];
+        yield 'multibyte 2' => [
+            <<<'EOT'
+                <?php
+
+                转注字 / 轉注字
+                转注字 / 轉注字<>
+
+                Thanks
+                EOT
+            , '转注字 / 轉注字',
+        ];
+    }
+
+    public function testOutOfRange(): void
+    {
+        $this->expectException(OutOfBoundsException::class);
+        (new LineAtOffset())('a', 2);
+    }
+}
diff --git a/src/Tests/Unit/Util/LineColRangeForLineTest.php b/src/Tests/Unit/Util/LineColRangeForLineTest.php
new file mode 100644
index 00000000..00d4ee8b
--- /dev/null
+++ b/src/Tests/Unit/Util/LineColRangeForLineTest.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit\Util;
+
+use Generator;
+use PHPUnit\Framework\TestCase;
+use Phpactor\TextDocument\LineCol;
+use Phpactor\TextDocument\LineColRange;
+use Phpactor\TextDocument\Util\LineColRangeForLine;
+
+class LineColRangeForLineTest extends TestCase
+{
+    /**
+     * @dataProvider provideRangeForLine
+     */
+    public function testRangeForLine(string $text, int $lineNo, LineColRange $expected): void
+    {
+        self::assertEquals($expected, (new LineColRangeForLine())->rangeFromLine($text, $lineNo));
+    }
+
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideRangeForLine(): Generator
+    {
+        yield [
+            'one',
+            1,
+            new LineColRange(
+                new LineCol(1, 1),
+                new LineCol(1, 3),
+            )
+        ];
+        yield [
+            '  one',
+            1,
+            new LineColRange(
+                new LineCol(1, 3),
+                new LineCol(1, 5),
+            )
+        ];
+        yield [
+            '  one  ',
+            1,
+            new LineColRange(
+                new LineCol(1, 3),
+                new LineCol(1, 5),
+            )
+        ];
+        yield [
+            "  one  \n  two  \n",
+            2,
+            new LineColRange(
+                new LineCol(2, 3),
+                new LineCol(2, 5),
+            )
+        ];
+
+        yield 'empty line' => [
+            "  one  \n  two  \n",
+            3,
+            new LineColRange(
+                new LineCol(3, 1),
+                new LineCol(3, 1),
+            )
+        ];
+
+        yield 'out of range' => [
+            "  one  \n  two  \n",
+            4,
+            new LineColRange(
+                new LineCol(4, 1),
+                new LineCol(4, 1),
+            )
+        ];
+    }
+}
diff --git a/src/Tests/Unit/Util/WordAtOffsetTest.php b/src/Tests/Unit/Util/WordAtOffsetTest.php
new file mode 100644
index 00000000..933ba21b
--- /dev/null
+++ b/src/Tests/Unit/Util/WordAtOffsetTest.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace Phpactor\TextDocument\Tests\Unit\Util;
+
+use Generator;
+use PHPUnit\Framework\TestCase;
+use Phpactor\TestUtils\ExtractOffset;
+use Phpactor\TextDocument\Util\WordAtOffset;
+
+class WordAtOffsetTest extends TestCase
+{
+    /**
+     * @dataProvider provideWordAtOffset
+     */
+    public function testWordAtOffset(string $text, string $expectedWord, string $split = WordAtOffset::SPLIT_WORD): void
+    {
+        [ $text, $offset ] = ExtractOffset::fromSource($text);
+        $offset = (int)$offset;
+
+        $this->assertEquals($expectedWord, (new WordAtOffset($split))($text, $offset));
+    }
+
+    /**
+     * @return Generator<mixed>
+     */
+    public function provideWordAtOffset(): Generator
+    {
+        yield [
+            'hello thi<>s is',
+            'this',
+        ];
+
+        yield [
+            'h<>ello this is',
+            'hello',
+        ];
+        yield [
+            'hello this i<>s',
+            'is',
+        ];
+        yield [
+            'hello this is<>',
+            'is',
+        ];
+        yield [
+            'hello this is <>',
+            ' ',
+        ];
+        yield [
+            'hello this <>is',
+            ' ',
+        ];
+        yield [
+            "hello this is\nsom<>ething",
+            'something',
+        ];
+        yield [
+            " <>  hello this is\nsom<>ething",
+            ' ',
+        ];
+        yield [
+            'Reque<>st;',
+            'Request',
+        ];
+        yield [
+            "Foobar\Reque<>st;",
+            'Request',
+        ];
+        yield 'qualified name' => [
+            "Foobar\Reque<>st;",
+            'Foobar\Request',
+            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
+        ];
+        yield 'nullable type' => [
+            '?Reque<>st;',
+            'Request',
+            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
+        ];
+        yield 'trailing comma' => [
+            'Reque<>st,',
+            'Request',
+            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
+        ];
+        yield 'pipe type separator' => [
+            'Reque<>st|null,',
+            'Request',
+            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
+        ];
+        yield 'annotations' => [
+            '@Reque<>st',
+            'Request',
+            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
+        ];
+        yield 'subannotations (removing equal)' => [
+             '* input=Re<>quest::class',
+            'Request',
+            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
+        ];
+        yield 'templated type' => [
+             'array<Re<>quest>',
+            'Request',
+            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
+        ];
+        yield 'constant' => [
+            <<<'EOT'
+                    /**
+                     * @SWG\Post(
+                     *     @SWG\Response(
+                     *         response=Resp<>onse::HTTP_OK,
+                     *         description="Reset password sent successfully"
+                     * )
+                     */
+                EOT
+            , 'Response',
+            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
+        ];
+    }
+}
diff --git a/src/TextDocument.php b/src/TextDocument.php
new file mode 100644
index 00000000..dc893a36
--- /dev/null
+++ b/src/TextDocument.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+/**
+ * Represents source code or other text documents.
+ */
+interface TextDocument
+{
+    /**
+     * Return the document as a string
+     */
+    public function __toString(): string;
+
+    /**
+     * Return the URI to the document or NULL if the document has not been
+     * persisted yet.
+     */
+    public function uri(): ?TextDocumentUri;
+
+    /**
+     * Return language value object for the text document.
+     */
+    public function language(): TextDocumentLanguage;
+
+    public function uriOrThrow(): TextDocumentUri;
+}
diff --git a/src/TextDocumentBuilder.php b/src/TextDocumentBuilder.php
new file mode 100644
index 00000000..1c9306ca
--- /dev/null
+++ b/src/TextDocumentBuilder.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use Phpactor\TextDocument\Exception\TextDocumentNotFound;
+use RuntimeException;
+use Symfony\Component\Filesystem\Path;
+
+final class TextDocumentBuilder
+{
+    private ?TextDocumentUri $uri = null;
+
+    private ?TextDocumentLanguage $language = null;
+
+    private function __construct(private string $text)
+    {
+    }
+
+    public static function create(string $text): self
+    {
+        return new self($text);
+    }
+
+    public static function fromUri(string $uri, ?string $language = null): self
+    {
+        $uri = TextDocumentUri::fromString($uri);
+
+        if (!file_exists($uri)) {
+            throw new TextDocumentNotFound(sprintf(
+                'Text Document not found at URI "%s"',
+                $uri
+            ));
+        }
+
+        if (!is_readable($uri)) {
+            throw new RuntimeException(sprintf(
+                'Could not read file at URI "%s"',
+                $uri
+            ));
+        }
+
+        if (null === $language) {
+            $language = Path::getExtension((string) $uri);
+        }
+
+        $contents = file_get_contents($uri);
+
+        if (false === $contents) {
+            throw new RuntimeException(sprintf(
+                'Could not read file at URI "%s"',
+                $uri
+            ));
+        }
+
+        $new = new self($contents);
+        $new->uri = $uri;
+        $new->language = TextDocumentLanguage::fromString($language);
+
+        return $new;
+    }
+
+    public static function fromTextDocument(TextDocument $document): self
+    {
+        $new = new self($document->__toString());
+        $new->uri = $document->uri();
+        $new->language = $document->language();
+
+        return $new;
+    }
+
+    public function uri(string $uri): self
+    {
+        $this->uri = TextDocumentUri::fromString($uri);
+
+        return $this;
+    }
+
+    public function language(string $language): self
+    {
+        $this->language = TextDocumentLanguage::fromString($language);
+
+        return $this;
+    }
+
+    public function text(string $text): self
+    {
+        $this->text = $text;
+
+        return $this;
+    }
+
+    public function build(): TextDocument
+    {
+        return new StandardTextDocument(
+            $this->language ?? TextDocumentLanguage::undefined(),
+            $this->text,
+            $this->uri
+        );
+    }
+
+    /**
+     * @deprecated this method encourages the creation of documents without the URI.
+     */
+    public static function fromUnknown(TextDocument|string $sourceCode): TextDocument
+    {
+        if ($sourceCode instanceof TextDocument) {
+            return $sourceCode;
+        }
+
+        return self::create($sourceCode)->build();
+    }
+
+    public static function empty(): TextDocument
+    {
+        return self::create('')->build();
+    }
+
+    public static function fromPathAndString(string $path, string $string): TextDocument
+    {
+        return self::create($string)->uri($path)->build();
+    }
+}
diff --git a/src/TextDocumentEdits.php b/src/TextDocumentEdits.php
new file mode 100644
index 00000000..c3f4976b
--- /dev/null
+++ b/src/TextDocumentEdits.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use Iterator;
+use IteratorAggregate;
+
+/**
+ * @implements IteratorAggregate<int, TextEdit>
+ */
+class TextDocumentEdits implements IteratorAggregate
+{
+    public function __construct(private TextDocumentUri $uri, private TextEdits $textEdits)
+    {
+    }
+
+    public static function fromTextDocument(TextDocument $textDocument, TextEdits $edits): self
+    {
+        return new self(
+            TextDocumentUri::fromString($textDocument->uriOrThrow()),
+            $edits
+        );
+    }
+
+    public function uri(): TextDocumentUri
+    {
+        return $this->uri;
+    }
+
+    public function textEdits(): TextEdits
+    {
+        return $this->textEdits;
+    }
+    /**
+     * @return Iterator<TextEdit>
+     */
+    public function getIterator(): Iterator
+    {
+        return $this->textEdits->getIterator();
+    }
+}
diff --git a/src/TextDocumentLanguage.php b/src/TextDocumentLanguage.php
new file mode 100644
index 00000000..4c4c8bea
--- /dev/null
+++ b/src/TextDocumentLanguage.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+class TextDocumentLanguage
+{
+    const LANGUAGE_UNDEFINED = 'undefined';
+    const LANGUAGE_PHP = 'php';
+
+    private function __construct(private string $language)
+    {
+    }
+
+    public function __toString(): string
+    {
+        return $this->language;
+    }
+
+    public static function fromString(string $language): self
+    {
+        return new self($language);
+    }
+
+    public static function undefined(): self
+    {
+        return new self(self::LANGUAGE_UNDEFINED);
+    }
+
+    public function is(string $language): bool
+    {
+        return $this->language === strtolower($language);
+    }
+
+    /**
+     * @param array<string> $languages
+     */
+    public function in(array $languages): bool
+    {
+        return in_array($this->language, $languages);
+    }
+
+    public function isDefined(): bool
+    {
+        return !$this->is(self::LANGUAGE_UNDEFINED);
+    }
+
+    public function isPhp(): bool
+    {
+        return $this->is(self::LANGUAGE_PHP);
+    }
+}
diff --git a/src/TextDocumentLocator.php b/src/TextDocumentLocator.php
new file mode 100644
index 00000000..972925ae
--- /dev/null
+++ b/src/TextDocumentLocator.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use Phpactor\TextDocument\Exception\TextDocumentNotFound;
+
+interface TextDocumentLocator
+{
+    /**
+     * Retrieve text document by URI
+     *
+     * @throws TextDocumentNotFound
+     */
+    public function get(TextDocumentUri $uri): TextDocument;
+}
diff --git a/src/TextDocumentLocator/ChainDocumentLocator.php b/src/TextDocumentLocator/ChainDocumentLocator.php
new file mode 100644
index 00000000..f9788c7c
--- /dev/null
+++ b/src/TextDocumentLocator/ChainDocumentLocator.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Phpactor\TextDocument\TextDocumentLocator;
+
+use Phpactor\TextDocument\Exception\TextDocumentNotFound;
+use Phpactor\TextDocument\TextDocument;
+use Phpactor\TextDocument\TextDocumentUri;
+use Phpactor\TextDocument\TextDocumentLocator;
+
+class ChainDocumentLocator implements TextDocumentLocator
+{
+    /**
+     * @param TextDocumentLocator[] $locators
+     */
+    public function __construct(private array $locators)
+    {
+    }
+
+
+    public function get(TextDocumentUri $uri): TextDocument
+    {
+        foreach ($this->locators as $workspace) {
+            try {
+                return $workspace->get($uri);
+            } catch (TextDocumentNotFound) {
+            }
+        }
+
+        throw TextDocumentNotFound::fromUri($uri);
+    }
+}
diff --git a/src/TextDocumentLocator/InMemoryDocumentLocator.php b/src/TextDocumentLocator/InMemoryDocumentLocator.php
new file mode 100644
index 00000000..72f02b81
--- /dev/null
+++ b/src/TextDocumentLocator/InMemoryDocumentLocator.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Phpactor\TextDocument\TextDocumentLocator;
+
+use Phpactor\TextDocument\Exception\TextDocumentNotFound;
+use Phpactor\TextDocument\TextDocument;
+use Phpactor\TextDocument\TextDocumentUri;
+use Phpactor\TextDocument\TextDocumentLocator;
+
+final class InMemoryDocumentLocator implements TextDocumentLocator
+{
+    /**
+     * @param array<string, TextDocument> $documents
+     */
+    private function __construct(private array $documents)
+    {
+    }
+
+    public function get(TextDocumentUri $uri): TextDocument
+    {
+        if (isset($this->documents[$uri->__toString()])) {
+            return $this->documents[$uri->__toString()];
+        }
+
+        throw TextDocumentNotFound::fromUri($uri);
+    }
+
+    /**
+     * @param TextDocument[] $textDocuments
+     */
+    public static function fromTextDocuments(array $textDocuments): self
+    {
+        return new self((array)array_combine(array_map(function (TextDocument $document): string {
+            return $document->uri()->__toString();
+        }, $textDocuments), array_values($textDocuments)));
+    }
+
+    public static function new(): self
+    {
+        return new self([]);
+    }
+}
diff --git a/src/TextDocumentUri.php b/src/TextDocumentUri.php
new file mode 100644
index 00000000..88ffb140
--- /dev/null
+++ b/src/TextDocumentUri.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use Phpactor\TextDocument\Exception\InvalidUriException;
+use Symfony\Component\Filesystem\Path;
+
+class TextDocumentUri
+{
+    public const SCHEME_FILE = 'file';
+    public const SCHEME_UNTITLED = 'untitled';
+    public const SCHEME_PHAR = 'phar';
+    public const SCHEMES = [self::SCHEME_FILE, self::SCHEME_UNTITLED, self::SCHEME_PHAR];
+
+    final private function __construct(private string $scheme, private string $path)
+    {
+    }
+
+    public function __toString(): string
+    {
+        if ($this->scheme === self::SCHEME_UNTITLED) {
+            return sprintf('%s:%s', $this->scheme, $this->path);
+        }
+        return sprintf('%s://%s', $this->scheme, $this->path);
+    }
+
+    public static function fromString(?string $uri): self
+    {
+        if ($uri === null || $uri === '') {
+            throw new InvalidUriException(sprintf(
+                'Could not parse_url "%s"',
+                $uri
+            ));
+        }
+
+        if (str_starts_with($uri, 'untitled:')) {
+            return new self(self::SCHEME_UNTITLED, substr($uri, 9));
+        }
+
+        $match = preg_match('{^(?<scheme>[a-z]+://){0,1}(?<path>.+)?}', $uri, $components);
+
+        if (!isset($components['scheme']) || $components['scheme'] == '') {
+            $components['scheme'] = self::SCHEME_FILE;
+        } else {
+            $components['scheme'] = substr($components['scheme'], 0, -3);
+        }
+
+        if (!isset($components['path'])) {
+            throw new InvalidUriException(sprintf(
+                'URI "%s" has no path component',
+                $uri
+            ));
+        }
+
+        if (!in_array($components['scheme'], self::SCHEMES)) {
+            throw new InvalidUriException(sprintf(
+                'Only "%s" schemes are supported, got "%s"',
+                implode('", "', self::SCHEMES),
+                $components['scheme']
+            ));
+        }
+
+        if ($components['scheme'] === self::SCHEME_FILE && false === Path::isAbsolute($uri)) {
+            throw new InvalidUriException(sprintf(
+                'URI for file:// must be absolute, got "%s"',
+                $uri
+            ));
+        }
+
+        $components['path'] = Path::canonicalize($components['path']);
+
+        return new self(
+            $components['scheme'],
+            $components['path']
+        );
+    }
+
+    public function path(): string
+    {
+        return $this->path;
+    }
+
+    public function scheme(): string
+    {
+        return $this->scheme;
+    }
+}
diff --git a/src/TextEdit.php b/src/TextEdit.php
new file mode 100644
index 00000000..667d12f4
--- /dev/null
+++ b/src/TextEdit.php
@@ -0,0 +1,59 @@
+<?php
+
+
+namespace Phpactor\TextDocument;
+
+use OutOfRangeException;
+
+class TextEdit
+{
+    private ByteOffset $start;
+
+    private int $length;
+
+    private string $replacement;
+
+    private function __construct(ByteOffset $start, int $length, string $content)
+    {
+        if ($length < 0) {
+            throw new OutOfRangeException(sprintf(
+                'Text edit length cannot be less than 0, got "%s" (start: %s, content: %s)',
+                $length,
+                $start->toInt(),
+                $content
+            ));
+        }
+
+        $this->start = $start;
+        $this->length = $length;
+        $this->replacement = $content;
+    }
+
+    /**
+     * @param int|ByteOffset $start
+     */
+    public static function create($start, int $length, string $replacement): self
+    {
+        return new self(ByteOffset::fromIntOrByteOffset($start), $length, $replacement);
+    }
+
+    public function end(): ByteOffset
+    {
+        return $this->start->add($this->length);
+    }
+
+    public function start(): ByteOffset
+    {
+        return $this->start;
+    }
+
+    public function length(): int
+    {
+        return $this->length;
+    }
+
+    public function replacement(): string
+    {
+        return $this->replacement;
+    }
+}
diff --git a/src/TextEditDiff.php b/src/TextEditDiff.php
new file mode 100644
index 00000000..3cdf6f12
--- /dev/null
+++ b/src/TextEditDiff.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+final class TextEditDiff
+{
+    private const REPLACE = 'r';
+    private const NOOP = 'o';
+    private const ADD = '+';
+    private const DEL = '-';
+
+    public function diff(string $one, string $two): TextEdits
+    {
+        $table = $this->lcsTable($one, $two);
+        $ops = $this->resolveOps(
+            $table,
+            mb_str_split($one),
+            mb_str_split($two),
+            mb_strlen($one) - 1,
+            mb_strlen($two) - 1
+        );
+        $edits = $this->textEdits($ops);
+
+        return $edits;
+    }
+
+    /**
+     * @param array<int, array<int,int>> $table
+     * @param list<string> $x
+     * @param list<string> $y
+     * @param list<array{string,string,int}> $ops
+     * @return list<array{string,string,int}>
+     */
+    public function resolveOps(array $table, array $x, array $y, int $i, int $j, array $ops = []): array
+    {
+        if ($i > 0 && $j > 0 && $x[$i] === $y[$j]) {
+            $ops = $this->resolveOps($table, $x, $y, $i-1, $j-1);
+            $ops[] = [self::NOOP, $x[$i], $i];
+            return $ops;
+        }
+
+        if ($j > 0 && ($i === 0 || $table[$i][$j-1] >= $table[$i-1][$j])) {
+            $ops = $this->resolveOps($table, $x, $y, $i, $j-1);
+            $ops[] = [self::ADD, $y[$j], $i + 1];
+            return $ops;
+        }
+
+        if ($i > 0 && ($j === 0 || $table[$i][$j-1] < $table[$i-1][$j])) {
+            $ops = $this->resolveOps($table, $x, $y, $i - 1, $j);
+            $ops[] = [self::DEL, $x[$i], $i];
+            return $ops;
+        }
+
+        if ($j === 0 && $i === 0 && $x[$i] !== $y[$j]) {
+            $ops[] = [self::REPLACE, $y[$i], $i];
+            return $ops;
+        }
+
+        return $ops;
+    }
+
+    /**
+     * @return array<int,array<int, int>>
+     */
+    private function lcsTable(string $one, string $two): array
+    {
+        $m = mb_strlen($one);
+        $n = mb_strlen($two);
+        $table = [];
+
+        for ($i = 0; $i <= $m; $i++) {
+            $table[$i][0] = 0;
+        }
+        for ($j = 0; $j <= $n; $j++) {
+            $table[0][$j] = 0;
+        }
+
+        for ($i = 1; $i <= $m; $i++) {
+            for ($j = 1; $j <= $n; $j++) {
+                if (substr($one, $i - 1, 1) === substr($two, $j - 1, 1)) {
+                    $table[$i][$j] = $table[$i - 1][$j - 1] + 1;
+                } else {
+                    $table[$i][$j] = max($table[$i][$j - 1], $table[$i - 1][$j]);
+                }
+            }
+        }
+
+        return $table;
+    }
+
+    /**
+     * @param list<array{string,string,int}> $ops
+     */
+    private function textEdits(array $ops): TextEdits
+    {
+        $chunks = [];
+        $currentOps = [];
+        $currentOpName = null;
+        $lastOp = null;
+
+        // chunk by operation
+        foreach ($ops as $op) {
+            $opName = $op[0];
+
+            if ($lastOp === null) {
+                $currentOps[] = $op;
+            } elseif ($opName != $lastOp) {
+                $chunks[] = $currentOps;
+                $currentOps = [$op];
+            } else {
+                $currentOps[] = $op;
+            }
+
+            $lastOp = $opName;
+        }
+
+        if ($currentOps) {
+            $chunks[] = $currentOps;
+        }
+
+        // covert to text edits
+        $edits = [];
+        foreach ($chunks as $chunk) {
+            if ($chunk[0][0] === self::ADD) {
+                $edits[] = TextEdit::create(
+                    $chunk[0][2],
+                    0,
+                    implode('', array_map(fn (array $op) => $op[1], $chunk))
+                );
+            }
+            if ($chunk[0][0] === self::DEL) {
+                $edits[] = TextEdit::create(
+                    $chunk[0][2],
+                    count($chunk),
+                    '',
+                );
+            }
+            if ($chunk[0][0] === self::REPLACE) {
+                $edits[] = TextEdit::create(
+                    $chunk[0][2],
+                    count($chunk),
+                    implode('', array_map(fn (array $ops) => $ops[1], $chunk)),
+                );
+            }
+        }
+
+        return TextEdits::fromTextEdits($edits);
+    }
+}
diff --git a/src/TextEdits.php b/src/TextEdits.php
new file mode 100644
index 00000000..c597a88f
--- /dev/null
+++ b/src/TextEdits.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use ArrayIterator;
+use Countable;
+use Iterator;
+use IteratorAggregate;
+use OutOfBoundsException;
+
+/**
+ * @implements IteratorAggregate<int, TextEdit>
+ */
+class TextEdits implements IteratorAggregate, Countable
+{
+    /**
+     * @var TextEdit[]
+     */
+    private array $textEdits;
+
+    public function __construct(TextEdit ...$textEdits)
+    {
+        usort($textEdits, function (TextEdit $a, TextEdit $b) {
+            return $a->start() <=> $b->start();
+        });
+        $this->textEdits = $textEdits;
+    }
+
+    public static function one(TextEdit $textEdit): self
+    {
+        return new self($textEdit);
+    }
+
+    /**
+     * @return Iterator<TextEdit>
+     */
+    public function getIterator(): Iterator
+    {
+        return new ArrayIterator($this->textEdits);
+    }
+
+    public static function none(): self
+    {
+        return new self();
+    }
+
+    /**
+     * @param array<TextEdit> $textEdits
+     */
+    public static function fromTextEdits(array $textEdits): self
+    {
+        return new self(...$textEdits);
+    }
+
+    /**
+     * Merge one set of edits into this set.
+     *
+     * Edits from this set are ordered before those of the merged edits.
+     */
+    public function merge(TextEdits $edits): self
+    {
+        return new self(...array_merge($this->textEdits, $edits->textEdits));
+    }
+
+    /**
+     * Apply this set of edits to the given text
+     */
+    public function apply(string $text): string
+    {
+        $prevEditStart = PHP_INT_MAX;
+
+        for ($i = \count($this->textEdits) - 1; $i >= 0; $i--) {
+            $edit = $this->textEdits[$i];
+            assert($edit instanceof TextEdit);
+
+            if ($prevEditStart < $edit->start()->toInt() || $prevEditStart < $edit->end()->toInt()) {
+                throw new OutOfBoundsException(sprintf(
+                    "Overlapping text edit:\n%s",
+                    self::renderDebugTextEdits($edit, $this->textEdits)
+                ));
+            }
+
+            if ($edit->end()->toInt() > \strlen($text)) {
+                throw new OutOfBoundsException(sprintf(
+                    'Text edit end (%s) exceeds length of text (%s): %s',
+                    $edit->end()->toInt(),
+                    $edit->replacement(),
+                    self::renderDebugTextEdits($edit, $this->textEdits)
+                ));
+            }
+
+            $prevEditStart = $edit->start()->toInt();
+            $head = \substr($text, 0, $edit->start()->toInt());
+            $tail = \substr($text, $edit->end()->toInt());
+            $text = $head . $edit->replacement() . $tail;
+        }
+
+        return $text;
+    }
+
+    public function add(TextEdit $textEdit): self
+    {
+        return new self(...array_merge($this->textEdits, [$textEdit]));
+    }
+
+    public function count(): int
+    {
+        return count($this->textEdits);
+    }
+
+    /**
+     * @param array<TextEdit> $edits
+     */
+    private static function renderDebugTextEdits(TextEdit $edit, array $edits): string
+    {
+        return implode("\n", array_map(function (TextEdit $otherEdit) use ($edit) {
+            return sprintf(
+                '%s%s %s "%s"',
+                $edit === $otherEdit ? '> ' : '  ',
+                $otherEdit->start()->toInt(),
+                $otherEdit->end()->toInt(),
+                str_replace("\n", '\n', $otherEdit->replacement())
+            );
+        }, $edits));
+    }
+}
diff --git a/src/Util/LineAtOffset.php b/src/Util/LineAtOffset.php
new file mode 100644
index 00000000..05f7f75e
--- /dev/null
+++ b/src/Util/LineAtOffset.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Phpactor\TextDocument\Util;
+
+use OutOfBoundsException;
+use Phpactor\TextDocument\ByteOffset;
+use RuntimeException;
+
+final class LineAtOffset
+{
+    public function __invoke(string $text, int $byteOffset): string
+    {
+        $lines = preg_split("{(\r\n|\n|\r)}", $text, -1, PREG_SPLIT_DELIM_CAPTURE);
+        if (false === $lines) {
+            throw new RuntimeException(
+                'Failed to preg-split text into lines'
+            );
+        }
+
+        $start = 0;
+        $lastLine = '';
+        foreach ($lines as $line) {
+            $end = $start + strlen($line);
+            if ($byteOffset >= $start && $byteOffset <= $end) {
+                if (preg_match('{^(\r\n|\n|\r)$}', $line)) {
+                    return $lastLine;
+                }
+                return $line;
+            }
+            $lastLine = $line;
+            $start = $end;
+        }
+
+        throw new OutOfBoundsException(sprintf(
+            'Byte offset %s is larger than text length %s',
+            $byteOffset,
+            strlen($text)
+        ));
+    }
+    public static function lineAtByteOffset(string $text, ByteOffset $offset): string
+    {
+        return (new self())->__invoke($text, $offset->toInt());
+    }
+}
diff --git a/src/Util/LineColRangeForLine.php b/src/Util/LineColRangeForLine.php
new file mode 100644
index 00000000..986084ef
--- /dev/null
+++ b/src/Util/LineColRangeForLine.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Phpactor\TextDocument\Util;
+
+use Phpactor\TextDocument\LineCol;
+use Phpactor\TextDocument\LineColRange;
+use RuntimeException;
+
+final class LineColRangeForLine
+{
+    public function rangeFromLine(string $text, int $lineNo): LineColRange
+    {
+        $lines = preg_split("{(\r\n|\n|\r)}", $text, -1);
+
+        if (false === $lines) {
+            throw new RuntimeException(
+                'Failed to preg-split text into lines'
+            );
+        }
+
+        // if out of range, let the caller deal with it
+        if (!isset($lines[$lineNo - 1])) {
+            return new LineColRange(
+                new LineCol($lineNo, 1),
+                new LineCol($lineNo, 1)
+            );
+        }
+
+        $line = $lines[$lineNo - 1];
+
+        // if an empty line, return the first char
+        if (!$line) {
+            return new LineColRange(
+                new LineCol($lineNo, 1),
+                new LineCol($lineNo, 1)
+            );
+        }
+
+        $start = mb_strpos($line, trim($line));
+        $end = $start + mb_strlen(trim($line));
+
+        return new LineColRange(
+            new LineCol($lineNo, $start + 1),
+            new LineCol($lineNo, $end)
+        );
+    }
+}
diff --git a/src/Util/WordAtOffset.php b/src/Util/WordAtOffset.php
new file mode 100644
index 00000000..769238b4
--- /dev/null
+++ b/src/Util/WordAtOffset.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Phpactor\TextDocument\Util;
+
+use OutOfBoundsException;
+use RuntimeException;
+
+final class WordAtOffset
+{
+    const SPLIT_WORD = '\s|;|\\\|%|\(|\)|\[|\]|:|\r|\r\n|\n';
+
+    // see https://www.php.net/manual/en/language.oop5.basic.php
+    const SPLIT_PHP_NAME = '[^a-zA-Z0-9_\x80-\xff]';
+    const SPLIT_QUALIFIED_PHP_NAME = '[^a-zA-Z0-9_\x80-\xff\\\]';
+
+    public function __construct(private string $splitPattern = self::SPLIT_WORD)
+    {
+    }
+
+    public function __invoke(string $text, int $byteOffset): string
+    {
+        $chunks = preg_split('{(' . $this->splitPattern . ')}', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
+
+        if (false === $chunks) {
+            throw new RuntimeException(
+                'Failed to preg-split text into chunks'
+            );
+        }
+
+        $start = 1;
+        foreach ($chunks as $chunk) {
+            $end = $start + strlen($chunk);
+            if ($byteOffset >= $start && $byteOffset < $end) {
+                return $chunk;
+            }
+            $start = $end;
+        }
+
+        throw new OutOfBoundsException(sprintf(
+            'Byte offset %s is larger than text length %s',
+            $byteOffset,
+            strlen($text)
+        ));
+    }
+}
diff --git a/src/WorkspaceEdits.php b/src/WorkspaceEdits.php
new file mode 100644
index 00000000..09a52ab7
--- /dev/null
+++ b/src/WorkspaceEdits.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Phpactor\TextDocument;
+
+use ArrayIterator;
+use Countable;
+use Iterator;
+use IteratorAggregate;
+
+/**
+ * @implements IteratorAggregate<int, TextDocumentEdits>
+ */
+final class WorkspaceEdits implements IteratorAggregate, Countable
+{
+    /**
+     * @var TextDocumentEdits[]
+     */
+    private array $documentEdits;
+
+    public function __construct(TextDocumentEdits ...$documentEdits)
+    {
+        $this->documentEdits = $documentEdits;
+    }
+    /**
+     * @return Iterator<TextDocumentEdits>
+     */
+    public function getIterator(): Iterator
+    {
+        return new ArrayIterator($this->documentEdits);
+    }
+
+    public static function none(): self
+    {
+        return new self();
+    }
+
+    public function count(): int
+    {
+        return count($this->documentEdits);
+    }
+}

From 08c865d3758075c778143abca2d5a579a6295124 Mon Sep 17 00:00:00 2001
From: Daniel Leech <daniel@dantleech.com>
Date: Fri, 26 Jan 2024 16:23:56 +0000
Subject: [PATCH 18/19] Updfafte READMe

---
 README.md | 16 +++++-----------
 1 file changed, 5 insertions(+), 11 deletions(-)

diff --git a/README.md b/README.md
index 914da576..002f63ff 100644
--- a/README.md
+++ b/README.md
@@ -6,15 +6,9 @@ Text Document
 Collection of value objects for representing and referencing text documents
 (principally source code) for use with [Phpactor](https://phpactor/phpactor).
 
-Contributing
-------------
-
-This package is open source and welcomes contributions! Feel free to open a
-pull request on this repository.
-
-Support
--------
-
-- Create an issue on the main [Phpactor](https://github.com/phpactor/phpactor) repository.
-- Join the `#phpactor` channel on the Slack [Symfony Devs](https://symfony.com/slack-invite) channel.
+Notice
+------
 
+This package is not actively developed here but in the main
+[phpactor](https://github.com/phpactor/phpactor) repository. It is
+_occasionally_ updated. Raise any issues about this repository there.

From 58fe1e8b8cf61bc10138d4efd9c6fe8d88d38de4 Mon Sep 17 00:00:00 2001
From: Daniel Leech <daniel@dantleech.com>
Date: Mon, 3 Jun 2024 09:59:50 +0100
Subject: [PATCH 19/19] Ignore test files

---
 .gitattributes                                |  13 ++
 src/Tests/Unit/ByteOffsetTest.php             |  39 ----
 src/Tests/Unit/EfficientLineColsTest.php      |  77 -------
 src/Tests/Unit/LineColTest.php                |  73 -------
 src/Tests/Unit/LocationTest.php               |  22 --
 src/Tests/Unit/LocationsTest.php              |  99 ---------
 src/Tests/Unit/TextDocumentBuilderTest.php    |  48 -----
 src/Tests/Unit/TextDocumentLanguageTest.php   |  28 ---
 .../ChainDocumentLocatorTest.php              |  60 ------
 src/Tests/Unit/TextDocumentUriTest.php        |  80 -------
 src/Tests/Unit/TextEditDiffTest.php           |  93 --------
 src/Tests/Unit/TextEditTest.php               |  38 ----
 src/Tests/Unit/TextEditsTest.php              | 198 ------------------
 src/Tests/Unit/Util/LineAtOffsetTest.php      |  85 --------
 .../Unit/Util/LineColRangeForLineTest.php     |  77 -------
 src/Tests/Unit/Util/WordAtOffsetTest.php      | 118 -----------
 16 files changed, 13 insertions(+), 1135 deletions(-)
 create mode 100644 .gitattributes
 delete mode 100644 src/Tests/Unit/ByteOffsetTest.php
 delete mode 100644 src/Tests/Unit/EfficientLineColsTest.php
 delete mode 100644 src/Tests/Unit/LineColTest.php
 delete mode 100644 src/Tests/Unit/LocationTest.php
 delete mode 100644 src/Tests/Unit/LocationsTest.php
 delete mode 100644 src/Tests/Unit/TextDocumentBuilderTest.php
 delete mode 100644 src/Tests/Unit/TextDocumentLanguageTest.php
 delete mode 100644 src/Tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php
 delete mode 100644 src/Tests/Unit/TextDocumentUriTest.php
 delete mode 100644 src/Tests/Unit/TextEditDiffTest.php
 delete mode 100644 src/Tests/Unit/TextEditTest.php
 delete mode 100644 src/Tests/Unit/TextEditsTest.php
 delete mode 100644 src/Tests/Unit/Util/LineAtOffsetTest.php
 delete mode 100644 src/Tests/Unit/Util/LineColRangeForLineTest.php
 delete mode 100644 src/Tests/Unit/Util/WordAtOffsetTest.php

diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..ed1821b6
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,13 @@
+# Handle line endings automatically for files detected as text and leave all
+# files detected as binary untouched.
+* text=auto
+
+# Files and directories with the attribute export-ignore won’t be added to
+# archive files. See http://git-scm.com/docs/gitattributes for details.
+.gitattributes          export-ignore
+.gitignore              export-ignore
+/*.neon                 export-ignore
+/.github                export-ignore
+/.php-cs-fixer.dist.php export-ignore
+/phpunit.xml.dist       export-ignore
+/tests/                 export-ignore
diff --git a/src/Tests/Unit/ByteOffsetTest.php b/src/Tests/Unit/ByteOffsetTest.php
deleted file mode 100644
index 2f45c0d9..00000000
--- a/src/Tests/Unit/ByteOffsetTest.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit;
-
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\ByteOffset;
-use Phpactor\TextDocument\Exception\InvalidByteOffset;
-
-class ByteOffsetTest extends TestCase
-{
-    public function testValue(): void
-    {
-        $offset = ByteOffset::fromInt(10);
-        $this->assertEquals(10, $offset->toInt());
-    }
-
-    public function testAdd(): void
-    {
-        $offset = ByteOffset::fromInt(10)->add(10);
-        $this->assertEquals(20, $offset->toInt());
-    }
-
-    public function testExceptionOnLessThanZero1(): void
-    {
-        $this->expectException(InvalidByteOffset::class);
-        ByteOffset::fromInt(-1);
-    }
-
-    public function testExceptionOnLessThanZero2(): void
-    {
-        $this->expectException(InvalidByteOffset::class);
-        ByteOffset::fromInt(-10);
-    }
-
-    public function testByteOffsetIsZero(): void
-    {
-        self::assertEquals(0, ByteOffset::fromInt(0)->toInt());
-    }
-}
diff --git a/src/Tests/Unit/EfficientLineColsTest.php b/src/Tests/Unit/EfficientLineColsTest.php
deleted file mode 100644
index 252459f7..00000000
--- a/src/Tests/Unit/EfficientLineColsTest.php
+++ /dev/null
@@ -1,77 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit;
-
-use Closure;
-use Generator;
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\EfficientLineCols;
-
-class EfficientLineColsTest extends TestCase
-{
-    /**
-     * @dataProvider provideConvertOffsetsToLineCol
-     * @param list<int> $offsets
-     */
-    public function testFromByteOffsets(array $offsets, string $text, Closure $assertion): void
-    {
-        $converter = EfficientLineCols::fromByteOffsetInts($text, $offsets);
-        $assertion($converter);
-    }
-
-    /**
-     * @return Generator<mixed>
-     */
-    public function provideConvertOffsetsToLineCol(): Generator
-    {
-        yield [
-            [],
-            '',
-            function (EfficientLineCols $lineCols): void {
-                self::assertInstanceOf(EfficientLineCols::class, $lineCols);
-            }
-        ];
-        yield [
-            [2],
-            '01234',
-            function (EfficientLineCols $lineCols): void {
-                self::assertEquals(3, $lineCols->get(2)->col());
-                self::assertEquals(1, $lineCols->get(2)->line());
-            }
-        ];
-        yield [
-            [2, 3, 0, 10],
-            "01234\n5678",
-            function (EfficientLineCols $lineCols): void {
-                self::assertEquals(3, $lineCols->get(2)->col());
-                self::assertEquals(1, $lineCols->get(2)->line());
-                self::assertEquals(2, $lineCols->get(10)->line());
-                self::assertEquals(5, $lineCols->get(10)->col());
-            }
-        ];
-    }
-
-    /**
-     * @dataProvider provideConvertOffsetsToLineColAsOffset
-     * @param list<int> $offsets
-     */
-    public function testFromByteOffsetsAsOffset(array $offsets, string $text, Closure $assertion): void
-    {
-        $converter = EfficientLineCols::fromByteOffsetInts($text, $offsets, true);
-        $assertion($converter);
-    }
-
-    /**
-     * @return Generator<mixed>
-     */
-    public function provideConvertOffsetsToLineColAsOffset(): Generator
-    {
-        yield 'cat is 4 bytes' => [
-            [5],
-            'a😸bc',
-            function (EfficientLineCols $lineCols): void {
-                self::assertEquals(6, $lineCols->get(5)->col());
-            }
-        ];
-    }
-}
diff --git a/src/Tests/Unit/LineColTest.php b/src/Tests/Unit/LineColTest.php
deleted file mode 100644
index c7c1f5df..00000000
--- a/src/Tests/Unit/LineColTest.php
+++ /dev/null
@@ -1,73 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit;
-
-use Generator;
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\LineCol;
-
-class LineColTest extends TestCase
-{
-    /**
-     * @dataProvider provideConvertLineColToOffset
-     */
-    public function testToByteOffset(string $text, LineCol $lineCol, int $expectedOffset, ?string $sanityCheck = null): void
-    {
-        $this->assertEquals($expectedOffset, $lineCol->toByteOffset($text)->toInt());
-        if ($sanityCheck) {
-            self::assertEquals($sanityCheck, substr($text, 0, $lineCol->toByteOffset($text)->toInt()));
-        }
-    }
-
-    /**
-     * @return Generator<mixed>
-     */
-    public function provideConvertLineColToOffset(): Generator
-    {
-        yield [
-            '',
-            new LineCol(1, 1),
-            0,
-        ];
-
-        yield [
-            'a',
-            new LineCol(1, 1),
-            0,
-        ];
-
-        yield 'new line' => [
-            "\na",
-            new LineCol(2, 1),
-            1,
-        ];
-
-        yield 'multi-byte 1' => [
-            'ᅑa',
-            new LineCol(1, 2),
-            3,
-            'ᅑ',
-        ];
-
-        yield 'multi-byte 2' => [
-            'ᅑacd',
-            new LineCol(1, 3),
-            4,
-            'ᅑa'
-        ];
-
-        yield 'multi-byte 3' => [
-            "ᅑ\nacd",
-            new LineCol(2, 2),
-            5,
-            "ᅑ\na"
-        ];
-    }
-
-    public function testOutOfBoundsToByteOffset(): void
-    {
-        $lineCol = new LineCol(10, 20);
-        assert($lineCol instanceof LineCol);
-        self::assertEquals(13, $lineCol->toByteOffset("foobar\nbarfoo")->toInt());
-    }
-}
diff --git a/src/Tests/Unit/LocationTest.php b/src/Tests/Unit/LocationTest.php
deleted file mode 100644
index 82d81117..00000000
--- a/src/Tests/Unit/LocationTest.php
+++ /dev/null
@@ -1,22 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit;
-
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\Location;
-
-class LocationTest extends TestCase
-{
-    public function testProvidesAccessToUri(): void
-    {
-        $location = Location::fromPathAndOffsets('/path/to.php', 123, 234);
-        $this->assertEquals('file:///path/to.php', $location->uri()->__toString());
-    }
-
-    public function testProvidesAccessToByteOffset(): void
-    {
-        $location = Location::fromPathAndOffsets('/path/to.php', 123, 455);
-        $this->assertEquals(123, $location->range()->start()->toInt());
-        $this->assertEquals(455, $location->range()->end()->toInt());
-    }
-}
diff --git a/src/Tests/Unit/LocationsTest.php b/src/Tests/Unit/LocationsTest.php
deleted file mode 100644
index b5b6599f..00000000
--- a/src/Tests/Unit/LocationsTest.php
+++ /dev/null
@@ -1,99 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit;
-
-use Generator;
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\Location;
-use Phpactor\TextDocument\Locations;
-use RuntimeException;
-
-class LocationsTest extends TestCase
-{
-    public function testContainsLocations(): void
-    {
-        $locations = new Locations([
-            Location::fromPathAndOffsets('/path/to.php', 12, 124),
-            Location::fromPathAndOffsets('/path/to.php', 13, 14)
-        ]);
-        $this->assertCount(2, $locations);
-    }
-
-    public function testIsCountable(): void
-    {
-        $locations = new Locations([
-            Location::fromPathAndOffsets('/path/to.php', 12, 12),
-            Location::fromPathAndOffsets('/path/to.php', 13, 13)
-        ]);
-
-        $this->assertEquals(2, $locations->count());
-    }
-
-    public function testExceptionIfFirstNotAvailable(): void
-    {
-        $this->expectException(RuntimeException::class);
-
-        $locations = new Locations([]);
-        $locations->first();
-    }
-
-    public function testAppendLocations(): void
-    {
-        $locations = new Locations([
-            Location::fromPathAndOffsets('/path/to.php', 12, 19),
-        ]);
-        $locations = $locations->append(new Locations([
-            Location::fromPathAndOffsets('/path/to.php', 13, 40),
-        ]));
-
-        self::assertEquals(new Locations([
-            Location::fromPathAndOffsets('/path/to.php', 12, 19),
-            Location::fromPathAndOffsets('/path/to.php', 13, 40)
-        ]), $locations);
-    }
-
-    /**
-     * @dataProvider provideUnsortedLocations
-     *
-     * @param Location[] $unsortedLocationsArray
-     * @param Location[] $sortedLocationsArray
-     */
-    public function testSortLocations(
-        array $unsortedLocationsArray,
-        array $sortedLocationsArray
-    ): void {
-        $locations = new Locations($unsortedLocationsArray);
-        $sortedLocations = $locations->sorted();
-
-        $this->assertNotSame($locations, $sortedLocations);
-        $this->assertCount(count($unsortedLocationsArray), $sortedLocations);
-
-        foreach(iterator_to_array($sortedLocations) as $index => $sortedLocation) {
-            $expectedLocation = $sortedLocationsArray[$index];
-
-            self::assertEquals($sortedLocation, new Location($expectedLocation->uri(), $expectedLocation->range()));
-        }
-    }
-
-    /**
-     * @return Generator<mixed>
-     */
-    public function provideUnsortedLocations(): Generator
-    {
-        yield 'Same file is sorted by start position' => [[
-            Location::fromPathAndOffsets('/path/to.php', 30, 50),
-            Location::fromPathAndOffsets('/path/to.php', 12, 24),
-        ], [
-            Location::fromPathAndOffsets('/path/to.php', 12, 24),
-            Location::fromPathAndOffsets('/path/to.php', 30, 50),
-        ]];
-
-        yield 'Sort by file name first' => [[
-            Location::fromPathAndOffsets('/path/to.php', 12, 42),
-            Location::fromPathAndOffsets('/path/from.php', 15, 43),
-        ], [
-            Location::fromPathAndOffsets('/path/from.php', 15, 43),
-            Location::fromPathAndOffsets('/path/to.php', 12, 42),
-        ]];
-    }
-}
diff --git a/src/Tests/Unit/TextDocumentBuilderTest.php b/src/Tests/Unit/TextDocumentBuilderTest.php
deleted file mode 100644
index f3dce33f..00000000
--- a/src/Tests/Unit/TextDocumentBuilderTest.php
+++ /dev/null
@@ -1,48 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit;
-
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\Exception\TextDocumentNotFound;
-use Phpactor\TextDocument\TextDocumentBuilder;
-
-class TextDocumentBuilderTest extends TestCase
-{
-    const EXAMPLE_TEXT = 'hello world';
-    const EXAMPLE_URI = 'file:///path/to';
-
-    public function testCreate(): void
-    {
-        $doc = TextDocumentBuilder::create(self::EXAMPLE_TEXT)->language('php')->uri(self::EXAMPLE_URI)->build();
-        $this->assertEquals(self::EXAMPLE_URI, $doc->uri()->__toString());
-        $this->assertEquals(self::EXAMPLE_TEXT, $doc->__toString());
-        $this->assertEquals('php', $doc->language());
-    }
-
-    public function testFromUri(): void
-    {
-        $doc = TextDocumentBuilder::fromUri('file://' . __FILE__)->build();
-        $this->assertEquals('file://' . __FILE__, $doc->uri());
-        $this->assertEquals(file_get_contents(__FILE__), $doc->__toString());
-    }
-
-    public function testFromTextDocument(): void
-    {
-        $doc = TextDocumentBuilder::fromTextDocument(
-            TextDocumentBuilder::create('foobar')
-                ->uri('file:///foobar/asd')
-                ->language('foo')->build()
-        )->build();
-
-        $this->assertEquals('foobar', $doc->__toString());
-        $this->assertEquals('file:///foobar/asd', $doc->uri()->__toString());
-        $this->assertEquals('/foobar/asd', $doc->uri()?->path());
-        $this->assertEquals('foo', $doc->language()->__toString());
-    }
-
-    public function testExceptionOnNotExists(): void
-    {
-        $this->expectException(TextDocumentNotFound::class);
-        TextDocumentBuilder::fromUri('file:///no-existy');
-    }
-}
diff --git a/src/Tests/Unit/TextDocumentLanguageTest.php b/src/Tests/Unit/TextDocumentLanguageTest.php
deleted file mode 100644
index 67c9c90e..00000000
--- a/src/Tests/Unit/TextDocumentLanguageTest.php
+++ /dev/null
@@ -1,28 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit;
-
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\TextDocumentLanguage;
-
-class TextDocumentLanguageTest extends TestCase
-{
-    public function testCreate(): void
-    {
-        $language = TextDocumentLanguage::fromString('php');
-        $this->assertEquals('php', (string) $language);
-        $this->assertTrue($language->isDefined());
-        $this->assertTrue($language->isPhp());
-        $this->assertTrue($language->is('php'));
-        $this->assertTrue($language->is('PHP'));
-        $this->assertFalse($language->is('french'));
-        $this->assertTrue($language->in(['php', 'cobol']));
-        $this->assertFalse($language->in(['c', 'cobol']));
-    }
-
-    public function testCreateUndefined(): void
-    {
-        $language = TextDocumentLanguage::undefined();
-        $this->assertFalse($language->isDefined());
-    }
-}
diff --git a/src/Tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php b/src/Tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php
deleted file mode 100644
index a4126bf4..00000000
--- a/src/Tests/Unit/TextDocumentLocator/ChainDocumentLocatorTest.php
+++ /dev/null
@@ -1,60 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit\TextDocumentLocator;
-
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\Exception\TextDocumentNotFound;
-use Phpactor\TextDocument\TextDocumentBuilder;
-use Phpactor\TextDocument\TextDocumentLocator;
-use Phpactor\TextDocument\TextDocumentUri;
-use Phpactor\TextDocument\TextDocumentLocator\ChainDocumentLocator;
-use Phpactor\TextDocument\TextDocumentLocator\InMemoryDocumentLocator;
-
-class ChainDocumentLocatorTest extends TestCase
-{
-    public function testThrowsExceptionWhenNotFound(): void
-    {
-        $this->expectException(TextDocumentNotFound::class);
-        $this->createWorkspace()->get(TextDocumentUri::fromString('file:///foobar'));
-    }
-
-    public function testReturnsTextDocument(): void
-    {
-        $document = TextDocumentBuilder::create('foobar')->uri('/path/to/foo')->build();
-
-        self::assertSame(
-            $document,
-            $this->createWorkspace([
-                InMemoryDocumentLocator::fromTextDocuments([
-                    $document
-                ])
-            ])->get(TextDocumentUri::fromString('file:///path/to/foo'))
-        );
-    }
-
-    public function testReturnsTextDocumentFromFirstWorkspace(): void
-    {
-        $document1 = TextDocumentBuilder::create('one')->uri('/path/to/foo')->build();
-        $document2 = TextDocumentBuilder::create('two')->uri('/path/to/foo')->build();
-
-        self::assertSame(
-            $document1,
-            $this->createWorkspace([
-                InMemoryDocumentLocator::fromTextDocuments([
-                    $document1
-                ]),
-                InMemoryDocumentLocator::fromTextDocuments([
-                    $document2
-                ])
-            ])->get(TextDocumentUri::fromString('file:///path/to/foo'))
-        );
-    }
-
-    /**
-     * @param TextDocumentLocator[] $workspaces
-     */
-    private function createWorkspace(array $workspaces = []): ChainDocumentLocator
-    {
-        return new ChainDocumentLocator($workspaces);
-    }
-}
diff --git a/src/Tests/Unit/TextDocumentUriTest.php b/src/Tests/Unit/TextDocumentUriTest.php
deleted file mode 100644
index c05dfb8e..00000000
--- a/src/Tests/Unit/TextDocumentUriTest.php
+++ /dev/null
@@ -1,80 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit;
-
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\Exception\InvalidUriException;
-use Phpactor\TextDocument\TextDocumentUri;
-
-class TextDocumentUriTest extends TestCase
-{
-    public function testCreate(): void
-    {
-        $uri = TextDocumentUri::fromString('file://' . __FILE__);
-        $this->assertEquals('file://' . __FILE__, (string) $uri);
-    }
-
-    public function testCreateUntitled(): void
-    {
-        $uri = TextDocumentUri::fromString('untitled:Untitled-1');
-        $this->assertEquals('untitled:Untitled-1', (string) $uri);
-    }
-
-    public function testCreatePhar(): void
-    {
-        $uri = TextDocumentUri::fromString('phar://' . __FILE__);
-        $this->assertEquals('phar://' . __FILE__, (string) $uri);
-    }
-
-    public function testNormalizesToFileScheme(): void
-    {
-        $uri = TextDocumentUri::fromString(__FILE__);
-        $this->assertEquals('file://' . __FILE__, (string) $uri);
-    }
-
-    public function testExceptionOnNonAbsolutePath(): void
-    {
-        $this->expectException(InvalidUriException::class);
-        TextDocumentUri::fromString('i is relative');
-    }
-
-    public function testExceptionOnInvalidUri(): void
-    {
-        $this->expectException(InvalidUriException::class);
-        $this->expectExceptionMessage('not parse');
-        TextDocumentUri::fromString('');
-    }
-
-    public function testExceptionOnNoPath(): void
-    {
-        $this->expectException(InvalidUriException::class);
-        $this->expectExceptionMessage('has no path');
-        TextDocumentUri::fromString('file://');
-    }
-
-    public function testFromPath(): void
-    {
-        $uri = TextDocumentUri::fromString('/foobar');
-        $this->assertEquals('file:///foobar', $uri->__toString());
-    }
-
-    public function testFromHttpUri(): void
-    {
-        $this->expectException(InvalidUriException::class);
-        $this->expectExceptionMessage('Only "file", "untitled", "phar" schemes are supported, got "http"');
-        $uri = TextDocumentUri::fromString('http://foobar/foobar');
-    }
-
-
-    public function testReturnsPath(): void
-    {
-        $uri = TextDocumentUri::fromString('file://' . __FILE__);
-        $this->assertEquals(__FILE__, $uri->path());
-    }
-
-    public function testScheme(): void
-    {
-        $uri = TextDocumentUri::fromString('file://' . __FILE__);
-        $this->assertEquals('file', $uri->scheme());
-    }
-}
diff --git a/src/Tests/Unit/TextEditDiffTest.php b/src/Tests/Unit/TextEditDiffTest.php
deleted file mode 100644
index 8382d614..00000000
--- a/src/Tests/Unit/TextEditDiffTest.php
+++ /dev/null
@@ -1,93 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit;
-
-use Generator;
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\TextEditDiff;
-
-class TextEditDiffTest extends TestCase
-{
-    /**
-     * @dataProvider provideDiff
-     */
-    public function testDiff(string $one, string $two): void
-    {
-        $edits = (new TextEditDiff())->diff($one, $two);
-        self::assertEquals(
-            $two,
-            $edits->apply($one)
-        );
-    }
-
-    /**
-      * @return Generator<string,array{string,string}>
-      */
-    public function provideDiff(): Generator
-    {
-        yield 'add string' => [
-            'foo',
-            'foo bar',
-        ];
-        yield 'remove string' => [
-            'foo bar',
-            'foo',
-        ];
-        yield 'insert string' => [
-            'foo bar',
-            'foo baz boo bar bag',
-        ];
-
-        yield 'first char' => [
-            'i',
-            'b',
-        ];
-
-        yield 'differnet' => [
-            'it little profits',
-            'that an idle king',
-        ];
-
-        yield 'poem' => [
-            implode("\n", [
-                'it little profits that an idle king',
-                'matched with an aged wife',
-            ]),
-            implode("\n", [
-                'by this still hearth',
-                'it little profits that an idle king',
-            ])
-        ];
-
-        yield 'code' => [
-            implode("\n", [
-                '<?php',
-                'class Foobar {',
-                '    private $foobar;',
-                '    /**',
-                '     * @return array<int,foobar>',
-                '     */',
-                '    public function bar() {',
-                '    }',
-                '}'
-            ]),
-            implode("\n", [
-                '<?php',
-                'class Foobar {',
-                '',
-                '    /**',
-                '     * @var Foobars',
-                '     */',
-                '    private $foobar;',
-                '',
-                '    /**',
-                '     * @param array<int,foobar> $bar',
-                '     * @return array<string,foobar>',
-                '     */',
-                '    public function bar(array $bar) {',
-                '    }',
-                '}'
-            ]),
-        ];
-    }
-}
diff --git a/src/Tests/Unit/TextEditTest.php b/src/Tests/Unit/TextEditTest.php
deleted file mode 100644
index b486f56c..00000000
--- a/src/Tests/Unit/TextEditTest.php
+++ /dev/null
@@ -1,38 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit;
-
-use OutOfRangeException;
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\ByteOffset;
-use Phpactor\TextDocument\TextEdit;
-
-class TextEditTest extends TestCase
-{
-    public function testExceptionIfLengthIsNegative1(): void
-    {
-        $this->expectException(OutOfRangeException::class);
-        TextEdit::create(10, -10, 'asd');
-    }
-
-    public function testExceptionIfLengthIsNegative2(): void
-    {
-        $this->expectException(OutOfRangeException::class);
-        TextEdit::create(10, -1, 'asd');
-    }
-
-    public function testLengthIsZero(): void
-    {
-        self::assertEquals(0, TextEdit::create(10, 0, 'asd')->length());
-    }
-
-    public function testReturnLength(): void
-    {
-        self::assertEquals(1, TextEdit::create(10, 1, 'asd')->length());
-    }
-
-    public function testCreateWithByteOffset(): void
-    {
-        self::assertEquals(1, TextEdit::create(ByteOffset::fromInt(10), 1, 'asd')->length());
-    }
-}
diff --git a/src/Tests/Unit/TextEditsTest.php b/src/Tests/Unit/TextEditsTest.php
deleted file mode 100644
index 50f0c2e9..00000000
--- a/src/Tests/Unit/TextEditsTest.php
+++ /dev/null
@@ -1,198 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit;
-
-use Generator;
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\TextEdit;
-use Phpactor\TextDocument\TextEdits;
-
-class TextEditsTest extends TestCase
-{
-    /**
-     * @dataProvider provideMerge
-     * @param TextEdit[] $edits1
-     * @param TextEdit[] $edits2
-     * @param TextEdit[] $expectedEdits
-     */
-    public function testMerge(array $edits1, array $edits2, array $expectedEdits): void
-    {
-        self::assertEquals(
-            TextEdits::fromTextEdits($expectedEdits),
-            TextEdits::fromTextEdits($edits1)->merge(
-                TextEdits::fromTextEdits($edits2)
-            )
-        );
-    }
-
-
-    public function provideMerge(): Generator
-    {
-        yield 'empty' => [
-            [
-            ],
-            [
-            ],
-            [
-            ],
-        ];
-
-        yield 'empty merge does not affect existing data' => [
-            [
-                TextEdit::create(1, 5, 'foobar'),
-                TextEdit::create(2, 5, 'foobar'),
-            ],
-            [
-            ],
-            [
-                TextEdit::create(1, 5, 'foobar'),
-                TextEdit::create(2, 5, 'foobar'),
-            ],
-        ];
-
-        yield 'original edits are ordered before subsequent edits with same offset' => [
-            [
-                TextEdit::create(1, 5, 'foobar'),
-                TextEdit::create(2, 5, 'foobar'),
-            ],
-            [
-                TextEdit::create(1, 5, 'barfoo'),
-                TextEdit::create(2, 5, 'barfoo'),
-            ],
-            [
-                TextEdit::create(1, 5, 'foobar'),
-                TextEdit::create(1, 5, 'barfoo'),
-                TextEdit::create(2, 5, 'foobar'),
-                TextEdit::create(2, 5, 'barfoo'),
-            ],
-        ];
-
-        yield 'text edits are sorted' => [
-            [
-                TextEdit::create(2, 5, 'foobar'),
-                TextEdit::create(3, 5, 'foobar'),
-            ],
-            [
-                TextEdit::create(1, 5, 'barfoo'),
-                TextEdit::create(2, 5, 'barfoo'),
-            ],
-            [
-                TextEdit::create(1, 5, 'barfoo'),
-                TextEdit::create(2, 5, 'foobar'),
-                TextEdit::create(2, 5, 'barfoo'),
-                TextEdit::create(3, 5, 'foobar'),
-            ],
-        ];
-    }
-
-    /**
-     * @dataProvider provideApplyTextEdits
-     */
-    public function testApplyTextEdits(string $source, TextEdits $textEdits, string $expected): void
-    {
-        self::assertEquals(
-            $expected,
-            $textEdits->apply($source)
-        );
-    }
-
-    /**
-     * @return Generator<mixed>
-     */
-    public function provideApplyTextEdits(): Generator
-    {
-        yield 'nothing' => [
-            '',
-            TextEdits::none(),
-            ''
-        ];
-
-        yield 'insert' => [
-            '',
-            TextEdits::one(TextEdit::create(0, 0, 'hello')),
-            'hello'
-        ];
-
-        yield 'delete' => [
-            'delete',
-            TextEdits::one(TextEdit::create(0, 6, '')),
-            ''
-        ];
-
-        yield 'replace' => [
-            'delete',
-            TextEdits::one(TextEdit::create(0, 6, 'foobar')),
-            'foobar'
-        ];
-
-        yield 'multiple edits at same offset' => [
-            'hello ',
-            TextEdits::fromTextEdits([
-                TextEdit::create(6, 0, 'world'),
-                TextEdit::create(6, 0, ' how'),
-                TextEdit::create(6, 0, ' you'),
-                TextEdit::create(6, 0, ' do'),
-            ]),
-            'hello world how you do'
-        ];
-    }
-
-    /**
-     * @dataProvider provideApplyTextEditsErrors
-     */
-    public function testApplyTextEditsErrors(string $source, TextEdits $textEdits, string $expectedMessage): void
-    {
-        $this->expectExceptionMessage($expectedMessage);
-        $textEdits->apply($source);
-    }
-
-    /**
-     * @return Generator<mixed>
-     */
-    public function provideApplyTextEditsErrors(): Generator
-    {
-        yield 'shows debug information' => [
-            'hello      ',
-            TextEdits::fromTextEdits([
-                TextEdit::create(1, 4, 'world'),
-                TextEdit::create(2, 8, ' how'),
-            ]),
-            '> 1 5 "world"',
-        ];
-
-        yield 'overlapping text edits disallowed' => [
-            'hello      ',
-            TextEdits::fromTextEdits([
-                TextEdit::create(1, 4, 'world'),
-                TextEdit::create(2, 8, ' how'),
-            ]),
-            'Overlapping',
-        ];
-
-        yield 'out of bounds text edit' => [
-            'hello',
-            TextEdits::fromTextEdits([
-                TextEdit::create(10, 4, 'world'),
-            ]),
-            'Text edit end',
-        ];
-    }
-
-    public function testCreateOneConstructor(): void
-    {
-        self::assertInstanceOf(TextEdits::class, TextEdits::one(TextEdit::create(10, 10, 'f')));
-    }
-
-    public function testCreateNoneConstructor(): void
-    {
-        self::assertInstanceOf(TextEdits::class, TextEdits::none());
-    }
-
-    public function testAddTextEdit(): void
-    {
-        self::assertEquals(
-            TextEdits::none()->add(TextEdit::create(10, 10, 'asd')),
-            TextEdits::one(TextEdit::create(10, 10, 'asd'))
-        );
-    }
-}
diff --git a/src/Tests/Unit/Util/LineAtOffsetTest.php b/src/Tests/Unit/Util/LineAtOffsetTest.php
deleted file mode 100644
index ec4f023c..00000000
--- a/src/Tests/Unit/Util/LineAtOffsetTest.php
+++ /dev/null
@@ -1,85 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit\Util;
-
-use Generator;
-use OutOfBoundsException;
-use PHPUnit\Framework\TestCase;
-use Phpactor\TestUtils\ExtractOffset;
-use Phpactor\TextDocument\Util\LineAtOffset;
-
-class LineAtOffsetTest extends TestCase
-{
-    /**
-     * @dataProvider provideLineAtOffset
-     */
-    public function testLineAtOffset(string $text, string $expectedWord): void
-    {
-        [ $text, $offset ] = ExtractOffset::fromSource($text);
-        $offset = (int) $offset;
-
-        self::assertEquals($expectedWord, (new LineAtOffset())($text, --$offset));
-    }
-
-    /**
-     * @return Generator<mixed>
-     */
-    public function provideLineAtOffset(): Generator
-    {
-        yield [
-            'hello thi<>s is',
-            'hello this is',
-        ];
-        yield 'first char' => [
-            'h<>ello this is',
-            'hello this is',
-        ];
-        yield 'last char' => [
-            'hello this is<>',
-            'hello this is',
-        ];
-        yield 'offset is newline' => [
-            "hello this is\n<>",
-            'hello this is',
-        ];
-        yield [
-            <<<'EOT'
-                <?php
-
-                Hello
-                Thi<>s is my line
-
-                Thanks
-                EOT
-            , 'This is my line',
-        ];
-        yield 'multibyte 1' => [
-            <<<'EOT'
-                <?php
-
-                转注字 / 轉注字
-                转<>注字 / 轉注字
-
-                Thanks
-                EOT
-            , '转注字 / 轉注字',
-        ];
-        yield 'multibyte 2' => [
-            <<<'EOT'
-                <?php
-
-                转注字 / 轉注字
-                转注字 / 轉注字<>
-
-                Thanks
-                EOT
-            , '转注字 / 轉注字',
-        ];
-    }
-
-    public function testOutOfRange(): void
-    {
-        $this->expectException(OutOfBoundsException::class);
-        (new LineAtOffset())('a', 2);
-    }
-}
diff --git a/src/Tests/Unit/Util/LineColRangeForLineTest.php b/src/Tests/Unit/Util/LineColRangeForLineTest.php
deleted file mode 100644
index 00d4ee8b..00000000
--- a/src/Tests/Unit/Util/LineColRangeForLineTest.php
+++ /dev/null
@@ -1,77 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit\Util;
-
-use Generator;
-use PHPUnit\Framework\TestCase;
-use Phpactor\TextDocument\LineCol;
-use Phpactor\TextDocument\LineColRange;
-use Phpactor\TextDocument\Util\LineColRangeForLine;
-
-class LineColRangeForLineTest extends TestCase
-{
-    /**
-     * @dataProvider provideRangeForLine
-     */
-    public function testRangeForLine(string $text, int $lineNo, LineColRange $expected): void
-    {
-        self::assertEquals($expected, (new LineColRangeForLine())->rangeFromLine($text, $lineNo));
-    }
-
-    /**
-     * @return Generator<mixed>
-     */
-    public function provideRangeForLine(): Generator
-    {
-        yield [
-            'one',
-            1,
-            new LineColRange(
-                new LineCol(1, 1),
-                new LineCol(1, 3),
-            )
-        ];
-        yield [
-            '  one',
-            1,
-            new LineColRange(
-                new LineCol(1, 3),
-                new LineCol(1, 5),
-            )
-        ];
-        yield [
-            '  one  ',
-            1,
-            new LineColRange(
-                new LineCol(1, 3),
-                new LineCol(1, 5),
-            )
-        ];
-        yield [
-            "  one  \n  two  \n",
-            2,
-            new LineColRange(
-                new LineCol(2, 3),
-                new LineCol(2, 5),
-            )
-        ];
-
-        yield 'empty line' => [
-            "  one  \n  two  \n",
-            3,
-            new LineColRange(
-                new LineCol(3, 1),
-                new LineCol(3, 1),
-            )
-        ];
-
-        yield 'out of range' => [
-            "  one  \n  two  \n",
-            4,
-            new LineColRange(
-                new LineCol(4, 1),
-                new LineCol(4, 1),
-            )
-        ];
-    }
-}
diff --git a/src/Tests/Unit/Util/WordAtOffsetTest.php b/src/Tests/Unit/Util/WordAtOffsetTest.php
deleted file mode 100644
index 933ba21b..00000000
--- a/src/Tests/Unit/Util/WordAtOffsetTest.php
+++ /dev/null
@@ -1,118 +0,0 @@
-<?php
-
-namespace Phpactor\TextDocument\Tests\Unit\Util;
-
-use Generator;
-use PHPUnit\Framework\TestCase;
-use Phpactor\TestUtils\ExtractOffset;
-use Phpactor\TextDocument\Util\WordAtOffset;
-
-class WordAtOffsetTest extends TestCase
-{
-    /**
-     * @dataProvider provideWordAtOffset
-     */
-    public function testWordAtOffset(string $text, string $expectedWord, string $split = WordAtOffset::SPLIT_WORD): void
-    {
-        [ $text, $offset ] = ExtractOffset::fromSource($text);
-        $offset = (int)$offset;
-
-        $this->assertEquals($expectedWord, (new WordAtOffset($split))($text, $offset));
-    }
-
-    /**
-     * @return Generator<mixed>
-     */
-    public function provideWordAtOffset(): Generator
-    {
-        yield [
-            'hello thi<>s is',
-            'this',
-        ];
-
-        yield [
-            'h<>ello this is',
-            'hello',
-        ];
-        yield [
-            'hello this i<>s',
-            'is',
-        ];
-        yield [
-            'hello this is<>',
-            'is',
-        ];
-        yield [
-            'hello this is <>',
-            ' ',
-        ];
-        yield [
-            'hello this <>is',
-            ' ',
-        ];
-        yield [
-            "hello this is\nsom<>ething",
-            'something',
-        ];
-        yield [
-            " <>  hello this is\nsom<>ething",
-            ' ',
-        ];
-        yield [
-            'Reque<>st;',
-            'Request',
-        ];
-        yield [
-            "Foobar\Reque<>st;",
-            'Request',
-        ];
-        yield 'qualified name' => [
-            "Foobar\Reque<>st;",
-            'Foobar\Request',
-            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
-        ];
-        yield 'nullable type' => [
-            '?Reque<>st;',
-            'Request',
-            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
-        ];
-        yield 'trailing comma' => [
-            'Reque<>st,',
-            'Request',
-            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
-        ];
-        yield 'pipe type separator' => [
-            'Reque<>st|null,',
-            'Request',
-            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
-        ];
-        yield 'annotations' => [
-            '@Reque<>st',
-            'Request',
-            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
-        ];
-        yield 'subannotations (removing equal)' => [
-             '* input=Re<>quest::class',
-            'Request',
-            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
-        ];
-        yield 'templated type' => [
-             'array<Re<>quest>',
-            'Request',
-            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
-        ];
-        yield 'constant' => [
-            <<<'EOT'
-                    /**
-                     * @SWG\Post(
-                     *     @SWG\Response(
-                     *         response=Resp<>onse::HTTP_OK,
-                     *         description="Reset password sent successfully"
-                     * )
-                     */
-                EOT
-            , 'Response',
-            WordAtOffset::SPLIT_QUALIFIED_PHP_NAME
-        ];
-    }
-}
