configorama 0.6.12 → 0.6.13

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.
Files changed (66) hide show
  1. package/README.md +196 -24
  2. package/cli.js +3 -3
  3. package/package.json +1 -1
  4. package/src/index.js +22 -32
  5. package/src/main.js +690 -778
  6. package/src/parsers/yaml.js +3 -47
  7. package/src/resolvers/valueFromCron.js +3 -1
  8. package/src/resolvers/valueFromEnv.js +1 -0
  9. package/src/resolvers/valueFromEval.js +1 -0
  10. package/src/resolvers/valueFromFile.js +394 -0
  11. package/src/resolvers/valueFromGit.js +3 -2
  12. package/src/resolvers/valueFromOptions.js +1 -0
  13. package/src/resolvers/valueFromString.js +2 -1
  14. package/src/sync.js +12 -5
  15. package/src/utils/parsing/arrayToJsonPath.test.js +56 -0
  16. package/src/utils/{enrichMetadata.js → parsing/enrichMetadata.js} +177 -15
  17. package/src/utils/{parse.js → parsing/parse.js} +13 -13
  18. package/src/utils/parsing/preProcess.js +165 -0
  19. package/src/utils/{filePathUtils.js → paths/filePathUtils.js} +3 -2
  20. package/src/utils/paths/findLineForKey.js +47 -0
  21. package/src/utils/paths/findLineForKey.test.js +126 -0
  22. package/src/utils/{getFullFilePath.js → paths/getFullFilePath.js} +22 -26
  23. package/src/utils/{resolveAlias.js → paths/resolveAlias.js} +1 -1
  24. package/src/utils/regex/index.js +23 -1
  25. package/src/utils/resolution/preResolveVariable.js +260 -0
  26. package/src/utils/resolution/preResolveVariable.test.js +98 -0
  27. package/src/utils/strings/bracketMatcher.js +86 -0
  28. package/src/utils/strings/bracketMatcher.test.js +135 -0
  29. package/src/utils/{formatFunctionArgs.js → strings/formatFunctionArgs.js} +3 -2
  30. package/src/utils/strings/formatFunctionArgs.test.js +77 -0
  31. package/src/utils/strings/quoteUtils.js +89 -0
  32. package/src/utils/strings/quoteUtils.test.js +217 -0
  33. package/src/utils/strings/replaceAll.test.js +82 -0
  34. package/src/utils/{splitByComma.js → strings/splitByComma.js} +1 -1
  35. package/src/utils/strings/splitCsv.js +38 -0
  36. package/src/utils/strings/splitCsv.test.js +96 -0
  37. package/src/utils/strings/textUtils.test.js +86 -0
  38. package/src/utils/{configWizard.js → ui/configWizard.js} +177 -38
  39. package/src/utils/{createEditorLink.js → ui/createEditorLink.js} +11 -2
  40. package/src/utils/{logs.js → ui/logs.js} +3 -3
  41. package/src/utils/validation/isValidValue.test.js +64 -0
  42. package/src/utils/validation/warnIfNotFound.js +52 -0
  43. package/src/utils/variables/appendDeepVariable.test.js +41 -0
  44. package/src/utils/{cleanVariable.js → variables/cleanVariable.js} +5 -26
  45. package/src/utils/{find-nested-variables.js → variables/findNestedVariables.js} +2 -2
  46. package/src/utils/{find-nested-variables.test.js → variables/findNestedVariables.test.js} +5 -5
  47. package/src/utils/variables/getVariableType.test.js +109 -0
  48. package/src/utils/variables/variableUtils.test.js +117 -0
  49. package/src/utils/isValidValue.js +0 -8
  50. package/src/utils/splitCsv.js +0 -29
  51. package/src/utils/trimSurroundingQuotes.js +0 -5
  52. /package/src/utils/{arrayToJsonPath.js → parsing/arrayToJsonPath.js} +0 -0
  53. /package/src/utils/{cloudformationSchema.js → parsing/cloudformationSchema.js} +0 -0
  54. /package/src/utils/{mergeByKeys.js → parsing/mergeByKeys.js} +0 -0
  55. /package/src/utils/{filePathUtils.test.js → paths/filePathUtils.test.js} +0 -0
  56. /package/src/utils/{find-project-root.js → paths/findProjectRoot.js} +0 -0
  57. /package/src/utils/{resolveAlias.test.js → paths/resolveAlias.test.js} +0 -0
  58. /package/src/utils/{replaceAll.js → strings/replaceAll.js} +0 -0
  59. /package/src/utils/{splitByComma.test.js → strings/splitByComma.test.js} +0 -0
  60. /package/src/utils/{textUtils.js → strings/textUtils.js} +0 -0
  61. /package/src/utils/{chalk.js → ui/chalk.js} +0 -0
  62. /package/src/utils/{deep-log.js → ui/deep-log.js} +0 -0
  63. /package/src/utils/{appendDeepVariable.js → variables/appendDeepVariable.js} +0 -0
  64. /package/src/utils/{cleanVariable.test.js → variables/cleanVariable.test.js} +0 -0
  65. /package/src/utils/{getVariableType.js → variables/getVariableType.js} +0 -0
  66. /package/src/utils/{variableUtils.js → variables/variableUtils.js} +0 -0
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Tests for findLineForKey utility
3
+ */
4
+ const { test } = require('uvu')
5
+ const assert = require('uvu/assert')
6
+ const { findLineForKey } = require('./findLineForKey')
7
+
8
+ // YAML tests
9
+ test('YAML - finds key at start of line', () => {
10
+ const lines = ['foo: bar', 'baz: qux']
11
+ assert.equal(findLineForKey('foo', lines, '.yml'), 1)
12
+ assert.equal(findLineForKey('baz', lines, '.yml'), 2)
13
+ })
14
+
15
+ test('YAML - finds key with indentation', () => {
16
+ const lines = ['root:', ' nested: value', ' other: thing']
17
+ assert.equal(findLineForKey('nested', lines, '.yaml'), 2)
18
+ assert.equal(findLineForKey('other', lines, '.yaml'), 3)
19
+ })
20
+
21
+ test('YAML - finds key with space before colon', () => {
22
+ const lines = ['foo : bar']
23
+ assert.equal(findLineForKey('foo', lines, '.yml'), 1)
24
+ })
25
+
26
+ test('YAML - returns 0 for missing key', () => {
27
+ const lines = ['foo: bar']
28
+ assert.equal(findLineForKey('missing', lines, '.yml'), 0)
29
+ })
30
+
31
+ // JSON tests
32
+ test('JSON - finds quoted key', () => {
33
+ const lines = ['{', ' "foo": "bar",', ' "baz": 123', '}']
34
+ assert.equal(findLineForKey('foo', lines, '.json'), 2)
35
+ assert.equal(findLineForKey('baz', lines, '.json'), 3)
36
+ })
37
+
38
+ test('JSON - finds key with space before colon', () => {
39
+ const lines = ['{ "foo" : "bar" }']
40
+ assert.equal(findLineForKey('foo', lines, '.json'), 1)
41
+ })
42
+
43
+ test('JSON5 - works same as JSON', () => {
44
+ const lines = ['{', ' "foo": "bar"', '}']
45
+ assert.equal(findLineForKey('foo', lines, '.json5'), 2)
46
+ })
47
+
48
+ // TOML tests
49
+ test('TOML - finds key with equals', () => {
50
+ const lines = ['foo = "bar"', 'baz = 123']
51
+ assert.equal(findLineForKey('foo', lines, '.toml'), 1)
52
+ assert.equal(findLineForKey('baz', lines, '.toml'), 2)
53
+ })
54
+
55
+ test('TOML - finds key without spaces around equals', () => {
56
+ const lines = ['foo="bar"']
57
+ assert.equal(findLineForKey('foo', lines, '.toml'), 1)
58
+ })
59
+
60
+ // INI tests
61
+ test('INI - finds key with equals', () => {
62
+ const lines = ['[section]', 'foo = bar', 'baz = qux']
63
+ assert.equal(findLineForKey('foo', lines, '.ini'), 2)
64
+ assert.equal(findLineForKey('baz', lines, '.ini'), 3)
65
+ })
66
+
67
+ // JS/TS tests
68
+ test('JS - finds unquoted key', () => {
69
+ const lines = ['module.exports = {', ' foo: "bar",', ' baz: 123', '}']
70
+ assert.equal(findLineForKey('foo', lines, '.js'), 2)
71
+ assert.equal(findLineForKey('baz', lines, '.js'), 3)
72
+ })
73
+
74
+ test('JS - finds double-quoted key', () => {
75
+ const lines = ['module.exports = {', ' "foo": "bar"', '}']
76
+ assert.equal(findLineForKey('foo', lines, '.js'), 2)
77
+ })
78
+
79
+ test('JS - finds single-quoted key', () => {
80
+ const lines = ["module.exports = {", " 'foo': 'bar'", "}"]
81
+ assert.equal(findLineForKey('foo', lines, '.js'), 2)
82
+ })
83
+
84
+ test('JS - finds backtick-quoted key', () => {
85
+ const lines = ['module.exports = {', ' `foo`: "bar"', '}']
86
+ assert.equal(findLineForKey('foo', lines, '.js'), 2)
87
+ })
88
+
89
+ test('TS - works same as JS', () => {
90
+ const lines = ['export default {', ' foo: "bar"', '}']
91
+ assert.equal(findLineForKey('foo', lines, '.ts'), 2)
92
+ })
93
+
94
+ test('MJS - works same as JS', () => {
95
+ const lines = ['export default {', ' foo: "bar"', '}']
96
+ assert.equal(findLineForKey('foo', lines, '.mjs'), 2)
97
+ })
98
+
99
+ // Edge cases
100
+ test('returns 0 for empty lines array', () => {
101
+ assert.equal(findLineForKey('foo', [], '.yml'), 0)
102
+ })
103
+
104
+ test('returns 0 for null/undefined key', () => {
105
+ const lines = ['foo: bar']
106
+ assert.equal(findLineForKey(null, lines, '.yml'), 0)
107
+ assert.equal(findLineForKey(undefined, lines, '.yml'), 0)
108
+ })
109
+
110
+ test('returns 0 for null/undefined lines', () => {
111
+ assert.equal(findLineForKey('foo', null, '.yml'), 0)
112
+ assert.equal(findLineForKey('foo', undefined, '.yml'), 0)
113
+ })
114
+
115
+ test('escapes special regex characters in key', () => {
116
+ const lines = ['foo.bar: value', 'baz[0]: thing']
117
+ assert.equal(findLineForKey('foo.bar', lines, '.yml'), 1)
118
+ assert.equal(findLineForKey('baz[0]', lines, '.yml'), 2)
119
+ })
120
+
121
+ test('unknown file type falls back to YAML-style', () => {
122
+ const lines = ['foo: bar']
123
+ assert.equal(findLineForKey('foo', lines, '.unknown'), 1)
124
+ })
125
+
126
+ test.run()
@@ -2,28 +2,39 @@ const os = require('os')
2
2
  const fs = require('fs')
