Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatically fix blade variable typos and optional variables #38

Merged
merged 36 commits into from
Sep 18, 2019
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
bba7121
Initial work on Undefined Variable Solution
MrRio Sep 1, 2019
40b42b8
Add basic test, and run params
MrRio Sep 1, 2019
c2442ef
Improve formatting
MrRio Sep 1, 2019
0b3bc57
Finish initial version of the solution
MrRio Sep 1, 2019
ded2ee0
If text is quite similar, offer up typo correction solutions
MrRio Sep 1, 2019
b5e14c4
Add automated tests
MrRio Sep 1, 2019
746af02
Use a tokenizer to make sure the solution works, don't offer it if it…
MrRio Sep 1, 2019
958e432
Add safety checks to making the variable optional
MrRio Sep 1, 2019
312b375
Fix the tests, make sure we use line number when asserting the new to…
MrRio Sep 1, 2019
69fd903
Fix style issues
MrRio Sep 1, 2019
5c49f99
Add error back into the stub
MrRio Sep 1, 2019
84d37b5
Make sure variable is valid
MrRio Sep 1, 2019
1ceda6a
Save the repaired file out to a temporary file in the test
MrRio Sep 1, 2019
f9ee08e
Add view file creation solution
MrRio Sep 1, 2019
3e73f39
Merge branch 'master' into feature/undefined-variable-solution
MrRio Sep 1, 2019
4da6aa5
Revert "Add view file creation solution"
MrRio Sep 1, 2019
6d16707
Merge branch 'master' into feature/undefined-variable-solution
MrRio Sep 2, 2019
7e8e466
Merge branch 'master' into feature/undefined-variable-solution
MrRio Sep 2, 2019
04e3afb
Return early if variableName isn't set for whatever reason
MrRio Sep 2, 2019
1f63a37
Merge branch 'master' into feature/undefined-variable-solution
MrRio Sep 2, 2019
91edbe6
Fix tests on older Laravel/Testbench
MrRio Sep 2, 2019
e188938
Actually fix tests
MrRio Sep 2, 2019
dbafeda
Merge branch 'master' into feature/undefined-variable-solution
freekmurze Sep 2, 2019
467bc9b
Merge branch 'master' into feature/undefined-variable-solution
MrRio Sep 3, 2019
c8384ea
Make code clearer, add type hints
MrRio Sep 3, 2019
a7eb0eb
Remove unused `use`
MrRio Sep 3, 2019
90d5981
Fix style-ci issue
MrRio Sep 3, 2019
be7b541
Add in check to make sure it matches expected tokens
MrRio Sep 3, 2019
4bdc4fd
Merge branch 'master' of github.com:facade/ignition into feature/unde…
MrRio Sep 4, 2019
28f27dd
Merge branch 'master' of github.com:facade/ignition into feature/unde…
MrRio Sep 17, 2019
be66160
Fix up requested changes
MrRio Sep 17, 2019
d873dac
Add missing line
MrRio Sep 17, 2019
a06364f
Fix up some more bits
MrRio Sep 17, 2019
26ff9be
Fix style issue
MrRio Sep 17, 2019
4059953
Remove object type hint, fails on PHP 7.1
MrRio Sep 17, 2019
b2b9c53
Style fixes
MrRio Sep 17, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/IgnitionServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
use Facade\Ignition\SolutionProviders\TableNotFoundSolutionProvider;
use Illuminate\View\Engines\CompilerEngine as LaravelCompilerEngine;
use Facade\Ignition\SolutionProviders\MissingPackageSolutionProvider;
use Facade\Ignition\SolutionProviders\UndefinedVariableSolutionProvider;
use Facade\Ignition\SolutionProviders\InvalidRouteActionSolutionProvider;
use Facade\Ignition\SolutionProviders\IncorrectValetDbCredentialsSolutionProvider;
use Facade\IgnitionContracts\SolutionProviderRepository as SolutionProviderRepositoryContract;
Expand Down Expand Up @@ -301,6 +302,7 @@ protected function getDefaultSolutions(): array
MissingPackageSolutionProvider::class,
InvalidRouteActionSolutionProvider::class,
ViewNotFoundSolutionProvider::class,
UndefinedVariableSolutionProvider::class,
];
}

