[yamlcomposer] YAML Composer Add-on for Enhanced YAML Preprocessing (#20305)

* [yamlcomposer] New Yaml Composer add-on

Signed-off-by: Jimmy Tanagra <jcode@tanagra.id.au>
This commit is contained in:
jimtng
2026-06-10 19:26:30 +02:00
committed by GitHub
parent 67394e2994
commit 0a26a04b2e
64 changed files with 10262 additions and 0 deletions
+1
View File
@@ -473,6 +473,7 @@
/bundles/org.openhab.io.metrics/ @pravussum
/bundles/org.openhab.io.neeo/ @morph166955
/bundles/org.openhab.io.openhabcloud/ @kaikreuzer
/bundles/org.openhab.io.yamlcomposer/ @jimtng
/bundles/org.openhab.persistence.dynamodb/ @ssalonen
/bundles/org.openhab.persistence.influxdb/ @lujop
/bundles/org.openhab.persistence.inmemory/ @J-N-K
@@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons
@@ -0,0 +1,329 @@
# YAML Composer
YAML Composer introduces extended YAML features that make openHAB configuration more modular, reusable, and maintainable. These features let you structure configuration as composable building blocks rather than large, repetitive files.
The add-on loads enhanced-syntax YAML files from `OPENHAB_CONF/yamlcomposer/` and compiles them into fully resolved plain YAML written to `OPENHAB_CONF/yaml/composed/`.
[[toc]]
## Feature Summary
YAML Composer adds several enhancements on top of standard YAML.
Each feature addresses a different kind of reuse, composition, or abstraction to help you build cleaner and more maintainable YAML.
| Feature | Purpose | Typical Use |
|--------------------------------------------|-----------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|
| **Variables and Substitution (`${..}`)** | Insert dynamic values or evaluate expressions | Build labels, topics, IDs, or computed values |
| **Conditionals (`!if`)** | Conditionally include or exclude YAML blocks | Enable or disable features when using packages or template flags |
| **Include (`!include`)** | Insert the contents of another file | Reuse YAML across files; parameterize reusable blocks |
| **Templates (`!insert`)** | Reuse YAML defined within the same file | Local parameterized blocks; reusable channel or item fragments |
| **Packages** | Bundle multiple top-level sections into one reusable unit | Define reusable device structures containing things, items, metadata; sourced from external files or templates |
| **Anchors and Aliases (`&name`, `*name`)** | Define small, reusable YAML fragments | Static defaults, shared fields |
| **Merge Keys (`<<:`)** | Combine mappings from multiple sources | Layer defaults, override fields, compose structures |
Each feature has a dedicated documentation page:
- [Variables and Substitution](doc/variables.md)
- [Conditionals](doc/conditionals.md)
- [Include](doc/include.md)
- [Templates](doc/templates.md)
- [Packages](doc/packages.md)
- [Anchors and Aliases](doc/anchors.md)
- [Merge Keys](doc/merge-keys.md)
These features can be used independently, but they become especially powerful when combined.
For a general introduction to YAML, see [YAML Basics](doc/basics.md).
## Packaging Example
**CONF/yamlcomposer/LivingRoom.yaml:**
```yaml
version: 1
variables:
# Captures "LivingRoom" from the filename automatically
location: ${__FILE_NAME__} # => LivingRoom
packages:
Light1: !include
file: $pkg/zigbee_light.inc.yaml
vars: &LIGHT_VARS
color_temperature: {} # Include color temperature feature
power: # Customize the power item
groups:
- gInsideLights
- gSmartLights
Light2: !include
file: $pkg/zigbee_light.inc.yaml
vars:
<<: *LIGHT_VARS
```
**CONF/yamlcomposer/pkg/zigbee_light.inc.yaml:**
```yaml
# This is the package file, i.e. the template for a zigbee light
variables:
id: ${location}_${package_id}
thingid: ${id | lower | replace("_", "-")}
equipment: ${id}_Equipment
label: ${id | label}
# Set defaults
power: &DEFAULTS
groups: []
dimmer:
<<: *DEFAULTS
things:
mqtt:topic:${thingid}:
bridge: mqtt:broker:mosquitto
channels:
power:
type: switch
config:
stateTopic: zigbee2mqtt/${thingid}/state
commandTopic: zigbee2mqtt/${thingid}/set/state
dimmer:
type: dimmer
config:
stateTopic: zigbee2mqtt/${thingid}/brightness
commandTopic: zigbee2mqtt/${thingid}/set/brightness
<<: !if
if: VARS.containsKey('color_temperature')
then:
color-temperature: !sub
type: dimmer
config:
stateTopic: zigbee2mqtt/${thingid}/color_temp
commandTopic: zigbee2mqtt/${thingid}/set/color_temp
items:
${equipment}:
type: Group
label: ${label} Equipment
tags: [Lightbulb]
groups: ${[location]}
${id}:
type: Switch
label: ${label}
tags: [Control, Light]
groups: ${[equipment] + power.groups}
channel: mqtt:topic:${thingid}:power
${id}_Dimmer:
type: Dimmer
label: ${label} Brightness
tags: [Control, Level]
groups: ${[equipment] + dimmer.groups}
channel: mqtt:topic:${thingid}:dimmer
<<: !if
if: VARS.containsKey('color_temperature')
then:
${id}_CT:
type: Dimmer
label: ${label} Color Temperature
tags: [Control, ColorTemperature]
groups: ${[equipment] + color_temperature.groups}
channel: mqtt:topic:${thingid}:color-temperature
```
<details>
<summary><b>Output in CONF/yaml/composed/LivingRoom.yaml:</b></summary>
```yaml
version: 1
things:
mqtt:topic:livingroom-light1:
bridge: mqtt:broker:mosquitto
channels:
power:
type: switch
config:
stateTopic: zigbee2mqtt/livingroom-light1/state
commandTopic: zigbee2mqtt/livingroom-light1/set/state
dimmer:
type: dimmer
config:
stateTopic: zigbee2mqtt/livingroom-light1/brightness
commandTopic: zigbee2mqtt/livingroom-light1/set/brightness
color-temperature:
type: dimmer
config:
stateTopic: zigbee2mqtt/livingroom-light1/color_temp
commandTopic: zigbee2mqtt/livingroom-light1/set/color_temp
mqtt:topic:livingroom-light2:
bridge: mqtt:broker:mosquitto
channels:
power:
type: switch
config:
stateTopic: zigbee2mqtt/livingroom-light2/state
commandTopic: zigbee2mqtt/livingroom-light2/set/state
dimmer:
type: dimmer
config:
stateTopic: zigbee2mqtt/livingroom-light2/brightness
commandTopic: zigbee2mqtt/livingroom-light2/set/brightness
color-temperature:
type: dimmer
config:
stateTopic: zigbee2mqtt/livingroom-light2/color_temp
commandTopic: zigbee2mqtt/livingroom-light2/set/color_temp
items:
LivingRoom_Light1_Equipment:
type: Group
label: Living Room Light 1 Equipment
tags:
- Lightbulb
groups:
- LivingRoom
LivingRoom_Light1:
type: Switch
label: Living Room Light 1
tags:
- Control
- Light
groups:
- LivingRoom_Light1_Equipment
- gInsideLights
- gSmartLights
channel: mqtt:topic:livingroom-light1:power
LivingRoom_Light1_Dimmer:
type: Dimmer
label: Living Room Light 1 Brightness
tags:
- Control
- Level
groups:
- LivingRoom_Light1_Equipment
channel: mqtt:topic:livingroom-light1:dimmer
LivingRoom_Light1_CT:
type: Dimmer
label: Living Room Light 1 Color Temperature
tags:
- Control
- ColorTemperature
groups:
- LivingRoom_Light1_Equipment
channel: mqtt:topic:livingroom-light1:color-temperature
LivingRoom_Light2_Equipment:
type: Group
label: Living Room Light 2 Equipment
tags:
- Lightbulb
groups:
- LivingRoom
LivingRoom_Light2:
type: Switch
label: Living Room Light 2
tags:
- Control
- Light
groups:
- LivingRoom_Light2_Equipment
- gInsideLights
- gSmartLights
channel: mqtt:topic:livingroom-light2:power
LivingRoom_Light2_Dimmer:
type: Dimmer
label: Living Room Light 2 Brightness
tags:
- Control
- Level
groups:
- LivingRoom_Light2_Equipment
channel: mqtt:topic:livingroom-light2:dimmer
LivingRoom_Light2_CT:
type: Dimmer
label: Living Room Light 2 Color Temperature
tags:
- Control
- ColorTemperature
groups:
- LivingRoom_Light2_Equipment
channel: mqtt:topic:livingroom-light2:color-temperature
```
</details>
## Processing Overview
YAML Composer reads enhanced-syntax YAML files and performs a compilation pass that expands all extended features into a single, fully resolved YAML document that openHAB can load.
During compilation, YAML Composer performs the following steps:
1. **YAML Parsing**: The source file is parsed into an internal structure.
1. **Variable Substitution (`${..}`)**: Expressions are evaluated and injected.
1. **Conditionals (`!if`)**: Conditional logic determines which blocks remain.
1. **Template and Include Expansion**: `!insert` and `!include` bring in referenced content, often using resolved variables.
1. **Package Expansion**: External or local packages are loaded and expanded into their component sections.
1. **Recursive Merging**: Merge keys and package structures are combined into the main document.
1. **Hidden Key Removal**: Keys beginning with `.` are removed from the final output.
The resulting YAML contains:
- all variables resolved
- all conditionals evaluated
- all templates and includes expanded
- all anchors and merges applied
- all packages integrated
- all hidden keys removed
This final compiled YAML is written to `OPENHAB_CONF/yaml/composed/`, where openHAB loads it as Things, Items, Metadata, and other configuration elements as defined by the Core YAML Configuration structure.
## Hidden Keys
Keys beginning with a dot (`.`) are treated as hidden. They:
- exist only during compilation
- are ideal for storing anchors, templates, or shared structures
- keep visible configuration clean
- are removed from the final output
**Example:**
```yaml
.base-switch: &BASE_SWITCH
type: Switch
autoupdate: false
items:
Light1:
<<: *BASE_SWITCH
label: Light One
```
## File Structure and Conventions
YAML files can be organized freely, but the following conventions improve clarity and maintainability:
- Place `variables:` and `templates:` near the top of the file.
- Group reusable structures under hidden keys.
- Use anchors for static fragments and includes for parameterized ones.
- Keep packages in separate files when they represent reusable device or feature definitions.
These conventions are optional but help keep complex configurations predictable and easy to navigate.
### File naming convention
- Use `*.yaml` or `*.yml` for main source files that produce composed output.
- Use `*.inc.yaml` or `*.inc.yml` for include fragments referenced by `!include`.
- Mixed-role files (both main and include) are not supported.
@@ -0,0 +1,63 @@
# Anchors & Aliases
Anchors and aliases provide efficient, infile content reuse to reduce repetition without the overhead of external include files or packages.
[[toc]]
## Core Concepts
YAML provides two primary symbols for defining reusable content:
- **Anchor (`&name`)** — Marks a YAML node (a map, list, or scalar) so it can be reused later.
- **Alias (`*name`)** — References a previously anchored node and injects its content at the current location.
::: tip Scope Note
Anchors and aliases are **filelocal**.
An anchor defined in an included file cannot be referenced from the main file, and vice versa.
:::
### Using Hidden Keys for Anchors
Hidden keys are a convenient place to store anchored nodes without exposing them in the final configuration.
For a full explanation of hidden keys, see [Hidden Keys](../#hidden-keys).
Anchors are often combined with the YAML **merge key** (`<<:`), which inserts the contents of an anchored map into the current map.
For full mergekey semantics, see [Merge Keys](merge-keys.md).
```yaml
.base-switch: &BASE_SWITCH
type: Switch
autoupdate: false
items:
Light1:
<<: *BASE_SWITCH
label: Light One
```
Anchors can also be applied to scalar values.
This works similarly to using a variable:
```yaml
.bar: &BAR value
foo: *BAR
```
## When to Use Anchors
Anchors and aliases are most useful when reusing structural blocks **within the same file**.
They work well for:
- repeating common fields across many entries
- sharing channel or configuration structures
- keeping small reusable fragments close to where they are used
Anchors are a YAMLnative mechanism.
They participate in variable substitution, but they do not support crossfile reuse or templatestyle evaluation.
## Best Practices
- **Naming** — Use `UPPER_CASE` for anchors to distinguish them from standard keys.
- **Standardization** — Use anchors to enforce consistent structure across repeated YAML blocks within a file.
@@ -0,0 +1,329 @@
# Standard YAML Syntax
This page introduces the standard YAML syntax used throughout this documentation.
It is not a full YAML tutorial.
It provides the foundational concepts needed to understand and work with the examples in the YAML Configuration section.
The YAML Composer builds on top of standard YAML 1.2 and adds its own processing features such as custom tags and preprocessing behavior.
This page focuses on the core YAML syntax that those features extend.
For more detailed information on the YAML 1.2 syntax supported in openHAB configurations, see the [YAML 1.2 specification](https://yaml.org/spec/1.2/).
[[toc]]
## Indentation and Structure
YAML uses indentation to represent structure.
- Indentation defines the nesting level.
- Only spaces are allowed.
- Tabs are not permitted for indentation.
- All items at the same level must use the same indentation.
openHAB recommends using **two spaces** per indentation level for consistency.
```yaml
thing:
id: myThing
label: "Example Thing"
```
Misaligned indentation is one of the most common YAML errors.
## Block Style vs Flow Style
YAML supports two ways of expressing mappings and lists.
### Block Style (Recommended)
Block style is the most readable form.
```yaml
item:
type: Switch
label: "Lamp"
tags:
- Light
- LivingRoom
```
### Flow Style
Flow style is more compact and resembles JSON.
It is functionally identical to block style.
```yaml
item: { type: Switch, label: "Lamp", tags: [Light, LivingRoom] }
```
YAML allows block and flow styles to be mixed freely.
## Comments
- Comments begin with `#`.
- Comments continue to the end of the line.
- Comments may appear on their own line or after a value.
```yaml
# This is a comment describing the thing
thing:
id: myThing # Inline comment
label: "Example Thing"
```
## Data Types
YAML defines three fundamental data types: **scalars**, **lists** (sequences), and **mappings**.
### Scalars
Scalars represent simple values such as strings, numbers, booleans, and nulls.
#### Booleans
In openHAB YAML files, only the unquoted literals `true`, `True`, `TRUE`, and `false`, `False`, `FALSE` are recognized as boolean values.
`ON`, `OFF`, `Yes`, `No`, `enable`, and `disable` are parsed as plain strings.
To specify `true` or `false` as a string, they must be enclosed in single or double quotes.
#### Strings
Strings may be written with or without quotes.
Unquoted strings are allowed only when the value **cannot** be interpreted as another YAML scalar type such as a number, boolean, or null.
In practice, this means unquoted strings should contain letters or simple punctuation, but **not** values that look like numbers or booleans.
```yaml
name: KitchenLight
label: Kitchen Light
role: admin
```
If a value could be parsed as a number, boolean, or null, it must be quoted to ensure it is treated as a string.
```yaml
string_number: "123"
string_boolean: "true"
string_null: "null"
```
Quotes are required when:
- The string contains characters YAML may interpret as syntax.
- The string contains leading or trailing spaces.
- The string contains a colon followed by a space, which YAML interprets as a key/value separator.
```yaml
label: "Kitchen: Main Light"
note: " This string preserves leading spaces"
```
Singlequoted and doublequoted strings behave differently.
- Singlequoted strings treat the content literally.
- Singlequoted strings do not process escape sequences.
- Singlequoted strings are useful when you want the text to appear exactly as written.
```yaml
description: 'This string contains \n and it will not be interpreted as a newline'
```
- Doublequoted strings support escape sequences.
- Doublequoted strings allow characters such as `\n`, `\t`, and `\"` to be interpreted.
```yaml
message: "Line one\nLine two"
```
- Use single quotes when you want literal text or when the string contains double quotes.
- Use double quotes when you need escape sequences or when the string contains single quotes and requires escaping.
#### Multiline Strings
Multiline strings are used for longer descriptions or scripts.
Use `|` to preserve line breaks exactly as written.
```yaml
description: |
This is a multi-line
description.
Line breaks are preserved.
```
Use `>` to fold lines into a single paragraph.
```yaml
notes: >
This text will be folded
into a single line,
with spaces instead of line breaks.
```
- `|` preserves line breaks.
- `>` folds single line breaks into spaces.
- Blank lines are preserved when using `>`.
- Indentation under `|` or `>` must be consistent and becomes part of the string.
#### Numbers
YAML supports numeric scalar values without requiring quotes.
A number is interpreted as numeric when it contains only digits, with an optional sign, decimal point, or exponent.
##### Valid Examples
```yaml
positive_integer: 42
negative_integer: -7
decimal_number: 3.1415
negative_decimal: -0.25
scientific_notation: 1.2e6
```
##### When to Use Quotes
Numbers must be quoted if you want them treated as **strings**:
```yaml
string_number: "42"
leading_zero_string: "007"
```
#### Null
YAML supports a null value using any of the following forms:
```yaml
value1: null
value2: Null
value3: NULL
value4: ~
value5:
```
All of these are interpreted as `null`.
### Lists (Sequences)
Lists are introduced with a dash (`-`) at the start of each item.
```yaml
tags:
- Light
- LivingRoom
```
Flow style is also supported:
```yaml
tags: [Light, LivingRoom]
```
### Mappings (Key/Value Pairs)
Mappings define key/value structures.
```yaml
stateDescription:
config:
pattern: "%.1f °C"
```
Flow style is also supported:
```yaml
stateDescription: { config: { pattern: "%.1f °C" } }
```
#### How YAML Identifies a Mapping Entry
A mapping entry uses the pattern:
```yaml
key: value
```
The space after the colon is required.
```yaml
text: "Alice: Hello" # Must be quoted because the colon is followed by a space
url: http://example.com # Safe unquoted because the colon is not followed by a space
```
## Anchors and Aliases
Anchors define reusable blocks.
Aliases reference those blocks.
```yaml
defaults: &defaultConfig
refresh: 60
geolocation: 1.23456, 7.890123
thing:
label: "Lamp"
config: *defaultConfig
```
For more details, see [Anchors and Aliases](anchors.md).
## Merge Keys
YAML merge keys use the `<<:` syntax to merge mappings.
> **Note:** Although merge keys (`<<:`) are not defined in YAML 1.2, the YAML Composer includes full support for them.
> They are a foundational mechanism used throughout the preprocessing system to combine mappings, apply defaults, and build modular configuration structures.
```yaml
base: &base
type: Switch
category: Light
item:
<<: *base
label: "Desk Lamp"
```
Effective structure:
```yaml
item:
type: Switch
category: Light
label: "Desk Lamp"
```
For more details, see [Merge Keys](merge-keys.md).
## YAML Tags (Brief Introduction)
YAML supports tags, which change how a value is interpreted.
Some tags are built in, and YAML Composer adds several **custom tags** such as `!include`, `!if`, and `!remove` to enable enhanced features.
A tag can be applied to a simple scalar:
```yaml
value: !include file.inc.yaml
```
A tag can also be applied to a mapping.
In that form, it applies to everything inside the mapping:
```yaml
thing: !if
if: ${thing_enabled}
then:
label: Room Light
```
You will see these custom tags throughout the enhanced YAML configuration.
## Common Pitfalls
- Using tab characters instead of spaces for indentation.
- Using inconsistent indentation.
- Misaligning list items.
- Omitting quotes around strings containing special characters.
- Incorrectly nesting mappings.
- Incorrect indentation under `|` or `>` for multiline strings.
If a YAML file fails to load, indentation, quoting, or multiline formatting issues are often the cause.
@@ -0,0 +1,164 @@
# Conditionals (!if)
The `!if` tag performs logical branching during the **preprocessing phase**.
> **Note:** Conditions are evaluated **once** when the YAML file is loaded.
> These are not runtime rules.
> They do not react to live state changes in openHAB.
[[toc]]
## When to Use `!if`
Use the `!if` tag to adapt your configuration based on the **Resolution Context** (variables defined in the file, injected via `!include` or `!insert`, or [environment globals](variables.md#env-to-access-environment-variables)).
- **Conditional Snippets**: choose between alternative configuration blocks or values.
- **Optional Properties**: conditionally merge in additional settings using [merge keys (<<)](merge-keys.md).
## Basic Syntax
The `!if` tag supports two forms: a **Mapping Form** for simple logic and a **Sequence Form** for multiple branches.
The `if:` and `elseif:` keys are treated as **implicit expressions**.
You do not need to wrap the expression in `${...}`.
### Mapping Form (Simple)
Use this for simple if/else decisions.
```yaml
example: !if
if: env == 'prod'
then: "secure-server-url"
else: "localhost"
```
| Key | Description | Required |
|:-------|:------------------------------------------------------------------------------------------------------------------------|:---------|
| `if` | The expression to evaluate. | Yes |
| `then` | The value to return if **truthy** ([see rules below](#truthiness-rules)). Can be a scalar, map, list, or any valid tag. | Yes |
| `else` | The value to return if **falsy**. | No |
### Sequence Form (Multiple Branches)
Use this for multiple ordered conditions.
Evaluates conditions in order and stops at the first **truthy** match.
If no condition matches and no `else` is provided, the tag resolves to `null`.
```yaml
environment_type: !if
- if: hardware_version >= 2
then: "high-power-mode"
- elseif: battery_powered
then: "eco-mode"
- else: "standard-mode"
```
## Expression Evaluation
The `if:` key follows a specific order of operations.
### 1. Bare Expressions (Recommended)
The string is evaluated directly as an expression against the available variables.
```yaml
if: count > 10 and status == 'ALARM'
```
::: tip
The expression can be quoted when it contains characters that YAML would otherwise misinterpret, such as `:` or `#`.
:::
### 2. Using substitution pattern (Advanced — Double Evaluation)
If you use a substitution pattern inside an `if:` key, the substitution engine runs **first** to resolve `${...}` patterns.
The resulting string is then evaluated as an expression.
This is useful for building logic strings from variables.
```yaml
variables:
operator: ">"
test: !if
if: 75 ${operator} 50
# Step 1: ${operator} resolves to ">"
# Step 2: The if expression resolves to "75 > 50"
# Step 3: expression evaluates to true
then: "High"
```
## Truthiness Rules
When a value is used in a conditional, it is first evaluated and then interpreted as either **truthy** or **falsy**.
The following values are considered **falsy**:
- `false`
- `null`
- `0` or `0.0`
- empty strings (`""`)
- empty lists (`[]`)
- empty maps (`{}`)
All other values are **truthy**.
This includes any nonempty string, any nonzero number, and any nonempty collection.
### Short-Circuiting (Lazy Evaluation)
Only the active branch is processed.
Tags such as `!include` inside inactive branches are ignored.
An `!include` in an inactive branch is never loaded and will not cause errors if the file does not exist.
## Advanced Integration
### Nesting and Composition
The `!if` tag is fully recursive.
You can nest `!if` tags within the `then` or `else` blocks to create complex decision trees.
```yaml
status: !if
if: device_online
then: !if
if: battery_level < 20
then: "online-low-battery"
else: "online-healthy"
else: "offline"
```
### Conditional Merging (Mixins)
Use `!if` with the YAML merge key (`<<`) to conditionally mix in sets of properties.
```yaml
server_config:
port: 8080
<<: !if
if: is_prod
then:
ssl_enabled: true
strict_security: true
```
### Using !include and !insert
You can return entire files or templates by using `!include` or `!insert` inside a branch.
Only the tag in the active branch is processed.
```yaml
network_settings: !if
if: wifi_enabled
then: !include wifi-config.inc.yaml
else: !insert ethernet-template
```
## Common Pitfalls
1. **Expression vs String Literal**: `if: production` checks for a variable named `production`.
To check for the literal string, quote it: `if: env == 'production'`.
1. **Omitting `else`**: If no condition matches and there is no `else`, the result is `null`.
1. **Invalid YAML**: Even inactive branches must be syntactically valid YAML.
See [Expression Syntax](variables.md#expression-syntax) for more details.
@@ -0,0 +1,291 @@
# Including Other Files
`!include` inserts the referenced file or structure exactly at the position where the include appears.
YAML Composer supports including external YAML files to facilitate modular, reusable, and maintainable configurations.
This is especially useful for modular reuse, creating device [packages](packages.md), or separating concerns across multiple files.
[[toc]]
## Syntax Options
### Short Form
```yaml
keyname: !include filename.inc.yaml
```
```yaml
keyname: !include filename.inc.yaml?arg1=value1&flag
```
The short form allows you to reference another file directly after the `!include` tag.
Optional arguments may be appended using URLstyle query syntax:
- `arg1=value1` assigns a specific value
- `flag` (a valueless argument) is interpreted as `true`
- Multiple arguments are separated with `&`
- The file name, argument names, and argument values are URLdecoded.
If any of them contain characters such as `+`, `%`, or other reserved symbols, make sure to URLencode them.
This form is ideal when you only need to include a file and pass a small number of simple parameters.
### Long Form (supports variables)
```yaml
# Block style (multi-line)
keyname: !include
file: filename.inc.yaml
vars:
var1: value1
var2: value2
```
```yaml
# Flow style (single-line)
keyname: !include { file: filename.inc.yaml, vars: { var1: value1, var2: value2 } }
```
The `vars:` mapping is layered on top of the files current variables to form the evaluation context.
In the long form, the `vars:` section is optional.
::: tip Passing Existing Variables to Included Files
The `vars:` section of an `!include` directive can contain literal values or references to **existing variables**.
Example:
```yaml
keyname: !include
file: xx.inc.yaml
vars:
var1: ${mainvar}
```
The include file can now refer to `var1` without relying on the variable names used in the main file.
This is especially useful when the same include file is shared across multiple configurations that may use different variable names.
:::
The contents of the include file are inserted as the value for the given key.
This means that the top-level keys in the include file become sub-keys of the key in the main file.
## Variable Resolution Order
Understanding how variables interact across files is important when using includes, especially when include files define their own defaults.
When include files are involved, variables can originate from multiple sources.
Their values are resolved according to the following order, from highest priority to lowest priority.
1. Inline `vars` in `!include` directives
1. Global `variables` defined in the main file
1. Local `variables` defined inside the include file
Variables provided in the `vars` section of an `!include` directive are visible only within the included file, but they override both global and local variables.
Local variables defined inside an include file can act as default values when they are not provided in the `!include` directive.
This allows include files to be reused with different parameters while keeping sensible defaults inside the file itself.
**Example:**
`main.yaml`:
```yaml
variables:
broker: mqtt:broker:main
things:
mqtt:topic:livingroom-window: !include
file: mqtt_contact.inc.yaml
vars:
label: Living Room Window
id: livingroom-window
mqtt:topic:bedroom-window: !include
file: mqtt_contact.inc.yaml
vars:
label: Bedroom Window
id: bedroom-window
broker: mqtt:broker:external # override the global broker variable
```
`mqtt_contact.inc.yaml`:
```yaml
bridge: ${broker}
label: ${label}
config:
availabilityTopic: ${id}/availability
payloadAvailable: online
payloadNotAvailable: offline
```
Resulting configuration:
```yaml
things:
mqtt:topic:livingroom-window:
bridge: mqtt:broker:main
label: Living Room Window
config:
availabilityTopic: livingroom-window/availability
payloadAvailable: online
payloadNotAvailable: offline
mqtt:topic:bedroom-window:
bridge: mqtt:broker:external
label: Bedroom Window
config:
availabilityTopic: bedroom-window/availability
payloadAvailable: online
payloadNotAvailable: offline
```
## File Naming & Reload Behavior
### Include File Naming
Include files should use a dedicated extension, either `.inc.yaml` or `.inc.yml`.
Files with a normal `.yaml` extension are treated as **main configuration files** and must contain the full [toplevel structure]({{base}}/configuration/yaml/) required by the YAML configuration schema.
In contrast, `.inc.yaml` files are recognized as include fragments and are only processed when referenced through `!include`.
::: tip Note
Include fragments and main files are intentionally treated as different roles.
Using the same file as both a compiled main file and an included fragment is not supported.
:::
### Path Resolution
Include file paths may be written as absolute paths or as paths relative to the current file.
YAML Composer also supports two shorthand prefixes that simplify referencing files inside the configuration directory.
#### Shorthand Prefixes
| Shorthand | Resolves To | Meaning |
|---------------------|-------------------------------------|----------------------------------------------------------------------------------------------------------------------------|
| `@/path`<br>`@path` | `${OPENHAB_CONF}/path` | The openHAB configuration root. Use when referencing files anywhere under `OPENHAB_CONF`. The slash after `@` is optional. |
| `$/path`<br>`$path` | `${OPENHAB_CONF}/yamlcomposer/path` | The source directory for your extended-syntax YAML files. The slash after `$` is optional. |
##### `@/path` → `${OPENHAB_CONF}/path`
A leading `@` resolves directly to the openHAB configuration root (`OPENHAB_CONF`).
```yaml
key: !include "@/yaml/includes/device.inc.yaml"
# Resolves to: ${OPENHAB_CONF}/yaml/includes/device.inc.yaml
```
**Notes:**
- Paths beginning with `@` **must be quoted** (e.g., `"@/path"`), because YAML plain scalars cannot begin with `@`.
- The slash after `@` is **optional**: `"@yaml/includes/device.inc.yaml"` works the same.
- Use `@` when you want to reference files anywhere under the configuration root without writing long absolute paths.
##### `$/path` → `${OPENHAB_CONF}/yamlcomposer/path`
A leading `$` resolves to `OPENHAB_CONF/yamlcomposer`.
**Example directory layout:**
```sh
OPENHAB_CONF/
yamlcomposer/
shared.inc.yaml
lights/
kitchen/
main.yaml
```
If `main.yaml` contains:
```yaml
key: !include "$/shared.inc.yaml"
```
Then `$` resolves to:
```sh
${OPENHAB_CONF}/yamlcomposer
```
So the final resolved path becomes:
```sh
${OPENHAB_CONF}/yamlcomposer/shared.inc.yaml
```
**Notes:**
- The slash after `$` is **optional**: `"$shared.inc.yaml"` works the same.
- It avoids brittle paths like `../../../shared.inc.yaml`.
#### Relative Paths
If the path does not begin with `/`, `@`, or `$` it is interpreted as a path **relative to the directory of the including file**.
You may use `.` and `..` to refer to the current and parent directory.
**Example directory layout:**
```sh
yamlcomposer/
parent.inc.yaml
main/
main.yaml
shared.inc.yaml
common/
defaults.inc.yaml
```
**Same directory:**
```yaml
key: !include "shared.inc.yaml"
# Resolves to: yamlcomposer/main/shared.inc.yaml
```
**Navigate downward (into a subdirectory):**
```yaml
key: !include "common/defaults.inc.yaml"
# Resolves to: yamlcomposer/main/common/defaults.inc.yaml
```
**Navigate upward:**
```yaml
key: !include "../parent.inc.yaml"
# Resolves to: yamlcomposer/parent.inc.yaml
```
Relative paths always resolve from the directory containing the including file.
#### Using Variable Substitutions
You can use variable substitution patterns in the file name to construct paths dynamically.
This includes normal variables, [predefined variables](variables.md#predefined-variables), and environment variables exposed through `ENV`.
**Example:**
```yaml
keyname: !include
file: ${OPENHAB_CONF}/packages/hue-light.pkg.yaml
```
### File Organization
It may be helpful to store include files in a dedicated subdirectory.
These files can be referenced using relative paths, the `@` or `$` shorthands, or full absolute paths.
Choose whichever style best matches your preference.
### Nested Includes
Include files may themselves contain `!include` directives.
This allows configurations to be composed from multiple layers:
main.yaml → a.inc.yaml → b.inc.yaml → …
### Reload Behavior
When an include file changes, YAML Composer reloads the main files that reference it rather than attempting to load the include file directly.
Reloads occur only if the include file is located within `${OPENHAB_CONF}/yamlcomposer/`.
@@ -0,0 +1,205 @@
# Merge Keys
Merge keys (`<<`) let you combine mappings defined directly in a mapping with other mappings defined elsewhere, such as:
- [Anchors](anchors.md)
- [!include](include.md) (External files)
- [!insert](templates.md) (In-file Templates)
- [!if](conditionals.md) (Conditionals)
- [Variables](variables.md)
They promote reusability and avoid repetition by letting you define common mappings once and update them in a single place.
[[toc]]
## What Merge Keys Do
A merge key takes one or more source mappings and merges their keyvalue pairs into the current mapping.
The source mappings may be written inline, referenced through an anchor, or loaded from another file.
The merged values behave exactly as if they were written inline.
Local fields may add to or override the merged content.
## Merge Rules
- Only mappings can be merged.
- Merge keys may appear multiple times or as a list.
- Merge order matters because keys from earlier mappings override keys from later ones.
- Local keys always override merged keys, even when defined after a merge key.
- Merges are shallow, and nested mappings are replaced rather than combined.
### Example: Multiple Merges and Precedence
```yaml
.defaults: &DEFAULTS
icon: default
autoupdate: false
.metadata: &LIGHTS
tags: [Light]
icon: light
items:
Lamp:
<<: [*LIGHTS, *DEFAULTS]
label: Desk Lamp
```
Result:
```yaml
items:
Lamp:
label: Desk Lamp
icon: light # from LIGHTS (earlier mapping wins)
autoupdate: false
tags: [Light]
```
## Limitations
- Merge keys only merge mappings, not lists.
- Merge keys cannot modify scalar values.
- Merge keys cannot be used inside sequences unless the sequence elements are mappings.
## Integrating with Other Features
### Anchors
[Anchors](anchors.md) define reusable structures whose content can be inserted into the current mapping via an alias.
Merge keys then combine that anchored content with any local fields in the mapping.
Anchors are a standard YAML feature for sharing data within a single file.
Variables or templates are often preferred for more complex needs.
```yaml
items:
Light1: &LIGHT_BASE
type: Switch
icon: light
autoupdate: false
label: Light One
Light2:
<<: *LIGHT_BASE
label: Light Two
```
See also: [Using Hidden Keys for Anchors](anchors.md#using-hidden-keys-for-anchors).
### `!include`
The [!include](include.md) tag loads the contents of another file so it can be inserted into the current mapping.
Merge keys then combine the included content with any local fields in the mapping.
**Benefits over anchors:**
- Included files can be parameterized, allowing the same structure to be reused with different values.
- Included files can be shared across many different YAML files, whereas anchors are limited to a single file.
```yaml
# light-common.inc.yaml
power:
type: switch
stateTopic: ${id}/power
availability:
type: contact
stateTopic: ${id}/availability
```
```yaml
# light-color.inc.yaml
color:
type: color
stateTopic: ${id}/color
```
```yaml
# main.yaml
things:
mqtt:topic:living-room-light:
channels:
<<: !include { file: light-common.inc.yaml, vars: { id: living-room-light } }
<<: !include { file: light-color.inc.yaml, vars: { id: living-room-light } }
```
### Templates (`!insert`)
The [!insert](templates.md) tag inserts the contents of an in-file template into the current mapping.
Merge keys then combine the templates fields with any local fields in that mapping.
Templates can be parameterized when used in a merge key.
```yaml
templates:
common_channels:
power:
type: switch
stateTopic: ${id}/power
availability:
type: contact
stateTopic: ${id}/availability
color_channel:
color:
type: color
stateTopic: ${id}/color
things:
mqtt:topic:living-room-light:
channels:
<<: !insert { template: common_channels, vars: { id: living-room-light } }
<<: !insert { template: color_channel, vars: { id: living-room-light } }
```
### Conditionals (`!if`)
The [!if](conditionals.md) tag allows you to choose between different mappings or return `null` to skip the merge entirely.
The engine resolves tags before the merge, so the merge key receives the final mapping.
```yaml
# Merge extra settings only for production
server:
port: 8080
<<: !if
if: is_prod
then: { ssl: true, cache: true }
```
### Substitution
Merge keys can combine content produced dynamically from an expression.
This enables inline conditional merging using Python-style expressions.
```yaml
variables:
has_color: true
color_channel:
color:
type: color
things:
mqtt:topic:light:
channels:
power:
type: switch
# Returns color_channel map if true
<<: ${color_channel if has_color}
```
::: tip Hints
- Merge keys only merge mappings, so the expression must resolve to a map.
- Avoid compound patterns such as `${foo}${bar}` or `x${foo}` because they are interpreted as literal strings and cannot be merged.
- Merging `null` or an empty map is a no-op, effectively omitting the merge key.
:::
## Related Topics
For details on how variables are resolved and how substitution interacts with YAML structures, see [Variables & Substitution — Return Types & Coercion](variables.md#return-types--coercion).
## Best Practices
- Use merge keys to define reusable base templates.
- Keep anchors inside hidden keys to avoid clutter.
- Use includes or packages to centralize shared structures.
- Prefer simple, predictable structures for reusable blocks.
@@ -0,0 +1,450 @@
# Packages
Packages provide a way to bundle multiple related YAML sections into a reusable, parameterized unit.
Unlike fragment-level insertion, a package expands into full toplevel sections such as `things:` or `items:`.
These sections may come from an external file or a samefile template.
They are then merged into the current configuration.
[[toc]]
## Purposes
- **Logical Grouping:**
Packages allow a **Thing** and its related **Items**, channels, and metadata to be defined together in one file, representing a complete, selfcontained device definition.
- **Reuse with Different Parameters:**
Through variable substitution, a single package can be instantiated multiple times with different values.
This makes it easy to define many similar devices such as sensors, lights, or switches from one shared template.
## Package Syntax and Structure
Packages are declared in the main YAML file under the toplevel `packages:` section.
Each entry defines a package ID and the source from which the package content is obtained.
```yaml
packages:
<package_id>: <package_source>
<another_package_id>: <package_source>
...
```
### Key Components
- **Package ID:**
A package ID is a unique identifier for the package.
It may include spaces.
The special variable `${package_id}` resolves to this key inside the package content.
- **Package Source:**
A package can be created from either of the following sources:
- **`!include` (external file):**
Loads a separate YAML file and applies the packages variable context to it.
See the [!include syntax options](include.md#syntax-options).
- **`!insert` (samefile template):**
Expands a template defined under the main files `templates:` section.
See the [!insert syntax options](templates.md#syntax-options).
Both forms support parameterization through `vars:` and participate fully in package merging.
## Package Source Contents
- **TopLevel Sections:**
Package sources contain any combination of toplevel keys such as `things:` and `items:`.
- **Uniqueness:**
Because package sources can be referenced multiple times, use variable substitutions such as `${package_id}` and unique `vars:` variables for entity UIDs in each invocation to avoid collisions.
- **Deep Nesting:**
A package source may itself include other packages.
When a package references another package via `packages:` inside an included file, it creates an inheritance chain.
Properties merge downward sequentially from the deepest core file out to the final main file.
## Package Example
`main.yaml`:
```yaml
variables:
broker: mqtt:broker:main
packages:
livingroom-light: !include
file: package/mqtt-light.inc.yaml
vars:
name: Living_Room_Light
label: Living Room Light
bedroom-light: !include
file: package/mqtt-light.inc.yaml
vars:
name: Bed_Room_Light
label: Bed Room Light
```
`package/mqtt-light.inc.yaml`:
```yaml
things:
mqtt:topic:${package_id}:
bridge: ${broker}
channels:
power:
type: switch
config:
stateTopic: ${package_id}/state
commandTopic: ${package_id}/set/state
# ... other channels (brightness, color)
items:
${name}_Power:
type: Switch
label: ${label} Power
channel: mqtt:topic:${package_id}:power
# ... more items for the light, e.g. brightness, color, etc.
```
Resulting YAML structure:
```yaml
things:
mqtt:topic:livingroom-light:
bridge: mqtt:broker:main
channels:
power:
type: switch
config:
stateTopic: livingroom-light/state
commandTopic: livingroom-light/set/state
mqtt:topic:bedroom-light:
bridge: mqtt:broker:main
channels:
power:
type: switch
config:
stateTopic: bedroom-light/state
commandTopic: bedroom-light/set/state
items:
Living_Room_Light_Power:
type: Switch
label: Living Room Light Power
channel: mqtt:topic:livingroom-light:power
Bed_Room_Light_Power:
type: Switch
label: Bed Room Light Power
channel: mqtt:topic:bedroom-light:power
```
## Merge Behavior
### Final Top-Level Sections
A final toplevel section is the fully expanded `things:`, `items:`, or other toplevel section of the configuration that openHAB receives after all packages, templates, includes, and merges have been applied.
Entries defined directly under these sections can merge with packagegenerated entries when their identifiers match.
Entries remain independent when their identifiers do not match.
::: tip Note
The following example uses `!insert`, but the same merge rules apply to packages sourced from `!include`.
:::
When a package is expanded, its contents are merged into the main YAML structure.
You may customize the resulting structure by overriding, adding, or removing elements defined in the package.
This is done by redefining the elements you want to customize in the main file.
These redefinitions appear in the final toplevel section.
### Default Merge Behavior
Source YAML File:
```yaml
templates:
number_item:
items:
${package_id}_Item:
type: Number
label: Package Label
tags: [Measurement]
metadata:
stateDescription:
config:
min: 1
pattern: '%.3f'
widget:
value: oh-card
packages:
Number: !insert number_item
# This is the final toplevel `items:` section of the configuration
# The packages will merge into this section
items:
Number_Item:
label: Power Draw
dimension: Power
tags: [Power]
metadata:
stateDescription:
config:
max: 10
```
Result:
```yaml
items:
Number_Item:
type: Number
label: Power Draw
dimension: Power
tags:
- Measurement
- Power
metadata:
stateDescription:
config:
min: 1
max: 10
pattern: '%.3f'
widget:
value: oh-card
```
The way keys interact depends on their data type.
| Data Type | Behavior | Description |
|-----------|-----------|----------------------------------------------------------------------------------------------------------|
| Scalar | Overwrite | A scalar in the final toplevel section replaces the scalar defined at the same path inside the package. |
| Map | Merge | Maps are merged key by key, recursively. |
| List | Merge | Lists are concatenated (package values first) and de-duplicated. |
### Automatic Removal of Empty Values
During merging, empty structures are automatically stripped from the final configuration.
Empty maps (`{}`) and lists (`[]`) as well as map keys whose value is `null` or an empty string are removed.
This keeps the resulting configuration clean and allows packages to define catchall defaults.
**Example:**
```yaml
variables:
icon: null # default to avoid unknownvariable warnings
icon: ${icon}
```
Because `icon` evaluates to `null`, the entire `icon:` key is removed from the merged output unless the including file overrides it.
### How Package Merging Differs from YAML Merge Keys
Mappings from packages are merged recursively with the corresponding mappings in the final toplevel section.
This differs from standard YAML merge keys, which perform only shallow merges.
**Merge Key (shallow merge):**
```yaml
# merge key:
targetkey:
foo:
bar:
boo: baz
<<: # merge `foo` into `targetkey`
foo:
bar:
boo: waldo
goo: fy
qux: quux
```
```yaml
# result — the merge key's foo mapping
# is ignored because foo already exists in main
targetkey:
foo:
bar:
boo: baz
```
**Package Merging (recursive merge):**
```yaml
# main file
targetkey:
foo:
bar:
boo: baz
packages:
anyid: !include packagefile.inc.yaml
```
```yaml
# packagefile.inc.yaml
targetkey:
foo:
bar:
boo: waldo
goo: fy
qux: quux
```
```yaml
# result:
targetkey:
foo:
bar:
boo: baz # main file overrides matching keys
goo: fy # but includes additional keys...
qux: quux # from the package
```
recursive merging allows customization at any depth in the mapping.
### Controlling Package Merge Behavior with Tags
Use these special YAML tags to explicitly override the default recursive merge behavior.
These directives can be declared at any point in the configuration pipeline—either within the **main YAML file** or deeply embedded inside an intermediate **include file** acting as a middle-tier package.
#### 1. The `!replace` Tag
The `!replace` tag forces an absolute replacement for maps or lists that would otherwise merge.
This acts as a destructive splice, completely discarding any existing inherited data structure beneath that key from upstream sources and starting fresh with the new layout provided.
#### 2. The `!remove` Tag
The `!remove` tag cleanly deletes the corresponding key and its entire sub-tree from the configuration hierarchy.
It is ideal for pruning specific components, metadata keys, or configurations exposed by a baseline package that do not apply to the current downstream context.
---
### Example: Nested Package Overrides
When packages include other packages, a middle-tier file can surgically modify or strip elements declared by the core baseline configuration before it ever reaches the main composition file.
#### Core Configuration (`nested-items.inc.yaml`)
Provides a core item layout with deep metadata fields:
```yaml
items:
target_item:
label: base_label
tags:
- from_nested
metadata:
stateDescription:
value: from_nested
category:
value: from_nested
untouched_item:
label: untouched_from_nested
```
#### Middle-Tier Override Package (`pkg-with-overrides.inc.yaml`)
This file consumes the core configuration via `!include`, but applies `!replace` and `!remove` tags to selectively adjust properties:
```yaml
packages:
nested: !include nested-items.inc.yaml
items:
target_item:
tags: !replace # 1. Overwrite the list, discarding 'from_nested'
- from_outer
metadata:
stateDescription: !remove # 2. Entirely purge this sub-tree key
category: !replace # 3. Swap out the map structure with a fresh one
value: from_outer
config:
origin: nested_package_override
```
#### Root Configuration (`main.yaml`)
Loads the middle-tier package:
```yaml
packages:
p1: !include pkg-with-overrides.inc.yaml
```
#### Final Merged Output
When evaluated by the preprocessor, the downstream overrides successfully neutralize the deep properties inherited from the original package file.
The `stateDescription` block is erased, lists and maps are replaced cleanly, and unrelated siblings are passed through untouched:
```yaml
items:
target_item:
label: base_label
tags:
- from_outer
metadata:
category:
value: from_outer
config:
origin: nested_package_override
untouched_item:
label: untouched_from_nested
```
::: tip Usage Notes
- `!replace` and `!remove` are evaluated locally within each package layer before that package's content is returned and merged into the caller.
This allows intermediate include files to cleanly intercept, prune, or completely reset configurations inherited from deeper core packages.
- They can be declared in the root configuration file or anywhere down an inheritance chain of nested package includes.
- `!remove` target references apply to entire map keys; individual array elements within a list cannot be targeted for independent removal.
- Use `!replace` on an array to drop inherited elements and start an array list with entirely new contents.
:::
## Strategic Use of Package IDs
Choose a **Package ID** that can also serve as a Thing UID fragment, Item name, or similar identifier.
This avoids defining extra variables in your package source and lets you derive related identifiers directly from `${package_id}`.
You can override `${package_id}` in the `vars:` block if needed.
**Example:**
```yaml
# main file
packages:
Living_Room_Light: !include light.inc.yaml
Kitchen_Light: !include light.inc.yaml
```
```yaml
# light.inc.yaml package source
variables:
id: ${package_id|lower|replace('_', '-')}
thing_uid: "mqtt:topic:${id}"
item_name: ${package_id}
label: ${package_id|replace('_', ' ')}
```
**Resulting variables:**
| Variable | Living_Room_Light | Kitchen_Light |
|-----------------|--------------------------------|----------------------------|
| `${package_id}` | `Living_Room_Light` | `Kitchen_Light` |
| `${id}` | `living-room-light` | `kitchen-light` |
| `${thing_uid}` | `mqtt:topic:living-room-light` | `mqtt:topic:kitchen-light` |
| `${item_name}` | `Living_Room_Light` | `Kitchen_Light` |
| `${label}` | `Living Room Light` | `Kitchen Light` |
## Limitation: Top-Level Merge Keys in `packages:`
Top-level YAML merge keys (for example `<<:`) inside the `packages:` map are not supported.
Each package must be declared explicitly as a direct key under `packages:`.
The following pattern is **not supported**:
```yaml
packages:
<<: !include common-packages.yaml
```
In this form, the merge key tries to inject package declarations into `packages:` itself.
The composer does not expand package declarations through top-level merge keys.
@@ -0,0 +1,188 @@
# Templates
Templates let you define reusable blocks of YAML directly within the same file.
They are stored under the toplevel `templates:` section and can be expanded using the `!insert` tag.
Templates behave similarly to `!include` in how they are evaluated — including variable scoping and perinvocation isolation — but they are defined locally rather than in external files.
[[toc]]
## What Templates Are
A template is a named YAML node stored under the toplevel `templates:` section.
A template may contain **any YAML value**:
- a mapping
- a sequence
- a scalar
The keys under `templates:` (`simple_greeting`, `greeting`, `channel_block`, `color_list`) are **template names**.
They identify each template and are not part of the templates content.
Example:
```yaml
templates:
simple_greeting: Hello!
greeting: Hello, ${name}!
channel_block:
power:
type: switch
stateTopic: ${id}/power
color_list:
- red
- green
- blue
```
::: tip Important!
Templates are not evaluated when defined.
They are evaluated **each time** they are expanded with `!insert`.
Therefore, a template may contain references to variables that do not yet exist; those variables are supplied later during the `!insert` invocation.
:::
## Syntax Options
`!insert` expands a template at the point where it appears.
It supports two forms:
### Short Form
```yaml
message: !insert simple_greeting
```
```yaml
message: !insert simple_greeting?arg1=value1&flag
```
The short form allows you to reference a template directly after the `!insert` tag.
Optional arguments may be appended using URLstyle query syntax:
- `arg1=value1` assigns a specific value
- `flag` (a valueless argument) is interpreted as `true`
- Multiple arguments are separated with `&`
- The template name, argument names, and argument values are URLdecoded.
If any of them contain characters such as `+`, `%`, or other reserved symbols, make sure to URLencode them.
Use the noargument form when you simply want to insert a template asis.
Use the queryparameter form when you want to pass a small number of simple parameters.
### Long Form (supports variables)
```yaml
# Block style (multi-line)
message: !insert
template: greeting
vars:
name: "Jimmy"
# Or flow style (single-line)
message: !insert { template: greeting, vars: { name: "Jimmy" } }
```
The `vars:` mapping is layered on top of the files current variables to form the evaluation context.
In the long form, the `vars:` section is optional.
## How Template Evaluation Works
Each `!insert` call is evaluated in its **own isolated context**:
- It begins with all variables defined in the current file.
- If this file was loaded via `!include`, it also inherits variables from parent files.
- Variables provided through `vars:` override or extend this context.
- The template body is evaluated using this combined set of variables.
- The resulting value — scalar, list, or mapping — replaces the `!insert` node.
### Example: Scalar Template
```yaml
templates:
greeting: Hello, ${name}!
message: !insert { template: greeting, vars: { name: "Alice" } }
```
Result:
```yaml
message: Hello, Alice!
```
### Example: Template Producing a Mapping
```yaml
templates:
channel_block:
power:
type: switch
stateTopic: ${id}/power
thing:
channels: !insert { template: channel_block, vars: { id: living-room-light } }
```
### Example: Template as a List Item
```yaml
templates:
color_entry: ${color}
colors:
- !insert { template: color_entry, vars: { color: red } }
- !insert { template: color_entry, vars: { color: green } }
- !insert { template: color_entry, vars: { color: blue } }
```
### Example: Template Producing a Sequence
```yaml
templates:
rgb:
- red
- green
- blue
palette: !insert rgb
```
## Similarities with `!include`
Templates and includes share the same evaluation model:
- Each invocation is evaluated in its own isolated context.
- The context includes:
- variables defined in the current file
- variables inherited from parent files (if this file was loaded via `!include`)
- variables provided through `vars:` for this specific invocation
- Variable resolution inside the template uses this combined context.
This section is intentionally mechanical — it explains _how_ templates behave, not _when_ to use them.
## Template Scope and Variable Resolution
Inside a template:
- Interpolation happens only when the template is inserted.
- `${var}` resolves using the combined variable context described above.
- Each `!insert` call is isolated — variables from one invocation do not leak into another.
## Limitations
- Templates must be defined under the toplevel `templates:` section.
- Templates cannot be shared across files (use `!include` for that).
- Template names must be unique within the file.
- A template may produce any YAML value, but the resulting value must be valid for the location where it is inserted (scalar, list item, or mapping field).
## Best Practices
- Keep template names descriptive and consistent.
- Prefer the block style when passing multiple variables for readability.
- Use the composed output in `OPENHAB_CONF/yaml/composed/` to verify template expansion.
---
Infile templates provide a flexible, parameterizable way to structure reusable YAML within a single file.
They complement `!include` and anchors, offering a clean mechanism for building consistent configurations.
@@ -0,0 +1,281 @@
# Variables & Substitution
Variables allow you to define reusable values and substitute them throughout your YAML configuration.
They provide the flexibility needed for complex templates and reduce hard-coded values.
These variables work consistently across both the current file and any included packages.
[[toc]]
## Variable Definition
Variables are defined within a top-level `variables:` section.
It is recommended to place this section at the beginning of the file for better organization.
**Example:**
```yaml
variables:
# Scalar variables
expire: 5m
label: Living Room
# Map variable
mqtt:
broker: mqtt:broker:mybroker
# List variable
rooms:
- Kitchen
- Bedroom
```
## Variable Substitution
### Default Substitution Behavior
Substitution enables dynamic value construction instead of static hard-coding.
Whenever a scalar contains a `${...}` pattern, the preprocessor evaluates the expression and replaces the pattern with the result.
This makes it easy to construct labels, identifiers, and paths from defined variables.
Substitution also applies to YAML keys to allow identifiers to be built dynamically.
**Example:**
```yaml
variables:
room: Kitchen
light_id: Kitchen_Light
items:
${light_id}:
label: ${room} Light
```
**Resulting Output:**
```yaml
items:
Kitchen_Light:
label: Kitchen Light
```
### Return Types & Coercion
The output type depends on how the substitution is structured within the YAML scalar.
#### Type Preservation (Single Expression)
If a YAML value consists **entirely** of a single substitution pattern, the resulting object preserves the original Java type returned by the expression.
This allows you to inject complex structures like booleans, lists, or maps directly.
```yaml
is_active: ${status == 'ON'} # Becomes a real Boolean
target_rooms: ${rooms} # Becomes a real List
connection: "${mqtt_config_map}" # Becomes a real Map
```
#### String Coercion (Mixed Content)
If the substitution pattern is combined with any other text, or if multiple patterns are used together, the entire value is coerced into a **String**.
```yaml
description: "Status is ${status}" # String: "Status is ON"
concatenated: "${10}${20}" # String: "1020"
room_name: "${room} " # String: "Kitchen " (includes space)
```
| Syntax Pattern | Resulting Type | Example Output |
|:---------------|:---------------|:------------------------|
| `${expr}` | **Preserved** | `[Item1, Item2]` (List) |
| `"${expr}"` | **Preserved** | `true` (Boolean) |
| `Text ${expr}` | **String** | `"Count: 5"` (String) |
| `${ex1}${ex2}` | **String** | `"1020"` (String) |
### The `!literal` Tag and `!sub` Escape Hatch
The `!literal` tag disables substitution recursively for a specific YAML node.
This is useful when you need to preserve the `${...}` syntax as literal text.
Inside a `!literal` section, you can use the `!sub` tag to re-enable substitution for a specific child node.
When these tags overlap, the innermost tag always controls the final behavior.
**Example:**
```yaml
top: !literal
foo: ${LITERAL}
bar:
baz: ${LITERAL}
quux: !sub ${substituted}
grault: ${LITERAL}
```
## Expression Syntax
The Expression syntax is based on the [Jinja expression](https://jinja.palletsprojects.com/en/stable/templates/#expressions) language.
Only expressions inside `${...}` are supported; template blocks like `{% if %}` or `{% for %}` are not available.
### Variable References
1. `label`: Refers to a scalar variable.
1. `rooms[0]`: Refers to the first element of a list.
1. `mqtt.broker` or `mqtt['broker']`: Refers to a map subkey.
1. `mqtt[key]`: Resolves the key dynamically using the value of the `key` variable.
### Operations & Concatenation
An expression can include string, arithmetic, and boolean operations.
1. **String Concatenation**: Use the `~` operator (e.g., `"Room " ~ index`).
1. **Coercion**: The `~` operator is the preferred way to join values because it automatically converts non-string operands into strings.
1. **Automatic Joining**: Adjacent literal text and substitution patterns are automatically joined without operators (e.g., `value: "Hello ${username}"`).
> **Note:** Referencing an undefined variable resolves to `null`, logs a warning, and results in an empty string if used in a string context.
### List Concatenation with `+`
Jinjas `+` operator supports list concatenation.
If one side is a list and the other is a scalar, the scalar is automatically wrapped into a single-element list.
**Example:**
```yaml
variables:
groups: [Group1, Group2]
location: SemanticLocationGroup
effective_groups: ${ groups + location }
# Result: [Group1, Group2, SemanticLocationGroup]
```
### Built-in Filters
Filters are applied using the `variable|filter` syntax and can be chained.
#### Text Transformation
| Filter | Description |
|:-------------|:------------------------------------------------------------|
| `capitalize` | Capitalize a value. |
| `title` | Return a titlecased version. |
| `lower` | Convert a value to lowercase. |
| `upper` | Convert a value to uppercase. |
| `replace` | Replace a substring. |
| `trim` | Strip leading and trailing characters (default whitespace). |
#### Formatting
| Filter | Description |
|:---------|:----------------------------------------------|
| `format` | Apply values to a printf-style format string. |
| `round` | Round a number to an optional precision. |
| `int` | Convert a value into an integer. |
#### Collection Helpers
| Filter | Description |
|:---------|:---------------------------------------|
| `first` | Return the first item of a list. |
| `length` | Return the length of a list or string. |
#### Fallbacks
| Filter | Description |
|:----------|:--------------------------------------------------------------|
| `default` | Return a default value if the variable is empty or undefined. |
**Default Example:**
```yaml
label: ${room_label | default('Kitchen')}
```
### Custom Filters
| Filter | Description |
|:--------|:------------------------------------------------------------------------------------|
| `label` | Converts identifiers (camelCase, snake_case) into human-friendly titles. |
| `dig` | Safely navigates deep maps; returns `null` instead of an error if a key is missing. |
**`dig` Example:**
```yaml
# Dot notation and mixed access supported
user: ${ infrastructure | dig('config.login.user') }
host: ${ VARS | dig('config', 'servers', 1, 'host') | default('localhost') }
```
## Advanced Usage
### Predefined Variables
The Composer injects environmental and file-system context automatically.
These variables can be interpolated just like regular ones and are helpful when constructing paths for directives.
| Variable | Description |
|:-------------------|:------------------------------------------------------------------------|
| `OPENHAB_CONF` | Absolute path to openHAB's main configuration directory. |
| `OPENHAB_USERDATA` | Absolute path to openHAB's userdata directory. |
| `__FILE__` | Absolute path to the current file. |
| `__FILE_NAME__` | Filename portion without the extension or leading path. |
| `__FILE_EXT__` | File extension portion of the current file name. |
| `__DIRECTORY__` | Directory portion of the current file. |
| `__DIR__` | Alias for `__DIRECTORY__`. |
| `package_id` | Automatically resolved to the Package ID within included package files. |
### Handling Reserved Keywords
If a variable name is a Jinja keyword (like `and`, `or`, `if`), access it via the `VARS` dictionary.
This also works for variable names containing characters like hyphens that are invalid in direct references.
```yaml
foo: ${VARS['and']}
```
### ENV to Access Environment Variables
A special variable `ENV` exposes a map of system environment variables.
This is especially useful for configurations running within Docker containers.
```yaml
mode: ${ENV.OPENHAB_MODE} # Resolves to the environment value
```
### Calling Java Methods
Variables retain their Java types, allowing you to call standard methods directly.
Common types you may encounter include:
- [String](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/String.html)
- [Integer](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Integer.html)
- [Double](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Double.html)
- [Boolean](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Boolean.html)
- [Map](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Map.html)
- [List](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html)
This is useful for logic beyond built-in filters, such as complex regex replacements.
```yaml
# Use Java String methods for complex logic
is_sensor: ${ device_id.startsWith("sensor_") }
clean_id: ${ device_id.replaceAll("ABC", "XYZ") }
```
### Custom Pattern Delimiters
If your content conflicts with `${...}`, you can define custom delimiters using a named pattern.
This allows the same pattern to be used consistently across all included files.
```yaml
variables:
jinja: "{{..}}"
foo: !sub:jinja "Hello {{ username }}!"
```
## Common Pitfalls
1. **Unquoted Operators**: Expressions with operators should be quoted so YAML doesn't misinterpret characters.
1. **Reserved Names**: Avoid naming variables using keywords like `true`, `false`, `null`, `in`, or `if`.
1. **`+` vs `~`**: Use `~` for strings to avoid type mismatch errors and use `+` for numbers or lists.
1. **Jinja Blocks**: Template blocks such as `{% for %}` are not supported; use inline `if` expressions instead.
1. **Whitespace Sensitivity**: Spaces outside of quotes inside `${ ... }` are ignored, but spaces in quoted strings are preserved.
+100
View File
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>5.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.io.yamlcomposer</artifactId>
<name>openHAB Add-ons :: Bundles :: IO :: YAML Composer</name>
<properties>
<bnd.importpackage>com.sun.jdi;resolution:=optional,com.sun.jdi.connect;resolution:=optional,com.sun.jdi.event;resolution:=optional,com.sun.jdi.request;resolution:=optional,com.sun.tools.attach;resolution:=optional,jdk.internal.module;resolution:=optional,sun.misc;resolution:=optional,*</bnd.importpackage>
</properties>
<dependencies>
<dependency>
<groupId>org.snakeyaml</groupId>
<artifactId>snakeyaml-engine</artifactId>
<version>3.0.1</version>
</dependency>
<dependency>
<groupId>org.openhab.osgiify</groupId>
<artifactId>com.hubspot.jinjava.jinjava</artifactId>
<version>2.7.4</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.google.re2j</groupId>
<artifactId>re2j</artifactId>
<version>1.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.5.0-jre</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openhab.osgiify</groupId>
<artifactId>com.hubspot.immutables.immutables-exceptions</artifactId>
<version>1.9</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.20.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.15.3</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>${commons.net.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>ch.obermuhlner</groupId>
<artifactId>big-math</artifactId>
<version>2.3.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.googlecode.java-ipv6</groupId>
<artifactId>java-ipv6</artifactId>
<version>0.17</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>3.0.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.30.2-GA</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jdk8</artifactId>
<version>${jackson.version}</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.io.yamlcomposer-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-misc-yamlcomposer" description="YAML Composer" version="${project.version}">
<feature>openhab-runtime-base</feature>
<feature dependency="true">openhab.tp-commons-net</feature>
<bundle dependency="true">mvn:org.snakeyaml/snakeyaml-engine/3.0.1</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/com.hubspot.jinjava.jinjava/2.7.4</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/com.google.re2j.re2j/1.2</bundle>
<bundle dependency="true">mvn:ch.obermuhlner/big-math/2.3.2</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/com.hubspot.immutables.immutables-exceptions/1.9</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.io.yamlcomposer/${project.version}</bundle>
</feature>
</features>
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.slf4j.Logger;
import org.slf4j.helpers.MessageFormatter;
/**
* A wrapper/decorator for SLF4J loggers that intercepts warnings for
* consolidation through {@link LogSession} while passing other
* log levels through immediately.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class BufferedLogger {
private final Logger delegate;
private final LogSession session;
public BufferedLogger(Logger delegate, LogSession session) {
this.delegate = delegate;
this.session = session;
}
public LogSession getLogSession() {
return session;
}
/**
* Intercepts warnings and sends them to the session for count consolidation.
*/
public void warn(String template, Object... args) {
String resolvedMessage = Objects.requireNonNull(MessageFormatter.arrayFormat(template, args).getMessage());
session.trackWarning(delegate, resolvedMessage);
}
// Pass-through methods for immediate logging
public void info(String format, Object... args) {
delegate.info(format, args);
}
public void debug(String format, Object... args) {
delegate.debug(format, args);
}
public void trace(String format, Object... args) {
delegate.trace(format, args);
}
public void error(String format, Object... args) {
delegate.error(format, args);
}
// Simple string overloads
public void info(String msg) {
delegate.info(msg);
}
public void debug(String msg) {
delegate.debug(msg);
}
public void trace(String msg) {
delegate.trace(msg);
}
public void error(String msg) {
delegate.error(msg);
}
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal;
import java.nio.file.Path;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.OpenHAB;
/**
* Static configuration constants and system path resolution for the YAML Composer.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public final class ComposerConfig {
private static final Path DEFAULT_CONFIG_ROOT = normalizeAbsolute(Path.of(OpenHAB.getConfigFolder()));
private static final Path DEFAULT_USERDATA_ROOT = normalizeAbsolute(Path.of(OpenHAB.getUserDataFolder()));
private static volatile Path configRootOverride = DEFAULT_CONFIG_ROOT;
private static volatile Path userDataRootOverride = DEFAULT_USERDATA_ROOT;
public static final Path SOURCE_ROOT_DIRECTORY = Path.of("yamlcomposer");
public static final Path OUTPUT_ROOT_DIRECTORY = Path.of("yaml", "composed");
// Special section keys
public static final String TEMPLATES_KEY = "templates";
public static final String VARIABLES_KEY = "variables";
public static final String PACKAGES_KEY = "packages";
// Preprocessing limits
public static final int MAX_INCLUDE_DEPTH = 100;
private ComposerConfig() {
// Static utility class
}
public static Path configRoot() {
return configRootOverride;
}
public static Path userDataRoot() {
return userDataRootOverride;
}
public static Path sourceRoot() {
return configRoot().resolve(SOURCE_ROOT_DIRECTORY);
}
public static Path outputRoot() {
return configRoot().resolve(OUTPUT_ROOT_DIRECTORY);
}
static void setRootsForTesting(Path configRoot, Path userDataRoot) {
configRootOverride = normalizeAbsolute(configRoot);
userDataRootOverride = normalizeAbsolute(userDataRoot);
}
static void resetRootsForTesting() {
configRootOverride = DEFAULT_CONFIG_ROOT;
userDataRootOverride = DEFAULT_USERDATA_ROOT;
}
private static Path normalizeAbsolute(Path path) {
return path.toAbsolutePath().normalize();
}
/**
* Resolves the output path for a generated YAML file.
*
* @param sourcePath source file's absolute path
* @return the path where the generated output should be written
*/
public static Path resolveOutputPath(Path sourcePath) {
Path relativeToSourceRoot = sourceRoot().relativize(sourcePath);
return outputRoot().resolve(relativeToSourceRoot);
}
}
@@ -0,0 +1,280 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.OpenHAB;
import org.openhab.io.yamlcomposer.internal.constructors.ModelConstructor;
import org.snakeyaml.engine.v2.api.Dump;
import org.snakeyaml.engine.v2.api.DumpSettings;
import org.snakeyaml.engine.v2.api.Load;
import org.snakeyaml.engine.v2.api.LoadSettings;
import org.snakeyaml.engine.v2.common.FlowStyle;
import org.snakeyaml.engine.v2.resolver.ScalarResolver;
import org.snakeyaml.engine.v2.schema.CoreSchema;
import org.snakeyaml.engine.v2.schema.Schema;
/**
* Pure utility functions for YAML preprocessing.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
final class ComposerUtils {
private static final String GENERATED_HEADER = """
# ==============================================================================
# Generated by openHAB %s (%s) YAML Composer, DO NOT EDIT
# Source: %s
# Generated: %s
# ==============================================================================
""";
private ComposerUtils() {
// Static utility class
}
/**
* Removes top-level keys that start with a dot from the given map.
*
* @param dataMap the map to process
*/
static void removeHiddenKeys(Map<?, ?> dataMap) {
dataMap.keySet().removeIf(key -> key instanceof String keyStr && keyStr.startsWith("."));
}
/**
* Writes the fully processed YAML representation as it would be seen by openHAB after preprocessing
* to a compiled output file.
*
* @param dataMap the resulting data map to dump
* @param sourcePath the absolute path of the source file being processed
* @throws IOException if writing to the file fails
*/
static void writeCompiledOutput(Object dataMap, Path sourcePath, Path outputPath) throws IOException {
Path outputDir = outputPath.getParent();
if (outputDir != null) {
Files.createDirectories(outputDir);
}
DumpSettings dumpSettings = DumpSettings.builder() //
.setDefaultFlowStyle(FlowStyle.BLOCK) //
.setIndentWithIndicator(true) //
.setIndicatorIndent(2) //
.setMultiLineFlow(true) //
.build();
Dump yaml = new Dump(dumpSettings);
Object unaliasedData = breakAliases(dataMap);
String compiledYaml = yaml.dumpToString(unaliasedData);
compiledYaml = formatYamlSpacing(compiledYaml);
Path sourcePathRelative = ComposerConfig.configRoot().relativize(sourcePath);
compiledYaml = GENERATED_HEADER.formatted(OpenHAB.getVersion(), OpenHAB.buildString(), sourcePathRelative,
ZonedDateTime.now()) + "\n" + compiledYaml;
Files.writeString(outputPath, compiledYaml, StandardCharsets.UTF_8);
}
/**
* Creates a new SnakeYAML Engine load instance with the custom composer constructor and resolver.
*
* @param sourcePath the source path of the YAML file for error reporting in the constructor
* @return a new Load instance
*/
private static Load createYamlLoader(String sourcePath) {
Schema schema = new CoreSchema() {
@Override
public ScalarResolver getScalarResolver() {
return new ModelResolver();
}
};
LoadSettings loadSettings = LoadSettings.builder() //
.setLabel(sourcePath) //
.setAllowDuplicateKeys(false) //
.setUseMarks(true) //
.setSchema(schema) //
.build();
return new Load(loadSettings, new ModelConstructor(loadSettings, sourcePath));
}
/**
* Loads YAML content from the given byte array using a Yaml instance configured with the custom constructor.
*
* @param fileBytes the YAML content as a byte array
* @param sourcePath the source path of the YAML file for error reporting in the constructor
* @return the loaded YAML object
* @throws IOException if an I/O error occurs
*/
static @Nullable Object loadYaml(byte[] fileBytes, Path sourcePath) throws IOException {
Load loader = createYamlLoader(sourcePath.toString());
return loader.loadFromInputStream(new ByteArrayInputStream(fileBytes));
}
/**
* Produces a deep-copy of the common YAML structure objects (maps, lists, sets)
* such that no container instance is shared more than once in the resulting
* graph. This removes object identity sharing that SnakeYAML would otherwise
* emit as anchors/aliases when dumping.
*
* @param root the loaded YAML object (may be Map, List, Set, scalar, or custom)
* @return a deep-copied object graph with sharing broken
*/
private static @Nullable Object breakAliases(@Nullable Object root) {
if (root == null) {
return null;
}
return cloneNode(root, new IdentityHashMap<>());
}
/**
* Post-processes a YAML string to inject empty lines for improved readability.
*
* <p>
* This method applies two specific spacing rules:
* <ul>
* <li><strong>Level 0:</strong> Inserts a newline before every top-level key (except the first line).</li>
* <li><strong>Level 2:</strong> Inserts a newline between sibling nodes indented by exactly two spaces.</li>
* </ul>
*
* <p>
* The logic ensures that the first child of a section remains "glued" to its parent header,
* and it safely handles both Map keys (e.g., {@code Key:}) and List items (e.g., {@code - Item}).
*
* @param yaml The raw YAML string to be formatted.
* @return A formatted YAML string with injected "breathing room."
*/
private static @Nullable String formatYamlSpacing(@Nullable String yaml) {
if (yaml == null || yaml.isEmpty()) {
return yaml;
}
String[] lines = yaml.split("\n");
StringBuilder sb = new StringBuilder();
boolean hasSeenLevel0 = false;
boolean hasSeenLevel2 = false;
for (String line : lines) {
// --- Level 0 Logic (Sections) ---
// Matches lines starting with a character (not space, not comment)
if (!line.startsWith(" ") && !line.startsWith("#")) {
if (hasSeenLevel0) {
sb.append("\n");
}
hasSeenLevel0 = true;
hasSeenLevel2 = false; // Reset level 2 tracker for the new section
}
// --- Level 2 Logic (Items or List Elements) ---
// Matches exactly 2 spaces followed by a non-whitespace character.
// This targets siblings within a top-level section.
else if (line.matches("^ {2}[^ \\t].*")) {
if (hasSeenLevel2) {
sb.append("\n");
}
hasSeenLevel2 = true;
}
sb.append(line).append("\n");
}
return sb.toString().trim();
}
/**
* Recursively clones a YAML node, breaking object identity to prevent SnakeYAML from emitting anchors/aliases.
*
* @param node The YAML node to clone.
* @param stack The recursion stack used to handle cycles.
* @return A deep-cloned YAML node with object identity broken.
*/
private static @Nullable Object cloneNode(@Nullable Object node, IdentityHashMap<Object, Object> stack) {
if (node == null) {
return null;
}
// Scalars and non-container objects are returned as-is
if (!(node instanceof Map) && !(node instanceof List) && !(node instanceof Set)) {
return node;
}
// If the node is already on the current recursion stack, return the
// partially-built copy to handle cycles. We do NOT reuse completed
// copies for separate occurrences — that would preserve sharing and
// produce aliases.
if (stack.containsKey(node)) {
return stack.get(node);
}
if (node instanceof Map) {
@SuppressWarnings("unchecked")
Map<@Nullable Object, @Nullable Object> original = (Map<@Nullable Object, @Nullable Object>) node;
Map<@Nullable Object, @Nullable Object> copy = new LinkedHashMap<>(original.size());
stack.put(node, copy);
for (Map.Entry<@Nullable Object, @Nullable Object> e : original.entrySet()) {
Object k = e.getKey();
Object v = e.getValue();
Object newKey = cloneNode(k, stack);
Object newVal = cloneNode(v, stack);
copy.put(newKey, newVal);
}
// Remove from stack so later separate occurrences are cloned again
stack.remove(node);
return copy;
}
if (node instanceof List) {
@SuppressWarnings("unchecked")
List<@Nullable Object> original = (List<@Nullable Object>) node;
List<@Nullable Object> copy = new ArrayList<>(original.size());
stack.put(node, copy);
for (@Nullable
Object item : original) {
copy.add(cloneNode(item, stack));
}
stack.remove(node);
return copy;
}
// Set (preserve insertion order if possible)
if (node instanceof Set) {
@SuppressWarnings("unchecked")
Set<@Nullable Object> original = (Set<@Nullable Object>) node;
Set<@Nullable Object> copy = new LinkedHashSet<>(original.size());
stack.put(node, copy);
for (Object item : original) {
copy.add(cloneNode(item, stack));
}
stack.remove(node);
return copy;
}
// Fallback: return node as-is
return node;
}
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link IncludeRegistry} manages a bidirectional association between
* main files and the include files they reference.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public final class IncludeRegistry {
private final Map<Path, Set<Path>> mainToIncludes = new HashMap<>();
private final Map<Path, Set<Path>> includeToMains = new HashMap<>();
@SuppressWarnings("null")
public synchronized void registerInclude(Path mainFile, Path include) {
mainToIncludes.computeIfAbsent(mainFile, key -> new HashSet<>()).add(include);
includeToMains.computeIfAbsent(include, key -> new HashSet<>()).add(mainFile);
}
public synchronized void removeMain(Path mainFile) {
Set<Path> includes = mainToIncludes.remove(mainFile);
if (includes == null) {
return;
}
includes.forEach(include -> {
Set<Path> mains = includeToMains.get(include);
if (mains != null) {
mains.remove(mainFile);
if (mains.isEmpty()) {
includeToMains.remove(include);
}
}
});
}
public synchronized Set<Path> getMainsForInclude(Path include) {
return new HashSet<>(includeToMains.getOrDefault(include, Set.of()));
}
public synchronized Set<Path> getIncludesForMain(Path mainFile) {
return new HashSet<>(mainToIncludes.getOrDefault(mainFile, Set.of()));
}
public synchronized boolean hasInclude(Path include) {
Set<Path> mains = includeToMains.get(include);
return mains != null && !mains.isEmpty();
}
public synchronized void clear() {
mainToIncludes.clear();
includeToMains.clear();
}
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.slf4j.Logger;
/**
* Coordinates log aggregation across multiple classes and instances during a single
* processing session.
* <p>
* It collects warnings and their counts, then flushes them with a summary suffix
* (e.g., " (3 times)") when the session is closed. This prevents log flooding
* during recursive operations like YAML !includes.
* </p>
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class LogSession implements AutoCloseable {
private final Map<String, Integer> counts = new LinkedHashMap<>();
private final Map<String, Logger> loggers = new HashMap<>();
/**
* Records a warning message for later consolidation.
*/
public void trackWarning(Logger logger, String resolvedMessage) {
loggers.putIfAbsent(resolvedMessage, logger);
counts.merge(resolvedMessage, 1, Integer::sum);
}
/**
* Returns an unmodifiable list of tracked warning messages in the order they were recorded.
* Primarily used for verification in unit tests.
*/
List<String> getTrackedWarnings() {
return List.copyOf(counts.keySet());
}
public int getTotalWarningCount() {
return counts.values().stream().mapToInt(Integer::intValue).sum();
}
/**
* Issues all buffered warnings to their respective loggers with occurrence counts.
*/
public void flush() {
counts.forEach((msg, count) -> {
String output = msg;
if (count > 1) {
String separator = msg.contains("\n") ? System.lineSeparator() : " ";
output = msg + separator + "(" + count + " times)";
}
Objects.requireNonNull(loggers.get(msg)).warn(output);
});
counts.clear();
loggers.clear();
}
@Override
public void close() {
flush();
}
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.io.yamlcomposer.internal.constructors.ModelConstructor;
import org.snakeyaml.engine.v2.nodes.Tag;
import org.snakeyaml.engine.v2.resolver.CoreScalarResolver;
/**
* A custom scalar resolver for SnakeYAML Engine that shifts structural processing
* from the engine's native layer to our internal composer layer.
*
* <p>
* This implementation modifies the default SnakeYAML Engine resolution in two key ways:
* </p>
* <ul>
* <li><b>Custom Merge Key Handling:</b> It disables the default merge key ({@code <<})
* resolution. Instead, it registers a custom implicit resolver that maps the
* merge key to a unique tag. This allows the composer to handle object
* merging manually, providing more control over the transformation lifecycle.</li>
*
* <li><b>Collision Prevention:</b> It opts out of the default {@code ENV_TAG}
* resolver. This ensures that the standard YAML environment variable syntax
* ({@code ${VAR}}) does not interfere with our custom substitution syntax.</li>
* </ul>
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
class ModelResolver extends CoreScalarResolver {
ModelResolver() {
super(false);
addImplicitResolver(ModelConstructor.DEFERRED_MERGE_TAG, MERGE, "<");
}
@Override
@NonNullByDefault({})
public void addImplicitResolver(Tag tag, Pattern regexp, String first) {
if (!Tag.ENV_TAG.equals(tag)) {
super.addImplicitResolver(tag, regexp, first);
}
}
}
@@ -0,0 +1,163 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.expression.ExpressionEvaluator;
/**
* The {@link StringInterpolator} provides utility methods for performing
* variable substitution and expression evaluation in YAML processing.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class StringInterpolator {
static final String DEFAULT_BEGIN = "${";
static final String DEFAULT_END = "}";
/**
* Matches ${...} interpolation patterns in YAML strings.
*
* Allows nested quotes like default('{foo}') without prematurely stopping at
* the inner '}'
*
* Examples:
* - ${name} : Simple variable substitution
* - ${ name|capitalize } : Apply capitalize filter
* - ${ name|default('value') } : Use default if variable is not set
* - ${ name|upper } : Convert to uppercase
*/
public static final Pattern DEFAULT_SUBSTITUTION_PATTERN = Pattern.compile("""
\\$\\{
(?<content>
(?:
"[^"]*" |
'[^']*' |
\\{[^}]*?\\} |
[^}]
)*?
)
\\}
""", Pattern.COMMENTS | Pattern.DOTALL);
private StringInterpolator() {
// Utility class, no instances
}
/**
* Compiles a substitution pattern from begin and end delimiters.
*
* @param begin the opening delimiter (e.g., "${")
* @param end the closing delimiter (e.g., "}")
* @return the compiled pattern
*/
public static Pattern compileSubstitutionPattern(String begin, String end) {
if (DEFAULT_BEGIN.equals(begin) && DEFAULT_END.equals(end)) {
return DEFAULT_SUBSTITUTION_PATTERN;
}
String quotedBegin = Pattern.quote(begin);
String quotedEnd = Pattern.quote(end);
// Allow quoted segments to contain the end delimiter; otherwise stop at the
// first closing delimiter.
String content = """
"[^"]*"|'[^']*'|\\{[^}]*?\\}|(?:(?!%s).)
""".formatted(quotedEnd).strip();
String regex = quotedBegin + "(?<content>(" + content + ")*?)" + quotedEnd;
return Pattern.compile(regex, Pattern.DOTALL);
}
/**
* Compiles a substitution pattern from a pattern specification string with format
* {@code <begin>..<end>}.
*
* @param patternSpec the pattern specification, e.g. {@code $[[..]]}
* @return the compiled pattern, or {@code null} when the specification is invalid
*/
public static @Nullable Pattern compilePatternSpec(String patternSpec) {
int separator = patternSpec.indexOf("..");
if (separator <= 0 || separator >= patternSpec.length() - 2) {
return null;
}
String begin = patternSpec.substring(0, separator);
String end = patternSpec.substring(separator + 2);
if (begin.isEmpty() || end.isEmpty()) {
return null;
}
return compileSubstitutionPattern(begin, end);
}
/**
* Evaluates a variable value, performing substitution if needed.
* Returns the native object type if the entire value is a single placeholder,
* otherwise returns a string with interpolated values.
*
* @param value the raw string value that may contain placeholder patterns to substitute
* @param pattern the substitution pattern to use
* @param variables the variable map
* @param logSession the logging session
* @param sourceLocation description of the source location for logging
* @return the evaluated value
*/
public static @Nullable Object interpolate(String value, Pattern pattern, Map<String, @Nullable Object> variables,
LogSession logSession, String sourceLocation) {
Matcher matcher = pattern.matcher(value);
if (!matcher.find()) {
return value;
}
// If the whole value is exactly one ${...}, evaluate it and return the native object.
// This preserves non-string types (maps, lists, numbers, booleans)
// when the value is a plain placeholder.
if (matcher.matches()) {
String content = Objects.requireNonNull(matcher.group("content"));
return evaluateExpression(content, variables, logSession, sourceLocation);
}
// Evaluate the expressions inside the ${...} patterns and replace them in the string.
String interpolated = matcher.replaceAll(match -> {
String content = Objects.requireNonNull(match.group("content"));
Object result = evaluateExpression(content, variables, logSession, sourceLocation);
String rendered = result != null ? result.toString() : "";
return Matcher.quoteReplacement(rendered);
});
return interpolated;
}
/**
* Evaluates a Jinjava expression.
*
* When an error occurs during evaluation, and null is returned.
* A warning is logged and consolidated through logSession
*
* @param expression the expression to evaluate without the ${} delimiters
* @param variables the variable context for evaluation
* @param logSession the logging session for warnings and errors during evaluation
* @param sourceLocation description of the source location for logging
* @return the evaluated result
*/
public static @Nullable Object evaluateExpression(String expression, Map<String, @Nullable Object> variables,
LogSession logSession, String sourceLocation) {
Object rendered = ExpressionEvaluator.renderObject(expression, variables, logSession, sourceLocation);
return rendered;
}
}
@@ -0,0 +1,349 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.core.PackageProcessor;
import org.openhab.io.yamlcomposer.internal.core.ProcessingPhase;
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
import org.openhab.io.yamlcomposer.internal.core.SourceLocator;
import org.openhab.io.yamlcomposer.internal.core.TemplateLoader;
import org.openhab.io.yamlcomposer.internal.core.VariableLoader;
import org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder;
import org.openhab.io.yamlcomposer.internal.processors.IfProcessor;
import org.openhab.io.yamlcomposer.internal.processors.IncludeProcessor;
import org.openhab.io.yamlcomposer.internal.processors.InsertProcessor;
import org.openhab.io.yamlcomposer.internal.processors.RemoveProcessor;
import org.openhab.io.yamlcomposer.internal.processors.ReplaceProcessor;
import org.openhab.io.yamlcomposer.internal.processors.SubstitutionProcessor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.snakeyaml.engine.v2.exceptions.Mark;
import org.snakeyaml.engine.v2.exceptions.MarkedYamlEngineException;
import org.snakeyaml.engine.v2.exceptions.YamlEngineException;
/**
* The {@link YamlComposer} is a utility class to load YAML files
* and process them into a final YAML structure that openHAB can use.
*
* The following features are supported:
*
* <ul>
* <li>YAML Anchors and aliases.
* <li>YAML Merge keys (<code>&lt;&lt;</code>) to allow merging of maps with override semantics.
* <li>Variable substitution and interpolation using <code>${var}</code> syntax.
* <li>Conditional evaluation using <code>!if</code> tag with simple boolean logic.
* <li><code>!include</code> tag for including other YAML files.
* <li><code>!insert</code> tag for inserting template content with local variable substitution.
* <li>Combining elements using packages.
* </ul>
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class YamlComposer {
private static final Logger RAW_LOGGER = LoggerFactory.getLogger(YamlComposer.class);
private final BufferedLogger logger;
public static record CacheEntry(byte[] bytes, long mtime) {
}
private final Path absolutePath;
private final Path relativePath;
private final Map<String, @Nullable Object> variables;
private final Map<Object, @Nullable Object> templates;
private final Set<Path> includeStack;
private final ConcurrentHashMap<Path, @Nullable CacheEntry> includeCache;
private final RecursiveTransformer recursiveTransformer;
/**
* Constructs a YamlComposer for the given file path and context.
*
* @param path the file path for resolving relative includes
* @param variables initial variable context
* @param includeStack current include stack for circular reference detection
* @param includeCallback callback invoked for each included file
* @param logSession the log session for warning consolidation
* @param includeCache the cache for included files
* @throws YamlEngineException if a circular include is detected or if the maximum include depth is exceeded
*/
public YamlComposer(Path path, Map<String, @Nullable Object> variables, Set<Path> includeStack,
Consumer<Path> includeCallback, LogSession logSession,
ConcurrentHashMap<Path, @Nullable CacheEntry> includeCache) {
this.absolutePath = Objects.requireNonNull(path.toAbsolutePath().normalize());
this.relativePath = ComposerConfig.configRoot().relativize(absolutePath);
this.logger = new BufferedLogger(RAW_LOGGER, logSession);
this.variables = new HashMap<>(variables);
this.includeCache = includeCache;
this.templates = new HashMap<>();
// Validate circular inclusion and depth before processing
Set<Path> newIncludeStack = new LinkedHashSet<>(includeStack);
if (!newIncludeStack.add(absolutePath)) {
@SuppressWarnings("null")
String includeStackChain = newIncludeStack.stream().map(Path::toString).collect(Collectors.joining(" -> "));
throw new YamlEngineException(
"Circular inclusion detected: %s -> %s".formatted(includeStackChain, absolutePath));
}
if (newIncludeStack.size() > ComposerConfig.MAX_INCLUDE_DEPTH) {
throw new YamlEngineException("Maximum include depth (" + ComposerConfig.MAX_INCLUDE_DEPTH + ") exceeded");
}
this.includeStack = newIncludeStack;
this.recursiveTransformer = new RecursiveTransformer(this.variables);
this.recursiveTransformer.register(new SubstitutionProcessor(logger));
this.recursiveTransformer.register(new IfProcessor(logger));
this.recursiveTransformer.register(
new IncludeProcessor(absolutePath.getParent(), newIncludeStack, includeCallback, includeCache, logger));
this.recursiveTransformer.register(new InsertProcessor(templates, logger));
this.recursiveTransformer.register(new RemoveProcessor());
this.recursiveTransformer.register(new ReplaceProcessor());
}
/**
* Loads a YAML file from the given {@link Path} and processes it through the
* full composer pipeline.
* <p>
* This is the main entry point for the YAML Composer. It reads the file,
* parses the YAML, and applies all supported composer features.
* <p>
* The {@code includeCallback} is invoked for each file referenced via an
* include directive, allowing the caller to track include usage so it can
* refresh models when included files change.
* <p>
* The returned value is the fully evaluated Java object representation of the
* YAML document after all the processing steps have been applied.
*
* @param path the path to the YAML file to load and process; also used as
* the base directory for resolving relative includes
* @param includeCallback a callback invoked for each included file
* @return the processed Java object representation of the YAML file
* @throws IOException if the file cannot be read or if processing fails
*/
public static @Nullable Object load(Path path, Consumer<Path> includeCallback) throws IOException {
// Create a LogSession autocloseable object. It consolidates warnings and duplicates.
// Upon exit, any warnings will be logged.
try (LogSession session = new LogSession()) {
ConcurrentHashMap<Path, @Nullable CacheEntry> cache = new ConcurrentHashMap<>();
return load(path, includeCallback, session, cache);
}
}
/**
* Internal method to allow passing in a LogSession so we can manage it externally in tests.
*
* @param path the file path for resolving relative includes
* @param includeCallback callback invoked for each included file
* @param logSession the LogSession to use for logging warnings during loading
* @param includeCache the cache for included files to optimize repeated loads
* @return the processed Java object representation of the YAML file
* @throws IOException if there is an error reading or processing the YAML
*/
static @Nullable Object load(Path path, Consumer<Path> includeCallback, LogSession logSession,
ConcurrentHashMap<Path, @Nullable CacheEntry> includeCache) throws IOException {
Path absolutePath = path.toAbsolutePath().normalize();
Path relativePath = ComposerConfig.configRoot().relativize(absolutePath);
try {
YamlComposer composer = new YamlComposer(absolutePath, Map.of(), Set.of(), includeCallback, logSession,
includeCache);
Object result = composer.load();
// Print a summary of warnings before the LogSession outputs all the warnings.
int totalWarnings = logSession.getTotalWarningCount();
if (totalWarnings > 0) {
int unique = logSession.getTrackedWarnings().size();
String issuesLabel = (unique == 1) ? "unique issue" : "unique issues";
String warningLabel = (totalWarnings == 1) ? "warning" : "warnings";
RAW_LOGGER.warn("YAML Composer {}: Preprocessing completed with {} {} ({} {}).", relativePath,
totalWarnings, warningLabel, unique, issuesLabel);
}
return result;
} catch (MarkedYamlEngineException e) {
String errorMsg = e.getMessage();
Mark mark = e.getProblemMark().orElse(null);
if (mark != null) {
String location = "%d:%d".formatted(mark.getLine() + 1, mark.getColumn() + 1);
String errorClass = e.getClass().getSimpleName();
errorMsg = "\n%s:%s %s %s".formatted(relativePath, location, errorClass, e.getMessage());
}
throw new IOException(errorMsg, e);
} catch (YamlEngineException e) {
throw new IOException(e.getMessage(), e);
}
}
/**
* Internal load method that performs the actual loading and processing of the YAML file.
*
* @return the processed Java object representation of the YAML file
* @throws IOException if there is an error reading the YAML file
* @throws YamlEngineException if there is an error during YAML parsing or processing
*/
public @Nullable Object load() throws IOException, YamlEngineException {
logger.debug("Loading file({}): {} with given vars {}", includeStack.size(), absolutePath, variables);
// Phase 1: Parse YAML and initialize helper objects
byte[] yamlBytes = readYamlBytes();
SourceLocator locator = new SourceLocator(yamlBytes);
// Phase 2: set up initial variables
VariableLoader variableLoader = new VariableLoader(variables, absolutePath, recursiveTransformer, logger);
variableLoader.setSpecialVariables();
// Phase 3: load and parse YAML
Object yamlObj = ComposerUtils.loadYaml(yamlBytes, relativePath);
if (!(yamlObj instanceof Map<?, ?>)) {
yamlObj = recursiveTransformer.transform(yamlObj, ProcessingPhase.STANDARD);
if (!(yamlObj instanceof Map<?, ?>)) {
return yamlObj;
}
}
Map<?, ?> yamlMap = (Map<?, ?>) yamlObj;
// Phase 4: extract variables and templates
Object variablesSection = removeByScalarKey(yamlMap, ComposerConfig.VARIABLES_KEY);
variableLoader.extractVariables(variablesSection, locator);
// Extract templates because we want to defer substitutions in the templates
// until the template is instantiated with !insert, so that the variable context
// includes any variables passed in the !insert directive.
Object templatesSection = removeByScalarKey(yamlMap, ComposerConfig.TEMPLATES_KEY);
new TemplateLoader(logger, relativePath, templates, recursiveTransformer, locator)
.extractTemplates(templatesSection);
// Phase 5: extract/remove packages from the main data because we want to
// inject the package_id into each package context
@Nullable
Object packagesObj = removeByScalarKey(yamlMap, ComposerConfig.PACKAGES_KEY);
// Phase 6: Resolve merge keys and process substitutions, conditionals, includes and inserts
// in a single pass so that merge keys can merge data produced by includes/inserts.
yamlMap = recursiveTransformer.transform(yamlMap, ProcessingPhase.STANDARD);
// Phase 7: process and merge packages
new PackageProcessor(logger, absolutePath, relativePath, locator, recursiveTransformer).mergePackages(yamlMap,
packagesObj);
// Phase 8: process override placeholders (!replace, !remove)
yamlMap = recursiveTransformer.transform(yamlMap, ProcessingPhase.PACKAGE_OVERRIDES);
// Phase 9: final cleanup and optional compiled output
ComposerUtils.removeHiddenKeys(yamlMap);
return yamlMap;
}
/**
* Reads the YAML file bytes with caching based on file modification time to optimize repeated loads of the same
* file. The bytes are cached in {@link #includeCache}.
*
* @return the YAML file bytes
* @throws IOException if there is an error reading the file
*/
private byte[] readYamlBytes() throws IOException {
CacheEntry cached = includeCache.get(absolutePath);
long currentMtime = Files.getLastModifiedTime(absolutePath).toMillis();
if (cached != null && cached.mtime == currentMtime) {
return cached.bytes;
}
byte[] yamlBytes = Files.readAllBytes(absolutePath);
includeCache.put(absolutePath, new CacheEntry(yamlBytes, currentMtime));
return yamlBytes;
}
/**
* Checks if the given file name is an include file based on its extension.
* An include file ends with .inc.yml or .inc.yaml.
*
* @param fileName the name of the file to check
* @return true if it's an include file, false otherwise
*/
public static boolean isIncludeFile(String fileName) {
return fileName.endsWith(".inc.yml") || fileName.endsWith(".inc.yaml");
}
/**
* Checks if the given file name is a Yaml file based on its extension.
* A Yaml file ends with .yml or .yaml.
*
* @param fileName the name of the file to check
* @return true if it's a Yaml file, false otherwise
*/
public static boolean isYamlFile(String fileName) {
return fileName.endsWith(".yml") || fileName.endsWith(".yaml");
}
private static @Nullable Object removeByScalarKey(Map<?, ?> map, String key) {
@SuppressWarnings("unchecked")
Map<@Nullable Object, @Nullable Object> mutableMap = (Map<@Nullable Object, @Nullable Object>) map;
for (Iterator<Map.Entry<@Nullable Object, @Nullable Object>> iterator = mutableMap.entrySet()
.iterator(); iterator.hasNext();) {
Map.Entry<@Nullable Object, @Nullable Object> entry = iterator.next();
@Nullable
Object entryKey = entry.getKey();
if (entryKey == null) {
continue;
}
boolean keyMatches = switch (entryKey) {
case String s -> key.equals(s);
case SubstitutionPlaceholder p -> key.equals(p.value());
default -> false;
};
if (keyMatches) {
Object value = entry.getValue();
iterator.remove();
return value;
}
}
return null;
}
public Path getAbsolutePath() {
return absolutePath;
}
public Path getRelativePath() {
return relativePath;
}
}
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.service.WatchService;
import org.openhab.core.service.WatchService.Kind;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Watches OPENHAB_CONF/yamlcomposer and writes composed YAML files to OPENHAB_CONF/yaml/composed.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
@Component(immediate = true, service = YamlComposerWatchService.class)
public class YamlComposerWatchService implements WatchService.WatchEventListener {
private static final Path WATCHED_SOURCE_PATH = ComposerConfig.SOURCE_ROOT_DIRECTORY;
private static final String OUTPUT_ROOT_README = """
This directory contains YAML files generated by the YAML Composer add-on.
Do not edit these files manually, as they will be overwritten by the composer.
Instead, edit the source YAML files in the 'yamlcomposer' directory and let the composer generate the output here.
""";
private final Logger logger = LoggerFactory.getLogger(YamlComposerWatchService.class);
private final IncludeRegistry includeRegistry = new IncludeRegistry();
private final WatchService watchService;
@Activate
public YamlComposerWatchService(@Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService) {
this.watchService = watchService;
try {
Path outputRoot = ComposerConfig.outputRoot();
boolean brandNew = !Files.exists(outputRoot);
Files.createDirectories(ComposerConfig.sourceRoot());
Files.createDirectories(outputRoot);
if (brandNew) {
Files.writeString(outputRoot.resolve("readme.txt"), OUTPUT_ROOT_README);
}
processDirectory(ComposerConfig.sourceRoot());
} catch (IOException e) {
logger.warn("Cannot prepare yaml composer directories: {}", e.getMessage());
}
watchService.registerListener(this, WATCHED_SOURCE_PATH);
}
@Deactivate
public void deactivate() {
watchService.unregisterListener(this);
includeRegistry.clear();
}
@Override
public synchronized void processWatchEvent(Kind kind, Path fullPath) {
Path sourcePath = fullPath;
if (kind != Kind.DELETE && (Files.isDirectory(sourcePath) || !Files.isReadable(sourcePath))) {
return;
}
if (includeRegistry.hasInclude(sourcePath)) {
Path relativeSourcePath = ComposerConfig.configRoot().relativize(sourcePath);
logger.info("YAML Composer: detected change in included file '{}', recompiling affected main files",
relativeSourcePath);
Set<Path> dependingMains = includeRegistry.getMainsForInclude(sourcePath);
dependingMains.forEach(mainFilePath -> processWatchEvent(Kind.MODIFY, mainFilePath));
return;
}
String fileName = sourcePath.getFileName().toString();
if (!YamlComposer.isYamlFile(fileName) || YamlComposer.isIncludeFile(fileName)) {
return;
}
includeRegistry.removeMain(sourcePath);
Path outputPath = ComposerConfig.resolveOutputPath(sourcePath);
Path relativeSourcePath = ComposerConfig.configRoot().relativize(sourcePath);
Path relativeOutputPath = ComposerConfig.configRoot().relativize(outputPath);
if (kind == Kind.DELETE) {
try {
Files.deleteIfExists(outputPath);
logger.info("YAML Composer: DELETED generated file '{}'", relativeOutputPath);
} catch (IOException e) {
logger.warn("YAML Composer: failed to delete generated file '{}': {}", relativeOutputPath,
e.getMessage());
}
return;
}
try {
Object yamlObject = YamlComposer.load(sourcePath,
includePath -> includeRegistry.registerInclude(sourcePath, includePath));
if (yamlObject == null) {
logger.warn("YAML Composer produced no output when processing '{}'", relativeSourcePath);
return;
}
Set<Path> includePaths = includeRegistry.getIncludesForMain(sourcePath);
if (!isOutputUpToDate(sourcePath, includePaths, outputPath)) {
ComposerUtils.writeCompiledOutput(yamlObject, sourcePath, outputPath);
logger.info("YAML Composer: {} -> {}", relativeSourcePath, relativeOutputPath);
}
} catch (IOException e) {
logger.warn("YAML Composer failed to process '{}': {}", relativeSourcePath, e.getMessage());
}
}
private boolean isOutputUpToDate(Path sourcePath, Set<Path> includePaths, Path outputPath) {
if (!Files.exists(outputPath)) {
return false;
}
try {
long outputMtime = Files.getLastModifiedTime(outputPath).toMillis();
long sourceMtime = Files.getLastModifiedTime(sourcePath).toMillis();
if (outputMtime <= sourceMtime) {
return false;
}
for (Path includePath : includePaths) {
long includeMtime = Files.getLastModifiedTime(includePath).toMillis();
if (outputMtime <= includeMtime) {
return false;
}
}
return true;
} catch (IOException e) {
logger.debug("Failed to compare mtime for '{}' and '{}': {}", outputPath, sourcePath, e.getMessage());
return false;
}
}
private void processDirectory(Path sourceDirectory) {
try {
Files.walkFileTree(sourceDirectory, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(@Nullable Path sourcePath, @Nullable BasicFileAttributes attrs)
throws IOException {
if (sourcePath == null || attrs == null || !attrs.isRegularFile()) {
return FileVisitResult.CONTINUE;
}
String fileName = sourcePath.getFileName().toString();
if (YamlComposer.isYamlFile(fileName) && !YamlComposer.isIncludeFile(fileName)) {
processWatchEvent(Kind.CREATE, sourcePath);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(@Nullable Path sourcePath, @Nullable IOException exc)
throws IOException {
if (sourcePath != null && exc != null) {
Path relativeSourcePath = ComposerConfig.configRoot().relativize(sourcePath);
logger.warn("Failed to process {}: {}", relativeSourcePath, exc.getMessage());
}
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
logger.warn("Could not list YAML source files in '{}': {}", sourceDirectory, e.getMessage());
}
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.constructors;
import java.util.function.BiFunction;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.placeholders.InterpolablePlaceholder;
import org.snakeyaml.engine.v2.api.ConstructNode;
import org.snakeyaml.engine.v2.nodes.Node;
/**
* The {@link ConstructInterpolablePlaceholder} is a generic constructor used to create
* placeholder objects for nodes whose value can be interpolated by {@link YamlComposer}.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
class ConstructInterpolablePlaceholder<T extends InterpolablePlaceholder<T>> implements ConstructNode {
private final BiFunction<@Nullable Object, String, T> creator;
private final ModelConstructor constructor;
ConstructInterpolablePlaceholder(ModelConstructor constructor, BiFunction<@Nullable Object, String, T> creator) {
this.constructor = constructor;
this.creator = creator;
}
@Override
public @Nullable Object construct(@Nullable Node node) {
if (node == null) {
return null;
}
Object value = constructor.constructByType(node);
return creator.apply(value, constructor.getLocation(node));
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.constructors;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.snakeyaml.engine.v2.api.ConstructNode;
import org.snakeyaml.engine.v2.nodes.Node;
/**
* The {@link ConstructLiteral} is a constructor for the <code>!literal</code> tag.
*
* It just needs to resolve our custom tag and construct the underlying node
* as usual.
*
* We don't need a special placeholder since the tracking of substitution state
* is done in {@link ModelConstructor#constructObject(Node)}.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
class ConstructLiteral implements ConstructNode {
protected final ModelConstructor constructor;
ConstructLiteral(ModelConstructor constructor) {
this.constructor = constructor;
}
@Override
@NonNullByDefault({})
public @Nullable Object construct(Node node) {
return constructor.constructByType(Objects.requireNonNull(node));
}
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.constructors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.snakeyaml.engine.v2.api.ConstructNode;
import org.snakeyaml.engine.v2.nodes.Node;
import org.snakeyaml.engine.v2.nodes.ScalarNode;
/**
* The {@link ConstructStr} is the constructor used for STR tag which
* may create a {@link org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder}, depending on the
* current
* substitution stack.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
class ConstructStr implements ConstructNode {
private final ModelConstructor constructor;
ConstructStr(ModelConstructor constructor) {
this.constructor = constructor;
}
@Override
@NonNullByDefault({})
public Object construct(Node node) {
return constructor.constructScalarOrSubstitution((ScalarNode) node);
}
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.constructors;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.snakeyaml.engine.v2.api.ConstructNode;
import org.snakeyaml.engine.v2.nodes.Node;
import org.snakeyaml.engine.v2.nodes.Tag;
/**
* The {@link ConstructSub} is the constructor used on the <code>!sub</code> tag
* to keep track of custom substitution patterns.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
class ConstructSub implements ConstructNode {
private static final String SUB_TAG_PREFIX = "sub:";
ModelConstructor constructor;
ConstructSub(ModelConstructor constructor) {
this.constructor = constructor;
}
@Override
@NonNullByDefault({})
public Object construct(Node node) {
String patternName = extractPatternName(Objects.requireNonNull(node.getTag()));
constructor.trackPatternName(patternName);
return constructor.constructByType(node);
}
private @Nullable String extractPatternName(Tag tag) {
String suffix = extractSubTagSuffix(tag);
if (suffix == null || suffix.isBlank()) {
return null;
}
return suffix;
}
private @Nullable String extractSubTagSuffix(Tag tag) {
String tagValue = tag.getValue();
// The tag value doesn't always start with !, for example
// when using the verbatim tag syntax like !<sub:pattern>
// the value will be "sub:pattern" without the leading "!"
// see https://yaml.org/spec/1.2.2/#verbatim-tags
if (tagValue.startsWith("!")) {
tagValue = tagValue.substring(1);
}
if (!tagValue.startsWith(SUB_TAG_PREFIX)) {
return null;
}
return tagValue.substring(SUB_TAG_PREFIX.length());
}
}
@@ -0,0 +1,215 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.constructors;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedList;
import java.util.Objects;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.placeholders.IfPlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.IncludePlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.InsertPlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.MergeKeyPlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.RemovePlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.ReplacePlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder;
import org.snakeyaml.engine.v2.api.ConstructNode;
import org.snakeyaml.engine.v2.api.LoadSettings;
import org.snakeyaml.engine.v2.constructor.StandardConstructor;
import org.snakeyaml.engine.v2.exceptions.Mark;
import org.snakeyaml.engine.v2.nodes.MappingNode;
import org.snakeyaml.engine.v2.nodes.Node;
import org.snakeyaml.engine.v2.nodes.ScalarNode;
import org.snakeyaml.engine.v2.nodes.SequenceNode;
import org.snakeyaml.engine.v2.nodes.Tag;
/**
* Extends SnakeYAML Engine's {@link StandardConstructor} to add support for the
* composer's custom YAML tags and modeltransformation features.
*
* <p>
* The {@code ModelConstructor} handles all extended tags, including:
* <ul>
* <li><code>!sub</code> - variable interpolation</li>
* <li><code>!literal</code> - disable interpolation for a value</li>
* <li><code>!if</code> - conditional evaluation</li>
* <li><code>!include</code> - include external YAML files</li>
* <li><code>!insert</code> - insert templates with variable context</li>
* <li><code>!replace</code> - replace parts of the model within a package</li>
* <li><code>!remove</code> - remove parts of the model within a package</li>
* </ul>
*
* These extensions allow the composer to construct a fully evaluated
* inmemory model before further processing or consumption.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class ModelConstructor extends StandardConstructor {
static final String SUB_TAG = "sub";
private static final Tag LITERAL_TAG = new Tag("!literal");
private static final Tag IF_TAG = new Tag("!if");
private static final Tag INCLUDE_TAG = new Tag("!include");
private static final Tag REPLACE_TAG = new Tag("!replace");
private static final Tag REMOVE_TAG = new Tag("!remove");
private static final Tag INSERT_TAG = new Tag("!insert");
// A replacement for the built in Tag.MERGE because we want to
// do the merge key processing at the composer level
public static final Tag DEFERRED_MERGE_TAG = new Tag("!deferred-merge");
String sourcePath;
final Deque<Boolean> substitutionStack = new ArrayDeque<>();
final Deque<@Nullable String> substitutionPatternNameStack = new LinkedList<>();
private final ConstructSub constructSub;
public ModelConstructor(LoadSettings settings, String sourcePath) {
super(settings);
this.sourcePath = sourcePath;
this.substitutionStack.push(true);
this.substitutionPatternNameStack.push(null);
this.constructSub = new ConstructSub(this);
this.tagConstructors.put(LITERAL_TAG, new ConstructLiteral(this));
this.tagConstructors.put(Tag.STR, new ConstructStr(this));
this.tagConstructors.put(IF_TAG, new ConstructInterpolablePlaceholder<IfPlaceholder>(this, IfPlaceholder::new));
this.tagConstructors.put(DEFERRED_MERGE_TAG,
new ConstructInterpolablePlaceholder<MergeKeyPlaceholder>(this, MergeKeyPlaceholder::new));
this.tagConstructors.put(INCLUDE_TAG,
new ConstructInterpolablePlaceholder<IncludePlaceholder>(this, IncludePlaceholder::new));
this.tagConstructors.put(INSERT_TAG,
new ConstructInterpolablePlaceholder<InsertPlaceholder>(this, InsertPlaceholder::new));
this.tagConstructors.put(REPLACE_TAG,
new ConstructInterpolablePlaceholder<ReplacePlaceholder>(this, ReplacePlaceholder::new));
this.tagConstructors.put(REMOVE_TAG,
new ConstructInterpolablePlaceholder<RemovePlaceholder>(this, RemovePlaceholder::new));
}
@Override
@NonNullByDefault({})
@SuppressWarnings("null")
protected Optional<ConstructNode> findConstructorFor(Node node) {
if (isSubstitutionTag(node.getTag())) {
return Optional.of(constructSub);
}
return super.findConstructorFor(node);
}
/**
* Gets a string representation of the node's location for logging purposes.
*
* @param node the YAML node to get the location of
* @return a string describing the source location of the node, including file path and line/column if available
*/
String getLocation(Node node) {
String location = "";
Mark startMark = node.getStartMark().orElse(null);
if (startMark != null) {
location = ":%d:%d".formatted(startMark.getLine() + 1, startMark.getColumn() + 1);
}
return this.sourcePath + location;
}
/**
* Default construction method that routes to the appropriate construct method
* based on the node type.
*
* Use this instead of constructObject() to avoid an infinite recursion when
* constructing a node on a custom tag.
*
* @param node the node to construct
* @return the constructed object
*/
protected @Nullable Object constructByType(Node node) {
return switch (node) {
case MappingNode mappingNode -> constructMapping(mappingNode);
case SequenceNode sequenceNode -> constructSequence(sequenceNode);
case ScalarNode scalarNode -> constructScalarOrSubstitution(scalarNode);
default -> constructObject(node);
};
}
/**
* Construct a scalar node, potentially as a SubstitutionPlaceholder
* if the current substitution state is enabled.
*
* @param scalarNode the scalar node to construct
* @return
*/
@SuppressWarnings("null") // The stacks and SnakeYAML methods shouldn't return null
protected @Nullable Object constructScalarOrSubstitution(ScalarNode scalarNode) {
Tag tag = scalarNode.getTag();
String value = constructScalar(scalarNode);
boolean enabled = substitutionStack.peek();
if (enabled || isSubstitutionTag(tag)) {
String patternName = substitutionPatternNameStack.peek();
String location = getLocation(scalarNode);
return new SubstitutionPlaceholder(value, patternName, location);
}
return value;
}
/**
* Intercept constructObject to keep track of the current substitution state.
*
* @param node the node to construct
* @return the constructed object
*/
@Override
@NonNullByDefault({})
protected @Nullable Object constructObject(Node node) {
Tag tag = Objects.requireNonNull(node.getTag());
boolean parent = Objects.requireNonNull(substitutionStack.peek());
boolean enabled = resolveSubstitution(tag, parent);
substitutionStack.push(enabled);
substitutionPatternNameStack.push(substitutionPatternNameStack.peek());
try {
return super.constructObject(node);
} finally {
substitutionPatternNameStack.pop();
substitutionStack.pop();
}
}
private static boolean resolveSubstitution(Tag tag, boolean parent) {
if (LITERAL_TAG.equals(tag)) {
return false;
}
if (isSubstitutionTag(tag)) {
return true;
}
return parent;
}
static boolean isSubstitutionTag(Tag tag) {
String value = tag.getValue();
if (value.startsWith("!")) {
value = value.substring(1);
}
return value.startsWith(SUB_TAG);
}
protected void trackPatternName(@Nullable String patternName) {
substitutionPatternNameStack.pop();
substitutionPatternNameStack.push(patternName);
}
}
@@ -0,0 +1,184 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.core;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
import org.openhab.io.yamlcomposer.internal.placeholders.ReplacePlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder;
/**
* Processor for handling 'packages' in YAML models.
*
* Merges package definitions into the main data structure, injecting the package ID
* into any included or inserted fragments.
*
* This is slightly different to the other processors as it operates on the overall
* data structure rather than merely performing resolutions of a particular placeholder.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class PackageProcessor {
private static final String PACKAGES_KEY = "packages";
private static final String PACKAGE_ID_VAR = "package_id";
private final BufferedLogger logger;
private final Path absolutePath;
private final Path relativePath;
private final SourceLocator sourceLocator;
private final RecursiveTransformer recursiveTransformer;
public PackageProcessor(BufferedLogger logger, Path absolutePath, Path relativePath, SourceLocator sourceLocator,
RecursiveTransformer recursiveTransformer) {
this.logger = logger;
this.absolutePath = absolutePath;
this.relativePath = relativePath;
this.sourceLocator = sourceLocator;
this.recursiveTransformer = recursiveTransformer;
}
/**
* Convenience wrapper that accepts the raw 'packages' section value and
* applies merging if it's a map, otherwise logs the same warning used by
* the composer.
*
* @param yamlMap the root YAML map to merge packages into
* @param packagesObj the raw 'packages' section value to process and merge, or null if not present
*/
public void mergePackages(Map<?, ?> yamlMap, @Nullable Object packagesObj) {
if (packagesObj instanceof Map<?, ?> packagesMap) {
mergePackages(yamlMap, packagesMap);
logger.debug("Merged packages into data in {}: {}", absolutePath, yamlMap);
} else if (packagesObj != null) {
var position = sourceLocator.findPosition(PACKAGES_KEY);
logger.warn("{}:{} The 'packages' section is not a map", relativePath, position);
}
}
/**
* Deep merge packages map into the main data map
*/
private void mergePackages(Map<?, ?> mainData, Map<?, ?> packages) {
packages.forEach((pkgKey, pkg) -> {
Object pkgKeyObj = recursiveTransformer.transform(pkgKey);
if (pkgKeyObj == null) {
var position = sourceLocator.findPosition(PACKAGES_KEY);
logger.warn("{}:{} package key resolved to null; skipping package entry", relativePath, position);
return;
}
String packageId = String.valueOf(pkgKeyObj);
// Inject `package_id` into the package context for use in
// !include and !insert within the package definition
Map<String, @Nullable Object> packageIdMap = Map.of(PACKAGE_ID_VAR, packageId);
RecursiveTransformer packageTransformer = recursiveTransformer.withOverrideVariables(packageIdMap);
Object resolvedPkg = packageTransformer.transform(pkg, ProcessingPhase.STANDARD);
resolvedPkg = stripEmptyMapsAndLists(resolvedPkg);
if (!(resolvedPkg instanceof Map<?, ?> packageMap)) {
var position = sourceLocator.findPosition(PACKAGES_KEY, packageId);
logger.warn("{}:{} package '{}' resolved to {} instead of a Map", relativePath, position, packageId,
resolvedPkg == null ? "null" : resolvedPkg.getClass().getSimpleName());
return;
}
logger.debug("Merging package '{}' {} into main data: {}", packageId, packageMap, mainData);
mergeElements(packageId, mainData, packageMap);
});
}
private void mergeElements(String packageId, Map<?, ?> mainData, Map<?, ?> packageData) {
packageData.forEach((packageKey, packageValue) -> {
if (mainData.containsKey(packageKey)) {
Object mainValue = mainData.get(packageKey);
if (mainValue instanceof ReplacePlaceholder || mainValue instanceof SubstitutionPlaceholder) {
return; // Ignore the package value - we'll process these later
}
if (mainValue instanceof Map<?, ?> mainValueMap) {
if (packageValue instanceof Map<?, ?> packageValueMap) {
mergeElements(packageId, mainValueMap, packageValueMap);
@SuppressWarnings("unchecked")
Map<@Nullable Object, Object> rawMainData = (Map<@Nullable Object, Object>) mainData;
rawMainData.put(packageKey, mainValueMap);
return;
} else {
// Type mismatch - keep main value
var position = sourceLocator.findPosition(PACKAGES_KEY, packageId);
logger.warn(
"{}:{} Type mismatch when merging package ID '{}' for key '{}': main is Map, package is {}; keeping main value",
relativePath, position, packageId, packageKey,
packageValue == null ? "null" : packageValue.getClass().getSimpleName());
return;
}
}
if (mainValue instanceof List<?> mainValueList) {
if (packageValue instanceof List<?> pkgValueList) {
@SuppressWarnings("unchecked")
Map<@Nullable Object, Object> rawMainData = (Map<@Nullable Object, Object>) mainData;
rawMainData.put(packageKey,
Stream.concat(pkgValueList.stream(), mainValueList.stream()).distinct().toList());
return;
} else {
// Type mismatch - keep main value
var position = sourceLocator.findPosition(PACKAGES_KEY, packageId);
logger.warn(
"{}:{} Type mismatch when merging package ID '{}' for key '{}': main is List, package is {}; keeping main value",
relativePath, position, packageId, packageKey,
packageValue == null ? "null" : packageValue.getClass().getSimpleName());
return;
}
}
// For non-map/non-list values, keep the main value ignoring the package value
} else {
@SuppressWarnings("unchecked")
Map<@Nullable Object, @Nullable Object> rawMainData = (Map<@Nullable Object, @Nullable Object>) mainData;
rawMainData.put(packageKey, packageValue);
}
});
}
private static @Nullable Object stripEmptyMapsAndLists(@Nullable Object data) {
if (data == null || data instanceof String s && s.isBlank()) {
return null;
}
if (data instanceof Map<?, ?> map) {
var result = new LinkedHashMap<Object, Object>();
for (Map.Entry<?, ?> e : map.entrySet()) {
Object key = e.getKey();
Object value = stripEmptyMapsAndLists(e.getValue());
if (value != null) {
result.put(key, value);
}
}
return result.isEmpty() ? null : result;
} else if (data instanceof List<?> list) {
var result = new java.util.ArrayList<Object>(list.size());
for (Object item : list) {
Object value = stripEmptyMapsAndLists(item);
if (value != null) {
result.add(value);
}
}
return result.isEmpty() ? null : result;
}
return data;
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.core;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.io.yamlcomposer.internal.placeholders.IfPlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.IncludePlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.InsertPlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.Placeholder;
import org.openhab.io.yamlcomposer.internal.placeholders.RemovePlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.ReplacePlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder;
/**
* Defines the different processing phases for the YAML composer.
*
* @author Jimmy Tanagra - Initial Contribution
*/
@NonNullByDefault
public class ProcessingPhase {
public static final Set<Class<? extends Placeholder>> SUBSTITUTION = //
Set.of(SubstitutionPlaceholder.class);
public static final Set<Class<? extends Placeholder>> INCLUDES = //
Set.of(IncludePlaceholder.class);
public static final Set<Class<? extends Placeholder>> STANDARD = //
Set.of(SubstitutionPlaceholder.class, IfPlaceholder.class, IncludePlaceholder.class,
InsertPlaceholder.class);
public static final Set<Class<? extends Placeholder>> PACKAGE_OVERRIDES = //
Set.of(RemovePlaceholder.class, ReplacePlaceholder.class);
private ProcessingPhase() {
}
}
@@ -0,0 +1,361 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.core;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.placeholders.IfPlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.InterpolablePlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.MergeKeyPlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.Placeholder;
import org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder;
import org.openhab.io.yamlcomposer.internal.processors.PlaceholderProcessor;
/**
* The {@link RecursiveTransformer} traverses a YAML data tree, applies merge keys logic,
* and transforms placeholders into the final values by invoking registered handlers
* for their respective types.
*
* It holds a variables map that can be overridden for nested transformations,
* allowing for context-specific variable values during processing.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class RecursiveTransformer {
private final Map<Class<? extends Placeholder>, PlaceholderProcessor<?>> handlers = new LinkedHashMap<>();
private final Map<String, @Nullable Object> variables;
public RecursiveTransformer(Map<String, @Nullable Object> variables) {
this.variables = variables;
}
public Map<String, @Nullable Object> getVariables() {
return variables;
}
/**
* Creates a new RecursiveTransformer with the same handlers but a new variables map that
* includes the given overrides.
*
* The new combined variables map will be used for all placeholder processing within
* the nested structure, allowing for isolated modifications without impacting the original context.
*
* @param overrideVariables additional variables to include in the new transformer's context,
* which will override any existing variables with the same keys
* @return a new RecursiveTransformer instance with the combined variables and the same handlers
*/
public RecursiveTransformer withOverrideVariables(Map<String, @Nullable Object> overrideVariables) {
Map<String, @Nullable Object> combinedVariables = new HashMap<>(variables);
combinedVariables.putAll(overrideVariables);
RecursiveTransformer copy = new RecursiveTransformer(combinedVariables);
copy.handlers.putAll(this.handlers);
return copy;
}
/**
* Registers a handler for a specific placeholder type.
*
* @param handler the processor that can transform the placeholder
*/
public void register(PlaceholderProcessor<?> handler) {
handlers.put(handler.getPlaceholderType(), handler);
}
/**
* Default: transforms the entire tree using all registered handlers.
* Placeholders are transformed if their class matches any registered handler.
*
* This is the most common usage, but the other overloads allow for more control
* and optimization by restricting which handlers are applied.
*
* @param data the YAML data tree to transform
* @return the transformed data tree with placeholders transformed
*/
public @Nullable Object transform(@Nullable Object data) {
return transformWithVisited(data, handlers.keySet());
}
/**
* Transforms the data tree but only applies handlers for the specified placeholder classes.
*
* <p>
* Use this when you want to restrict transformation to a subset of placeholder
* classes.
*
* @param data the YAML data tree to transform
* @param allowedTypes the set of placeholder classes to transform
* @return the transformed data tree with the given placeholders transformed
*/
public @Nullable Object transform(@Nullable Object data, Set<Class<? extends Placeholder>> allowedTypes) {
return transformWithVisited(data, allowedTypes);
}
/**
* Convenience overload for transforming a Map container.
*
* <p>
* Transforms the given map and transforms any placeholders found within keys and values.
* This is equivalent to calling {@link #transform(Object, Set)} with the full set of
* registered placeholder handler types.
*
* @param data the map to transform
* @return the transformed map
*/
public Map<Object, @Nullable Object> transform(Map<?, ?> data) {
return transform(data, handlers.keySet());
}
/**
* Transforms the given map but only applies handlers for the specified placeholder classes.
*
* <p>
* Keys and values are transformed recursively. If the overall transformed result is not a
* {@link Map} (for example, if a placeholder handler returned a non-container), an
* {@link IllegalStateException} is thrown.
*
* @param data the map to transform
* @param allowedTypes the set of placeholder classes to transform
* @return the transformed map
* @throws IllegalStateException if the transformed result is not a Map
*/
@SuppressWarnings("unchecked")
public Map<Object, @Nullable Object> transform(Map<?, ?> data, Set<Class<? extends Placeholder>> allowedTypes) {
Object transformed = transformWithVisited(data, allowedTypes);
if (transformed instanceof Map<?, ?> map) {
return (Map<Object, @Nullable Object>) map;
}
throw new IllegalStateException("Expected transformed result to be a Map but was: "
+ (transformed == null ? "null" : transformed.getClass()));
}
/**
* Central helper to start transformation with a fresh visited map.
*/
private @Nullable Object transformWithVisited(@Nullable Object data,
Set<Class<? extends Placeholder>> allowedTypes) {
return transformInternal(data, allowedTypes, new IdentityHashMap<>());
}
/**
* The actual tree traversal.
*/
private @Nullable Object transformInternal(@Nullable Object data, Set<Class<? extends Placeholder>> allowedTypes,
IdentityHashMap<Object, Object> visited) {
if (data == null) {
return null;
}
Class<?> clazz = data.getClass();
// Handle cyclic references for containers: if we've already started transforming
// this container, return the placeholder/result to avoid infinite recursion.
if ((data instanceof Map<?, ?> || data instanceof List<?>)) {
if (visited.containsKey(data)) {
return visited.get(data);
}
}
// Resolve placeholder value (arguments) first before transforming the placeholder itself
// So that e.g. !include ${filename} gets the real argument value to transform
if (data instanceof InterpolablePlaceholder interpolable) {
Object transformedValue;
if (interpolable.eagerArgumentProcessing()) {
// Eagerly transform arguments using all registered handlers
transformedValue = transform(interpolable.value());
} else {
// Only perform substitutions in arguments
// for !if conditions, don't transform placeholders (e.g. !include) in the unselected branch
transformedValue = transform(interpolable.value(), ProcessingPhase.SUBSTITUTION);
}
data = interpolable.withValue(transformedValue);
}
if (data instanceof Placeholder placeholder && allowedTypes.contains(clazz)) {
// Use the override callback if provided, otherwise look up in registry
PlaceholderProcessor<?> handler = handlers.get(clazz);
if (handler != null) {
// Execute and recurse
Object result = invokeHandler(handler, placeholder);
return transformInternal(result, allowedTypes, visited);
}
}
if (data instanceof Map<?, ?> map) {
return resolveMap(map, allowedTypes, visited);
}
if (data instanceof List<?> list) {
return resolveList(list, allowedTypes, visited);
}
return data;
}
@SuppressWarnings("unchecked")
private @Nullable <T extends Placeholder> Object invokeHandler(PlaceholderProcessor<?> handler,
Placeholder placeholder) {
return ((PlaceholderProcessor<T>) handler).process((T) placeholder, this);
}
/**
* Resolves a map by transforming its keys and values, applying placeholder handlers as needed,
* and handling special cases like merge keys and removal signals.
*
* @param rawMap the original map to transform
* @param allowedTypes the set of placeholder classes to transform
* @return the transformed map with placeholders transformed, or the original map if no changes were made
*/
private Object resolveMap(Map<?, ?> rawMap, Set<Class<? extends Placeholder>> allowedTypes,
IdentityHashMap<Object, Object> visited) {
// Always create a new map for transformed results to simplify cycle handling
@SuppressWarnings("unchecked")
Map<Object, @Nullable Object> map = (Map<Object, @Nullable Object>) rawMap;
Map<Object, @Nullable Object> result = new LinkedHashMap<>(map.size());
// Register in visited before transforming entries to handle self-references
visited.put(rawMap, result);
List<Map.Entry<Object, @Nullable Object>> mergeEntries = new ArrayList<>();
for (Map.Entry<Object, @Nullable Object> entry : map.entrySet()) {
Object oldKey = entry.getKey();
Object oldVal = entry.getValue();
Object newKey = transformInternal(oldKey, allowedTypes, visited);
Object newVal = transformInternal(oldVal, allowedTypes, visited);
// Dropping null keys or removal signals
if (shouldRemoveEntry(newKey, newVal, oldVal)) {
continue;
}
newKey = Objects.requireNonNull(newKey); // null keys should have been filtered out in shouldRemoveEntry
if (newKey instanceof MergeKeyPlaceholder mkp) {
mergeEntries.add(new AbstractMap.SimpleEntry<>(mkp, newVal));
continue;
}
if ("<<".equals(newKey)) {
mergeEntries.add(new AbstractMap.SimpleEntry<>(newKey, newVal));
continue;
}
result.put(newKey, newVal);
}
resolveMergeKeys(result, allowedTypes, mergeEntries, visited);
return result;
}
private boolean shouldRemoveEntry(@Nullable Object newKey, @Nullable Object newVal, @Nullable Object oldVal) {
return newKey == null //
|| newKey == RemovalSignal.REMOVE //
|| newVal == RemovalSignal.REMOVE //
|| (newVal == null && oldVal != null);
}
public void resolveMergeKeys(Map<?, ?> rawMap) {
resolveMergeKeys(rawMap, Set.of());
}
public void resolveMergeKeys(Map<?, ?> rawMap, Set<Class<? extends Placeholder>> allowedTypes) {
@SuppressWarnings("unchecked")
Map<Object, @Nullable Object> map = (Map<Object, @Nullable Object>) rawMap;
// First: handle YAML merge keys (<<: ...). We collect merge-key entries, transform their
// values fully, merge into this container, and remove the merge-key entries before
// performing the normal per-entry transformation below.
List<Map.Entry<Object, @Nullable Object>> mergeEntries = new ArrayList<>();
for (Map.Entry<Object, @Nullable Object> entry : map.entrySet()) {
if (entry.getKey() instanceof MergeKeyPlaceholder || "<<".equals(entry.getKey())) {
mergeEntries.add(new AbstractMap.SimpleEntry<>(entry.getKey(), entry.getValue()));
}
}
resolveMergeKeys(map, allowedTypes, mergeEntries, new IdentityHashMap<>());
}
@SuppressWarnings({ "unchecked" })
private void resolveMergeKeys(Map<?, ?> rawMap, Set<Class<? extends Placeholder>> allowedTypes,
List<Map.Entry<Object, @Nullable Object>> mergeEntries, IdentityHashMap<Object, Object> visited) {
Map<Object, @Nullable Object> map = (Map<Object, @Nullable Object>) rawMap;
if (!mergeEntries.isEmpty()) {
for (var mergeEntry : mergeEntries) {
@Nullable
Object rawVal = mergeEntry.getValue();
@Nullable
Object transformedVal = transformInternal(rawVal, allowedTypes, visited);
if (transformedVal instanceof Map<?, ?> fromMap) {
mergeMap((Map<Object, @Nullable Object>) fromMap, map);
} else if (transformedVal instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Map<?, ?> m) {
mergeMap((Map<Object, @Nullable Object>) m, map);
}
}
} else if (transformedVal == null) {
if (rawVal instanceof SubstitutionPlaceholder || rawVal instanceof IfPlaceholder) {
// nothing to merge
}
}
map.remove(mergeEntry.getKey());
}
}
}
private void mergeMap(Map<Object, @Nullable Object> from, Map<Object, @Nullable Object> to) {
for (Map.Entry<Object, @Nullable Object> entry : from.entrySet()) {
Object key = entry.getKey();
if (!to.containsKey(key)) {
to.put(key, entry.getValue());
}
}
}
private Object resolveList(List<?> list, Set<Class<? extends Placeholder>> allowedTypes,
IdentityHashMap<Object, Object> visited) {
// Always produce a new list and register it to handle cycles
List<@Nullable Object> result = new ArrayList<>(list.size());
visited.put(list, result);
for (Object oldItem : list) {
Object newItem = transformInternal(oldItem, allowedTypes, visited);
if (newItem != RemovalSignal.REMOVE && newItem != null) {
result.add(newItem);
}
}
return result;
}
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.core;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* A signal returned by {@link org.openhab.io.yamlcomposer.internal.processors.PlaceholderProcessor}s to indicate that
* the entry
* currently being processed should be <strong>intentionally removed</strong> from its
* parent container (Map or List).
* <p>
* This enum provides an <em>explicit</em> way to signal removal, distinguishing it from
* error-based removal that occurs when a processor returns {@code null}.
*
* <h2>Usage Guidelines</h2>
* <p>
* <strong>Use {@code RemovalSignal.REMOVE} when:</strong>
* <ul>
* <li>The placeholder's purpose is to explicitly remove an entry (e.g., {@code !remove} directive)</li>
* <li>The removal is intentional and by design, not due to an error condition</li>
* <li>You want to make the removal intent clear in the code</li>
* </ul>
*
* <strong>Use {@code null} when:</strong>
* <ul>
* <li>Processing failed due to an error (missing parameter, invalid input, resource not found)</li>
* <li>You've logged a warning describing the error before returning {@code null}</li>
* <li>The entry should be removed as error recovery, not as intended functionality</li>
* </ul>
*
* <h2>Technical Details</h2>
* <p>
* The {@link RecursiveTransformer} treats both {@code RemovalSignal.REMOVE} and {@code null}
* identically in terms of behavior (both cause entry removal), but the semantic distinction
* helps developers understand whether removal was intentional or due to error recovery.
*
* @author Jimmy Tanagra - Initial contribution
* @see org.openhab.io.yamlcomposer.internal.processors.PlaceholderProcessor#process
*/
@NonNullByDefault
public enum RemovalSignal {
/**
* Signals that the current entry should be intentionally removed from its parent container.
* <p>
* This is the recommended way to explicitly indicate removal as part of normal processing,
* as opposed to returning {@code null} which should be reserved for error cases.
*/
REMOVE;
}
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.core;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Helper to find positions in YAML source for logging.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class SourceLocator {
private final byte[] yamlBytes;
public SourceLocator(byte[] yamlBytes) {
this.yamlBytes = yamlBytes;
}
public record FilePosition(int line, int column) {
@Override
public String toString() {
if (line < 0) {
return "";
}
return line + ":" + column;
}
public static FilePosition empty() {
return new FilePosition(-1, -1);
}
}
/**
* Find the position of a given key in the YAML file.
*
* @param keys the sequence of keys representing the path in the YAML structure
* @return the Position of the last key in the sequence, or (-1, -1) if not
* found
*/
public FilePosition findPosition(String... keys) {
if (keys.length == 0) {
return FilePosition.empty();
}
try (Scanner scanner = new Scanner(new ByteArrayInputStream(yamlBytes), StandardCharsets.UTF_8)) {
int lineNumber = 1;
int keyIndex = 0;
while (scanner.hasNextLine()) {
String line = scanner.nextLine();
int lineOffset = 0; // Tracks our horizontal position in the current line
String searchArea = line;
boolean foundInLine;
do {
foundInLine = false;
String targetKey = keys[keyIndex] + ":";
int matchIndex = searchArea.indexOf(targetKey);
if (matchIndex != -1) {
// Calculate the column:
// segments already skipped + position in current segment + length of key
int columnAtEndOfKey = lineOffset + matchIndex + targetKey.length();
if (keyIndex == keys.length - 1) {
return new FilePosition(lineNumber, columnAtEndOfKey + 1); // +1 for 1-based indexing
}
// Prepare for next key on the same line
lineOffset += matchIndex + targetKey.length();
searchArea = searchArea.substring(matchIndex + targetKey.length());
keyIndex++;
foundInLine = true;
}
} while (foundInLine && keyIndex < keys.length);
lineNumber++;
}
}
return FilePosition.empty();
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.core;
import java.nio.file.Path;
import java.util.Map;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
import org.openhab.io.yamlcomposer.internal.ComposerConfig;
/**
* The {@link TemplateLoader} is responsible for extracting templates from the YAML model and storing them in the
* composer context for later use.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class TemplateLoader {
private final BufferedLogger logger;
private final Path relativePath;
private final Map<Object, @Nullable Object> templates;
private final RecursiveTransformer recursiveTransformer;
private final SourceLocator locator;
public TemplateLoader(BufferedLogger logger, Path relativePath, Map<Object, @Nullable Object> templates,
RecursiveTransformer recursiveTransformer, SourceLocator locator) {
this.logger = logger;
this.relativePath = relativePath;
this.templates = templates;
this.recursiveTransformer = recursiveTransformer;
this.locator = locator;
}
/**
* Extracts templates from the given map and stores them into the templates map.
*
* @param templatesSection the section of the YAML model containing templates
*/
public void extractTemplates(@Nullable Object templatesSection) {
if (templatesSection instanceof java.util.Map<?, ?> templatesMap) {
templatesMap.keySet().removeIf(Objects::isNull);
recursiveTransformer.resolveMergeKeys(templatesMap, ProcessingPhase.STANDARD);
templatesMap.forEach((key, value) -> {
// Only resolve the key so we can look up the template name in the templates map.
// The value substitutions must NOT be resolved here!
// They will be resolved at insertion-time using insertion context instead of the main variables.
Object resolvedKey = recursiveTransformer.transform(key, ProcessingPhase.SUBSTITUTION);
if (resolvedKey != null) {
templates.put(resolvedKey, value);
}
});
} else if (templatesSection != null) {
var position = locator.findPosition(ComposerConfig.TEMPLATES_KEY);
logger.warn("{}:{} 'templates' is not a map", relativePath, position);
}
}
}
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.core;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
import org.openhab.io.yamlcomposer.internal.ComposerConfig;
import org.openhab.io.yamlcomposer.internal.placeholders.IncludePlaceholder;
import org.openhab.io.yamlcomposer.internal.placeholders.MergeKeyPlaceholder;
/**
* The {@link VariableLoader} is responsible for extracting variable definitions from the YAML model and storing
* them in the composer context for later use in substitutions.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class VariableLoader {
private final Map<String, @Nullable Object> variables;
private final Path absolutePath;
private final RecursiveTransformer recursiveTransformer;
private final BufferedLogger logger;
public VariableLoader(Map<String, @Nullable Object> variables, Path absolutePath,
RecursiveTransformer recursiveTransformer, BufferedLogger logger) {
this.variables = variables;
this.absolutePath = absolutePath;
this.recursiveTransformer = recursiveTransformer;
this.logger = logger;
}
/**
* Add special file-related variables
*
* These are added early so they're available in variable definitions during the
* first pass
* Special variables will override any user-defined variables with the same name
*/
public void setSpecialVariables() {
Path fileNamePath = absolutePath.getFileName();
String fullFileName = fileNamePath != null ? fileNamePath.toString() : "";
int dotIndex = fullFileName.lastIndexOf(".");
String fileName = fullFileName;
String fileExtension = "";
if (dotIndex > 0) {
fileName = fullFileName.substring(0, dotIndex);
fileExtension = fullFileName.substring(dotIndex + 1);
}
var parentPath = absolutePath.getParent();
String directory = parentPath != null ? parentPath.toString() : "";
Map<String, @Nullable Object> vars = variables;
vars.put("OPENHAB_CONF", ComposerConfig.configRoot().toString());
vars.put("OPENHAB_USERDATA", ComposerConfig.userDataRoot().toString());
vars.put("__FILE__", absolutePath.toString());
vars.put("__FILE_NAME__", fileName);
vars.put("__FILE_EXT__", fileExtension);
vars.put("__DIRECTORY__", directory);
vars.put("__DIR__", directory);
}
/**
* Extracts variables from the given map and stores them into the context's variable map.
*
* Since variables can reference previously defined variables, we perform incremental resolution
* while iterating through the variable definitions.
*
* @param variablesSection the section of the YAML file containing variable definitions, can be null
* @param locator the source locator for logging purposes
* @see ComposerConfig#VARIABLES_KEY
*/
public void extractVariables(@Nullable Object variablesSection, SourceLocator locator) {
Map<String, @Nullable Object> existingVariables = variables;
if (variablesSection instanceof Map<?, ?> variablesMap) {
Map<Object, @Nullable Object> mergeKeys = new LinkedHashMap<>();
variablesMap.forEach((key, value) -> {
if (key instanceof MergeKeyPlaceholder) {
mergeKeys.put(key, value);
return;
}
Object keyObj = recursiveTransformer.transform(key);
if (keyObj == null) {
return;
}
String keyStr = String.valueOf(keyObj);
if (!existingVariables.containsKey(keyStr)) {
Object resolvedValue = recursiveTransformer.transform(value);
existingVariables.put(keyStr, resolvedValue);
}
});
if (!mergeKeys.isEmpty()) {
Map<Object, @Nullable Object> mergedVars = new LinkedHashMap<>(existingVariables);
Map<Object, @Nullable Object> processedMergeKeys = recursiveTransformer.transform(mergeKeys);
mergedVars.putAll(processedMergeKeys);
recursiveTransformer.resolveMergeKeys(mergedVars, ProcessingPhase.STANDARD);
existingVariables.clear();
mergedVars.forEach((k, v) -> existingVariables.put(String.valueOf(k), v));
}
} else if (variablesSection instanceof IncludePlaceholder includePlaceholder) {
Object includedData = recursiveTransformer.transform(includePlaceholder, ProcessingPhase.INCLUDES);
extractVariables(includedData, locator);
} else if (variablesSection != null) {
var position = locator.findPosition(ComposerConfig.VARIABLES_KEY);
Path relativePath = ComposerConfig.configRoot().relativize(absolutePath);
logger.warn("{}:{} 'variables' is not a map", relativePath, position);
}
}
}
@@ -0,0 +1,250 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.expression;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.LogSession;
import org.openhab.io.yamlcomposer.internal.expression.filters.DigFilter;
import org.openhab.io.yamlcomposer.internal.expression.filters.LabelFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.hubspot.jinjava.Jinjava;
import com.hubspot.jinjava.JinjavaConfig;
import com.hubspot.jinjava.features.FeatureConfig;
import com.hubspot.jinjava.features.FeatureStrategies;
import com.hubspot.jinjava.interpret.Context;
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
import com.hubspot.jinjava.interpret.TemplateError;
import com.hubspot.jinjava.util.ObjectTruthValue;
/**
* Wrapper around Jinjava template engine for rendering ${...} expressions.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class ExpressionEvaluator {
private static final JinjavaConfig CONFIG;
private static final Jinjava JINJAVA;
private static final Logger LOGGER = LoggerFactory.getLogger(ExpressionEvaluator.class);
static {
@SuppressWarnings("null")
JinjavaConfig config = JinjavaConfig.newBuilder() //
.withFeatureConfig(FeatureConfig.newBuilder()
.add(JinjavaInterpreter.OUTPUT_UNDEFINED_VARIABLES_ERROR, FeatureStrategies.ACTIVE).build())
.withFailOnUnknownTokens(false) //
.withMaxRenderDepth(1) //
.withMaxMacroRecursionDepth(0) // We don't use macros; disallow recursion
.withEnableRecursiveMacroCalls(false) //
.build();
CONFIG = config;
JINJAVA = new Jinjava(CONFIG);
Context context = JINJAVA.getGlobalContext();
context.registerFilter(new LabelFilter());
context.registerFilter(new DigFilter());
}
/**
* Evaluate a Jinjava expression and return the raw object result (no string coercion).
*
* @param expression the expression content without delimiters (e.g., "user.profile")
* @param variables the variable context
* @return the evaluated object in its native type
*/
public static @Nullable Object renderObject(String expression, Map<String, @Nullable Object> variables,
LogSession logSession, String sourceLocation) {
@SuppressWarnings("null")
Context context = new Context(JINJAVA.getGlobalContext(), variables);
context.setDynamicVariableResolver(varName -> dynamicVariableResolver(varName, variables));
JinjavaInterpreter interpreter = new JinjavaInterpreter(JINJAVA, context, CONFIG);
Object result = interpreter.resolveELExpression(expression, 0);
@SuppressWarnings("null")
List<TemplateError> errors = interpreter.getErrorsCopy();
if (!errors.isEmpty()) {
List<String> messages = errors.stream().map((e) -> {
String msg = Objects.requireNonNullElse(e.getMessage(), "");
if (e.getItem() == TemplateError.ErrorItem.TOKEN) {
String missingVarName = extractVariableName(e);
if (missingVarName != null && variables.containsKey(missingVarName)) {
// Jinjava emits a TOKEN error when a referenced variable resolves to null.
// If the variable key exists in our variables map (even if its value is null),
// treat it as intentionally defined and suppress an "undefined variable" suggestion.
return null;
}
String suggestion = findClosestVariableName(missingVarName, variables);
if (suggestion != null) {
msg += " (Did you mean '" + suggestion + "'?)";
}
}
return msg;
}).filter(Objects::nonNull).toList();
if (!messages.isEmpty()) {
String combinedMessage = (messages.size() == 1) //
? " " + messages.getFirst() //
: "\n- " + String.join("\n- ", messages);
String logMsg = String.format("%s Error evaluating expression '%s':%s", sourceLocation, expression,
combinedMessage);
logSession.trackWarning(LOGGER, logMsg);
}
}
return normalizeType(result);
}
/**
* Determines the truthiness of a value according to Jinjava rules.
*
* @param value the value to evaluate
* @return true if the value is considered truthy, false otherwise
*/
public static boolean isTruthy(@Nullable Object value) {
return ObjectTruthValue.evaluate(value);
}
/**
* Dynamic variable resolver for Jinjava to provide special variables like "VARS" and "ENV".
*
* @param varName the name of the variable being resolved
* @param context the current variable context
* @return the value of the special variable, or null if it's not a special variable
*/
private static @Nullable Object dynamicVariableResolver(@Nullable String varName,
Map<String, @Nullable Object> context) {
if ("VARS".equals(varName)) {
return context;
}
if ("ENV".equals(varName)) {
return System.getenv();
}
return null;
}
/**
* Normalize types returned by Jinjava to match SnakeYAML's
*
* Jinjava tends to return Long for all integer numbers.
* SnakeYAML, however, uses Integer for small integers.
* Normalize accordingly to avoid type mismatches.
*
* Example:
*
* ```yaml
* native: 1 # returns a Java Integer
* jinja: ${ 1 } # before normalization returns a Java Long
* ```
*
* @param obj the object to normalize
* @return the normalized object
*/
private static @Nullable Object normalizeType(@Nullable Object obj) {
if (obj instanceof Long longValue) {
// Check if the value fits in an Integer
if (longValue >= Integer.MIN_VALUE && longValue <= Integer.MAX_VALUE) {
return Math.toIntExact(longValue);
}
}
return obj;
}
private static @Nullable String extractVariableName(TemplateError error) {
@SuppressWarnings("null")
Map<String, String> categoryErrors = error.getCategoryErrors();
String variableName = null;
if (categoryErrors != null && categoryErrors.containsKey("variable")) {
variableName = categoryErrors.get("variable");
}
if (variableName == null) {
String message = Objects.requireNonNullElse(error.getMessage(), "");
variableName = message.replaceAll(".*'([^']+)'.*", "$1");
}
return variableName;
}
/**
* Finds the most similar variable name using a standalone Levenshtein implementation.
*/
private static @Nullable String findClosestVariableName(@Nullable String missingVar,
Map<String, @Nullable Object> variables) {
if (missingVar == null) {
return null;
}
String bestMatch = null;
int maxDistance = 3; // Threshold: only suggest if 1 or 2 edits away
for (String existingVar : variables.keySet()) {
int distance = levenshteinDistance(missingVar, existingVar, maxDistance);
if (distance < maxDistance) {
maxDistance = distance;
bestMatch = existingVar;
}
}
return bestMatch;
}
/**
* Calculate the Levenshtein distance between two strings with an early exit if the distance exceeds the threshold.
*
* @param s1 The first string
* @param s2 The second string
* @param threshold The maximum distance to calculate before exiting early
* @return the Levenshtein distance, or Integer.MAX_VALUE if the distance exceeds the threshold
*/
private static int levenshteinDistance(@Nullable String s1, @Nullable String s2, int threshold) {
if (s1 == null || s2 == null) {
return Integer.MAX_VALUE;
}
int len1 = s1.length();
int len2 = s2.length();
// If length difference is greater than our threshold, skip expensive math
if (Math.abs(len1 - len2) >= threshold) {
return Integer.MAX_VALUE;
}
int[] costs = new int[len2 + 1];
for (int i = 0; i <= len1; i++) {
int lastValue = i;
for (int j = 0; j <= len2; j++) {
if (i == 0) {
costs[j] = j;
} else if (j > 0) {
int newValue = costs[j - 1];
if (s1.charAt(i - 1) != s2.charAt(j - 1)) {
newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1;
}
costs[j - 1] = lastValue;
lastValue = newValue;
}
}
if (i > 0) {
costs[len2] = lastValue;
}
}
return costs[len2];
}
}
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.expression.filters;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
import com.hubspot.jinjava.lib.filter.Filter;
/**
* Custom Jinjava filter to dig into nested maps/lists.
* Supports negative indices for lists to access elements from the end.
*
* Usage: variable|dig("key1", "key2", 0) to access variable["key1"]["key2"][0]
* Also supports dot-notation in a single argument: variable|dig("key1.key2.0")
*/
@NonNullByDefault
public class DigFilter implements Filter {
@Override
public String getName() {
return "dig";
}
@Override
@NonNullByDefault({})
public @Nullable Object filter(@Nullable Object var, JinjavaInterpreter interpreter, String... args) {
Object current = var;
// Expand any dot-notation args (e.g. "a.b.c") into separate key segments
java.util.List<Object> keys = new java.util.ArrayList<>();
if (args != null) {
for (String arg : args) {
if (arg != null && arg.contains(".")) {
String[] parts = arg.split("\\.");
for (String p : parts) {
keys.add(p);
}
} else {
keys.add(arg);
}
}
}
for (Object key : keys) { // Changed to Object to be more flexible
if (current == null) {
return null;
}
if (current instanceof Map) {
// Java Maps (like HashMap) can have a null key
current = ((Map<?, ?>) current).get(key);
} else if (current instanceof List) {
if (key == null) {
return null; // A list index cannot be null
}
current = getFromList((List<?>) current, key);
} else {
return null;
}
}
return current;
}
private @Nullable Object getFromList(List<?> list, Object key) {
try {
int index;
if (key instanceof Integer intKey) {
index = intKey;
} else {
index = Integer.parseInt(key.toString());
}
int size = list.size();
if (index < 0) {
index = size + index;
}
if (index >= 0 && index < size) {
return list.get(index);
}
} catch (NumberFormatException ignored) {
}
return null;
}
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.expression.filters;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.util.StringUtils;
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
import com.hubspot.jinjava.lib.filter.Filter;
/**
* Custom Jinjava filter to convert a string to a label.
*
* <p>
* It inserts spaces before capital letters, replaces '_' , ':' and '-' with spaces,
* and converts the result to title case.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class LabelFilter implements Filter {
@Override
public String getName() {
return "label";
}
@Override
@NonNullByDefault({})
public @Nullable Object filter(@Nullable Object var, JinjavaInterpreter interpreter, String... args) {
if (var == null) {
return null;
}
String input = var.toString();
String result = input //
.replaceAll("([a-z])([A-Z])", "$1 $2") // Insert space before capital letters
.replaceAll("([A-Z])([A-Z][a-z])", "$1 $2") // Handle cases like "HTTPServer" -> "HTTP Server"
.replaceAll("([A-Za-z])([0-9])", "$1 $2") // Insert space between letters and numbers
.replaceAll("([0-9])([A-Za-z])", "$1 $2") // Insert space between numbers and letters
.replaceAll("[_:-]+", " ") // Replace '_' , ':' and '-' with spaces
.replaceAll("\\s+", " "); // Replace multiple spaces with a single space
// Title case
result = StringUtils.capitalizeByWhitespace(result);
return result;
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.placeholders;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link IfPlaceholder} represents an object constructed from an <code>!if</code> node
* to be processed by the {@link org.openhab.io.yamlcomposer.internal.YamlComposer}.
*
* @param value The constructed object of the node containing the raw argument for the if placeholder
* @param sourceLocation Description of the source location for logging purposes
*
* @author Jimmy Tanagra - Initial contribution
*/
@SuppressWarnings("null")
@NonNullByDefault
public record IfPlaceholder(@Nullable Object value,
@NonNull String sourceLocation) implements InterpolablePlaceholder<IfPlaceholder> {
@Override
public IfPlaceholder recreate(@Nullable Object newValue, String location) {
return new IfPlaceholder(newValue, location);
}
@Override
public boolean eagerArgumentProcessing() {
// Do not process the arguments before resolving the conditions
// so that !include within unmet conditions are not processed
return false;
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.placeholders;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link IncludePlaceholder} represents an object constructed from an <code>!include</code> node
* to be processed by the {@link org.openhab.io.yamlcomposer.internal.YamlComposer}.
*
* @param value The constructed object of the node containing the raw argument for the include placeholder
* @param sourceLocation Description of the source location for logging purposes
*
* @author Jimmy Tanagra - Initial contribution
*/
@SuppressWarnings("null")
@NonNullByDefault
public record IncludePlaceholder(@Nullable Object value,
@NonNull String sourceLocation) implements InterpolablePlaceholder<IncludePlaceholder> {
@Override
public IncludePlaceholder recreate(@Nullable Object newValue, String location) {
return new IncludePlaceholder(newValue, location);
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.placeholders;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link InsertPlaceholder} represents an object constructed from an <code>!insert</code> node
* to be processed by the {@link org.openhab.io.yamlcomposer.internal.YamlComposer}.
*
* @param value The constructed object of the node containing the raw argument for the insert placeholder
* @param sourceLocation Description of the source location for logging purposes
*
* @author Jimmy Tanagra - Initial contribution
*/
@SuppressWarnings("null")
@NonNullByDefault
public record InsertPlaceholder(@Nullable Object value,
@NonNull String sourceLocation) implements InterpolablePlaceholder<InsertPlaceholder> {
@Override
public InsertPlaceholder recreate(@Nullable Object newValue, String location) {
return new InsertPlaceholder(newValue, location);
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.placeholders;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Common abstraction for placeholders to allow generic access to their
* argument data while preserving typed, record-based implementations.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public interface InterpolablePlaceholder<R extends InterpolablePlaceholder<R>> extends Placeholder {
@Nullable
Object value();
/**
* The factory method each record must implement to allow the generic withValue()
* method to create new instances of the correct type.
*/
R recreate(@Nullable Object newValue, String location);
/**
* Convenience method to create a new instance of the placeholder with a new value but the same source location.
*
* @param newValue the new value for the placeholder
* @return a new instance of the placeholder with the given value and the same source location
*/
@SuppressWarnings("unchecked")
default R withValue(@Nullable Object newValue) {
if (Objects.equals(this.value(), newValue)) {
return (R) this;
}
// Direct method call - no reflection needed
return recreate(newValue, this.sourceLocation());
}
/**
* By default, we want to eagerly process the arguments of placeholders before processing the placeholder itself
* since most of them are just simple wrappers.
*
* However, some placeholders like !if need to resolve their conditions first before processing their arguments
* to avoid processing arguments that are not relevant (e.g. !include within unmet conditions).
*
* Implementations can override this method to change this behavior.
*
* @return true if the arguments should be processed before processing the placeholder, false otherwise
*/
default boolean eagerArgumentProcessing() {
return true;
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.placeholders;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link MergeKeyPlaceholder} replaces the merge key (<<) in a mapping node
* as a unique object, so that multiple merge keys can be processed later.
*
* @param value The value associated with the merge key placeholder ('<<' - not used)
* @param sourceLocation Description of the source location for logging purposes
*
* @author Jimmy Tanagra - Initial contribution
*/
@SuppressWarnings("null")
@NonNullByDefault
public record MergeKeyPlaceholder(@Nullable Object value,
@NonNull String sourceLocation) implements InterpolablePlaceholder<MergeKeyPlaceholder> {
@Override
public MergeKeyPlaceholder recreate(@Nullable Object newValue, String location) {
return new MergeKeyPlaceholder(newValue, location);
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.placeholders;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Common abstraction for placeholders.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public interface Placeholder {
/**
* Source location string for logging and diagnostics.
*
* @return non-null source location
*/
String sourceLocation();
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.placeholders;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link RemovePlaceholder} represents an object constructed from an <code>!remove</code> node
* to be processed by the {@link org.openhab.io.yamlcomposer.internal.YamlComposer}.
*
* @param value The value associated with the remove placeholder (not used)
* @param sourceLocation The source location of the remove placeholder for logging purposes
*
* @author Jimmy Tanagra - Initial contribution
*/
@SuppressWarnings("null")
@NonNullByDefault
public record RemovePlaceholder(@Nullable Object value,
@NonNull String sourceLocation) implements InterpolablePlaceholder<RemovePlaceholder> {
@Override
public RemovePlaceholder recreate(@Nullable Object newValue, String location) {
return new RemovePlaceholder(newValue, location);
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.placeholders;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link ReplacePlaceholder} represents an object constructed from a <code>!replace</code> node
* to be processed by the {@link org.openhab.io.yamlcomposer.internal.YamlComposer}.
*
* @param value The value associated with the replace placeholder
* @param sourceLocation Description of the source location for logging purposes
*
* @author Jimmy Tanagra - Initial contribution
*/
@SuppressWarnings("null")
@NonNullByDefault
public record ReplacePlaceholder(@Nullable Object value,
@NonNull String sourceLocation) implements InterpolablePlaceholder<ReplacePlaceholder> {
@Override
public ReplacePlaceholder recreate(@Nullable Object newValue, String location) {
return new ReplacePlaceholder(newValue, location);
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.placeholders;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link SubstitutionPlaceholder} represents a deferred string interpolation constructed from a <code>!sub</code>
* node to be processed by the {@link org.openhab.io.yamlcomposer.internal.YamlComposer}.
*
* <p>
* It preserves the raw scalar value and the optional variable name that defines delimiter pattern for
* interpolation.
*
* @param value The raw string value containing variable interpolation patterns
* @param patternName The variable name containing delimiter specification in the form <begin>..<end>
* @param sourceLocation Description of the source location for logging purposes
*
* @author Jimmy Tanagra - Initial contribution
*/
@SuppressWarnings("null")
@NonNullByDefault
public record SubstitutionPlaceholder(String value, @Nullable String patternName,
@NonNull String sourceLocation) implements Placeholder {
}
@@ -0,0 +1,141 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.processors;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.placeholders.InterpolablePlaceholder;
/**
* The {@link FragmentUtils} provides common functionality
* for dealing with {@link org.openhab.io.yamlcomposer.internal.placeholders.IncludePlaceholder} and
* {@link org.openhab.io.yamlcomposer.internal.placeholders.InsertPlaceholder} parameters.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class FragmentUtils {
public static record Parameters(@Nullable String name, Map<String, @Nullable Object> varsMap) {
}
/**
* Parses the parameters from an IncludePlaceholder or InsertPlaceholder.
*/
public static @Nullable Parameters parseParameters(InterpolablePlaceholder<?> placeholder, String objectName) {
return switch (placeholder.value()) {
case null -> null;
case String name -> parseStringParameters(name, objectName);
case Map<?, ?> paramsMap -> parseMapParameters(paramsMap, objectName);
default -> null;
};
}
private static @Nullable Parameters parseMapParameters(Map<?, ?> paramsMap, String objectName) {
if (!(paramsMap.get(objectName) instanceof String name)) {
return new Parameters(null, Map.of());
}
if (!(paramsMap.get("vars") instanceof Map<?, ?> varsMap)) {
return new Parameters(name, Map.of());
}
Map<String, @Nullable Object> vars = varsMap.entrySet().stream().collect(LinkedHashMap::new,
(m, v) -> m.put(String.valueOf(v.getKey()), v.getValue()), LinkedHashMap::putAll);
return new Parameters(name, vars);
}
private static Parameters parseStringParameters(String input, String objectName) {
if (input.isBlank()) {
return new Parameters(null, Map.of());
}
// 1. Separate Name and Query without substrings yet
int queryStart = input.indexOf('?');
// Process the Fragment Name
String rawName = (queryStart == -1) ? input : input.substring(0, queryStart);
String decodedName = safeDecode(rawName.trim());
@Nullable
String finalName = decodedName.isEmpty() ? null : decodedName;
// 2. Early exit if no query
if (queryStart == -1 || queryStart == input.length() - 1) {
return new Parameters(finalName, Map.of());
}
// 3. Parse Query String efficiently
Map<String, @Nullable Object> vars = new LinkedHashMap<>();
int len = input.length();
int pos = queryStart + 1;
while (pos < len) {
int nextAmp = input.indexOf('&', pos);
int end = (nextAmp == -1) ? len : nextAmp;
if (end > pos) { // Ignore empty pairs like '&&'
parsePair(input, pos, end, vars);
}
pos = end + 1;
}
return new Parameters(finalName, vars);
}
private static void parsePair(String input, int start, int end, Map<String, @Nullable Object> vars) {
int eq = input.indexOf('=', start);
// If eq is outside our current segment, there is no value
if (eq == -1 || eq >= end) {
String key = safeDecode(input.substring(start, end).trim());
if (!key.isEmpty()) {
vars.put(key, Boolean.TRUE);
}
} else {
String key = safeDecode(input.substring(start, eq).trim());
String val = safeDecode(input.substring(eq + 1, end));
if (!key.isEmpty()) {
vars.put(key, val);
}
}
}
private static String safeDecode(String str) {
if (str.indexOf('%') == -1 && str.indexOf('+') == -1) {
return str; // Fast path: nothing to decode
}
try {
return URLDecoder.decode(str, StandardCharsets.UTF_8);
} catch (Exception e) {
return str;
}
}
/**
* Converts the Parameters back to a value object for IncludePlaceholder or InsertPlaceholder.
*/
public static @Nullable Object toValue(@Nullable String name, Map<String, @Nullable Object> varsMap,
String objectName) {
if (varsMap.isEmpty()) {
return name;
}
if (name == null) {
return Map.of("vars", varsMap);
}
return Map.of(objectName, name, "vars", varsMap);
}
}
@@ -0,0 +1,170 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.processors;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
import org.openhab.io.yamlcomposer.internal.StringInterpolator;
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
import org.openhab.io.yamlcomposer.internal.expression.ExpressionEvaluator;
import org.openhab.io.yamlcomposer.internal.placeholders.IfPlaceholder;
/**
* Processor for resolving {@link IfPlaceholder} in YAML models.
* Evaluates conditions and returns the appropriate branch value.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class IfProcessor implements PlaceholderProcessor<IfPlaceholder> {
private final BufferedLogger logger;
public IfProcessor(BufferedLogger logger) {
this.logger = logger;
}
@Override
public Class<IfPlaceholder> getPlaceholderType() {
return IfPlaceholder.class;
}
private record Branch(@Nullable Object condition, @Nullable Object thenValue) {
}
private record Logic(List<Branch> branches, @Nullable Object elseValue) {
}
@Override
public @Nullable Object process(IfPlaceholder ifPlaceholder, RecursiveTransformer recursiveTransformer) {
Logic logic = switch (ifPlaceholder.value()) {
case null -> null;
case Map<?, ?> map -> parseFromMapDefinition(map, ifPlaceholder.sourceLocation());
case List<?> list -> parseFromListDefinition(list, ifPlaceholder.sourceLocation());
default -> null;
};
if (logic == null) {
return null;
}
for (Branch branch : logic.branches()) {
Object evaluated = switch (branch.condition()) {
case null -> "false"; // Treat null condition as false
case String s -> StringInterpolator.evaluateExpression(s, recursiveTransformer.getVariables(),
logger.getLogSession(), ifPlaceholder.sourceLocation());
default -> branch.condition();
};
if (ExpressionEvaluator.isTruthy(evaluated)) {
return branch.thenValue();
}
}
return logic.elseValue();
}
private @Nullable Logic parseFromMapDefinition(Map<?, ?> map, String sourceLocation) {
Branch mainBranch = createBranch(map, false, false, sourceLocation);
if (mainBranch == null) {
return null;
}
Object elseValue = map.get("else");
return new Logic(List.of(mainBranch), elseValue);
}
private @Nullable Logic parseFromListDefinition(List<?> list, String sourceLocation) {
List<Branch> branches = new ArrayList<>();
@Nullable
Object elseValue = null;
for (int i = 0; i < list.size(); i++) {
Object item = list.get(i);
if (!(item instanceof Map<?, ?> map)) {
logger.warn("{} !if list item is not a mapping and will be ignored: {}.", sourceLocation, item);
continue;
}
if (map.containsKey("else")) {
elseValue = map.get("else");
if (map.containsKey("if") || map.containsKey("elseif") || map.containsKey("then")) {
logger.warn("{} !if sequence item at index {} should only contain 'else'. Other keys ignored.",
sourceLocation, i);
}
if (i < list.size() - 1) {
logger.warn("{} !if list has unreachable branches after the 'else' block.", sourceLocation);
}
break; // Else stops evaluation of further branches
}
Branch branch = createBranch(map, i > 0, true, sourceLocation);
if (branch != null) {
branches.add(branch);
}
}
if (branches.isEmpty()) {
logger.warn("{} !if sequence has no valid branches.", sourceLocation);
}
return new Logic(branches, elseValue);
}
/**
* Creates a Branch from the given map.
*
* @param map the map representing the branch
* @param isElseIf whether this branch is an "elseif" branch
* @param isList whether this is for a list (non-root) branch
* @return the created Branch, or null if creation failed
*/
private @Nullable Branch createBranch(Map<?, ?> map, boolean isElseIf, boolean isList, String sourceLocation) {
String key = isElseIf ? "elseif" : "if";
boolean hasConditionKey = map.containsKey(key);
boolean hasValueKey = map.containsKey("then");
Object rawCondition = map.get(key); // Will be null if missing OR explicitly null
Object rawValue = map.get("then");
if (rawCondition == null) {
rawCondition = "false"; // Treat explicit null as false
}
// 1. Guard: Check existence and nullability
// Condition: must exist
// Value: must exist (can be null)
if (!hasValueKey || !hasConditionKey) {
String prefix = isList ? "branch " : "";
String suffix = isList ? "Ignoring branch." : "Returning null.";
String reason;
if (!hasConditionKey && !hasValueKey) {
reason = "is empty (missing both '" + key + "' and 'then' fields)";
} else if (!hasConditionKey) {
reason = "is missing '" + key + "' field";
} else {
reason = "is missing 'then' field";
}
logger.warn("{} !if {}{}. {}", sourceLocation, prefix, reason, suffix);
return null;
}
// 2. Success: Value exists (even if null), Condition exists and is non-null
return new Branch(rawCondition, rawValue);
}
}
@@ -0,0 +1,179 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.processors;
import java.io.IOException;
import java.nio.file.FileSystemException;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
import org.openhab.io.yamlcomposer.internal.ComposerConfig;
import org.openhab.io.yamlcomposer.internal.YamlComposer;
import org.openhab.io.yamlcomposer.internal.YamlComposer.CacheEntry;
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
import org.openhab.io.yamlcomposer.internal.placeholders.IncludePlaceholder;
import org.snakeyaml.engine.v2.exceptions.MarkedYamlEngineException;
import org.snakeyaml.engine.v2.exceptions.YamlEngineException;
/**
* Processor for resolving {@link IncludePlaceholder} in YAML models
* into the included file content.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class IncludeProcessor implements PlaceholderProcessor<IncludePlaceholder> {
private final BufferedLogger logger;
private final Path basePath;
private final Set<Path> includeStack;
private final Consumer<Path> includeCallback;
private final ConcurrentHashMap<Path, @Nullable CacheEntry> includeCache;
/**
* Creates a new IncludeProcessor.
*
* @param basePath the base path to resolve relative includes against
* @param includeStack the stack of currently included files
* @param includeCallback the callback to invoke when a file is included
* @param includeCache the cache for storing included file contents
* @param logger the logger to use for logging messages
*/
public IncludeProcessor(Path basePath, Set<Path> includeStack, Consumer<Path> includeCallback,
ConcurrentHashMap<Path, @Nullable CacheEntry> includeCache, BufferedLogger logger) {
this.logger = logger;
this.basePath = basePath.toAbsolutePath().normalize();
this.includeStack = includeStack;
this.includeCallback = includeCallback;
this.includeCache = includeCache;
}
@Override
public Class<IncludePlaceholder> getPlaceholderType() {
return IncludePlaceholder.class;
}
/**
* Resolves an {@link IncludePlaceholder}, loads the referenced file
* (recursively following any nested includes), and returns the fully expanded
* content.
*
* @param placeholder the placeholder to process
* @param recursiveTransformer the recursive transformer to use
* @return the expanded content
*/
@Override
public @Nullable Object process(IncludePlaceholder placeholder, RecursiveTransformer recursiveTransformer) {
FragmentUtils.Parameters params = FragmentUtils.parseParameters(placeholder, "file");
if (params == null) {
logger.warn("{} Failed to process !include: invalid parameters", placeholder.sourceLocation());
return null;
}
Object fileNameObj = params.name();
if (fileNameObj == null || String.valueOf(fileNameObj).isBlank()) {
logger.warn("{} Failed to process !include: missing 'file' parameter", placeholder.sourceLocation());
return null;
}
Path includePath = resolvePathPlaceholder(String.valueOf(fileNameObj), placeholder.sourceLocation())
.toAbsolutePath().normalize();
Path configRoot = ComposerConfig.configRoot();
Path includePathRelative = includePath.startsWith(configRoot) ? configRoot.relativize(includePath)
: includePath;
// Handle parameters and variables
Map<String, @Nullable Object> includeVariables = new HashMap<>(recursiveTransformer.getVariables());
includeVariables.putAll(params.varsMap()); // params override current variables
try {
YamlComposer includeComposer = new YamlComposer(includePath, includeVariables, includeStack,
includeCallback, logger.getLogSession(), includeCache);
includeCallback.accept(includePath);
return includeComposer.load();
} catch (YamlEngineException | IOException e) {
logIncludeError(placeholder, String.valueOf(fileNameObj), includePathRelative, e);
return null;
}
}
/**
* Resolves the file path from the !include statement, handling any placeholders
* and providing detailed logging if resolution fails.
*
* Placeholders starting with '@' are resolved relative to OPENHAB_CONF,
* while those starting with '$' are resolved relative to OPENHAB_CONF/yamlcomposer.
* (i.e., {@link ComposerConfig#sourceRoot()}).
*
* This allows for flexible referencing of files
* within the configuration structure.
*
* If no placeholder prefix is used, absolute paths are kept as-is and relative paths are resolved against the
* including file's directory.
*
* @param includeFileName the file name from the !include statement, which may contain placeholders
* @param sourceLocation the source location for logging purposes
* @return the resolved Path to the included file, attempting to resolve placeholders if present
*/
private Path resolvePathPlaceholder(String includeFileName, String sourceLocation) {
char prefix = includeFileName.charAt(0);
Path root = switch (prefix) {
case '@' -> ComposerConfig.configRoot();
case '$' -> ComposerConfig.sourceRoot();
default -> null;
};
if (root != null) {
String cleanedPath = includeFileName.replaceFirst("^[@$]/*", "");
return root.resolve(cleanedPath);
}
Path includeFile = Path.of(includeFileName);
return includeFile.isAbsolute() ? includeFile.normalize() : basePath.resolve(includeFile);
}
private void logIncludeError(IncludePlaceholder p, String name, Path path, Exception e) {
String location = (e instanceof MarkedYamlEngineException me)
? me.getProblemMark().map(m -> "%d:%d".formatted(m.getLine() + 1, m.getColumn() + 1)).orElse(null)
: null;
String pathWithLocation = (location == null) ? path.toString() : path + ":" + location;
String msg = (e instanceof IOException ioe) ? getFriendlyMessage(ioe) : e.getMessage();
logger.warn("{} Failed to process !include '{}'\n{} {}", p.sourceLocation(), name, pathWithLocation, msg);
}
private static @Nullable String getFriendlyMessage(Exception e) {
if (e instanceof FileSystemException fse) {
// If the JDK provided a specific reason string, use it
if (fse.getReason() != null && !fse.getReason().isBlank()) {
return fse.getReason();
}
// Otherwise, use our "Sentence case" class name logic
String name = e.getClass().getSimpleName().replace("Exception", "");
String spaced = name.replaceAll("([a-z])([A-Z])", "$1 $2").toLowerCase().trim();
return (spaced.isBlank()) ? "File system error"
: Character.toUpperCase(spaced.charAt(0)) + spaced.substring(1);
}
return e.getMessage();
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.processors;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
import org.openhab.io.yamlcomposer.internal.core.ProcessingPhase;
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
import org.openhab.io.yamlcomposer.internal.placeholders.InsertPlaceholder;
/**
* Processor for resolving {@link InsertPlaceholder} in YAML models
* into template content with local variable substitution.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class InsertProcessor implements PlaceholderProcessor<InsertPlaceholder> {
private final Map<Object, @Nullable Object> templates;
private final BufferedLogger logger;
/**
* Creates a new InsertProcessor.
*
* @param templates the map of available templates for insertion
* @param logger the logger to use for logging messages
*/
public InsertProcessor(Map<Object, @Nullable Object> templates, BufferedLogger logger) {
this.templates = templates;
this.logger = logger;
}
@Override
public Class<InsertPlaceholder> getPlaceholderType() {
return InsertPlaceholder.class;
}
/**
* Resolves an {@link InsertPlaceholder} recursively
* following any nested inserts, and returns the fully expanded content.
*/
@Override
public @Nullable Object process(InsertPlaceholder placeholder, RecursiveTransformer recursiveTransformer) {
FragmentUtils.Parameters params = FragmentUtils.parseParameters(placeholder, "template");
if (params == null) {
logger.warn("{} Failed to process !insert: invalid parameters", placeholder.sourceLocation());
return null;
}
String templateName = params.name();
if (templateName == null || templateName.isBlank()) {
logger.warn("{} Failed to process !insert: missing template name", placeholder.sourceLocation());
return null;
}
Object templateObj = templates.get(templateName);
if (templateObj == null) {
logger.warn("{} Failed to process !insert '{}': template not found", placeholder.sourceLocation(),
templateName);
return null;
}
// The substitution placeholders in the template are resolved using the templateVariables context
// unlike any other processing which uses the main variables map
Map<String, @Nullable Object> templateVariables = params.varsMap();
RecursiveTransformer localTransformer = recursiveTransformer.withOverrideVariables(templateVariables);
// Keep package override placeholders (!remove/!replace) intact so they are only
// applied in the dedicated PACKAGE_OVERRIDES phase.
Object resolvedTemplate = localTransformer.transform(templateObj, ProcessingPhase.STANDARD);
return resolvedTemplate;
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.processors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
import org.openhab.io.yamlcomposer.internal.placeholders.Placeholder;
/**
* The {@link PlaceholderProcessor} processes placeholder instances in YAML models.
*
* @param <T> The type of placeholder to be processed
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public interface PlaceholderProcessor<T extends Placeholder> {
@Nullable
Object process(T placeholder, RecursiveTransformer recursiveTransformer);
Class<T> getPlaceholderType();
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.processors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
import org.openhab.io.yamlcomposer.internal.core.RemovalSignal;
import org.openhab.io.yamlcomposer.internal.placeholders.RemovePlaceholder;
/**
* The {@link RemoveProcessor} processes {@link RemovePlaceholder} instances in YAML models.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class RemoveProcessor implements PlaceholderProcessor<RemovePlaceholder> {
@Override
public Class<RemovePlaceholder> getPlaceholderType() {
return RemovePlaceholder.class;
}
@Override
public @Nullable Object process(RemovePlaceholder placeholder, RecursiveTransformer recursiveTransformer) {
return RemovalSignal.REMOVE;
}
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.processors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
import org.openhab.io.yamlcomposer.internal.placeholders.ReplacePlaceholder;
/**
* The {@link ReplaceProcessor} processes {@link ReplacePlaceholder} instances in YAML models.
* It simply returns the value contained within the placeholder.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class ReplaceProcessor implements PlaceholderProcessor<ReplacePlaceholder> {
@Override
public Class<ReplacePlaceholder> getPlaceholderType() {
return ReplacePlaceholder.class;
}
@Override
public @Nullable Object process(ReplacePlaceholder placeholder, RecursiveTransformer recursiveTransformer) {
return placeholder.value();
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal.processors;
import java.util.Map;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
import org.openhab.io.yamlcomposer.internal.StringInterpolator;
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
import org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder;
/**
* Processor for handling variable substitutions by resolving {@code SubstitutionPlaceholder} in YAML models
* into interpolated strings.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class SubstitutionProcessor implements PlaceholderProcessor<SubstitutionPlaceholder> {
private final BufferedLogger logger;
public SubstitutionProcessor(BufferedLogger logger) {
this.logger = logger;
}
@Override
public Class<SubstitutionPlaceholder> getPlaceholderType() {
return SubstitutionPlaceholder.class;
}
/**
* Recursively resolve all SubstitutionPlaceholders in a value (map, list, or scalar).
* using the RecursiveTransformer's variables as the substitution context.
*
* @param value The value to process
* @return The processed value with substitutions applied
*/
@Override
public @Nullable Object process(SubstitutionPlaceholder placeholder, RecursiveTransformer recursiveTransformer) {
return process(placeholder, recursiveTransformer.getVariables());
}
/**
* Recursively resolve all SubstitutionPlaceholders in a value (map, list, or scalar)
* using the provided context for substitutions.
*
* This overload is needed by {@link InsertProcessor} to apply substitutions
* with a custom variable context when processing templates.
*
* @param value The value to process
* @param context The variable context for substitutions
* @return The processed value with substitutions applied
*/
public @Nullable Object process(SubstitutionPlaceholder placeholder, Map<String, @Nullable Object> context) {
Pattern pattern = resolvePattern(placeholder, context);
return StringInterpolator.interpolate(placeholder.value(), pattern, context, logger.getLogSession(),
placeholder.sourceLocation());
}
private Pattern resolvePattern(SubstitutionPlaceholder placeholder, Map<String, @Nullable Object> context) {
String patternName = placeholder.patternName();
if (patternName == null || patternName.isBlank()) {
return StringInterpolator.DEFAULT_SUBSTITUTION_PATTERN;
}
Object rawPatternSpec = context.get(patternName);
if (!(rawPatternSpec instanceof String patternSpec) || patternSpec.isBlank()) {
logger.warn("{} Undefined or invalid pattern variable '{}' for !sub tag; using default pattern.",
placeholder.sourceLocation(), patternName);
return StringInterpolator.DEFAULT_SUBSTITUTION_PATTERN;
}
Pattern compiled = StringInterpolator.compilePatternSpec(patternSpec);
if (compiled == null) {
logger.warn("{} Invalid pattern specification '{}' in variable '{}' for !sub tag; using default pattern.",
placeholder.sourceLocation(), patternSpec, patternName);
return StringInterpolator.DEFAULT_SUBSTITUTION_PATTERN;
}
return compiled;
}
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="yamlcomposer" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>misc</type>
<name>YAML Composer</name>
<description>This add-on processes YAML with extended syntax (includes, packages, variables) and outputs fully resolved
plain YAML for openHAB YAML Configuration.</description>
<connection>local</connection>
<service-id>org.openhab.io.yamlcomposer</service-id>
</addon:addon>
@@ -0,0 +1,4 @@
# add-on
addon.yamlcomposer.name = YAML Composer
addon.yamlcomposer.description = This add-on processes YAML with extended syntax (includes, packages, variables) and outputs fully resolved plain YAML for openHAB YAML Configuration.
+1
View File
@@ -33,6 +33,7 @@
<module>org.openhab.io.metrics</module>
<module>org.openhab.io.neeo</module>
<module>org.openhab.io.openhabcloud</module>
<module>org.openhab.io.yamlcomposer</module>
<!-- transformations -->
<module>org.openhab.transform.basicprofiles</module>
<module>org.openhab.transform.bin2json</module>
@@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons
@@ -0,0 +1,76 @@
-include: ../itest-common.bndrun
Bundle-SymbolicName: ${project.artifactId}
Fragment-Host: org.openhab.io.yamlcomposer
-runrequires: \
bnd.identity;id='org.openhab.io.yamlcomposer.tests',\
bnd.identity;id='org.openhab.core',\
bnd.identity;id='org.openhab.io.yamlcomposer'
# We would like to use the "volatile" storage only
-runblacklist: \
bnd.identity;id='org.openhab.core.storage.json'
#
# done
#
-runbundles: \
biz.aQute.tester.junit-platform;version='[7.2.3,7.2.4)',\
ch.qos.logback.classic;version='[1.5.32,1.5.33)',\
ch.qos.logback.core;version='[1.5.32,1.5.33)',\
com.fasterxml.jackson.core.jackson-annotations;version='[2.21.0,2.21.1)',\
com.fasterxml.jackson.core.jackson-core;version='[2.21.3,2.21.4)',\
com.fasterxml.jackson.core.jackson-databind;version='[2.21.3,2.21.4)',\
com.fasterxml.jackson.dataformat.jackson-dataformat-yaml;version='[2.21.3,2.21.4)',\
com.google.gson;version='[2.13.2,2.13.3)',\
com.google.guava;version='[33.6.0,33.6.1)',\
com.google.guava.failureaccess;version='[1.0.3,1.0.4)',\
com.sun.jna;version='[5.18.1,5.18.2)',\
com.sun.xml.bind.jaxb-osgi;version='[2.3.9,2.3.10)',\
io.methvin.directory-watcher;version='[0.19.1,0.19.2)',\
jakarta.annotation-api;version='[2.1.1,2.1.2)',\
jakarta.inject.jakarta.inject-api;version='[2.0.1,2.0.2)',\
jakarta.xml.bind-api;version='[2.3.3,2.3.4)',\
javax.measure.unit-api;version='[2.2.0,2.2.1)',\
junit-jupiter-api;version='[5.14.4,5.14.5)',\
junit-jupiter-engine;version='[5.14.4,5.14.5)',\
junit-platform-commons;version='[1.14.4,1.14.5)',\
junit-platform-engine;version='[1.14.4,1.14.5)',\
junit-platform-launcher;version='[1.14.4,1.14.5)',\
org.apache.aries.spifly.dynamic.bundle;version='[1.3.7,1.3.8)',\
org.apache.felix.configadmin;version='[1.9.26,1.9.27)',\
org.apache.felix.http.servlet-api;version='[1.2.0,1.2.1)',\
org.apache.felix.scr;version='[2.2.18,2.2.19)',\
org.apache.servicemix.specs.activation-api-1.2.1;version='[1.2.1,1.2.2)',\
org.eclipse.equinox.event;version='[1.7.300,1.7.301)',\
org.eclipse.jetty.http;version='[9.4.58,9.4.59)',\
org.eclipse.jetty.io;version='[9.4.58,9.4.59)',\
org.eclipse.jetty.security;version='[9.4.58,9.4.59)',\
org.eclipse.jetty.server;version='[9.4.58,9.4.59)',\
org.eclipse.jetty.servlet;version='[9.4.58,9.4.59)',\
org.eclipse.jetty.util;version='[9.4.58,9.4.59)',\
org.eclipse.jetty.util.ajax;version='[9.4.58,9.4.59)',\
org.glassfish.hk2.osgi-resource-locator;version='[3.0.0,3.0.1)',\
org.hamcrest;version='[3.0.0,3.0.1)',\
org.objectweb.asm;version='[9.9.1,9.9.2)',\
org.objectweb.asm.commons;version='[9.9.1,9.9.2)',\
org.objectweb.asm.tree;version='[9.9.1,9.9.2)',\
org.objectweb.asm.tree.analysis;version='[9.9.1,9.9.2)',\
org.objectweb.asm.util;version='[9.9.1,9.9.2)',\
org.openhab.core;version='[5.2.0,5.2.1)',\
org.openhab.core.test;version='[5.2.0,5.2.1)',\
org.openhab.io.yamlcomposer;version='[5.2.0,5.2.1)',\
org.openhab.io.yamlcomposer.tests;version='[5.2.0,5.2.1)',\
org.opentest4j;version='[1.3.0,1.3.1)',\
org.ops4j.pax.logging.pax-logging-api;version='[2.3.3,2.3.4)',\
org.osgi.service.component;version='[1.5.1,1.5.2)',\
org.osgi.service.event;version='[1.4.1,1.4.2)',\
org.osgi.util.function;version='[1.2.0,1.2.1)',\
org.osgi.util.promise;version='[1.3.0,1.3.1)',\
org.snakeyaml.engine;version='[3.0.1,3.0.2)',\
org.yaml.snakeyaml;version='[2.5.0,2.5.1)',\
si-quantity;version='[2.2.3,2.2.4)',\
si-units;version='[2.2.3,2.2.4)',\
tech.units.indriya;version='[2.2.3,2.2.4)',\
uom-lib-common;version='[2.2.0,2.2.1)'
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.itests</groupId>
<artifactId>org.openhab.addons.reactor.itests</artifactId>
<version>5.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.io.yamlcomposer.tests</artifactId>
<name>openHAB Add-ons :: Integration Tests :: YAML Composer IO Tests</name>
<dependencies>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.io.yamlcomposer</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.yamlcomposer.internal;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openhab.core.service.WatchService.Kind;
import org.openhab.core.test.java.JavaOSGiTest;
/**
* OSGi integration tests for {@link YamlComposerWatchService}.
*
* @author Jimmy Tanagra - Initial contribution
*/
public class YamlComposerWatchServiceOSGiTest extends JavaOSGiTest {
private YamlComposerWatchService watchService;
private Path testSourceDir;
private Path testOutputDir;
@BeforeEach
public void setUp() throws IOException {
watchService = getService(YamlComposerWatchService.class);
assertNotNull(watchService);
testSourceDir = ComposerConfig.sourceRoot();
testOutputDir = ComposerConfig.outputRoot();
deleteRecursively(testSourceDir);
deleteRecursively(testOutputDir);
Files.createDirectories(testSourceDir.resolve("defs"));
Files.createDirectories(testOutputDir);
}
@AfterEach
public void tearDown() throws IOException {
deleteRecursively(testSourceDir);
deleteRecursively(testOutputDir);
}
@Test
public void shouldGenerateOutputFromSourceAndRecompileOnIncludeChange() throws IOException {
Path includeFile = testSourceDir.resolve("defs/items.inc.yaml");
Path mainFile = testSourceDir.resolve("main.yaml");
Path generatedFile = testOutputDir.resolve("main.yaml");
Files.writeString(includeFile, """
- type: Switch
name: MySwitch
label: Label One
""");
Files.writeString(mainFile, """
version: 1
items: !include defs/items.inc.yaml
""");
watchService.processWatchEvent(Kind.CREATE, mainFile);
waitForAssert(() -> {
assertTrue(Files.exists(generatedFile));
assertTrue(fileContains(generatedFile, "Label One"));
});
Files.writeString(includeFile, """
- type: Switch
name: MySwitch
label: Label Two
""");
watchService.processWatchEvent(Kind.MODIFY, includeFile);
waitForAssert(() -> {
assertTrue(Files.exists(generatedFile));
assertTrue(fileContains(generatedFile, "Label Two"));
});
}
@Test
public void shouldRemoveGeneratedOutputOnSourceDelete() throws IOException {
Path mainFile = testSourceDir.resolve("delete-me.yaml");
Path generatedFile = testOutputDir.resolve("delete-me.yaml");
Files.writeString(mainFile, """
version: 1
items:
- type: Switch
name: DeleteMe
""");
watchService.processWatchEvent(Kind.CREATE, mainFile);
waitForAssert(() -> assertTrue(Files.exists(generatedFile)));
watchService.processWatchEvent(Kind.DELETE, mainFile);
waitForAssert(() -> assertFalse(Files.exists(generatedFile)));
}
private static boolean fileContains(Path file, String expectedContent) {
try {
return Files.readString(file).contains(expectedContent);
} catch (IOException e) {
throw new AssertionError("Failed to read file " + file, e);
}
}
private static void deleteRecursively(Path path) throws IOException {
if (!Files.exists(path)) {
return;
}
Files.walkFileTree(path, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.deleteIfExists(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.deleteIfExists(dir);
return FileVisitResult.CONTINUE;
}
});
}
}
+1
View File
@@ -30,6 +30,7 @@
<module>org.openhab.binding.modbus.tests</module>
<module>org.openhab.binding.mqtt.ruuvigateway.tests</module>
<module>org.openhab.binding.ntp.tests</module>
<module>org.openhab.io.yamlcomposer.tests</module>
<module>org.openhab.binding.systeminfo.tests</module>
<module>org.openhab.binding.tradfri.tests</module>
<module>org.openhab.persistence.mapdb.tests</module>
@@ -37,6 +37,11 @@
<Class name="org.openhab.core.model.script.actions.Log"/>
<Bug pattern="SLF4J_FORMAT_SHOULD_BE_CONST"/>
</Match>
<!-- BufferedLogger intentionally accepts dynamic SLF4J templates, so the format string cannot be constant -->
<Match>
<Class name="org.openhab.io.yamlcomposer.internal.BufferedLogger"/>
<Bug pattern="SLF4J_FORMAT_SHOULD_BE_CONST"/>
</Match>
<Match>
<Bug pattern="SLF4J_UNKNOWN_ARRAY"/>
</Match>