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.
Files changed (65) hide show
  1. package/README.md +156 -5
  2. package/package.json +20 -2
  3. package/src/main.js +268 -105
  4. package/src/parsers/esm.js +0 -14
  5. package/src/parsers/hcl-parse-script.js +40 -0
  6. package/src/parsers/hcl.js +131 -3
  7. package/src/parsers/hcl.slow-test.js +141 -0
  8. package/src/parsers/index.js +3 -1
  9. package/src/parsers/typescript.js +0 -10
  10. package/src/resolvers/valueFromEval.js +69 -11
  11. package/src/resolvers/valueFromFile.js +54 -1
  12. package/src/resolvers/valueFromIf.js +75 -0
  13. package/src/resolvers/valueFromIf.test.js +66 -0
  14. package/src/resolvers/valueFromNumber.js +3 -0
  15. package/src/utils/handleSignalEvents.js +3 -4
  16. package/src/utils/lodash.js +18 -7
  17. package/src/utils/parsing/cloudformationSchema.js +1 -2
  18. package/src/utils/parsing/cloudformationSchema.test.js +14 -0
  19. package/src/utils/parsing/parse.js +11 -1
  20. package/src/utils/parsing/preProcess.js +220 -5
  21. package/src/utils/paths/getFullFilePath.js +6 -2
  22. package/src/utils/paths/getFullFilePath.test.js +18 -0
  23. package/src/utils/regex/index.js +18 -3
  24. package/src/utils/regex/index.test.js +24 -0
  25. package/src/utils/strings/quoteAware.js +141 -0
  26. package/src/utils/strings/replaceAll.js +13 -1
  27. package/src/utils/strings/splitByComma.js +25 -15
  28. package/src/utils/strings/splitByComma.test.js +19 -0
  29. package/src/utils/strings/splitOnPipe.js +30 -0
  30. package/src/utils/strings/splitOnPipe.test.js +68 -0
  31. package/src/utils/validation/isValidValue.test.js +1 -1
  32. package/src/utils/validation/warnIfNotFound.js +1 -1
  33. package/src/utils/variables/findNestedVariables.js +8 -2
  34. package/types/src/main.d.ts +3 -1
  35. package/types/src/main.d.ts.map +1 -1
  36. package/types/src/parsers/esm.d.ts.map +1 -1
  37. package/types/src/parsers/hcl-parse-script.d.ts +3 -0
  38. package/types/src/parsers/hcl-parse-script.d.ts.map +1 -0
  39. package/types/src/parsers/hcl.d.ts +43 -0
  40. package/types/src/parsers/hcl.d.ts.map +1 -1
  41. package/types/src/parsers/hcl.slow-test.d.ts +2 -0
  42. package/types/src/parsers/hcl.slow-test.d.ts.map +1 -0
  43. package/types/src/parsers/typescript.d.ts.map +1 -1
  44. package/types/src/resolvers/valueFromEval.d.ts +1 -0
  45. package/types/src/resolvers/valueFromEval.d.ts.map +1 -1
  46. package/types/src/resolvers/valueFromFile.d.ts +4 -0
  47. package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
  48. package/types/src/resolvers/valueFromIf.d.ts +7 -0
  49. package/types/src/resolvers/valueFromIf.d.ts.map +1 -0
  50. package/types/src/resolvers/valueFromNumber.d.ts.map +1 -1
  51. package/types/src/utils/handleSignalEvents.d.ts.map +1 -1
  52. package/types/src/utils/lodash.d.ts.map +1 -1
  53. package/types/src/utils/parsing/parse.d.ts.map +1 -1
  54. package/types/src/utils/parsing/preProcess.d.ts +5 -1
  55. package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
  56. package/types/src/utils/paths/getFullFilePath.d.ts.map +1 -1
  57. package/types/src/utils/regex/index.d.ts.map +1 -1
  58. package/types/src/utils/strings/quoteAware.d.ts +30 -0
  59. package/types/src/utils/strings/quoteAware.d.ts.map +1 -0
  60. package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
  61. package/types/src/utils/strings/splitByComma.d.ts +1 -1
  62. package/types/src/utils/strings/splitByComma.d.ts.map +1 -1
  63. package/types/src/utils/strings/splitOnPipe.d.ts +8 -0
  64. package/types/src/utils/strings/splitOnPipe.d.ts.map +1 -0
  65. package/types/src/utils/variables/findNestedVariables.d.ts.map +1 -1
@@ -1,6 +1,9 @@
1
1
  const isNumber = require('lodash.isnumber')
2
2
 
3
3
  function isNumberVariable(variableString) {
4
+ if (!variableString || variableString.trim().length === 0) {
5
+ return false
6
+ }
4
7
  const num = Number(variableString)
5
8
  return !isNaN(num) && isNumber(num)
6
9
  }
@@ -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
- // Use once() instead of on()
28
- rl.once('SIGINT', () => process.emit('SIGINT'))
29
- rl.once('SIGTERM', () => process.emit('SIGTERM'))
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
@@ -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
- // Create a regex pattern with the characters to trim
68
- const pattern = new RegExp(`^[${chars.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')}]+|[${chars.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')}]+$`, 'g');
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
- * and escape variables inside help() filter arguments
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
- return fixFallbacksInString(withHelpEscaped)
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
- const cleanName = path.basename(pathToResolve)
23
- const findUpResult = findUp.sync(cleanName, { cwd: basePath })
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
  // ==========================================
@@ -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
- if (char === '(') depth++
35
- else if (char === ')') depth--
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
- const pat = new RegExp(replaceThis.replace(REPLACE_PATTERN, '\\$&'), 'g')
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