prettier-plugin-wolfram 0.7.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.
- package/LICENSE +7 -0
- package/README.md +290 -0
- package/bin/prettier-wolfram.js +55 -0
- package/package.json +58 -0
- package/src/index.js +80 -0
- package/src/options.js +206 -0
- package/src/parser/adapter.js +690 -0
- package/src/parser/cstEqual.js +18 -0
- package/src/parser/index.js +29 -0
- package/src/parser/operators.js +35 -0
- package/src/parser/position.js +62 -0
- package/src/parser/tree-sitter-wolfram.wasm +0 -0
- package/src/range.js +98 -0
- package/src/rules/index.js +57 -0
- package/src/rules/line-width.js +129 -0
- package/src/rules/newlines-between-definitions.js +103 -0
- package/src/rules/no-bare-symbol-set.js +19 -0
- package/src/rules/no-dynamic-module-leak.js +74 -0
- package/src/rules/no-general-infix-function.js +52 -0
- package/src/rules/no-shadowed-pattern.js +71 -0
- package/src/rules/no-unused-module-var.js +84 -0
- package/src/rules/prefer-rule-delayed.js +59 -0
- package/src/rules/spacing-commas.js +64 -0
- package/src/rules/spacing-operators.js +87 -0
- package/src/translator/commentSpacing.js +51 -0
- package/src/translator/docComments.js +89 -0
- package/src/translator/index.js +98 -0
- package/src/translator/nodes/binary.js +205 -0
- package/src/translator/nodes/call.js +254 -0
- package/src/translator/nodes/compound.js +117 -0
- package/src/translator/nodes/container.js +194 -0
- package/src/translator/nodes/group.js +159 -0
- package/src/translator/nodes/infix.js +408 -0
- package/src/translator/nodes/leaf.js +605 -0
- package/src/translator/nodes/postfix.js +29 -0
- package/src/translator/nodes/prefix.js +27 -0
- package/src/translator/nodes/ternary.js +82 -0
- package/src/translator/ruleAlignment.js +133 -0
- package/src/translator/sourceLines.js +49 -0
- package/src/translator/sourcePreservation.js +22 -0
- package/src/translator/specialForms.js +665 -0
- package/src/utils/codeSpacing.js +420 -0
- package/src/utils/cstErrors.js +36 -0
- package/src/utils/offsets.js +132 -0
- package/src/utils/operatorSpacing.js +49 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const TRIVIA = new Set(["Whitespace", "Token`Whitespace", "Newline", "Token`Newline"]);
|
|
2
|
+
|
|
3
|
+
export function stripTrivia(node) {
|
|
4
|
+
if (!node || typeof node !== "object") return node;
|
|
5
|
+
const copy = { ...node };
|
|
6
|
+
delete copy.locStart; delete copy.locEnd; // offsets are derived downstream
|
|
7
|
+
if (Array.isArray(copy.children)) {
|
|
8
|
+
copy.children = copy.children
|
|
9
|
+
.filter((c) => !(c?.type === "LeafNode" && TRIVIA.has(c.kind)))
|
|
10
|
+
.map(stripTrivia);
|
|
11
|
+
}
|
|
12
|
+
if (copy.head) copy.head = stripTrivia(copy.head);
|
|
13
|
+
return copy;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function cstEqualModuloTrivia(a, b) {
|
|
17
|
+
return JSON.stringify(stripTrivia(a)) === JSON.stringify(stripTrivia(b));
|
|
18
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Parser, Language } from "web-tree-sitter";
|
|
2
|
+
import { dirname, resolve } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import { adapt } from "./adapter.js";
|
|
6
|
+
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const WASM_PATH = resolve(here, "tree-sitter-wolfram.wasm");
|
|
9
|
+
|
|
10
|
+
let _langPromise = null;
|
|
11
|
+
async function getLanguage() {
|
|
12
|
+
if (!_langPromise) {
|
|
13
|
+
_langPromise = (async () => {
|
|
14
|
+
await Parser.init();
|
|
15
|
+
return Language.load(readFileSync(WASM_PATH));
|
|
16
|
+
})();
|
|
17
|
+
}
|
|
18
|
+
return _langPromise;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class WolframParser {
|
|
22
|
+
async getCST(sourceText) {
|
|
23
|
+
const lang = await getLanguage();
|
|
24
|
+
const parser = new Parser();
|
|
25
|
+
parser.setLanguage(lang);
|
|
26
|
+
const tree = parser.parse(sourceText);
|
|
27
|
+
return adapt(tree, sourceText);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const INFIX_OPS = {
|
|
2
|
+
",": "Comma", ";": "CompoundExpression",
|
|
3
|
+
"+": "Plus", "-": "Plus", "*": "Times", "/": "Divide",
|
|
4
|
+
".": "Dot", "**": "NonCommutativeMultiply",
|
|
5
|
+
"~~": "StringExpression", "<>": "StringJoin",
|
|
6
|
+
"|": "Alternatives", "||": "Or", "&&": "And",
|
|
7
|
+
"===": "SameQ", "=!=": "UnsameQ",
|
|
8
|
+
"==": "Equal", "!=": "Unequal", "<": "Less", "<=": "LessEqual",
|
|
9
|
+
">": "Greater", ">=": "GreaterEqual",
|
|
10
|
+
"@*": "Composition", "/*": "RightComposition",
|
|
11
|
+
};
|
|
12
|
+
export const BINARY_OPS = {
|
|
13
|
+
"=": "Set", ":=": "SetDelayed", "^=": "UpSet", "^:=": "UpSetDelayed",
|
|
14
|
+
"->": "Rule", ":>": "RuleDelayed", "<->": "TwoWayRule", "|->": "Function",
|
|
15
|
+
"/;": "Condition", "/.": "ReplaceAll", "//.": "ReplaceRepeated",
|
|
16
|
+
"/:": "TagSet", "//": "BinarySlashSlash", "//=": "ApplyTo",
|
|
17
|
+
"+=": "AddTo", "-=": "SubtractFrom", "*=": "TimesBy", "/=": "DivideBy",
|
|
18
|
+
"/": "Divide",
|
|
19
|
+
"^": "Power", "@": "BinaryAt", "@@": "Apply", "@@@": "Apply",
|
|
20
|
+
"/@": "Map", "//@": "MapAll", "?": "PatternTest", ":": "Pattern",
|
|
21
|
+
};
|
|
22
|
+
export const PREFIX_OPS = {
|
|
23
|
+
"-": "Minus", "+": "Plus", "!": "Not", "!!": "Not",
|
|
24
|
+
"++": "PreIncrement", "--": "PreDecrement",
|
|
25
|
+
};
|
|
26
|
+
export const POSTFIX_OPS = {
|
|
27
|
+
"&": "Function", "..": "Repeated", "...": "RepeatedNull",
|
|
28
|
+
"'": "Derivative", "!": "Factorial", "!!": "Factorial2",
|
|
29
|
+
"++": "Increment", "--": "Decrement", "=.": "Unset",
|
|
30
|
+
};
|
|
31
|
+
export function opName(table, literal) {
|
|
32
|
+
const op = table[literal];
|
|
33
|
+
if (op === undefined) throw new Error(`unmapped operator: ${JSON.stringify(literal)}`);
|
|
34
|
+
return op;
|
|
35
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Convert tree-sitter character offsets to CodeParser 1-based [line,col] source.
|
|
2
|
+
//
|
|
3
|
+
// CodeParser (WL's official parser) uses a byte-based column model with two quirks:
|
|
4
|
+
// 1. Non-ASCII UTF-8 characters are counted by byte width, not character count.
|
|
5
|
+
// 2. Tab characters (\t, byte 0x09) are counted as 2 bytes/columns wide.
|
|
6
|
+
//
|
|
7
|
+
// web-tree-sitter uses JS character (UTF-16 code unit) offsets internally, so we
|
|
8
|
+
// pre-build a char→"WL byte" offset map here and use it in nodeSource().
|
|
9
|
+
|
|
10
|
+
export function makeLineIndex(source) {
|
|
11
|
+
// charToWlOffset[i] = WL byte offset of the i-th JS code unit in source.
|
|
12
|
+
const charToWlOffset = new Int32Array(source.length + 1);
|
|
13
|
+
let wlOff = 0;
|
|
14
|
+
for (let i = 0; i < source.length; i++) {
|
|
15
|
+
charToWlOffset[i] = wlOff;
|
|
16
|
+
const code = source.charCodeAt(i);
|
|
17
|
+
if (code === 0x09) {
|
|
18
|
+
// Tab: WL CodeParser counts it as 2 columns.
|
|
19
|
+
wlOff += 2;
|
|
20
|
+
} else if (code < 0x80) {
|
|
21
|
+
wlOff += 1;
|
|
22
|
+
} else if (code >= 0xd800 && code <= 0xdbff) {
|
|
23
|
+
// High surrogate: part of a surrogate pair (4 UTF-8 bytes total).
|
|
24
|
+
wlOff += 4;
|
|
25
|
+
i++; // Skip the low surrogate (next code unit).
|
|
26
|
+
charToWlOffset[i] = wlOff - 2; // low surrogate maps to same 4-byte run
|
|
27
|
+
} else if (code < 0x800) {
|
|
28
|
+
wlOff += 2;
|
|
29
|
+
} else {
|
|
30
|
+
wlOff += 3;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
charToWlOffset[source.length] = wlOff;
|
|
34
|
+
|
|
35
|
+
// Build line start array in WL byte offsets.
|
|
36
|
+
const lineStarts = [0];
|
|
37
|
+
for (let i = 0; i < source.length; i++) {
|
|
38
|
+
if (source[i] === "\n") lineStarts.push(charToWlOffset[i + 1]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { charToWlOffset, lineStarts };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function offsetToLineCol(lineIndex, charOffset) {
|
|
45
|
+
const wlOffset = lineIndex.charToWlOffset[charOffset];
|
|
46
|
+
const { lineStarts } = lineIndex;
|
|
47
|
+
// Binary search for the greatest line start <= wlOffset.
|
|
48
|
+
let lo = 0, hi = lineStarts.length - 1, line = 0;
|
|
49
|
+
while (lo <= hi) {
|
|
50
|
+
const mid = (lo + hi) >> 1;
|
|
51
|
+
if (lineStarts[mid] <= wlOffset) { line = mid; lo = mid + 1; }
|
|
52
|
+
else hi = mid - 1;
|
|
53
|
+
}
|
|
54
|
+
return [line + 1, wlOffset - lineStarts[line] + 1];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function nodeSource(tsNode, lineIndex) {
|
|
58
|
+
return [
|
|
59
|
+
offsetToLineCol(lineIndex, tsNode.startIndex),
|
|
60
|
+
offsetToLineCol(lineIndex, tsNode.endIndex),
|
|
61
|
+
];
|
|
62
|
+
}
|
|
Binary file
|
package/src/range.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
function isIgnorableTopLevelChild(node) {
|
|
2
|
+
return (
|
|
3
|
+
node?.type === "LeafNode" &&
|
|
4
|
+
["Token`Whitespace", "Token`Newline"].includes(node.kind)
|
|
5
|
+
);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function findLastIndex(values, predicate) {
|
|
9
|
+
for (let index = values.length - 1; index >= 0; index -= 1) {
|
|
10
|
+
if (predicate(values[index], index)) {
|
|
11
|
+
return index;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return -1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getFormattableTopLevelChildren(ast) {
|
|
19
|
+
const children = Array.isArray(ast?.children) ? ast.children : [];
|
|
20
|
+
|
|
21
|
+
return children.filter(
|
|
22
|
+
(node) =>
|
|
23
|
+
!isIgnorableTopLevelChild(node) &&
|
|
24
|
+
typeof node.locStart === "number" &&
|
|
25
|
+
typeof node.locEnd === "number",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function snapRangeToTopLevelChildren(ast, rangeStart, rangeEnd) {
|
|
30
|
+
if (ast?.type === "UnformattableNode") {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
typeof rangeStart !== "number" ||
|
|
36
|
+
typeof rangeEnd !== "number" ||
|
|
37
|
+
!Number.isFinite(rangeStart) ||
|
|
38
|
+
!Number.isFinite(rangeEnd) ||
|
|
39
|
+
rangeStart >= rangeEnd
|
|
40
|
+
) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const children = getFormattableTopLevelChildren(ast);
|
|
45
|
+
if (children.length === 0) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let startIndex = children.findIndex((child) => child.locEnd > rangeStart);
|
|
50
|
+
if (startIndex === -1) {
|
|
51
|
+
startIndex = 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let endIndex = findLastIndex(
|
|
55
|
+
children,
|
|
56
|
+
(child) => child.locStart < rangeEnd,
|
|
57
|
+
);
|
|
58
|
+
if (endIndex === -1) {
|
|
59
|
+
endIndex = children.length - 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (endIndex < startIndex) {
|
|
63
|
+
endIndex = startIndex;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
children,
|
|
68
|
+
startIndex,
|
|
69
|
+
endIndex,
|
|
70
|
+
rangeStart: children[startIndex].locStart,
|
|
71
|
+
rangeEnd: children[endIndex].locEnd,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function preprocessRange(ast, opts) {
|
|
76
|
+
if (!ast || ast.type === "UnformattableNode") {
|
|
77
|
+
return ast;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!opts || (opts.rangeStart === 0 && opts.rangeEnd === Infinity)) {
|
|
81
|
+
return ast;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const snappedRange = snapRangeToTopLevelChildren(
|
|
85
|
+
ast,
|
|
86
|
+
opts.rangeStart,
|
|
87
|
+
opts.rangeEnd,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (!snappedRange) {
|
|
91
|
+
return ast;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
opts.rangeStart = snappedRange.rangeStart;
|
|
95
|
+
opts.rangeEnd = snappedRange.rangeEnd;
|
|
96
|
+
|
|
97
|
+
return ast;
|
|
98
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/rules/index.js
|
|
2
|
+
import { readdirSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { normalizeWolframOptions } from "../options.js";
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
// Auto-discover rule modules by listing this directory
|
|
10
|
+
async function loadRules() {
|
|
11
|
+
const files = readdirSync(__dirname).filter(
|
|
12
|
+
(f) => f.endsWith(".js") && f !== "index.js",
|
|
13
|
+
);
|
|
14
|
+
const modules = await Promise.all(
|
|
15
|
+
files.map((f) => import(join(__dirname, f))),
|
|
16
|
+
);
|
|
17
|
+
return modules.map((m) => m.default);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run all lint rules over a CST node tree.
|
|
22
|
+
* Returns an array of diagnostics: { rule, message, node, level }.
|
|
23
|
+
*/
|
|
24
|
+
export async function runRules(rootNode, lintRuleOverrides = {}, options = {}) {
|
|
25
|
+
const rules = await loadRules();
|
|
26
|
+
const diagnostics = [];
|
|
27
|
+
const normalizedOptions = normalizeWolframOptions(options);
|
|
28
|
+
|
|
29
|
+
for (const rule of rules) {
|
|
30
|
+
const level = lintRuleOverrides[rule.name] ?? rule.defaultLevel;
|
|
31
|
+
if (level === "off") continue;
|
|
32
|
+
|
|
33
|
+
const context = {
|
|
34
|
+
options: normalizedOptions,
|
|
35
|
+
report({ node, message }) {
|
|
36
|
+
diagnostics.push({
|
|
37
|
+
rule: rule.name,
|
|
38
|
+
message,
|
|
39
|
+
node,
|
|
40
|
+
level,
|
|
41
|
+
fixableByFormatter: Boolean(rule.fixableByFormatter),
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
walkCST(rootNode, (node) => rule.visit(node, context));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return diagnostics;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function walkCST(node, fn) {
|
|
53
|
+
if (!node || typeof node !== "object") return;
|
|
54
|
+
fn(node);
|
|
55
|
+
if (node.children) node.children.forEach((c) => walkCST(c, fn));
|
|
56
|
+
if (node.head) walkCST(node.head, fn);
|
|
57
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// src/rules/line-width.js
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: "line-width",
|
|
5
|
+
description: "Line exceeds configured printWidth",
|
|
6
|
+
defaultLevel: "warn",
|
|
7
|
+
fixableByFormatter: true,
|
|
8
|
+
|
|
9
|
+
visit(node, context) {
|
|
10
|
+
if (node.type !== "ContainerNode") return;
|
|
11
|
+
|
|
12
|
+
const maxWidth = context.options?.printWidth ?? 80;
|
|
13
|
+
const sourceText = String(context.options?.__sourceText ?? "");
|
|
14
|
+
const overflows = lineOverflowRangesIgnoringComments(
|
|
15
|
+
sourceText,
|
|
16
|
+
maxWidth,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
for (const overflow of overflows) {
|
|
20
|
+
context.report({
|
|
21
|
+
node: {
|
|
22
|
+
source: [
|
|
23
|
+
[overflow.line, overflow.startCol],
|
|
24
|
+
[overflow.line, overflow.endCol],
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
message: `Line exceeds printWidth (${maxWidth}).`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function lineOverflowRangesIgnoringComments(sourceText, maxWidth) {
|
|
34
|
+
const overflows = [];
|
|
35
|
+
let line = 1;
|
|
36
|
+
let rawCol = 1;
|
|
37
|
+
let commentDepth = 0;
|
|
38
|
+
let inString = false;
|
|
39
|
+
let escape = false;
|
|
40
|
+
let keptChars = [];
|
|
41
|
+
let keptCols = [];
|
|
42
|
+
|
|
43
|
+
const flushLine = () => {
|
|
44
|
+
let visibleLength = keptChars.length;
|
|
45
|
+
while (
|
|
46
|
+
visibleLength > 0 &&
|
|
47
|
+
/[ \t]/.test(keptChars[visibleLength - 1])
|
|
48
|
+
) {
|
|
49
|
+
visibleLength--;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (visibleLength > maxWidth) {
|
|
53
|
+
overflows.push({
|
|
54
|
+
line,
|
|
55
|
+
startCol: keptCols[maxWidth],
|
|
56
|
+
endCol: keptCols[visibleLength - 1] + 1,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
keptChars = [];
|
|
61
|
+
keptCols = [];
|
|
62
|
+
line++;
|
|
63
|
+
rawCol = 1;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < sourceText.length; i++) {
|
|
67
|
+
const ch = sourceText[i];
|
|
68
|
+
const next = sourceText[i + 1];
|
|
69
|
+
|
|
70
|
+
if (ch === "\r") continue;
|
|
71
|
+
if (ch === "\n") {
|
|
72
|
+
flushLine();
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (commentDepth > 0) {
|
|
77
|
+
if (ch === "(" && next === "*") {
|
|
78
|
+
commentDepth++;
|
|
79
|
+
rawCol += 2;
|
|
80
|
+
i++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (ch === "*" && next === ")") {
|
|
84
|
+
commentDepth--;
|
|
85
|
+
rawCol += 2;
|
|
86
|
+
i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
rawCol++;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (inString) {
|
|
94
|
+
keptChars.push(ch);
|
|
95
|
+
keptCols.push(rawCol);
|
|
96
|
+
if (escape) {
|
|
97
|
+
escape = false;
|
|
98
|
+
} else if (ch === "\\") {
|
|
99
|
+
escape = true;
|
|
100
|
+
} else if (ch === '"') {
|
|
101
|
+
inString = false;
|
|
102
|
+
}
|
|
103
|
+
rawCol++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (ch === '"') {
|
|
108
|
+
inString = true;
|
|
109
|
+
keptChars.push(ch);
|
|
110
|
+
keptCols.push(rawCol);
|
|
111
|
+
rawCol++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (ch === "(" && next === "*") {
|
|
116
|
+
commentDepth++;
|
|
117
|
+
rawCol += 2;
|
|
118
|
+
i++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
keptChars.push(ch);
|
|
123
|
+
keptCols.push(rawCol);
|
|
124
|
+
rawCol++;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
flushLine();
|
|
128
|
+
return overflows;
|
|
129
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// src/rules/newlines-between-definitions.js
|
|
2
|
+
import { blankLinesForCodeGap } from "../utils/codeSpacing.js";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: "newlines-between-definitions",
|
|
6
|
+
description:
|
|
7
|
+
"Top-level declarations and preserved code gaps should match configured blank-line spacing; leading comments stay attached to the following statement",
|
|
8
|
+
defaultLevel: "warn",
|
|
9
|
+
fixableByFormatter: true,
|
|
10
|
+
|
|
11
|
+
visit(node, context) {
|
|
12
|
+
if (node.type !== "ContainerNode") return;
|
|
13
|
+
const children = (node.children ?? []).filter(
|
|
14
|
+
(c) => !isWhitespaceTrivia(c),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
let previousGroup = null;
|
|
18
|
+
let pendingLeadingCommentStartLine = null;
|
|
19
|
+
let pendingLeadingCommentEndLine = null;
|
|
20
|
+
|
|
21
|
+
for (const child of children) {
|
|
22
|
+
if (isComment(child)) {
|
|
23
|
+
const startLine = child.source?.[0]?.[0] ?? 0;
|
|
24
|
+
const endLine = child.source?.[1]?.[0] ?? startLine;
|
|
25
|
+
|
|
26
|
+
if (previousGroup && startLine <= previousGroup.endLine) {
|
|
27
|
+
previousGroup = {
|
|
28
|
+
...previousGroup,
|
|
29
|
+
endLine: Math.max(previousGroup.endLine, endLine),
|
|
30
|
+
};
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pendingLeadingCommentStartLine ??= startLine;
|
|
35
|
+
pendingLeadingCommentEndLine = endLine;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const currStartLine = child.source?.[0]?.[0] ?? 0;
|
|
40
|
+
const groupStartLine =
|
|
41
|
+
pendingLeadingCommentStartLine ?? currStartLine;
|
|
42
|
+
const leadingCommentGap =
|
|
43
|
+
pendingLeadingCommentEndLine == null
|
|
44
|
+
? 0
|
|
45
|
+
: Math.max(
|
|
46
|
+
0,
|
|
47
|
+
currStartLine - pendingLeadingCommentEndLine - 1,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (leadingCommentGap !== 0) {
|
|
51
|
+
context.report({
|
|
52
|
+
node: child,
|
|
53
|
+
message: `Expected 0 blank lines between a leading comment block and the following top-level statement, found ${leadingCommentGap}.`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!previousGroup) {
|
|
58
|
+
previousGroup = {
|
|
59
|
+
node: child,
|
|
60
|
+
endLine: child.source?.[1]?.[0] ?? 0,
|
|
61
|
+
};
|
|
62
|
+
pendingLeadingCommentStartLine = null;
|
|
63
|
+
pendingLeadingCommentEndLine = null;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const gap = groupStartLine - previousGroup.endLine - 1;
|
|
68
|
+
const expected = blankLinesForCodeGap(
|
|
69
|
+
previousGroup.node,
|
|
70
|
+
child,
|
|
71
|
+
gap,
|
|
72
|
+
context.options,
|
|
73
|
+
{ topLevel: true },
|
|
74
|
+
);
|
|
75
|
+
if (gap !== expected) {
|
|
76
|
+
context.report({
|
|
77
|
+
node: child,
|
|
78
|
+
message: `Expected ${expected} blank line${expected === 1 ? "" : "s"} between top-level statements, found ${gap}.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
previousGroup = {
|
|
83
|
+
node: child,
|
|
84
|
+
endLine: child.source?.[1]?.[0] ?? previousGroup.endLine,
|
|
85
|
+
};
|
|
86
|
+
pendingLeadingCommentStartLine = null;
|
|
87
|
+
pendingLeadingCommentEndLine = null;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function isWhitespaceTrivia(node) {
|
|
93
|
+
return (
|
|
94
|
+
node?.type === "LeafNode" &&
|
|
95
|
+
["Token`Whitespace", "Whitespace", "Token`Newline", "Newline"].includes(
|
|
96
|
+
node.kind,
|
|
97
|
+
)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isComment(node) {
|
|
102
|
+
return node?.type === "LeafNode" && node.kind === "Token`Comment";
|
|
103
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/rules/no-bare-symbol-set.js
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: "no-bare-symbol-set",
|
|
5
|
+
description:
|
|
6
|
+
"Global symbol assignment without scoping (x = val at top level)",
|
|
7
|
+
defaultLevel: "warn",
|
|
8
|
+
|
|
9
|
+
visit(node, context) {
|
|
10
|
+
if (node.type !== "BinaryNode" || node.op !== "Set") return;
|
|
11
|
+
const lhs = node.children?.[0];
|
|
12
|
+
if (!lhs) return;
|
|
13
|
+
if (lhs.type !== "LeafNode" || lhs.kind !== "Symbol") return;
|
|
14
|
+
context.report({
|
|
15
|
+
node,
|
|
16
|
+
message: `"${lhs.value} = ..." is a global assignment. Consider scoping inside Module or With.`,
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// src/rules/no-dynamic-module-leak.js
|
|
2
|
+
|
|
3
|
+
function getModuleVarNames(varListNode) {
|
|
4
|
+
const names = new Set();
|
|
5
|
+
if (!varListNode?.children) return names;
|
|
6
|
+
for (const c of varListNode.children) {
|
|
7
|
+
if (c.type === "LeafNode" && c.kind === "Symbol") names.add(c.value);
|
|
8
|
+
if (c.type === "BinaryNode" && c.op === "Set") {
|
|
9
|
+
const lhs = c.children?.[0];
|
|
10
|
+
if (lhs?.type === "LeafNode") names.add(lhs.value);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return names;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function findSetTargets(node) {
|
|
17
|
+
const targets = [];
|
|
18
|
+
walk(node, (n) => {
|
|
19
|
+
if (n.type === "BinaryNode" && n.op === "Set") {
|
|
20
|
+
const lhs = n.children?.[0];
|
|
21
|
+
if (lhs?.type === "LeafNode" && lhs.kind === "Symbol") {
|
|
22
|
+
targets.push({ name: lhs.value, node: n });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
return targets;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function walk(node, fn) {
|
|
30
|
+
if (!node) return;
|
|
31
|
+
fn(node);
|
|
32
|
+
node.children?.forEach((c) => walk(c, fn));
|
|
33
|
+
if (node.head) walk(node.head, fn);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default {
|
|
37
|
+
name: "no-dynamic-module-leak",
|
|
38
|
+
description:
|
|
39
|
+
"Symbol assigned inside Module body but not declared in var list",
|
|
40
|
+
defaultLevel: "warn",
|
|
41
|
+
|
|
42
|
+
visit(node, context) {
|
|
43
|
+
if (node.type !== "CallNode") return;
|
|
44
|
+
if (!["Module", "Block", "DynamicModule"].includes(node.head?.value))
|
|
45
|
+
return;
|
|
46
|
+
|
|
47
|
+
const args =
|
|
48
|
+
node.children?.filter(
|
|
49
|
+
(c) =>
|
|
50
|
+
!(
|
|
51
|
+
c.type === "LeafNode" &&
|
|
52
|
+
[
|
|
53
|
+
"Token`Comma",
|
|
54
|
+
"Token`Whitespace",
|
|
55
|
+
"Token`Newline",
|
|
56
|
+
].includes(c.kind)
|
|
57
|
+
),
|
|
58
|
+
) ?? [];
|
|
59
|
+
if (args.length < 2) return;
|
|
60
|
+
|
|
61
|
+
const declared = getModuleVarNames(args[0]);
|
|
62
|
+
const body = args.slice(1);
|
|
63
|
+
const setTargets = body.flatMap(findSetTargets);
|
|
64
|
+
|
|
65
|
+
for (const { name, node: setNode } of setTargets) {
|
|
66
|
+
if (!declared.has(name)) {
|
|
67
|
+
context.report({
|
|
68
|
+
node: setNode,
|
|
69
|
+
message: `"${name}" is assigned inside ${node.head.value} but not declared in the variable list — this creates a global side effect.`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/rules/no-general-infix-function.js
|
|
2
|
+
import { normalizeWolframOptions } from "../options.js";
|
|
3
|
+
|
|
4
|
+
function preservedTildeFunctions(options) {
|
|
5
|
+
options = normalizeWolframOptions(options);
|
|
6
|
+
return new Set(
|
|
7
|
+
String(options?.wolframPreserveTildeInfixFunctions ?? "Join")
|
|
8
|
+
.split(",")
|
|
9
|
+
.map((s) => s.trim())
|
|
10
|
+
.filter(Boolean),
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
name: "no-general-infix-function",
|
|
16
|
+
description:
|
|
17
|
+
"Prefer fully qualified call syntax over general infix ~f~ form",
|
|
18
|
+
defaultLevel: "warn",
|
|
19
|
+
fixableByFormatter: true,
|
|
20
|
+
|
|
21
|
+
visit(node, context) {
|
|
22
|
+
if (node.type !== "TernaryNode" || node.op !== "TernaryTilde") return;
|
|
23
|
+
const semantic = (node.children ?? []).filter(
|
|
24
|
+
(c) =>
|
|
25
|
+
!(
|
|
26
|
+
c.type === "LeafNode" &&
|
|
27
|
+
[
|
|
28
|
+
"Token`Whitespace",
|
|
29
|
+
"Whitespace",
|
|
30
|
+
"Token`Newline",
|
|
31
|
+
"Newline",
|
|
32
|
+
].includes(c.kind)
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
if (semantic.length !== 5) return;
|
|
36
|
+
|
|
37
|
+
const fn = semantic[2];
|
|
38
|
+
if (
|
|
39
|
+
fn?.type === "LeafNode" &&
|
|
40
|
+
fn.kind === "Symbol" &&
|
|
41
|
+
preservedTildeFunctions(context.options).has(fn.value)
|
|
42
|
+
) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const fnName = fn?.value ?? "function";
|
|
47
|
+
context.report({
|
|
48
|
+
node,
|
|
49
|
+
message: `Prefer fully qualified call syntax ${fnName}[x, y] over infix ~${fnName}~ form.`,
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
};
|