Skip to content

Commit 5d9b457

Browse files
committed
refine the variables doc, add extra integration tests for !var
Signed-off-by: Jimmy Tanagra <jcode@tanagra.id.au>
1 parent 9f5370b commit 5d9b457

2 files changed

Lines changed: 156 additions & 149 deletions

File tree

bundles/org.openhab.io.yamlcomposer/doc/variables.md

Lines changed: 87 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,6 @@ Each directive updates the local variable scope immediately and produces **no ou
4040

4141
Variables defined this way are **local to the current mapping node** and automatically propagate to all of its descendants.
4242

43-
::: tip Scope & Propagation
44-
45-
1. **Immediate Effect:**
46-
Each `!var` directive is evaluated in order. Once declared, the variable is available to all subsequent keys, values, and nested mappings **within the same mapping node**.
47-
48-
1. **Mapping‑Node Boundaries:**
49-
Entering a nested mapping creates a new scope.
50-
Variables declared in the parent mapping remain visible to the child, but variables declared inside the child mapping do **not** propagate back to the parent.
51-
52-
1. **Sequential Evaluation:**
53-
Variables only apply to entries that appear **after** their declaration.
54-
Earlier entries in the same mapping cannot see variables declared later.
55-
56-
:::
57-
5843
#### `!var` Syntax
5944

