Your browser is not supported anymore. Please update to a more recent one.


Download Chrome

Download Firefox

Download
Internet Explorer

Download Safari

Badoo PHP Code Formatter. Теперь в open source!

Несколько лет назад компания Badoo начала значительно расти по числу сотрудников, с 20 до 100 и более. Это потребовало серьезной перестройки многих процессов, касающихся разработки. Одна из проблем, с которой мы столкнулись, — как заставить всех разработчиков следовать единому стандарту кодирования, чтобы весь наш код выглядел единообразно и был легко поддерживаемым?

Для решения этой задачи мы решили внедрить инструмент для форматирования кода, который умел бы следующее:

  1. выводить сообщения о несоответствии стандарту форматирования в виде списка, не трогая сам файл;
  2. автоматически исправлять все найденные проблемы с форматированием;
  3. уметь форматировать только часть файла (нам не нужно переформатировать репозиторий сразу целиком, чтобы не потерять историю).

Мы рассматривали два проекта, которые можно было бы взять за основу для написания такого инструмента — PHP Beautifier и PHP Code Sniffer. Первый умел форматировать код, но не умел печатать диагностику, а второй — наоборот, умел печатать диагностику, но не умел форматировать файлы. К сожалению, оба этих проекта, по нашей оценке, были не слишком пригодны для того, чтобы добавить в них недостающую нам функциональность, поэтому была написана новая утилита — phpcf (PHP Code Formatter). Уже в течение двух лет она работает как git pre-receive hook, настроенный на отклонение (!) изменений, которые не оформлены по нашему стандарту кодирования.

Наконец настало время открыть исходные тексты нашей утилиты для широкой публики: https://github.com/badoo/phpcf/

Функциональность


Форматер был создан для того, чтобы в основном менять пробельные символы: переносы строк, отступы, пробелы вокруг операторов, и т.д. Таким образом, phpcf не заменяет другие схожие утилиты, такие как вышеупомянутый PHP Code Sniffer и PHP Coding Standards Fixer от Фабьена Потенцьера. Он дополняет их, выполняя «грязную работу» по правильной расстановке пробелов и переносов строк в файле. Важно отметить, что наша утилита учитывает изначальное форматирование в файле и меняет только те пробелы, которые не соответствуют выбранному стандарту (в отличие от некоторых других решений, которые сначала удаляют все пробельные токены, а потом начинают форматирование).

Утилита расширяема и поддерживает произвольные наборы стилей. Можно достаточно легко определить свой стиль форматирования, который будет реализовать другой стандарт, отличный от нашего (стандарт кодирования в нашей компании очень близок к PSR).

Пример использования (команда “phpcf apply <filename>” форматирует указанный файл, а “phpcf check <filename>” проверяет форматирование и возвращает ненулевой exit-код в случае наличия неотформатированных фрагментов):


$ cat minifier.php
<?php
$tokens=token_get_all(file_get_contents($argv[1]));$contents='';foreach($tokens as $tok){if($tok[0]===T_WHITESPACE||$tok[0]===T_COMMENT)continue;if($tok[0]===T_AS||$tok[0]===T_ELSE)$contents.=' '.$tok[1].' '; else $contents.=is_array($tok)?$tok[1]:$tok;}echo$contents."\n";

$ phpcf apply minifier.php
minifier.php formatted successfully

$ cat minifier.php
<?php
$tokens = token_get_all(file_get_contents($argv[1]));
$contents = '';
foreach ($tokens as $tok) {
    if ($tok[0] === T_WHITESPACE || $tok[0] === T_COMMENT) continue;
    if ($tok[0] === T_AS || $tok[0] === T_ELSE) $contents .= ' ' . $tok[1] . ' ';
    else $contents .= is_array($tok) ? $tok[1] : $tok;
}
echo $contents . "\n";

$ phpcf check minifier.php; echo $?
minifier.php does not need formatting
0


Помимо форматирования файла целиком, наша утилита также умеет форматировать часть файла. Для этого нужно указать диапазоны номеров строк через двоеточие:


$ cat zebra.php 
<?php
echo "White "."strip".PHP_EOL;
echo "Black "."strip".PHP_EOL; // not formatted
echo "Arse".PHP_EOL;

$ phpcf apply zebra.php:1-2,4
zebra.php formatted successfully

$ cat zebra.php 
<?php
echo "White " . "strip" . PHP_EOL;
echo "Black "."strip".PHP_EOL; // not formatted
echo "Arse" . PHP_EOL;

