eslint-plugin-sfmc 2.6.1 → 4.0.0

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 (49) hide show
  1. package/README.md +77 -65
  2. package/docs/rules/amp/no-mcn-unsupported.md +76 -0
  3. package/docs/rules/amp/no-unknown-function.md +2 -39
  4. package/docs/rules/hbs/helper-arity.md +55 -0
  5. package/docs/rules/hbs/no-mcn-unsupported.md +69 -0
  6. package/docs/rules/hbs/no-unknown-binding.md +49 -0
  7. package/docs/rules/hbs/no-unknown-helper.md +60 -0
  8. package/docs/rules/hbs/no-unsupported-construct.md +65 -0
  9. package/docs/rules/ssjs/no-mcn-unsupported.md +68 -0
  10. package/docs/rules/ssjs/no-unknown-function.md +4 -31
  11. package/package.json +14 -12
  12. package/src/ampscript-parser.js +1 -1
  13. package/src/ampscript-processor.js +22 -16
  14. package/src/handlebars-parser.js +195 -0
  15. package/src/index.js +108 -23
  16. package/src/processor.js +76 -16
  17. package/src/rules/amp/{arg-types.js → argument-types.js} +16 -12
  18. package/src/rules/amp/function-arity.js +9 -9
  19. package/src/rules/amp/no-inline-statement.js +5 -5
  20. package/src/rules/amp/no-loop-counter-assign.js +7 -7
  21. package/src/rules/amp/no-mcn-unsupported.js +102 -0
  22. package/src/rules/amp/no-nested-ampscript-delimiter.js +58 -66
  23. package/src/rules/amp/no-smart-quotes.js +41 -47
  24. package/src/rules/amp/no-unknown-function.js +2 -28
  25. package/src/rules/amp/prefer-attribute-value.js +7 -10
  26. package/src/rules/amp/require-rowcount-check.js +12 -15
  27. package/src/rules/amp/require-variable-declaration.js +3 -3
  28. package/src/rules/hbs/_shared.js +116 -0
  29. package/src/rules/hbs/helper-arity.js +97 -0
  30. package/src/rules/hbs/no-mcn-unsupported.js +164 -0
  31. package/src/rules/hbs/no-unknown-binding.js +74 -0
  32. package/src/rules/hbs/no-unknown-helper.js +79 -0
  33. package/src/rules/hbs/no-unsupported-construct.js +70 -0
  34. package/src/rules/ssjs/cache-loop-length.js +14 -17
  35. package/src/rules/ssjs/no-deprecated-function.js +8 -8
  36. package/src/rules/ssjs/no-hardcoded-credentials.js +3 -6
  37. package/src/rules/ssjs/no-mcn-unsupported.js +182 -0
  38. package/src/rules/ssjs/no-property-call.js +6 -9
  39. package/src/rules/ssjs/no-treatascontent-injection.js +6 -13
  40. package/src/rules/ssjs/no-unavailable-method.js +22 -22
  41. package/src/rules/ssjs/no-unknown-function.js +15 -59
  42. package/src/rules/ssjs/no-unsupported-syntax.js +5 -5
  43. package/src/rules/ssjs/{prefer-parsejson-safe-arg.js → prefer-parsejson-safe-argument.js} +13 -21
  44. package/src/rules/ssjs/require-hasownproperty.js +54 -42
  45. package/src/rules/ssjs/require-platform-load-order.js +1 -1
  46. package/src/rules/ssjs/require-platform-load.js +6 -6
  47. package/src/rules/ssjs/{ssjs-arg-types.js → ssjs-argument-types.js} +59 -46
  48. package/src/rules/ssjs/ssjs-core-method-arity.js +41 -15
  49. /package/src/rules/amp/{no-var-redeclaration.js → no-variable-redeclaration.js} +0 -0
