cmkr源码解析:从TOML解析到生成CMakeLists.txt的完整旅程(project_parser与cmake_generator)
cmkr 是一款基于 CMake 与 TOML 的现代 C++ 构建系统:它读取 cmake.toml,自动为你生成一份地道的 CMakeLists.txt。本文带你走读 cmkr 源码,完整拆解 project_parser(TOML 解析)与 cmake_generator(CMake 代码生成)两大核心模块,看懂这份文件"从 TOML 到 CMake"的翻译之旅 🧭
一、cmkr 是什么?一个构建文件的"翻译官"
写 CMake 的人都见过这样的场景:缩进、引号、分号、target_* 命令……语法细节又多又碎。cmkr 的思路很直接——让你写声明式的 TOML,CMake 脚本由程序自动生成。
一个最小项目的 cmake.toml 长这样(参考 tests/basic/cmake.toml 的写法):
[project]
name = "cmkr_for_beginners"
[target.hello_world]
type = "executable"
sources = ["src/main.cpp"]
就这么几行,cmkr 就能生成等价的 CMakeLists.txt。更妙的是,cmkr 用自己生成的 CMake 文件构建自己——打开项目根目录的 cmake.toml 就能看到它"吃自己的狗粮":版本 0.2.46、声明 src/*.cpp 通配符源文件、链接四个第三方单文件库。
二、整体架构:一条三步流水线 🛠️
cmkr 的源码组织非常克制,核心代码就集中在两个文件里:
| 模块 | 位置 | 职责 |
|---|---|---|
| 入口 | src/main.cpp(L8-L21) | 解析命令行参数(init / gen / build 等),统一捕获异常 |
| TOML 解析 | src/project_parser.cpp(L212-L892) | 读取 cmake.toml,填充 Project 结构体,报错精确到行列 |
| 数据模型 | include/project_parser.hpp(L178-L220) | 定义 Project/Target/Test 等结构体,即"中间表示" |
| CMake 生成 | src/cmake_generator.cpp(L724-L1633) | 把 Project 逐段翻译成 CMake 命令并写盘 |
| 引导脚本 | cmake/cmkr.cmake | 让 cmake -B build 时自动触发重新生成 |
这种"解析 → 中间表示 → 生成"的经典三段式设计,是理解整个项目的钥匙:两个模块之间唯一的契约就是 Project 结构体。
三、project_parser:把 cmake.toml 读成结构化数据
关键设计:Condition,一切皆可条件化
源码里最高频的类型是 Condition(include/project_parser.hpp L13-L16):
template <typename T>
using Condition = tsl::ordered_map<std::string, T>;
它是一个"条件名 → 值"的有序映射,键为空字符串表示无条件生效。于是 sources、link-libraries、cmake-after 这些字段全都变成了 ConditionVector——这就是 cmkr 支持按平台分写的底层原因。
看看 tests/conditions/cmake.toml 里有多直观:
[target.example]
type = "executable"
sources = ["src/main.cpp"]
windows.sources = ["src/windows_specific.cpp"] # 只在 Windows 追加源文件
linux.cmake-after = "message(STATUS linux-after)"
解析时(src/project_parser.cpp L82-L104 的 optional 重载),程序会遍历 TOML 表:顶层键直接落到 destination[""],嵌套子表(如 windows)则作为条件键存入 destination["windows"]。
优雅报错:错误信息直接"画出"出错行
cmkr 的错误提示体验相当好。format_key_message(src/project_parser.cpp L35-L66)会把错误定位到 TOML 文件的具体行列,并在下方用 ~ 标出有问题的键。配合 TomlChecker(L68-L169)这个"访问追踪器":每读取一个键就记录一次,全部解析完后统一 check(),凡是你写了却没被识别的键、或引用了未定义的条件,统统报"Unknown key / Unknown condition"——拼写错误基本无处遁形。
内置条件:开箱即用的平台判断
解析器在构造函数里预置了一批常用条件(src/project_parser.cpp L261-L279):windows、linux、macos、gcc、msvc、clang、x64、ios、android 等,全部映射到对应的 CMake 判断表达式。你可以直接写 msvc.sources = [...],也可以自定义 [conditions] 追加自己的条件(注意只能在根项目定义,子目录会自动继承父级条件)。
逐个"表"解析
Project 构造函数按 TOML 表逐一处理:[cmake](版本、生成器)、[project](名称、语言、版本)、[options](自动生成同名条件)、[variables]、[find-package]、[fetch-content]、[template]、[target]、[[test]]、[[install]]、[vcpkg]。其中目标解析最复杂,封装在 parse_target lambda 中(L593-L772):识别目标类型、合并 headers 进 sources、校验相对路径的库文件是否真实存在、处理 msvc-runtime 等——校验前移,把"配置错误"消灭在生成之前。
四、cmake_generator:CMakeLists.txt 是如何"写"出来的
Command 类:一条 CMake 命令的"打印机"
生成的代码之所以缩进整齐、引号规范,功劳全在 Command 结构体(src/cmake_generator.cpp L317-L484)。它采用可变参数模板 + 流式风格,内部自动处理:
- 智能引号:参数含空格、
;、$等字符才加引号,避免无谓噪音; - Tab 缩进与折行:参数过多时自动换行并对齐;
- RAII 防呆:忘记调用
()时在析构函数直接抛异常。
Generator(L496-L655)在此之上提供 cmd()、comment()、inject_cmake() 等便捷接口,并负责把 TOML 里的条件名翻译成 CMake 的 if()/endif()——还支持 $<linux> 这种"条件组合条件"的嵌套替换。
通配符展开:在生成期就落地文件列表
expand_cmake_paths(L69-L148)在代码生成阶段就把 src/*.cpp、src/**/*.hpp 展开成真实文件列表(而不是运行时 CMake GLOB),随后按字母排序保证跨平台结果一致。它还会顺手做两件事:拦截越界的路径穿越;若通配符一个文件都没匹配到,立即报错"wildcard found 0 files"——新手漏配路径时尤其有用。
ConditionScope:RAII 风格的条件块
每个带条件的段落(目标、测试、安装包……)都被 ConditionScope(L657-L673)包住:构造时输出 if(...),析构时自动补上 endif()。作用域一结束,endif 必然成对出现,生成器永远不会"括号失衡"。
目标翻译:一行 type 对应一族 CMake 命令
[target.xxx] 的 type 字段经一张 switch 表(L1357-L1397)映射到 CMake 命令:executable → add_executable,static/shared → add_library ... STATIC/SHARED,interface → add_library ... INTERFACE,object、custom 同理。之后按固定顺序追加 target_sources、target_compile_definitions、target_include_directories、target_link_libraries、set_target_properties 等命令,private- 前缀的字段自动落到 PRIVATE 作用域。依赖库以 :: 开头时还会生成"目标不存在即 FATAL_ERROR"的友好检查。
落盘策略:内容没变就不写文件
生成结果先攒在字符串流里,写盘前与磁盘上已有的 CMakeLists.txt 逐字节比较(L1593-L1608),只有内容变化才落盘。这样反复 configure 时不会制造无意义的 git 改动和 IDE 重索引。最后 generate_cmake 还会递归进入每个含 cmake.toml 的子目录(L1610-L1632),为每个子目录各生成一份文件——多模块项目也因此有了天然的 add_subdirectory 结构。
五、完整旅程:一个最小项目的翻译过程 ✅
以第一节的三行 TOML 为例,cmake -B build 触发 cmake/cmkr.cmake 引导脚本重新生成,产出的 CMakeLists.txt(节选)大致如下:
# This file is automatically generated from cmake.toml - DO NOT EDIT
cmake_minimum_required(VERSION 3.15)
set(CMKR_ROOT_PROJECT OFF)
if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR)
set(CMKR_ROOT_PROJECT ON)
# Bootstrap cmkr and automatically regenerate CMakeLists.txt
include(cmkr.cmake OPTIONAL RESULT_VARIABLE CMKR_INCLUDE_RESULT)
if(CMKR_INCLUDE_RESULT)
cmkr()
endif()
endif()
project(cmkr_for_beginners LANGUAGES C CXX)
# Target: hello_world
set(hello_world_SOURCES "cmake.toml" "src/main.cpp")
add_executable(hello_world)
target_sources(hello_world PRIVATE ${hello_world_SOURCES})
source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${hello_world_SOURCES})
可以看到完整闭环:[project] 变成 project(),type = "executable" 变成 add_executable,sources 展开排序后变成 target_sources;而 include(cmkr.cmake) 又保证了下次 configure 时 cmkr 会再次检查 cmake.toml 是否有变化——配置即代码,代码随配置自动重生。
六、源码导读路线:从哪里下筷子 📖
建议按这条路线阅读(均在项目根目录下):
src/main.cpp(L8-L21)——入口与命令分发,5 分钟读完;include/project_parser.hpp——Project结构体全景,对照cmake.toml的表名理解每个字段的含义;src/project_parser.cpp(L212 起)——重点看TomlChecker的条件键收集和 L261-L279 的内置条件表;src/cmake_generator.cpp(L317-L655)——Command与Generator是生成器的"引擎室";src/cmake_generator.cpp(L724 起)——generate_cmake主流程,按注释分段对照输出即可;- 动手实验:复制
tests/basic目录,给cmake.toml加一个windows.sources,观察生成的if(WIN32)块。
依赖方面,四个第三方库全部以单文件形式内嵌在 third_party/ 下(toml11 负责 TOML 语法、mpark/variant 负责 bool|string 双类型值、tsl/ordered-map 保证插入顺序、ghc/filesystem 提供跨平台路径操作),阅读时基本无需跳转外部仓库。
总结
cmkr 源码的精华可以浓缩成三句话:
- 解析侧(
project_parser):用Condition映射统一表达"条件化配置",用TomlChecker把配置错误连同行列号一起暴露出来; - 生成侧(
cmake_generator):用Command+ RAII 条件作用域保证输出 CMake 代码永远格式规范、结构闭合; - 中间层:一个
Project结构体就是两个模块的全部契约——解析与生成彻底解耦,这也是它最容易读懂的地方。
读懂这两个文件,你不仅掌握了 cmkr 的实现,也顺便收获了一套"配置 → IR → 代码生成器"的完整方法论。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