Expand Down
73 changes: 73 additions & 0 deletions src/SolutionProviders/UndefinedVariableSolutionProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace Facade\Ignition\SolutionProviders;

use Throwable;
use Facade\IgnitionContracts\Solution;
use Facade\IgnitionContracts\BaseSolution;
use Facade\Ignition\Exceptions\ViewException;
use Facade\IgnitionContracts\HasSolutionsForThrowable;
use Facade\Ignition\Solutions\MakeViewVariableOptionalSolution;
use Facade\Ignition\Solutions\SuggestCorrectVariableNameSolution;

class UndefinedVariableSolutionProvider implements HasSolutionsForThrowable
{
private $variableName;

private $viewFile;

public function canSolve(Throwable $throwable): bool
{
if (! $throwable instanceof ViewException) {
return false;
}

return $this->getNameAndView($throwable) !== null;
}

public function getSolutions(Throwable $throwable): array
{
$solutions = [];

extract($this->getNameAndView($throwable));
MrRio marked this conversation as resolved.
Show resolved Hide resolved
$solutions = collect($throwable->getViewData())->map(function ($value, $key) use ($variableName) {
MrRio marked this conversation as resolved.
Show resolved Hide resolved
similar_text($variableName, $key, $percentage);

return ['match' => $percentage, 'value' => $value];
})->sortByDesc('match')->filter(function ($var, $key) {
return $var['match'] > 40;
})->keys()->map(function ($suggestion) use ($variableName, $viewFile) {
return new SuggestCorrectVariableNameSolution($variableName, $viewFile, $suggestion);
})->map(function ($solution) {
// If the solution isn't runnable, then just return the suggestions without the fix
if ($solution->isRunnable()) {
return $solution;
} else {
return BaseSolution::create($solution->getSolutionTitle())
MrRio marked this conversation as resolved.
Show resolved Hide resolved
->setSolutionDescription($solution->getSolutionActionDescription());
}
})->toArray();

$optionalSolution = new MakeViewVariableOptionalSolution($variableName, $viewFile);
MrRio marked this conversation as resolved.
Show resolved Hide resolved
if ($optionalSolution->isRunnable()) {
MrRio marked this conversation as resolved.
Show resolved Hide resolved
$solutions[] = $optionalSolution;
} else {
$solutions[] = BaseSolution::create($optionalSolution->getSolutionTitle())
->setSolutionDescription($optionalSolution->getSolutionActionDescription());
}

return $solutions;
}

private function getNameAndView(Throwable $throwable)
MrRio marked this conversation as resolved.
Show resolved Hide resolved
{
$pattern = '/Undefined variable: (.*?) \(View: (.*?)\)/';

preg_match($pattern, $throwable->getMessage(), $matches);
if (count($matches) === 3) {
[$string, $variableName, $viewFile] = $matches;

return compact('variableName', 'viewFile');
}
}
}
95 changes: 95 additions & 0 deletions src/Solutions/MakeViewVariableOptionalSolution.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace Facade\Ignition\Solutions;

use Illuminate\Support\Facades\Blade;
use Facade\IgnitionContracts\RunnableSolution;