6045
Declare a variable using the key‑level form:
@@ -80,112 +65,13 @@ endpoint: "${api_url}/users"
8065
endpoint: "http://localhost:8080/v1/users"
8166
```
8267

83-
#### `!var` Scope Diagram
84-
85-
```text
86-
parent-map:
87-
├─ !var a: 1 ← defines `a` in this mapping
88-
├─ key1: ${a} ← sees `a`
89-
90-
├─ child-map: ← new mapping node (inherits `a`)
91-
│ ├─ key2: ${a} ← sees `a` but not `b`
92-
│ │ (because `b` is declared *after* this entry)
93-
│ ├─ !var b: 2 ← defines `b` only in this child mapping
94-
│ └─ key3: ${b} ← sees `b` (same mapping node, declared earlier)
95-
96-
└─ key4: ${a} ← sees `a` but not `b`
97-
(because `b` was declared inside child-map)
98-
```
99-
100-
::: tip List Context Restriction
101-
102-
`!var` is valid **only inside mapping nodes**.
103-
104-
If used inside a list item (e.g., `- !var foo: bar`), the list element becomes a mapping containing the directive.
105-
If your list item must remain a scalar, declare the variable in the parent mapping instead.
106-
68+
::: tip Position dependence and usage
69+
The top‑level `variables:` block is position independent (its variables are visible everywhere).
70+
Inline `!var` directives are position dependent: they take effect where they appear and only affect subsequent entries in the same mapping node and its descendants.
71+
`!var` can be used to **define new local variables** or to **override** existing ones.
72+
See [Variable Scoping and Isolation](#variable-scoping-and-isolation) for more info.
10773
:::
10874

109-
## Variable Scoping & Isolation
110-
111-
Understanding how variable scope flows is critical when designing complex modular compositions.
112-
113-
### Propagation & Isolation Rules
114-
115-
Inline variables follow a combination of sequential evaluation and lexical block isolation:
116-
117-
- **Sequential Propagation:** A `!var` directive applies to all subsequent entries (keys, values, and nested descendant blocks) evaluated after it within the same mapping context.
118-
- **Downstream Inheritance:** Nested maps, templates (`!insert`), included files (`!include`), conditional branches (`!if`), and loop iterations (`!for`) inherit all variables in scope at their point of declaration.
119-
- **Upward & Lateral Isolation:** Declarations made inside a child mapping block exist only within that branch and its descendants. They never leak upward to the parent mapping or laterally into adjacent sub-mappings.
120-
121-
#### Example: Scope Propagation & Isolation
122-
123-
```yaml
124-
# Parent Mapping
125-
!var prefix: "main"
126-
127-
# 1. Applies to subsequent keys, values, and nested descendants:
128-
${prefix}_section:
129-
device_name: "${prefix}_sensor"
130-
config:
131-
topic: "tele/${prefix}/state" # Descendants inherit parent's !var
132-
133-
# 2. Re-declaration updates subsequent entries below it:
134-
!var prefix: "sub"
135-
136-
${prefix}_section: # Key evaluates to: "sub_section"
137-
!var local_val: "active" # Local to this child block
138-
139-
device_name: "${prefix}_sensor" # Value evaluates to: "sub_sensor"
140-
status: "${local_val}" # Evaluates to: "active"
141-
142-
# 3. Next block in parent mapping:
143-
another_section:
144-
name: "${prefix}_node" # Inherits parent's updated prefix ("sub")
145-
state: "${local_val}" # WARNING: local_val is undefined here
146-
```
147-
148-
#### Example: Template, Package & Loop Isolation
149-
150-
Because control structures and modular includes create their own child mapping contexts, local variables declared inside them remain isolated to that iteration or file execution:
151-
152-
```yaml
153-
templates:
154-
component:
155-
!var internal_id: "tpl_123"
156-
id: "${internal_id}"
157-
name: "${component_name}"
158-
159-
component_instance:
160-
!insert
161-
template: component
162-
vars:
163-
component_name: "sensor_main"
164-
165-
outer_id: "${internal_id}" # Warning: internal_id is undefined in outer scope
166-
```
167-
168-
### Progressive Evaluation & Self-Referencing
169-
170-
Within a mapping block or sequence of key-form `!var` directives, variables evaluate sequentially from top to bottom.
171-
172-
- **Progressive Resolution:** A variable can reference previously defined variables within the same block or earlier single-form `!var` directives.
173-
- **Sequential Re-assignment & Self-Reference:** Re-declaring an existing variable name evaluates the expression against the **current scope value** before updating the variable for subsequent substitutions. Prior substitutions retain the value active at the time they were evaluated.
174-
175-
```yaml
176-
!var mode: "dev"
177-
env_first: "${mode}" # Resolves to "dev"
178-
179-
!var mode: "prod"
180-
env_second: "${mode}" # Resolves to "prod"
181-
182-
!var count: 10
183-
!var count: "${count + 1}" # Evaluates ${count + 1} using current scope (10) -> 11
184-
total_count: "${count}" # Resolves to 11
185-
```
186-
187-
> **Note:** Referencing an undefined variable in a self-assignment (e.g., `!var count: "${count}"` when `count` does not exist in scope) logs an unresolved variable warning and evaluates to `null`.
188-
18975
## Variable Substitution
19076

19177
### Default Substitution Behavior
@@ -500,7 +386,88 @@ enumerated_map: ${enumerate(mapping)}
500386
# - Value access: ${enumerated_map[0][1].value} -> "one"
501387
```
502388

