Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 24 additions & 4 deletions src/Rule/Rules/File/Psr1PhpTagsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,12 @@ private function fixInvalidTagOnLine(string $code, int $line): ?string
$replacement = $this->replacementForInvalidTag($token[0], $text, $token[2], $line);

if ($replacement !== null) {
return substr($code, 0, $offset + $replacement['offset'])
. $replacement['text']
. substr($code, $offset + $replacement['offset'] + $replacement['length']);
$replaceAt = $offset + $replacement['offset'];
$afterOffset = $replaceAt + $replacement['length'];

return substr($code, 0, $replaceAt)
. $this->normalizedTagReplacement($replacement['text'], substr($code, $afterOffset, 1))
. substr($code, $afterOffset);
}
}

Expand All @@ -125,6 +128,23 @@ private function fixInvalidTagOnLine(string $code, int $line): ?string
return null;
}

/**
* Ensures the replaced `<?php` tag keeps a separator before the following
* code. Short open tags such as `<?echo` tokenize as `<?` with the next
* character in a separate token, so `<?php` alone would produce `<?phpecho`.
*/
private function normalizedTagReplacement(string $text, string $nextChar): string
{
// The `<?php` open tag is only recognized when followed by whitespace or
// end of file, so it needs a separator before adjacent code
// (e.g. `<?echo` must not become `<?phpecho`).
if ($text === '<?php' && $nextChar !== '' && preg_match('/^\s/', $nextChar) !== 1) {
return '<?php ';
}

return $text;
}

/**
* @return array{offset: int, length: int, text: string}|null
*/
Expand Down Expand Up @@ -157,7 +177,7 @@ private function replacementForInvalidTag(int $id, string $text, int $tokenLine,
return [
'offset' => $tagOffset,
'length' => 2,
'text' => preg_match('/^\s/', substr($text, $tagOffset + 2)) === 1 ? '<?php' : '<?php ',
'text' => '<?php',
];
}

Expand Down
97 changes: 97 additions & 0 deletions tests/Rule/File/Psr1PhpTagsRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
use PHPUnit\Framework\TestCase;

use function bin2hex;
use function dirname;
use function escapeshellarg;
use function exec;
use function file_get_contents;
use function file_put_contents;
use function implode;
use function mkdir;
use function random_bytes;
use function realpath;
Expand All @@ -29,6 +31,7 @@
use function symlink;
use function sys_get_temp_dir;
use function unlink;
use function var_export;

use const DIRECTORY_SEPARATOR;
use const PHP_BINARY;
Expand Down Expand Up @@ -92,6 +95,59 @@ public function testViolatesEchoImmediatelyAfterShortOpenTag(): void
}
}

public function testFixesShortOpenTagFollowedByCodeWhenShortOpenTagEnabled(): void
{
$basePath = $this->makeTempDir();

try {
mkdir($basePath . '/src');
file_put_contents($basePath . '/src/Foo.php', '<?echo "hi";');

$fixedContent = $this->fixWithShortOpenTagEnabled($basePath);

// A bare `<?` short open tag tokenizes without the trailing character,
// so replacing it with `<?php` alone would yield `<?phpecho "hi";`.
$this->assertSame('<?php echo "hi";', $fixedContent);

$command = escapeshellarg(PHP_BINARY)
. ' -d short_open_tag=1 '
. escapeshellarg($basePath . '/src/Foo.php');
exec($command, $output, $exitCode);

$this->assertSame(0, $exitCode);
$this->assertSame(['hi'], $output);
} finally {
unlink($basePath . '/src/Foo.php');
rmdir($basePath . '/src');
rmdir($basePath);
}
}

public function testFixesShortOpenTagFollowedByVariableWhenShortOpenTagEnabled(): void
{
$basePath = $this->makeTempDir();

try {
mkdir($basePath . '/src');
file_put_contents($basePath . '/src/Foo.php', '<?$value = 1; echo $value;');

$fixedContent = $this->fixWithShortOpenTagEnabled($basePath);

$this->assertSame('<?php $value = 1; echo $value;', $fixedContent);

$command = escapeshellarg(PHP_BINARY)
. ' -d short_open_tag=1 -l '
. escapeshellarg($basePath . '/src/Foo.php');
exec($command, $output, $exitCode);

$this->assertSame(0, $exitCode);
} finally {
unlink($basePath . '/src/Foo.php');
rmdir($basePath . '/src');
rmdir($basePath);
}
}

public function testViolatesAssignmentUsingShortOpenTag(): void
{
$basePath = $this->makeTempDir();
Expand Down Expand Up @@ -587,4 +643,45 @@ private function makeTempDir(): string

return $path;
}

/**
* Runs the fixer in a subprocess with short_open_tag=On so that bare `<?`
* short open tags are tokenized as T_OPEN_TAG, then returns the fixed file
* contents. This is the only configuration under which the T_OPEN_TAG branch
* of the fixer is exercised.
*/
private function fixWithShortOpenTagEnabled(string $basePath): string
{
$autoload = dirname(__DIR__, 3) . '/vendor/autoload.php';
$runner = $basePath . '/run-fix.php';

file_put_contents($runner, <<<PHP
<?php
require {$this->exported($autoload)};

use Boundwize\\StructArmed\\Architecture;
use Boundwize\\StructArmed\\Rule\\Rules\\File\\Psr1PhpTagsRule;

\$rule = new Psr1PhpTagsRule(['src/']);
\$violations = \$rule->evaluateProjectAll({$this->exported($basePath)}, Architecture::define());
\$rule->fix(\$violations[0]);
PHP);

$command = escapeshellarg(PHP_BINARY)
. ' -d short_open_tag=1 '
. escapeshellarg($runner);
exec($command, $output, $exitCode);

$fixedContent = (string) file_get_contents($basePath . '/src/Foo.php');
unlink($runner);

$this->assertSame(0, $exitCode, 'Fixer runner failed: ' . implode("\n", $output));

return $fixedContent;
}

private function exported(string $value): string
{
return var_export($value, true);
}
}
Loading