class MakeViewVariableOptionalSolution implements RunnableSolution
{
private $variableName;
private $viewFile;

public function __construct($variableName = null, $viewFile = null)
{
$this->variableName = $variableName;
$this->viewFile = $viewFile;
}

public function getSolutionTitle(): string
{
return '$'.$this->variableName.' is undefined';
}

public function getDocumentationLinks(): array
{
return [];
}

public function getSolutionActionDescription(): string
{
$path = str_replace(base_path().'/', '', $this->viewFile);
$output = [
'Make the variable optional in the blade template.',
'Replace `{{ $'.$this->variableName.' }}` with `{{ $'.$this->variableName.' ?? \'\' }}`',
];

return implode(PHP_EOL, $output);
}

public function getRunButtonText(): string
{
return 'Make variable optional';
}

public function getSolutionDescription(): string
{
return '';
}

public function getRunParameters(): array
{
return [
'variableName' => $this->variableName,
'viewFile' => $this->viewFile,
];
}

public function isRunnable(array $parameters = [])
{
return $this->makeOptional($this->getRunParameters()) !== false;
}

public function run(array $parameters = [])
{
$output = $this->makeOptional($parameters);
if ($output !== false) {
file_put_contents($parameters['viewFile'], $output);
}
}

public function makeOptional(array $parameters = [])
{
$originalContents = file_get_contents($parameters['viewFile']);
$newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);
// Compile blade, tokenize
MrRio marked this conversation as resolved.
Show resolved Hide resolved
$originalTokens = token_get_all(Blade::compileString($originalContents));
$newTokens = token_get_all(Blade::compileString($newContents));
// Generate what we expect the tokens to be after we change the blade file
$expectedTokens = [];
MrRio marked this conversation as resolved.
Show resolved Hide resolved
foreach ($originalTokens as $key => $token) {
$expectedTokens[] = $token;
if ($token[0] === T_VARIABLE && $token[1] === '$'.$parameters['variableName']) {
$expectedTokens[] = [T_WHITESPACE, ' ', $token[2]];
$expectedTokens[] = [T_COALESCE, '??', $token[2]];
$expectedTokens[] = [T_WHITESPACE, ' ', $token[2]];
$expectedTokens[] = [T_CONSTANT_ENCAPSED_STRING, "''", $token[2]];
}
}
if ($expectedTokens !== $newTokens) {
return false;
}

return $newContents;
}
}
99 changes: 99 additions & 0 deletions src/Solutions/SuggestCorrectVariableNameSolution.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

namespace Facade\Ignition\Solutions;

use Illuminate\Support\Facades\Blade;
use Facade\IgnitionContracts\RunnableSolution;

class SuggestCorrectVariableNameSolution implements RunnableSolution
{
private $variableName;
MrRio marked this conversation as resolved.
Show resolved Hide resolved
private $viewFile;

public function __construct($variableName = null, $viewFile = null, $suggested = null)
{
$this->variableName = $variableName;
$this->viewFile = $viewFile;
$this->suggested = $suggested;
}

public function getSolutionTitle(): string
{
return 'Possible typo $'.$this->variableName;
}

public function getDocumentationLinks(): array
{
return [];
}

public function getSolutionActionDescription(): string
{
$path = str_replace(base_path().'/', '', $this->viewFile);
$output = [
'Did you mean `$'.$this->suggested.'`?',
MrRio marked this conversation as resolved.
Show resolved Hide resolved
];

return implode(PHP_EOL, $output);
}

public function getRunButtonText(): string
{
return 'Fix typo';
}

public function getSolutionDescription(): string
{
return '';
}

public function getRunParameters(): array
{
return [
'variableName' => $this->variableName,
'viewFile' => $this->viewFile,
'suggested' => $this->suggested,
];
}

public function isRunnable(array $parameters = [])
MrRio marked this conversation as resolved.
Show resolved Hide resolved
{
return $this->fixTypo($this->getRunParameters()) !== false;
}

public function run(array $parameters = [])
MrRio marked this conversation as resolved.
Show resolved Hide resolved
{
$output = $this->fixTypo($parameters);
if ($output !== false) {
MrRio marked this conversation as resolved.
Show resolved Hide resolved
file_put_contents($parameters['viewFile'], $output);
}
}

public function fixTypo(array $parameters = [])
{
// Make sure suggested variable is valid alpha-numeric with underscore, or return false
if (! preg_match('/^[a-zA-Z]+[a-zA-Z0-9_]+$/', $parameters['suggested'])) {
MrRio marked this conversation as resolved.
Show resolved Hide resolved
return false;
}

$originalContents = file_get_contents($parameters['viewFile']);
$newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['suggested'], $originalContents);

// Compile blade, tokenize
$originalTokens = token_get_all(Blade::compileString($originalContents));
$newTokens = token_get_all(Blade::compileString($newContents));

// Generate what we expect the tokens to be after we change the blade file
$expectedTokens = $originalTokens;
foreach ($expectedTokens as $key => $token) {
if ($token[0] === T_VARIABLE && $token[1] === '$'.$parameters['variableName']) {
$expectedTokens[$key][1] = '$'.$parameters['suggested'];
}
}
if ($expectedTokens !== $newTokens) {
return false;
}

return $newContents;
}
}
84 changes: 84 additions & 0 deletions tests/Solutions/UndefinedVariableSolutionProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace Facade\Ignition\Tests\Solutions;