503-
## Advanced Usage
389+
## Advanced Topics
390+
391+
### Variable Scoping and Isolation
392+
393+
Variable scoping in YAML Composer follows a strict combination of **sequential evaluation**, **lexical mapping‑node boundaries**, and **downstream inheritance**. The two declaration mechanisms — the top‑level `variables:` block (global scope) and inline `!var` directives (local scope) — participate in the same unified scoping model. This section explains how they interact and lists the concrete rules you must follow.
394+
395+
#### Global vs Local: how `variables:` and `!var` interact
396+
397+
- **Global variables (`variables:` block)**
398+
- Define the **initial scope** for the entire file.
399+
- Are visible everywhere in the file, including included files, templates, and loops, regardless of where the `variables:` block appears in the document (i.e., `variables:` is position independent).
400+
- Are evaluated as part of the file composition and act as the root values that inline `!var` directives may override locally.
401+
- **Cannot** override system variables (e.g., `OPENHAB_CONF`, `__FILE__`, etc.).
402+
403+
- **Inline variables (`!var` directives)**
404+
- Declare or reassign variables **inline at the key level** within a mapping.
405+
- Update the local variable scope immediately and produce **no output key** in the final composed structure.
406+
- Are **local to the mapping node** in which they appear and automatically propagate to that node’s descendants.
407+
- Override global variables for that mapping and its descendants but do **not** change the `variables:` block itself.
408+
- Are **position dependent**: a `!var` only affects entries that appear **after** it in the same mapping node. A `!var` placed at the root mapping behaves like a global override **from the point it appears onward** but does not retroactively change values already evaluated earlier in the file.
409+
410+
**Practical summary:** treat `variables:` as the file’s initial defaults (position independent) and `!var` as local, sequential declarations that take effect at the point they are evaluated (position dependent).
411+
A `!var` may either **define a new local variable** or **override** an existing one; when a `!var` appears at the root mapping it behaves like a global override only for entries processed after it appears and does not retroactively change values already evaluated earlier in the file.
412+
413+
#### Core rules for `!var`
414+
415+
##### 1. Sequential Evaluation (Order Matters)
416+
417+
`!var` directives apply **immediately** and affect all subsequent keys, values, and nested mappings **within the same mapping node**.
418+
419+
- Earlier entries cannot see variables declared later.
420+
- Reassigning a variable updates its value only for entries that appear after the reassignment.
421+
- Reassignments evaluate expressions using the **current** scope value.
422+
423+
```yaml
424+
!var mode: "dev"
425+
first: "${mode}" # "dev"
426+
427+
!var mode: "prod"
428+
second: "${mode}" # "prod"
429+
```
430+
431+
##### 2. Mapping‑Node Boundaries (Lexical Scope)
432+
433+
Each mapping node defines a scope.
434+
435+
- Child mappings **inherit** all variables visible at the moment they are created.
436+
- Variables declared **inside** a child mapping:
437+
- apply only to that child and its descendants,
438+
- do **not** propagate back to the parent,
439+
- do **not** leak sideways into sibling mappings.
440+
441+
**Diagram:**
442+
443+
```text
444+
parent-map:
445+
├─ !var a: 1 ← defines `a` in this mapping
446+
├─ key1: ${a} ← sees `a`
447+
448+
├─ child-map: ← new mapping node (inherits `a`)
449+
│ ├─ key2: ${a} ← sees `a` but not `b`
450+
│ │ (because `b` is declared *after* this entry)
451+
│ ├─ !var b: 2 ← defines `b` only in this child mapping
452+
│ └─ key3: ${b} ← sees `b` (same mapping node, declared earlier)
453+
454+
└─ key4: ${a} ← sees `a` but not `b`
455+
(because `b` was declared inside child-map)
456+
```
457+
458+
##### 3. List Context Restriction
459+
460+
`!var` is valid **only inside mapping nodes**.
461+
462+
If used inside a list item:
463+
464+
```yaml
465+
- !var foo: bar
466+
```
467+
468+
the list item becomes a mapping containing the directive.
469+
470+
If the list item must remain a scalar, declare the variable in the parent mapping instead.
504471

505472
### Predefined Variables
506473

bundles/org.openhab.io.yamlcomposer/src/test/java/org/openhab/io/yamlcomposer/internal/YamlComposerVarTagTest.java

Lines changed: 69 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -496,35 +496,6 @@ void testVarInTemplateDoesNotLeakToCaller() throws IOException {
496496
assertThat(logSession.getTrackedWarnings(), hasItem(containsString("internal_id")));
497497
}
498498

