Согласитесь, приятно и полезно, когда в проекте исходный код выглядит красиво и единообразно. Это облегчает его понимание и поддержку. Покажем и расскажем, как реализовать форматирование исходного кода при помощи 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, но с ней возникли проблемы:


  1. Программа определяла типы файлов только по расширению. Например, файлы с расширением ts, которые у нас имели формат xml, воспринимала как JavaScript и падала при форматировании. Потом, она зачем-то пыталась поправить pro-файлы проектов Qt, наверное, как Protobuf.
  2. Программу приходилось запускать вручную, перед добавлением файлов в индекс 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} менять файлы самостоятельно.

Демонстрация работы


  1. Установить clang-format-6.0
  2. cd /tmp && mkdir temp_project && cd temp_project
  3. git init
  4. Добавить под контроль версий и закомитить любой файл C++ под именем wrong.cpp. Желательно >50 строк неформатированного кода.
  5. Сделать скрипт .git/hooks/pre-commit, показанный выше.
  6. Назначить скрипту права на запуск (для git): chmod +x .git/hooks/pre-commit.
  7. Запустить вручную скрипт .git/hooks/pre-commit, он должен запускаться с сообщением «Nothing to be formatted», без ошибок интерпретатора.
  8. Создать 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; } } одной строкой или с другим плохим форматированием. В конце — перевод строки!
  9. git add file.cpp && git commit -m " file.cpp " должны быть сообщения от скрипта типа «Патч file.cpp применен без ошибок».
  10. git log -p -1 должен показать добавление форматированного файла.
  11. Если 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 и до коммита лучше не редактировать.

Вот пример такой ошибки:

  1. Добавить в file.cpp, " Second case " лишний std::endl. (std::cout << " Second case " << std::endl << std::endl;) и несколько табов лишнего отступа перед строкой.
  2. git add file.cpp
  3. Очистить строку (в этом же файле) с " First case " так, что бы на её месте остался(!) только перенос строки.
  4. 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.

