Skip to content
academia.sh

Lesson 02 / 10

Code Style Checking

Measure that unformatted code runs the same in both profiles, that only a style checker reports it, and where a formatter can change the meaning.

Published Updated

In this lesson

  • Measures that unformatted code gives the same result in both configuration profiles
  • Counts trailing whitespace, consecutive spaces, and mixed indentation with a small style checker that reads tokens
  • Shows that a formatter does not change the meaning when it touches only whitespace tokens
  • Measures that a correction that ignores the token type changes the contents of a string
Contents

In the previous lesson a tool that reads code without running it found a defect the run could not see. Does the format of the code fall into the same class? Extra spaces, a tab forgotten at the end of a line, or a file indented half with tabs and half with spaces may mean nothing to PHP. This lesson measures that assumption and asks the same question on the measurement axis: does this behavior reach production?

A style checker reads the source against a coding standard and reports deviations. The concept of a standard recommendation (PSR) was introduced in the Object-Oriented PHP and Standards course; a coding standard is the set of rules that determines how code is written. What is measured here is not the content of the standard but the place in the run of the tool that applies it.

Unformatted Code Runs in Both Profiles

The measurement core of the lesson is the same as in the previous lesson. side.php does not change at all; in this lesson it only prints its own probe with sideReport().

<?php
// side.php — does this behavior reach production?
declare(strict_types=1);

// The two configuration files PHP ships (php.ini-development, php.ini-production) differ
// in seven directives; all seven are copied here verbatim. The last two production lines
// are comments in the shipped file: code cache on for the command line too, file checks off.
const PROFILES = [
    'development' => [
        'display_errors' => '1',
        'display_startup_errors' => '1',
        'error_reporting' => '32767',
        'mysqlnd.collect_memory_statistics' => '1',
        'zend.assertions' => '1',
        'zend.exception_ignore_args' => '0',
        'zend.exception_string_param_max_len' => '15',
    ],
    'production' => [
        'display_errors' => '0',
        'display_startup_errors' => '0',
        'error_reporting' => '24575',
        'mysqlnd.collect_memory_statistics' => '0',
        'zend.assertions' => '-1',
        'zend.exception_ignore_args' => '1',
        'zend.exception_string_param_max_len' => '0',
        'opcache.enable_cli' => '1',
        'opcache.validate_timestamps' => '0',
    ],
];

/** Runs the script in a separate process with no configuration file, only the profile's directives. */
function run(string $script, array $directives): bool {
    $command = ['php', '-n'];
    foreach ($directives as $name => $value) {
        array_push($command, '-d', "$name=$value");
    }
    $command[] = $script;
    $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes);
    $output = stream_get_contents($pipes[1]);
    fclose($pipes[1]);
    proc_close($process);
    $lines = array_values(array_filter(array_map('trim', explode("\n", $output))));
    return end($lines) === 'yes';
}

/** Derives the side from whether the behavior was observed in the two runs. */
function side(bool $inDevelopment, bool $inProduction): string {
    return match (true) {
        $inDevelopment && $inProduction => 'both sides',
        $inDevelopment => 'development only',
        $inProduction => 'production only',
        default => 'not in the run',
    };
}

function sideReport(string $name, string $script): string {
    $inDevelopment = run($script, PROFILES['development']);
    $inProduction = run($script, PROFILES['production']);
    $finalSide = side($inDevelopment, $inProduction);
    echo "$name → $finalSide\n    development ", $inDevelopment ? 'yes' : 'no',
        ' · production ', $inProduction ? 'yes' : 'no', "\n";
    return $finalSide;
}

The code to be measured is a small file written to be unformatted on purpose: two spaces after the function name, trailing whitespace at the end of a line, a tab on one line, and four-space indentation on another. The driver produces the file with escape sequences, so the invisible characters stay visible in the lesson’s source too.

A Small Style Checker

The checker looks at tokens, as in the previous lesson. When PHP splits the source into tokens, whitespace becomes a token as well: T_WHITESPACE. A whitespace token carries line endings and the indentation that follows them in one piece. The checker splits this piece at the line endings: the whitespace left before each line ending is trailing whitespace; whitespace that contains no line ending and is at least two characters long is consecutive spaces; the whitespace left after the last line ending is indentation, and it is counted as tab indentation if it contains a tab and as space indentation if it does not.

