configorama 0.9.5 → 0.9.11
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/README.md +156 -5
- package/package.json +20 -2
- package/src/main.js +268 -105
- package/src/parsers/esm.js +0 -14
- package/src/parsers/hcl-parse-script.js +40 -0
- package/src/parsers/hcl.js +131 -3
- package/src/parsers/hcl.slow-test.js +141 -0
- package/src/parsers/index.js +3 -1
- package/src/parsers/typescript.js +0 -10
- package/src/resolvers/valueFromEval.js +69 -11
- package/src/resolvers/valueFromFile.js +54 -1
- package/src/resolvers/valueFromIf.js +75 -0
- package/src/resolvers/valueFromIf.test.js +66 -0
- package/src/resolvers/valueFromNumber.js +3 -0
- package/src/utils/handleSignalEvents.js +3 -4
- package/src/utils/lodash.js +18 -7
- package/src/utils/parsing/cloudformationSchema.js +1 -2
- package/src/utils/parsing/cloudformationSchema.test.js +14 -0
- package/src/utils/parsing/parse.js +11 -1
- package/src/utils/parsing/preProcess.js +220 -5
- package/src/utils/paths/getFullFilePath.js +6 -2
- package/src/utils/paths/getFullFilePath.test.js +18 -0
- package/src/utils/regex/index.js +18 -3
- package/src/utils/regex/index.test.js +24 -0
- package/src/utils/strings/quoteAware.js +141 -0
- package/src/utils/strings/replaceAll.js +13 -1
- package/src/utils/strings/splitByComma.js +25 -15
- package/src/utils/strings/splitByComma.test.js +19 -0
- package/src/utils/strings/splitOnPipe.js +30 -0
- package/src/utils/strings/splitOnPipe.test.js +68 -0
- package/src/utils/validation/isValidValue.test.js +1 -1
- package/src/utils/validation/warnIfNotFound.js +1 -1
- package/src/utils/variables/findNestedVariables.js +8 -2
- package/types/src/main.d.ts +3 -1
- package/types/src/main.d.ts.map +1 -1
- package/types/src/parsers/esm.d.ts.map +1 -1
- package/types/src/parsers/hcl-parse-script.d.ts +3 -0
- package/types/src/parsers/hcl-parse-script.d.ts.map +1 -0
- package/types/src/parsers/hcl.d.ts +43 -0
- package/types/src/parsers/hcl.d.ts.map +1 -1
- package/types/src/parsers/hcl.slow-test.d.ts +2 -0
- package/types/src/parsers/hcl.slow-test.d.ts.map +1 -0
- package/types/src/parsers/typescript.d.ts.map +1 -1
- package/types/src/resolvers/valueFromEval.d.ts +1 -0
- package/types/src/resolvers/valueFromEval.d.ts.map +1 -1
- package/types/src/resolvers/valueFromFile.d.ts +4 -0
- package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
- package/types/src/resolvers/valueFromIf.d.ts +7 -0
- package/types/src/resolvers/valueFromIf.d.ts.map +1 -0
- package/types/src/resolvers/valueFromNumber.d.ts.map +1 -1
- package/types/src/utils/handleSignalEvents.d.ts.map +1 -1
- package/types/src/utils/lodash.d.ts.map +1 -1
- package/types/src/utils/parsing/parse.d.ts.map +1 -1
- package/types/src/utils/parsing/preProcess.d.ts +5 -1
- package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
- package/types/src/utils/paths/getFullFilePath.d.ts.map +1 -1
- package/types/src/utils/regex/index.d.ts.map +1 -1
- package/types/src/utils/strings/quoteAware.d.ts +30 -0
- package/types/src/utils/strings/quoteAware.d.ts.map +1 -0
- package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
- package/types/src/utils/strings/splitByComma.d.ts +1 -1
- package/types/src/utils/strings/splitByComma.d.ts.map +1 -1
- package/types/src/utils/strings/splitOnPipe.d.ts +8 -0
- package/types/src/utils/strings/splitOnPipe.d.ts.map +1 -0
- package/types/src/utils/variables/findNestedVariables.d.ts.map +1 -1
|
@@ -24,10 +24,9 @@ Exit received. Waiting for current operation to finish...
|
|
|
24
24
|
// Clean up readline interface when done
|
|
25
25
|
process.once('exit', () => rl.close())
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
rl.
|
|
29
|
-
rl.
|
|
30
|
-
rl.once('SIGBREAK', () => process.emit('SIGBREAK'))
|
|
27
|
+
rl.on('SIGINT', () => process.emit('SIGINT'))
|
|
28
|
+
rl.on('SIGTERM', () => process.emit('SIGTERM'))
|
|
29
|
+
rl.on('SIGBREAK', () => process.emit('SIGBREAK'))
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
// Remove any existing listeners before adding new ones
|
package/src/utils/lodash.js
CHANGED
|
@@ -47,26 +47,37 @@ function set(object, path, value) {
|
|
|
47
47
|
return object;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Cache for trim regex patterns (perf: avoid recompilation)
|
|
51
|
+
const trimRegexCache = new Map()
|
|
52
|
+
|
|
50
53
|
// Custom implementation of lodash.trim
|
|
51
54
|
function trim(string, chars) {
|
|
52
55
|
if (string === null || string === undefined) {
|
|
53
56
|
return '';
|
|
54
57
|
}
|
|
55
|
-
|
|
58
|
+
|
|
56
59
|
string = String(string);
|
|
57
|
-
|
|
60
|
+
|
|
58
61
|
if (!chars && String.prototype.trim) {
|
|
59
62
|
return string.trim();
|
|
60
63
|
}
|
|
61
|
-
|
|
64
|
+
|
|
62
65
|
if (!chars) {
|
|
63
66
|
// Default characters to trim (whitespace)
|
|
64
67
|
chars = ' \t\n\r\f\v\u00a0\u1680\u2000\u200a\u2028\u2029\u202f\u205f\u3000\ufeff';
|
|
65
68
|
}
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
|
|
70
|
+
// Check cache first
|
|
71
|
+
let pattern = trimRegexCache.get(chars)
|
|
72
|
+
if (!pattern) {
|
|
73
|
+
// Create and cache regex pattern with the characters to trim
|
|
74
|
+
const escaped = chars.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
|
|
75
|
+
pattern = new RegExp(`^[${escaped}]+|[${escaped}]+$`, 'g')
|
|
76
|
+
trimRegexCache.set(chars, pattern)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Reset lastIndex for global regex reuse
|
|
80
|
+
pattern.lastIndex = 0
|
|
70
81
|
return string.replace(pattern, '');
|
|
71
82
|
}
|
|
72
83
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const YAML = require('js-yaml');
|
|
2
2
|
const includes = require('lodash.includes');
|
|
3
3
|
const isString = require('lodash.isstring');
|
|
4
|
-
const split = require('lodash.split');
|
|
5
4
|
const flatten = require('lodash.flatten');
|
|
6
5
|
const map = require('lodash.map');
|
|
7
6
|
|
|
@@ -67,7 +66,7 @@ const createSchema = () => {
|
|
|
67
66
|
map(['mapping', 'scalar', 'sequence'], kind => yamlType(functionName, kind))
|
|
68
67
|
)
|
|
69
68
|
);
|
|
70
|
-
return YAML.Schema.create(types);
|
|
69
|
+
return YAML.Schema.create(YAML.DEFAULT_SAFE_SCHEMA, types);
|
|
71
70
|
};
|
|
72
71
|
|
|
73
72
|
module.exports = {
|
|
@@ -233,4 +233,18 @@ test('!EachMemberIn - member inclusion check', () => {
|
|
|
233
233
|
assert.equal(parsed.Value['Fn::EachMemberIn'], [['a', 'b'], ['a', 'b', 'c']])
|
|
234
234
|
})
|
|
235
235
|
|
|
236
|
+
// ==========================================
|
|
237
|
+
// Security - Unsafe YAML tags should be blocked
|
|
238
|
+
// ==========================================
|
|
239
|
+
|
|
240
|
+
test('security - !!js/function should be rejected', () => {
|
|
241
|
+
const maliciousYaml = `Handler: !!js/function 'function() { return "pwned"; }'`
|
|
242
|
+
assert.throws(() => parseCfYaml(maliciousYaml), /unknown tag/)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('security - !!js/regexp should be rejected', () => {
|
|
246
|
+
const maliciousYaml = `Pattern: !!js/regexp /test/`
|
|
247
|
+
assert.throws(() => parseCfYaml(maliciousYaml), /unknown tag/)
|
|
248
|
+
})
|
|
249
|
+
|
|
236
250
|
test.run()
|
|
@@ -5,6 +5,7 @@ const YAML = require('../../parsers/yaml')
|
|
|
5
5
|
const TOML = require('../../parsers/toml')
|
|
6
6
|
const INI = require('../../parsers/ini')
|
|
7
7
|
const JSON5 = require('../../parsers/json5')
|
|
8
|
+
const HCL = require('../../parsers/hcl')
|
|
8
9
|
const { executeTypeScriptFileSync } = require('../../parsers/typescript')
|
|
9
10
|
const { executeESMFileSync } = require('../../parsers/esm')
|
|
10
11
|
const cloudFormationSchema = require('./cloudformationSchema')
|
|
@@ -54,8 +55,17 @@ function parseFileContents({ contents, filePath, varRegex, dynamicArgs }) {
|
|
|
54
55
|
configObject = TOML.parse(contents)
|
|
55
56
|
} else if (fileType.match(/\.(ini)/i)) {
|
|
56
57
|
configObject = INI.parse(contents)
|
|
57
|
-
} else if (fileType.match(/\.(json|json5)/i)) {
|
|
58
|
+
} else if (fileType.match(/\.(json|json5|jsonc)/i)) {
|
|
58
59
|
configObject = JSON5.parse(contents)
|
|
60
|
+
} else if (fileType.match(/\.(tf|hcl)$/i) || filePath.match(/\.tf\.json$/i)) {
|
|
61
|
+
// Handle Terraform HCL files (.tf, .hcl) and Terraform JSON (.tf.json)
|
|
62
|
+
if (filePath.match(/\.tf\.json$/i)) {
|
|
63
|
+
// .tf.json files are just JSON
|
|
64
|
+
configObject = JSON5.parse(contents)
|
|
65
|
+
} else {
|
|
66
|
+
// .tf and .hcl files need HCL parsing
|
|
67
|
+
configObject = HCL.parse(contents, path.basename(filePath))
|
|
68
|
+
}
|
|
59
69
|
// TODO detect js syntax and use appropriate parser
|
|
60
70
|
} else if (fileType.match(/\.(js|cjs)/i)) {
|
|
61
71
|
let jsFile
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Preprocesses config to fix malformed fallback references
|
|
3
|
-
*
|
|
2
|
+
* Preprocesses config to fix malformed fallback references,
|
|
3
|
+
* escape variables inside help() filter arguments,
|
|
4
|
+
* and convert bare references in if() expressions
|
|
4
5
|
*/
|
|
5
6
|
const { splitByComma } = require('../strings/splitByComma')
|
|
7
|
+
const { getQuoteRanges } = require('../strings/quoteAware')
|
|
6
8
|
const { extractVariableWrapper } = require('../variables/variableUtils')
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -10,9 +12,12 @@ const { extractVariableWrapper } = require('../variables/variableUtils')
|
|
|
10
12
|
* @param {Object} configObject - The parsed configuration object
|
|
11
13
|
* @param {RegExp} variableSyntax - The variable syntax regex to use
|
|
12
14
|
* @param {Array} [variableTypes] - Array of variable type definitions with type/prefix fields
|
|
15
|
+
* @param {Object} [options] - Options for preprocessing
|
|
16
|
+
* @param {boolean} [options.skipFallbackFix] - Skip fixing malformed fallbacks (for object configs)
|
|
13
17
|
* @returns {Object} The preprocessed configuration object
|
|
14
18
|
*/
|
|
15
|
-
function preProcess(configObject, variableSyntax, variableTypes) {
|
|
19
|
+
function preProcess(configObject, variableSyntax, variableTypes, options = {}) {
|
|
20
|
+
const { skipFallbackFix = false } = options
|
|
16
21
|
// Extract prefix/suffix from variable syntax for reconstructing variables
|
|
17
22
|
const { prefix: varPrefix, suffix: varSuffix } = variableSyntax
|
|
18
23
|
? extractVariableWrapper(variableSyntax.source)
|
|
@@ -51,6 +56,214 @@ function preProcess(configObject, variableSyntax, variableTypes) {
|
|
|
51
56
|
})
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Convert bare config references inside if() expressions to ${...} syntax
|
|
61
|
+
* Also wraps unquoted ${...} refs in quotes for proper string comparison
|
|
62
|
+
* e.g., ${if(provider.stage === "prod")} => ${if("${provider.stage}" === "prod")}
|
|
63
|
+
* e.g., ${if(${provider.stage} === "prod")} => ${if("${provider.stage}" === "prod")}
|
|
64
|
+
* @param {string} str - String potentially containing if() expressions
|
|
65
|
+
* @returns {string} String with bare refs converted
|
|
66
|
+
*/
|
|
67
|
+
function convertBareRefsInIf(str) {
|
|
68
|
+
if (typeof str !== 'string') return str
|
|
69
|
+
|
|
70
|
+
const reserved = ['true', 'false', 'null', 'undefined', 'NaN', 'Infinity']
|
|
71
|
+
const prefixLen = varPrefix.length
|
|
72
|
+
const suffixLen = varSuffix.length
|
|
73
|
+
|
|
74
|
+
// Find if( blocks and process them
|
|
75
|
+
let result = str
|
|
76
|
+
let i = 0
|
|
77
|
+
|
|
78
|
+
while (i < result.length) {
|
|
79
|
+
// Look for ${if( or similar with custom prefix
|
|
80
|
+
const ifStart = result.indexOf(varPrefix + 'if(', i)
|
|
81
|
+
if (ifStart === -1) break
|
|
82
|
+
|
|
83
|
+
// Find the matching closing suffix by counting nested prefixes/suffixes
|
|
84
|
+
const contentStart = ifStart + prefixLen + 3 // after "${if("
|
|
85
|
+
let depth = 1
|
|
86
|
+
let j = contentStart
|
|
87
|
+
|
|
88
|
+
while (j < result.length && depth > 0) {
|
|
89
|
+
if (result.substring(j, j + prefixLen) === varPrefix) {
|
|
90
|
+
depth++
|
|
91
|
+
j += prefixLen
|
|
92
|
+
} else if (result.substring(j, j + suffixLen) === varSuffix) {
|
|
93
|
+
depth--
|
|
94
|
+
if (depth > 0) j += suffixLen
|
|
95
|
+
} else {
|
|
96
|
+
j++
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (depth === 0) {
|
|
101
|
+
// Extract the if content (everything between "if(" and the final ")")
|
|
102
|
+
const fullContent = result.substring(contentStart, j)
|
|
103
|
+
|
|
104
|
+
// Process the content: wrap bare refs and unquoted var refs in quotes
|
|
105
|
+
let processed = fullContent
|
|
106
|
+
|
|
107
|
+
// 1. First convert bare refs (word.word or word:word) to quoted var refs
|
|
108
|
+
// Must do this BEFORE handling ${...} to avoid double-wrapping
|
|
109
|
+
// Pattern excludes refs inside ${...} by using negative lookbehind for varPrefix
|
|
110
|
+
const escapedPrefix = varPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
111
|
+
const bareRefPattern = new RegExp(
|
|
112
|
+
`(?<!${escapedPrefix}[^${varSuffix}]*)(?<!")(?<!')(?<=^|[^.\\w])([a-zA-Z_][a-zA-Z0-9_]*(?:[.:][a-zA-Z_][a-zA-Z0-9_]*)+)(?![.\\w])`,
|
|
113
|
+
'g'
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// Simpler approach: find bare refs that are NOT inside ${...}
|
|
117
|
+
// Build list of ${...} ranges to exclude
|
|
118
|
+
const varRanges = []
|
|
119
|
+
let pos = 0
|
|
120
|
+
while (pos < processed.length) {
|
|
121
|
+
if (processed.substring(pos, pos + prefixLen) === varPrefix) {
|
|
122
|
+
const start = pos
|
|
123
|
+
let varDepth = 1
|
|
124
|
+
pos += prefixLen
|
|
125
|
+
while (pos < processed.length && varDepth > 0) {
|
|
126
|
+
if (processed.substring(pos, pos + prefixLen) === varPrefix) {
|
|
127
|
+
varDepth++
|
|
128
|
+
pos += prefixLen
|
|
129
|
+
} else if (processed.substring(pos, pos + suffixLen) === varSuffix) {
|
|
130
|
+
varDepth--
|
|
131
|
+
if (varDepth > 0) pos += suffixLen
|
|
132
|
+
} else {
|
|
133
|
+
pos++
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
pos += suffixLen
|
|
137
|
+
varRanges.push([start, pos])
|
|
138
|
+
} else {
|
|
139
|
+
pos++
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Build list of quoted string ranges to exclude
|
|
144
|
+
const quoteRanges = getQuoteRanges(fullContent)
|
|
145
|
+
|
|
146
|
+
// Comparison operators for detecting string comparison context
|
|
147
|
+
const comparisonOps = ['===', '!==', '==', '!=']
|
|
148
|
+
|
|
149
|
+
// Find and replace bare refs, skipping those inside ${...} or quoted strings
|
|
150
|
+
// Only quote bare refs that are in string comparison context
|
|
151
|
+
const simpleBarePat = /([a-zA-Z_][a-zA-Z0-9_]*(?:[.:][a-zA-Z_][a-zA-Z0-9_]*)+)/g
|
|
152
|
+
let offset = 0
|
|
153
|
+
let match
|
|
154
|
+
while ((match = simpleBarePat.exec(fullContent)) !== null) {
|
|
155
|
+
const bareRef = match[1]
|
|
156
|
+
const matchStart = match.index
|
|
157
|
+
const matchEnd = matchStart + bareRef.length
|
|
158
|
+
|
|
159
|
+
// Skip if inside a ${...} range
|
|
160
|
+
const insideVar = varRanges.some(([s, e]) => matchStart >= s && matchEnd <= e)
|
|
161
|
+
if (insideVar) continue
|
|
162
|
+
|
|
163
|
+
// Skip if inside a quoted string
|
|
164
|
+
const insideQuote = quoteRanges.some(([s, e]) => matchStart >= s && matchEnd <= e)
|
|
165
|
+
if (insideQuote) continue
|
|
166
|
+
|
|
167
|
+
// Skip reserved words
|
|
168
|
+
if (reserved.includes(bareRef)) continue
|
|
169
|
+
|
|
170
|
+
// Check if this ref is in a string comparison context
|
|
171
|
+
const afterRef = fullContent.substring(matchEnd).trimStart()
|
|
172
|
+
const beforeRef = fullContent.substring(0, matchStart).trimEnd()
|
|
173
|
+
|
|
174
|
+
const isComparedToString = comparisonOps.some(op => {
|
|
175
|
+
// Check if followed by: op "string"
|
|
176
|
+
if (afterRef.startsWith(op)) {
|
|
177
|
+
const afterOp = afterRef.substring(op.length).trimStart()
|
|
178
|
+
return afterOp.startsWith('"') || afterOp.startsWith("'")
|
|
179
|
+
}
|
|
180
|
+
// Check if preceded by: "string" op
|
|
181
|
+
for (const o of comparisonOps) {
|
|
182
|
+
const pattern = new RegExp(`["'][^"']*["']\\s*${o.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`)
|
|
183
|
+
if (pattern.test(beforeRef)) return true
|
|
184
|
+
}
|
|
185
|
+
return false
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// Replace with var ref - quoted if string comparison, unquoted otherwise
|
|
189
|
+
const replacement = isComparedToString
|
|
190
|
+
? `"${varPrefix}${bareRef}${varSuffix}"`
|
|
191
|
+
: `${varPrefix}${bareRef}${varSuffix}`
|
|
192
|
+
processed = processed.substring(0, matchStart + offset) + replacement + processed.substring(matchEnd + offset)
|
|
193
|
+
offset += replacement.length - bareRef.length
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 2. Quote unquoted ${...} refs that are used in string comparisons
|
|
197
|
+
// Pattern: ref followed by comparison operator and string, or string followed by operator and ref
|
|
198
|
+
// e.g., ${foo} === "bar" or "bar" === ${foo}
|
|
199
|
+
// Find ${...} refs that are in comparison context
|
|
200
|
+
pos = 0
|
|
201
|
+
let newProcessed = ''
|
|
202
|
+
while (pos < processed.length) {
|
|
203
|
+
if (processed.substring(pos, pos + prefixLen) === varPrefix) {
|
|
204
|
+
const precededByQuote = pos > 0 && processed[pos - 1] === '"'
|
|
205
|
+
|
|
206
|
+
// Find matching suffix
|
|
207
|
+
let varDepth = 1
|
|
208
|
+
let endPos = pos + prefixLen
|
|
209
|
+
while (endPos < processed.length && varDepth > 0) {
|
|
210
|
+
if (processed.substring(endPos, endPos + prefixLen) === varPrefix) {
|
|
211
|
+
varDepth++
|
|
212
|
+
endPos += prefixLen
|
|
213
|
+
} else if (processed.substring(endPos, endPos + suffixLen) === varSuffix) {
|
|
214
|
+
varDepth--
|
|
215
|
+
if (varDepth > 0) endPos += suffixLen
|
|
216
|
+
} else {
|
|
217
|
+
endPos++
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
endPos += suffixLen
|
|
221
|
+
|
|
222
|
+
const varRef = processed.substring(pos, endPos)
|
|
223
|
+
const followedByQuote = endPos < processed.length && processed[endPos] === '"'
|
|
224
|
+
|
|
225
|
+
// Check if this ref is in a string comparison context
|
|
226
|
+
const afterRef = processed.substring(endPos).trimStart()
|
|
227
|
+
const beforeRef = processed.substring(0, pos).trimEnd()
|
|
228
|
+
|
|
229
|
+
const isComparedToString = comparisonOps.some(op => {
|
|
230
|
+
// Check if followed by: op "string"
|
|
231
|
+
if (afterRef.startsWith(op)) {
|
|
232
|
+
const afterOp = afterRef.substring(op.length).trimStart()
|
|
233
|
+
return afterOp.startsWith('"') || afterOp.startsWith("'")
|
|
234
|
+
}
|
|
235
|
+
// Check if preceded by: "string" op
|
|
236
|
+
for (const o of comparisonOps) {
|
|
237
|
+
const pattern = new RegExp(`["'][^"']*["']\\s*${o.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`)
|
|
238
|
+
if (pattern.test(beforeRef)) return true
|
|
239
|
+
}
|
|
240
|
+
return false
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
if (!precededByQuote && !followedByQuote && isComparedToString) {
|
|
244
|
+
newProcessed += '"' + varRef + '"'
|
|
245
|
+
} else {
|
|
246
|
+
newProcessed += varRef
|
|
247
|
+
}
|
|
248
|
+
pos = endPos
|
|
249
|
+
} else {
|
|
250
|
+
newProcessed += processed[pos]
|
|
251
|
+
pos++
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
processed = newProcessed
|
|
255
|
+
|
|
256
|
+
// Reconstruct
|
|
257
|
+
result = result.substring(0, contentStart) + processed + result.substring(j)
|
|
258
|
+
i = contentStart + processed.length + suffixLen
|
|
259
|
+
} else {
|
|
260
|
+
i = ifStart + prefixLen
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return result
|
|
265
|
+
}
|
|
266
|
+
|
|
54
267
|
/**
|
|
55
268
|
* Fix malformed fallback references in a string
|
|
56
269
|
* @param {string} str - String potentially containing variables
|
|
@@ -154,9 +367,11 @@ function preProcess(configObject, variableSyntax, variableTypes) {
|
|
|
154
367
|
*/
|
|
155
368
|
function traverseAndFix(obj) {
|
|
156
369
|
if (typeof obj === 'string') {
|
|
157
|
-
// First escape help() variables, then fix fallbacks
|
|
370
|
+
// First escape help() variables, convert bare refs in if(), then fix fallbacks
|
|
158
371
|
const withHelpEscaped = escapeHelpVariables(obj)
|
|
159
|
-
|
|
372
|
+
const withBareRefsConverted = convertBareRefsInIf(withHelpEscaped)
|
|
373
|
+
// Skip fallback fixing for object configs (they handle bare refs differently)
|
|
374
|
+
return skipFallbackFix ? withBareRefsConverted : fixFallbacksInString(withBareRefsConverted)
|
|
160
375
|
}
|
|
161
376
|
|
|
162
377
|
if (Array.isArray(obj)) {
|
|
@@ -19,8 +19,12 @@ function resolveFilePath(pathToResolve, basePath) {
|
|
|
19
19
|
fullFilePath = fs.realpathSync(fullFilePath)
|
|
20
20
|
// Only use findUp for relative paths (not absolute paths)
|
|
21
21
|
} else if (!path.isAbsolute(pathToResolve)) {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// Strip ./ and ../ prefixes for findUp, but preserve directory structure like utils/
|
|
23
|
+
let searchPath = pathToResolve
|
|
24
|
+
while (searchPath.startsWith('./') || searchPath.startsWith('../')) {
|
|
25
|
+
searchPath = searchPath.replace(/^\.\.?\//, '')
|
|
26
|
+
}
|
|
27
|
+
const findUpResult = findUp.sync(searchPath, { cwd: basePath })
|
|
24
28
|
if (findUpResult) {
|
|
25
29
|
fullFilePath = findUpResult
|
|
26
30
|
}
|
|
@@ -109,6 +109,24 @@ test('resolveFilePath - relative path without ./ prefix triggers findUp', () =>
|
|
|
109
109
|
assert.is(result, expected)
|
|
110
110
|
})
|
|
111
111
|
|
|
112
|
+
test('resolveFilePath - preserves directory structure when using findUp', () => {
|
|
113
|
+
// Create additional structure for this test:
|
|
114
|
+
// _test-getFullFilePath/
|
|
115
|
+
// config.yml <- WRONG file
|
|
116
|
+
// utils/
|
|
117
|
+
// config.yml <- CORRECT file
|
|
118
|
+
// subdir/deepdir/ <- searching from here
|
|
119
|
+
const utilsDir = path.join(testDir, 'utils')
|
|
120
|
+
fs.mkdirSync(utilsDir, { recursive: true })
|
|
121
|
+
fs.writeFileSync(path.join(utilsDir, 'config.yml'), 'correct: true')
|
|
122
|
+
|
|
123
|
+
// From deepDir, request "utils/config.yml" - should find testDir/utils/config.yml
|
|
124
|
+
const result = resolveFilePath('utils/config.yml', deepDir)
|
|
125
|
+
const expected = path.join(utilsDir, 'config.yml')
|
|
126
|
+
assert.is(result, expected,
|
|
127
|
+
`Should preserve 'utils/' directory and find utils/config.yml, not root config.yml. Got ${result}`)
|
|
128
|
+
})
|
|
129
|
+
|
|
112
130
|
// ==========================================
|
|
113
131
|
// getFullPath - wrapper function
|
|
114
132
|
// ==========================================
|
package/src/utils/regex/index.js
CHANGED
|
@@ -27,12 +27,27 @@ function parseFunctionCall(str) {
|
|
|
27
27
|
|
|
28
28
|
let depth = 1
|
|
29
29
|
let pos = startPos
|
|
30
|
-
|
|
30
|
+
let inString = null // null, '"', or "'"
|
|
31
|
+
|
|
31
32
|
// Track parenthesis depth to find matching closing paren
|
|
32
33
|
while (pos < str.length && depth > 0) {
|
|
33
34
|
const char = str[pos]
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
const prevChar = pos > 0 ? str[pos - 1] : ''
|
|
36
|
+
|
|
37
|
+
// Toggle string state on unescaped quotes
|
|
38
|
+
if ((char === '"' || char === "'") && prevChar !== '\\') {
|
|
39
|
+
if (!inString) {
|
|
40
|
+
inString = char
|
|
41
|
+
} else if (char === inString) {
|
|
42
|
+
inString = null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Only count parens outside strings
|
|
47
|
+
if (!inString) {
|
|
48
|
+
if (char === '(') depth++
|
|
49
|
+
else if (char === ')') depth--
|
|
50
|
+
}
|
|
36
51
|
pos++
|
|
37
52
|
}
|
|
38
53
|
|
|
@@ -91,6 +91,30 @@ test('parseFunctionCall - handles multiple parens in text', () => {
|
|
|
91
91
|
assert.is(result[2], "'Choose option (A) or (B) or (C)'")
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
+
test('parseFunctionCall - handles unbalanced close paren in string', () => {
|
|
95
|
+
const result = parseFunctionCall('func("value)")')
|
|
96
|
+
assert.ok(result)
|
|
97
|
+
assert.is(result[0], 'func("value)")')
|
|
98
|
+
assert.is(result[1], 'func')
|
|
99
|
+
assert.is(result[2], '"value)"')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('parseFunctionCall - handles unbalanced open paren in string', () => {
|
|
103
|
+
const result = parseFunctionCall("func('open (')")
|
|
104
|
+
assert.ok(result)
|
|
105
|
+
assert.is(result[0], "func('open (')")
|
|
106
|
+
assert.is(result[1], 'func')
|
|
107
|
+
assert.is(result[2], "'open ('")
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('parseFunctionCall - handles multiple unbalanced parens in string', () => {
|
|
111
|
+
const result = parseFunctionCall('func("))))")')
|
|
112
|
+
assert.ok(result)
|
|
113
|
+
assert.is(result[0], 'func("))))")')
|
|
114
|
+
assert.is(result[1], 'func')
|
|
115
|
+
assert.is(result[2], '"))))"')
|
|
116
|
+
})
|
|
117
|
+
|
|
94
118
|
// ==========================================
|
|
95
119
|
// parseFunctionCall - edge cases
|
|
96
120
|
// ==========================================
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/* Quote-aware string processing utilities */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Find index of a character/pattern outside of quoted strings
|
|
5
|
+
* @param {string} str - String to search
|
|
6
|
+
* @param {string|function} matcher - Char to find, or function(str, idx) => matchLength|0
|
|
7
|
+
* @param {number} [startIdx=0] - Start index
|
|
8
|
+
* @returns {number} Index of match, or -1 if not found
|
|
9
|
+
*/
|
|
10
|
+
function findOutsideQuotes(str, matcher, startIdx = 0) {
|
|
11
|
+
let inQuote = false
|
|
12
|
+
let quoteChar = ''
|
|
13
|
+
|
|
14
|
+
for (let i = startIdx; i < str.length; i++) {
|
|
15
|
+
const ch = str[i]
|
|
16
|
+
|
|
17
|
+
if (!inQuote && (ch === '"' || ch === "'")) {
|
|
18
|
+
inQuote = true
|
|
19
|
+
quoteChar = ch
|
|
20
|
+
} else if (inQuote && ch === quoteChar) {
|
|
21
|
+
inQuote = false
|
|
22
|
+
} else if (!inQuote) {
|
|
23
|
+
if (typeof matcher === 'function') {
|
|
24
|
+
const matchLen = matcher(str, i)
|
|
25
|
+
if (matchLen > 0) return i
|
|
26
|
+
} else if (ch === matcher) {
|
|
27
|
+
return i
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return -1
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Replace a pattern only outside of quoted strings
|
|
37
|
+
* @param {string} str - String to process
|
|
38
|
+
* @param {string|RegExp} pattern - Pattern to match (if string, must be exact match)
|
|
39
|
+
* @param {string|function} replacement - Replacement string or function(match) => string
|
|
40
|
+
* @returns {string} Processed string
|
|
41
|
+
*/
|
|
42
|
+
function replaceOutsideQuotes(str, pattern, replacement) {
|
|
43
|
+
let result = ''
|
|
44
|
+
let inQuote = false
|
|
45
|
+
let quoteChar = ''
|
|
46
|
+
let i = 0
|
|
47
|
+
|
|
48
|
+
const patternStr = typeof pattern === 'string' ? pattern : null
|
|
49
|
+
const patternLen = patternStr ? patternStr.length : 0
|
|
50
|
+
|
|
51
|
+
while (i < str.length) {
|
|
52
|
+
const ch = str[i]
|
|
53
|
+
|
|
54
|
+
if (!inQuote && (ch === '"' || ch === "'")) {
|
|
55
|
+
inQuote = true
|
|
56
|
+
quoteChar = ch
|
|
57
|
+
result += ch
|
|
58
|
+
i++
|
|
59
|
+
} else if (inQuote && ch === quoteChar) {
|
|
60
|
+
inQuote = false
|
|
61
|
+
result += ch
|
|
62
|
+
i++
|
|
63
|
+
} else if (!inQuote && patternStr) {
|
|
64
|
+
// String pattern - check for exact match with word boundaries
|
|
65
|
+
if (str.substring(i, i + patternLen) === patternStr) {
|
|
66
|
+
const before = i === 0 || !/\w/.test(str[i - 1])
|
|
67
|
+
const after = i + patternLen >= str.length || !/\w/.test(str[i + patternLen])
|
|
68
|
+
if (before && after) {
|
|
69
|
+
const rep = typeof replacement === 'function' ? replacement(patternStr) : replacement
|
|
70
|
+
result += rep
|
|
71
|
+
i += patternLen
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
result += ch
|
|
76
|
+
i++
|
|
77
|
+
} else {
|
|
78
|
+
result += ch
|
|
79
|
+
i++
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if an index is inside a quoted string
|
|
88
|
+
* @param {string} str - String to check
|
|
89
|
+
* @param {number} idx - Index to check
|
|
90
|
+
* @returns {boolean} True if index is inside quotes
|
|
91
|
+
*/
|
|
92
|
+
function isInsideQuotes(str, idx) {
|
|
93
|
+
let inQuote = false
|
|
94
|
+
let quoteChar = ''
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < str.length && i <= idx; i++) {
|
|
97
|
+
const ch = str[i]
|
|
98
|
+
if (!inQuote && (ch === '"' || ch === "'")) {
|
|
99
|
+
inQuote = true
|
|
100
|
+
quoteChar = ch
|
|
101
|
+
} else if (inQuote && ch === quoteChar) {
|
|
102
|
+
inQuote = false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return inQuote
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get ranges of quoted strings in a string
|
|
111
|
+
* @param {string} str - String to analyze
|
|
112
|
+
* @returns {Array<[number, number]>} Array of [start, end] ranges
|
|
113
|
+
*/
|
|
114
|
+
function getQuoteRanges(str) {
|
|
115
|
+
/** @type {Array<[number, number]>} */
|
|
116
|
+
const ranges = []
|
|
117
|
+
let inQuote = false
|
|
118
|
+
let quoteChar = ''
|
|
119
|
+
let quoteStart = 0
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < str.length; i++) {
|
|
122
|
+
const ch = str[i]
|
|
123
|
+
if (!inQuote && (ch === '"' || ch === "'")) {
|
|
124
|
+
inQuote = true
|
|
125
|
+
quoteChar = ch
|
|
126
|
+
quoteStart = i
|
|
127
|
+
} else if (inQuote && ch === quoteChar) {
|
|
128
|
+
ranges.push([quoteStart, i + 1])
|
|
129
|
+
inQuote = false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return ranges
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
findOutsideQuotes,
|
|
138
|
+
replaceOutsideQuotes,
|
|
139
|
+
isInsideQuotes,
|
|
140
|
+
getQuoteRanges
|
|
141
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
const REPLACE_PATTERN = /([\/\,\!\\\^\$\{\}\[\]\(\)\.\*\+\?\|<>\-\&])/g
|
|
2
2
|
|
|
3
|
+
// Cache for compiled regex patterns (perf: avoid recompilation)
|
|
4
|
+
const regexCache = new Map()
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* Replace all occurrences of a string while handling regex special characters
|
|
5
8
|
* @param {string} replaceThis - String to replace
|
|
@@ -9,7 +12,16 @@ const REPLACE_PATTERN = /([\/\,\!\\\^\$\{\}\[\]\(\)\.\*\+\?\|<>\-\&])/g
|
|
|
9
12
|
*/
|
|
10
13
|
function replaceAll(replaceThis, withThis, inThis) {
|
|
11
14
|
withThis = withThis.replace(/\$/g, '$$$$')
|
|
12
|
-
|
|
15
|
+
|
|
16
|
+
// Check cache first
|
|
17
|
+
let pat = regexCache.get(replaceThis)
|
|
18
|
+
if (!pat) {
|
|
19
|
+
pat = new RegExp(replaceThis.replace(REPLACE_PATTERN, '\\$&'), 'g')
|
|
20
|
+
regexCache.set(replaceThis, pat)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Reset lastIndex for global regex reuse
|
|
24
|
+
pat.lastIndex = 0
|
|
13
25
|
return inThis.replace(pat, withThis)
|
|
14
26
|
}
|
|
15
27
|
|