diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..882745b05
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,43 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [Unreleased]
+
+### Added
+
+- **Interpolation Functions**: New string manipulation functions for use in data values and hierarchy paths.
+ - `substring(string, start[, count])`: Extract a portion of a string using 0-based indexing.
+ - `%{substring('abcdef', 2)}` returns `'cdef'`
+ - `%{substring('abcdef', 2, 3)}` returns `'cde'`
+ - `match(string, pattern)`: Test if a string matches a regex or contains a substring.
+ - `%{match($role, '/^web/')}` returns `'true'` or `'false'`
+ - `%{match($hostname, 'db')}` returns `'true'` if hostname contains 'db'
+ - Regex flags supported: `i` (case-insensitive), `m` (multiline), `x` (extended)
+ - `grep_captures(string, '/regex/'[, separator][, [groups]])`: Extract capture groups from a regex match.
+ - `%{grep_captures('abc-123', '/([a-z]+)-(\d+)/')}` returns `'abc123'` (all groups concatenated)
+ - `%{grep_captures('abc-123', '/([a-z]+)-(\d+)/', '-')}` returns `'abc-123'` (all groups with separator)
+ - `%{grep_captures('abc-123', '/([a-z]+)-(\d+)/', [1])}` returns `'abc'` (specific groups)
+ - `%{grep_captures('abc-123', '/([a-z]+)-(\d+)/', '_', [1,2])}` returns `'abc_123'`
+ - Defaults: empty separator `''`, all capture groups
+ - Third argument auto-detected: array = groups, quoted string = separator
+ - `version_gt(a, b)`, `version_gte(a, b)`, `version_lt(a, b)`, `version_lte(a, b)`: Compare semantic versions.
+ - `%{version_gte($app_version, '2.0.0')}` returns `'true'` or `'false'`
+ - Uses `Gem::Version` for proper semantic version comparison
+ - Handles `1.10.0 > 1.9.0` and prerelease versions correctly
+
+### Changed
+
+- `RX_METHOD_AND_ARG` regex updated to support function arguments containing parentheses (needed for regex capture groups).
+- Argument parser now handles array literals `[1,2,3]` for specifying capture group indices.
+
+### Fixed
+
+- Regex literals with escaped slashes (e.g., `/a\/b/`) are now parsed correctly.
+- Empty quoted strings (`''` or `""`) are now handled correctly as function arguments.
+
+### Notes
+
+- Single-quoted regex patterns (`'/pattern/'`) are recommended:
+ - Support curly brace quantifiers: `'/\d{3,5}/'`
+ - No YAML double-escaping needed: `'/\d+/'` instead of `/\\d+/`
diff --git a/README.md b/README.md
index 6e0a35a3b..20f17237d 100644
--- a/README.md
+++ b/README.md
@@ -144,6 +144,96 @@ other.key: 'hiera data %{hiera("a.''b.c''.d")}'
Note that two single quotes are used to escape a single quote inside a single quoted string
(that's YAML syntax, not Hiera) and that the quoted key must be quoted in turn.
+### Interpolation Functions
+
+Hiera supports interpolation functions that can manipulate strings and perform pattern matching.
+These functions can be used in both data values and hierarchy paths in `hiera.yaml`.
+
+#### substring
+
+Extract a portion of a string:
+
+
+# substring(string, start) - from start index to end
+# substring(string, start, count) - from start index, count characters
+
+path: "nodes/%{substring($certname, 0, 4)}"
+value: "%{substring('abcdef', 2, 3)}" # returns 'cde'
+
+
+Arguments can be:
+- Quoted literals: `'string'` or `"string"`
+- Scope variables: `$varname` or `varname`
+- Integers: `0`, `5`, etc.
+
+Indexing is 0-based. Negative indices are supported (e.g., `-1` for last character).
+
+#### match
+
+Test if a string matches a pattern:
+
+
+# match(string, /regex/) - returns 'true' or 'false'
+# match(string, 'needle') - returns 'true' if string contains needle
+
+is_web: "%{match($role, '/^web/')}"
+has_db: "%{match($hostname, 'db')}"
+
+
+Regex literals support flags:
+- `i` - case insensitive
+- `m` - multiline
+- `x` - extended (ignore whitespace)
+
+
+# Case-insensitive match
+match_result: "%{match('HELLO', '/hello/i')}" # returns 'true'
+
+
+#### grep_captures
+
+Extract capture groups from a regex match and join them with a separator:
+
+
+# grep_captures(string, '/regex/') - returns all capture groups concatenated
+# grep_captures(string, '/regex/', separator) - returns all groups joined by separator
+# grep_captures(string, '/regex/', [groups]) - returns specified groups concatenated
+# grep_captures(string, '/regex/', separator, [groups]) - returns specified groups joined by separator
+
+# Extract all capture groups (default: concatenated)
+all: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/')}" # returns 'abc123def'
+
+# Extract all groups with separator
+parts: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/', '-')}" # returns 'abc-123-def'
+
+# Extract specific groups (concatenated)
+selected: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/', [1,3])}" # returns 'abcdef'
+
+# Extract specific groups with custom separator
+custom: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/', '_', [1,3])}" # returns 'abc_def'
+
+
+**Tip:** Wrap regex patterns in single quotes (`'/pattern/'`) for cleaner syntax:
+- Curly brace quantifiers work: `'/\d{3,5}/'`
+- No double-escaping needed: `'/\d+/'` instead of `/\\d+/`
+
+#### Version Comparison Functions
+
+Compare semantic versions using `Gem::Version` comparison:
+
+
+# version_gt(a, b) - returns 'true' if a > b
+# version_gte(a, b) - returns 'true' if a >= b
+# version_lt(a, b) - returns 'true' if a < b
+# version_lte(a, b) - returns 'true' if a <= b
+
+is_newer: "%{version_gt($app_version, '2.0.0')}" # true if app_version > 2.0.0
+is_compatible: "%{version_gte($ruby_version, '2.7.0')}" # true if ruby >= 2.7.0
+needs_upgrade: "%{version_lt($os_version, '8.0')}" # true if os < 8.0
+
+
+Handles semantic versioning correctly (`1.10.0 > 1.9.0`) and prerelease versions (`1.0.0.alpha < 1.0.0`).
+
## Future Enhancements
* More backends should be created
diff --git a/docs/tutorials/interpolation_functions.md b/docs/tutorials/interpolation_functions.md
new file mode 100644
index 000000000..181d0d769
--- /dev/null
+++ b/docs/tutorials/interpolation_functions.md
@@ -0,0 +1,332 @@
+# Interpolation Functions
+
+Hiera supports interpolation functions that allow you to manipulate strings and perform pattern matching within your data. These functions can be used in both data values and hierarchy paths in `hiera.yaml`.
+
+## Overview
+
+Interpolation functions use the syntax `%{function_name(arg1, arg2, ...)}`. Arguments can be:
+
+- **Quoted literals**: `'string'` or `"string"`
+- **Scope variables**: `$varname` or just `varname`
+- **Integers**: `0`, `5`, `-1`, etc.
+- **Regex literals**: `/pattern/` with optional flags
+
+## Available Functions
+
+### substring
+
+Extract a portion of a string using 0-based indexing.
+
+**Syntax:**
+```
+%{substring(string, start)}
+%{substring(string, start, count)}
+```
+
+**Parameters:**
+- `string` - The source string (quoted literal or scope variable)
+- `start` - Starting index (0-based, can be negative)
+- `count` - Optional number of characters to extract
+
+**Examples:**
+
+```yaml
+# In data files
+---
+# Extract from index 2 to end
+slice_to_end: "%{substring('abcdef', 2)}" # Returns 'cdef'
+
+# Extract 3 characters starting at index 2
+slice_with_count: "%{substring('abcdef', 2, 3)}" # Returns 'cde'
+
+# Using scope variables
+node_prefix: "%{substring($certname, 0, 4)}"
+
+# Negative indices (from end of string)
+last_three: "%{substring('abcdef', -3)}" # Returns 'def'
+```
+
+**In hiera.yaml hierarchy:**
+
+```yaml
+:hierarchy:
+ - name: "node certname prefix"
+ path: "nodes/%{substring($certname, 0, 4)}.yaml"
+ - name: "environment"
+ path: "env/%{environment}.yaml"
+ - name: "common"
+ path: "common.yaml"
+```
+
+This would allow grouping nodes by the first 4 characters of their certname.
+
+### match
+
+Test if a string matches a regular expression or contains a substring.
+
+**Syntax:**
+```
+%{match(string, '/regex/')}
+%{match(string, '/regex/flags')}
+%{match(string, 'substring')}
+%{match(string, $pattern_var)}
+```
+
+**Parameters:**
+- `string` - The string to test (quoted literal or scope variable)
+- `pattern` - A regex literal (`/pattern/`) or a string to search for
+
+**Returns:** `'true'` or `'false'` (as strings)
+
+**Regex Flags:**
+- `i` - Case insensitive matching
+- `m` - Multiline mode (`.` matches newlines)
+- `x` - Extended mode (ignore whitespace in pattern)
+
+**Examples:**
+
+```yaml
+# In data files
+---
+# Simple regex match
+is_webserver: "%{match($role, '/^web/')}"
+
+# Case-insensitive match
+matches_hello: "%{match('HELLO WORLD', '/hello/i')}" # Returns 'true'
+
+# Substring contains check
+has_db_in_name: "%{match($hostname, 'db')}"
+
+# Complex regex with capture groups
+is_valid_version: "%{match($version, '/^(\d+)\.(\d+)\.(\d+)$/')}"
+```
+
+**In hiera.yaml hierarchy:**
+
+You can use `match()` results in hierarchy paths, though typically you'd use it in data values for conditional logic:
+
+```yaml
+# data/common.yaml
+---
+# Set a value based on pattern matching
+database_type: "%{match($hostname, '/^db/') ? 'primary' : 'replica'}"
+```
+
+Note: The ternary operator shown above is not currently supported. Use `match()` to return `'true'`/`'false'` and handle the logic in your Puppet code.
+
+### grep_captures
+
+Extract capture groups from a regex match and join them with a separator.
+
+**Syntax:**
+```
+%{grep_captures(string, '/regex/')}
+%{grep_captures(string, '/regex/', separator)}
+%{grep_captures(string, '/regex/', [group_indices])}
+%{grep_captures(string, '/regex/', separator, [group_indices])}
+```
+
+**Parameters:**
+- `string` - The string to match against (quoted literal or scope variable)
+- `'/regex/'` - A single-quoted regex literal with capture groups
+- `separator` - Optional separator string (default: `''` empty). Auto-detected if 3rd arg is a quoted string.
+- `[group_indices]` - Optional array of capture group numbers to extract (default: all groups). Auto-detected if 3rd arg is an array.
+
+**Returns:** The specified capture groups joined by the separator, or empty string if no match.
+
+**Examples:**
+
+```yaml
+# In data files
+---
+# Extract all capture groups concatenated (default behavior)
+all_concat: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/')}" # Returns 'abc123def'
+
+# Extract all groups with separator
+all_sep: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/', '-')}" # Returns 'abc-123-def'
+
+# Extract specific groups concatenated (3rd arg is array)
+selected: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/', [1,3])}" # Returns 'abcdef'
+
+# Extract specific groups with custom separator
+parts: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/', '_', [1,3])}" # Returns 'abc_def'
+
+# Parse structured hostname: web01-prod-dc1
+# Extract environment and datacenter
+env_dc: "%{grep_captures($hostname, '/\w+-(\w+)-(\w+)/', '_', [1,2])}" # Returns 'prod_dc1'
+```
+
+**Use case - Extracting components from structured names:**
+
+```yaml
+# data/common.yaml
+---
+# Hostname format: --.example.com
+# Example: web01-prod-dc1.example.com
+
+# Extract all parts concatenated
+all_parts: "%{grep_captures($hostname, '/(\w+)-(\w+)-(\w+)/')}" # Returns 'web01proddc1'
+
+# Extract environment from hostname (single group)
+extracted_env: "%{grep_captures($hostname, '/\w+-(\w+)-\w+/')}" # Returns 'prod'
+
+# Extract datacenter
+extracted_dc: "%{grep_captures($hostname, '/\w+-\w+-(\w+)/')}" # Returns 'dc1'
+
+# Create a combined identifier with separator
+node_id: "%{grep_captures($hostname, '/(\w+)-(\w+)-(\w+)/', '_')}" # Returns 'web01_prod_dc1'
+```
+
+## Practical Examples
+
+### Example 1: Environment-based configuration
+
+```yaml
+# hiera.yaml
+:hierarchy:
+ - name: "node"
+ path: "nodes/%{certname}.yaml"
+ - name: "role"
+ path: "roles/%{role}.yaml"
+ - name: "environment"
+ path: "env/%{environment}.yaml"
+ - name: "common"
+ path: "common.yaml"
+
+# data/common.yaml
+---
+# Use substring to create short identifiers
+short_hostname: "%{substring($hostname, 0, 8)}"
+
+# Check if this is a production-like environment
+is_production: "%{match($environment, '/^prod/')}"
+```
+
+### Example 2: Version-based paths
+
+```yaml
+# hiera.yaml
+:hierarchy:
+ - "os/%{operatingsystem}/%{substring($operatingsystemrelease, 0, 1)}"
+ - "os/%{operatingsystem}"
+ - common
+```
+
+This creates paths like `os/CentOS/7` for CentOS 7.9.2009.
+
+### Example 3: Datacenter detection
+
+```yaml
+# data/common.yaml
+---
+# Detect datacenter from hostname pattern
+# Hostnames like: web01-dc1.example.com, db02-dc2.example.com
+is_dc1: "%{match($hostname, '/-dc1\./')}"
+is_dc2: "%{match($hostname, '/-dc2\./')}"
+```
+
+### Version Comparison Functions
+
+Compare semantic versions using `Gem::Version` comparison.
+
+**Syntax:**
+```
+%{version_gt(version_a, version_b)} # true if a > b
+%{version_gte(version_a, version_b)} # true if a >= b
+%{version_lt(version_a, version_b)} # true if a < b
+%{version_lte(version_a, version_b)} # true if a <= b
+```
+
+**Parameters:**
+- `version_a` - First version string (quoted literal or scope variable)
+- `version_b` - Second version string (quoted literal or scope variable)
+
+**Returns:** `'true'` or `'false'` (as strings)
+
+**Examples:**
+
+```yaml
+# In data files
+---
+# Check if app version meets minimum requirement
+is_compatible: "%{version_gte($app_version, '2.0.0')}"
+
+# Check if OS needs upgrade
+needs_upgrade: "%{version_lt($os_version, '8.0')}"
+
+# Compare two scope variables
+is_newer: "%{version_gt($installed_version, $required_version)}"
+```
+
+**Semantic versioning:**
+
+The functions use Ruby's `Gem::Version` for proper comparison:
+
+```yaml
+# Numeric comparison (not string comparison)
+correct: "%{version_gte('1.10.0', '1.9.0')}" # Returns 'true' (1.10 > 1.9)
+
+# Prerelease versions sort before release
+prerelease: "%{version_lt('1.0.0.alpha', '1.0.0')}" # Returns 'true'
+```
+
+## Error Handling
+
+- If `substring()` indices are out of range, an empty string is returned.
+- If `match()` receives an invalid regex, an error is raised.
+- If `grep_captures()` doesn't match, an empty string is returned.
+- If `grep_captures()` receives a non-regex pattern, an error is raised.
+- If version comparison receives malformed versions, falls back to string comparison.
+- Missing scope variables resolve to empty strings.
+
+## Single-Quoted Regex Patterns
+
+You can wrap regex patterns in single quotes (`'/pattern/'`) to gain two benefits:
+
+1. **Curly brace quantifiers**: Allows `{3,5}` without breaking the `%{...}` interpolation
+2. **No double-escaping**: Backslashes work naturally without YAML escape doubling
+
+```yaml
+# Single-quoted pattern - clean and simple
+valid: "%{grep_captures('12345-widget', '/(\d{3,5})-(\w+)/', '_', [1,2])}"
+
+# Without quotes, curly braces break the interpolation
+# invalid: "%{grep_captures('12345', /\d{3,5}/)}" # DON'T do this
+```
+
+**Recommended approach** - use single-quoted patterns for cleaner regex:
+
+```yaml
+# Single-quoted: no double-escaping needed
+clean: "%{match($path, '/a\/b/')}"
+digits: "%{match($str, '/\d+/')}"
+
+# Unquoted regex in YAML double-quoted strings requires double-escaping
+escaped: "%{match($path, /a\\/b/)}"
+```
+
+## Escaping Special Characters (Unquoted Regex)
+
+When using unquoted regex patterns (`/pattern/`) in YAML double-quoted strings:
+
+1. **YAML escaping**: Backslashes must be doubled.
+ ```yaml
+ # To match a literal slash in regex:
+ has_slash: "%{match($path, /a\\/b/)}" # \\\\ in YAML becomes \\
+ ```
+
+2. **Regex escaping**: Standard regex escaping rules apply within the pattern.
+ ```yaml
+ # Match a literal dot
+ has_dot: "%{match($hostname, /\\./)}"`
+ ```
+
+**Tip**: Use single-quoted patterns (`'/pattern/'`) to avoid double-escaping complexity.
+
+## Tips
+
+1. **Use in hierarchy paths**: These functions are especially powerful in `hiera.yaml` hierarchy definitions to create dynamic data source paths.
+
+2. **Combine with facts**: Use Facter facts as function arguments to create flexible, fact-driven configurations.
+
+3. **Keep it simple**: While powerful, complex interpolation expressions can be hard to debug. Consider moving complex logic to Puppet code when appropriate.
diff --git a/ext/hiera_with_functions.yaml b/ext/hiera_with_functions.yaml
new file mode 100644
index 000000000..7ff12bef1
--- /dev/null
+++ b/ext/hiera_with_functions.yaml
@@ -0,0 +1,40 @@
+# Example hiera.yaml demonstrating interpolation functions
+#
+# This configuration shows how to use substring() and match() functions
+# in hierarchy paths for dynamic data source resolution.
+#
+---
+:backends:
+ - yaml
+
+:logger: console
+
+:hierarchy:
+ # Node-specific data using full certname
+ - name: "node"
+ path: "nodes/%{certname}.yaml"
+
+ # Group nodes by first 4 characters of certname
+ # e.g., web01.example.com -> nodes/prefix/web0
+ - name: "node prefix"
+ path: "nodes/prefix/%{substring($certname, 0, 4)}.yaml"
+
+ # Role-based configuration
+ - name: "role"
+ path: "roles/%{role}.yaml"
+
+ # Environment-specific
+ - name: "environment"
+ path: "env/%{environment}.yaml"
+
+ # OS family with major version only
+ # e.g., CentOS 7.9.2009 -> os/CentOS/7
+ - name: "os"
+ path: "os/%{operatingsystem}/%{substring($operatingsystemrelease, 0, 1)}.yaml"
+
+ # Common defaults
+ - name: "common"
+ path: "common.yaml"
+
+:yaml:
+ :datadir: /etc/puppetlabs/code/hieradata
diff --git a/lib/hiera/interpolate.rb b/lib/hiera/interpolate.rb
index 88bfa875e..81bf02467 100644
--- a/lib/hiera/interpolate.rb
+++ b/lib/hiera/interpolate.rb
@@ -8,7 +8,7 @@ class Hiera::InterpolationInvalidValue < StandardError; end
class Hiera::Interpolate
RX_INTERPOLATION = /%\{([^\}]*)\}/
RX_ONLY_INTERPOLATION = /^%\{([^\}]*)\}$/
- RX_METHOD_AND_ARG = /^(\w+)\(([^)]*)\)$/
+ RX_METHOD_AND_ARG = /^(\w+)\((.*)\)$/
EMPTY_INTERPOLATIONS = {
'' => true,
@@ -23,7 +23,14 @@ class Hiera::Interpolate
'hiera' => :hiera_interpolate,
'scope' => :scope_interpolate,
'literal' => :literal_interpolate,
- 'alias' => :alias_interpolate
+ 'alias' => :alias_interpolate,
+ 'substring' => :substring_interpolate,
+ 'match' => :match_interpolate,
+ 'grep_captures' => :grep_captures_interpolate,
+ 'version_gt' => :version_gt_interpolate,
+ 'version_gte' => :version_gte_interpolate,
+ 'version_lt' => :version_lt_interpolate,
+ 'version_lte' => :version_lte_interpolate
}.freeze
class << self
@@ -43,25 +50,102 @@ def interpolate(data, scope, extra_data, context)
# each interpolation site in isolation using separate recursion guards.
new_context = context.nil? ? {} : context.clone
new_context[:recurse_guard] ||= Hiera::RecursiveGuard.new
- data.gsub(RX_INTERPOLATION) do |match|
+
+ result = data.dup
+ offset = 0
+
+ find_interpolations(data).each do |start_pos, end_pos, inner|
+ match = "%{#{inner}}"
(interp_val, interpolate_method) = do_interpolation(match, scope, extra_data, new_context)
if (interpolate_method == :alias_interpolate) && !interp_val.is_a?(String)
- return interp_val if data.match(RX_ONLY_INTERPOLATION)
+ return interp_val if data == match
raise Hiera::InterpolationInvalidValue, "Cannot call alias in the string context"
- else
- interp_val
end
+
+ replacement = interp_val.to_s
+ result[start_pos + offset, end_pos - start_pos + 1] = replacement
+ offset += replacement.length - (end_pos - start_pos + 1)
end
+
+ result
else
data
end
end
+ def find_interpolations(data)
+ results = []
+ i = 0
+ while i < data.length - 1
+ if data[i] == '%' && data[i + 1] == '{'
+ start_pos = i
+ inner_start = i + 2
+ j = inner_start
+ in_single_quote = false
+ in_func_args = false
+ first_close_brace = nil
+
+ while j < data.length
+ ch = data[j]
+
+ # Remember first } position for fallback
+ first_close_brace ||= j if ch == '}'
+
+ # Track if we're inside function arguments (after opening paren)
+ if ch == '(' && !in_single_quote
+ in_func_args = true
+ elsif ch == ')' && !in_single_quote
+ in_func_args = false
+ end
+
+ # Only track single quotes inside function arguments
+ # This allows patterns like '/\d{3,5}/' while preserving
+ # error detection for malformed keys like %{'the.key"}
+ if in_func_args
+ if in_single_quote
+ in_single_quote = false if ch == "'"
+ elsif ch == "'"
+ in_single_quote = true
+ end
+ end
+
+ if ch == '}' && !in_single_quote
+ inner = data[inner_start...j]
+ results << [start_pos, j, inner]
+ i = j
+ break
+ end
+ j += 1
+ end
+
+ # If we reached end of string with unclosed quote, fall back to first }
+ # This allows downstream validation to catch the quote mismatch error
+ if j >= data.length && in_single_quote && first_close_brace
+ inner = data[inner_start...first_close_brace]
+ results << [start_pos, first_close_brace, inner]
+ i = first_close_brace
+ end
+ end
+ i += 1
+ end
+ results
+ end
+ private :find_interpolations
+
def do_interpolation(data, scope, extra_data, context)
- if data.is_a?(String) && (match = data.match(RX_INTERPOLATION))
- interpolation_variable = match[1]
+ # Extract interpolation variable - try smart parser first, fall back to regex
+ interpolation_variable = nil
+ if data.is_a?(String)
+ if data.start_with?('%{') && data.end_with?('}')
+ # Already extracted by find_interpolations
+ interpolation_variable = data[2...-1]
+ elsif (match = data.match(RX_INTERPOLATION))
+ interpolation_variable = match[1]
+ end
+ end
+ if interpolation_variable
# HI-494
return ['', nil] if EMPTY_INTERPOLATIONS[interpolation_variable.strip]
@@ -87,6 +171,7 @@ def get_interpolation_method_and_key(interpolation_variable, context)
method_sym = INTERPOLATION_METHODS[method]
raise Hiera::InterpolationInvalidValue, "Invalid interpolation method '#{method}'" unless method_sym
arg = match[2]
+ return [method_sym, arg] if %w[substring match grep_captures version_gt version_gte version_lt version_lte].include?(method)
match_data = arg.match(Hiera::QUOTED_KEY)
raise Hiera::InterpolationInvalidValue, "Argument to interpolation method '#{method}' must be quoted, got '#{arg}'" unless match_data
[method_sym, match_data[1] || match_data[2]]
@@ -113,6 +198,365 @@ def literal_interpolate(data, key, scope, extra_data, context)
end
private :literal_interpolate
+ def substring_interpolate(data, key, scope, extra_data, context)
+ args = parse_substring_args(key, data)
+ value = resolve_substring_value(args[0], scope, extra_data, data)
+ start = resolve_substring_integer(args[1], scope, extra_data, data)
+
+ if args.length == 3
+ count = resolve_substring_integer(args[2], scope, extra_data, data)
+ result = value[start, count]
+ else
+ result = value[start..-1]
+ end
+ result.nil? ? '' : result
+ end
+ private :substring_interpolate
+
+ def match_interpolate(data, key, scope, extra_data, context)
+ args = parse_match_args(key, data)
+ value = resolve_substring_value(args[0], scope, extra_data, data)
+ pattern = args[1]
+
+ regex = parse_regex_literal(pattern, data)
+ matched = if regex
+ !(value.match(regex)).nil?
+ else
+ needle = resolve_substring_value(pattern, scope, extra_data, data)
+ value.include?(needle)
+ end
+
+ matched ? 'true' : 'false'
+ end
+ private :match_interpolate
+
+ def grep_captures_interpolate(data, key, scope, extra_data, context)
+ args = parse_grep_captures_args(key, data)
+ value = resolve_substring_value(args[0], scope, extra_data, data)
+ pattern = args[1]
+
+ regex = parse_regex_literal(pattern, data)
+ raise Hiera::InterpolationInvalidValue, "grep_captures requires a regex pattern in interpolation expression: #{data}" unless regex
+
+ match_result = value.match(regex)
+ return '' if match_result.nil?
+
+ # Determine separator and capture group indices
+ # Default: empty separator, all capture groups
+ separator = ''
+ group_indices = (1..match_result.length - 1).to_a
+
+ if args.length >= 3
+ third_arg = args[2].strip
+ if third_arg.start_with?('[')
+ # Third arg is array literal - use default separator
+ group_indices = parse_array_literal(args[2], scope, extra_data, data)
+ else
+ # Third arg is separator (quoted string)
+ separator = resolve_substring_value(args[2], scope, extra_data, data)
+ if args.length >= 4
+ # Fourth arg is array literal of capture group indices: [1,2,4]
+ group_indices = parse_array_literal(args[3], scope, extra_data, data)
+ end
+ end
+ end
+
+ # Extract specified capture groups
+ captures = group_indices.map do |idx|
+ match_result[idx].to_s
+ end
+
+ captures.join(separator)
+ end
+ private :grep_captures_interpolate
+
+ def parse_grep_captures_args(arg_string, data)
+ args = split_function_args(arg_string)
+ unless args.length >= 2
+ raise Hiera::InterpolationInvalidValue, "grep_captures requires at least 2 arguments (string, regex) in interpolation expression: #{data}"
+ end
+ if args[0].empty? || args[1].empty?
+ raise Hiera::InterpolationInvalidValue, "grep_captures contains an empty required argument in interpolation expression: #{data}"
+ end
+ args
+ end
+ private :parse_grep_captures_args
+
+ def version_gt_interpolate(data, key, scope, extra_data, context)
+ versioncmp_interpolate(data, key, scope, extra_data, :>)
+ end
+ private :version_gt_interpolate
+
+ def version_gte_interpolate(data, key, scope, extra_data, context)
+ versioncmp_interpolate(data, key, scope, extra_data, :>=)
+ end
+ private :version_gte_interpolate
+
+ def version_lt_interpolate(data, key, scope, extra_data, context)
+ versioncmp_interpolate(data, key, scope, extra_data, :<)
+ end
+ private :version_lt_interpolate
+
+ def version_lte_interpolate(data, key, scope, extra_data, context)
+ versioncmp_interpolate(data, key, scope, extra_data, :<=)
+ end
+ private :version_lte_interpolate
+
+ def versioncmp_interpolate(data, key, scope, extra_data, operator)
+ args = parse_versioncmp_args(key, data)
+ version_a = resolve_substring_value(args[0], scope, extra_data, data)
+ version_b = resolve_substring_value(args[1], scope, extra_data, data)
+
+ result = compare_versions(version_a, version_b, operator)
+ result ? 'true' : 'false'
+ end
+ private :versioncmp_interpolate
+
+ def parse_versioncmp_args(arg_string, data)
+ args = split_function_args(arg_string)
+ unless args.length == 2
+ raise Hiera::InterpolationInvalidValue, "version comparison requires 2 arguments in interpolation expression: #{data}"
+ end
+ if args.any? { |a| a.empty? }
+ raise Hiera::InterpolationInvalidValue, "version comparison contains an empty argument in interpolation expression: #{data}"
+ end
+ args
+ end
+ private :parse_versioncmp_args
+
+ def compare_versions(version_a, version_b, operator)
+ # Use Gem::Version for proper semantic version comparison
+ # It handles versions like "1.2.3", "1.2.3-beta", "1.2.3.4", etc.
+ begin
+ gem_a = Gem::Version.new(version_a)
+ gem_b = Gem::Version.new(version_b)
+ gem_a.send(operator, gem_b)
+ rescue ArgumentError
+ # Fall back to string comparison if versions are malformed
+ version_a.send(operator, version_b)
+ end
+ end
+ private :compare_versions
+
+ def parse_array_literal(token, scope, extra_data, data)
+ value = token.to_s.strip
+ unless value.start_with?('[') && value.end_with?(']')
+ raise Hiera::InterpolationInvalidValue, "grep_captures capture groups must be an array literal like [1,2,3] in interpolation expression: #{data}"
+ end
+
+ inner = value[1...-1]
+ return [1] if inner.strip.empty?
+
+ inner.split(',').map do |item|
+ resolve_substring_integer(item.strip, scope, extra_data, data)
+ end
+ end
+ private :parse_array_literal
+
+ def parse_match_args(arg_string, data)
+ args = split_function_args(arg_string)
+ unless args.length == 2
+ raise Hiera::InterpolationInvalidValue, "match requires 2 arguments in interpolation expression: #{data}"
+ end
+ if args.any? { |a| a.empty? }
+ raise Hiera::InterpolationInvalidValue, "match contains an empty argument in interpolation expression: #{data}"
+ end
+ args
+ end
+ private :parse_match_args
+
+ def parse_substring_args(arg_string, data)
+ args = split_function_args(arg_string)
+ unless args.length == 2 || args.length == 3
+ raise Hiera::InterpolationInvalidValue, "substring requires 2 or 3 arguments in interpolation expression: #{data}"
+ end
+ if args.any? { |a| a.empty? }
+ raise Hiera::InterpolationInvalidValue, "substring contains an empty argument in interpolation expression: #{data}"
+ end
+ args
+ end
+ private :parse_substring_args
+
+ def split_function_args(arg_string)
+ input = arg_string.to_s
+ args = []
+ current = ''
+ in_single_quote = false
+ in_double_quote = false
+ in_regex = false
+ in_array = false
+ in_curly = 0
+ escaped = false
+ regex_escaped = false
+
+ input.each_char do |ch|
+ if escaped
+ current << ch
+ escaped = false
+ next
+ end
+
+ if ch == '\\'
+ current << ch
+ escaped = true
+ next
+ end
+
+ if in_single_quote
+ current << ch
+ in_single_quote = false if ch == "'"
+ next
+ end
+
+ if in_double_quote
+ current << ch
+ in_double_quote = false if ch == '"'
+ next
+ end
+
+ if in_regex
+ current << ch
+ if ch == '/' && !regex_escaped
+ in_regex = false
+ end
+ regex_escaped = (ch == '\\' && !regex_escaped)
+ next
+ end
+
+ if in_array
+ current << ch
+ in_array = false if ch == ']'
+ next
+ end
+
+ if in_curly > 0
+ current << ch
+ in_curly += 1 if ch == '{'
+ in_curly -= 1 if ch == '}'
+ next
+ end
+
+ case ch
+ when "'"
+ current << ch
+ in_single_quote = true
+ when '"'
+ current << ch
+ in_double_quote = true
+ when '/'
+ current << ch
+ in_regex = current.strip == '/'
+ when '['
+ current << ch
+ in_array = true
+ when '{'
+ current << ch
+ in_curly = 1
+ when ','
+ args << current.strip
+ current = ''
+ else
+ current << ch
+ end
+ end
+
+ args << current.strip
+ args
+ end
+ private :split_function_args
+
+ def find_closing_regex_delimiter(value)
+ escaped = false
+ (1...value.length).each do |i|
+ ch = value[i]
+ if escaped
+ escaped = false
+ next
+ end
+ if ch == '\\'
+ escaped = true
+ next
+ end
+ return i if ch == '/'
+ end
+ nil
+ end
+ private :find_closing_regex_delimiter
+
+ def parse_regex_literal(token, data)
+ value = token.to_s.strip
+
+ # Support quoted regex: '/pattern/' or "/pattern/"
+ if (value.start_with?("'") && value.end_with?("'")) ||
+ (value.start_with?('"') && value.end_with?('"'))
+ inner = value[1...-1].strip
+ return nil unless inner.start_with?('/')
+ value = inner
+ end
+
+ return nil unless value.start_with?('/')
+
+ closing = find_closing_regex_delimiter(value)
+ return nil if closing.nil? || closing == 0
+
+ pattern = value[1...closing]
+ flags = value[(closing + 1)..] || ''
+
+ options = 0
+ flags.each_char do |flag|
+ case flag
+ when 'i'
+ options |= Regexp::IGNORECASE
+ when 'm'
+ options |= Regexp::MULTILINE
+ when 'x'
+ options |= Regexp::EXTENDED
+ else
+ raise Hiera::InterpolationInvalidValue, "Invalid regex option '#{flag}' in interpolation expression: #{data}"
+ end
+ end
+
+ Regexp.new(pattern, options)
+ rescue RegexpError
+ raise Hiera::InterpolationInvalidValue, "Invalid regex '#{token}' in interpolation expression: #{data}"
+ end
+ private :parse_regex_literal
+
+ def resolve_substring_value(token, scope, extra_data, data)
+ match_data = token.match(Hiera::QUOTED_KEY)
+ return (match_data[1] || match_data[2]).to_s if match_data
+
+ # Handle empty quoted strings which QUOTED_KEY doesn't match
+ return '' if token == "''" || token == '""'
+
+ name = token.sub(/^\$/, '')
+ segments = Hiera::Util.split_key(name) { |problem| Hiera::InterpolationInvalidValue.new("#{problem} in interpolation expression: #{data}") }
+ value = catch(:no_such_key) { Hiera::Backend.qualified_lookup(segments, scope, name) }
+ return value.to_s unless value.nil?
+
+ value = catch(:no_such_key) { Hiera::Backend.qualified_lookup(segments, extra_data, name) }
+ value.to_s
+ end
+ private :resolve_substring_value
+
+ def resolve_substring_integer(token, scope, extra_data, data)
+ match_data = token.match(Hiera::QUOTED_KEY)
+ token = (match_data[1] || match_data[2]) if match_data
+ token = token.to_s
+
+ if token =~ /^\$/
+ token = token.sub(/^\$/, '')
+ segments = Hiera::Util.split_key(token) { |problem| Hiera::InterpolationInvalidValue.new("#{problem} in interpolation expression: #{data}") }
+ token = catch(:no_such_key) { return Integer(Hiera::Backend.qualified_lookup(segments, scope, token).to_s) }
+ token = catch(:no_such_key) { Hiera::Backend.qualified_lookup(segments, extra_data, token) }
+ end
+
+ Integer(token.to_s)
+ rescue ArgumentError
+ raise Hiera::InterpolationInvalidValue, "substring requires integer arguments in interpolation expression: #{data}"
+ end
+ private :resolve_substring_integer
+
def alias_interpolate(data, key, scope, extra_data, context)
Hiera::Backend.lookup(key, nil, scope, context[:order_override], :priority, context)
end
diff --git a/spec/unit/fixtures/interpolate/data/weird_keys.yaml b/spec/unit/fixtures/interpolate/data/weird_keys.yaml
index 93af5facb..bca9abfde 100644
--- a/spec/unit/fixtures/interpolate/data/weird_keys.yaml
+++ b/spec/unit/fixtures/interpolate/data/weird_keys.yaml
@@ -10,3 +10,47 @@ angry: '%{alias("#@!&%|§")}'
'#@!&%|§': 'not happy at all'
very_angry: '%{alias("!$\%!.#@!&%|§")}'
+
+substring_literal: "%{substring('abcdef', 2, 3)}"
+substring_literal_to_end: "%{substring('abcdef', 2)}"
+substring_scope: "%{substring($str, $start, $count)}"
+
+match_regex_true: "%{match('abcdef', /cde/)}"
+match_regex_false: "%{match('abcdef', /xyz/)}"
+match_regex_parens: "%{match('abcdef', /(c(d)e)/)}"
+match_regex_flags: "%{match('ABCDEF', /cde/i)}"
+match_regex_escaped_slash: "%{match('a/b/c', /a\\/b/)}"
+match_string_true: "%{match('abcdef', 'cde')}"
+match_string_false: "%{match('abcdef', 'xyz')}"
+
+grep_captures_simple: "%{grep_captures('abc-123-def', /([a-z]+)-(\\d+)-([a-z]+)/, '-')}"
+grep_captures_with_sep: "%{grep_captures('abc-123-def', /([a-z]+)-(\\d+)-([a-z]+)/, '_')}"
+grep_captures_with_groups: "%{grep_captures('abc-123-def', /([a-z]+)-(\\d+)-([a-z]+)/, '_', [1,3])}"
+grep_captures_all_groups: "%{grep_captures('abc-123-def', /([a-z]+)-(\\d+)-([a-z]+)/, '-', [1,2,3])}"
+grep_captures_no_match: "%{grep_captures('xyz', /([a-z]+)-(\\d+)/)}"
+grep_captures_version: "%{grep_captures('12345-widget-org-prod-99', /(\\d+)-(\\w+)-(\\w+)-(\\w+)-(\\d+)/, '_', [1,2,4])}"
+grep_captures_empty_sep: "%{grep_captures('abc-123-def', /([a-z]+)-(\\d+)-([a-z]+)/, '', [1,2])}"
+grep_captures_quoted_regex: '%{grep_captures(''abc-123-def'', ''/([a-z]+)-(\d+)-([a-z]+)/'', ''_'', [1,2])}'
+match_quoted_regex: '%{match(''abc123'', ''/\d+/'')}'
+grep_captures_curly_quantifier: '%{grep_captures(''12345-widget'', ''/(\d{3,5})-(\w+)/'', ''_'', [1,2])}'
+
+version_gt_true: "%{version_gt('2.0.0', '1.0.0')}"
+version_gt_false: "%{version_gt('1.0.0', '2.0.0')}"
+version_gt_equal: "%{version_gt('1.0.0', '1.0.0')}"
+version_gte_true: "%{version_gte('2.0.0', '1.0.0')}"
+version_gte_equal: "%{version_gte('1.0.0', '1.0.0')}"
+version_gte_false: "%{version_gte('1.0.0', '2.0.0')}"
+version_lt_true: "%{version_lt('1.0.0', '2.0.0')}"
+version_lt_false: "%{version_lt('2.0.0', '1.0.0')}"
+version_lt_equal: "%{version_lt('1.0.0', '1.0.0')}"
+version_lte_true: "%{version_lte('1.0.0', '2.0.0')}"
+version_lte_equal: "%{version_lte('1.0.0', '1.0.0')}"
+version_lte_false: "%{version_lte('2.0.0', '1.0.0')}"
+version_semver: "%{version_gte('1.10.0', '1.9.0')}"
+version_prerelease: "%{version_lt('1.0.0.alpha', '1.0.0')}"
+
+grep_captures_5groups_selected: '%{grep_captures(''abcd 1234 ABCD !@#$ 4567'', ''/^([a-z]+).*?(\d{3}).*(ABC).* !(.{3}) (\d{3})/'', [1,2,4])}'
+grep_captures_5groups_underscore: '%{grep_captures(''abcd 1234 ABCD !@#$ 4567'', ''/^([a-z]+).*?(\d{3}).*(ABC).* !(.{3}) (\d{3})/'', ''_'', [1,2,4])}'
+grep_captures_5groups_empty: '%{grep_captures(''abcd 1234 ABCD !@#$ 4567'', ''/^([a-z]+).*?(\d{3}).*(ABC).* !(.{3}) (\d{3})/'', '''', [1,2,4])}'
+grep_captures_5groups_all: '%{grep_captures(''abcd 1234 ABCD !@#$ 4567'', ''/^([a-z]+).*?(\d{3}).*(ABC).* !(.{3}) (\d{3})/'')}'
+grep_captures_5groups_sep_only: '%{grep_captures(''abcd 1234 ABCD !@#$ 4567'', ''/^([a-z]+).*?(\d{3}).*(ABC).* !(.{3}) (\d{3})/'', ''-'')}'
diff --git a/spec/unit/interpolate_spec.rb b/spec/unit/interpolate_spec.rb
index 7548d219e..21463dc89 100644
--- a/spec/unit/interpolate_spec.rb
+++ b/spec/unit/interpolate_spec.rb
@@ -31,6 +31,153 @@
it 'allows keys with non alphanumeric characters' do
expect(hiera.lookup('angry', nil, {})).to eq('not happy')
end
+
+ it 'supports substring interpolation with literal arguments' do
+ expect(hiera.lookup('substring_literal', nil, {})).to eq('cde')
+ end
+
+ it 'supports substring interpolation to end of string' do
+ expect(hiera.lookup('substring_literal_to_end', nil, {})).to eq('cdef')
+ end
+
+ it 'supports substring interpolation with scope arguments' do
+ expect(hiera.lookup('substring_scope', nil, {'str' => 'abcdef', 'start' => 1, 'count' => 2})).to eq('bc')
+ end
+
+ it 'supports match interpolation with regex literals' do
+ expect(hiera.lookup('match_regex_true', nil, {})).to eq('true')
+ expect(hiera.lookup('match_regex_false', nil, {})).to eq('false')
+ end
+
+ it 'supports match interpolation with regex parentheses and flags' do
+ expect(hiera.lookup('match_regex_parens', nil, {})).to eq('true')
+ expect(hiera.lookup('match_regex_flags', nil, {})).to eq('true')
+ end
+
+ it 'supports match interpolation with escaped slash in regex' do
+ expect(hiera.lookup('match_regex_escaped_slash', nil, {})).to eq('true')
+ end
+
+ it 'supports match interpolation with plain string matching' do
+ expect(hiera.lookup('match_string_true', nil, {})).to eq('true')
+ expect(hiera.lookup('match_string_false', nil, {})).to eq('false')
+ end
+
+ it 'supports grep_captures interpolation with explicit separator' do
+ expect(hiera.lookup('grep_captures_simple', nil, {})).to eq('abc-123-def')
+ end
+
+ it 'supports grep_captures interpolation with custom separator (all groups)' do
+ expect(hiera.lookup('grep_captures_with_sep', nil, {})).to eq('abc_123_def')
+ end
+
+ it 'supports grep_captures interpolation with specific capture groups' do
+ expect(hiera.lookup('grep_captures_with_groups', nil, {})).to eq('abc_def')
+ end
+
+ it 'supports grep_captures interpolation with all capture groups' do
+ expect(hiera.lookup('grep_captures_all_groups', nil, {})).to eq('abc-123-def')
+ end
+
+ it 'returns empty string when grep_captures does not match' do
+ expect(hiera.lookup('grep_captures_no_match', nil, {})).to eq('')
+ end
+
+ it 'supports grep_captures with complex version-like pattern' do
+ expect(hiera.lookup('grep_captures_version', nil, {})).to eq('12345_widget_prod')
+ end
+
+ it 'supports grep_captures with empty separator to merge captures directly' do
+ expect(hiera.lookup('grep_captures_empty_sep', nil, {})).to eq('abc123')
+ end
+
+ it 'supports grep_captures with quoted regex pattern' do
+ expect(hiera.lookup('grep_captures_quoted_regex', nil, {})).to eq('abc_123')
+ end
+
+ it 'supports match with quoted regex pattern' do
+ expect(hiera.lookup('match_quoted_regex', nil, {})).to eq('true')
+ end
+
+ it 'supports grep_captures with curly brace quantifier in quoted regex' do
+ expect(hiera.lookup('grep_captures_curly_quantifier', nil, {})).to eq('12345_widget')
+ end
+
+ it 'supports version_gt returning true when first version is greater' do
+ expect(hiera.lookup('version_gt_true', nil, {})).to eq('true')
+ end
+
+ it 'supports version_gt returning false when first version is less' do
+ expect(hiera.lookup('version_gt_false', nil, {})).to eq('false')
+ end
+
+ it 'supports version_gt returning false when versions are equal' do
+ expect(hiera.lookup('version_gt_equal', nil, {})).to eq('false')
+ end
+
+ it 'supports version_gte returning true when first version is greater' do
+ expect(hiera.lookup('version_gte_true', nil, {})).to eq('true')
+ end
+
+ it 'supports version_gte returning true when versions are equal' do
+ expect(hiera.lookup('version_gte_equal', nil, {})).to eq('true')
+ end
+
+ it 'supports version_gte returning false when first version is less' do
+ expect(hiera.lookup('version_gte_false', nil, {})).to eq('false')
+ end
+
+ it 'supports version_lt returning true when first version is less' do
+ expect(hiera.lookup('version_lt_true', nil, {})).to eq('true')
+ end
+
+ it 'supports version_lt returning false when first version is greater' do
+ expect(hiera.lookup('version_lt_false', nil, {})).to eq('false')
+ end
+
+ it 'supports version_lt returning false when versions are equal' do
+ expect(hiera.lookup('version_lt_equal', nil, {})).to eq('false')
+ end
+
+ it 'supports version_lte returning true when first version is less' do
+ expect(hiera.lookup('version_lte_true', nil, {})).to eq('true')
+ end
+
+ it 'supports version_lte returning true when versions are equal' do
+ expect(hiera.lookup('version_lte_equal', nil, {})).to eq('true')
+ end
+
+ it 'supports version_lte returning false when first version is greater' do
+ expect(hiera.lookup('version_lte_false', nil, {})).to eq('false')
+ end
+
+ it 'handles semantic versioning correctly (1.10.0 > 1.9.0)' do
+ expect(hiera.lookup('version_semver', nil, {})).to eq('true')
+ end
+
+ it 'handles prerelease versions correctly (1.0.0.alpha < 1.0.0)' do
+ expect(hiera.lookup('version_prerelease', nil, {})).to eq('true')
+ end
+
+ it 'supports grep_captures with 5 groups and selected groups (no separator)' do
+ expect(hiera.lookup('grep_captures_5groups_selected', nil, {})).to eq('abcd123@#$')
+ end
+
+ it 'supports grep_captures with 5 groups and underscore separator' do
+ expect(hiera.lookup('grep_captures_5groups_underscore', nil, {})).to eq('abcd_123_@#$')
+ end
+
+ it 'supports grep_captures with 5 groups and explicit empty separator' do
+ expect(hiera.lookup('grep_captures_5groups_empty', nil, {})).to eq('abcd123@#$')
+ end
+
+ it 'supports grep_captures with 5 groups returning all groups concatenated' do
+ expect(hiera.lookup('grep_captures_5groups_all', nil, {})).to eq('abcd123ABC@#$456')
+ end
+
+ it 'supports grep_captures with 5 groups and separator only (all groups)' do
+ expect(hiera.lookup('grep_captures_5groups_sep_only', nil, {})).to eq('abcd-123-ABC-@#$-456')
+ end
end
context "when not finding value for interpolated key" do