A formatter uses the same split, but instead of producing counts it rewrites the whitespace: it deletes trailing whitespace, reduces consecutive spaces to a single space, and replaces every tab with four spaces. Every token other than whitespace is written back with its text unchanged. The outputOf() function runs a file in a separate process with the profile’s directives and returns its output.

<?php
// style.php — a style checker and formatter that look at tokens
declare(strict_types=1);

/** Extracts four counts from whitespace tokens; ignores whitespace inside strings and comments. */
function styleCounts(string $source): array {
    $tally = ['trailingWhitespace' => 0, 'consecutive' => 0, 'tabIndent' => 0, 'spaceIndent' => 0];
    foreach (PhpToken::tokenize($source) as $token) {
        if (!$token->is(T_WHITESPACE)) {
            continue;
        }
        $parts = explode("\n", $token->text);
        $last = array_pop($parts);
        foreach ($parts as $part) {
            if ($part !== '') {
                $tally['trailingWhitespace']++;
            }
        }
        if ($parts === []) {
            if (strlen($last) >= 2) {
                $tally['consecutive']++;
            }
        } elseif ($last !== '') {
            $tally[str_contains($last, "\t") ? 'tabIndent' : 'spaceIndent']++;
        }
    }
    return $tally;
}

/** Rewrites only whitespace tokens; the other tokens stay as they are. */
function format(string $source): string {
    $output = '';
    foreach (PhpToken::tokenize($source) as $token) {
        if (!$token->is(T_WHITESPACE)) {
            $output .= $token->text;
            continue;
        }
        $parts = explode("\n", $token->text);
        $last = array_pop($parts);
        if ($parts === []) {
            $output .= ' ';
            continue;
        }
        $output .= str_repeat("\n", count($parts)) . str_replace("\t", '    ', $last);
    }
    return $output;
}

/** Runs the script in a separate process with the given directives and returns its output. */
function outputOf(string $script, array $directives): string {
    $command = ['php', '-n'];
    foreach ($directives as $name => $value) {
        array_push($command, '-d', "$name=$value");
    }
    $command[] = $script;
    $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes);
    $output = stream_get_contents($pipes[1]);
    fclose($pipes[1]);
    proc_close($process);
    return $output;
}

The driver produces the unformatted file, also writes the formatted version of the same file, and measures three things. The first is whether the unformatted code produces a notice in the two profiles; the second is the counts of the checker and the formatter; the third is the output of four runs: the unformatted and the formatted file, in the development and the production profile.

<?php
// format.php — format does not change the run
declare(strict_types=1);
require 'side.php';
require 'style.php';

$unformatted = "<?php\n"
    . "function  add(\$a,\$b){return \$a+\$b;}   \n"
    . "function  multiply(\$a,\$b){\n"
    . "\t\$result = \$a * \$b;\n"
    . "    return \$result;\n"
    . "}\n"
    . "echo add(2, 3), multiply(2, 3), \"\\n\";\n";
file_put_contents('unformatted.php', $unformatted);
file_put_contents('formatted.php', format($unformatted));

file_put_contents('probe.php', <<<'PHP'
    <?php
    ob_start();
    include 'unformatted.php';
    ob_end_clean();
    echo error_get_last() !== null ? "yes\n" : "no\n";
    PHP);
sideReport('unformatted code is reported', 'probe.php');

function countLine(string $source): string {
    $s = styleCounts($source);
    return "trailing whitespace {$s['trailingWhitespace']} · consecutive spaces {$s['consecutive']}"
        . " · tab indentation {$s['tabIndent']} · space indentation {$s['spaceIndent']}";
}
echo 'unformatted: ', countLine($unformatted), "\n";
echo 'formatted: ', countLine(file_get_contents('formatted.php')), "\n";

$results = [];
foreach (['unformatted.php', 'formatted.php'] as $file) {
    foreach (PROFILES as $directives) {
        $results[] = trim(outputOf($file, $directives));
    }
}
echo 'output of the four runs: ', implode(' ', $results), "\n";
echo 'all the same: ', count(array_unique($results)) === 1 ? 'yes' : 'no', "\n";
$formatted = file_get_contents('formatted.php');
echo 'does a second pass change the file: ', format($formatted) === $formatted ? 'no' : 'yes', "\n";
unformatted code is reported → not in the run
    development no · production no
unformatted: trailing whitespace 1 · consecutive spaces 2 · tab indentation 1 · space indentation 1
formatted: trailing whitespace 0 · consecutive spaces 0 · tab indentation 0 · space indentation 2
output of the four runs: 56 56 56 56
all the same: yes
does a second pass change the file: no

