В этой простенькой статье расскажу о проблемах, с которыми я столкнулся при изучении CMake, и о их решениях. Если кратко, то с помощью .cmake файла от TheLartians, а также замечания от Sazonov у меня получилось из такого:

Пример древа проекта до
Пример древа проекта до

Сделать такое:

Пример древа проекта послe
Пример древа проекта послe

Для кого и зачем эта статья?

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

Подмечу что мой пример и мои проекты хранят заголовочные файлы вместе с CPP файлами, то есть у меня нету в проектах директории Include, где обычно располагают хейдеры. Для моих пока что мелких проектов создавать библиотеки не имеет смысла. Имейте это ввиду!

Приступим к решению проблемы

UPD: Как и ожидалось, моё решение проблемы оказалось в значительной степени нагромождено лишним и ненужным .В комментариях к статье пользователь Sazonov указал, что создать древовидную структуру можно проще, если воспользоваться второй сигнатурой команды source_group, которая выглядит следующим образом:

source_group(TREE <root> [PREFIX <prefix>] [FILES <src>...])

В качестве аргументов команда принимает абсолютный путь к папке проекта <root>), опциональный параметр <prefix>, добавляющий к началу древа проекта директорию, которую мы укажем (она не обязательно должна существовать, так как этот пункт влияет только на вид внутри Visual Studio), и опциональный параметр с путями файлов, которые следует обработать. Разберём сначала решение проблемы с помощью данной команды. Допустим имеется следующая структура проекта :

ExampleFolderStructure
.
├── CMakeLists.txt
└── src
    ├──  Game
    │   ├──  Game.cpp
    |   ├──  Game.h
    |   └──  GameObjects
    │        ├──  IGameObject.cpp
    |        └──  IGameObject.h
    └──  ResourceManager
         ├──  ResourceManager.cpp
         └──  ResourceManager.h

Для создания древа файлов понадобится вписать в главный CMakelists.txt следующее:

cmake_minimum_required(VERSION 3.8)

project(my_game)

FILE(GLOB_RECURSE headers "src/*.h")
FILE(GLOB_RECURSE sources "src/*.cpp")

add_executable(my_game ${sources} ${headers})

source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR}  FILES ${sources} ${headers})

Абсолютный путь к папке проекта можно либо записать вручную(для меня это "E:/ExampleFolderStructure"), либо воспользоваться кэшированной переменной ${CMAKE_CURRENT_SOURCE_DIR}.

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

Подробная инструкция с другой реализацией

Cначала создадим таргет со всеми исполняемыми и заголовочными файлами. Сделать это можно либо введя все файлы вручную, либо воспользовавшись командой file. Воспользуемся вторым подходом:

cmake_minimum_required(VERSION 3.8)

project(my_game)

set_property(GLOBAL PROPERTY USE_FOLDERS ON)

FILE(GLOB_RECURSE headers "src/*.h")
FILE(GLOB_RECURSE sources "src/*.cpp")

add_executable(my_game ${headers} ${sources})

Первые две строчки думаю понятны. Пятая строка устанавливает свойство USE_FOLDER в true, и теперь мы сможем отправить таргеты внешних библиотек(Например, glad или glfw если вы работаете с OpenGL) в папки, чтобы они меньше мешались. 7 и 8 строка создает переменные headers и sources в которых находятся пути ко всем файлам каждого типа. Если рассмотреть поподробнее, то первый аргумент указывает на действие, которое будет выполнять команда file(в данном случае поиск путей к файлам определенного типа), второй параметр за переменную, куда всё будет записано, а третий аргумент отвечает за расположение директории, в которой нужно искать файлы определённого расширения. Уже теперь наш проект выглядит куда симпатичнее

ALL_BUILD и ZERO_CHECK были автоматический добавлены в папку по умолчанию, но Header Files и Source Files остались
ALL_BUILD и ZERO_CHECK были автоматический добавлены в папку по умолчанию, но Header Files и Source Files остались

Затем опционально можно обернуть наш таргет my_game в папку с помощью set_target_properties():

set_target_properties(my_game PROPERTIES FOLDER "myGameFolder")

Первый аргумент отвечает за таргет, который будет помещён в папку с названием, который задаётся 4 аргументом.

Теперь нам нужно будет воспользоваться функцией от пользователя TheLartians с его репозитория GroupSourcesByFolder.cmake. Можно как в инструкции импортировать его cmake файл в свой проект, но для простоты просто скопируем саму функцию, которая выглядит следующим образом:

function(GroupSourcesByFolder target) 
  set(SOURCE_GROUP_DELIMITER "/") 
  set(last_dir "")
  set(files "")

  get_target_property(sources ${target} SOURCES)
  foreach(file ${sources})                                            
    file(RELATIVE_PATH relative_file "${PROJECT_SOURCE_DIR}" ${file}) 
    get_filename_component(dir "${relative_file}" PATH)               
    if(NOT "${dir}" STREQUAL "${last_dir}")
      if(files)
        source_group("${last_dir}" FILES ${files})
      endif()
      set(files "")
    endif()
    set(files ${files} ${file})
    set(last_dir "${dir}")
  endforeach()

  if(files)
    source_group("${last_dir}" FILES ${files})
  endif()
endfunction()

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

get_target_property(sources ${target} SOURCES) #в sources теперь записаны полные пути к файлам

