mimo-lang 1.1.1 → 2.0.6

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 (165) hide show
  1. package/.gitattributes +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +71 -39
  4. package/adapters/browserAdapter.js +86 -0
  5. package/adapters/nodeAdapter.js +101 -0
  6. package/bin/cli.js +80 -0
  7. package/bin/commands/convert.js +27 -0
  8. package/bin/commands/doctor.js +139 -0
  9. package/bin/commands/eval.js +39 -0
  10. package/bin/commands/fmt.js +109 -0
  11. package/bin/commands/help.js +72 -0
  12. package/bin/commands/lint.js +117 -0
  13. package/bin/commands/repl.js +24 -0
  14. package/bin/commands/run.js +64 -0
  15. package/bin/commands/test.js +126 -0
  16. package/bin/utils/colors.js +38 -0
  17. package/bin/utils/formatError.js +47 -0
  18. package/bin/utils/fs.js +57 -0
  19. package/bin/utils/version.js +8 -0
  20. package/build.js +18 -0
  21. package/bun.lock +74 -0
  22. package/index.js +48 -77
  23. package/index.web.js +364 -0
  24. package/interpreter/BuiltinFunction.js +32 -0
  25. package/interpreter/ErrorHandler.js +120 -0
  26. package/interpreter/ExpressionEvaluator.js +106 -0
  27. package/interpreter/Interpreter.js +172 -0
  28. package/interpreter/MimoError.js +112 -0
  29. package/interpreter/ModuleLoader.js +236 -0
  30. package/interpreter/StatementExecutor.js +107 -0
  31. package/interpreter/Utils.js +82 -0
  32. package/interpreter/Values.js +87 -0
  33. package/interpreter/coreBuiltins.js +490 -0
  34. package/interpreter/environment.js +99 -0
  35. package/interpreter/evaluators/binaryExpressionEvaluator.js +111 -0
  36. package/interpreter/evaluators/collectionEvaluator.js +151 -0
  37. package/interpreter/evaluators/functionCallEvaluator.js +76 -0
  38. package/interpreter/evaluators/literalEvaluator.js +27 -0
  39. package/interpreter/evaluators/moduleAccessEvaluator.js +25 -0
  40. package/interpreter/evaluators/templateLiteralEvaluator.js +20 -0
  41. package/interpreter/executors/BaseExecutor.js +37 -0
  42. package/interpreter/executors/ControlFlowExecutor.js +206 -0
  43. package/interpreter/executors/FunctionExecutor.js +126 -0
  44. package/interpreter/executors/PatternMatchExecutor.js +93 -0
  45. package/interpreter/executors/VariableExecutor.js +144 -0
  46. package/interpreter/index.js +8 -0
  47. package/interpreter/stdlib/array/accessFunctions.js +61 -0
  48. package/interpreter/stdlib/array/arrayUtils.js +36 -0
  49. package/interpreter/stdlib/array/higherOrderFunctions.js +285 -0
  50. package/interpreter/stdlib/array/searchFunctions.js +77 -0
  51. package/interpreter/stdlib/array/setFunctions.js +49 -0
  52. package/interpreter/stdlib/array/transformationFunctions.js +68 -0
  53. package/interpreter/stdlib/array.js +85 -0
  54. package/interpreter/stdlib/assert.js +143 -0
  55. package/interpreter/stdlib/datetime.js +170 -0
  56. package/interpreter/stdlib/env.js +54 -0
  57. package/interpreter/stdlib/fs.js +161 -0
  58. package/interpreter/stdlib/http.js +92 -0
  59. package/interpreter/stdlib/json.js +70 -0
  60. package/interpreter/stdlib/math.js +309 -0
  61. package/interpreter/stdlib/object.js +142 -0
  62. package/interpreter/stdlib/path.js +69 -0
  63. package/interpreter/stdlib/regex.js +134 -0
  64. package/interpreter/stdlib/string.js +260 -0
  65. package/interpreter/suggestions.js +46 -0
  66. package/lexer/Lexer.js +245 -0
  67. package/lexer/TokenTypes.js +131 -0
  68. package/lexer/createToken.js +11 -0
  69. package/lexer/tokenizers/commentTokenizer.js +45 -0
  70. package/lexer/tokenizers/literalTokenizer.js +163 -0
  71. package/lexer/tokenizers/symbolTokenizer.js +69 -0
  72. package/lexer/tokenizers/whitespaceTokenizer.js +36 -0
  73. package/package.json +29 -13
  74. package/parser/ASTNodes.js +448 -0
  75. package/parser/Parser.js +188 -0
  76. package/parser/expressions/atomicExpressions.js +165 -0
  77. package/parser/expressions/conditionalExpressions.js +0 -0
  78. package/parser/expressions/operatorExpressions.js +79 -0
  79. package/parser/expressions/primaryExpressions.js +77 -0
  80. package/parser/parseStatement.js +184 -0
  81. package/parser/parserExpressions.js +115 -0
  82. package/parser/parserUtils.js +19 -0
  83. package/parser/statements/controlFlowParsers.js +106 -0
  84. package/parser/statements/functionParsers.js +314 -0
  85. package/parser/statements/moduleParsers.js +57 -0
  86. package/parser/statements/patternMatchParsers.js +124 -0
  87. package/parser/statements/variableParsers.js +155 -0
  88. package/repl.js +325 -0
  89. package/test.js +47 -0
  90. package/tools/PrettyPrinter.js +3 -0
  91. package/tools/convert/Args.js +46 -0
  92. package/tools/convert/Registry.js +91 -0
  93. package/tools/convert/Transpiler.js +78 -0
  94. package/tools/convert/plugins/README.md +66 -0
  95. package/tools/convert/plugins/alya/index.js +10 -0
  96. package/tools/convert/plugins/alya/to_alya.js +289 -0
  97. package/tools/convert/plugins/alya/visitors/expressions.js +257 -0
  98. package/tools/convert/plugins/alya/visitors/statements.js +403 -0
  99. package/tools/convert/plugins/base_converter.js +228 -0
  100. package/tools/convert/plugins/javascript/index.js +10 -0
  101. package/tools/convert/plugins/javascript/mimo_runtime.js +265 -0
  102. package/tools/convert/plugins/javascript/to_js.js +155 -0
  103. package/tools/convert/plugins/javascript/visitors/expressions.js +197 -0
  104. package/tools/convert/plugins/javascript/visitors/patterns.js +102 -0
  105. package/tools/convert/plugins/javascript/visitors/statements.js +236 -0
  106. package/tools/convert/plugins/python/index.js +10 -0
  107. package/tools/convert/plugins/python/mimo_runtime.py +811 -0
  108. package/tools/convert/plugins/python/to_py.js +329 -0
  109. package/tools/convert/plugins/python/visitors/expressions.js +272 -0
  110. package/tools/convert/plugins/python/visitors/patterns.js +100 -0
  111. package/tools/convert/plugins/python/visitors/statements.js +257 -0
  112. package/tools/convert.js +102 -0
  113. package/tools/format/CommentAttacher.js +190 -0
  114. package/tools/format/CommentLexer.js +152 -0
  115. package/tools/format/Printer.js +849 -0
  116. package/tools/format/config.js +107 -0
  117. package/tools/formatter.js +169 -0
  118. package/tools/lint/Linter.js +391 -0
  119. package/tools/lint/config.js +114 -0
  120. package/tools/lint/rules/consistent-return.js +62 -0
  121. package/tools/lint/rules/max-depth.js +56 -0
  122. package/tools/lint/rules/no-empty-function.js +45 -0
  123. package/tools/lint/rules/no-magic-numbers.js +46 -0
  124. package/tools/lint/rules/no-shadow.js +113 -0
  125. package/tools/lint/rules/no-unused-vars.js +26 -0
  126. package/tools/lint/rules/prefer-const.js +19 -0
  127. package/tools/linter.js +261 -0
  128. package/tools/replFormatter.js +93 -0
  129. package/tools/stamp-version.js +32 -0
  130. package/web/index.js +9 -0
  131. package/bun.lockb +0 -0
  132. package/cli.js +0 -84
  133. package/compiler/execute/interpreter.js +0 -68
  134. package/compiler/execute/interpreters/binary.js +0 -12
  135. package/compiler/execute/interpreters/call.js +0 -10
  136. package/compiler/execute/interpreters/if.js +0 -10
  137. package/compiler/execute/interpreters/try-catch.js +0 -10
  138. package/compiler/execute/interpreters/while.js +0 -8
  139. package/compiler/execute/utils/createfunction.js +0 -11
  140. package/compiler/execute/utils/evaluate.js +0 -20
  141. package/compiler/execute/utils/operate.js +0 -23
  142. package/compiler/lexer/processToken.js +0 -40
  143. package/compiler/lexer/tokenTypes.js +0 -4
  144. package/compiler/lexer/tokenizer.js +0 -74
  145. package/compiler/parser/expression/comparison.js +0 -18
  146. package/compiler/parser/expression/identifier.js +0 -29
  147. package/compiler/parser/expression/number.js +0 -10
  148. package/compiler/parser/expression/operator.js +0 -21
  149. package/compiler/parser/expression/punctuation.js +0 -31
  150. package/compiler/parser/expression/string.js +0 -6
  151. package/compiler/parser/parseExpression.js +0 -27
  152. package/compiler/parser/parseStatement.js +0 -34
  153. package/compiler/parser/parser.js +0 -45
  154. package/compiler/parser/statement/call.js +0 -26
  155. package/compiler/parser/statement/function.js +0 -29
  156. package/compiler/parser/statement/if.js +0 -34
  157. package/compiler/parser/statement/return.js +0 -10
  158. package/compiler/parser/statement/set.js +0 -11
  159. package/compiler/parser/statement/show.js +0 -10
  160. package/compiler/parser/statement/try-catch.js +0 -25
  161. package/compiler/parser/statement/while.js +0 -22
  162. package/converter/go/convert.js +0 -110
  163. package/converter/js/convert.js +0 -107
  164. package/jsconfig.json +0 -27
  165. package/vite.config.js +0 -17