The side is not in the run: the unformatted file runs in both profiles without producing a notice. The checker counts the four kinds of deviation separately; in the formatted file, trailing whitespace, consecutive spaces, and tab indentation drop to zero, and space indentation rises to two. The output of the four runs is exactly the same. Because the formatter touches only whitespace tokens, the meaning of the file does not change; what proves it is the equality of the four outputs.

Checking and Fixing Are Separate Jobs

The last line of the output shows the difference between the two tools. The checker reads and reports; it changes no file. The formatter writes; for that reason, when it is applied to the same file a second time, it should no longer change the file, otherwise every run produces the next difference. The measurement confirms this: the second pass does not change the file.

What the two tools have in common is that both stand outside the run. Neither needs to enter the package that is sent to production; they run on the developer’s machine and in the step before deployment. This is the same position as the static analyzer of the previous lesson and gives the same side on the measurement axis: not in the run. The difference is in the class of defect. Static analysis finds a mistake the run does not catch; style checking finds a deviation the run does not count as wrong. PHP counts neither unformatted code nor trailing whitespace as an error, because both conform to the rules of the language.

For that reason the justification for style checking is not correctness but readability and consistency. Trailing whitespace appears as an invisible change in a version difference; mixed indentation makes the same file align differently in different editors; consecutive spaces hide what a change actually changes. Handing the enforcement of the standard to a tool instead of to human attention keeps these three deviations from slipping past unnoticed.

Where Format Changes the Run

So far format has not changed the run: whitespace, indentation, and trailing whitespace did not move the output by a single character. PHP has an exception to this rule, and the exception shows why style checking should not be treated as merely an aesthetic job. A file opens with <?php and closes with ?>; every character that comes after the closing tag is written to the output as plain text, not as PHP code. There is a single exception: a single newline that immediately follows the closing tag is swallowed.

The measurement produces a file of the same name with three different contents and includes each in an output buffer. The only character the file writes is the letter a; if the length of the buffer is more than that, the whitespace after the closing tag has leaked into the output.

<?php
// closing.php — does a character after the closing tag leak into the output
declare(strict_types=1);
require 'side.php';

$probes = [
    'a single newline is swallowed' => <<<'PHP'
        <?php
        $close = '?' . '>';
        file_put_contents('part.php', "<?php echo 'a'; $close\n");
        ob_start();
        include 'part.php';
        echo strlen(ob_get_clean()) === 1 ? "yes\n" : "no\n";
        PHP,
    'a second newline leaks' => <<<'PHP'
        <?php
        $close = '?' . '>';
        file_put_contents('part.php', "<?php echo 'a'; $close\n\n");
        ob_start();
        include 'part.php';
        echo strlen(ob_get_clean()) === 2 ? "yes\n" : "no\n";
        PHP,
    'whitespace after the closing tag leaks' => <<<'PHP'
        <?php
        $close = '?' . '>';
        file_put_contents('part.php', "<?php echo 'a'; $close ");
        ob_start();
        include 'part.php';
        echo strlen(ob_get_clean()) === 2 ? "yes\n" : "no\n";
        PHP,
];

foreach ($probes as $name => $code) {
    file_put_contents('probe.php', $code);
    sideReport($name, 'probe.php');
}
a single newline is swallowed → both sides
    development yes · production yes
a second newline leaks → both sides
    development yes · production yes
whitespace after the closing tag leaks → both sides
    development yes · production yes

All three probes are on both sides: the leak does not depend on a configuration difference; it is a rule of the language. This is why it differs from the earlier three deviations. Trailing whitespace changed nothing; whitespace after the closing tag changes the output. In a real application this means that one extra line at the end of a file adds a character to the output of the code that includes that file; a test whose output is compared character by character, or a binary response, breaks for this reason. The side is the same as before: not “not in the run” but both sides. Never writing the closing tag in pure PHP files removes this class of problem entirely; style standards such as PSR-12 also make it a rule that the closing tag is not written in pure PHP files.

The First Statement of the File

The second point about format is the position of the declare(strict_types=1) statement. This statement must be the first statement of the file; if there is an echo before it, PHP refuses to compile the file. A style checker can keep this order as a rule, but the order is not only style; a misplaced statement makes the file impossible to run. The driver below writes two small files, runs each in a separate PHP process, and asks whether the exit code is nonzero.

<?php
// order.php — does the position of the declare statement change the run
declare(strict_types=1);
require 'side.php';