$ phpcf check zebra.php
zebra.php issues:
        Expected one space before binary operators (= < > * . etc)   on line 3 column 14
        Expected one space after binary operators (= < > * . etc)   on line 3 column 15
        ...

$ echo $?
1


Даже несмотря на то, что утилита написана на PHP, форматирование большинства файлов проходит за доли секунды. Но у нас большой репозиторий и много кода, так что мы написали расширение, которое, будучи подключенным, увеличивает производительность работы в сотню раз: весь наш репозиторий в 2 миллиона строк форматируется за 8 секунд на «ноутбучном» Core i7. Для использования расширения требуется его собрать из директории “ext/”, установить, включить “enable_dl = On” в php.ini или прописать его как extension.

Хотелось бы еще раз подчеркнуть, что phpcf прежде всего меняет пробельные символы и умеет делать лишь простейшие преобразования над кодом: например, заменять короткий открывающий тег на длинный или убирать последний закрывающий тег из файла. Помимо этого, phpcf умеет автоматически исправлять кириллицу в названиях функций на английские символы. Также не трогаются выражения, выровненные вручную с помощью пробелов. Это происходит из-за архитектуры — форматер работает как конечный автомат с правилами, которые задает пользователь, а не как набор «захардкоженных» замен (форматер поставляется с «конфигом по умолчанию», соответствующим нашим правилам форматирования). Поэтому, если вы хотите автоматическую замену “var” на “public” или похожих вещей, рекомендуем обратить внимание на PHP-CS-Fixer — он мало внимания уделяет пробельным символам (в отличие от phpcf), но зато умеет переписывать токены.

Поддержка версий PHP


Изначально наш форматер работал на версии PHP 5.3 и поддерживал только ее. В данный момент мы полностью поддерживаем синтаксис PHP 5.4 и 5.5, и для работы форматера требуется PHP версии не ниже 5.4. Если вы хотите форматировать код, предназначенный для более ранних версий PHP, то вы можете это делать, но непосредственно phpcf должен запускаться с помощью PHP 5.4+.

Хотелось бы отдельно отметить, что phpcf не умеет форматировать «недописанные» файлы, содержащие, к примеру, несбалансированные скобки: в этом случае будет выдано сообщение об ошибке и файл просто не будет отформатирован. При этом в некоторых случаях вы можете форматировать «невалидный» с точки зрения интерпретатора PHP код, потому что сам по себе форматер не делает проверки синтаксиса файла.

Что касается поддержки следующих версий PHP, архитектура phpcf такова, что при добавлении или встрече «незнакомых» ключевых слов и токенов в файле они просто будут проигнорированы и оставлены как есть. Таким образом, phpcf уже сейчас поддерживает будущие версии PHP, но с той оговоркой, что для неизвестных токенов просто не будут применяться правила форматирования.

Поддержка интеграции с git


Когда вы скачаете нашу утилиту, вы скорее всего заметите, что есть не только действия “check”, “preview” и “apply”, но и такие же действия с суффиксом “-git”. В Badoo мы в качестве системы контроля версий используем Git, и по умолчанию проверяются и форматируются только измененные строки. Чтобы не заставлять разработчиков вспоминать номера измененных строк, мы сделали “*-git” команды, которые работают следующим образом:

  1. Посмотреть «незакоммиченные» и добавленные в индекс изменения.
  2. Посмотреть изменения, которые произведены в текущей ветке, но при этом отсутствуют в origin/master и origin/<текущая-ветка> (соответствующие ветки обновляются при git push / git pull), или, другими словами, еще не отправлены в репозиторий.
  3. Применить форматирование только к найденным в (1) и (2) строкам.

Мы используем разработку в feature branches, и при этом в ветке master у нас находится production-код, поэтому “-git” команды заточены под этот flow и определяют измененные строки по приведённому выше алгоритму.

Пример использования:

(master) $ git checkout -b some_feature
(some_feature) $ vim test.php # меняем test.php
(some_feature) $ phpcf apply-git # при запуске без аргументов, форматируются все измененные файлы
test.php formatted successfully



Использование классов phpcf напрямую