@@ -0,0 +1,102 @@
1
+ import { functionNames, functionLookup, getMcnApiVersion } from 'ampscript-data';
2
+
3
+ /**
4
+ * Flags AMPscript functions that cannot be used when targeting Marketing Cloud
5
+ * Next, and functions that work in MCN AMPscript but have no Handlebars
6
+ * equivalent (so they block a Handlebars migration).
7
+ *
8
+ * Each function carries an `mcnSince` value = the MCN API version it first
9
+ * became available in, or `null`/unset when it was never supported in MCN.
10
+ *
11
+ * `apiVersion` semantics (shared with the SSJS and Handlebars MCN rules):
12
+ * - Not set / null: flag every function whose `mcnSince` is null/unset. Any
13
+ * function supported at some point in MCN (numeric `mcnSince`) passes.
14
+ * - Set to N: flag everything the null case flags, PLUS everything whose
15
+ * `mcnSince` is greater than N (too new for the target). Pass iff
16
+ * `mcnSince != null && mcnSince <= N`.
17
+ *
18
+ * Unknown-function reporting stays in `amp-no-unknown-function`; this rule only
19
+ * evaluates functions that exist in the AMPscript catalog.
20
+ */
21
+ export default {
22
+ meta: {
23
+ type: 'problem',
24
+ docs: {
25
+ description:
26
+ 'Disallow AMPscript functions that are not available in the targeted Marketing Cloud Next API version',
27
+ recommended: true,
28
+ },
29
+ messages: {
30
+ notSupportedInMcn: "'{{name}}' is not supported in Marketing Cloud Next.",
31
+ tooNewForTarget:
32
+ "'{{name}}' was introduced in Marketing Cloud Next API version {{since}}, which is newer than the targeted version {{target}}.",
33
+ noHandlebarsEquivalent:
34
+ "'{{name}}' is supported by Marketing Cloud Next AMPscript but has no Handlebars for Marketing Cloud Next equivalent. It cannot be migrated to a Handlebars helper.",
35
+ },
36
+ schema: [
37
+ {
38
+ type: 'object',
39
+ properties: {
40
+ apiVersion: {
41
+ type: 'number',
42
+ description:
43
+ 'The targeted Marketing Cloud Next API version (e.g. 65 = Winter \u{2019}26, 67 = Summer \u{2019}26). Functions newer than this are flagged.',
44
+ },
45
+ },
46
+ additionalProperties: false,
47
+ },
48
+ ],
49
+ },
50
+
51
+ create(context) {
52
+ const options = context.options[0] ?? {};
53
+ const apiVersion = typeof options.apiVersion === 'number' ? options.apiVersion : null;
54
+
55
+ return {
56
+ FunctionCall(node) {
57
+ const lower = node.name.toLowerCase();
58
+
59
+ // Unknown functions are handled by amp-no-unknown-function.
60
+ if (!functionNames.has(lower)) {
61
+ return;
62
+ }
63
+
64
+ const since = getMcnApiVersion(lower);
65
+
66
+ // Never supported in MCN → always flag.
67
+ if (since === null) {
68
+ context.report({
69
+ node,
70
+ messageId: 'notSupportedInMcn',
71
+ data: { name: node.name },
72
+ });
73
+ return;
74
+ }
75
+
76
+ // Supported in MCN but newer than the targeted API version.
77
+ if (apiVersion !== null && since > apiVersion) {
78
+ context.report({
79
+ node,
80
+ messageId: 'tooNewForTarget',
81
+ data: {
82
+ name: node.name,
83
+ since: String(since),
84
+ target: String(apiVersion),
85
+ },
86
+ });
87
+ return;
88
+ }
89
+
90
+ // Works in MCN AMPscript but has no Handlebars counterpart, so it
91
+ // cannot be migrated to a Handlebars helper.
92
+ if (functionLookup.get(lower)?.mcnHandlebarsGap === true) {
93
+ context.report({
94
+ node,
95
+ messageId: 'noHandlebarsEquivalent',
96
+ data: { name: node.name },
97
+ });
98
+ }
99
+ },
100
+ };
101
+ },
102
+ };
@@ -1,12 +1,16 @@
1
1
  // The processor extracts each AMPscript region into its own virtual .amp file,
2
2
  // including the outer delimiters (%%[...]/%%] or <script>...</script>).
