configorama 0.6.18 → 0.6.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "configorama",
3
- "version": "0.6.18",
3
+ "version": "0.6.19",
4
4
  "description": "Variable support for configuration files",
5
5
  "main": "src/index.js",
6
6
  "types": "index.d.ts",
package/src/main.js CHANGED
@@ -67,7 +67,7 @@ const { warnIfNotFound, isValidValue } = require('./utils/validation/warnIfNotFo
67
67
  /* Utils - variables */
68
68
  const cleanVariable = require('./utils/variables/cleanVariable')
69
69
  const appendDeepVariable = require('./utils/variables/appendDeepVariable')
70
- const { getFallbackString, verifyVariable } = require('./utils/variables/variableUtils')
70
+ const { extractVariableWrapper, getFallbackString, verifyVariable } = require('./utils/variables/variableUtils')
71
71
  const { findNestedVariables } = require('./utils/variables/findNestedVariables')
72
72
 
73
73
  /* Resolvers */
@@ -174,6 +174,15 @@ class Configorama {
174
174
  const variableSyntax = varRegex
175
175
  this.variableSyntax = variableSyntax
176
176
 
177
+ // Extract variable prefix/suffix from syntax regex for reconstructing variables
178
+ const syntaxWrapper = extractVariableWrapper(variableSyntax.source)
179
+ this.varPrefix = syntaxWrapper.prefix
180
+ this.varSuffix = syntaxWrapper.suffix
181
+ const escapedSuffix = this.varSuffix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
182
+ this.varPrefixPattern = new RegExp('^' + this.varPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
183
+ this.varSuffixPattern = new RegExp(escapedSuffix + '$')
184
+ this.varSuffixWithSpacePattern = new RegExp('\\s+' + escapedSuffix + '$')
185
+
177
186
  // Set initial config object to populate
178
187
  if (typeof fileOrObject === 'object') {
179
188
  // set config objects
@@ -566,7 +575,7 @@ class Configorama {
566
575
  this.rawOriginalConfig = cloneDeep(configObject)
567
576
 
568
577
  /* Preprocess step here - escapes ${} in help() args, fixes malformed fallbacks */
569
- configObject = preProcess(configObject, this.variableSyntax)
578
+ configObject = preProcess(configObject, this.variableSyntax, this.variableTypes)
570
579
  /*
571
580
  console.log('after preprocess', configObject)
572
581
  /** */
@@ -654,7 +663,7 @@ class Configorama {
654
663
  const fileName = this.configFilePath ? ` in ${this.configFilePath}` : ''
655
664
 
656
665
  // Extract base variable name from varMatch key (e.g., '${env:FOO, default}' -> 'env:FOO')
657
- const getBaseVarName = (key) => key.replace(/^\$\{/, '').replace(/\}$/, '').split(',')[0].trim()
666
+ const getBaseVarName = (key) => key.replace(this.varPrefixPattern, '').replace(this.varSuffixPattern, '').split(',')[0].trim()
658
667
 
659
668
  logHeader(`Found ${varKeys.length} Variables${fileName}`)
660
669
 
@@ -1309,9 +1318,9 @@ class Configorama {
1309
1318
  .map((filter) => filter.trim())
1310
1319
  .filter(Boolean)
1311
1320
 
1312
- // Remove filters from the key (replace "| String}" with "}")
1321
+ // Remove filters from the key (replace "| String}" with suffix)
1313
1322
  // Also clean up any trailing whitespace before the closing brace
1314
- keyWithoutFilters = originalSrc.replace(filterMatch, '}').replace(/\s+}$/, '}')
1323
+ keyWithoutFilters = originalSrc.replace(filterMatch, this.varSuffix).replace(this.varSuffixWithSpacePattern, this.varSuffix)
1315
1324
  }
1316
1325
 
1317
1326
  const key = keyWithoutFilters
@@ -1337,7 +1346,7 @@ class Configorama {
1337
1346
  if (cleaned.varMatch && filterMatch) {
1338
1347
  const match = cleaned.varMatch.match(filterMatch)
1339
1348
  if (match) {
1340
- cleaned.varMatch = cleaned.varMatch.replace(filterMatch, '').replace(/\s+$/, '') + '}'
1349
+ cleaned.varMatch = cleaned.varMatch.replace(filterMatch, '').replace(/\s+$/, '') + this.varSuffix
1341
1350
  }
1342
1351
  }
1343
1352
  if (cleaned.variable && filterMatch) {
@@ -2202,7 +2211,7 @@ class Configorama {
2202
2211
  let foundFilters = []
2203
2212
  if (hasFilters) {
2204
2213
  foundFilters = hasFilters[0]
2205
- .replace(/}$/, '') // remove trailing }
2214
+ .replace(this.varSuffixPattern, '')
2206
2215
  .split('|')
2207
2216
  .map((filter) => filter.trim())
2208
2217
  .filter(Boolean)
@@ -2287,13 +2296,13 @@ class Configorama {
2287
2296
  console.log('isString currentMatchedString', currentMatchedString)
2288
2297
  console.log('>------')
2289
2298
  /** */
2290
- // Handle comma ${opt:stage, dev} and remove extra }
2299
+ // Handle comma ${opt:stage, dev} and remove extra suffix
2291
2300
  if (
2292
2301
  currentMatchedString.match(this.variableSyntax) &&
2293
2302
  !valueToPopulate.match(this.variableSyntax) &&
2294
- valueToPopulate.match(/}$/)
2303
+ valueToPopulate.match(this.varSuffixPattern)
2295
2304
  ) {
2296
- valueToPopulate = valueToPopulate.replace(/}$/, '')
2305
+ valueToPopulate = valueToPopulate.replace(this.varSuffixPattern, '')
2297
2306
  }
2298
2307
 
2299
2308
  property = replaceAll(currentMatchedString, valueToPopulate, property)
@@ -2340,7 +2349,7 @@ class Configorama {
2340
2349
  let missingValue = matchedString
2341
2350
 
2342
2351
  if (matchedString.match(deepRefSyntax)) {
2343
- const deepIndex = matchedString.split(':')[1].replace('}', '')
2352
+ const deepIndex = matchedString.split(':')[1].replace(this.varSuffixPattern, '')
2344
2353
  const i = Number(deepIndex)
2345
2354
  missingValue = this.deep[i]
2346
2355
  }
@@ -2372,6 +2381,39 @@ class Configorama {
2372
2381
  }
2373
2382
  }
2374
2383
 
2384
+ // If allowUnresolvedVariables and there are fallbacks, use the fallback
2385
+ if (this.opts.allowUnresolvedVariables && splitVars.length > 1) {
2386
+ const nextFallback = splitVars[1].trim()
2387
+ // Strip trailing variable suffix (handles }, }}, >, ]], etc.)
2388
+ const nextFallbackClean = nextFallback.replace(this.varSuffixPattern, '')
2389
+ const isQuotedString = /^['"].*['"]$/.test(nextFallbackClean)
2390
+ const isNumeric = /^-?\d+(\.\d+)?$/.test(nextFallbackClean)
2391
+ if (isQuotedString || isNumeric) {
2392
+ let staticValue = nextFallbackClean.replace(/^['"]|['"]$/g, '')
2393
+ // Convert to number if it's a numeric fallback
2394
+ if (isNumeric) {
2395
+ staticValue = Number(staticValue)
2396
+ }
2397
+ return {
2398
+ value: staticValue,
2399
+ path: valueObject.path,
2400
+ originalSource: valueObject.originalSource,
2401
+ resolutionHistory: valueObject.resolutionHistory || [],
2402
+ }
2403
+ }
2404
+ // Next fallback is another variable
2405
+ const remainingContent = splitVars.slice(1).join(', ').replace(this.varSuffixPattern, '')
2406
+ const remainingFallbacks = this.varPrefix + remainingContent + this.varSuffix
2407
+ return {
2408
+ value: remainingFallbacks,
2409
+ path: valueObject.path,
2410
+ originalSource: valueObject.originalSource,
2411
+ resolutionHistory: valueObject.resolutionHistory || [],
2412
+ __internal_only_flag: true,
2413
+ caller: 'allowUnresolvedVariables-fallback',
2414
+ }
2415
+ }
2416
+
2375
2417
  const currentPath = valueObject.path.join('.')
2376
2418
 
2377
2419
  const errorMessage = `
@@ -2394,7 +2436,7 @@ Missing Value ${missingValue} - ${matchedString}
2394
2436
  )
2395
2437
 
2396
2438
  // Double processing needed for `${eval(${self:three} > ${self:four})}`
2397
- if (prop.startsWith('${')) {
2439
+ if (prop.startsWith(this.varPrefix)) {
2398
2440
  prop = cleanVariable(prop, this.variableSyntax, true, `populateVariable string ${this.callCount}`)
2399
2441
  }
2400
2442
 
@@ -2595,7 +2637,7 @@ Missing Value ${missingValue} - ${matchedString}
2595
2637
 
2596
2638
  if (deepProperties > 0) {
2597
2639
  // Reconstruct a minimal variable string with deep refs, not the full outer string
2598
- const reconstructed = '${' + deepVariableParts.join(', ') + '}'
2640
+ const reconstructed = this.varPrefix + deepVariableParts.join(', ') + this.varSuffix
2599
2641
  return Promise.resolve(reconstructed)
2600
2642
  }
2601
2643
  return Promise.resolve(extractedValues.find(isValidValue)) // resolve first valid value, else undefined
@@ -2694,7 +2736,7 @@ Missing Value ${missingValue} - ${matchedString}
2694
2736
  .map((f) => {
2695
2737
  return trim(f)
2696
2738
  // TODO refactor this. This is a temp fix for filters with nested vars.
2697
- .replace(/}$/, '')
2739
+ .replace(this.varSuffixPattern, '')
2698
2740
  })
2699
2741
  // console.log('filters to run', _filter)
2700
2742
 
@@ -2809,6 +2851,16 @@ Missing Value ${missingValue} - ${matchedString}
2809
2851
  }
2810
2852
 
2811
2853
  if (this.opts.allowUnresolvedVariables) {
2854
+ // Check if outer expression has fallbacks we can use
2855
+ // valueCount[0] is the primary var, valueCount[1+] are fallbacks
2856
+ if (valueCount.length > 1) {
2857
+ const primaryVar = valueCount[0]
2858
+ // If the unresolvable variableString is used INSIDE the primary var,
2859
+ // return undefined to trigger the outer fallback mechanism
2860
+ if (primaryVar.includes(variableString)) {
2861
+ return Promise.resolve(undefined)
2862
+ }
2863
+ }
2812
2864
  // Encode unresolved variable to pass through resolution
2813
2865
  return Promise.resolve(encodeUnknown(propertyString))
2814
2866
  }
@@ -2885,7 +2937,7 @@ Missing Value ${missingValue} - ${matchedString}
2885
2937
  if (typeof val === 'string' && val.match(/deep:/)) {
2886
2938
  // TODO refactor the deep filter logic here. match | filter | filter..
2887
2939
  const allFilters = propertyString
2888
- .replace(/}$/, '')
2940
+ .replace(this.varSuffixPattern, '')
2889
2941
  .split('|')
2890
2942
  .reduce((acc, currentFilter, i) => {
2891
2943
  if (i === 0) {
@@ -2895,7 +2947,7 @@ Missing Value ${missingValue} - ${matchedString}
2895
2947
  return acc
2896
2948
  }, '')
2897
2949
  // add filters to deep references if filter is used
2898
- const deepValueWithFilters = newHasFilter[1] ? val.replace(/}$/, ` ${allFilters}}`) : val
2950
+ const deepValueWithFilters = newHasFilter[1] ? val.replace(this.varSuffixPattern, ` ${allFilters}${this.varSuffix}`) : val
2899
2951
  // console.log('deepValueWithFilters', deepValueWithFilters)
2900
2952
  // console.log('RESOLVER RETURN newValue 4', deepValueWithFilters)
2901
2953
  return Promise.resolve(deepValueWithFilters)
@@ -3,16 +3,27 @@
3
3
  * and escape variables inside help() filter arguments
4
4
  */
5
5
  const { splitByComma } = require('../strings/splitByComma')
6
+ const { extractVariableWrapper } = require('../variables/variableUtils')
6
7
 
7
8
  /**
8
9
  * Preprocess config to fix malformed fallback references
9
10
  * @param {Object} configObject - The parsed configuration object
10
11
  * @param {RegExp} variableSyntax - The variable syntax regex to use
12
+ * @param {Array} [variableTypes] - Array of variable type definitions with type/prefix fields
11
13
  * @returns {Object} The preprocessed configuration object
12
14
  */
13
- function preProcess(configObject, variableSyntax) {
14
- // Known reference prefixes that should be wrapped in ${}
15
- const refPrefixes = ['self:', 'opt:', 'env:', 'file:', 'text:', 'deep:']
15
+ function preProcess(configObject, variableSyntax, variableTypes) {
16
+ // Extract prefix/suffix from variable syntax for reconstructing variables
17
+ const { prefix: varPrefix, suffix: varSuffix } = variableSyntax
18
+ ? extractVariableWrapper(variableSyntax.source)
19
+ : { prefix: '${', suffix: '}' }
20
+
21
+ // Extract reference prefixes from variable types, or use defaults
22
+ const refPrefixes = variableTypes && variableTypes.length > 0
23
+ ? variableTypes
24
+ .map(v => (v.prefix || v.type) + ':')
25
+ .filter(p => p !== 'dot.prop:' && p !== 'string:' && p !== 'number:')
26
+ : ['self:', 'opt:', 'env:', 'file:', 'text:', 'deep:']
16
27
 
17
28
  /**
18
29
  * Escape variables inside help() filter arguments so main resolver skips them
@@ -52,31 +63,37 @@ function preProcess(configObject, variableSyntax) {
52
63
  let changed = true
53
64
 
54
65
  // Keep iterating until no more changes (to handle nested variables)
66
+ const prefixLen = varPrefix.length
67
+ const suffixLen = varSuffix.length
68
+
55
69
  while (changed) {
56
70
  changed = false
57
71
 
58
- // Find innermost ${...} blocks (ones that don't contain other ${)
72
+ // Find innermost variable blocks (ones that don't contain other variables)
59
73
  let i = 0
60
74
  while (i < result.length) {
61
- if (result[i] === '$' && result[i + 1] === '{') {
75
+ if (result.substring(i, i + prefixLen) === varPrefix) {
62
76
  const start = i
63
- let braceCount = 1
64
- let j = i + 2
65
-
66
- // Find the matching closing brace by counting { and }
67
- while (j < result.length && braceCount > 0) {
68
- if (result[j] === '{') {
69
- braceCount++
70
- } else if (result[j] === '}') {
71
- braceCount--
77
+ let depth = 1
78
+ let j = i + prefixLen
79
+
80
+ // Find the matching suffix by counting full prefix/suffix occurrences
81
+ while (j < result.length && depth > 0) {
82
+ if (result.substring(j, j + prefixLen) === varPrefix) {
83
+ depth++
84
+ j += prefixLen
85
+ } else if (result.substring(j, j + suffixLen) === varSuffix) {
86
+ depth--
87
+ if (depth > 0) j += suffixLen
88
+ } else {
89
+ j++
72
90
  }
73
- j++
74
91
  }
75
92
 
76
- if (braceCount === 0) {
77
- const end = j
93
+ if (depth === 0) {
94
+ const end = j + suffixLen
78
95
  const match = result.substring(start, end)
79
- const content = result.substring(start + 2, end - 1)
96
+ const content = result.substring(start + prefixLen, end - suffixLen)
80
97
 
81
98
  // Only process if there's a comma (indicating fallback syntax)
82
99
  if (content.includes(',')) {
@@ -84,10 +101,10 @@ function preProcess(configObject, variableSyntax) {
84
101
  const parts = splitByComma(content, variableSyntax)
85
102
 
86
103
  if (parts.length > 1) {
87
- // Check if the first part has nested ${} - if so, skip this (process inner ones first)
104
+ // Check if the first part has nested variables - if so, skip this (process inner ones first)
88
105
  const firstPart = parts[0]
89
- if (firstPart.includes('${')) {
90
- i = start + 2 // Move past ${ to find inner variables
106
+ if (firstPart.includes(varPrefix)) {
107
+ i = start + prefixLen // Move past prefix to find inner variables
91
108
  continue
92
109
  }
93
110
 
@@ -101,16 +118,16 @@ function preProcess(configObject, variableSyntax) {
101
118
 
102
119
  // Check if this looks like a reference but is not wrapped
103
120
  const looksLikeRef = refPrefixes.some(prefix => trimmed.startsWith(prefix))
104
- const alreadyWrapped = trimmed.startsWith('${') && trimmed.endsWith('}')
121
+ const alreadyWrapped = trimmed.startsWith(varPrefix) && trimmed.endsWith(varSuffix)
105
122
 
106
123
  if (looksLikeRef && !alreadyWrapped) {
107
- return ` \${${trimmed}}`
124
+ return ` ${varPrefix}${trimmed}${varSuffix}`
108
125
  }
109
126
 
110
127
  return ` ${trimmed}`
111
128
  })
112
129
 
113
- const replacement = `\${${fixed.join(',')}}`
130
+ const replacement = `${varPrefix}${fixed.join(',')}${varSuffix}`
114
131
  if (replacement !== match) {
115
132
  result = result.substring(0, start) + replacement + result.substring(end)
116
133
  changed = true
@@ -119,7 +136,7 @@ function preProcess(configObject, variableSyntax) {
119
136
  }
120
137
  }
121
138
 
122
- i = start + 2 // Move past ${ to continue searching for nested variables
139
+ i = start + prefixLen // Move past prefix to continue searching for nested variables
123
140
  } else {
124
141
  i++
125
142
  }
@@ -0,0 +1,214 @@
1
+ // Tests for preProcess utility (fixFallbacksInString)
2
+
3
+ const { test } = require('uvu')
4
+ const assert = require('uvu/assert')
5
+ const preProcess = require('./preProcess')
6
+
7
+ // Default ${} syntax
8
+ const defaultSyntax = /\$\{([ ~:a-zA-Z0-9._\\'",\-\/\(\)]+?)\}/g
9
+
10
+ // Custom syntaxes
11
+ const doubleBraceSyntax = /\$\{\{([ ~:a-zA-Z0-9._\\'",\-\/\(\)]+?)\}\}/g
12
+ const hashSyntax = /\#\{([ ~:a-zA-Z0-9._\\'",\-\/\(\)]+?)\}/g
13
+ const angleSyntax = /\<([ ~:a-zA-Z0-9._\\'",\-\/\(\)]+?)\>/g
14
+
15
+ // Tests for fixFallbacksInString with default ${} syntax
16
+ test('fixFallbacksInString - wraps unwrapped self: fallback', () => {
17
+ const input = { key: '${opt:missing, self:fallback}' }
18
+ const result = preProcess(input, defaultSyntax)
19
+ assert.is(result.key, '${opt:missing, ${self:fallback}}')
20
+ })
21
+
22
+ test('fixFallbacksInString - wraps unwrapped env: fallback', () => {
23
+ const input = { key: '${opt:missing, env:FALLBACK}' }
24
+ const result = preProcess(input, defaultSyntax)
25
+ assert.is(result.key, '${opt:missing, ${env:FALLBACK}}')
26
+ })
27
+
28
+ test('fixFallbacksInString - wraps unwrapped opt: fallback', () => {
29
+ const input = { key: '${self:missing, opt:fallback}' }
30
+ const result = preProcess(input, defaultSyntax)
31
+ assert.is(result.key, '${self:missing, ${opt:fallback}}')
32
+ })
33
+
34
+ test('fixFallbacksInString - wraps unwrapped file: fallback', () => {
35
+ const input = { key: '${opt:missing, file:./config.json}' }
36
+ const result = preProcess(input, defaultSyntax)
37
+ assert.is(result.key, '${opt:missing, ${file:./config.json}}')
38
+ })
39
+
40
+ test('fixFallbacksInString - leaves already wrapped fallback alone', () => {
41
+ const input = { key: '${opt:missing, ${self:fallback}}' }
42
+ const result = preProcess(input, defaultSyntax)
43
+ assert.is(result.key, '${opt:missing, ${self:fallback}}')
44
+ })
45
+
46
+ test('fixFallbacksInString - leaves string fallback alone', () => {
47
+ const input = { key: '${opt:missing, "default"}' }
48
+ const result = preProcess(input, defaultSyntax)
49
+ assert.is(result.key, '${opt:missing, "default"}')
50
+ })
51
+
52
+ test('fixFallbacksInString - leaves numeric fallback alone', () => {
53
+ const input = { key: '${opt:missing, 42}' }
54
+ const result = preProcess(input, defaultSyntax)
55
+ assert.is(result.key, '${opt:missing, 42}')
56
+ })
57
+
58
+ test('fixFallbacksInString - handles multiple fallbacks', () => {
59
+ const input = { key: '${opt:missing, self:first, env:second}' }
60
+ const result = preProcess(input, defaultSyntax)
61
+ assert.is(result.key, '${opt:missing, ${self:first}, ${env:second}}')
62
+ })
63
+
64
+ test('fixFallbacksInString - handles nested variables in primary', () => {
65
+ const input = { key: '${file(./config.${opt:stage}.json), "default"}' }
66
+ const result = preProcess(input, defaultSyntax)
67
+ assert.is(result.key, '${file(./config.${opt:stage}.json), "default"}')
68
+ })
69
+
70
+ // Tests for ${{}} syntax
71
+ test('fixFallbacksInString - ${{}} syntax wraps unwrapped fallback', () => {
72
+ const input = { key: '${{opt:missing, self:fallback}}' }
73
+ const result = preProcess(input, doubleBraceSyntax)
74
+ assert.is(result.key, '${{opt:missing, ${{self:fallback}}}}')
75
+ })
76
+
77
+ test('fixFallbacksInString - ${{}} syntax leaves wrapped fallback alone', () => {
78
+ const input = { key: '${{opt:missing, ${{self:fallback}}}}' }
79
+ const result = preProcess(input, doubleBraceSyntax)
80
+ assert.is(result.key, '${{opt:missing, ${{self:fallback}}}}')
81
+ })
82
+
83
+ test('fixFallbacksInString - ${{}} syntax leaves string fallback alone', () => {
84
+ const input = { key: '${{opt:missing, "default"}}' }
85
+ const result = preProcess(input, doubleBraceSyntax)
86
+ assert.is(result.key, '${{opt:missing, "default"}}')
87
+ })
88
+
89
+ // Tests for #{} syntax
90
+ test('fixFallbacksInString - #{} syntax wraps unwrapped fallback', () => {
91
+ const input = { key: '#{opt:missing, self:fallback}' }
92
+ const result = preProcess(input, hashSyntax)
93
+ assert.is(result.key, '#{opt:missing, #{self:fallback}}')
94
+ })
95
+
96
+ test('fixFallbacksInString - #{} syntax leaves wrapped fallback alone', () => {
97
+ const input = { key: '#{opt:missing, #{self:fallback}}' }
98
+ const result = preProcess(input, hashSyntax)
99
+ assert.is(result.key, '#{opt:missing, #{self:fallback}}')
100
+ })
101
+
102
+ test('fixFallbacksInString - #{} syntax handles multiple fallbacks', () => {
103
+ const input = { key: '#{opt:missing, env:FIRST, self:second}' }
104
+ const result = preProcess(input, hashSyntax)
105
+ assert.is(result.key, '#{opt:missing, #{env:FIRST}, #{self:second}}')
106
+ })
107
+
108
+ // Tests for <> syntax
109
+ test('fixFallbacksInString - <> syntax wraps unwrapped fallback', () => {
110
+ const input = { key: '<opt:missing, self:fallback>' }
111
+ const result = preProcess(input, angleSyntax)
112
+ assert.is(result.key, '<opt:missing, <self:fallback>>')
113
+ })
114
+
115
+ test('fixFallbacksInString - <> syntax leaves wrapped fallback alone', () => {
116
+ const input = { key: '<opt:missing, <self:fallback>>' }
117
+ const result = preProcess(input, angleSyntax)
118
+ assert.is(result.key, '<opt:missing, <self:fallback>>')
119
+ })
120
+
121
+ test('fixFallbacksInString - <> syntax leaves string fallback alone', () => {
122
+ const input = { key: '<opt:missing, "default">' }
123
+ const result = preProcess(input, angleSyntax)
124
+ assert.is(result.key, '<opt:missing, "default">')
125
+ })
126
+
127
+ // Edge cases
128
+ test('fixFallbacksInString - handles deeply nested objects', () => {
129
+ const input = {
130
+ level1: {
131
+ level2: {
132
+ key: '${opt:missing, self:fallback}'
133
+ }
134
+ }
135
+ }
136
+ const result = preProcess(input, defaultSyntax)
137
+ assert.is(result.level1.level2.key, '${opt:missing, ${self:fallback}}')
138
+ })
139
+
140
+ test('fixFallbacksInString - handles arrays', () => {
141
+ const input = {
142
+ items: [
143
+ '${opt:one, self:fallback1}',
144
+ '${opt:two, self:fallback2}'
145
+ ]
146
+ }
147
+ const result = preProcess(input, defaultSyntax)
148
+ assert.is(result.items[0], '${opt:one, ${self:fallback1}}')
149
+ assert.is(result.items[1], '${opt:two, ${self:fallback2}}')
150
+ })
151
+
152
+ test('fixFallbacksInString - handles no variable syntax', () => {
153
+ const input = { key: 'just a plain string' }
154
+ const result = preProcess(input, defaultSyntax)
155
+ assert.is(result.key, 'just a plain string')
156
+ })
157
+
158
+ test('fixFallbacksInString - handles null variableSyntax', () => {
159
+ const input = { key: '${opt:missing, self:fallback}' }
160
+ const result = preProcess(input, null)
161
+ // Without syntax, should use defaults
162
+ assert.is(result.key, '${opt:missing, ${self:fallback}}')
163
+ })
164
+
165
+ // Tests with explicit variableTypes
166
+ const defaultVariableTypes = [
167
+ { type: 'env' },
168
+ { type: 'options', prefix: 'opt' },
169
+ { type: 'self', prefix: 'self' },
170
+ { type: 'file', prefix: 'file' },
171
+ { type: 'text', prefix: 'text' },
172
+ { type: 'deep' },
173
+ ]
174
+
175
+ test('fixFallbacksInString - uses variableTypes to determine prefixes', () => {
176
+ const input = { key: '${opt:missing, self:fallback}' }
177
+ const result = preProcess(input, defaultSyntax, defaultVariableTypes)
178
+ assert.is(result.key, '${opt:missing, ${self:fallback}}')
179
+ })
180
+
181
+ test('fixFallbacksInString - custom variableTypes with custom prefix', () => {
182
+ const customTypes = [
183
+ { type: 'ssm', prefix: 'ssm' },
184
+ { type: 'secret', prefix: 'secret' },
185
+ ]
186
+ const input = { key: '${opt:missing, ssm:/path/to/param}' }
187
+ const result = preProcess(input, defaultSyntax, customTypes)
188
+ assert.is(result.key, '${opt:missing, ${ssm:/path/to/param}}')
189
+ })
190
+
191
+ test('fixFallbacksInString - custom variableTypes wraps secret: fallback', () => {
192
+ const customTypes = [
193
+ { type: 'ssm', prefix: 'ssm' },
194
+ { type: 'secret', prefix: 'secret' },
195
+ { type: 'self', prefix: 'self' },
196
+ ]
197
+ const input = { key: '${self:missing, secret:API_KEY}' }
198
+ const result = preProcess(input, defaultSyntax, customTypes)
199
+ assert.is(result.key, '${self:missing, ${secret:API_KEY}}')
200
+ })
201
+
202
+ test('fixFallbacksInString - ignores dot.prop and string types in prefixes', () => {
203
+ const typesWithDotProp = [
204
+ { type: 'env' },
205
+ { type: 'dot.prop' },
206
+ { type: 'string' },
207
+ { type: 'self', prefix: 'self' },
208
+ ]
209
+ const input = { key: '${env:MISSING, self:fallback}' }
210
+ const result = preProcess(input, defaultSyntax, typesWithDotProp)
211
+ assert.is(result.key, '${env:MISSING, ${self:fallback}}')
212
+ })
213
+
214
+ test.run()
@@ -1,3 +1,45 @@
1
+ /**
2
+ * Extract variable prefix/suffix from regex source
3
+ * @param {string} syntaxSource - The regex source string (e.g., '\\$\\{(...)\\}')
4
+ * @returns {{ prefix: string, suffix: string }} The unescaped prefix and suffix
5
+ */
6
+ function extractVariableWrapper(syntaxSource) {
7
+ // Find first capturing group ( that's not escaped and not a special group (?:, (?=, etc.)
8
+ let openParen = -1
9
+ for (let i = 0; i < syntaxSource.length; i++) {
10
+ if (syntaxSource[i] === '(' && (i === 0 || syntaxSource[i - 1] !== '\\')) {
11
+ // Check if it's a special group like (?:, (?=, (?!, (?<
12
+ if (syntaxSource[i + 1] !== '?') {
13
+ openParen = i
14
+ break
15
+ }
16
+ }
17
+ }
18
+
19
+ // Find last ) that's not escaped
20
+ let closeParen = -1
21
+ for (let i = syntaxSource.length - 1; i >= 0; i--) {
22
+ if (syntaxSource[i] === ')' && (i === 0 || syntaxSource[i - 1] !== '\\')) {
23
+ closeParen = i
24
+ break
25
+ }
26
+ }
27
+
28
+ let escapedPrefix = openParen > 0 ? syntaxSource.substring(0, openParen) : ''
29
+ const escapedSuffix = closeParen >= 0 ? syntaxSource.substring(closeParen + 1) : ''
30
+
31
+ // Strip any leading non-capturing groups like (?:...) from prefix
32
+ escapedPrefix = escapedPrefix.replace(/^\(\?:[^)]*\)/g, '')
33
+
34
+ // Unescape regex escapes: \$ -> $, \{ -> {, \[ -> [, etc.
35
+ const unescape = (s) => s.replace(/\\(.)/g, '$1')
36
+
37
+ return {
38
+ prefix: unescape(escapedPrefix) || '${',
39
+ suffix: unescape(escapedSuffix) || '}'
40
+ }
41
+ }
42
+
1
43
  /**
2
44
  * Get fallback variable string
3
45
  * @param {string[]} split - Array from split at comma
@@ -47,6 +89,7 @@ Remove or update the \${${variableString}} to fix
47
89
  }
48
90
 
49
91
  module.exports = {
92
+ extractVariableWrapper,
50
93
  getFallbackString,
51
94
  verifyVariable
52
95
  }
@@ -1,6 +1,6 @@
1
1
  const { test } = require('uvu')
2
2
  const assert = require('uvu/assert')
3
- const { getFallbackString, verifyVariable } = require('./variableUtils')
3
+ const { extractVariableWrapper, getFallbackString, verifyVariable } = require('./variableUtils')
4
4
 
5
5
  // Tests for getFallbackString
6
6
  test('getFallbackString - should reconstruct variable from split array', () => {
@@ -113,5 +113,42 @@ test('verifyVariable - should match with function and config param', () => {
113
113
  assert.is(result, true)
114
114
  })
115
115
 
116
+ // Tests for extractVariableWrapper
117
+ test('extractVariableWrapper - standard ${} syntax', () => {
118
+ const result = extractVariableWrapper('\\$\\{([^}]+)\\}')
119
+ assert.equal(result.prefix, '${')
120
+ assert.equal(result.suffix, '}')
121
+ })
122
+
123
+ test('extractVariableWrapper - double brace ${{}} syntax', () => {
124
+ const result = extractVariableWrapper('\\$\\{\\{([^}]+)\\}\\}')
125
+ assert.equal(result.prefix, '${{')
126
+ assert.equal(result.suffix, '}}')
127
+ })
128
+
129
+ test('extractVariableWrapper - hash #{} syntax', () => {
130
+ const result = extractVariableWrapper('\\#\\{([^}]+)\\}')
131
+ assert.equal(result.prefix, '#{')
132
+ assert.equal(result.suffix, '}')
133
+ })
134
+
135
+ test('extractVariableWrapper - angle bracket <> syntax', () => {
136
+ const result = extractVariableWrapper('\\<([^>]+)\\>')
137
+ assert.equal(result.prefix, '<')
138
+ assert.equal(result.suffix, '>')
139
+ })
140
+
141
+ test('extractVariableWrapper - double bracket [[]] syntax', () => {
142
+ const result = extractVariableWrapper('\\[\\[([^\\]]+)\\]\\]')
143
+ assert.equal(result.prefix, '[[')
144
+ assert.equal(result.suffix, ']]')
145
+ })
146
+
147
+ test('extractVariableWrapper - strips non-capturing group prefix', () => {
148
+ const result = extractVariableWrapper('(?:prefix)\\$\\{([^}]+)\\}')
149
+ assert.equal(result.prefix, '${')
150
+ assert.equal(result.suffix, '}')
151
+ })
152
+
116
153
  // Run all tests
117
154
  test.run()