Repository: phpactor/phpactor (php)

Issue:
Error when using `class:move` with FQCN
There is an error in the refactoring when using `class:move` with code that uses FQCN (Fully Quallified Class Name).

```
$ phpactor class:move src/Foo/Bar/Baz/Qux.php src/Foo/Qux.php
[REPL] /home/pierrick/test/src/Foo/Bar.php
       51:67 \Foo\Bar\Baz\Qux => Foo\Qux
[REPL] /home/pierrick/test/src/Foo/Bar/Baz/Qux.php
       63:66 Qux => Qux
[MOVE] /home/pierrick/test/src/Foo/Bar/Baz/Qux.php => /home/pierrick/test/src/Foo/Qux.php
```

The result of this refactoring for the Bar.php file is this

```diff
diff --git a/src/Foo/Bar.php b/src/Foo/Bar.php
index 9105219..bfd0da7 100644
--- a/src/Foo/Bar.php
+++ b/src/Foo/Bar.php
@@ -1,8 +1,10 @@
 <?php
 
+use Foo\Qux;
+
 class Bar
 {
-    public function __construct(\Foo\Bar\Baz\Qux $qux)
+    public function __construct(Foo\Qux $qux)
     {
     }
 }
```

The expected result should be 

```diff
-    public function __construct(\Foo\Bar\Baz\Qux $qux)
+    public function __construct(\Foo\Qux $qux)
```

Pull request description:
Keeping class references fully qualified after moving
If a class name was fully qualified before moving, then it should be fully qualified after moving as well.

Fixes #2115

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9aba0bdb..2c8b174c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,11 @@ Changelog
 
 ## master
 
+Features:
+
+  - Diagnostic for undefined variables #2209
+  - Code action to suggest fixes for undefined variables (in case of typos) #2209
+
 Bug fixes:
 
   - Fix resolution of `self` type (esp. in relation to traits) #2116
diff --git a/doc/reference/configuration.rst b/doc/reference/configuration.rst
index c850b8ab..396f980e 100644
--- a/doc/reference/configuration.rst
+++ b/doc/reference/configuration.rst
@@ -844,6 +844,22 @@ Location of the core PHP stubs - these will be scanned and cached on the first r
 **Default**: ``"%application_root%\/vendor\/jetbrains\/phpstorm-stubs"``
 
 
+.. _param_worse_reflection.diagnostics.undefined_variable.suggestion_levenshtein_disatance:
+
+
+``worse_reflection.diagnostics.undefined_variable.suggestion_levenshtein_disatance``
+""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
+
+
+Type: integer
+
+
+Levenshtein distance to use when suggesting corrections for variable names
+
+
+**Default**: ``4``
+
+
 .. _FilePathResolverExtension:
 
 