3
3
  const path = require('path')
4
4
  const findUp = require('find-up')
5
- const trimSurroundingQuotes = require('./trimSurroundingQuotes')
5
+ const { trimSurroundingQuotes } = require('../strings/quoteUtils')
6
6
  const { resolveAlias } = require('./resolveAlias')
7
7
 
8
- module.exports = function getFullPath(fileString, cwd) {
9
- const configPath = cwd || process.cwd()
10
- const relativePath = fileString.replace('~', os.homedir())
11
-
12
- let fullFilePath = (path.isAbsolute(relativePath) ? relativePath : path.join(configPath, relativePath))
8
+ /**
9
+ * Core path resolution logic - resolves a file path handling absolute paths, symlinks, and findUp
10
+ * @param {string} pathToResolve - The path to resolve
11
+ * @param {string} basePath - The base directory for relative paths
12
+ * @returns {string} The resolved full file path
13
+ */
14
+ function resolveFilePath(pathToResolve, basePath) {
15
+ let fullFilePath = path.isAbsolute(pathToResolve) ? pathToResolve : path.join(basePath, pathToResolve)
13
16
 
14
17
  if (fs.existsSync(fullFilePath)) {
15
18
  // Get real path to handle potential symlinks (but don't fatal error)
16
19
  fullFilePath = fs.realpathSync(fullFilePath)
17
-
18
20
  // Only match files that are relative
19
- } else if (relativePath.match(/\.\//)) {
20
- const cleanName = path.basename(relativePath)
21
- fullFilePath = findUp.sync(cleanName, { cwd: configPath })
21
+ } else if (pathToResolve.match(/\.\//)) {
22
+ const cleanName = path.basename(pathToResolve)
23
+ const findUpResult = findUp.sync(cleanName, { cwd: basePath })
24
+ if (findUpResult) {
25
+ fullFilePath = findUpResult
26
+ }
22
27
  }
23
28
 
24
29
  return fullFilePath
25
30
  }
26
31
 
32
+ module.exports = function getFullPath(fileString, cwd) {
33
+ const configPath = cwd || process.cwd()
34
+ const relativePath = fileString.replace('~', os.homedir())
35
+ return resolveFilePath(relativePath, configPath)
36
+ }
37
+
27
38
  /**
28
39
  * Resolves a file path from a matched file string (e.g., from file() or text() syntax)
29
40
  * @param {string} matchedFileString - The matched file string (e.g., "file(path/to/file.js)")
@@ -38,22 +49,7 @@ function resolveFilePathFromMatch(matchedFileString, syntax, configPath) {
38
49
 
39
50
  // Resolve alias if the path contains alias syntax
40
51
  const resolvedPath = resolveAlias(relativePath, configPath)
41
-
42
- let fullFilePath = path.isAbsolute(resolvedPath) ? resolvedPath : path.join(configPath, resolvedPath)
43
-
44
- if (fs.existsSync(fullFilePath)) {
45
- // Get real path to handle potential symlinks (but don't fatal error)
46
- fullFilePath = fs.realpathSync(fullFilePath)
47
-
48
- // Only match files that are relative
49
- } else if (resolvedPath.match(/\.\//)) {
50
- // TODO test higher parent refs
51
- const cleanName = path.basename(resolvedPath)
52
- const findUpResult = findUp.sync(cleanName, { cwd: configPath })
53
- if (findUpResult) {
54
- fullFilePath = findUpResult
55
- }
56
- }
52
+ const fullFilePath = resolveFilePath(resolvedPath, configPath)
57
53
 
58
54
  return { fullFilePath, resolvedPath, relativePath }
59
55
  }
@@ -1,7 +1,7 @@
1
1
  const path = require('path')
2
2
  const fs = require('fs')
3
3
  const findUp = require('find-up')
4
- const JSON5 = require('../parsers/json5')
4
+ const JSON5 = require('../../parsers/json5')
5
5
 
6
6
  const DEBUG = false
7
7
  const DEBUG_LOG = (message) => {
@@ -1,4 +1,26 @@
1
+ /**
2
+ * Shared regex patterns and utilities
3
+ */
4
+
5
+ const funcRegex = /(\w+)\s*\(((?:[^()]+)*)?\s*\)\s*/
6
+ const funcStartOfLineRegex = /^(\w+)\s*\(((?:[^()]+)*)?\s*\)\s*/
7
+ const subFunctionRegex = /(\w+):(\w+)\s*\(((?:[^()]+)*)?\s*\)\s*/
8
+
9
+ /**
10
+ * Combine multiple regex patterns into single OR pattern
11
+ * @param {RegExp[]} regexes - Array of regex patterns to combine
12
+ * @returns {RegExp} Combined regex with OR operator
13
+ */
14
+ function combineRegexes(regexes) {
15
+ const patterns = regexes.map(regex => regex.source).filter(Boolean)
16
+ return new RegExp(`(${patterns.join('|')})`)
17
+ }
1
18
 
2
19
  module.exports = {
3
- functionRegex: /(\w+)\s*\(((?:[^()]+)*)?\s*\)\s*/
20
+ funcRegex,
21
+ funcStartOfLineRegex,
22
+ subFunctionRegex,
23
+ combineRegexes,
24
+ // Keep old export name for backwards compat
25
+ functionRegex: funcRegex
4
26
  }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Pre-resolve variables that don't have dynamic dependencies.
3
+ * Uses existing resolvers to avoid duplicating logic.
4
+ */
5
+ const fs = require('fs')
6
+ const path = require('path')
7
+ const dotProp = require('dot-prop')
8
+
9
+ const createGitResolver = require('../../resolvers/valueFromGit')
10
+ const { parseFileContents } = require('../../resolvers/valueFromFile')
11
+
12
+ // Cache for resolved values (they don't change during execution)
13
+ const resolverCache = {}
14
+
15
+ /**
16
+ * Check if a string contains unresolved variable syntax
17
+ * @param {string} str - String to check
18
+ * @param {RegExp} variableSyntax - Variable syntax regex
19
+ * @returns {boolean}
20
+ */
21
+ function hasUnresolvedVars(str, variableSyntax) {
22
+ if (typeof str !== 'string') return false
23
+ if (!variableSyntax) {
24
+ // Fallback to basic pattern if no syntax provided
25
+ return /\$\{[^}]+\}/.test(str)
26
+ }
27
+ // Create a new regex from the source to avoid stateful lastIndex issues
28
+ const regex = new RegExp(variableSyntax.source, variableSyntax.flags.replace('g', ''))
29
+ return regex.test(str)
30
+ }
31
+
32
+ /**
33
+ * Pre-resolve a single variable reference
34
+ * @param {string} varString - Variable string like "self:path.to.value" or "env:VAR_NAME"
35
+ * @param {object} context - Resolution context
36
+ * @param {object} context.config - Original config object
37
+ * @param {string} context.configDir - Config file directory
38
+ * @param {RegExp} context.variableSyntax - Variable syntax regex
39
+ * @returns {Promise<*>} Resolved value or undefined if can't pre-resolve
40
+ */
41
+ async function preResolveSingle(varString, context) {
42
+ const { config = {}, configDir, variableSyntax } = context
43
+
44
+ // self: reference
45
+ if (varString.startsWith('self:')) {
46
+ const path = varString.slice(5).trim()
47
+ if (hasUnresolvedVars(path, variableSyntax)) return undefined
48
+ const value = dotProp.get(config, path)
49
+ // Only return if the value itself doesn't contain variables
50
+ if (value !== undefined && !hasUnresolvedVars(JSON.stringify(value), variableSyntax)) {
51
+ return value
52
+ }
53
+ return undefined
54
+ }
55
+
56
+ // env: reference
57
+ if (varString.startsWith('env:')) {
58
+ const envVar = varString.slice(4).trim()
59
+ if (hasUnresolvedVars(envVar, variableSyntax)) return undefined
60
+ return process.env[envVar]
61
+ }
62
+
63
+ // git: reference - use existing resolver
64
+ if (varString.startsWith('git:')) {
65
+ const gitVar = varString.slice(4).trim()
66
+ if (hasUnresolvedVars(gitVar, variableSyntax)) return undefined
67
+
68
+ const cacheKey = `git:${configDir || '.'}:${gitVar}`
69
+ if (resolverCache[cacheKey] !== undefined) {
70
+ return resolverCache[cacheKey]
71
+ }
72
+
73
+ try {
74
+ const gitResolver = createGitResolver(configDir)
75
+ const value = await gitResolver.resolver(`git:${gitVar}`)
76
+ resolverCache[cacheKey] = value
77
+ return value
78
+ } catch (e) {
79
+ resolverCache[cacheKey] = undefined
80
+ return undefined
81
+ }
82
+ }
83
+
84
+ // opt: reference - CLI options
85
+ if (varString.startsWith('opt:')) {
86
+ const optName = varString.slice(4).trim()
87
+ if (hasUnresolvedVars(optName, variableSyntax)) return undefined
88
+ const { options = {} } = context
89
+ return options[optName]
90
+ }
91
+
92
+ // file() reference - read file contents
93
+ const fileMatch = varString.match(/^file\((.+?)\)(?::(.+))?$/)
94
+ if (fileMatch) {
95
+ const filePath = fileMatch[1].trim()
96
+ const subPath = fileMatch[2] ? fileMatch[2].trim() : null
97
+
98
+ if (hasUnresolvedVars(filePath, variableSyntax)) return undefined
99
+
100
+ const cacheKey = `file:${configDir || '.'}:${filePath}`
101
+ if (resolverCache[cacheKey] !== undefined) {
102
+ const cached = resolverCache[cacheKey]
103
+ if (subPath && cached && typeof cached === 'object') {
104
+ return dotProp.get(cached, subPath)
105
+ }
106
+ return cached
107
+ }
108
+
109
+ try {
110
+ const resolvedPath = path.resolve(configDir || '.', filePath)
111
+ if (!fs.existsSync(resolvedPath)) {
112
+ resolverCache[cacheKey] = undefined
113
+ return undefined
114
+ }
115
+
116
+ const content = fs.readFileSync(resolvedPath, 'utf8')
117
+ const parsed = parseFileContents(content, resolvedPath)
118
+
119
+ resolverCache[cacheKey] = parsed
120
+
121
+ if (subPath && parsed && typeof parsed === 'object') {
122
+ return dotProp.get(parsed, subPath)
123
+ }
124
+ return parsed
125
+ } catch (e) {
126
+ resolverCache[cacheKey] = undefined
127
+ return undefined
128
+ }
129
+ }
130
+
131
+ // Simple config reference (no prefix, just a path like "foo.bar")
132
+ // Only if it looks like a valid path (alphanumeric, dots, underscores)
133
+ if (/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(varString)) {
134
+ const value = dotProp.get(config, varString)
135
+ if (value !== undefined && !hasUnresolvedVars(JSON.stringify(value), variableSyntax)) {
136
+ return value
137
+ }
138
+ }
139
+
140
+ return undefined
141
+ }
142
+
143
+ /**
144
+ * Pre-resolve variable references in a string
145
+ * Replaces ${varRef} patterns with resolved values where possible
146
+ * @param {string} str - String containing variable references
147
+ * @param {object} context - Resolution context
148
+ * @param {object} context.config - Original config object
149
+ * @param {string} context.configDir - Config file directory
150
+ * @param {RegExp} context.variableSyntax - Variable syntax regex
151
+ * @param {object} [options] - Options
152
+ * @param {boolean} [options.formatArrays=true] - Join arrays with ", "
153
+ * @returns {Promise<string>} String with pre-resolved variables
154
+ */
155
+ async function preResolveString(str, context, options = {}) {
156
+ if (typeof str !== 'string') return str
157
+
158
+ const { formatArrays = true } = options
159
+ const { variableSyntax } = context
160
+
161
+ // Use provided syntax or fallback to basic pattern
162
+ const varPattern = variableSyntax ? new RegExp(variableSyntax.source, 'g') : /\$\{([^{}]+)\}/g
163
+
164
+ // Collect all matches first since we need async resolution
165
+ const matches = []
166
+ let match
167
+ while ((match = varPattern.exec(str)) !== null) {
168
+ matches.push({ match: match[0], index: match.index })
169
+ }
170
+
171
+ if (matches.length === 0) return str
172
+
173
+ // Resolve all matches
174
+ const resolutions = await Promise.all(matches.map(async ({ match: matchStr }) => {
175
+ // Extract content between ${ and }
176
+ const varContent = matchStr.slice(2, -1)
177
+
178
+ // Skip if the content itself has ${} (nested variable)
179
+ if (hasUnresolvedVars(varContent, variableSyntax)) {
180
+ return matchStr
181
+ }
182
+
183
+ // Handle fallback syntax: ${var, fallback1, fallback2}
184
+ // Try each in order until one resolves
185
+ const parts = varContent.split(',').map(p => p.trim())
186
+
187
+ for (const part of parts) {
188
+ // Skip quoted literals for now (they're fallbacks, not resolvable)
189
+ if (/^['"].*['"]$/.test(part)) {
190
+ // Return the literal without quotes
191
+ return part.slice(1, -1)
192
+ }
193
+
194
+ // Skip numeric literals
195
+ if (/^\d+(\.\d+)?$/.test(part)) {
196
+ return part
197
+ }
198
+
199
+ const resolved = await preResolveSingle(part, context)
200
+ if (resolved !== undefined) {
201
+ // Format the value
202
+ if (Array.isArray(resolved) && formatArrays) {
203
+ return resolved.join(', ')
204
+ }
205
+ if (typeof resolved === 'object') {
206
+ return JSON.stringify(resolved)
207
+ }
208
+ return String(resolved)
209
+ }
210
+ }
211
+
212
+ // Couldn't resolve any part, return original
213
+ return matchStr
214
+ }))
215
+
216
+ // Replace matches with resolved values (in reverse order to preserve indices)
217
+ let result = str
218
+ for (let i = matches.length - 1; i >= 0; i--) {
219
+ const { match: matchStr, index } = matches[i]
220
+ result = result.slice(0, index) + resolutions[i] + result.slice(index + matchStr.length)
221
+ }
222
+
223
+ return result
224
+ }
225
+
226
+ /**
227
+ * Pre-resolve variables in an object recursively
228
+ * @param {*} obj - Object to process
229
+ * @param {object} context - Resolution context
230
+ * @param {object} [options] - Options passed to preResolveString
231
+ * @returns {Promise<*>} Object with pre-resolved variables
232
+ */
233
+ async function preResolveObject(obj, context, options = {}) {
234
+ if (typeof obj === 'string') {
235
+ return preResolveString(obj, context, options)
236
+ }
237
+
238
+ if (Array.isArray(obj)) {
239
+ return Promise.all(obj.map(item => preResolveObject(item, context, options)))
240
+ }
241
+
242
+ if (obj !== null && typeof obj === 'object') {
243
+ const result = {}
244
+ const entries = Object.keys(obj)
245
+ const values = await Promise.all(entries.map(key => preResolveObject(obj[key], context, options)))
246
+ entries.forEach((key, i) => {
247
+ result[key] = values[i]
248
+ })
249
+ return result
250
+ }
251
+
252
+ return obj
253
+ }
254
+
255
+ module.exports = {
256
+ preResolveSingle,
257
+ preResolveString,
258
+ preResolveObject,
259
+ hasUnresolvedVars
260
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Tests for preResolveVariable utility
3
+ */
4
+ const { test } = require('uvu')
5
+ const assert = require('uvu/assert')
6
+ const { preResolveSingle, preResolveString, hasUnresolvedVars } = require('./preResolveVariable')
7
+
8
+ // Default variable syntax (matches configorama default)
9
+ const variableSyntax = /\$\{([\s\S]+?)\}/g
10
+
11
+ // Test hasUnresolvedVars
12
+ test('hasUnresolvedVars - detects variable syntax', () => {
13
+ assert.is(hasUnresolvedVars('${foo}', variableSyntax), true)
14
+ assert.is(hasUnresolvedVars('${self:bar}', variableSyntax), true)
15
+ assert.is(hasUnresolvedVars('hello ${world}', variableSyntax), true)
16
+ assert.is(hasUnresolvedVars('no variables here', variableSyntax), false)
17
+ assert.is(hasUnresolvedVars('', variableSyntax), false)
18
+ assert.is(hasUnresolvedVars(null, variableSyntax), false)
19
+ assert.is(hasUnresolvedVars(123, variableSyntax), false)
20
+ })
21
+
22
+ // Test preResolveSingle with config refs
23
+ test('preResolveSingle - resolves simple config path', async () => {
24
+ const config = { foo: 'bar', nested: { value: 42 } }
25
+ const ctx = { config, variableSyntax }
26
+ assert.is(await preResolveSingle('foo', ctx), 'bar')
27
+ assert.is(await preResolveSingle('nested.value', ctx), 42)
28
+ assert.is(await preResolveSingle('notfound', ctx), undefined)
29
+ })
30
+
31
+ test('preResolveSingle - resolves self: refs', async () => {
32
+ const config = { appName: 'MyApp', settings: { port: 3000 } }
33
+ const ctx = { config, variableSyntax }
34
+ assert.is(await preResolveSingle('self:appName', ctx), 'MyApp')
35
+ assert.is(await preResolveSingle('self:settings.port', ctx), 3000)
36
+ assert.is(await preResolveSingle('self:missing', ctx), undefined)
37
+ })
38
+
39
+ test('preResolveSingle - resolves env: refs', async () => {
40
+ process.env.TEST_PRE_RESOLVE = 'testvalue'
41
+ const ctx = { config: {}, variableSyntax }
42
+ assert.is(await preResolveSingle('env:TEST_PRE_RESOLVE', ctx), 'testvalue')
43
+ assert.is(await preResolveSingle('env:DEFINITELY_NOT_SET_12345', ctx), undefined)
44
+ delete process.env.TEST_PRE_RESOLVE
45
+ })
46
+
47
+ test('preResolveSingle - skips values with unresolved vars', async () => {
48
+ const config = { dynamic: '${other}' }
49
+ const ctx = { config, variableSyntax }
50
+ assert.is(await preResolveSingle('dynamic', ctx), undefined)
51
+ assert.is(await preResolveSingle('self:dynamic', ctx), undefined)
52
+ })
53
+
54
+ // Test preResolveString
55
+ test('preResolveString - resolves variables in string', async () => {
56
+ const config = { name: 'World', count: 5 }
57
+ const ctx = { config, variableSyntax }
58
+ const result = await preResolveString('Hello ${name}, count: ${count}', ctx)
59
+ assert.is(result, 'Hello World, count: 5')
60
+ })
61
+
62
+ test('preResolveString - formats arrays as comma-separated', async () => {
63
+ const config = { items: ['a', 'b', 'c'] }
64
+ const ctx = { config, variableSyntax }
65
+ const result = await preResolveString('Items: ${items}', ctx)
66
+ assert.is(result, 'Items: a, b, c')
67
+ })
68
+
69
+ test('preResolveString - handles fallback values', async () => {
70
+ const config = { existing: 'found' }
71
+ const ctx = { config, variableSyntax }
72
+ assert.is(await preResolveString('${existing, "default"}', ctx), 'found')
73
+ assert.is(await preResolveString('${missing, "default"}', ctx), 'default')
74
+ assert.is(await preResolveString('${missing, 42}', ctx), '42')
75
+ })
76
+
77
+ test('preResolveString - leaves unresolvable vars unchanged', async () => {
78
+ const ctx = { config: {}, variableSyntax }
79
+ const result = await preResolveString('Value: ${unknown}', ctx)
80
+ assert.is(result, 'Value: ${unknown}')
81
+ })
82
+
83
+ test('preResolveString - handles env: in strings', async () => {
84
+ process.env.TEST_STRING_RESOLVE = 'envval'
85
+ const ctx = { config: {}, variableSyntax }
86
+ const result = await preResolveString('Env: ${env:TEST_STRING_RESOLVE}', ctx)
87
+ assert.is(result, 'Env: envval')
88
+ delete process.env.TEST_STRING_RESOLVE
89
+ })
90
+
91
+ test('preResolveString - handles self: in strings', async () => {
92
+ const config = { version: '1.0.0' }
93
+ const ctx = { config, variableSyntax }
94
+ const result = await preResolveString('Version: ${self:version}', ctx)
95
+ assert.is(result, 'Version: 1.0.0')
96
+ })
97
+
98
+ test.run()
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Finds all outermost matching brace pairs in a string
3
+ * @param {string} text - The text to search
4
+ * @param {string} openChar - The opening character (default: '{')
5
+ * @param {string} closeChar - The closing character (default: '}')
6
+ * @param {string} prefix - Optional prefix before opening char (e.g., '$' for '${')
7
+ * @returns {Array<string>} Array of matched substrings including delimiters
8
+ */
9
+ function findOutermostBraces(text, openChar = '{', closeChar = '}', prefix = '') {
10
+ const matches = []
11
+ let i = 0
12
+ const openPattern = prefix + openChar
13
+
14
+ while (i < text.length) {
15
+ // Check if we have a match at this position
16
+ const checkLen = openPattern.length
17
+ if (text.substring(i, i + checkLen) === openPattern) {
18
+ let depth = 1
19
+ let start = i
20
+ i += checkLen
21
+
22
+ while (i < text.length && depth > 0) {
23
+ if (text[i] === openChar) {
24
+ depth++
25
+ } else if (text[i] === closeChar) {
26
+ depth--
27
+ }
28
+ i++
29
+ }
30
+
31
+ if (depth === 0) {
32
+ matches.push(text.substring(start, i))
33
+ }
34
+ } else {
35
+ i++
36
+ }
37
+ }
38
+
39
+ return matches
40
+ }
41
+
42
+ /**
43
+ * Alternative implementation for finding outermost braces using depth tracking
44
+ * Optimized for simple bracket matching without prefix
45
+ * @param {string} text - The text to search
46
+ * @param {string} openChar - The opening character
47
+ * @param {string} closeChar - The closing character
48
+ * @returns {Array<string>} Array of matched substrings including delimiters
49
+ */
50
+ function findOutermostBracesDepthFirst(text, openChar = '{', closeChar = '}') {
51
+ const results = []
52
+ let depth = 0
53
+ let startIndex = -1
54
+
55
+ for (let i = 0; i < text.length; i++) {
56
+ if (text[i] === openChar) {
57
+ if (depth === 0) {
58
+ startIndex = i
59
+ }
60
+ depth++
61
+ } else if (text[i] === closeChar) {
62
+ depth--
63
+ if (depth === 0 && startIndex !== -1) {
64
+ results.push(text.substring(startIndex, i + 1))
65
+ startIndex = -1
66
+ }
67
+ }
68
+ }
69
+
70
+ return results
71
+ }
72
+
73
+ /**
74
+ * Finds outermost variables with ${} syntax
75
+ * @param {string} text - The text to search
76
+ * @returns {Array<string>} Array of matched variables including ${}
77
+ */
78
+ function findOutermostVariables(text) {
79
+ return findOutermostBraces(text, '{', '}', '$')
80
+ }
81
+
82
+ module.exports = {
83
+ findOutermostBraces,
84
+ findOutermostBracesDepthFirst,
85
+ findOutermostVariables
86
+ }