configorama 0.6.17 → 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.17",
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
 
@@ -2566,7 +2608,6 @@ Missing Value ${missingValue} - ${matchedString}
2566
2608
 
2567
2609
  // console.log('variableValues', variableValues)
2568
2610
  return Promise.all(variableValues).then((values) => {
2569
- let deepPropertyStr = propertyString
2570
2611
  let deepProperties = 0
2571
2612
  // console.log('overwrite values', valuesToUse)
2572
2613
  // Extract actual values from metadata objects
@@ -2576,6 +2617,10 @@ Missing Value ${missingValue} - ${matchedString}
2576
2617
  }
2577
2618
  return value
2578
2619
  })
2620
+
2621
+ // Build deep variable parts for reconstruction
2622
+ const deepVariableParts = variableStrings.slice()
2623
+
2579
2624
  extractedValues.forEach((value, index) => {
2580
2625
  // console.log('───────────────────────────────> value', value)
2581
2626
  if (isString(value) && value.match(this.variableSyntax)) {
@@ -2585,14 +2630,17 @@ Missing Value ${missingValue} - ${matchedString}
2585
2630
  // console.log('deepVariable', deepVariable)
2586
2631
  const newValue = cleanVariable(deepVariable, this.variableSyntax, true, `overwrite ${this.callCount}`)
2587
2632
  // console.log(`overwrite newValue ${variableStrings[index]}`, newValue)
2588
- // console.log('variableStrings', variableStrings)
2589
- deepPropertyStr = deepPropertyStr.replace(variableStrings[index], newValue)
2590
- // console.log('deepPropertyString', deepPropertyStr)
2633
+ // Store the deep ref for this part
2634
+ deepVariableParts[index] = newValue
2591
2635
  }
2592
2636
  })
2593
- return deepProperties > 0
2594
- ? Promise.resolve(deepPropertyStr) // return deep variable replacement of original
2595
- : Promise.resolve(extractedValues.find(isValidValue)) // resolve first valid value, else undefined
2637
+
2638
+ if (deepProperties > 0) {
2639
+ // Reconstruct a minimal variable string with deep refs, not the full outer string
2640
+ const reconstructed = this.varPrefix + deepVariableParts.join(', ') + this.varSuffix
2641
+ return Promise.resolve(reconstructed)
2642
+ }
2643
+ return Promise.resolve(extractedValues.find(isValidValue)) // resolve first valid value, else undefined
2596
2644
  })
2597
2645
  }
2598
2646
 
@@ -2688,7 +2736,7 @@ Missing Value ${missingValue} - ${matchedString}
2688
2736
  .map((f) => {
2689
2737
  return trim(f)
2690
2738
  // TODO refactor this. This is a temp fix for filters with nested vars.
2691
- .replace(/}$/, '')
2739
+ .replace(this.varSuffixPattern, '')
2692
2740
  })
2693
2741
  // console.log('filters to run', _filter)
2694
2742
 
@@ -2803,6 +2851,16 @@ Missing Value ${missingValue} - ${matchedString}
2803
2851
  }
2804
2852
 
2805
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
+ }
2806
2864
  // Encode unresolved variable to pass through resolution
2807
2865
  return Promise.resolve(encodeUnknown(propertyString))
2808
2866
  }
@@ -2879,7 +2937,7 @@ Missing Value ${missingValue} - ${matchedString}
2879
2937
  if (typeof val === 'string' && val.match(/deep:/)) {
2880
2938
  // TODO refactor the deep filter logic here. match | filter | filter..
2881
2939
  const allFilters = propertyString
2882
- .replace(/}$/, '')
2940
+ .replace(this.varSuffixPattern, '')
2883
2941
  .split('|')
2884
2942
  .reduce((acc, currentFilter, i) => {
2885
2943
  if (i === 0) {
@@ -2889,7 +2947,7 @@ Missing Value ${missingValue} - ${matchedString}
2889
2947
  return acc
2890
2948
  }, '')
2891
2949
  // add filters to deep references if filter is used
2892
- const deepValueWithFilters = newHasFilter[1] ? val.replace(/}$/, ` ${allFilters}}`) : val
2950
+ const deepValueWithFilters = newHasFilter[1] ? val.replace(this.varSuffixPattern, ` ${allFilters}${this.varSuffix}`) : val
2893
2951
  // console.log('deepValueWithFilters', deepValueWithFilters)
2894
2952
  // console.log('RESOLVER RETURN newValue 4', deepValueWithFilters)
2895
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()