A guide by use case: how to look at a variant, how to see what a change does, and how to write documentation that depends on the variant.
SPLed builds five product variants from one code base, and its documentation works the same way.
One set of documents describes every variant, and data decides what one variant shows. No
document is a template, and nothing in conf.py or CMake decides what a page contains:
- a variant data file per variant says which features are on and which components are in;
- conditions read that file: in the documents, and in rules that
tools/variant_data.pygenerates from where each component's documentation lives, so no rule is written by hand; - a selection file says which variant data file, and which build's generated pages, the editor and a plain build show;
- Sphinx and ubCode, with its command-line tool
ubc, read the same files, so the editor shows what the build produces.
| I want to … | Use case |
|---|---|
| set up and generate the variant data | 1, 2 |
| know which variant I am looking at | 3 |
| switch the variant my editor shows | 4 |
| look at another variant without switching | 5 |
| see what differs between two variants | 6 |
| try a change without touching the product | 7 |
| understand what ubCode shows me | 8 |
| make a paragraph, requirement or diagram depend on a feature | 9 |
| print a variant value in the text | 10 |
| show a section only in the reports build | 11 |
| include a document only when its component is in the variant | 12 |
| put one document in different places per variant | 13 |
| add a component, a feature or a variant | 14, 15, 16 |
| link code to the design, per variant | 17 |
| check my work, or find out why something is missing | 18, 19 |
New to SPLed? Go through use cases 1 to 8 once, in order.
flowchart LR
subgraph edit["You edit"]
K["KConfig<br/>the feature model"]
C["variants/Sleep/config.txt<br/>the features of a variant"]
P["variants/Sleep/parts.cmake<br/>the components of a variant"]
end
G(["tools/variant_data.py<br/>run by CMake configure"])
subgraph gen["Generated, never edited"]
V["build/variants/Sleep/test/docs.json<br/>one file per variant, kit and target"]
RU["ubproject.variants.toml<br/>one rule per component"]
A["build/selection.toml<br/>the build you work on"]
end
R["documents<br/>hold the {if} conditions"]
U["ubCode and ubc"]
S["sphinx-build"]
K --> G
C --> G
P --> G
G --> V
G --> RU
G --> A
A -->|names| V
RU --> U
RU --> S
A --> U
A --> S
R --> U
R --> S
V -.->|"-c override"| U
V -.->|"-D override"| S
| Word | Meaning in SPLed |
|---|---|
| Variant | A product configuration: Disco, Sleep, Spa, IDEA/Sloemada or Base/Dev. Each is a folder under variants/. |
| Feature | A switch or value of the feature model KConfig, such as BLINKING or CUSTOMER. A variant sets its features in its config.txt. |
| Component | A folder under components/, plus the integration suite test/spled_integration. A variant lists its components in its parts.cmake. |
| Kit | prod, the product, or test, which adds unit tests, coverage and, for Disco, the integration suite. |
| Target | docs, the documents, or reports, the documents plus test results and coverage. |
| Variant data file | build/variants/<variant>/<kit>/<target>.json, one per combination: 5 variants × 2 kits × 2 targets = 20 files, plus one per component report under <kit>/<component>/. |
| Rules | ubproject.variants.toml, generated: one rule per component, which leaves the component's documents out of a variant that does not contain it. |
| Selection | build/selection.toml, generated when a variant is selected: which variant data file the editor and a plain sphinx-build read, and, for a CMake build's reports, that build's generated pages, read where the build writes them. |
| 150 % documentation | Every document of every variant lives in the tree. Conditions remove what one variant does not have. |
var.* |
How a condition reads the variant data: var.features.BLINKING, var.build_config.components. |
build/variants/Sleep/test/docs.json, shortened:
{
"build_config": {
"components": ["components/platform_types", "components/rte", "…", "components/brightness_controller", "components/auto_off"],
"component": "",
"kit": "test",
"scope": "variant",
"target": "docs",
"variant": "Sleep"
},
"features": {
"AUTO_OFF": true,
"AUTO_OFF_PERIOD_SECONDS": 10,
"BLINKING": false,
"BRIGHTNESS_ADJUSTMENT_MANUAL": true,
"CUSTOMER": "B"
}
}featuresholds every symbol of the feature model with this variant's value. Booleans aretrueorfalse, numbers are numbers, strings are strings. Every boolean is present, also the ones that are off, so a condition on it always has an answer.build_config.componentsis the variant's component list, read from itsparts.cmake.build_config.variant,kitandtargetsay which combination the file describes.build_config.scopeisvariant, orcomponentin the file of a per-component report, whosebuild_config.componentnames the component.
Everything under build/ is generated, and nobody edits it; VS Code opens it read-only. To change
a variant, change its sources (15, 16), or
try a change on a copy (7).
The quickest way in is the guided tour, which works the same on Linux, macOS and Windows.
It needs only Python 3.11+ and uv; ubc for the ubCode side, and
CMake, Ninja and a C/C++ compiler for the build cases. Each step says what it is about to do, runs
it, and then says what to look at and what to expect:
python tools/try_variants.py # the cases, and what each needs
python tools/try_variants.py setup # .venv, the variant data, the rules
python tools/try_variants.py docs -v Sleep # one variant's documents in Sphinx and ubc
python tools/try_variants.py build -v Disco # a CMake build: reports, test results
python tools/try_variants.py component light_controller # one component's report
python tools/try_variants.py compare Disco Spa # the needs that differ ([6](#6-see-what-differs-between-two-variants))
python tools/try_variants.py check # the tests and the CI gate ([18](#18-run-the-checks))
python tools/try_variants.py all # all of it, in that orderIts output goes to build/try/. The rest of this guide is the same steps by hand.
You need Python 3.12 and the locked Python dependencies. Nothing in this guide needs a compiler.
-
Full toolchain, to build the firmware as well: follow Start developing.
-
Documentation only, the same steps the CI documentation job runs:
pipx install poetry==2.4.1 POETRY_VIRTUALENVS_IN_PROJECT=true poetry install --no-root # creates .venv source .venv/bin/activate
On Windows, run
$env:POETRY_VIRTUALENVS_IN_PROJECT = "true"beforepoetry install --no-root, and activate with.venv\Scripts\Activate.ps1.
For the editor, install the ubCode extension (useblocks.ubcode) in VS Code and open the
repository folder. ubCode reads ubproject.toml; there is nothing else to configure. The
command-line tool ubc ships inside the extension, in server/cli/ under the extension's folder
in ~/.vscode/extensions/. Put that folder on your PATH.
All commands below run from the repository root, with the virtualenv activated.
python tools/variant_data.py --all- This writes the variant data files under
build/variants/, fromKConfig, each variant'sconfig.txtand each variant'sparts.cmake, and the rules fileubproject.variants.toml. KConfig is pure Python, so it needs neither a compiler nor CMake. - Run it again whenever
KConfig, aconfig.txtor aparts.cmakechanges.python tools/variant_data.py --all --checktells you whether the files are up to date; CI runs it. - Configuring a CMake build, with the build scripts or the CMake extension, writes the files of the variant it configures, and selects that build (4).
The selection says it:
python -c "import tomllib; print(tomllib.load(open('build/selection.toml', 'rb'))['needs']['variant_data_file'])"/home/you/SPLed/build/variants/Disco/test/reports.json
If the selection also reads a build's generated pages, its [parse.parsers.rst] entry names
them, below the build directory, and build/selected_build.txt names the directory.
The documentation says it as well: its start page shows Variant: Disco. The page takes that from the variant data file (10).
-
In VS Code: Terminal → Run Task… → Select documentation variant, then pick the variant and the kit.
-
On the command line:
python tools/variant_data.py --variant Sleep --kit test --current
Both write build/selection.toml, which names the chosen variant's docs file, so ubCode and a
plain sphinx-build now read Sleep. If the editor still shows the previous variant, reload the
window (Command Palette → Developer: Reload Window).
- The kit defaults to
prod. Give--kit testfor the test kit. - Configuring a CMake build selects that build: a
testkit build itsreportsfile and its generated pages, where the build writes them; aprodkit build itsdocsfile.
You do not have to switch to look at a variant. Point the reader at its file instead.
-
The rendered documentation (Sphinx):
python -m sphinx -b html -D needs_variant_data_file=build/variants/Sleep/test/docs.json . build/docs/SleepThen open
build/docs/Sleep/index.html. A build takes a few seconds. In VS Code, the task Build documentation for a variant does the same, and also puts the variant's name in the page title. -
ubCode's view (ubc):
ubc check -c "needs.variant_data_file = 'build/variants/Sleep/test/docs.json'"Each
info[toctree.variant_excluded]line names a document this variant leaves out, and the rule that removed it.ubc checkalso lists the project's other warnings, so its exit code says nothing about the variant.
-D for Sphinx and -c for ubc override the same setting, variant_data_file.
To look at another build, with its generated pages, hand the readers that build's own selection file. CMake writes one per documentation run into the build directory:
python -m sphinx -b html -D spl_selection=build/Spa/test/Debug/selection/reports.toml . build/docs/Spa
ubc check -c "$(cat build/Spa/test/Debug/selection/reports.toml)"spl-core does the same for every docs and reports run it starts, so the reports of two builds
can be built side by side.
Build both variants as in 5, then compare them.
-
Pages. Sleep has pages for
auto_offandbrightness_controller, and Disco does not. On the light controller page, Disco shows the blinking state diagram and Sleep does not. -
Needs. Every HTML build also writes a
needs.jsonnext to itsindex.html.ubc diffcompares two of them:ubc diff -n build/docs/Disco/needs.json -n build/docs/Sleep/needs.json
Or skip the builds and compare the current variant with another one directly:
ubc diff -c "needs.variant_data_file = 'build/variants/Sleep/test/docs.json'"The output lists every need that is new, removed or changed. For Disco against Sleep, it starts with
New need SWDD_AO-100, the first of the auto-off requirements that only Sleep has. -
Builds. Every build keeps its own report pages and
needs.json(build/<variant>/<kit>/<type>/reports/html/needs.json), so two builds compare withubc diff -n, or directly with the other build's selection file:ubc diff -c "$(cat build/Spa/test/Debug/selection/reports.toml)"
Do not edit a variant data file. It is generated, and the next run overwrites it. For a what-if, use one of these, from the quickest to the most complete:
-
One value, in ubc. Override the value on the command line:
ubc diff -c "needs.variant_data.features.BLINKING = false"With Disco as the current variant this prints
Removed need SWDD_LC-101andRemoved need SWDD_LC-203, the two blinking requirements. The same-cworks withubc checkandubc build needs. -
Any change, in both readers. Copy a variant data file to a folder outside
build/, edit the copy, and point the readers at it:cp build/variants/Disco/test/docs.json /tmp/what-if.json # now edit /tmp/what-if.json python -m sphinx -b html -D needs_variant_data_file=/tmp/what-if.json . build/docs/what-if ubc check -c "needs.variant_data_file = '/tmp/what-if.json'"
On Windows, use any folder outside
build/instead of/tmp. -
The real change. Change the variant itself, in its
config.txtorparts.cmake(15, 16), regenerate withpython tools/variant_data.py --all, and look again. This changes the product, so revert it if it was only an experiment.
- Inactive blocks are faded, not hidden. The editor fades the source of an
{if}block whose condition is false, the way a C editor fades code under a false#if. The preview shows the block collapsed and greyed, labelled with its condition. Its needs are still left out of the index, as in the Sphinx build. - Left-out documents are information, not errors. The components page lists every component
(12). An entry for a
component the variant does not have is reported as
toctree.variant_excluded, with the rule that removed the document. - Generated pages after a build. Test results, test specifications and source listings exist after a CMake build of the test kit, under its build directory. The selection names them, so ubCode reads exactly the pages Sphinx reads, of that one build. Before the build they do not exist, and the editor shows the documents without them.
ubCode's side is described in ubCode's guide to variants.
Wrap the content in the if directive of Sphinx-Needs. Its condition is a Python expression over
var.*.
In Markdown, use a fence of four backticks, so that the block can hold directives with three:
````{if} var.features.BLINKING
```{spec} Blinking Behavior
:id: SWDD_LC-101
...
```
````In reStructuredText:
.. if:: var.features.BLINKING
This paragraph exists only in variants with blinking.- There is no
else. Write a second block with the opposite condition,not var.features.BLINKING; the light controller page does this for its two state diagrams. - Strings and numbers compare as in Python:
var.features.CUSTOMER == "A", as indoc/customer_requirements/index.md, orvar.features.AUTO_OFF_PERIOD_SECONDS > 5. - Content behind a false condition is never read, so its needs do not exist in that variant, in Sphinx and in ubCode alike.
- A condition that names a key the variant data file does not have gives the warning
'if' directive expression failed, and the block is left out.
The variant role puts a value where it stands:
- in Markdown:
{variant}`features.CUSTOMER`printsBin Sleep; - in reStructuredText:
:variant:`features.CUSTOMER`.
The path starts below var, so it is features.CUSTOMER, not var.features.CUSTOMER. A list is
printed comma-separated, for example {variant}`build_config.components`. An unknown key gives
a warning and prints nothing. The start page uses {variant}`build_config.variant`.
A reports build adds test results and coverage. Gate the section on the target:
````{if} var.build_config.target == "reports"
## Verification
```{toctree}
:maxdepth: 1
:glob:
/build/**/components/light_controller/reports/unit_test_results
/build/**/components/light_controller/reports/coverage
```
````The component pages end with a block like this one. The pages are where the build writes them,
build/<variant>/<kit>/<build type>/components/…, and the glob finds exactly one of each: a
reader reads the pages of one build only, the selected one. Always name the component's own path
in the entry; a bare /build/**/reports/coverage would also find every other component's.
You do not write anything for it. tools/variant_data.py generates one rule per component that has
a doc/index page, into ubproject.variants.toml:
[[source.variant_sources]]
if = "'components/auto_off' in var.build_config.components and (var.build_config.scope == 'variant' or var.build_config.component == 'components/auto_off')"
files = ["components/auto_off/**"]- When the condition is false, the files are left out before anything is read: no page, no needs, and no warning about a page that no toctree lists.
- The components page,
doc/components/index.md, globs every component's documentation, so it lists exactly the variant's components. - The second half of the condition is for the per-component report, which shows one component.
- Gate on the component list, never on the variant name. The list comes from the variant's
parts.cmake, so the product structure is written down once.test_no_rule_names_a_variant_by_namefails if a rule names a variant. - Rule conditions, such as those of use case 13, use a smaller grammar than
{if}. A boolean needs== True, as inif = "var.features.AUTO_OFF == True".and,or,not,==,!=,<,>,inandnot inwork. A condition outside the grammar stops the Sphinx build. A condition that names an unknown key is reported, and removes its files.
Toctrees in SPLed carry no conditions. Sphinx reads every document it finds, whether a toctree lists it or not, so a condition on a toctree entry could only take the entry away: the document would stay in the build, with its needs, but without a place in the navigation. SPLed has no document that changes its place per variant. If one ever has to:
-
Put the content where neither reader looks for documents, for example
doc/_shared/safety_concept.md, and exclude that folder inconf.py(exclude_patterns) and inubproject.toml([source] extend_exclude). -
Write two thin pages at the two places. Each one pulls the content in:
```{include} /doc/_shared/safety_concept.md ```
-
Gate the two pages with a rule and its negation, so that every variant has exactly one of them. Hand-written rules go into
doc/variant_rules.toml, whichtools/variant_data.pyadds to the generated ones;ubproject.tomlcannot hold rules, becauseextendreplaces an array:[[source.variant_sources]] if = "var.features.BLINKING == True" files = ["doc/chapter_x/safety_concept.md"] [[source.variant_sources]] if = "not (var.features.BLINKING == True)" files = ["doc/chapter_y/safety_concept.md"]
Two independent conditions could both be false for a variant, and the content would disappear without a warning.
- Add
spl_add_component(components/<name>)to theparts.cmakeof every variant that has it. - Write its documentation in
components/<name>/doc/index.md. If its sources implement design needs, give that page a## Traceabilitysection with asrc-traceblock (17), and end it with a## Verificationsection for the reports, as the other components do (11). - Regenerate:
python tools/variant_data.py --all, or configure a build.
That is all. The generator writes the component's rule, and the glob toctree in
doc/components/index.md lists its page. No rule and no toctree line is written by hand, and
nothing under build/ is touched. To have the report tasks in VS Code offer the component, add it to the component input in .vscode/tasks.json. For
the code side of a component, see AGENTS.md.
-
Declare the feature in
KConfig, for example:config NIGHT_MODE bool "Night mode" default n -
Switch it on where it applies:
CONFIG_NIGHT_MODE=yinvariants/<variant>/config.txt. The VS Code task Configure variant opens KConfig's graphical editor for the same file. -
Regenerate:
python tools/variant_data.py --all. Every variant data file now has"NIGHT_MODE": trueor"NIGHT_MODE": false. -
Use it:
var.features.NIGHT_MODEin an{if}block,var.features.NIGHT_MODE == Truein a rule.
CMake sees the same symbol as NIGHT_MODE="True" for conditional components; see
AGENTS.md.
- Create
variants/<Name>/with aconfig.cmake, copied from an existing variant, aparts.cmakewith its components, and aconfig.txtwith its feature values, which the VS Code task Configure variant can write.tools/variant_data.pyfinds every folder that holds aconfig.cmake.Base/Devshows that a variant may sit two levels deep and may have noconfig.txt. - Regenerate:
python tools/variant_data.py --all. This adds the four filesbuild/variants/<Name>/{prod,test}/{docs,reports}.json. - Add the name where tools and tests list the variants:
.vscode/cmake-variants.json, thevariantinput in.vscode/tasks.json,VARIANTSintest/test_documentation.py,known_variantsand the expected documents intest/test_ubproject_config.py, and the lists intest/test_variant_data.py. Build tests for the variant go intotest/<Name>/.
No rule and no document has to change. The rules gate on components, so the new variant gets its
documents from its parts.cmake.
An implementation need lives in the source file, as a one-line comment right above the code it describes:
// @need Periodic Brightness Adjustment, SWIMPL_BC-001c, impl, [SWDD_BC-100, SWDD_BC-102], [REQ_46, REQ_48]The fields are the title, the ID, the type, the design needs it implements and the requirements it
fulfills. Each component's page shows its needs with one src-trace block, in a ## Traceability
section:
```{src-trace}
:project: components
:directory: brightness_controller
```
Both readers read the same comments, so the needs are part of the docs build, and ubCode shows
them without building anything. Each need links to its line on GitHub. Test specifications stay in
@rst blocks in the test sources.
The preprocessor decides per variant. A need inside an #ifdef branch exists only in the
variants that compile that branch. A link that holds only in some variants therefore belongs to a
need inside that branch. SWDD_BC-203 exists only with automatic brightness adjustment, so the
runnable's automatic branch carries SWIMPL_BC-004c for it, and the runnable's own need
SWIMPL_BC-003c does not link it.
codelinks takes the branches from build/compile_commands.json. Every build of the selected CMake
build copies its compile database there (tools/compile_commands.py), without the -save-temps
option of the test kit, which libclang rejects. After configuring without building, run
cmake --build <build dir> --target spled_codelinks_compile_commands. Without the file, codelinks
treats every #ifdef as false, and Sphinx warns.
python -m pytest -m docs # the documentation gate
python -m pytest test/test_ubproject_config.py test/test_variant_data.py # rules and variant data-m docsbuilds every variant's documents. Among other things, it checks that each variant has exactly its components' documents, that no document uses Jinja, and that ubc and Sphinx leave out the same documents. In VS Code, the tasks Documentation gate (all variants) and Check documentation with ubc (all variants) run it.- The ubc tests find
ubcthrough theUBCenvironment variable, on thePATHor in the ubCode extension folder, and are skipped when it is not there. CI installs ubc and fails instead of skipping. - The tests regenerate
build/variants/and the rules, and restorebuild/selection.tomlwhen they have selected another cell.
| What you see | What to do |
|---|---|
| A block or a page is missing in one variant | Its condition is false there, or it names a key that is not in the variant data file. Look the key up in build/variants/<variant>/<kit>/docs.json, check the spelling, and look for 'if' directive expression failed or a rule warning in the build output. In a rule, a boolean needs == True. |
| The editor shows another variant than you expect | Look at the selection (3). Configuring a CMake build selects that build. Switch back (4) and reload the window. |
Sphinx stops with No variant is selected |
build/selection.toml does not exist yet. Configure a build, or select a cell (4). ubc stops for the same reason, with Configuration file could not be loaded. |
A change to a file under build/ is gone |
build/ is generated and rewritten on every run. Change KConfig, a config.txt or a parts.cmake instead, or try the change on a copy (7). |
python tools/variant_data.py --all --check fails |
The variant data is older than its sources. Run python tools/variant_data.py --all. |
| A link from a need in the code is dead in one variant | The need sits outside the #ifdef that decides the link. Give the link to a need inside the branch that implements it (17). |
Sphinx warns compile_commands … is not a readable file |
codelinks has no compile database and treats every #ifdef as false. Build the selected build, or its spled_codelinks_compile_commands target (17). |
| ubCode shows no test results or listings | The selected build has not been built yet, or the selection names a docs shape. Build the test kit's reports target (8). |
A listing shows the code of the wrong #ifdef branch |
The installed clanguru predates the fix this project pins. Run poetry install (or the tour's setup) again. |
- Everything a condition names has to be in the variant data file. If a condition needs a new
fact, add it to
KConfig, aconfig.txtor aparts.cmake, never toconf.py. - Gate whole documents on components, blocks on features, and report sections on the target. Never gate on the variant name.
- No Jinja in documents, no conditions in toctree entries, and no variant selection in
conf.py. Select a variant by configuring a build or withtools/variant_data.py --current; look at another one with-Dfor Sphinx and-cfor ubc. - A link that holds only in some variants belongs to a need inside the
#ifdefbranch that implements it. - Do not edit
build/orubproject.variants.toml. - After changing a variant's sources, regenerate and run
python -m pytest -m docs.
The design behind all this is described under Variant-Dependent Documentation in AGENTS.md.