$probes = [
    'a file does not run when declare is not the first statement' => <<<'PHP'
        <?php
        file_put_contents('part.php', "<?php\necho 1;\ndeclare(strict_types=1);\n");
        exec('php -n part.php 2>/dev/null', $output, $code);
        echo $code !== 0 ? "yes\n" : "no\n";
        PHP,
    'a file runs when declare is the first statement' => <<<'PHP'
        <?php
        file_put_contents('part.php', "<?php\ndeclare(strict_types=1);\necho 1;\n");
        exec('php -n part.php 2>/dev/null', $output, $code);
        echo $code === 0 ? "yes\n" : "no\n";
        PHP,
];

foreach ($probes as $name => $code) {
    file_put_contents('probe.php', $code);
    sideReport($name, 'probe.php');
}
a file does not run when declare is not the first statement → both sides
    development yes · production yes
a file runs when declare is the first statement → both sides
    development yes · production yes

The result is on both sides again. A compile error depends not on a setting but on the syntax of the language; it occurs the same way in development and in production. In the development profile the error is printed into the response while in production it goes only to the log, but a file that does not compile does not run on either side. Read together, these two measurements divide style checking into two classes: deviations that do not affect the run at all (trailing whitespace, indentation, consecutive spaces) and deviations that do (the character after the closing tag, the position of the first statement). The second class is where a style checker finding something is not a convenience but a guarantee.

The Bounding Measurement

Whitespace is not always whitespace. Trailing whitespace inside a string or a nowdoc is the contents of the string; if it is deleted, the program produces a different value. For this reason a correction that ignores the token type is dangerous. The source below contains a nowdoc that carries three spaces at the end of a line. The same file is run in three forms: in its original form, corrected with a regular expression that deletes the whitespace at the end of every line, and with the formatter that touches only T_WHITESPACE tokens.

<?php
// limit.php — a correction that ignores the token type changes the meaning
declare(strict_types=1);
require 'side.php';
require 'style.php';

$source = "<?php\n"
    . "\$text = <<<'TEXT'\n"
    . "first   \n"
    . "last\n"
    . "TEXT;\n"
    . "echo strlen(\$text), \"\\n\";\n";

$versions = [
    'original' => $source,
    'correction without tokens' => preg_replace('/[ \t]+$/m', '', $source),
    'token-aware formatting' => format($source),
];
foreach ($versions as $name => $text) {
    file_put_contents('text.php', $text);
    echo "$name: ", trim(outputOf('text.php', PROFILES['development'])), "\n";
}
echo 'trailing whitespace counted by the token-aware checker: ', styleCounts($source)['trailingWhitespace'], "\n";
echo 'found by the regular expression without tokens: ', preg_match_all('/[ \t]+$/m', $source), "\n";
original: 13
correction without tokens: 10
token-aware formatting: 13
trailing whitespace counted by the token-aware checker: 0
found by the regular expression without tokens: 1

The original file and the formatted file produce a string of the same length (13); the correction without tokens deletes the three spaces inside the nowdoc as well and shortens the string. The token-aware checker does not count the nowdoc contents as whitespace; the regular expression does. The difference shows that a style tool must read the source not as text but as a sequence of tokens. The formatter’s guarantee also comes from here: a tool that rewrites only T_WHITESPACE tokens cannot reach the contents of a string. The limit is this: whether whitespace inside a string or a comment is unnecessary is not a question a token reading can answer.

Summary

  • Unformatted code runs with the same output in both profiles without producing a notice; the side of this behavior is not in the run, and only a style checker reports it.
  • Whitespace is a token too (T_WHITESPACE): trailing whitespace, consecutive spaces, tab indentation, and space indentation are counted from this token.
  • A formatter does not change the meaning when it rewrites only whitespace tokens; the unformatted and the formatted file give the same output in four runs.
  • A correction that ignores the token type also deletes trailing whitespace inside a nowdoc or a string and changes the output.
  • For whitespace format, the answer is the same in both profiles: a difference in format does not enter the run.
  • A character after the closing tag leaks into the output, and a file whose declare(strict_types=1) is not the first statement does not compile; both are rules of the language (side: both sides).

Next Step

In this lesson the second behavior that the run never sees turned into a report in the hands of the style checker. The next lesson moves to a tool that enters the run but whose behavior changes with the profile: the assertion. A failing assertion throws an AssertionError in development; in the production profile the same assertion is not even compiled. We will measure which side this mechanism, on which test frameworks rely, stands on, and what changes when a test is built on its own helper.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close