499-
@Test
500-
@DisplayName("Verify parent !var is inherited downstream by child !include and !insert template")
501-
void testParentVarInheritedByChildAndTemplate() throws IOException {
502-
writeFixture("child.yaml", """
503-
child_prop: "${prefix}_child"
504-
""");
505-
506-
Path mainPath = writeFixture("main.yaml", """
507-
!var prefix: global_scope
508-
509-
templates:
510-
tpl:
511-
tpl_prop: "${prefix}_tpl"
512-
513-
imported:
514-
!var prefix: include_scope
515-
foo: !include "child.yaml"
516-
inserted:
517-
!var prefix: insert_scope
518-
foo: !insert
519-
template: tpl
520-
""");
521-
522-
Map<Object, @Nullable Object> result = loadFixture(mainPath);
523-
524-
assertThat(getNestedValue(result, "imported", "foo", "child_prop"), equalTo("include_scope_child"));
525-
assertThat(getNestedValue(result, "inserted", "foo", "tpl_prop"), equalTo("insert_scope_tpl"));
526-
}
527-
528499
@Test
529500
@DisplayName("Verify !var declared inside a package does not leak to root composition scope")
530501
void testVarInsidePackageDoesNotLeak() throws IOException {
@@ -594,5 +565,74 @@ void testVarInsideIfBlockDoesNotLeak() throws IOException {
594565
assertNotEquals("active_feature", result.get("outer_check"));
595566
assertThat(logSession.getTrackedWarnings(), hasItem(containsString("branch_secret")));
596567
}
568+
569+
@Test
570+
@DisplayName("Verify parent !var is inherited downstream by child !include and !insert template")
571+
void testParentVarInheritedByChildAndTemplate() throws IOException {
572+
writeFixture("child.yaml", """
573+
child_prop: "${prefix}_child"
574+
""");
575+
576+
Path mainPath = writeFixture("main.yaml", """
577+
!var prefix: global_scope
578+
579+
templates:
580+
tpl:
581+
tpl_prop: "${prefix}_tpl"
582+
583+
imported:
584+
!var prefix: include_scope
585+
foo: !include "child.yaml"
586+
inserted:
587+
!var prefix: insert_scope
588+
foo: !insert
589+
template: tpl
590+
""");
591+
592+
Map<Object, @Nullable Object> result = loadFixture(mainPath);
593+
594+
assertThat(getNestedValue(result, "imported", "foo", "child_prop"), equalTo("include_scope_child"));
595+
assertThat(getNestedValue(result, "inserted", "foo", "tpl_prop"), equalTo("insert_scope_tpl"));
596+
}
597+
598+
@Test
599+
@DisplayName("Verify !var inside included file overwrites 'vars' argument passed via !include")
600+
void testVarInIncludeOverwritesIncludeVarsArgument() throws IOException {
601+
writeFixture("child.yaml", """
602+
!var arg_var: "overridden_by_var"
603+
result: "${arg_var}"
604+
""");
605+
606+
Path mainPath = writeFixture("main.yaml", """
607+
imported: !include
608+
file: "child.yaml"
609+
vars:
610+
arg_var: "passed_via_vars"
611+
""");
612+
613+
Map<Object, @Nullable Object> result = loadFixture(mainPath);
614+
615+
assertThat(getNestedValue(result, "imported", "result"), equalTo("overridden_by_var"));
616+
}
617+
618+
@Test
619+
@DisplayName("Verify !var inside template overwrites 'vars' argument passed via !insert")
620+
void testVarInTemplateOverwritesInsertVarsArgument() throws IOException {
621+
String yaml = """
622+
templates:
623+
component:
624+
!var arg_var: "overridden_by_var"
625+
result: "${arg_var}"
626+
627+
component_instance: !insert
628+
template: component
629+
vars:
630+
arg_var: "passed_via_vars"
631+
""";
632+
633+
Map<Object, @Nullable Object> result = loadYaml(yaml);
634+
635+
assertThat(getNestedValue(result, "component_instance", "result"), equalTo("overridden_by_var"));
636+
}
597637
}
598638
}

0 commit comments

Comments
 (0)