@weborigami/language 0.6.8 → 0.6.10

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 (39) hide show
  1. package/index.ts +19 -4
  2. package/main.js +5 -2
  3. package/package.json +2 -2
  4. package/src/compiler/compile.js +11 -3
  5. package/src/compiler/origami.pegjs +4 -2
  6. package/src/compiler/parse.js +77 -68
  7. package/src/compiler/parserHelpers.js +13 -13
  8. package/src/handlers/getPackedPath.js +17 -0
  9. package/src/handlers/jpeg_handler.js +5 -0
  10. package/src/handlers/js_handler.js +3 -3
  11. package/src/handlers/json_handler.js +3 -1
  12. package/src/handlers/tsv_handler.js +1 -1
  13. package/src/handlers/yaml_handler.js +1 -1
  14. package/src/project/jsGlobals.js +3 -3
  15. package/src/protocols/package.js +3 -3
  16. package/src/runtime/asyncStorage.js +7 -0
  17. package/src/runtime/codeFragment.js +4 -3
  18. package/src/runtime/errors.js +82 -129
  19. package/src/runtime/evaluate.js +8 -77
  20. package/src/runtime/execute.js +82 -0
  21. package/src/runtime/explainReferenceError.js +248 -0
  22. package/src/runtime/explainTraverseError.js +77 -0
  23. package/src/runtime/expressionFunction.js +8 -7
  24. package/src/runtime/expressionObject.js +9 -6
  25. package/src/runtime/handleExtension.js +22 -8
  26. package/src/runtime/internal.js +1 -1
  27. package/src/runtime/interop.js +15 -0
  28. package/src/runtime/ops.js +24 -19
  29. package/src/runtime/symbols.js +0 -1
  30. package/src/runtime/typos.js +22 -3
  31. package/test/compiler/compile.test.js +7 -103
  32. package/test/compiler/parse.test.js +38 -31
  33. package/test/project/fixtures/withPackageJson/subfolder/README.md +1 -0
  34. package/test/runtime/errors.test.js +296 -0
  35. package/test/runtime/evaluate.test.js +110 -34
  36. package/test/runtime/execute.test.js +41 -0
  37. package/test/runtime/expressionObject.test.js +4 -4
  38. package/test/runtime/ops.test.js +36 -35
  39. package/test/runtime/typos.test.js +2 -0