Комментарии (31)



  1. neon1ks
    28.01.2019 15:59
    +2

    Костыльное решение. А не проще сделать день рефакторинга и переформатировать весь проект с помощью clang-format. А затем прикрутить clang-format к IDE. И дальше жить спокойно)


    1. stanislav888
      28.01.2019 16:17
      +1

      Как раз вашего варианта мы и хотели избежать. Потому что дождаться такого решения от PM-ов и всевозможных Lead-ов нереально. Дополнительный коммит, который только форматирует код, тем более глобально, затрудняет чтение истории изменений.


      1. staticmain
        28.01.2019 17:08
        +2

        Т.е. у вас все это время все писали вразнобой и никто не соблюдал codestyle?


        1. mapron
          28.01.2019 19:03

          Если проекту лет так дцать, то ситуация «писали вразнобой» более чем вероятна. Дело не в том что никто не соблюдал, а скорее что какие-то древние фрагменты писались просто до возникновения кодестайла.


        1. Promwad Автор
          28.01.2019 19:08

          Конечно, сode style на проекте был, но исходно его делали вручную, т.к. некоторые файлы нельзя было форматировать целиком. Это отнимало время и порождало ненужные дискуссии. Те, кто делал review коммитов, писали замечания по каждому пропущенному пробелу или переносу строки, заворачивали merge request-ы на доработку.

          Разработчиков не радовало, что из-за таких пустяков приходилось возвращаться к задаче. Тем более что несвоевременно влитый merge request это ещё и merge с новыми изменениями, сделанными другими.

          На проекте работала распределенная команда: часть — на стороне Promwad, часть — на стороне заказчика. Поэтому в рамках этой конкретной разработки мы договорились использовать общий инструмент для более простого и удобного форматирования.

          Сейчас разработчики просто коммитят свой код, не заботясь о том, правильно ли там расставлены пробелы с переносами. Проблемы возникают только при ошибках форматера, но их гораздо проще обойти при коммите, чем потом ловить замечания от Lead-ов. :-)


  1. technic93
    28.01.2019 17:23

    Пытался использовать clang-format но он не работал с переносами строчек нормально. Была у меня из Qt функция connect принимает 4 аргумента: sender, signal, receiver, slot. Все четыре не помещались в строчку, тогда я сделал так


    connect(sender, signal,
            receiver, slot);

    Запустил шланг и он мне выдал такое уродство:


    connect(sender, signal, receiver,
            slot);
    


    1. vintage
      28.01.2019 17:25

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


      connect(
          sender, 
          signal, 
          receiver,
          slot,
      )


      1. technic93
        28.01.2019 18:14

        после каждой запятой делать перенос, не всегда удобно. В другом примере у меня были сгруппированы по логике елементы массива в несколько строчек, а он из них сделал матрицу 2x4. Я так и не нашёл как это настраивается.


        1. tzlom
          28.01.2019 22:48

          // clang-format off
          ... My beautiful something ...
          // clang-format on

          Касательно переносов и прочего форматирования — день поработать с автоформатированием, включённым при сохранении файла ( Qt Creator так умеет) — вообще перестаёшь задумываться над форматированием.
          Я пишу жуткую фигню как придётся, потом жму сохранить и раз — красивый код, сколько там трудов ушло на расставление переносов — знать не знаю.
          Мне это на столько удобно, что трюк выше я пока использовал всего один раз когда цланг портил колонки данных в константе, при этом в случае массива объектов он неплохо справляется с форматированием.


          1. technic93
            29.01.2019 00:02

            А какой используете .clang-format файл?


            1. khim
              29.01.2019 01:39

              Любой. Вначале глаз сильно цепляется за отдельные некрасивости, но быстро приходит понимание, что лучше иметь код, который единообразен, пусть и чуток некрасив кое-где, чем код, который местами — хотя на выставку произведений искусства посылай, а местами — коряв просто потому, что вы неудачно пробелы поставили.

              Да, clang-format никогда не научится понимать, что какие-то параметры тесно связаны между собой — ну и фиг с ним. Лучше выставить ширину строки где-нибудь на 100-120 символов и всё: случаев, когда вызов функции с четырьмя аргументами перестанет помещаться в строку, станет меньше — и их уже можно будет терпеть переформатированными в столбик.


    1. stanislav888
      28.01.2019 17:44

      Можно увидеть ваш коннект в оригинале, без сопутствующего кода? Плюс конфиг .clang-format.


      1. technic93
        28.01.2019 18:19

        Например:


          connect(this, &ColorListWindow::colorChanged,
                  mapper, &QDataWidgetMapper::submit);

        Cтиль пробовал просто от Google.


        1. stanislav888
          29.01.2019 10:40

          У меня получился вот такой код.

          int main() {
            connect(this, &ColorListWindow::colorChanged, mapper,
                    &QDataWidgetMapper::submit);
          }

          Это потому что в конфиге от гугла есть: ColumnLimit: 80
          Не вижу ничего ужасного тут, всё вполне логично. Длинные строки кода надо переносить ибо это нечитаемо.
          Если у вас другое мнение на этот счёт, изучайте опции конфига clang-format. Насколько помню, там можно разрешить ручной перенос аргументов функции на разные строки.


          1. technic93
            29.01.2019 12:05

            Я с самого начала о том и писал что он поменял расположение переноса.


            1. Tantrido
              29.01.2019 22:07

              У меня та же ерунда: взял clang-format файл от Qt — вроде всё хорошо, но лямбда функции форматирует ужасно и connect, если в строку не помещается разбивает на 4 строки, хотя можно было бы так:

                connect(this, &ColorListWindow::colorChanged, 
                        mapper, &QDataWidgetMapper::submit);

              Хотя файл официальный, но после форматирования code review пройти не возможно ;) Пришлось его при сохранении отключить, где настроить данные параметры тоже не нашёл, хотя просмотрел всю документацию по clang-format.


              1. stanislav888
                30.01.2019 10:16

                Попробуйте другую версию clang-format.


                1. Tantrido
                  30.01.2019 14:18

                  Это какую другую?! В системе используется та же, что и в QtC — 7.0.1 кажется. Попробовать как-то установить 9-ю? Там много поменялось?


                  1. stanislav888
                    30.01.2019 17:12

                    Попытка не пытка. Может будет и лучше. У меня в Debian, например доступна ещё и 6-ая наряду с 7-ой. Не обязательно же только повышать версию.
                    Возможно, вам надо форматировать diff кода перед тем как коммитить его

                    git diff -U0 --no-color | clang-format-diff-6.0 -i -p1
                    если под Linux.
                    Или git-clang-format уже после коммита. Далее смотрите изменения форматера и git commit --amend --no-edit (Хотя сам не пробовал так)


                    1. Tantrido
                      30.01.2019 17:53

                      У меня если запускается формат, то только при сохранении — хочется всё-таки сразу видеть результат.


              1. khim
                30.01.2019 15:05

                Хотя файл официальный, но после форматирования code review пройти не возможно ;)
                А вы пробовали? Обычно если в проекте начинают использовать clang-format, то претензии к расстановке пробелов больше в code review не рассматриваются: как clang-format отформатировал — так и будет.

                Иначе в нём и смысла-то никакого нет: зачем нужен clang-format, если он время не экономит?


                1. Tantrido
                  30.01.2019 18:15

                  Смешной вопрос. Конечно пробовал, я же написал выше, что пришлось отключить. Пробелы он отлично расставляет, а вот отступы и переносы не всегда так как хочется. Команда в Qt большая — одни советуют clang-format использовать, а другие — не принимают код отформатированный этим форматом при code review ;)


  1. Cheater
    28.01.2019 22:23
    +1

    Имхо опасно делать такие pre-commit hook.


    Во-первых, вы должны абсолютно доверять clang-format и молчаливо считать, что его переформатирование абсолютно всегда правильно (готовы это гарантировать? Вообще для любого кода?) Во-вторых, бывают случаи, когда нарушение стиля кода — вынужденная мера. В вашем же случае автор коммита даже не узнает про то, что его код был переформатирован.


    Более корректное решение: Hook должен отклонять неподходящий коммит. После чего происходит 1 из 2 вещей: а) автор переформатирует код clang-овским или любым другим тулом и отправляет повторно (он также может заранее попытаться реализовать автоформатирование на своей стороне): б) автор доказывает необходимость ошибки форматирования, и коммит разрешается в административном порядке.


    1. stanislav888
      29.01.2019 10:55

      В вашем же случае автор коммита даже не узнает про то, что его код был переформатирован.

      Неправда. Там выводиться либо «Nothing to be formatted» либо полный diff форматера. Исключение может быть только в GUI утилитах, которые не показывают вывод git commit.

      Более корректное решение: Hook должен отклонять неподходящий коммит.

      На практике, в pipeline -ах сервера где делают code review(gitlab например), надо показывать что для .clang-format проекта есть diff в рассматриваемом коммите. Тогда, тот кто делает review решает что дальше.


  1. khim
    29.01.2019 02:09

    А почему был использован голый clang-format? git-clang-format чем-то не подходил?


    1. stanislav888
      29.01.2019 11:53

      Тут нет большой разницы что использовать. Т.к. обе утилиты не могут переписать сделанный коммит или изменения в индексе. Они всего лишь берут один diff со входа и выдают другой на выходе. А вы уже сами должны встраивать применение этого diff в коде хука.


  1. bfDeveloper
    29.01.2019 12:09

    Раз уж тут собрались знатоки clang-format, то посмею задать вопрос не совсем в тему статьи. Есть ли какой-нибудь способ объяснить ему несколько допустимых вариантов форматирования? Самый простой пример — разрешить делать однострочные функции, но если в коде уже есть перенос, то не трогать его? Это вкусовщина, но уже привык делать однострочными только конструкции вида return smth; и никогда не заталкивать никакую логику. Кстати, встречал и полностью обратное — однострочные if разрешены, но return всегда должен быть перенесён, чтобы проще было искать точки выхода. Ну или есть ли другие форматилки, которые оставляют свободу выбора?


    1. stanislav888
      29.01.2019 12:32

      Есть ли какой-нибудь способ объяснить ему несколько допустимых вариантов форматирования?
      Это опции конфига начинающиеся с «Allow». В вашем случае AllowAllParametersOfDeclarationOnNextLine
      clang.llvm.org/docs/ClangFormatStyleOptions.html


    1. technic93
      29.01.2019 13:51
      +1

      Из ответа на stack-overflow следует что clang-format сначало всё парсит в AST а потом собирает назад согласно указанным правилам, т.е. информация о начальном форматировании теряется полностью.


  1. orion_tvv
    29.01.2019 14:11

    У шланг-формата очень плохо с поддержкой. В минорном апдейте несколько раз ломали дефолтное поведение. Пытались решить это прибив версию, но, как оказалось (создатели это подтвердили) что нет никакой гарантии что билды под макось и линукс одной версии работают одинаково, а по факту всегда есть различия. Штука очень нужная и полезная, но с переносимостью нужно что-то делать, например завернуть в докер и вызывать по хоткею из ide или по при сохранении файла. Прекомит хук нам показался не очень удобным и опасным