Помимо использования phpcf как утилиты, также есть возможность использовать классы phpcf напрямую, в том числе с подключением расширения. Эта возможность бывает полезна для разных задач, например, для создания веб-сервиса по форматированию PHP-файлов. Для своих нужд мы ее используем в процессе ревью кода: при просмотре изменений, сделанных в ветке, мы не умеем не показывать изменения, которые связаны исключительно с форматированием (для этого полностью форматируются две версии файла, старая и новая, после чего считается diff между ними).

Пример использования классов phpcf напрямую:

<?php
// Подключение констант и автолоадера
require_once ПУТЬ_К_PHPCF_SRC . '/src/init.php';
// Создание опций форматирования
$Options = new \Phpcf\Options();
// Опциональные настройки (для всех существуют дефолтные значения)
$Options->setTabSequence('   '); // Ваши 3-4 пробела или Tab
$Options->setMaxLineLength(130); // 120 по умолчанию
$Options->setCustomStyle('style'); // путь к директории с вашими стилями
$Options->toggleCyrillicFilter(true|false); // переключение фильтра кириллических символов
$Options->usePure(true); // принудительное  использование версии без extension
$Formatter = new \Phpcf\Formatter($Options);

// Форматирование файла
$Formatter->formatFile('file.php'); // весь файл
$Formatter->formatFile('file.php:1-40,65'); // диапазон строк

// Форматирование строки
$Formatter->format('<?php phpinfo()'); // вся строка с кодом
$Formatter->format($code, [1, 2, 10]); // номера строк для форматирования

// Все вышеуказанные функции форматирования возвращают объект \Phpcf\FormattingResult

$Result->getContent(); // строка с отформатированным кодом
$Result->wasFormatted(); // bool, был ли правлен код
$Result->getIssues(); // array, текстовое описание проблем форматирования кода
$Result->getError(); // \Exception|null ошибка при форматировании кода



Интеграция с IDE на примере PHPStorm


Если вы хотите уметь форматировать PHP-код с помощью нашего форматера и при этом пользуетесь PHPStorm, можно проделать следующие шаги:

1. git clone https://github.com/badoo/phpcf.git
2. В PHPStorm идем в настройки и находим секцию «External Tools».
3. Жмём «Add...» и заполняем поля:

Name: format whole file
Group: phpcf
Снимаем галочку «open console» (чтоб не надоедала)
Program: php
Parameters: путь_до_phpcf.git/phpcf apply $FilePath$
Working directory: любая

Name: format selection
Group: phpcf
Снимаем галочку «open console» (чтоб не надоедала)
Program: php
Parameters: путь_до_phpcf.git/phpcf apply $FilePath$:$SelectionStartLine$-$SelectionEndLine$
Working directory: любая


После этого вы можете привязать горячие клавиши к соответствующим действиям и форматировать либо файлы целиком, либо только выделенные фрагменты.

Помимо этого, можно таким же способом настроить работу “phpcf check-git --emacs”: в таком режиме phpcf будет печатать имена файлов с номерами строк в emacs-стиле, и благодаря этому можно переходить к указанной в выводе строке нажатием по ссылке.

Реализация


В процессе своей работы форматер проходит следующие этапы:

  1. Подготовка списка имен файлов и номеров строк, которые требуется отформатировать.
  2. Получение списка токенов для файла с помощью вызова token_get_all (prepareTokens).
  3. Преобразование токенов в единый формат с возможностью выполнения хуков, позволяющих заменять одни токены на другие.
  4. Вызов метода process(), который проходит по всем токенам с использованием конечного автомата Phpcf\Impl\Fsm и составляет массив действий по форматированию (exec).
  5. Вызов метода exec(), в котором сформированный массив действий обрабатывается и превращается в конечную строку.


Больше подробностей

Описание класса Phpcf\Impl\Fsm — конечного автомата для разбора токенов


Класс Phpcf\Impl\Fsm представляет из себя конечный автомат, в котором состояние представлено в виде стека (массива). Верхний элемент стека используется для правил перехода между состояниями:


<?php
$fsm_context_rules = array(
    'CTX_SOMETHING' => array(        // правила переходов, когда вершина стека = CTX_SOMETHING
        'T_1' => 'CTX_OTHER_THING',  // для токена T_1 заменить вершину стека на CTX_OTHER_THING
        'T_2' => array('CTX_OTHER_THING'),  // для токена T_2 выполнить push(CTX_OTHER_THING)
        'T_3' => -2,                 // для токена T_3 выполнить pop() со стека 2 раза
        // выполнить переход N сейчас и выполнить переход M перед обработкой следующего токена
        // полезно, чтобы дать отдельный контекст для закрывающей скобки
        'T_4' => array(PHPCF_CTX_NOW => N, PHPCF_CTX_NEXT => M),
        // cнять часть элементов со стека и заменить новым
        'T_5' => array('REPLACE' => array(-2, array('CTX_OTHER_THING')),
        // если в текущем контексте встречается токен, который не перечислен в массиве, ничего не делается
    ),
);



В общем виде правила для конечного автомата выглядят следующим образом:


<?php
$fsm_context_rules = array(
    '<context_name>[ ... <context_name>]' => array(
        '<token_code>[ ... <token_code>]' => <context_rule>,
    ),
);
 
$context_rule = '<context_name>';        // переход в состояние <context_name> с заменой вершины стека
$context_rule = array('<context_name>'); // переход в состояние <context_name>, добавив <context_name> в стек
$context_rule = -N;                      // перейти на N уровней вверх, N — натуральное число
// выполнить переход, определенный в секции PHPCF_CTX_NOW, а перед обработкой следующего токена выполнить переход,
// определенный в PHPCF_CTX_NEXT (в debug будет выводится сообщение об этом как о "delayed rule")
$context_rule = array(PHPCF_CTX_NOW => <context_rule>, PHPCF_CTX_NEXT => <context_rule>);



Массив правил форматирования: $controls


Форматер принципиально устроен так, что он регулирует только содержимое пробельных токенов (за исключением хуков). Все правила форматирования определяются в массиве "$controls", который представлен в следующем виде:


<?php
$controls = array(
    '<token_code>[ ... <token_code>]' => array(    // коды токенов через пробел, для которых применить указанное правило форматирования
        ['<context>' => <formatting_rule>,]        // применить <formatting_rule> для указанного контекста
        PHPCF_KEY_ALL => <formatting_rule>,        // применить <formatting_rule>, если правило для конкретного контекста не было найдено
    ),
);
 
$formatting_rule = array(
    PHPCF_KEY_DESCR_LEFT => '<description>',       // описание того, что надо сделать с пробелами слева токена
    PHPCF_KEY_LEFT => PHPCF_EX_<action>,           // действие, которое нужно выполнить с пробелами слева от токена
    PHPCF_KEY_DESCR_RIGHT => '<description>',      // описание действия справа от токена
    PHPCF_KEY_RIGHT => PHPCF_EX_<action>,          // действие, которое надо выполнить справа
);
 
// пример:
 
'T_AS T_ELSEIF T_ELSE T_CATCH' => array(  // для токенов "as", "elseif", "else" и "catch"
    PHPCF_KEY_ALL => array(               // во всех контекстах
        PHPCF_KEY_DESCR_LEFT => 'One space before as, elseif, else, catch',
        PHPCF_KEY_LEFT => PHPCF_EX_SHRINK_SPACES_STRONG,                    // все пробелы слева превратить в один (больший приоритет)
        PHPCF_KEY_DESCR_RIGHT => 'One space after as, elseif, else, catch', // то же самое
        PHPCF_KEY_RIGHT => PHPCF_EX_SHRINK_SPACES_STRONG,                   // сделать справа
    )
),



В случае, если определены разные правила для одних и тех же пробелов, например в "$a = $b;" после "=" требуется поставить 1 пробел, а перед $b — убрать все пробелы, то порядок применения правил зависит от их приоритета. Приоритет операций описан в секции «PHPCF_EX-constants»: чем выше стоит правило, тем выше у него приоритет.

Хуки на токены


В свойстве $token_hook_names определены названия методов, которые должны вызываться, когда метод prepareTokens натыкается на этот токен. Хуки определяются в следующем виде:


<?php
namespace Phpcf\Impl;

class Pure implements \Phpcf\IFormatter
{
    /*
     * Массив $idx_tokens содержит в себе массив вида array(T_SOMETHING => 'T_SOMETHING'),
     * необходимый для составления обработанного вида токена
     *
     * Параметр $i_value представляет из себя один токен в виде, отдаваемом token_get_all
     *
     * Метод может сдвигать текущую итерируемую позицию вперед с помощью чтения $this->tokens с использованием each()
     *  пример такого метода: tokenHookStr
     *
     * Метод должен возвращать массив токенов, на которые нужно заменить
     * текущий итерируемый токен в уже преобразованном виде
     *
     * Пример минимального хука, который ничего не делает с токеном:
     */
    private function tokenHookDoNothing($idx_tokens, $i_value)
    {
        if (is_array($i_value)) {
            $this->current_line = $i_value[2];
            return array(
                array(
                    PHPCF_KEY_CODE => $idx_tokens[$i_value[0]],
                    PHPCF_KEY_TEXT => $i_value[1],
                    PHPCF_KEY_LINE => $this->current_line,
                )
            );
            // set correct current line for next token if it does not have line number
            $this->current_line += substr_count($i_value[1], "\n");
        }
 
        return array(
            array(
                PHPCF_KEY_CODE => $i_value,
                PHPCF_KEY_TEXT => $i_value,
                PHPCF_KEY_LINE => $this->current_line,
            )
        );
    }
}



Описание хуков для токенов


Краткое описание хуков для токенов с описанием причины появления хука и его действий:

  • tokenHookHeredoc, tokenHookStr: по умолчанию PHP «токенизирует» текст внутри HEREDOC'ов, «двойных» и `косых` кавычек и выделяет там переменные. Поскольку форматер не должен трогать строки, содержимое объединяется в один токен;
  • tokenHookOpenBrace превращает токен "(" в "(_LONG" в том случае, если выражение в скобках является длинным (по умолчанию 120 символов) или в выражении есть перенос строки. Используется для того, чтобы различать «длинные» и «короткие» массивы, а также вызовы и определения функций;
  • tokenHookCheckUnary определяет, является ли оператор унарным (например "+", "-" и &). Служит для уменьшения количества переходов между контекстами в правилах;
  • tokenHookStatic отделяет вызовы вроде «static::HELLO» от использования в виде «public static function». Также служит для упрощения логики переходов между состояниями;
  • tokenHookClassdef определяет, что после ключевого слова идет перенос строки (например «const\n»), служит для корректного форматирования конструкций вида «const \n var1 = 1, \n var2 = 2;»;
  • tokenHookOpenTag проверяет, что открывающий тег является длинным, а также отделяет whitespace от открывающего тега (превращая "<?php \n" в два токена: "<?php" и "\n");
  • tokenHookCloseTag проверяет, что в конце файла нет закрывающего тега;
  • tokenHookIncrement определяет, с какой стороны от переменной находится оператор "++" или "–". Сделано для упрощения логики перехода между контекстами;
  • tokenHookWhiteSpace определяет, что выражения выровнены с помощью пробелов, и заменяет T_WHITESPACE на T_WHITESPACE_ALIGNED, который не трогается при форматировании;
  • tokenHookElse определяет, является ли else однострочным или содержит блоки. Сделано для упрощения логики;
  • tokenHookComment проверяет, что однострочные комментарии начинаются с "//", а также отделяет перенос строки от токена ("// something \n" превращается в "// something" и "\n");
  • tokenHookTString переименовывает T_STRING в T_FUNCTION_NAME, когда этот T_STRING — имя метода или функции. Сделано для упрощения логики перехода между контекстами;
  • tokenHookBinary служит для обработки ситуации с переносом оператора на следующую строку;
  • tokenHookComma превращает "," в ",_LONG" для запятых, которые можно перенести на новую строку в длинном массиве;
  • tokenHookFunction разделяет анонимные и неанонимные функции друг от друга.

Если хук меняет содержимое токена, то он должен проверить, имеет ли он право это делать ($can_change_tokens = !isset($this->lines) || isset($this->lines[$this->current_line])). Если не имеет, то хук не должен менять содержимое токенов, но может менять их количество и разделять токены на составные части. В качестве примера можно привести tokenHookOpenTag: он не проверяет содержимое открывающего тега, если пользователь запрашивал форматирование лишь части файла, но все равно отделяет whitespace от открывающего тега. Отделение whitespace требуется для того, чтобы правильно учитывалось количество строк (и indentation) после открывающего тега и однострочных комментариев.


Ссылки


PHP Beautifier — pear.php.net/package/PHP_Beautifier
PHP Code Sniffer — pear.php.net/package/PHP_CodeSniffer
PHP CS Fixer — https://github.com/fabpot/PHP-CS-Fixer

Наша утилита phpcf — https://github.com/badoo/phpcf/

Спасибо за чтение нашей статьи, мы готовы выслушать ваши предложения и замечания. Надеемся, вам понравится пользоваться нашей утилитой.

Юрий Насретдинов, PHP-разработчик Badoo
Александр Крашенинников, PHP-разработчик Badoo