use Facade\Ignition\Tests\TestCase;
use Illuminate\Support\Facades\View;
use Facade\Ignition\Exceptions\ViewException;
use Facade\Ignition\Support\ComposerClassMap;
use Facade\Ignition\SolutionProviders\UndefinedVariableSolutionProvider;

class UndefinedVariableSolutionProviderTest extends TestCase
{
public function setUp(): void
{
parent::setUp();

View::addLocation(__DIR__.'/../stubs/views');

$this->app->bind(
ComposerClassMap::class,
function () {
return new ComposerClassMap(__DIR__.'/../../vendor/autoload.php');
}
);
}

/** @test */
public function it_can_solve_the_exception()
{
$canSolve = app(UndefinedVariableSolutionProvider::class)->canSolve($this->getUndefinedVariableException());

$this->assertTrue($canSolve);
}

/** @test */
public function it_can_recommend_fixing_a_variable_name_typo()
{
$viewData = [
'footerDescription' => 'foo',
];

try {
view('undefined-variable-1', $viewData)->render();
} catch (ViewException $exception) {
$viewException = $exception;
}

$canSolve = app(UndefinedVariableSolutionProvider::class)->canSolve($viewException);
$this->assertTrue($canSolve);

/** @var \Facade\IgnitionContracts\Solution $solution */
$solutions = app(UndefinedVariableSolutionProvider::class)->getSolutions($viewException);
$this->assertStringContainsString('Did you mean `$footerDescription`?', $solutions[0]->getSolutionActionDescription());
$this->assertStringContainsString('Replace `{{ $footerDescriptin }}` with `{{ $footerDescriptin ?? \'\' }}`', $solutions[1]->getSolutionActionDescription());
}

/** @test */
public function it_can_fix_a_variable_name_typo()
{
$viewData = [
'footerDescription' => 'foo',
];

try {
view('undefined-variable-1', $viewData)->render();
} catch (ViewException $exception) {
$viewException = $exception;
}

$canSolve = app(UndefinedVariableSolutionProvider::class)->canSolve($viewException);
$this->assertTrue($canSolve);

/** @var \Facade\IgnitionContracts\Solution $solution */
$solutions = app(UndefinedVariableSolutionProvider::class)->getSolutions($viewException);
$parameters = $solutions[0]->getRunParameters();
$parameters['viewFile'] = tempnam(sys_get_temp_dir(), 'undefined-variable-blade');
$solutions[0]->run($parameters);
}

protected function getUndefinedVariableException(): ViewException
{
return new ViewException('Undefined variable: notSet (View: ./views/welcome.blade.php)');
}
}
11 changes: 11 additions & 0 deletions tests/stubs/views/undefined-variable-1.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{{-- Intentional typo for test --}}

This is some contents

<footer>{{ $footerDescriptin }}</footer>

@isset($something)
{{ $something }}
@endisset

Test