3
- // Any %%[ or %%= found INSIDE the outer delimiters is nested and invalid.
4
- const DELIMITER_RE = /%%\[|%%=/g;
3
+ // Any %%[ or %%= found INSIDE an already-open block is nested and invalid.
4
+ //
5
+ // A raw .amp file may contain several *sibling* blocks (e.g. one %%[...]%% block
6
+ // followed by another). Those are not nested. To distinguish nesting from
7
+ // siblings we scan every open/close token in document order and track depth:
8
+ // an opener is only nested when depth > 0.
9
+ const TOKEN_RE = /%%\[|%%=|\]%%|=%%/g;
5
10
  const SCRIPT_TAG_RE = /^[\n]*<script\b/i;
6
- const BLOCK_OPEN_RE = /^[\n]*(%%\[)/;
7
- const INLINE_OPEN_RE = /^[\n]*(%%=)/;
8
- const BLOCK_CLOSE_SUFFIX = ']%%';
9
- const INLINE_CLOSE_SUFFIX = '=%%';
11
+
12
+ const OPEN_TOKENS = new Set(['%%[', '%%=']);
13
+ const CLOSE_FOR_OPEN = { '%%[': ']%%', '%%=': '=%%' };
10
14
 
11
15
  export default {
12
16
  meta: {
@@ -25,76 +29,64 @@ export default {
25
29
  },
26
30
 
27
31
  create(context) {
32
+ const sourceCode = context.sourceCode;
33
+
34
+ const reportNested = (text, match, isScriptBlock) => {
35
+ const delimiter = match[0];
36
+ const start = match.index;
37
+ const end = start + delimiter.length;
38
+ const closeToken = CLOSE_FOR_OPEN[delimiter];
39
+ const closeIndex = text.indexOf(closeToken, end);
40
+
41
+ context.report({
42
+ loc: {
43
+ start: sourceCode.getLocFromIndex(start),
44
+ end: sourceCode.getLocFromIndex(end),
45
+ },
46
+ messageId: isScriptBlock ? 'nestedDelimiterInScript' : 'nestedDelimiter',
47
+ data: { delimiter },
48
+ fix:
49
+ closeIndex === -1
50
+ ? (fixer) => fixer.removeRange([start, end])
51
+ : (fixer) => [
52
+ fixer.removeRange([closeIndex, closeIndex + closeToken.length]),
53
+ fixer.removeRange([start, end]),
54
+ ],
55
+ });
56
+ };
57
+
28
58
  return {
29
59
  Program() {
30
- const sourceCode = context.sourceCode;
31
60
  const text = sourceCode.getText();
32
61
  const isScriptBlock = SCRIPT_TAG_RE.test(text);
33
62
 
34
- // For %%[...]%% and %%=...=%% blocks the processor includes the outer
35
- // delimiters. Record their positions so we can skip the outermost ones.
36
- let outerOpenStart = -1;
37
- let outerCloseStart = -1;
38
- let outerCloseSuffix = BLOCK_CLOSE_SUFFIX;
39
-
40
- if (!isScriptBlock) {
41
- const blockOpenMatch = BLOCK_OPEN_RE.exec(text);
42
- const inlineOpenMatch = INLINE_OPEN_RE.exec(text);
43
-
44
- if (blockOpenMatch) {
45
- outerOpenStart = blockOpenMatch.index + blockOpenMatch[0].length - 3;
46
- outerCloseSuffix = BLOCK_CLOSE_SUFFIX;
47
- } else if (inlineOpenMatch) {
48
- outerOpenStart = inlineOpenMatch.index + inlineOpenMatch[0].length - 3;
49
- outerCloseSuffix = INLINE_CLOSE_SUFFIX;
50
- }
51
-
52
- // The outer close is the last occurrence of the matching close token
53
- const lastClose = text.lastIndexOf(outerCloseSuffix);
54
- if (lastClose !== -1) {
55
- outerCloseStart = lastClose;
63
+ if (isScriptBlock) {
64
+ // Inside a <script language="ampscript"> block the whole body is
65
+ // already AMPscript; every %%[ / %%= delimiter is redundant.
66
+ TOKEN_RE.lastIndex = 0;
67
+ let match;
68
+ while ((match = TOKEN_RE.exec(text)) !== null) {
69
+ if (OPEN_TOKENS.has(match[0])) {
70
+ reportNested(text, match, true);
71
+ }
56
72
  }
73
+ return;
57
74
  }
58
75
 
59
- DELIMITER_RE.lastIndex = 0;
76
+ // Depth-based scan for raw %%[...]%% / %%=...=%% content. Sibling blocks
77
+ // return depth to 0 between them, so their openers are not flagged.
78
+ let depth = 0;
79
+ TOKEN_RE.lastIndex = 0;
60
80
  let match;
61
- while ((match = DELIMITER_RE.exec(text)) !== null) {
62
- const delimiter = match[0];
63
- const start = match.index;
64
- const end = start + delimiter.length;
65
-
66
- // Skip the outermost %%[ opener (it is the block wrapper, not nested)
67
- if (!isScriptBlock && start === outerOpenStart) {
68
- continue;
81
+ while ((match = TOKEN_RE.exec(text)) !== null) {
82
+ if (OPEN_TOKENS.has(match[0])) {
83
+ if (depth > 0) {
84
+ reportNested(text, match, false);
85
+ }
86
+ depth++;
87
+ } else {
88
+ depth = Math.max(0, depth - 1);
69
89
  }
70
-
71
- const closeToken = delimiter === '%%[' ? ']%%' : '=%%';
72
-
73
- // Find the matching close token for the fix (first occurrence after this open)
74
- let closeIndex = text.indexOf(closeToken, end);
75
- // Don't use the outer ]%% as the fix target for an inner %%[
76
- if (!isScriptBlock && closeIndex === outerCloseStart) {
77
- closeIndex = -1;
78
- }
79
-
80
- context.report({
81
- loc: {
82
- start: sourceCode.getLocFromIndex(start),
83
- end: sourceCode.getLocFromIndex(end),
84
- },
85
- messageId: isScriptBlock ? 'nestedDelimiterInScript' : 'nestedDelimiter',
86
- data: { delimiter },
87
- fix:
88
- closeIndex === -1
89
- ? (fixer) => fixer.removeRange([start, end])
90
- : (fixer) => [
91
- fixer.removeRange([
92
- closeIndex,
93
- closeIndex + closeToken.length,
94
- ]),
95
- fixer.removeRange([start, end]),
96
- ],
97
- });
98
90
  }
99
91
  },
100
92
  };
@@ -1,27 +1,27 @@
1
- const SMART_QUOTE_RE = /[\u2018\u2019\u201C\u201D\u201A\u201E\u2039\u203A]/;
1
+ const SMART_QUOTE_RE = /[\u{2018}\u{2019}\u{201C}\u{201D}\u{201A}\u{201E}\u{2039}\u{203A}]/gu;
2
2
 
3
3
  const SMART_QUOTES = {
4
- '\u2018': 'left single curly quote \u2018',
5
- '\u2019': 'right single curly quote \u2019',
6
- '\u201C': 'left double curly quote \u201C',
7
- '\u201D': 'right double curly quote \u201D',
8
- '\u201A': 'single low-9 quote \u201A',
9
- '\u201E': 'double low-9 quote \u201E',
10
- '\u2039': 'single left angle quote \u2039',
11
- '\u203A': 'single right angle quote \u203A',
4
+ '\u{2018}': 'left single curly quote \u{2018}',
5
+ '\u{2019}': 'right single curly quote \u{2019}',
6
+ '\u{201C}': 'left double curly quote \u{201C}',
7
+ '\u{201D}': 'right double curly quote \u{201D}',
8
+ '\u{201A}': 'single low-9 quote \u{201A}',
9
+ '\u{201E}': 'double low-9 quote \u{201E}',
10
+ '\u{2039}': 'single left angle quote \u{2039}',
11
+ '\u{203A}': 'single right angle quote \u{203A}',
12
12
  };
13
13
 
14
14
  // Maps each smart-quote char to its nearest ASCII equivalent.
15
15
  // Single-flavour smart quotes -> ' ; double-flavour -> "
16
16
  const SMART_TO_ASCII = {
17
- '\u2018': "'",
18
- '\u2019': "'",
19
- '\u201A': "'",
20
- '\u2039': "'",
21
- '\u203A': "'",
22
- '\u201C': '"',
23
- '\u201D': '"',
24
- '\u201E': '"',
17
+ '\u{2018}': "'",
18
+ '\u{2019}': "'",
19
+ '\u{201A}': "'",
20
+ '\u{2039}': "'",
21
+ '\u{203A}': "'",
22
+ '\u{201C}': '"',
23
+ '\u{201D}': '"',
24
+ '\u{201E}': '"',
25
25
  };
26
26
 
27
27
  export default {
@@ -30,7 +30,7 @@ export default {
30
30
  fixable: 'code',
31
31
  docs: {
32
32
  description:
33
- 'Disallow smart/curly quotes in string literals (AMPscript requires ASCII quotes)',
33
+ 'Disallow smart/curly quotes in AMPscript (AMPscript requires ASCII quotes)',
34
34
  recommended: true,
35
35
  },
36
36
  messages: {
@@ -41,36 +41,30 @@ export default {
41
41
  },
42
42
 
43
43
  create(context) {
44
+ const sourceCode = context.sourceCode;
45
+
44
46
  return {
45
- StringLiteral(node) {
46
- if (SMART_QUOTE_RE.test(node.value)) {
47
- for (const [char, kind] of Object.entries(SMART_QUOTES)) {
48
- if (node.value.includes(char)) {
49
- context.report({
50
- node,
51
- messageId: 'smartQuote',
52
- data: { kind },
53
- fix(fixer) {
54
- const fixed = node.value.replaceAll(
55
- /[\u2018\u2019\u201C\u201D\u201A\u201E\u2039\u203A]/g,
56
- (c) => SMART_TO_ASCII[c],
57
- );
58
- const q = node.quote;
59
- // If the replacement introduces the outer delimiter, try switching quotes.
60
- if (fixed.includes(q)) {
61
- const other = q === '"' ? "'" : '"';
62
- if (!fixed.includes(other)) {
63
- return fixer.replaceText(node, other + fixed + other);
64
- }
65
- // Both quote chars present — cannot safely auto-fix.
66
- return null;
67
- }
68
- return fixer.replaceText(node, q + fixed + q);
69
- },
70
- });
71
- return;
72
- }
73
- }
47
+ // The AMPscript parser tokenizes string literals as RAW + IDENTIFIER
48
+ // tokens rather than a StringLiteral node, so a node listener never
49
+ // matches. Scan the virtual .amp region's raw text instead — the whole
50
+ // extracted file is AMPscript, mirroring the LSP smart-quote check.
51
+ Program() {
52
+ const text = sourceCode.getText();
53
+ SMART_QUOTE_RE.lastIndex = 0;
54
+ let match;
55
+ while ((match = SMART_QUOTE_RE.exec(text)) !== null) {
56
+ const char = match[0];
57
+ const start = match.index;
58
+ const end = start + char.length;
59
+ context.report({
60
+ loc: {
61
+ start: sourceCode.getLocFromIndex(start),
62
+ end: sourceCode.getLocFromIndex(end),
63
+ },
64
+ messageId: 'smartQuote',
65
+ data: { kind: SMART_QUOTES[char] },
66
+ fix: (fixer) => fixer.replaceTextRange([start, end], SMART_TO_ASCII[char]),
67
+ });
74
68
  }
75
69
  },
76
70
  };
@@ -1,4 +1,4 @@
1
- import { functionNames, isMcnSupported } from 'ampscript-data';
1
+ import { functionNames } from 'ampscript-data';
2
2
 
3
3
  export default {
4
4
  meta: {
@@ -11,28 +11,11 @@ export default {
11
11
  messages: {
12
12
  unknownFunction:
13
13
  "'{{name}}' is not a recognized AMPscript function. AMPscript does not support custom functions.",
14
- notSupportedInMcn: "'{{name}}' is not supported in Marketing Cloud Next.",
15
14
  },
16
- schema: [
17
- {
18
- type: 'object',
19
- properties: {
20
- target: {
21
- type: 'string',
22
- enum: ['engagement', 'next'],
23
- description:
24
- "Target platform. Set to 'next' to additionally flag functions not available in Marketing Cloud Next.",
25
- },
26
- },
27
- additionalProperties: false,
28
- },
29
- ],
15
+ schema: [],
30
16
  },
31
17
 
32
18
  create(context) {
33
- const options = context.options[0] ?? {};
34
- const targetNext = options.target === 'next';
35
-
36
19
  return {
37
20
  FunctionCall(node) {
38
21
  const lower = node.name.toLowerCase();
@@ -43,15 +26,6 @@ export default {
43
26
  messageId: 'unknownFunction',
44
27
  data: { name: node.name },
45
28
  });
46
- return;
47
- }
48
-
49
- if (targetNext && !isMcnSupported(lower)) {
50
- context.report({
51
- node,
52
- messageId: 'notSupportedInMcn',
53
- data: { name: node.name },
54
- });
55
29
  }
56
30
  },
57
31
  };
@@ -39,10 +39,7 @@ function isLikelyPersonalization(node) {
39
39
  if (functionNames.has(lower)) {
40
40
  return false;
41
41
  }
42
- if (AMPSCRIPT_KEYWORDS.has(lower)) {
43
- return false;
44
- }
45
- return true;
42
+ return !AMPSCRIPT_KEYWORDS.has(lower);
46
43
  }
47
44
 
48
45
  export default {
@@ -63,19 +60,19 @@ export default {
63
60
  },
64
61
 
65
62
  create(context) {
66
- function reportWithSuggestion(identNode) {
63
+ function reportWithSuggestion(identifierNode) {
67
64
  context.report({
68
- node: identNode,
65
+ node: identifierNode,
69
66
  messageId: 'preferAttributeValue',
70
- data: { name: identNode.value },
67
+ data: { name: identifierNode.value },
71
68
  suggest: [
72
69
  {
73
70
  messageId: 'wrapWithAttributeValue',
74
- data: { name: identNode.value },
71
+ data: { name: identifierNode.value },
75
72
  fix(fixer) {
76
73
  return fixer.replaceText(
77
- identNode,
78
- `AttributeValue("${identNode.value}")`,
74
+ identifierNode,
75
+ `AttributeValue("${identifierNode.value}")`,
79
76
  );
80
77
  },
81
78
  },
@@ -35,12 +35,7 @@ function extractRowCountVariables(node, into) {
35
35
  }
36
36
  }
37
37
 
38
- if (node.type === 'BinaryExpression') {
39
- extractRowCountVariables(node.left, into);
40
- extractRowCountVariables(node.right, into);
41
- }
42
-
43
- if (node.type === 'LogicalExpression') {
38
+ if (node.type === 'BinaryExpression' || node.type === 'LogicalExpression') {
44
39
  extractRowCountVariables(node.left, into);
45
40
  extractRowCountVariables(node.right, into);
46
41
  }
@@ -86,19 +81,21 @@ export default {
86
81
  },
87
82
 
88
83
  AmpForStatement(node) {
89
- if (
84
+ if (!(
90
85
  node.endExpr &&
91
86
  isRowCountCall(node.endExpr) &&
92
87
  node.endExpr.arguments &&
93
88
  node.endExpr.arguments.length > 0
94
- ) {
95
- const argument = node.endExpr.arguments[0];
96
- if (argument.type === 'Variable') {
97
- forLoops.push({
98
- varName: argument.value.toLowerCase(),
99
- node,
100
- });
101
- }
89
+ )) {
90
+ return;
91
+ }
92
+
93
+ const argument = node.endExpr.arguments[0];
94
+ if (argument.type === 'Variable') {
95
+ forLoops.push({
96
+ varName: argument.value.toLowerCase(),
97
+ node,
98
+ });
102
99
  }
103
100
  },
104
101
 
@@ -25,7 +25,7 @@ export default {
25
25
 
26
26
  create(context) {
27
27
  const declared = new Set();
28
- const fixedVars = new Set();
28
+ const fixedVariables = new Set();
29
29
 
30
30
  return {
31
31
  VarDeclaration(node) {
@@ -44,9 +44,9 @@ export default {
44
44
  }
45
45
  const lower = name.toLowerCase();
46
46
  if (!declared.has(lower)) {
47
- const isFirstFix = !fixedVars.has(lower);
47
+ const isFirstFix = !fixedVariables.has(lower);
48
48
  if (isFirstFix) {
49
- fixedVars.add(lower);
49
+ fixedVariables.add(lower);
50
50
  }
51
51
 
52
52
  context.report({
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Shared helpers for the Handlebars (Marketing Cloud Next) ESLint rules.
3
+ *
4
+ * Mirrors the logic used by the sfmc-language-lsp MCN Handlebars validator so
5
+ * that ESLint diagnostics and editor diagnostics stay consistent.
6
+ */
7
+
8
+ /** Matches a `{!$namespace.Field}` built-in data binding token. */
9
+ export const BINDING_PATTERN = /\{!\$([A-Za-z0-9_.]+)\}/g;
10
+
11
+ /**
12
+ * Returns the bare helper name when a node's path is a simple, single-part
13
+ * identifier (e.g. `add`), or null when it is a property access (`foo.bar`),
14
+ * a data variable (`@index`), a literal, or `this`.
15
+ *
16
+ * @param {object | undefined} path - The node's path expression.
17
+ * @returns {string | null} The simple helper name, or null.
18
+ */
19
+ export function simpleHelperName(path) {
20
+ if (!path || path.type !== 'PathExpression') {
21
+ return null;
22
+ }
23
+ if (path.data) {
24
+ return null;
25
+ }
26
+ if ((path.depth ?? 0) > 0) {
27
+ return null;
28
+ }
29
+ const parts = path.parts ?? [];
30
+ if (parts.length !== 1) {
31
+ return null;
32
+ }
33
+ const name = parts[0];
34
+ if (!name || name === 'this') {
35
+ return null;
36
+ }
37
+ return name;
38
+ }
39
+
40
+ /**
41
+ * Returns true when a mustache/subexpression node is a helper invocation rather
42
+ * than a bare data binding. A `{{foo}}` mustache with no params/hash is a data
43
+ * binding; a subexpression `(foo ...)` is always an invocation.
44
+ *
45
+ * @param {object} node - The AST node.
46
+ * @returns {boolean} True when the node invokes a helper.
47
+ */
48
+ export function isInvocation(node) {
49
+ const hasArguments = (node.params?.length ?? 0) > 0 || (node.hash?.pairs?.length ?? 0) > 0;
50
+ return node.type === 'SubExpression' || hasArguments;
51
+ }
52
+
53
+ /**
54
+ * Compute the Levenshtein edit distance between two strings.
55
+ *
56
+ * @param {string} a - First string.
57
+ * @param {string} b - Second string.
58
+ * @returns {number} The minimum number of single-character edits.
59
+ */
60
+ export function levenshtein(a, b) {
61
+ if (a === b) {
62
+ return 0;
63
+ }
64
+ if (a.length === 0) {
65
+ return b.length;
66
+ }
67
+ if (b.length === 0) {
68
+ return a.length;
69
+ }
70
+
71
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
72
+ const current = Array.from({ length: b.length + 1 }, () => 0);
73
+
74
+ for (let index = 1; index <= a.length; index++) {
75
+ current[0] = index;
76
+ for (let index_ = 1; index_ <= b.length; index_++) {
77
+ const cost = a[index - 1] === b[index_ - 1] ? 0 : 1;
78
+ current[index_] = Math.min(
79
+ current[index_ - 1] + 1,
80
+ previous[index_] + 1,
81
+ previous[index_ - 1] + cost,
82
+ );
83
+ }
84
+ previous = current.slice();
85
+ }
86
+ return previous[b.length];
87
+ }
88
+
89
+ /**
90
+ * Return the candidate closest to `word` (case-insensitive) when it is within a
91
+ * conservative typo threshold, or null when nothing is close enough.
92
+ *
93
+ * @param {string} word - The unknown word typed by the user.
94
+ * @param {Iterable<string>} candidates - Known valid names to match against.
95
+ * @returns {string | null} The closest candidate (original casing), or null.
96
+ */
97
+ export function closestMatch(word, candidates) {
98
+ const lowerWord = word.toLowerCase();
99
+ const maxDistance = lowerWord.length <= 4 ? 1 : 2;
100
+
101
+ let best = null;
102
+ let bestDistance = Infinity;
103
+
104
+ for (const candidate of candidates) {
105
+ const distance = levenshtein(lowerWord, candidate.toLowerCase());
106
+ if (distance < bestDistance) {
107
+ bestDistance = distance;
108
+ best = candidate;
109
+ }
110
+ }
111
+
112
+ if (best !== null && bestDistance > 0 && bestDistance <= maxDistance) {
113
+ return best;
114
+ }
115
+ return null;
116
+ }