Затем запускается цикл, в каждом проходе которого мы получаем относительную папку файла:

 file(RELATIVE_PATH relative_file "${PROJECT_SOURCE_DIR}" ${file}) 
  get_filename_component(dir "${relative_file}" PATH)    
# теперь dir хранит в себе относительный путь к директории, где находится текущй путь файла

Далее происходит интересная проверка. Если директория текущего файла совпадает с директории предыдущего файла(мы знаем это, потому что после каждого прохода цикла сохраняем dir в last_dir), то мы только добавляем путь текущего файла к переменной files. Если же случилось так, что при прохождении по циклу текущий файл будет из другой директории, то мы все накопленные пути в переменной files добавляем в last_dir c помощью следующей команды:

source_group("${last_dir}" FILES ${files})

В конце функции, после цикла, имеется ещё одна проверка, чтобы последние файлы добавились в нужную директорию, так как цикл просматривает последние файлы, но не добавляет их. Теперь осталось только вызывать функцию и всё! Весь CMakelists.txt выглядит следующим образом:

cmake_minimum_required(VERSION 3.8)

project(my_game)

set_property(GLOBAL PROPERTY USE_FOLDERS ON)

FILE(GLOB_RECURSE headers "src/*.h")
FILE(GLOB_RECURSE sources "src/*.cpp")

add_executable(my_game ${headers} ${sources})

set_target_properties(my_game PROPERTIES FOLDER "myGameFolder")


function(GroupSourcesByFolder target) 
  set(SOURCE_GROUP_DELIMITER "/")
  set(last_dir "")
  set(files "")

  get_target_property(sources ${target} SOURCES)
  foreach(file ${sources})                                            
    file(RELATIVE_PATH relative_file "${PROJECT_SOURCE_DIR}" ${file}) 
    get_filename_component(dir "${relative_file}" PATH)               
    if(NOT "${dir}" STREQUAL "${last_dir}")
      if(files)
        source_group("${last_dir}" FILES ${files})
        message(${files})
      endif()
      set(files "")
    endif()
    set(files ${files} ${file})
    set(last_dir "${dir}")
  endforeach()

  if(files)
    source_group("${last_dir}" FILES ${files})
  endif()
endfunction()

GROUPSOURCESBYFOLDER(my_game)

Теперь если запустить конфигурацию и билд через GUI CMake-a, мы получим вот такой вот результат:

Кра-со-та!
Кра-со-та!

Рекомендую подебагерить и поизучать код самостоятельно с помощью команды message.

Хочу подметить, что так как мы используем команду file, то при изменении структуры проекта или внесения дополнительных файлов понадобится заново собирать проект через CMake, а не Visual Studio. Не получится в самой визуалке добавить в таргет исполняемых фалов новый и сразу же вызвать билд ctrl + shift + B. Нужно будет либо через командную строку вызвать создание проекта, либо через графическую оболочку CMake. Если вас такой подход не устраивает, то следует самостоятельно прописывать все используемые файлы, как в примере ниже:

cmake_minimum_required(VERSION 2.8.10)
project(Main CXX)
set(
    source_list
    "main.cpp"
    "Logging/MyLog.cpp"
    "Logging/MyLog.h"
     "InputOutput/InputOutput.cpp"
     "InputOutput/InputOutput.h"
   )

add_executable(Main ${source_list})

foreach(source IN LISTS source_list)
  get_filename_component(source_path "${source}" PATH)
  string(REPLACE "/" "\\" source_path_msvc "${source_path}")
  source_group("${source_path_msvc}" FILES "${source}")
endforeach()

Пока писал статью, нашел на русском stack overflow вопрос в котором пользователь Евгений привел более упрощенную версию цикла foreach(код выше и есть его). Данный код работает аналогично, только имеет некоторые мелкие изменения, которые позволяют работать только в Visual Studio не обращаясь за билдингом к CMake-GUI или CMD.

Итоги

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

А уже после вот так:

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

Возможно для кого то может показаться странным и даже глупым то, что я пишу здесь. И может быть вы и будете правы. На изучение CMake я отвожу меньше времени чем OpenGL и потому у меня возникают порой глупые вопросы и ошибки, решение которых, однако, порой бывает трудно найти. Если вы нашли ошибку в статье или какую то неточность, то прошу поправить меня!

Ресурсы, к которым я обращался в процессе поиска решения:

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


  1. Sazonov
    13.08.2023 12:08
    +1

    Посмотрите вторую сигнатуру source_group: https://cmake.org/cmake/help/latest/command/source_group.html

    Есть мнение что вы изобрели велосипед.


    1. F1Soda Автор
      13.08.2023 12:08
      +1

      Вы правы! Признаю, допустил грубую ошибку -- не прочитал полностью документацию. Я видел вторую сигнатуру и пытался ею сам воспользоваться, но после первой же неудачной попытки отбросил старания и продолжил искать другие решения. А как оказалось, большая часть статей и репозиториев с github использовали версию с циклом foreach. Я добавлю ваше замечание к статье. Большое спасибо!


      1. Sazonov
        13.08.2023 12:08
        +1

        У нас на одном проекте было ограничение по версии cmake и из-за этого приходилось делать что-то похожее на ваше решение. Только мы вообще с нуля, руками, генерировали файл с фильтрами для студии.