@@ -0,0 +1,248 @@
1
+ import {
2
+ pathFromKeys,
3
+ scope,
4
+ trailingSlash,
5
+ Tree,
6
+ } from "@weborigami/async-tree";
7
+ import { ops } from "./internal.js";
8
+ import { typos } from "./typos.js";
9
+
10
+ // Doesn't include `/` because that would have been handled as a path separator
11
+ const binaryOperatorRegex =
12
+ /!==|!=|%|&&|&|\*\*|\*|\+|-|\/|<<|<|<=|===|==|>>>|>>|>=|>|\^|\|\||\|/g;
13
+
14
+ /**
15
+ * Try to provide a more helpful message for a ReferenceError by analyzing the
16
+ * code and suggesting possible typos.
17
+ *
18
+ * @param {import("../../index.ts").AnnotatedCode} code
19
+ * @param {import("../../index.ts").RuntimeState} state
20
+ */
21
+ export default async function explainReferenceError(code, state) {
22
+ if (!state) {
23
+ return null;
24
+ }
25
+
26
+ const stateKeys = await getStateKeys(state);
27
+
28
+ if (code[0] === ops.property) {
29
+ // An inner property access returned undefined.
30
+ // Might be a global+extension or local+extension.
31
+ const explanation = await accidentalReferenceExplainer(
32
+ code.source,
33
+ stateKeys,
34
+ );
35
+ return explanation;
36
+ }
37
+
38
+ // See if the code looks like an external scope reference that failed
39
+ let key;
40
+ if (code[0] === ops.cache) {
41
+ // External scope reference
42
+ const scopeCall = code[3].slice(1); // drop the ops.scope
43
+ const keys = scopeCall.map((part) => part[1]);
44
+ const path = pathFromKeys(keys);
45
+
46
+ if (keys.length > 1) {
47
+ return `This path returned undefined: ${path}`;
48
+ }
49
+ key = keys[0];
50
+ } else if (code[0]?.[0] === ops.scope) {
51
+ // Simple scope reference
52
+ key = code[1][1];
53
+ } else {
54
+ // Generic reference error, can't offer help
55
+ return null;
56
+ }
57
+
58
+ key = trailingSlash.remove(key);
59
+ if (typeof key !== "string") {
60
+ return null;
61
+ }
62
+
63
+ // Common case of a single key
64
+ let message = `"${key}" is not in scope.`;
65
+
66
+ const explainers = [
67
+ mathExplainer,
68
+ qualifiedReferenceExplainer,
69
+ typoExplainer,
70
+ ];
71
+ let explanation;
72
+ for (const explainer of explainers) {
73
+ explanation = await explainer(key, stateKeys);
74
+ if (explanation) {
75
+ message += "\n" + explanation;
76
+ break;
77
+ }
78
+ }
79
+
80
+ return message;
81
+ }
82
+
83
+ /**
84
+ * Handle a reference that worked but maybe shouldn't have:
85
+ *
86
+ * - a global + extension like `performance.html` (`performance` is a global)
87
+ * - a local + extension like `posts.md` (where `posts` is a local variable)
88
+ *
89
+ * In either case, suggest using angle brackets.
90
+ */
91
+ async function accidentalReferenceExplainer(key, stateKeys) {
92
+ const parts = key.split(".");
93
+ if (parts.length !== 2) {
94
+ return null;
95
+ }
96
+
97
+ const extensionHandlers = stateKeys.global.filter((globalKey) =>
98
+ globalKey.endsWith("_handler"),
99
+ );
100
+ const extensions = extensionHandlers.map((handler) => handler.slice(0, -8));
101
+ if (!extensions.includes(parts[1])) {
102
+ return null;
103
+ }
104
+
105
+ if (stateKeys.global.includes(parts[0])) {
106
+ return `"${parts[0]}" is a global, but "${parts[1]}" looks like a file extension.
107
+ If you intended to reference a file, use angle brackets: <${key}>`;
108
+ }
109
+
110
+ if (stateKeys.object.includes(parts[0])) {
111
+ return `"${key}" looks like a file reference, but is matching the local object property "${parts[0]}".
112
+ If you intended to reference a file, use angle brackets: <${key}>`;
113
+ }
114
+
115
+ if (stateKeys.stack.includes(parts[0])) {
116
+ return `"${key}" looks like a file reference, but is matching the local parameter "${parts[0]}".
117
+ If you intended to reference a file, use angle brackets: <${key}>`;
118
+ }
119
+
120
+ return null;
121
+ }
122
+
123
+ // Return global, local, and object keys in scope for the given state
124
+ async function getStateKeys(state) {
125
+ const { globals, parent, object, stack } = state;
126
+ const objectScope = object ? await scope(object) : null;
127
+ const parentScope = parent ? await scope(parent) : null;
128
+
129
+ const globalKeys = globals ? Object.keys(globals) : [];
130
+ const objectKeys = objectScope ? await Tree.keys(objectScope) : [];
131
+ const scopeKeys = parentScope ? await Tree.keys(parentScope) : [];
132
+ const stackKeys = stack?.map((frame) => Object.keys(frame)).flat() ?? [];
133
+
134
+ const qualifiedGlobal = [];
135
+ for (const globalKey of globalKeys) {
136
+ // Heuristic namespace test: name starts with capital, prototype is null (an
137
+ // exotic `Module` instance)
138
+ let global = globals[globalKey];
139
+ if (
140
+ /^[A-Z]/.test(globalKey) &&
141
+ global &&
142
+ Object.getPrototypeOf(global) === null
143
+ ) {
144
+ for (const [key, value] of Object.entries(global)) {
145
+ if (typeof value === "function") {
146
+ qualifiedGlobal.push(`${globalKey}.${key}`);
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ const normalizedGlobalKeys = globalKeys.map((key) =>
153
+ trailingSlash.remove(key),
154
+ );
155
+ const normalizedObjectKeys = objectKeys.map((key) =>
156
+ trailingSlash.remove(key),
157
+ );
158
+ const normalizedScopeKeys = scopeKeys.map((key) => trailingSlash.remove(key));
159
+ const normalizedStackKeys = stackKeys.map((key) => trailingSlash.remove(key));
160
+
161
+ return {
162
+ global: normalizedGlobalKeys,
163
+ object: normalizedObjectKeys,
164
+ qualifiedGlobal,
165
+ scope: normalizedScopeKeys,
166
+ stack: normalizedStackKeys,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * If it looks like a math operation, suggest adding spaces around the operator.
172
+ */
173
+ function mathExplainer(key, stateKeys) {
174
+ if (!binaryOperatorRegex.test(key)) {
175
+ return null;
176
+ }
177
+
178
+ // Create a global version of the regex for replacing all operators
179
+ const withSpaces = key.replace(binaryOperatorRegex, " $& ");
180
+ return `If you intended a math operation, Origami requires spaces around the operator: "${withSpaces}"`;
181
+ }
182
+
183
+ /**
184
+ * If the key is an unqualified reference (`repeat`), but there's a qualified
185
+ * version in scope (`Origami.repeat`), suggest that.
186
+ */
187
+ async function qualifiedReferenceExplainer(key, stateKeys) {
188
+ if (key.includes(".")) {
189
+ return null;
190
+ }
191
+
192
+ const qualifiedKeys = stateKeys.qualifiedGlobal.filter((k) =>
193
+ k.endsWith("." + key),
194
+ );
195
+
196
+ if (qualifiedKeys.length === 0) {
197
+ return null;
198
+ }
199
+
200
+ let message = `Perhaps you intended`;
201
+ const list = qualifiedKeys.join(", ");
202
+ if (qualifiedKeys.length > 1) {
203
+ message += " one of these";
204
+ }
205
+ message += `: ${list}`;
206
+
207
+ return message;
208
+ }
209
+
210
+ /**
211
+ * Suggest possible typos for the given key based on the keys in scope.
212
+ */
213
+ async function typoExplainer(key, stateKeys) {
214
+ const allKeys = [
215
+ ...new Set([
216
+ ...stateKeys.global,
217
+ ...stateKeys.object,
218
+ ...stateKeys.scope,
219
+ ...stateKeys.stack,
220
+ ]),
221
+ ];
222
+
223
+ let firstPartTypos;
224
+ if (key.includes(".")) {
225
+ // Split off first part
226
+ const [firstPart] = key.split(".");
227
+ firstPartTypos = typos(firstPart, allKeys);
228
+ } else {
229
+ firstPartTypos = [];
230
+ }
231
+
232
+ const fullTypos = typos(key, allKeys);
233
+ const allTypos = [...new Set([...firstPartTypos, ...fullTypos])];
234
+ allTypos.sort();
235
+
236
+ if (allTypos.length === 0) {
237
+ return null;
238
+ }
239
+
240
+ let message = `Perhaps you intended`;
241
+ const list = allTypos.join(", ");
242
+ if (allTypos.length > 1) {
243
+ message += " one of these";
244
+ }
245
+ message += `: ${list}`;
246
+
247
+ return message;
248
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ extension,
3
+ isPacked,
4
+ pathFromKeys,
5
+ trailingSlash,
6
+ TraverseError,
7
+ Tree,
8
+ } from "@weborigami/async-tree";
9
+ import { typos } from "./typos.js";
10
+
11
+ /**
12
+ * Try to provide a more helpful message for a TraverseError by analyzing the
13
+ * code and suggesting possible typos.
14
+ *
15
+ * @param {TraverseError} error
16
+ */
17
+ export default async function explainTraverseError(error) {
18
+ const { lastValue, keys, position } = error;
19
+ if (lastValue === undefined || keys === undefined || position === undefined) {
20
+ // Don't have sufficient information; shouldn't happen
21
+ return null;
22
+ }
23
+
24
+ if (position === 0) {
25
+ // Shouldn't happen; should have been a ReferenceError
26
+ return null;
27
+ }
28
+
29
+ // The key that caused the error is the one before the current position
30
+ const path = pathFromKeys(keys.slice(0, position));
31
+ let message = `The path traversal ended unexpectedly at: ${path}`;
32
+
33
+ const key = trailingSlash.remove(keys[position - 1]);
34
+
35
+ if (
36
+ error.message === "A path tried to unpack data that's already unpacked."
37
+ ) {
38
+ // Key ends in a slash but value isn't packed
39
+ message += `\nYou can drop the trailing slash and just use: ${key}`;
40
+ } else if (isPacked(lastValue) && typeof lastValue.unpack !== "function") {
41
+ // Missing an extension handler
42
+ const ext = extension.extname(key);
43
+ if (ext) {
44
+ message += `\nThe value couldn't be unpacked because no file extension handler is registered for "${ext}".`;
45
+ }
46
+ } else {
47
+ // Compare the requested key with the available keys
48
+ const lastValueKeys = await Tree.keys(lastValue);
49
+ const normalizedKeys = lastValueKeys.map(trailingSlash.remove);
50
+
51
+ const keyAsNumber = Number(key);
52
+ if (!isNaN(keyAsNumber)) {
53
+ // See if the string version of the key is present
54
+ if (lastValueKeys.includes(keyAsNumber)) {
55
+ const suggestedPath = `${pathFromKeys(keys.slice(0, position - 1))}(${keyAsNumber})`;
56
+ message += `\nSlash-separated keys are searched as strings. Here there's no string "${key}" key, but there is a number ${keyAsNumber} key.
57
+ To get the value for that number key, use parentheses: ${suggestedPath}`;
58
+ }
59
+ } else {
60
+ // Suggest typos
61
+ const possibleTypos = typos(key, normalizedKeys);
62
+ if (possibleTypos.length > 0) {
63
+ message += "\nPerhaps you intended";
64
+ if (possibleTypos.length > 1) {
65
+ message += " one of these";
66
+ }
67
+ message += ": ";
68
+
69
+ const withLeadingSlashes =
70
+ position > 1 ? possibleTypos.map(trailingSlash.add) : possibleTypos;
71
+ message += withLeadingSlashes.join(", ");
72
+ }
73
+ }
74
+ }
75
+
76
+ return message;
77
+ }
@@ -1,16 +1,17 @@
1
- import { evaluate } from "./internal.js";
1
+ import execute from "./execute.js";
2
2
 
3
3
  /**
4
4
  * Given parsed Origami code, return a function that executes that code.
5
5
  *
6
- * @typedef {import("@weborigami/async-tree").SyncOrAsyncMap} SyncOrAsyncMap
6
+ * @typedef {import("../../index.ts").RuntimeState} RuntimeState
7
+ * @typedef {import("../../index.js").AnnotatedCode} AnnotatedCode
7
8
  *
8
- * @param {import("../../index.js").AnnotatedCode} code - parsed Origami expression
9
- * @param {SyncOrAsyncMap} parent - the parent tree in which the code is running
9
+ * @param {AnnotatedCode} code - parsed Origami expression
10
+ * @param {RuntimeState} [state] - runtime state
10
11
  */
11
- export function createExpressionFunction(code, parent) {
12
+ export function createExpressionFunction(code, state) {
12
13
  async function fn() {
13
- return evaluate(code, { parent });
14
+ return execute(code, state);
14
15
  }
15
16
  fn.code = code;
16
17
  fn.toString = () => code.location.source.text;
@@ -22,7 +23,7 @@ export function createExpressionFunction(code, parent) {
22
23
  * expression.
23
24
  *
24
25
  * @param {any} obj
25
- * @returns {obj is { code: Array }}
26
+ * @returns {obj is AnnotatedCode}
26
27
  */
27
28
  export function isExpressionFunction(obj) {
28
29
  return typeof obj === "function" && obj.code;
@@ -6,8 +6,9 @@ import {
6
6
  trailingSlash,
7
7
  Tree,
8
8
  } from "@weborigami/async-tree";
9
+ import execute from "./execute.js";
9
10
  import handleExtension from "./handleExtension.js";
10
- import { evaluate, ops } from "./internal.js";
11
+ import { ops } from "./internal.js";
11
12
 
12
13
  export const KEY_TYPE = {
13
14
  STRING: 0, // Simple string key: `a: 1`
@@ -65,7 +66,7 @@ export default async function expressionObject(entries, state = {}) {
65
66
  for (const info of infos) {
66
67
  if (info.keyType === KEY_TYPE.COMPUTED) {
67
68
  const newState = Object.assign({}, state, { object: map });
68
- const key = await evaluate(/** @type {any} */ (info.key), newState);
69
+ const key = await execute(/** @type {any} */ (info.key), newState);
69
70
  // Destructively update the property info with the computed key
70
71
  info.key = key;
71
72
  defineProperty(object, info, state, map);
@@ -127,7 +128,7 @@ function defineProperty(object, propertyInfo, state, map) {
127
128
  enumerable,
128
129
  get: async () => {
129
130
  const newState = Object.assign({}, state, { object: map });
130
- const result = await evaluate(value, newState);
131
+ const result = await execute(value, newState);
131
132
  return hasExtension ? handleExtension(result, key, map) : result;
132
133
  },
133
134
  });
@@ -215,11 +216,13 @@ export function propertyInfo(key, value) {
215
216
  }
216
217
  }
217
218
 
218
- // Special case: a key with an extension has to be a getter
219
219
  const hasExtension =
220
220
  typeof key === "string" && extension.extname(key).length > 0;
221
- if (hasExtension) {
222
- valueType = VALUE_TYPE.GETTER;
221
+
222
+ // Special case: if the key has an extension but the value is a primitive,
223
+ // treat it as eager so we can handle the extension.
224
+ if (hasExtension && valueType === VALUE_TYPE.PRIMITIVE) {
225
+ valueType = VALUE_TYPE.EAGER;
223
226
  }
224
227
 
225
228
  return { enumerable, hasExtension, key, keyType, value, valueType };
@@ -7,6 +7,7 @@ import {
7
7
  setParent,
8
8
  trailingSlash,
9
9
  } from "@weborigami/async-tree";
10
+ import getPackedPath from "../handlers/getPackedPath.js";
10
11
  import projectGlobals from "../project/projectGlobals.js";
11
12
 
12
13
  /**
@@ -50,17 +51,30 @@ export default async function handleExtension(value, key, parent = null) {
50
51
  setParent(value, parent);
51
52
  }
52
53
 
53
- const unpack = handler.unpack;
54
- if (unpack) {
55
- // Wrap the unpack function so its only called once per value.
56
- let loadPromise;
57
- value.unpack = async () => {
58
- loadPromise ??= unpack(value, { key, parent });
59
- return loadPromise;
60
- };
54
+ if (handler.unpack) {
55
+ value.unpack = wrapUnpack(handler.unpack, value, key, parent);
61
56
  }
62
57
  }
63
58
  }
64
59
  }
60
+ value;
65
61
  return value;
66
62
  }
63
+
64
+ // Wrap the unpack function so it's only called once per value, and so we can
65
+ // add the file path to any errors it throws.
66
+ function wrapUnpack(unpack, value, key, parent) {
67
+ let result;
68
+ return async () => {
69
+ if (!result) {
70
+ try {
71
+ result = await unpack(value, { key, parent });
72
+ } catch (/** @type {any} */ error) {
73
+ const filePath = getPackedPath(value, { key, parent });
74
+ const message = `Can't unpack ${filePath}\n${error.message}`;
75
+ throw new error.constructor(message, { cause: error });
76
+ }
77
+ }
78
+ return result;
79
+ };
80
+ }
@@ -10,6 +10,6 @@
10
10
 
11
11
  export * as ops from "./ops.js";
12
12
 
13
- export { default as evaluate } from "./evaluate.js";
13
+ export { default as evaluate } from "./execute.js";
14
14
 
15
15
  export * as expressionFunction from "./expressionFunction.js";
@@ -0,0 +1,15 @@
1
+ import { interop } from "@weborigami/async-tree";
2
+ import asyncStorage from "./asyncStorage.js";
3
+ import { lineInfo } from "./errors.js";
4
+
5
+ /**
6
+ * Inject our warning function into async-tree calls
7
+ */
8
+ interop.warn = function warn(...args) {
9
+ console.warn(...args);
10
+ const context = asyncStorage.getStore();
11
+ const location = context?.code?.location;
12
+ if (location) {
13
+ console.warn(lineInfo(location));
14
+ }
15
+ };
@@ -8,8 +8,8 @@
8
8
 
9
9
  import { getParent, isUnpackable, Tree } from "@weborigami/async-tree";
10
10
  import os from "node:os";
11
+ import execute from "./execute.js";
11
12
  import expressionObject from "./expressionObject.js";
12
- import { evaluate } from "./internal.js";
13
13
  import mergeTrees from "./mergeTrees.js";
14
14
  import OrigamiFileMap from "./OrigamiFileMap.js";
15
15
  import { codeSymbol } from "./symbols.js";
@@ -70,7 +70,7 @@ export async function cache(cache, path, code) {
70
70
  }
71
71
 
72
72
  // Don't await: might get another request for this before promise resolves
73
- const promise = await evaluate(code);
73
+ const promise = await execute(code);
74
74
 
75
75
  // Save promise so another request will get the same promise
76
76
  cache[path] = promise;
@@ -94,23 +94,13 @@ cache.unevaluatedArgs = true;
94
94
  export async function comma(...args) {
95
95
  let result;
96
96
  for (const arg of args) {
97
- result = await evaluate(arg);
97
+ result = await execute(arg);
98
98
  }
99
99
  return result;
100
100
  }
101
101
  addOpLabel(comma, "«ops.comma»");
102
102
  comma.unevaluatedArgs = true;
103
103
 
104
- /**
105
- * Concatenate the given arguments.
106
- *
107
- * @param {any[]} args
108
- */
109
- export async function concat(...args) {
110
- return Tree.deepText(args);
111
- }
112
- addOpLabel(concat, "«ops.concat»");
113
-
114
104
  export async function conditional(condition, truthy, falsy) {
115
105
  const value = condition ? truthy : falsy;
116
106
  return value instanceof Function ? await value() : value;
@@ -123,6 +113,16 @@ export async function construct(constructor, ...args) {
123
113
  return Reflect.construct(constructor, args);
124
114
  }
125
115
 
116
+ /**
117
+ * Return the deep text of the arguments
118
+ *
119
+ * @param {any[]} args
120
+ */
121
+ export async function deepText(...args) {
122
+ return Tree.deepText(args);
123
+ }
124
+ addOpLabel(deepText, "«ops.deepText");
125
+
126
126
  /**
127
127
  * Default value for a parameter: if the value is defined, return that;
128
128
  * otherwise, return the result of invoking the initializer.
@@ -160,8 +160,8 @@ export async function flat(...args) {
160
160
  args.map(async (arg) =>
161
161
  arg instanceof Array || typeof arg !== "object"
162
162
  ? arg
163
- : await Tree.values(arg)
164
- )
163
+ : await Tree.values(arg),
164
+ ),
165
165
  );
166
166
 
167
167
  return arrays.flat();
@@ -210,7 +210,7 @@ export async function inherited(depth, state) {
210
210
  for (let i = 0; i < depth; i++) {
211
211
  if (!current) {
212
212
  throw new ReferenceError(
213
- `Origami internal error: Can't find context object`
213
+ `Origami internal error: Can't find context object`,
214
214
  );
215
215
  }
216
216
  current = getParent(current);
@@ -255,7 +255,7 @@ export function lambda(length, parameters, code, state = {}) {
255
255
  newState = Object.assign({}, state, { stack: newStack });
256
256
  }
257
257
 
258
- const result = await evaluate(code, newState);
258
+ const result = await execute(code, newState);
259
259
  return result;
260
260
  }
261
261
 
@@ -460,7 +460,12 @@ addOpLabel(optional, "«ops.optional»");
460
460
  */
461
461
  export async function property(object, key) {
462
462
  if (object == null) {
463
- throw new ReferenceError();
463
+ /** @type {any} */
464
+ const error = new ReferenceError(
465
+ "Tried to get a property of something that doesn't exist.",
466
+ );
467
+ error.position = 1; // position of the bad argument
468
+ throw error;
464
469
  }
465
470
 
466
471
  if (isUnpackable(object)) {
@@ -511,7 +516,7 @@ addOpLabel(rootDirectory, "«ops.rootDirectory»");
511
516
  export async function scope(parent) {
512
517
  if (!parent) {
513
518
  throw new ReferenceError(
514
- "Tried to find a value in scope, but no container was provided as the parent."
519
+ "Tried to find a value in scope, but no container was provided as the parent.",
515
520
  );
516
521
  }
517
522
  return Tree.scope(parent);
@@ -2,4 +2,3 @@ export const codeSymbol = Symbol("code");
2
2
  export const configSymbol = Symbol("config");
3
3
  export const scopeSymbol = Symbol("scope");
4
4
  export const sourceSymbol = Symbol("source");
5
- export const warningSymbol = Symbol("warning");
@@ -1,7 +1,12 @@
1
1
  /**
2
- * Returns true if the two strings have a Damerau-Levenshtein distance of 1.
3
- * This will be true if the strings differ by a single insertion, deletion,
4
- * substitution, or transposition.
2
+ * Returns true if one string could be a typo of the other.
3
+ *
4
+ * We generally define a typo as two strings with a Damerau-Levenshtein distance
5
+ * of 1. This will be true if the strings differ by a single insertion,
6
+ * deletion, substitution, or transposition.
7
+ *
8
+ * Additionally, we consider two strings that differ only in case to be typos,
9
+ * as well as two strings that differ only by accents.
5
10
  *
6
11
  * @param {string} s1
7
12
  * @param {string} s2
@@ -15,6 +20,16 @@ export function isTypo(s1, s2) {
15
20
  return false;
16
21
  }
17
22
 
23
+ // If the strings are the same ignoring case, consider them typos
24
+ if (s1.toLowerCase() === s2.toLowerCase()) {
25
+ return true;
26
+ }
27
+
28
+ // If the strings are the same ignoring accents, consider them typos
29
+ if (normalize(s1) === normalize(s2)) {
30
+ return true;
31
+ }
32
+
18
33
  // If strings are both a single character, we don't want to consider them
19
34
  // typos.
20
35
  if (length1 === 1 && length2 === 1) {
@@ -66,6 +81,10 @@ export function isTypo(s1, s2) {
66
81
  return shorter === longer.slice(0, shorter.length);
67
82
  }
68
83
 
84
+ function normalize(str) {
85
+ return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
86
+ }
87
+
69
88
  /**
70
89
  * Return any strings that could be a typo of s
71
90
  *