@@ -0,0 +1,107 @@
1
+ /**
2
+ * tools/format/config.js
3
+ *
4
+ * Loads and merges formatter options from .mimorc or a caller-supplied object.
5
+ * Uses an injected readFileFn so this module is safe in bundled/browser environments.
6
+ *
7
+ * .mimorc format (JSON):
8
+ * {
9
+ * "format": {
10
+ * "indentSize": 4,
11
+ * "useTabs": false,
12
+ * "quoteStyle": "double",
13
+ * "maxInlineArrayLength": 100,
14
+ * "maxInlineObjectLength": 80
15
+ * }
16
+ * }
17
+ */
18
+
19
+ export const DEFAULT_FORMAT_OPTIONS = {
20
+ indentSize: 4,
21
+ useTabs: false,
22
+ quoteStyle: 'double', // 'double' | 'single'
23
+ maxInlineArrayLength: 100,
24
+ maxInlineObjectLength: 80,
25
+ };
26
+
27
+ /**
28
+ * Find the nearest .mimorc file by walking up from startDir.
29
+ * @param {string} startDir Absolute directory path to start searching from.
30
+ * @param {Function} existsFn (path: string) => boolean
31
+ * @param {Function} joinFn path.join equivalent
32
+ * @param {Function} dirnameFn path.dirname equivalent
33
+ * @returns {string|null} Absolute path to .mimorc, or null if not found.
34
+ */
35
+ export function findConfigFile(startDir, existsFn, joinFn, dirnameFn) {
36
+ let dir = startDir;
37
+ while (true) {
38
+ const candidate = joinFn(dir, '.mimorc');
39
+ if (existsFn(candidate)) return candidate;
40
+ const parent = dirnameFn(dir);
41
+ if (parent === dir) return null; // filesystem root
42
+ dir = parent;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Load and parse a .mimorc file, returning only the `format` section.
48
+ * @param {string} configPath Absolute path to .mimorc.
49
+ * @param {Function} readFileFn (path: string) => string — injected so this works in bundlers.
50
+ * @returns {object} Raw format options from the config file (may be partial).
51
+ */
52
+ export function loadConfig(configPath, readFileFn) {
53
+ try {
54
+ const raw = readFileFn(configPath);
55
+ const parsed = JSON.parse(raw);
56
+ return parsed.format ?? {};
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Merge format options: defaults < config file < caller-supplied overrides.
64
+ * All values are validated; unknown keys and invalid values are silently ignored.
65
+ * @param {object} fromConfig Options from .mimorc (may be partial or empty).
66
+ * @param {object} fromCaller Options passed directly by the caller (highest priority).
67
+ * @returns {object} Fully-resolved format options object.
68
+ */
69
+ export function mergeOptions(fromConfig = {}, fromCaller = {}) {
70
+ const merged = { ...DEFAULT_FORMAT_OPTIONS };
71
+
72
+ // Apply config-file values
73
+ if (typeof fromConfig.indentSize === 'number' && fromConfig.indentSize > 0) {
74
+ merged.indentSize = fromConfig.indentSize;
75
+ }
76
+ if (typeof fromConfig.useTabs === 'boolean') {
77
+ merged.useTabs = fromConfig.useTabs;
78
+ }
79
+ if (fromConfig.quoteStyle === 'single' || fromConfig.quoteStyle === 'double') {
80
+ merged.quoteStyle = fromConfig.quoteStyle;
81
+ }
82
+ if (typeof fromConfig.maxInlineArrayLength === 'number' && fromConfig.maxInlineArrayLength > 0) {
83
+ merged.maxInlineArrayLength = fromConfig.maxInlineArrayLength;
84
+ }
85
+ if (typeof fromConfig.maxInlineObjectLength === 'number' && fromConfig.maxInlineObjectLength > 0) {
86
+ merged.maxInlineObjectLength = fromConfig.maxInlineObjectLength;
87
+ }
88
+
89
+ // Apply caller-supplied overrides (same validation)
90
+ if (typeof fromCaller.indentSize === 'number' && fromCaller.indentSize > 0) {
91
+ merged.indentSize = fromCaller.indentSize;
92
+ }
93
+ if (typeof fromCaller.useTabs === 'boolean') {
94
+ merged.useTabs = fromCaller.useTabs;
95
+ }
96
+ if (fromCaller.quoteStyle === 'single' || fromCaller.quoteStyle === 'double') {
97
+ merged.quoteStyle = fromCaller.quoteStyle;
98
+ }
99
+ if (typeof fromCaller.maxInlineArrayLength === 'number' && fromCaller.maxInlineArrayLength > 0) {
100
+ merged.maxInlineArrayLength = fromCaller.maxInlineArrayLength;
101
+ }
102
+ if (typeof fromCaller.maxInlineObjectLength === 'number' && fromCaller.maxInlineObjectLength > 0) {
103
+ merged.maxInlineObjectLength = fromCaller.maxInlineObjectLength;
104
+ }
105
+
106
+ return merged;
107
+ }
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'url';
5
+ import { Parser } from '../parser/Parser.js';
6
+ import { Lexer } from '../lexer/Lexer.js';
7
+ import { PrettyPrinter } from './format/Printer.js';
8
+ import { findConfigFile, loadConfig, mergeOptions } from './format/config.js';
9
+ import { extractComments } from './format/CommentLexer.js';
10
+ import { attachComments } from './format/CommentAttacher.js';
11
+
12
+ // ── Config resolution helpers (Node-only, safe to tree-shake in browser builds) ──
13
+
14
+ function _resolveOptions(filePath, callerOptions = {}) {
15
+ try {
16
+ const startDir = path.dirname(path.resolve(filePath));
17
+ const configPath = findConfigFile(
18
+ startDir,
19
+ fs.existsSync,
20
+ path.join,
21
+ path.dirname,
22
+ );
23
+ const fromConfig = configPath
24
+ ? loadConfig(configPath, (p) => fs.readFileSync(p, 'utf-8'))
25
+ : {};
26
+ return mergeOptions(fromConfig, callerOptions);
27
+ } catch {
28
+ return mergeOptions({}, callerOptions);
29
+ }
30
+ }
31
+
32
+ // ── Public API ────────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Formats Mimo source code from a string.
36
+ * Comments in the source are preserved in the output.
37
+ *
38
+ * @param {string} source The Mimo source code to format.
39
+ * @param {string} [filePath] Optional file path used for error reporting and config lookup.
40
+ * @param {object} [options] Optional format options (override .mimorc).
41
+ * @returns {string} The formatted source code.
42
+ */
43
+ export function formatSource(source, filePath = 'snippet.mimo', options = {}) {
44
+ // 1. Extract comments from raw source (formatter-only path, main Lexer untouched)
45
+ const { comments } = extractComments(source);
46
+
47
+ // 2. Lex + parse (comments are silently dropped by the main Lexer, as always)
48
+ const lexer = new Lexer(source, filePath);
49
+ const tokens = [];
50
+ let token;
51
+ while ((token = lexer.nextToken()) !== null) {
52
+ tokens.push(token);
53
+ }
54
+ const parser = new Parser(tokens, filePath);
55
+ const ast = parser.parse();
56
+
57
+ // 3. Re-attach extracted comments to AST nodes
58
+ attachComments(ast, comments);
59
+
60
+ // 4. Pretty-print with resolved options
61
+ const resolved = _resolveOptions(filePath, options);
62
+ const printer = new PrettyPrinter(resolved);
63
+ return printer.format(ast);
64
+ }
65
+
66
+ /**
67
+ * Formats a Mimo file on disk.
68
+ * @param {string} filePath Path to the file.
69
+ * @param {object} [options] Options: { write, check, quiet, ...formatOptions }
70
+ * @returns {{ ok: boolean, changed: boolean, error?: Error }}
71
+ */
72
+ export function formatFile(filePath, options = {}) {
73
+ const { write = false, check = false, quiet = false, ...formatOptions } = options;
74
+ if (!quiet) console.log(`Formatting ${filePath}...`);
75
+
76
+ try {
77
+ const source = fs.readFileSync(filePath, 'utf-8');
78
+ const formattedSource = formatSource(source, filePath, formatOptions);
79
+ const changed = formattedSource !== source;
80
+
81
+ if (write) {
82
+ if (changed) {
83
+ fs.writeFileSync(filePath, formattedSource, 'utf-8');
84
+ if (!quiet) console.log('✅ File formatted successfully.');
85
+ } else if (!quiet) {
86
+ console.log('✅ Already formatted.');
87
+ }
88
+ return { ok: true, changed };
89
+ }
90
+
91
+ if (check) {
92
+ if (changed) {
93
+ console.log(`❌ ${filePath} is not formatted.`);
94
+ } else if (!quiet) {
95
+ console.log(`✅ ${filePath} is formatted.`);
96
+ }
97
+ return { ok: true, changed };
98
+ }
99
+
100
+ // Preview mode (no flag)
101
+ console.log('\n--- Formatted Output ---');
102
+ console.log(formattedSource);
103
+ console.log('--- End Output ---');
104
+ console.log('\nUse --write flag to apply changes to the file.');
105
+ return { ok: true, changed };
106
+ } catch (err) {
107
+ console.error(`❌ Error formatting file ${filePath}:`);
108
+ if (err.format) {
109
+ const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
110
+ const lines = content.split('\n');
111
+ const snippet = lines[(err.location?.line ?? 1) - 1] || '';
112
+ console.error(err.format(snippet));
113
+ } else {
114
+ console.error(err.message);
115
+ }
116
+ return { ok: false, changed: false, error: err };
117
+ }
118
+ }
119
+
120
+ // ── Stdin helper ──────────────────────────────────────────────────────────────
121
+
122
+ async function readStdin() {
123
+ return new Promise((resolve) => {
124
+ let data = '';
125
+ process.stdin.setEncoding('utf-8');
126
+ process.stdin.on('data', (chunk) => { data += chunk; });
127
+ process.stdin.on('end', () => { resolve(data); });
128
+ });
129
+ }
130
+
131
+ // ── CLI entry point ───────────────────────────────────────────────────────────
132
+
133
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
134
+ const args = process.argv.slice(2);
135
+ const shouldWrite = args.includes('--write');
136
+ const shouldCheck = args.includes('--check');
137
+ const quiet = args.includes('--quiet');
138
+ const filePaths = args.filter(arg => !arg.startsWith('--'));
139
+
140
+ if (filePaths.length === 0 || filePaths.includes('-')) {
141
+ if (!process.stdin.isTTY || filePaths.includes('-')) {
142
+ readStdin().then(source => {
143
+ try {
144
+ const formatted = formatSource(source, 'stdin');
145
+ process.stdout.write(formatted);
146
+ } catch (err) {
147
+ console.error(err.message);
148
+ process.exit(1);
149
+ }
150
+ });
151
+ } else {
152
+ console.error('Error: No file path provided and STDIN is a TTY.');
153
+ console.log('Usage: node tools/formatter.js [--write|--check] [--quiet] <file1.mimo> ...');
154
+ console.log('Or pipe source to stdin: echo "..." | node tools/formatter.js');
155
+ process.exit(1);
156
+ }
157
+ } else {
158
+ let hadErrors = false;
159
+ let hadUnformatted = false;
160
+
161
+ filePaths.forEach(p => {
162
+ const result = formatFile(p, { write: shouldWrite, check: shouldCheck, quiet });
163
+ if (!result.ok) hadErrors = true;
164
+ if (shouldCheck && result.changed) hadUnformatted = true;
165
+ });
166
+
167
+ if (hadErrors || hadUnformatted) process.exit(1);
168
+ }
169
+ }
@@ -0,0 +1,391 @@
1
+ // tools/lint/Linter.js
2
+
3
+ // Import all rules from the rules directory.
4
+ // New rules must be added here manually (keeps bundler compatibility).
5
+ import { noUnusedVars } from './rules/no-unused-vars.js';
6
+ import { preferConst } from './rules/prefer-const.js';
7
+ import { noMagicNumbers } from './rules/no-magic-numbers.js';
8
+ import { noEmptyFunction } from './rules/no-empty-function.js';
9
+ import { maxDepth } from './rules/max-depth.js';
10
+ import { noShadow } from './rules/no-shadow.js';
11
+ import { consistentReturn } from './rules/consistent-return.js';
12
+
13
+ /**
14
+ * Central registry of all available lint rules.
15
+ *
16
+ * Keys are rule IDs (used in config and CLI flags).
17
+ * Values are rule modules that implement { meta, create(context) }.
18
+ *
19
+ * To add a new rule:
20
+ * 1. Create tools/lint/rules/<rule-id>.js
21
+ * 2. Add an import above
22
+ * 3. Add an entry here
23
+ */
24
+ export const RULES = {
25
+ 'no-unused-vars': noUnusedVars,
26
+ 'prefer-const': preferConst,
27
+ 'no-magic-numbers': noMagicNumbers,
28
+ 'no-empty-function': noEmptyFunction,
29
+ 'max-depth': maxDepth,
30
+ 'no-shadow': noShadow,
31
+ 'consistent-return': consistentReturn,
32
+ };
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Scope model
36
+ // ---------------------------------------------------------------------------
37
+
38
+ class LinterScope {
39
+ constructor(parent) {
40
+ this.parent = parent;
41
+ this.children = [];
42
+ /** @type {Map<string, { name, kind, node, used, reassigned, isExported }>} */
43
+ this.variables = new Map();
44
+ if (parent) parent.children.push(this);
45
+ }
46
+
47
+ declare(name, info) {
48
+ this.variables.set(name, { name, ...info, used: false, reassigned: false });
49
+ }
50
+
51
+ markAsUsed(name) {
52
+ if (this.variables.has(name)) {
53
+ this.variables.get(name).used = true;
54
+ } else if (this.parent) {
55
+ this.parent.markAsUsed(name);
56
+ }
57
+ }
58
+
59
+ markAsReassigned(name) {
60
+ if (this.variables.has(name)) {
61
+ this.variables.get(name).reassigned = true;
62
+ } else if (this.parent) {
63
+ this.parent.markAsReassigned(name);
64
+ }
65
+ }
66
+
67
+ resolveVariable(name) {
68
+ if (this.variables.has(name)) return this.variables.get(name);
69
+ return this.parent ? this.parent.resolveVariable(name) : null;
70
+ }
71
+
72
+ /** Find which scope actually owns the binding for `name` (not inherited). */
73
+ owningScope(name) {
74
+ if (this.variables.has(name)) return this;
75
+ return this.parent ? this.parent.owningScope(name) : null;
76
+ }
77
+
78
+ getUnusedVariables() {
79
+ let unused = [];
80
+ for (const info of this.variables.values()) {
81
+ if (!info.used && !info.isExported) unused.push(info);
82
+ }
83
+ for (const child of this.children) {
84
+ unused = unused.concat(child.getUnusedVariables());
85
+ }
86
+ return unused;
87
+ }
88
+
89
+ getUnmutatedLets() {
90
+ let unmutated = Array.from(this.variables.values())
91
+ .filter(info => info.kind === 'let' && !info.reassigned);
92
+ for (const child of this.children) {
93
+ unmutated = unmutated.concat(child.getUnmutatedLets());
94
+ }
95
+ return unmutated;
96
+ }
97
+ }
98
+
99
+ class LinterScopeTracker {
100
+ constructor() {
101
+ this.globalScope = new LinterScope(null);
102
+ this.currentScope = this.globalScope;
103
+ /** Nesting depth of block-creating nodes (for max-depth rule). */
104
+ this.currentDepth = 0;
105
+ }
106
+
107
+ enterNode(node, parent) {
108
+ // ------------------------------------------------------------------
109
+ // Scope creation
110
+ // ------------------------------------------------------------------
111
+ const createsScope =
112
+ node.type === 'FunctionDeclaration' ||
113
+ node.type === 'AnonymousFunction';
114
+
115
+ const createsBlockScope =
116
+ node.type === 'ForStatement' ||
117
+ node.type === 'WhileStatement' ||
118
+ node.type === 'LoopStatement' ||
119
+ node.type === 'IfStatement' ||
120
+ node.type === 'MatchStatement' ||
121
+ node.type === 'CaseClause' ||
122
+ node.type === 'TryStatement';
123
+
124
+ if (createsScope || createsBlockScope) {
125
+ this.currentScope = new LinterScope(this.currentScope);
126
+ }
127
+
128
+ if (createsScope) {
129
+ // Declare parameters in the new function scope
130
+ (node.params || []).forEach(p =>
131
+ this.currentScope.declare(p.name, { kind: 'parameter', node: p })
132
+ );
133
+ if (node.restParam) {
134
+ this.currentScope.declare(node.restParam.name, { kind: 'parameter', node: node.restParam });
135
+ }
136
+ }
137
+
138
+ if (createsBlockScope) {
139
+ this.currentDepth++;
140
+ }
141
+
142
+ // ------------------------------------------------------------------
143
+ // Declaration registration
144
+ // ------------------------------------------------------------------
145
+ if (node.type === 'VariableDeclaration' && typeof node.identifier === 'string') {
146
+ if (node.kind === 'set') {
147
+ const existing = this.currentScope.resolveVariable(node.identifier);
148
+ if (existing) {
149
+ this.currentScope.markAsReassigned(node.identifier);
150
+ } else {
151
+ this.currentScope.declare(node.identifier, {
152
+ kind: node.kind,
153
+ node,
154
+ isExported: node.isExported,
155
+ });
156
+ }
157
+ } else {
158
+ this.currentScope.declare(node.identifier, {
159
+ kind: node.kind,
160
+ node,
161
+ isExported: node.isExported,
162
+ });
163
+ }
164
+ }
165
+
166
+ if (node.type === 'FunctionDeclaration') {
167
+ // Function name is declared in the *parent* scope
168
+ this.currentScope.parent.declare(node.name, {
169
+ kind: 'function',
170
+ node,
171
+ isExported: node.isExported,
172
+ });
173
+ }
174
+
175
+ if (node.type === 'DestructuringAssignment') {
176
+ if (node.pattern.type === 'ArrayPattern') {
177
+ node.pattern.elements.forEach(id =>
178
+ this.currentScope.declare(id.name, { kind: 'let', node: id })
179
+ );
180
+ } else if (node.pattern.type === 'ObjectPattern') {
181
+ node.pattern.properties.forEach(id =>
182
+ this.currentScope.declare(id.name, { kind: 'let', node: id })
183
+ );
184
+ }
185
+ }
186
+
187
+ // ------------------------------------------------------------------
188
+ // Usage tracking
189
+ // ------------------------------------------------------------------
190
+ if (node.type === 'Identifier') {
191
+ const isDeclarationSite =
192
+ (parent?.type === 'VariableDeclaration' && parent.identifier === node.name) ||
193
+ (parent?.type === 'FunctionDeclaration' && parent.name === node.name) ||
194
+ (parent?.type === 'FunctionDeclaration' && parent.params?.includes(node)) ||
195
+ (parent?.type === 'AnonymousFunction' && parent.params?.includes(node)) ||
196
+ (parent?.type === 'FunctionDeclaration' && parent.restParam === node) ||
197
+ (parent?.type === 'AnonymousFunction' && parent.restParam === node) ||
198
+ (parent?.type === 'CallStatement' && parent.destination === node);
199
+
200
+ if (!isDeclarationSite) {
201
+ this.currentScope.markAsUsed(node.name);
202
+ }
203
+ }
204
+
205
+ // A `set` that targets an existing variable is also a reassignment
206
+ if (node.type === 'VariableDeclaration' && node.kind === 'set' && typeof node.identifier === 'string') {
207
+ const local = this.currentScope.variables.get(node.identifier);
208
+ if (!local || local.node !== node) {
209
+ this.currentScope.markAsReassigned(node.identifier);
210
+ }
211
+ }
212
+ }
213
+
214
+ exitNode(node) {
215
+ const createsScope =
216
+ node.type === 'FunctionDeclaration' ||
217
+ node.type === 'AnonymousFunction';
218
+
219
+ const createsBlockScope =
220
+ node.type === 'ForStatement' ||
221
+ node.type === 'WhileStatement' ||
222
+ node.type === 'LoopStatement' ||
223
+ node.type === 'IfStatement' ||
224
+ node.type === 'MatchStatement' ||
225
+ node.type === 'CaseClause' ||
226
+ node.type === 'TryStatement';
227
+
228
+ if ((createsScope || createsBlockScope) && this.currentScope.parent) {
229
+ this.currentScope = this.currentScope.parent;
230
+ }
231
+
232
+ if (createsBlockScope) {
233
+ this.currentDepth--;
234
+ }
235
+ }
236
+
237
+ /** Returns the tracker itself so rules can call any public method. */
238
+ getScope() { return this; }
239
+ getUnusedVariables() { return this.globalScope.getUnusedVariables(); }
240
+ getUnmutatedLets() { return this.globalScope.getUnmutatedLets(); }
241
+ getCurrentScope() { return this.currentScope; }
242
+ getCurrentDepth() { return this.currentDepth; }
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Linter
247
+ // ---------------------------------------------------------------------------
248
+
249
+ export class Linter {
250
+ /**
251
+ * @param {Object} options
252
+ * @param {Object} options.rules - Map of ruleId → true | false | { severity?, ...options }
253
+ * - `true` : enable with defaults
254
+ * - `false` : disable
255
+ * - object : enable with per-rule options; may include `severity: 'error'|'warning'`
256
+ */
257
+ constructor(options = {}) {
258
+ this.messages = [];
259
+ this.ancestry = [];
260
+ this.ruleConfig = options.rules || {};
261
+ }
262
+
263
+ verify(ast, sourceCode, filePath) {
264
+ this.messages = [];
265
+ this.ancestry = [];
266
+
267
+ const scopeTracker = new LinterScopeTracker();
268
+ const ruleListeners = this.initializeRules(scopeTracker);
269
+
270
+ this.traverse(ast, null, ruleListeners, scopeTracker);
271
+ this.triggerExitListeners(ast, ruleListeners);
272
+
273
+ // Sort messages by line then column
274
+ this.messages.sort((a, b) => a.line - b.line || a.column - b.column);
275
+
276
+ return this.messages;
277
+ }
278
+
279
+ // -------------------------------------------------------------------------
280
+ // Rule initialisation
281
+ // -------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Resolve the effective config entry for a rule.
285
+ * Returns `null` if the rule is disabled.
286
+ * Returns `{ severity, options }` if enabled.
287
+ */
288
+ _resolveRuleConfig(ruleId, ruleMeta) {
289
+ const entry = this.ruleConfig[ruleId];
290
+
291
+ // Explicitly disabled
292
+ if (entry === false) return null;
293
+
294
+ // Enabled with defaults
295
+ if (entry === true || entry === undefined) {
296
+ const defaultSeverity = (ruleMeta?.defaultSeverity) || 'warning';
297
+ return { severity: defaultSeverity, options: {} };
298
+ }
299
+
300
+ // Object config: { severity?, ...options }
301
+ if (typeof entry === 'object') {
302
+ const { severity, ...options } = entry;
303
+ const defaultSeverity = (ruleMeta?.defaultSeverity) || 'warning';
304
+ return { severity: severity || defaultSeverity, options };
305
+ }
306
+
307
+ return null;
308
+ }
309
+
310
+ initializeRules(scopeTracker) {
311
+ const listeners = {};
312
+
313
+ for (const ruleId in RULES) {
314
+ const ruleModule = RULES[ruleId];
315
+ const resolved = this._resolveRuleConfig(ruleId, ruleModule.meta);
316
+
317
+ if (!resolved) continue; // disabled
318
+
319
+ const context = {
320
+ getScope: () => scopeTracker,
321
+ getParent: () => this.ancestry[this.ancestry.length - 2] || null,
322
+ options: resolved.options,
323
+ report: (descriptor) => this.report({
324
+ ...descriptor,
325
+ ruleId,
326
+ severity: descriptor.severity || resolved.severity,
327
+ }),
328
+ };
329
+
330
+ const ruleListeners = ruleModule.create(context);
331
+
332
+ for (const event in ruleListeners) {
333
+ if (!listeners[event]) listeners[event] = [];
334
+ listeners[event].push(ruleListeners[event]);
335
+ }
336
+ }
337
+
338
+ return listeners;
339
+ }
340
+
341
+ // -------------------------------------------------------------------------
342
+ // Reporting
343
+ // -------------------------------------------------------------------------
344
+
345
+ report(descriptor) {
346
+ const { node, message, ruleId, severity = 'warning' } = descriptor;
347
+ this.messages.push({
348
+ ruleId,
349
+ message,
350
+ severity,
351
+ line: node.line,
352
+ column: node.column,
353
+ endColumn: node.column + (node.length || 1),
354
+ });
355
+ }
356
+
357
+ // -------------------------------------------------------------------------
358
+ // Traversal
359
+ // -------------------------------------------------------------------------
360
+
361
+ traverse(node, parent, listeners, scope) {
362
+ if (!node || typeof node !== 'object' || !node.type) return;
363
+
364
+ this.ancestry.push(node);
365
+ scope.enterNode(node, parent);
366
+
367
+ // Fire entry listeners for this node type
368
+ (listeners[node.type] || []).forEach(fn => fn(node));
369
+
370
+ // Recurse into children
371
+ for (const key in node) {
372
+ if (key === 'parent') continue;
373
+ const child = node[key];
374
+ if (Array.isArray(child)) {
375
+ child.forEach(item => this.traverse(item, node, listeners, scope));
376
+ } else if (child && typeof child === 'object' && child.type) {
377
+ this.traverse(child, node, listeners, scope);
378
+ }
379
+ }
380
+
381
+ // Fire exit listeners: "NodeType:exit"
382
+ (listeners[`${node.type}:exit`] || []).forEach(fn => fn(node));
383
+
384
+ scope.exitNode(node);
385
+ this.ancestry.pop();
386
+ }
387
+
388
+ triggerExitListeners(programNode, listeners) {
389
+ (listeners['Program_exit'] || []).forEach(fn => fn(programNode));
390
+ }
391
+ }