Согласитесь, приятно и полезно, когда в проекте исходный код выглядит красиво и единообразно. Это облегчает его понимание и поддержку. Покажем и расскажем, как реализовать форматирование исходного кода при помощи clang-format, git и sh.
Проблемы с форматированием и как их решить
В большинстве проектов существуют определенные правила оформления кода. Как сделать так, чтобы все участники их выполняли? На помощь приходят специальные программы — clang-format, astyle, uncrustify, — но у них есть свои недостатки.
Главная проблема форматеров состоит в том, что они меняют файлы целиком, а не только изменённые строки. Расскажем, как мы с этим справились, используя ClangFormat в рамках одного из проектов по разработке встроенного ПО для электроники, где С++ был основным языком. В команде работало несколько человек, поэтому для нас было важно обеспечить единый стиль кода. Наше решение может подойти не только программистам С++, но и тем, кто пишет код на C, Objective-C, JavaScript, Java, Protobuf.
Для форматирования мы использовали clang-format-diff-6.0. На старте запустили команду
git diff -U0 --no-color | clang-format-diff-6.0 -i -p1, но с ней возникли проблемы:
- Программа определяла типы файлов только по расширению. Например, файлы с расширением ts, которые у нас имели формат xml, воспринимала как JavaScript и падала при форматировании. Потом, она зачем-то пыталась поправить pro-файлы проектов Qt, наверное, как Protobuf.
- Программу приходилось запускать вручную, перед добавлением файлов в индекс git. Легко было об этом забыть.
Решение
В результате получился следующий sh-скрипт, запускаемый как pre-commit — хук для git:
#!/bin/sh
CLANG_FORMAT="clang-format-diff-6.0 -p1 -v -sort-includes -style=Chromium -iregex '.*\.(cxx|cpp|hpp|h)$' "
GIT_DIFF="git diff -U0 --no-color "
GIT_APPLY="git apply -v -p0 - "
FORMATTER_DIFF=$(eval ${GIT_DIFF} --staged | eval ${CLANG_FORMAT})
echo "\n------Format code hook is called-------"
if [ -z "${FORMATTER_DIFF}" ]; then
echo "Nothing to be formatted"
else
echo "${FORMATTER_DIFF}"
echo "${FORMATTER_DIFF}" | eval ${GIT_APPLY} --cached
echo " ---Format of staged area completed. Begin format unstaged files---"
eval ${GIT_DIFF} | eval ${CLANG_FORMAT} | eval ${GIT_APPLY}
fi
echo "------Format code hook is completed----\n"
exit 0
Что делает скрипт:
GIT_DIFF=" git diff -U0 --no-color " — изменения в коде, которые подадут на вход clang-format-diff-6.0.
- -U0: обычно git diff выводит так называемый «контекст»: несколько неизменёных строк кода вокруг тех, что были изменены. Но clang-format-diff-6.0 форматирует их тоже! Поэтому контекст в данном случае не нужен.
CLANG_FORMAT=" clang-format-diff-6.0 -p1 -v -sort-includes -style=Chromium -iregex '.*\.(cxx|cpp|hpp|h)$' " — команда для форматирования diff, полученного через стандартный ввод.
- clang-format-diff-6.0 — скрипт из пакета clang-format-6.0. Есть другие версии, но все тесты были только на этой.
- -p1 взят из примеров в документации, обеспечивает совместимость с выводом git diff.
- -style=Chromium — готовый пресет стиля форматирования кода. Другие возможные значения: LLVM, Google, Mozilla, WebKit.
- -sort-includes — опция сортировки по алфавиту директив #include (не обязательна).
- -iregex '.*\.(cxx|cpp|hpp|h)$' — регулярное выражение, фильтрующее имена файлов по расширениям. Тут перечислены только те расширения, которые надо форматировать. Это убережёт программу от падения и неожиданных глюков. Скорее всего список нужно будет дополнить в новых проектах. Кроме С++ можно форматировать C/Objective-C/JavaScript/Java/Protobuf. Хотя эти типы файлов мы не тестировали.
GIT_APPLY=" git apply -v -p0 — " — применение к коду патча, выданного предыдущей командой.
- -p0: по умолчанию git apply пропускает первый компонент в пути к файлу, это несовместимо с форматом, который выдаёт clang-format-diff-6.0. Здесь отключено такое пропускание.
FORMATTER_DIFF=$(eval ${GIT_DIFF} --staged | eval ${CLANG_FORMAT}) — изменения форматера для индекса.
echo "${FORMATTER_DIFF}" | eval ${GIT_APPLY} --cached форматирует исходный код в индексе (после git add). К сожалению, нет такого хука, который срабатывал бы перед добавлением файлов в индекс. Поэтому форматирование разделено на две части: форматируется то, что в индексе и отдельно то, что не добавлено в индекс.
eval ${GIT_DIFF} | eval ${CLANG_FORMAT} | eval ${GIT_APPLY} — форматирование кода не в индексе (запускается, только когда что-то было отформатировано в индексе). Форматирует вообще все текущие изменения в проекте (под контролем версий), а не только из предыдущего шага. Это спорное, на первый взгляд, решение. Но оно оказалось удобным, т.к. рано или поздно другие изменения надо форматировать тоже. Можно заменить "| eval ${GIT_APPLY}" опцией -i, которая заставит ${CLANG_FORMAT} менять файлы самостоятельно.
Демонстрация работы
- Установить clang-format-6.0
- cd /tmp && mkdir temp_project && cd temp_project
- git init
- Добавить под контроль версий и закомитить любой файл C++ под именем wrong.cpp. Желательно >50 строк неформатированного кода.
- Сделать скрипт .git/hooks/pre-commit, показанный выше.
- Назначить скрипту права на запуск (для git): chmod +x .git/hooks/pre-commit.
- Запустить вручную скрипт .git/hooks/pre-commit, он должен запускаться с сообщением «Nothing to be formatted», без ошибок интерпретатора.
- Создать file.cpp с содержимым int main() { for (int i = 0; i < 100; ++i) { std::cout << " First case " << std::endl; std::cout << " Second case " << std::endl; std::cout << " Third case " << std::endl; } } одной строкой или с другим плохим форматированием. В конце — перевод строки!
- git add file.cpp && git commit -m " file.cpp " должны быть сообщения от скрипта типа «Патч file.cpp применен без ошибок».
- git log -p -1 должен показать добавление форматированного файла.
- Если file.cpp попал в коммит действительно форматированным, значит можно тестировать форматирование только в diff. Измените пару строк wrong.cpp так, чтобы форматер на них среагировал. Например, добавьте неадекватные отступы в коде вместе с другими изменениями. git commit -a -m " Format only diff " должен залить форматированные изменения, но не затронуть другие части файла.
Недостатки и проблемы
git diff --staged (который здесь ${GIT_DIFF} --staged) выдаёт diff только тех файлов, что были добавлены в индекс. А clang-format-diff-6.0 обращается к полным версиям файлов за пределами него. Поэтому, если изменить какой-то файл, сделать git add, а потом изменить тот же файл, то clang-format-diff-6.0 будет генерировать патч для форматирования кода (в индексе) на основе отличающегося файла. Таким образом, файл после git add и до коммита лучше не редактировать.
Вот пример такой ошибки:
- Добавить в file.cpp, " Second case " лишний std::endl. (std::cout << " Second case " << std::endl << std::endl;) и несколько табов лишнего отступа перед строкой.
- git add file.cpp
- Очистить строку (в этом же файле) с " First case " так, что бы на её месте остался(!) только перенос строки.
- git commit -m " Formatter error on commit ".
Скрипт должен сообщить " error: при поиске: ", т.е. git apply не нашёл контекст патча, выданного clang-format-diff-6.0. Если вы не поняли, в чём тут проблема, просто не меняйте файлы после git add их и до git commit. Если надо поменять, можете сделать коммит (без push) и потом git commit --amend с новыми изменениями.
Самое серьёзное ограничение — необходимость иметь в конце каждого файла перевод строки. Это старая особенность git, поэтому большинство редакторов кода, поддерживают автоматическую вставку такого перевода в конец файла. Без этого скрипт будет падать при коммите нового файла, но это не принесет никакого вреда.
Очень редко clang-format-diff-6.0 форматирует код неадекватно. В этом случае можно добавить какие-нибудь бесполезные элементы в код, типа точки с запятой. Либо, окружить проблемный код комментариями, /* clang-format off */ и /* clang-format on */.
Также clang-format-diff-6.0 может выдавать неадекватный патч. Это заканчивается тем, что git apply не принимает его, и код части коммита остается неотфоматированным. Причина — внутри clang-format-diff. Нет времени разбираться во всех ошибках программы. В этом случае можно посмотреть на патч форматирования с помощью команды git diff -U0 --no-color HEAD^ | clang-format-diff-6.0 -p1 -v -sort-includes -style=Chromium -iregex '.*\.(cxx|cpp|hpp|h)$'. Самым простым решением будет добавление опции -i к предыдущей команде. В этом случае утилита не будет выдавать патч, а отформатирует код. Если не помогло, можно попробовать форматирование для отдельных файлов целиком clang-format-6.0 -i -sort-includes -style=Chromium file.cpp. Далее git add file.cpp и git commit --amend.
Есть предположение, что чем ближе ваш конфиг .clang-format к одному из пресетов, тем меньше таких ошибок вы увидите. (Здесь его заменяет опция -style=Chromium).
Отладка
Если хотите посмотреть, какие изменения сделает скрипт на ваших текущих правках (не в индексе), используйте git diff -U0 --no-color | clang-format-diff-6.0 -p1 -v -sort-includes -style=Chromium -iregex '.*\.(cxx|cpp|hpp|h)$' Также можно проверить, как будет работать скрипт на последних коммитах, например, на тридцати: git filter-branch -f --tree-filter " ${PWD}/.git/hooks/pre-commit " --prune-empty HEAD~30..HEAD . Данная команда должна была форматировать предыдущие коммиты, но по факту меняет только их id. Поэтому стоит проводить такие эксперименты в отдельной копии проекта! После она станет непригодной для работы.
Заключение
Субъективно, от такого решения гораздо больше пользы чем вреда. Но надо тестировать поведение clang-format-diff разных версий на коде вашего проекта, с конфигом для вашего стиля кода.
К сожалению, такой же git-hook для Windows мы не делали. Предлагайте в комментариях, как это сделать там. А если нужна статья для быстрого старта с clang-format, советуем посмотреть описание ClangFormat.