@@ -858,7 +874,7 @@ FilePathResolverExtension
 """""""""""""""""""""""""""""""""""
 
 
-**Default**: ``"\/home\/mamazu\/packages\/phpactor\/phpactor"``
+**Default**: ``"\/home\/daniel\/www\/phpactor\/phpactor"``
 
 
 .. _param_file_path_resolver.app_name:
diff --git a/lib/Amp/Process/ProcessBuilder.php b/lib/Amp/Process/ProcessBuilder.php
new file mode 100644
index 00000000..ca3fea14
--- /dev/null
+++ b/lib/Amp/Process/ProcessBuilder.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Phpactor\Amp\Process;
+
+use Amp\Process\Process;
+
+final class ProcessBuilder
+{
+    /**
+     * @var array<string,mixed>
+     */
+    private array $env = [];
+
+    private ?string $cwd = null;
+
+    private bool $mergeParentEnv = false;
+
+    /**
+     * @param list<string> $args
+     */
+    private function __construct(private array $args)
+    {
+    }
+
+    /**
+     * @param list<string> $args
+     */
+    public function cmd(array $args): self
+    {
+        $this->args = $args;
+        return $this;
+    }
+
+    public function cwd(string $cwd): self
+    {
+        $this->cwd = $cwd;
+        return $this;
+    }
+    /**
+     * @param array<string,mixed> $env
+     */
+    public function env(array $env): self
+    {
+        $this->env = $env;
+        return $this;
+    }
+
+    public function mergeParentEnv(): self
+    {
+        $this->mergeParentEnv = true;
+        return $this;
+    }
+
+    public function build(): Process
+    {
+        $env = $this->env;
+        if ($this->mergeParentEnv) {
+            $env = array_merge(getenv(), $env);
+        }
+        return new Process($this->args, $this->cwd, $env);
+    }
+
+    /**
+     * @param list<string> $args
+     */
+    public static function create(array $args): self
+    {
+        return new self($args);
+    }
+}
diff --git a/lib/ClassMover/Adapter/TolerantParser/TolerantClassReplacer.php b/lib/ClassMover/Adapter/TolerantParser/TolerantClassReplacer.php
index 4347773e..a5a4c1b5 100644
--- a/lib/ClassMover/Adapter/TolerantParser/TolerantClassReplacer.php
+++ b/lib/ClassMover/Adapter/TolerantParser/TolerantClassReplacer.php
@@ -33,7 +33,15 @@ class TolerantClassReplacer implements ClassReplacer
         $replacedImports = [];
 
         foreach ($classRefList as $classRef) {
-            assert($classRef instanceof ClassReference);
+            if ($classRef->name()->wasFullyQualified()) {
+                $edits[] = TextEdit::create(
+                    $classRef->position()->start(),
+                    $classRef->position()->length(),
+                    '\\'.$newName->__toString()
+                );
+                continue;
+            }
+
             if (false === $importClass) {
                 $importClass = $this->shouldImportClass($classRef, $originalName);
             }
@@ -59,6 +67,7 @@ class TolerantClassReplacer implements ClassReplacer
         });
 
         $edits = TextEdits::fromTextEdits($edits);
+        $source = Code::fromString($source);
         if (true === $importClass) {
             $edits = $edits->merge($this->addUseStatement($source, $newName));
         }
@@ -103,13 +112,16 @@ class TolerantClassReplacer implements ClassReplacer
         return $classRef->isClassDeclaration() && $classRef->fullName()->equals($originalName);
     }
 
-    private function addUseStatement(TextDocument $source, FullyQualifiedName $newName): TextEdits
+    private function addUseStatement(Code $source, FullyQualifiedName $newName): TextEdits
     {
-        return $this->updater->textEditsFor(SourceCodeBuilder::create()->use($newName->__toString())->build(), Code::fromString($source->__toString()));
+        return $this->updater->textEditsFor(SourceCodeBuilder::create()->use($newName->__toString())->build(), $source);
     }
 
-    private function addNamespace(TextDocument $source, QualifiedName $qualifiedName): TextEdits
+    private function addNamespace(Code $source, QualifiedName $qualifiedName): TextEdits
     {
-        return $this->updater->textEditsFor(SourceCodeBuilder::create()->namespace($qualifiedName->__toString())->build(), Code::fromString($source->__toString()));
+        return $this->updater->textEditsFor(
+            SourceCodeBuilder::create()->namespace($qualifiedName->__toString())->build(),
+            $source
+        );
     }
 }
diff --git a/lib/ClassMover/Domain/Name/QualifiedName.php b/lib/ClassMover/Domain/Name/QualifiedName.php
index d8e95462..37549a45 100644
--- a/lib/ClassMover/Domain/Name/QualifiedName.php
+++ b/lib/ClassMover/Domain/Name/QualifiedName.php
@@ -6,11 +6,16 @@ use InvalidArgumentException;
 
 class QualifiedName
 {
+    private bool $fullyQualified = false;
+
     /**
      * @param non-empty-array<string> $parts
      */
     protected function __construct(protected array $parts)
     {
+        if (count($this->parts) > 1) {
+            $this->fullyQualified = $this->parts[0] === '';
+        }
     }
 
     public function __toString(): string
@@ -18,6 +23,11 @@ class QualifiedName
         return implode('\\', $this->parts);
     }
 
+    public function wasFullyQualified(): bool
+    {
+        return $this->fullyQualified;
+    }
+
     public static function root(): QualifiedName
     {
         return new static([]);
diff --git a/lib/Extension/LanguageServerCodeTransform/CodeAction/CorrectUndefinedVariableCodeAction.php b/lib/Extension/LanguageServerCodeTransform/CodeAction/CorrectUndefinedVariableCodeAction.php
new file mode 100644
index 00000000..104ea5aa
--- /dev/null
+++ b/lib/Extension/LanguageServerCodeTransform/CodeAction/CorrectUndefinedVariableCodeAction.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Phpactor\Extension\LanguageServerCodeTransform\CodeAction;
+
+use Amp\CancellationToken;
+use Amp\Promise;
+use Phpactor\Extension\LanguageServerBridge\Converter\RangeConverter;
+use Phpactor\Extension\LanguageServerBridge\Converter\TextDocumentConverter;
+use Phpactor\LanguageServerProtocol\CodeAction;
+use Phpactor\LanguageServerProtocol\CodeActionKind;
+use Phpactor\LanguageServerProtocol\OptionalVersionedTextDocumentIdentifier;
+use Phpactor\LanguageServerProtocol\Range;
+use Phpactor\LanguageServerProtocol\TextDocumentEdit;
+use Phpactor\LanguageServerProtocol\TextDocumentItem;
+use Phpactor\LanguageServerProtocol\TextEdit;
+use Phpactor\LanguageServerProtocol\WorkspaceEdit;
+use Phpactor\LanguageServer\Core\CodeAction\CodeActionProvider;
+use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\UndefinedVariableDiagnostic;
+use Phpactor\WorseReflection\Core\Reflector\SourceCodeReflector;
+use function Amp\call;
+
+class CorrectUndefinedVariableCodeAction implements CodeActionProvider
+{
+    public const KIND = 'quickfix.correct_variable_name';
+
+    public function __construct(private SourceCodeReflector $reflector)
+    {
+    }
+
+    public function provideActionsFor(TextDocumentItem $textDocument, Range $range, CancellationToken $cancel): Promise
+    {
+        return call(function () use ($textDocument, $cancel) {
+            $actions = [];
+            foreach ((yield $this->reflector->diagnostics(
+                TextDocumentConverter::fromLspTextItem($textDocument)
+            ))->byClass(
+                UndefinedVariableDiagnostic::class
+            ) as $diagnostic) {
+                assert($diagnostic instanceof UndefinedVariableDiagnostic);
+                foreach ($diagnostic->suggestions() as $suggestion) {
+                    if ($cancel->isRequested()) {
+                        return $actions;
+                    }
+                    $actions[] =  new CodeAction(
+                        title: sprintf('Correct undefined variable "$%s" to "$%s"', $diagnostic->undefinedVariableName(), $suggestion),
+                        kind: CodeActionKind::QUICK_FIX,
+                        diagnostics: null,
+                        isPreferred: null,
+                        disabled: null,
+                        edit: new WorkspaceEdit(
+                            documentChanges: [
+                                new TextDocumentEdit(
+                                    new OptionalVersionedTextDocumentIdentifier($textDocument->uri, $textDocument->version),
+                                    [
+                                        new TextEdit(
+                                            range: RangeConverter::toLspRange($diagnostic->range(), $textDocument->text),
+                                            newText: '$' . $suggestion
+                                        ),
+                                    ]
+                                )
+                            ],
+                        ),
+                        command: null,
+                    );
+                }
+            }
+            return $actions;
+        });
+    }
+
+    public function kinds(): array
+    {
+        return [
+            self::KIND
+        ];
+    }
+
+    public function describe(): string
+    {
+        return 'correct undefined variable name';
+    }
+}
diff --git a/lib/Extension/LanguageServerCodeTransform/LanguageServerCodeTransformExtension.php b/lib/Extension/LanguageServerCodeTransform/LanguageServerCodeTransformExtension.php
index 4f7e95eb..2dd6be73 100644
--- a/lib/Extension/LanguageServerCodeTransform/LanguageServerCodeTransformExtension.php
+++ b/lib/Extension/LanguageServerCodeTransform/LanguageServerCodeTransformExtension.php
@@ -28,6 +28,7 @@ use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\ExtractConstantPro
 use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\ExtractExpressionProvider;
 use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\ExtractMethodProvider;
 use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\FillObjectProvider;
+use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\CorrectUndefinedVariableCodeAction;
 use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\GenerateConstructorProvider;
 use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\GenerateDecoratorProvider;
 use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\GenerateMethodProvider;
@@ -318,6 +319,14 @@ class LanguageServerCodeTransformExtension implements Extension
             LanguageServerExtension::TAG_CODE_ACTION_PROVIDER => []
         ]);
 
+        $container->register(CorrectUndefinedVariableCodeAction::class, function (Container $container) {
+            return new CorrectUndefinedVariableCodeAction(
+                $container->expect(WorseReflectionExtension::SERVICE_REFLECTOR, Reflector::class),
+            );
+        }, [
+            LanguageServerExtension::TAG_CODE_ACTION_PROVIDER => []
+        ]);
+
         $container->register(TransformerCodeActionPovider::class.'add_missing_properties', function (Container $container) {
             return new TransformerCodeActionPovider(
                 $container->get('code_transform.transformers'),
diff --git a/lib/Extension/LanguageServerPhpCsFixer/Model/PhpCsFixerProcess.php b/lib/Extension/LanguageServerPhpCsFixer/Model/PhpCsFixerProcess.php
index 85babdb2..5f92442b 100644
--- a/lib/Extension/LanguageServerPhpCsFixer/Model/PhpCsFixerProcess.php
+++ b/lib/Extension/LanguageServerPhpCsFixer/Model/PhpCsFixerProcess.php
@@ -4,6 +4,7 @@ namespace Phpactor\Extension\LanguageServerPhpCsFixer\Model;
 
 use Amp\Process\Process;
 use Amp\Promise;
+use Phpactor\Amp\Process\ProcessBuilder;
 use Phpactor\Extension\LanguageServerPhpCsFixer\Exception\PhpCsFixerError;
 use Psr\Log\LoggerInterface;
 use function Amp\ByteStream\buffer;
@@ -93,7 +94,7 @@ class PhpCsFixerProcess
     public function run(string ...$args): Promise
     {
         return call(function () use ($args) {
-            $process = new Process([$this->binPath, ...$args], null, $this->env);
+            $process = ProcessBuilder::create([$this->binPath, ...$args])->mergeParentEnv()->env($this->env)->build();
             yield $process->start();
 
             $process->join()
diff --git a/lib/Extension/WorseReflection/WorseReflectionExtension.php b/lib/Extension/WorseReflection/WorseReflectionExtension.php
index 6e027444..481bec5b 100644
--- a/lib/Extension/WorseReflection/WorseReflectionExtension.php
+++ b/lib/Extension/WorseReflection/WorseReflectionExtension.php
@@ -15,6 +15,7 @@ use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\MissingDocblockPa
 use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\MissingDocblockReturnTypeProvider;
 use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\MissingMethodProvider;
 use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\MissingReturnTypeProvider;
+use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\UndefinedVariableProvider;
 use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\UnresolvableNameProvider;
 use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\UnusedImportProvider;
 use Phpactor\WorseReflection\Core\Cache;
@@ -47,6 +48,7 @@ class WorseReflectionExtension implements Extension
     const TAG_DIAGNOSTIC_PROVIDER = 'worse_reflection.diagnostics_provider';
     const TAG_MEMBER_TYPE_RESOLVER = 'worse_reflection.member_type_resolver';
     const PARAM_IMPORT_GLOBALS = 'language_server_code_transform.import_globals';
+    const PARAM_UNDEFINED_VAR_LEVENSHTEIN = 'worse_reflection.diagnostics.undefined_variable.suggestion_levenshtein_disatance';
 
     public function configure(Resolver $schema): void
     {
@@ -57,10 +59,12 @@ class WorseReflectionExtension implements Extension
             self::PARAM_ENABLE_CONTEXT_LOCATION => true,
             self::PARAM_STUB_CACHE_DIR => '%cache%/worse-reflection',
             self::PARAM_STUB_DIR => '%application_root%/vendor/jetbrains/phpstorm-stubs',
+            self::PARAM_UNDEFINED_VAR_LEVENSHTEIN => 4,
         ]);
         $schema->setDescriptions([
             self::PARAM_ENABLE_CACHE => 'If reflection caching should be enabled',
             self::PARAM_CACHE_LIFETIME => 'If caching is enabled, limit the amount of time a cache entry can stay alive',
+            self::PARAM_UNDEFINED_VAR_LEVENSHTEIN => 'Levenshtein distance to use when suggesting corrections for variable names',
             self::PARAM_ENABLE_CONTEXT_LOCATION => <<<'EOT'
                 If source code is passed to a ``Reflector`` then temporarily make it available as a
                 source location. Note this should NOT be enabled if the source code can be
@@ -71,6 +75,9 @@ class WorseReflectionExtension implements Extension
             self::PARAM_STUB_CACHE_DIR => 'Cache directory for stubs',
             self::PARAM_IMPORT_GLOBALS => 'Show hints for non-imported global classes and functions',
         ]);
+        $schema->setTypes([
+            self::PARAM_UNDEFINED_VAR_LEVENSHTEIN => 'integer',
+        ]);
     }
 
     public function load(ContainerBuilder $container): void
@@ -200,6 +207,9 @@ class WorseReflectionExtension implements Extension
         $container->register(DeprecatedUsageDiagnosticProvider::class, function (Container $container) {
             return new DeprecatedUsageDiagnosticProvider();
         }, [ self::TAG_DIAGNOSTIC_PROVIDER => []]);
+        $container->register(UndefinedVariableProvider::class, function (Container $container) {
+            return new UndefinedVariableProvider($container->parameter(self::PARAM_UNDEFINED_VAR_LEVENSHTEIN)->int());
+        }, [ self::TAG_DIAGNOSTIC_PROVIDER => []]);
     }
 
     private function registerCommands(ContainerBuilder $container): void
diff --git a/lib/WorseReflection/Bridge/TolerantParser/Diagnostics/UndefinedVariableDiagnostic.php b/lib/WorseReflection/Bridge/TolerantParser/Diagnostics/UndefinedVariableDiagnostic.php
new file mode 100644
index 00000000..c0af5ff8
--- /dev/null
+++ b/lib/WorseReflection/Bridge/TolerantParser/Diagnostics/UndefinedVariableDiagnostic.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics;
+
+use Phpactor\TextDocument\ByteOffsetRange;
+use Phpactor\WorseReflection\Core\Diagnostic;
+use Phpactor\WorseReflection\Core\DiagnosticSeverity;
+
+class UndefinedVariableDiagnostic implements Diagnostic
+{
+    /**
+     * @param list<string> $suggestions
+     */
+    public function __construct(private ByteOffsetRange $byteOffsetRange, private string $varName, private array $suggestions)
+    {
+    }
+
+    public function range(): ByteOffsetRange
+    {
+        return $this->byteOffsetRange;
+    }
+
+    public function severity(): DiagnosticSeverity
+    {
+        return DiagnosticSeverity::ERROR();
+    }
+
+    public function message(): string
+    {
+        if(count($this->suggestions) === 0) {
+            return sprintf(
+                'Undefined variable "$%s"',
+                $this->varName
+            );
+        }
+        $suggestString = implode('", "$', $this->suggestions);
+        if(count($this->suggestions) === 1) {
+            return sprintf(
+                'Undefined variable "$%s", did you mean "$%s"',
+                $this->varName,
+                $suggestString
+            );
+        }
+        return sprintf(
+            'Undefined variable "$%s", did you mean one of "$%s"',
+            $this->varName,
+            $suggestString
+        );
+    }
+    /**
+     * @return list<string>
+     */
+    public function suggestions(): array
+    {
+        return $this->suggestions;
+    }
+
+    public function undefinedVariableName(): string
+    {
+        return $this->varName;
+    }
+
+}
diff --git a/lib/WorseReflection/Bridge/TolerantParser/Diagnostics/UndefinedVariableProvider.php b/lib/WorseReflection/Bridge/TolerantParser/Diagnostics/UndefinedVariableProvider.php
new file mode 100644
index 00000000..047a7804
--- /dev/null
+++ b/lib/WorseReflection/Bridge/TolerantParser/Diagnostics/UndefinedVariableProvider.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics;
+
+use Microsoft\PhpParser\Node;
+use Microsoft\PhpParser\Node\Expression\Variable;
+use Microsoft\PhpParser\Node\PropertyDeclaration;
+use Phpactor\WorseReflection\Core\DiagnosticProvider;
+use Phpactor\WorseReflection\Core\Inference\Frame;
+use Phpactor\WorseReflection\Core\Inference\NodeContextResolver;
+use Phpactor\WorseReflection\Core\Inference\Variable as PhpactorVariable;
+use Phpactor\WorseReflection\Core\Util\NodeUtil;
+
+class UndefinedVariableProvider implements DiagnosticProvider
+{
+    public function __construct(private int $suggestionLevensteinDistance = 4)
+    {
+    }
+
+    public function enter(NodeContextResolver $resolver, Frame $frame, Node $node): iterable
+    {
+        if (!$node instanceof Variable) {
+            return [];
+        }
+        if ($node->parent?->parent instanceof PropertyDeclaration) {
+            return [];
+        }
+        foreach ($frame->locals()->byName($node->getName()) as $variable) {
+            if ($variable->wasDefinition()) {
+                return [];
+            }
+        }
+
+        $name = $node->getName();
+        yield new UndefinedVariableDiagnostic(
+            NodeUtil::byteOffsetRangeForNode($node),
+            $name,
+            array_filter(array_map(function (PhpactorVariable $var) {
+                return $var->name();
+            }, $frame->locals()->definitionsOnly()->mostRecent()->toArray()), function (string $candidate) use ($name) {
+                return levenshtein($name, $candidate) < $this->suggestionLevensteinDistance;
+            })
+        );
+    }
+
+    public function exit(NodeContextResolver $resolver, Frame $frame, Node $node): iterable
+    {
+        return [];
+    }
+}
diff --git a/lib/WorseReflection/Core/Inference/Assignments.php b/lib/WorseReflection/Core/Inference/Assignments.php
index c0ae1a94..ed3f5e2f 100644
--- a/lib/WorseReflection/Core/Inference/Assignments.php
+++ b/lib/WorseReflection/Core/Inference/Assignments.php
@@ -214,6 +214,13 @@ abstract class Assignments implements Countable, IteratorAggregate
         }));
     }
 
+    public function definitionsOnly(): Assignments
+    {
+        return new static(array_filter($this->variables, function (Variable $v) {
+            return $v->wasDefinition();
+        }));
+    }
+
     public function lastOrNull(): ?Variable
     {
         $last = end($this->variables);
@@ -239,4 +246,12 @@ abstract class Assignments implements Countable, IteratorAggregate
 
         return new static($mostRecent);
     }
+
+    /**
+     * @return Variable[]
+     */
+    public function toArray(): array
+    {
+        return $this->variables;
+    }
 }
diff --git a/lib/WorseReflection/Core/Inference/Resolver/ForeachStatementResolver.php b/lib/WorseReflection/Core/Inference/Resolver/ForeachStatementResolver.php
index 2698b757..4288565a 100644
--- a/lib/WorseReflection/Core/Inference/Resolver/ForeachStatementResolver.php
+++ b/lib/WorseReflection/Core/Inference/Resolver/ForeachStatementResolver.php
@@ -91,7 +91,7 @@ class ForeachStatementResolver implements Resolver
             $context = $context->withType($this->resolveKeyType($type));
         }
 
-        $frame->locals()->set(WorseVariable::fromSymbolContext($context));
+        $frame->locals()->set(WorseVariable::fromSymbolContext($context)->asDefinition());
     }
 
     private function valueFromVariable(Variable $expression, ForeachStatement $node, NodeContext $nodeContext, Frame $frame): void
@@ -120,7 +120,7 @@ class ForeachStatementResolver implements Resolver
             $context = $context->withType($this->resolveValueType($type));
         }
 
-        $frame->locals()->set(WorseVariable::fromSymbolContext($context));
+        $frame->locals()->set(WorseVariable::fromSymbolContext($context)->asDefinition());
     }
 
     private function valueFromArrayCreation(
@@ -215,7 +215,7 @@ class ForeachStatementResolver implements Resolver
                 if ($previous = $frame->locals()->byName($local->name())->lessThan($local->offset())->lastOrNull()) {
                     $type = $previous->type()->addType($local->type())->reduce();
                     $frame->locals()->set(
-                        $previous->withType($type)->withOffset($compoundStatement->closeBrace->getEndPosition())
+                        $previous->withType($type)->withOffset($compoundStatement->closeBrace->getEndPosition())->asDefinition()
                     );
                 }
             }
diff --git a/lib/WorseReflection/Core/Inference/Resolver/VariableResolver.php b/lib/WorseReflection/Core/Inference/Resolver/VariableResolver.php
index 8411061a..d0b911f2 100644
--- a/lib/WorseReflection/Core/Inference/Resolver/VariableResolver.php
+++ b/lib/WorseReflection/Core/Inference/Resolver/VariableResolver.php
@@ -143,9 +143,9 @@ class VariableResolver implements Resolver
     {
         foreach ($frame->locals()->byName($context->symbol()->name())->equalTo($context->symbol()->position()->start()->toInt()) as $existing) {
             assert($existing instanceof PhpactorVariable);
-            $frame->locals()->replace($existing, $existing->withType($context->type()));
+            $frame->locals()->replace($existing, $existing->withType($context->type())->asDefinition());
             return;
         }
-        $frame->locals()->set(PhpactorVariable::fromSymbolContext($context));
+        $frame->locals()->set(PhpactorVariable::fromSymbolContext($context)->asDefinition());
     }
 }
diff --git a/lib/WorseReflection/Core/Inference/Variable.php b/lib/WorseReflection/Core/Inference/Variable.php
index d90b8bc4..f9ca08dc 100644
--- a/lib/WorseReflection/Core/Inference/Variable.php
+++ b/lib/WorseReflection/Core/Inference/Variable.php
@@ -14,7 +14,8 @@ final class Variable
         private int $offset,
         private Type $type,
         private ?Type $classType = null,
-        private bool $wasAssigned = false
+        private bool $wasAssigned = false,
+        private bool $wasDefined = false
     ) {
         $this->name = ltrim($name, '$');
     }
@@ -58,7 +59,12 @@ final class Variable
 
     public function asAssignment(): self
     {
-        return new self($this->name, $this->offset, $this->type, $this->classType, true);
+        return new self($this->name, $this->offset, $this->type, $this->classType, true, true);
+    }
+
+    public function asDefinition(): self
+    {
+        return new self($this->name, $this->offset, $this->type, $this->classType, false, true);
     }
 
     public function type(): Type
@@ -86,6 +92,11 @@ final class Variable
         return $this->wasAssigned;
     }
 
+    public function wasDefinition(): bool
+    {
+        return $this->wasDefined;
+    }
+
     public function key(): string
     {
         return sprintf('%s-%s', $this->name(), $this->offset());
diff --git a/lib/WorseReflection/Core/Inference/Walker/FunctionLikeWalker.php b/lib/WorseReflection/Core/Inference/Walker/FunctionLikeWalker.php
index eeb9cf44..430f7084 100644
--- a/lib/WorseReflection/Core/Inference/Walker/FunctionLikeWalker.php
+++ b/lib/WorseReflection/Core/Inference/Walker/FunctionLikeWalker.php
@@ -108,7 +108,7 @@ class FunctionLikeWalker implements Walker
                 ]
             );
 
-            $frame->locals()->set(Variable::fromSymbolContext($context));
+            $frame->locals()->set(Variable::fromSymbolContext($context)->asDefinition());
         }
     }
 
@@ -160,7 +160,7 @@ class FunctionLikeWalker implements Walker
             $variableContext = $variableContext
                 ->withType($variable->type());
 
-            $frame->locals()->set(Variable::fromSymbolContext($variableContext));
+            $frame->locals()->set(Variable::fromSymbolContext($variableContext)->asDefinition());
         }
     }
 
@@ -177,7 +177,7 @@ class FunctionLikeWalker implements Walker
         );
 
         // add this and self
-        $frame->locals()->set(Variable::fromSymbolContext($context));
+        $frame->locals()->set(Variable::fromSymbolContext($context)->asDefinition());
 
         if (!$classType instanceof ReflectedClassType) {
             return;
diff --git a/lib/WorseReflection/Core/Util/NodeUtil.php b/lib/WorseReflection/Core/Util/NodeUtil.php
index 1756b4e2..a6b51bb3 100644
--- a/lib/WorseReflection/Core/Util/NodeUtil.php
+++ b/lib/WorseReflection/Core/Util/NodeUtil.php
@@ -18,6 +18,7 @@ use Microsoft\PhpParser\Node\Statement\TraitDeclaration;
 use Microsoft\PhpParser\ResolvedName;
 use Microsoft\PhpParser\Token;
 use Microsoft\PhpParser\TokenKind;
+use Phpactor\TextDocument\ByteOffsetRange;
 use Phpactor\WorseReflection\Core\ClassName;
 use Phpactor\WorseReflection\Core\Type;
 use Phpactor\WorseReflection\Core\TypeFactory;
@@ -358,4 +359,9 @@ class NodeUtil
 
         return false;
     }
+
+    public static function byteOffsetRangeForNode(Variable $node): ByteOffsetRange
+    {
+        return ByteOffsetRange::fromInts($node->getStartPosition(), $node->getEndPosition());
+    }
 }


Test patch:
diff --git a/lib/Amp/Tests/Process/ProcessBuilderTest.php b/lib/Amp/Tests/Process/ProcessBuilderTest.php
new file mode 100644
index 00000000..5395ea5a
--- /dev/null
+++ b/lib/Amp/Tests/Process/ProcessBuilderTest.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Phpactor\Amp\Tests\Process;
+
+use PHPUnit\Framework\TestCase;
+use Phpactor\Amp\Process\ProcessBuilder;
+use function Amp\ByteStream\buffer;
+use function Amp\Promise\wait;
+
+class ProcessBuilderTest extends TestCase
+{
+    const PARENT_PROCESS_ENV_VAR = 'thisistheparentprocess';
+
+    public function testBuildProcess(): void
+    {
+        $process = ProcessBuilder::create(['export'])->build();
+        $pid = wait($process->start());
+        $exitCode = wait($process->join());
+        self::assertEquals(0, $exitCode);
+    }
+
+    public function testDoesNotMergeEnvByDefaultWhenEnvVarsPassed(): void
+    {
+        putenv('FOO='.self::PARENT_PROCESS_ENV_VAR);
+        $process = ProcessBuilder::create(['export'])->env(['ENV'=> 'myenvvar'])->build();
+        $pid = wait($process->start());
+        $exitCode = wait($process->join());
+        self::assertEquals(0, $exitCode);
+        /** @var string $out @phpstan-ignore-next-line */
+        $out = wait(buffer($process->getStdout()));
+        self::assertStringContainsString('myenvvar', $out);
+        self::assertStringNotContainsString(self::PARENT_PROCESS_ENV_VAR, $out);
+        putenv('FOO');
+    }
+
+    public function testInheritsWhenInstructedToWhenEnvVarsPassed(): void
+    {
+        putenv('FOO='.self::PARENT_PROCESS_ENV_VAR);
+        $process = ProcessBuilder::create(['export'])->env(['ENV'=> 'myenvvar'])->mergeParentEnv()->build();
+        $pid = wait($process->start());
+        $exitCode = wait($process->join());
+        self::assertEquals(0, $exitCode);
+        /** @var string $out @phpstan-ignore-next-line */
+        $out = wait(buffer($process->getStdout()));
+        self::assertStringContainsString('myenvvar', $out);
+        self::assertStringContainsString(self::PARENT_PROCESS_ENV_VAR, $out);
+        putenv('FOO');
+    }
+}
diff --git a/lib/ClassMover/Tests/Adapter/TolerantParser/TolerantClassReplacerTest.php b/lib/ClassMover/Tests/Adapter/TolerantParser/TolerantClassReplacerTest.php
index a7181451..4b9df9d1 100644
--- a/lib/ClassMover/Tests/Adapter/TolerantParser/TolerantClassReplacerTest.php
+++ b/lib/ClassMover/Tests/Adapter/TolerantParser/TolerantClassReplacerTest.php
@@ -219,6 +219,20 @@ class TolerantClassReplacerTest extends TestCase
                     }
                     EOT
             ],
+            'FQN stays fully qualified' => [
+                'Example12.php',
+                'FQN\Class',
+                'OtherFQN\ClassTwo',
+                <<<EOT
+                    <?php
+                    class Generic
+                    {
+                        public function __construct(\OtherFQN\ClassTwo \$test)
+                        {
+                        }
+                    }
+                    EOT,
+            ]
         ];
     }
 }
diff --git a/lib/ClassMover/Tests/Adapter/TolerantParser/examples/Example12.php b/lib/ClassMover/Tests/Adapter/TolerantParser/examples/Example12.php
new file mode 100644
index 00000000..32a3f49a
--- /dev/null
+++ b/lib/ClassMover/Tests/Adapter/TolerantParser/examples/Example12.php
@@ -0,0 +1,7 @@
+<?php
+class Generic
+{
+    public function __construct(\FQN\Class $test)
+    {
+    }
+}
diff --git a/lib/Extension/Configuration/Tests/Unit/Model/JsonSchemaBuilderTest.php b/lib/Extension/Configuration/Tests/Unit/Model/JsonSchemaBuilderTest.php
index e15899c8..6863ce61 100644
--- a/lib/Extension/Configuration/Tests/Unit/Model/JsonSchemaBuilderTest.php
+++ b/lib/Extension/Configuration/Tests/Unit/Model/JsonSchemaBuilderTest.php
@@ -16,7 +16,6 @@ class JsonSchemaBuilderTest extends TestCase
         $extensions[] = get_class($this->createExtension1());
 
         $schema = (new JsonSchemaBuilder('test', $extensions))->dump();
-        file_put_contents('foo', $schema);
         self::assertEquals(<<<'EOT'
             {
                 "$schema": "https:\/\/json-schema.org\/draft-07\/schema",
diff --git a/lib/Extension/LanguageServerCodeTransform/Tests/Unit/CodeAction/CorrectUndefinedVariableCodeActionTest.php b/lib/Extension/LanguageServerCodeTransform/Tests/Unit/CodeAction/CorrectUndefinedVariableCodeActionTest.php
new file mode 100644
index 00000000..34654774
--- /dev/null
+++ b/lib/Extension/LanguageServerCodeTransform/Tests/Unit/CodeAction/CorrectUndefinedVariableCodeActionTest.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Phpactor\Extension\LanguageServerCodeTransform\Tests\Unit\CodeAction;
+
+use Amp\CancellationTokenSource;
+use PHPUnit\Framework\TestCase;
+use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\CorrectUndefinedVariableCodeAction;
+use Phpactor\LanguageServer\Test\ProtocolFactory;
+use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\UndefinedVariableProvider;
+use Phpactor\WorseReflection\ReflectorBuilder;
+use function Amp\Promise\wait;
+
+class CorrectUndefinedVariableCodeActionTest extends TestCase
+{
+    public function testProvideActions(): void
+    {
+        $textDocument = ProtocolFactory::textDocumentItem('file:///foo', "<?php \$foo = 'bar'; \$fo;");
+        $reflector = ReflectorBuilder::create()->addDiagnosticProvider(new UndefinedVariableProvider())->build();
+        $range = ProtocolFactory::range(0, 0, 10, 10);
+        $cancel = (new CancellationTokenSource())->getToken();
+        $actions = wait((new CorrectUndefinedVariableCodeAction($reflector))->provideActionsFor($textDocument, $range, $cancel));
+        self::assertCount(1, $actions);
+    }
+}
diff --git a/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/UndefinedVariable.test b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/UndefinedVariable.test
new file mode 100644
index 00000000..7e4a6236
--- /dev/null
+++ b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/UndefinedVariable.test
@@ -0,0 +1,7 @@
+<?php
+
+$zebra = 'one';
+$foa = 'two';
+
+if ($foo) {
+}
diff --git a/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/UndefinedVariableButProprety.test b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/UndefinedVariableButProprety.test
new file mode 100644
index 00000000..960755b7
--- /dev/null
+++ b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/UndefinedVariableButProprety.test
@@ -0,0 +1,5 @@
+<?php
+
+class Foo {
+    public $foo;
+}
diff --git a/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/UndefinedVariableMany.test b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/UndefinedVariableMany.test
new file mode 100644
index 00000000..ed2fdc97
--- /dev/null
+++ b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/UndefinedVariableMany.test
@@ -0,0 +1,8 @@
+<?php
+
+$foz = 'one';
+$foa = 'two';
+$fob = 'three';
+
+if ($foo) {
+}
diff --git a/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableFromVarDoc.test b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableFromVarDoc.test
new file mode 100644
index 00000000..f4ad09bb
--- /dev/null
+++ b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableFromVarDoc.test
@@ -0,0 +1,7 @@
+<?php
+
+/** @var string $zebra */
+$zebra = 'one';
+
+if ($zebra) {
+}
diff --git a/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableHasBeenAssigned.test b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableHasBeenAssigned.test
new file mode 100644
index 00000000..ad618cce
--- /dev/null
+++ b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableHasBeenAssigned.test
@@ -0,0 +1,9 @@
+<?php
+
+class Foo {
+    public function foo(): void
+    {
+        $this->bar;
+    }
+
+}
diff --git a/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableIsParameter.test b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableIsParameter.test
new file mode 100644
index 00000000..3993abb9
--- /dev/null
+++ b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableIsParameter.test
@@ -0,0 +1,11 @@
+<?php
+
+function ($foo) {
+    return $foo;
+}
+
+class Foo {
+    public function foo($foo) {
+        return $foo;
+    }
+}
diff --git a/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableIsThis.test b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableIsThis.test
new file mode 100644
index 00000000..2929d8c2
--- /dev/null
+++ b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableIsThis.test
@@ -0,0 +1,7 @@
+<?php
+
+$zebra = 'one';
+$foa = 'two';
+
+if ($foa) {
+}
diff --git a/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableUndefinedWithNoSuggestions.test b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableUndefinedWithNoSuggestions.test
new file mode 100644
index 00000000..180e2a79
--- /dev/null
+++ b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProvider/VariableUndefinedWithNoSuggestions.test
@@ -0,0 +1,4 @@
+<?php
+
+if ($foa) {
+}
diff --git a/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProviderTest.php b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProviderTest.php
new file mode 100644
index 00000000..bdcb1f09
--- /dev/null
+++ b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/UndefinedVariableProviderTest.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Phpactor\WorseReflection\Tests\Integration\Bridge\TolerantParser\Diagonstics;
+
+use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\UndefinedVariableProvider;
+use Phpactor\WorseReflection\Core\Diagnostic;
+use Phpactor\WorseReflection\Core\DiagnosticProvider;
+use Phpactor\WorseReflection\Core\Diagnostics;
+
+class UndefinedVariableProviderTest extends DiagnosticsTestCase
+{
+    /**
+     * @param Diagnostics<Diagnostic> $diagnostics
+     */
+    public function checkUndefinedVariable(Diagnostics $diagnostics): void
+    {
+        self::assertCount(1, $diagnostics);
+        self::assertEquals('Undefined variable "$foo", did you mean "$foa"', $diagnostics->at(0)->message());
+    }
+
+    /**
+     * @param Diagnostics<Diagnostic> $diagnostics
+     */
+    public function checkVariableHasBeenAssigned(Diagnostics $diagnostics): void
+    {
+        self::assertCount(0, $diagnostics);
+    }
+
+    /**
+     * @param Diagnostics<Diagnostic> $diagnostics
+     */
+    public function checkVariableIsThis(Diagnostics $diagnostics): void
+    {
+        self::assertCount(0, $diagnostics);
+    }
+
+    /**
+     * @param Diagnostics<Diagnostic> $diagnostics
+     */
+    public function checkVariableIsParameter(Diagnostics $diagnostics): void
+    {
+        self::assertCount(0, $diagnostics);
+    }
+    /**
+     * @param Diagnostics<Diagnostic> $diagnostics
+     */
+    public function checkVariableUndefinedWithNoSuggestions(Diagnostics $diagnostics): void
+    {
+        self::assertCount(1, $diagnostics);
+        self::assertEquals('Undefined variable "$foa"', $diagnostics->at(0)->message());
+    }
+
+    /**
+     * @param Diagnostics<Diagnostic> $diagnostics
+     */
+    public function checkUndefinedVariableMany(Diagnostics $diagnostics): void
+    {
+        self::assertCount(1, $diagnostics);
+        self::assertEquals('Undefined variable "$foo", did you mean one of "$foz", "$foa", "$fob"', $diagnostics->at(0)->message());
+    }
+    /**
+     * @param Diagnostics<Diagnostic> $diagnostics
+     */
+    public function checkUndefinedVariableButProprety(Diagnostics $diagnostics): void
+    {
+        self::assertCount(0, $diagnostics);
+    }
+    /**
+     * @param Diagnostics<Diagnostic> $diagnostics
+     */
+    public function checkVariableFromVarDoc(Diagnostics $diagnostics): void
+    {
+        self::assertCount(0, $diagnostics);
+    }
+
+    protected function provider(): DiagnosticProvider
+    {
+        return new UndefinedVariableProvider();
+    }
+}
diff --git a/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/VariableUndefinedWithNoSuggestions.test b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/VariableUndefinedWithNoSuggestions.test
new file mode 100644
index 00000000..180e2a79
--- /dev/null
+++ b/lib/WorseReflection/Tests/Integration/Bridge/TolerantParser/Diagonstics/VariableUndefinedWithNoSuggestions.test
@@ -0,0 +1,4 @@
+<?php
+
+if ($foa) {
+}