Skip to content

Commit 76db10b

Browse files
Merge pull request #1889 from contour-terminal/feature/env-var-expansion-config-paths
Add environment variable expansion in configuration file paths (#1278)
2 parents 3272f6a + 24f535b commit 76db10b

File tree

7 files changed

+197
-20
lines changed

7 files changed

+197
-20
lines changed

docs/configuration/colors.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ color_schemes:
3636
blur: false
3737

3838
```
39-
:octicons-horizontal-rule-16: ==path== To specify the image file to use as the background, you need to provide the full path to the image. By default, the path option is set to an empty string, indicating that background image support is disabled. <br/>
39+
:octicons-horizontal-rule-16: ==path== To specify the image file to use as the background, you need to provide the full path to the image. Both `~` (home directory) and `${VAR}` (environment variable) expansion are supported — see [Path Handling](paths.md) for details. By default, the path option is set to an empty string, indicating that background image support is disabled. <br/>
4040
:octicons-horizontal-rule-16: ==opacity== option controls the opacity of the background image. It determines how transparent or intense the image appears. The default value is 0.5, which provides a moderately transparent background. You can adjust this value to make the image more or less prominent, depending on your preferences. <br/>
4141
:octicons-horizontal-rule-16: ==blur== option applies a blur effect to the background image. This can help reduce distractions and keep the focus on the terminal contents. By default, the blur option is set to false, indicating that background image blurring is disabled. If you want to enable it, set the value to true.
4242

docs/configuration/paths.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Path Handling in Configuration
2+
3+
Contour supports special syntax in configuration file paths to make configs portable across machines and platforms.
4+
5+
## Tilde Expansion
6+
7+
Use `~` at the start of a path to refer to the user's home directory:
8+
9+
``` yaml
10+
profiles:
11+
main:
12+
shell:
13+
initial_working_directory: ~/workspace
14+
```
15+
16+
On Linux/macOS this expands to e.g. `/home/user/workspace`, on Windows to `C:\Users\user\workspace`.
17+
18+
## Environment Variable Expansion
19+
20+
Use `${VAR_NAME}` to reference environment variables:
21+
22+
``` yaml
23+
color_schemes:
24+
default:
25+
background_image:
26+
path: '${HOME}/Pictures/terminal-bg.png'
27+
```
28+
29+
This resolves `${HOME}` to its value at config load time.
30+
31+
### Multiple Variables
32+
33+
You can use several variables in the same value:
34+
35+
``` yaml
36+
profiles:
37+
main:
38+
shell:
39+
program: '${MY_SHELL_DIR}/${MY_SHELL_NAME}'
40+
```
41+
42+
### Combining with Tilde
43+
44+
Environment variables are expanded **before** tilde resolution, so both syntaxes compose correctly:
45+
46+
``` yaml
47+
profiles:
48+
main:
49+
shell:
50+
initial_working_directory: '~/${PROJECT_DIR}'
51+
```
52+
53+
This first resolves `${PROJECT_DIR}` (e.g. to `workspace`), then resolves `~` to the home directory, yielding `/home/user/workspace`.
54+
55+
## Where Expansion Applies
56+
57+
The following configuration values support both `~` and `${VAR}` expansion:
58+
59+
| Configuration value | Example |
60+
|---------------------|---------|
61+
| `background_image.path` | `'${HOME}/Pictures/bg.png'` |
62+
| `shell.program` | `'${MY_SHELL}'` |
63+
| `shell.arguments` (each entry) | `'--config=${XDG_CONFIG_HOME}/shell.conf'` |
64+
| `shell.initial_working_directory` | `'~/${PROJECT_DIR}'` |
65+
| Any `std::filesystem::path` config entry | General file path values |
66+
67+
## Cross-Platform Configuration
68+
69+
A primary motivation for environment variable expansion is writing configs that work on multiple operating systems without modification:
70+
71+
``` yaml
72+
# Works on both Linux and Windows — no hardcoded paths
73+
color_schemes:
74+
default:
75+
background_image:
76+
path: '${HOME}/Pictures/terminal-bg.png'
77+
78+
profiles:
79+
main:
80+
shell:
81+
initial_working_directory: '${HOME}/projects'
82+
```
83+
84+
## Edge Cases
85+
86+
- **Undefined variables** expand to an empty string and a warning is logged.
87+
- **Malformed markers** like `${UNCLOSED` (missing closing `}`) pass through the string unchanged — no partial substitution occurs.
88+
- **No markers** — strings without any `${...}` syntax are passed through unchanged.

metainfo.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
<li>Adds natural momentum scrolling for touchpad gestures with configurable falloff via momentum_scrolling profile setting</li>
145145
<li>Adds Kitty OSC 99 desktop notification protocol with D-Bus backend on Linux, supporting structured metadata, chunked payloads, base64 encoding, urgency levels, display occasion filtering, bidirectional close/activation events, and query/alive responses</li>
146146
<li>Adds complete DECCIR (Cursor Information Report) response including character set designations, GL/GR mappings, and wrap-pending state (#97)</li>
147+
<li>Adds environment variable expansion (${VAR_NAME} syntax) in configuration file paths for cross-platform config reuse (#1278)</li>
147148
</ul>
148149
</description>
149150
</release>

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ nav:
9292
- configuration/profiles.md
9393
- configuration/colors.md
9494
- configuration/indicator-statusline.md
95+
- configuration/paths.md
9596
- configuration/key-mapping.md
9697
#- Images (Advanced) : configuration/advanced/images.md
9798
#- Mouse (Advanced) : configuration/advanced/images.md

src/contour/Config.cpp

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include <QtGui/QOpenGLContext>
1818

1919
#include <algorithm>
20+
#include <cstdlib>
2021
#include <fstream>
2122
#include <iostream>
2223

@@ -304,6 +305,40 @@ optional<std::string> readConfigFile(std::string const& filename)
304305
return nullopt;
305306
}
306307

308+
YAMLConfigReader::YAMLConfigReader(std::string const& filename,
309+
logstore::category const& log,
310+
VariableReplacer replacer):
311+
configFile(filename), logger { log }, variableReplacer { std::move(replacer) }
312+
{
313+
if (!variableReplacer)
314+
{
315+
variableReplacer = [&log = logger](std::string_view name) -> std::string {
316+
if (auto const* value = std::getenv(std::string(name).c_str()))
317+
return value;
318+
log()("Undefined environment variable: ${{{}}}", name);
319+
return {};
320+
};
321+
}
322+
try
323+
{
324+
doc = YAML::LoadFile(configFile.string());
325+
}
326+
catch (std::exception const& e)
327+
{
328+
errorLog()("Configuration file is corrupted. {}\nDefault config will be loaded.", e.what());
329+
}
330+
}
331+
332+
std::string YAMLConfigReader::resolveVariables(std::string const& input) const
333+
{
334+
return replaceVariables(input, variableReplacer);
335+
}
336+
337+
std::filesystem::path YAMLConfigReader::resolvedPath(std::string const& input) const
338+
{
339+
return homeResolvedPath(resolveVariables(input), vtpty::Process::homeDirectory());
340+
}
341+
307342
// NOLINTBEGIN(readability-convert-member-functions-to-static)
308343
void YAMLConfigReader::loadFromEntry(YAML::Node const& node,
309344
std::string const& entry,
@@ -312,8 +347,7 @@ void YAMLConfigReader::loadFromEntry(YAML::Node const& node,
312347
auto const child = node[entry];
313348
if (child)
314349
{
315-
where = crispy::homeResolvedPath(std::filesystem::path(child.as<std::string>()).string(),
316-
vtpty::Process::homeDirectory());
350+
where = resolvedPath(child.as<std::string>());
317351
}
318352
}
319353

@@ -733,9 +767,9 @@ void YAMLConfigReader::loadFromEntry(YAML::Node const& node,
733767
loadFromEntry(child, "path", filename);
734768
loadFromEntry(child, "opacity", where->opacity);
735769
loadFromEntry(child, "blur", where->blur);
736-
auto resolvedPath = crispy::homeResolvedPath(filename, vtpty::Process::homeDirectory());
737-
where->location = resolvedPath;
738-
where->hash = crispy::strong_hash::compute(resolvedPath.string());
770+
auto const resolved = resolvedPath(filename);
771+
where->location = resolved;
772+
where->hash = crispy::strong_hash::compute(resolved.string());
739773
}
740774
}
741775

@@ -856,13 +890,13 @@ void YAMLConfigReader::loadFromEntry(YAML::Node const& node,
856890
{
857891
if (auto const child = node[entry])
858892
{
859-
where.program = child.as<std::string>();
893+
where.program = resolveVariables(child.as<std::string>());
860894
}
861895
// loading arguments from the profile
862896
if (auto args = node["arguments"]; args && args.IsSequence())
863897
{
864898
for (auto const& argNode: args)
865-
where.arguments.emplace_back(argNode.as<string>());
899+
where.arguments.emplace_back(resolveVariables(argNode.as<string>()));
866900
}
867901
if (node["initial_working_directory"])
868902
{

src/contour/Config.h

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
#include <exception>
4848
#include <filesystem>
4949
#include <format>
50+
#include <functional>
5051
#include <limits>
5152
#include <memory>
5253
#include <optional>
@@ -856,22 +857,23 @@ struct Config
856857

857858
struct YAMLConfigReader
858859
{
860+
/// Callable that resolves a variable name to its value.
861+
using VariableReplacer = std::function<std::string(std::string_view)>;
862+
859863
std::filesystem::path configFile;
860864
YAML::Node doc;
861865
logstore::category const& logger;
866+
VariableReplacer variableReplacer;
862867

863-
YAMLConfigReader(std::string const& filename, logstore::category const& log):
864-
configFile(filename), logger { log }
865-
{
866-
try
867-
{
868-
doc = YAML::LoadFile(configFile.string());
869-
}
870-
catch (std::exception const& e)
871-
{
872-
errorLog()("Configuration file is corrupted. {}\nDefault config will be loaded.", e.what());
873-
}
874-
}
868+
YAMLConfigReader(std::string const& filename,
869+
logstore::category const& log,
870+
VariableReplacer replacer = {});
871+
872+
/// Expands `${VAR}` tokens in @p input using the configured variable replacer.
873+
[[nodiscard]] std::string resolveVariables(std::string const& input) const;
874+
875+
/// Expands `${VAR}` tokens then resolves `~` to the home directory.
876+
[[nodiscard]] std::filesystem::path resolvedPath(std::string const& input) const;
875877

876878
template <typename T, documentation::StringLiteral ConfigDoc, documentation::StringLiteral WebDoc>
877879
void loadFromEntry(YAML::Node const& node,

src/crispy/utils_test.cpp

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,57 @@ TEST_CASE("homeResolvedPath")
144144
CHECK("/var/tmp/workspace" == crispy::homeResolvedPath("~/workspace", "/var/tmp").generic_string());
145145
}
146146

147+
TEST_CASE("expandEnvironmentVariables")
148+
{
149+
auto const envReplacer = [](string_view name) -> string {
150+
if (name == "HOME")
151+
return "/home/user";
152+
if (name == "SHELL")
153+
return "/bin/bash";
154+
return {};
155+
};
156+
157+
// Known variables resolve correctly
158+
CHECK("/home/user/Pictures" == crispy::replaceVariables("${HOME}/Pictures", envReplacer));
159+
CHECK("/bin/bash" == crispy::replaceVariables("${SHELL}", envReplacer));
160+
161+
// Multiple variables in one string
162+
CHECK("/home/user runs /bin/bash" == crispy::replaceVariables("${HOME} runs ${SHELL}", envReplacer));
163+
164+
// Unknown variables expand to empty string
165+
CHECK("/Pictures" == crispy::replaceVariables("${UNDEFINED}/Pictures", envReplacer));
166+
167+
// No markers at all — input passes through unchanged
168+
CHECK("/usr/local/bin" == crispy::replaceVariables("/usr/local/bin", envReplacer));
169+
170+
// Malformed ${UNCLOSED at start of string passes through unchanged
171+
CHECK("${UNCLOSED" == crispy::replaceVariables("${UNCLOSED", envReplacer));
172+
}
173+
174+
TEST_CASE("replaceVariables.and.homeResolvedPath.composition")
175+
{
176+
auto const envReplacer = [](string_view name) -> string {
177+
if (name == "HOME")
178+
return "/home/user";
179+
if (name == "PICS")
180+
return "Pictures";
181+
return {};
182+
};
183+
184+
auto const resolve = [&](string const& input) {
185+
return crispy::homeResolvedPath(crispy::replaceVariables(input, envReplacer), "/home/user");
186+
};
187+
188+
// ${HOME}/path → env expansion → home resolution (no ~ involved)
189+
CHECK("/home/user/Pictures/bg.png" == resolve("${HOME}/Pictures/bg.png").generic_string());
190+
191+
// ~/path → no env vars to expand → home resolution handles ~
192+
CHECK("/home/user/workspace" == resolve("~/workspace").generic_string());
193+
194+
// Mixed: env var inside path with ~
195+
CHECK("/home/user/Pictures" == resolve("~/${PICS}").generic_string());
196+
}
197+
147198
TEST_CASE("unescapeURL")
148199
{
149200
CHECK(crispy::unescapeURL(""sv).empty());

0 commit comments

Comments
 (0)