@puruslang/linter 0.7.1 → 0.8.1
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/package.json +1 -1
- package/src/index.js +237 -14
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -4,22 +4,25 @@ const KEYWORDS = new Set([
|
|
|
4
4
|
"const", "let", "var", "be",
|
|
5
5
|
"fn", "async", "return", "to", "gives",
|
|
6
6
|
"if", "elif", "else", "unless", "then",
|
|
7
|
-
"while", "until", "for", "in", "range",
|
|
7
|
+
"while", "until", "do", "for", "in", "range",
|
|
8
8
|
"match", "when", "switch", "case",
|
|
9
9
|
"try", "catch", "finally", "throw",
|
|
10
10
|
"import", "from", "export", "default", "require", "use", "namespace", "public", "all", "with",
|
|
11
|
-
"add", "sub", "mul", "div", "mod", "neg", "pow",
|
|
11
|
+
"add", "sub", "mul", "div", "fdiv", "mod", "neg", "pow",
|
|
12
12
|
"eq", "neq", "lt", "gt", "le", "ge",
|
|
13
13
|
"and", "or", "not", "pipe", "coal",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
14
|
+
"band", "bor", "bxor", "bnot", "shl", "shr", "ushr",
|
|
15
|
+
"as", "of", "typeof", "instanceof", "type",
|
|
16
|
+
"new", "delete", "this", "await", "yield", "void",
|
|
17
|
+
"class", "extends", "super", "static", "private", "protected", "get", "set",
|
|
18
|
+
"true", "false", "null", "nil", "undefined", "nan", "infinity",
|
|
18
19
|
"break", "continue",
|
|
19
20
|
"list", "object",
|
|
21
|
+
"function",
|
|
20
22
|
]);
|
|
21
23
|
|
|
22
24
|
function tokenize(source) {
|
|
25
|
+
source = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
23
26
|
const tokens = [];
|
|
24
27
|
let i = 0;
|
|
25
28
|
let line = 1;
|
|
@@ -117,13 +120,25 @@ function tokenize(source) {
|
|
|
117
120
|
continue;
|
|
118
121
|
}
|
|
119
122
|
|
|
120
|
-
// Number
|
|
123
|
+
// Number (decimal, 0b binary, 0x hex, BigInt n-suffix)
|
|
121
124
|
if (/[0-9]/.test(source[i])) {
|
|
122
125
|
let start = i;
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
i++; col++;
|
|
126
|
+
if (source[i] === "0" && i + 1 < len && (source[i + 1] === "b" || source[i + 1] === "B")) {
|
|
127
|
+
i += 2; col += 2;
|
|
128
|
+
while (i < len && /[01]/.test(source[i])) { i++; col++; }
|
|
129
|
+
} else if (source[i] === "0" && i + 1 < len && (source[i + 1] === "x" || source[i + 1] === "X")) {
|
|
130
|
+
i += 2; col += 2;
|
|
131
|
+
while (i < len && /[0-9a-fA-F]/.test(source[i])) { i++; col++; }
|
|
132
|
+
} else {
|
|
126
133
|
while (i < len && /[0-9]/.test(source[i])) { i++; col++; }
|
|
134
|
+
if (i < len && source[i] === "." && i + 1 < len && /[0-9]/.test(source[i + 1])) {
|
|
135
|
+
i++; col++;
|
|
136
|
+
while (i < len && /[0-9]/.test(source[i])) { i++; col++; }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// BigInt suffix: n
|
|
140
|
+
if (i < len && source[i] === "n" && (i + 1 >= len || !/[a-zA-Z0-9_]/.test(source[i + 1]))) {
|
|
141
|
+
i++; col++;
|
|
127
142
|
}
|
|
128
143
|
tokens.push({ type: "number", value: source.slice(start, i), line: startLine, col: startCol });
|
|
129
144
|
continue;
|
|
@@ -149,7 +164,17 @@ function tokenize(source) {
|
|
|
149
164
|
|
|
150
165
|
const defaultRules = {
|
|
151
166
|
"no-var": { severity: "warn", message: "Avoid 'var'; use 'const' or 'let' instead" },
|
|
167
|
+
"bare-assignment": { severity: "warn", message: "Bare assignment without 'const'/'let'; use 'const x be ...' or 'let x be ...' instead" },
|
|
152
168
|
"no-nil": { severity: "warn", message: "Use 'null' instead of 'nil'" },
|
|
169
|
+
"no-function": { severity: "warn", message: "'function' is deprecated; use 'fn' instead" },
|
|
170
|
+
"no-protected": { severity: "warn", message: "'protected' is deprecated; use 'private' instead" },
|
|
171
|
+
"no-else-if": { severity: "warn", message: "Use 'elif' instead of 'else if'" },
|
|
172
|
+
"no-js-chars": { severity: "error", message: "JavaScript characters are not allowed in Purus" },
|
|
173
|
+
"no-js-operators": { severity: "error", message: "JavaScript operators are not allowed in Purus" },
|
|
174
|
+
"no-for-range": { severity: "warn", message: "'for ... in range' is deprecated; use 'for let i be 0; i lt N; i\\add' instead" },
|
|
175
|
+
"bracket-match": { severity: "error" },
|
|
176
|
+
"const-reassign": { severity: "error", message: "Cannot reassign a 'const' variable" },
|
|
177
|
+
"duplicate-use": { severity: "warn", message: "Duplicate 'use' import" },
|
|
153
178
|
"indent-size": { severity: "warn", size: 2 },
|
|
154
179
|
"no-trailing-whitespace": { severity: "warn", message: "Trailing whitespace" },
|
|
155
180
|
"no-unused-import": { severity: "warn" },
|
|
@@ -157,11 +182,23 @@ const defaultRules = {
|
|
|
157
182
|
"max-line-length": { severity: "off", max: 100 },
|
|
158
183
|
};
|
|
159
184
|
|
|
185
|
+
const JS_FORBIDDEN_CHARS = new Set(["(", ")", "{", "}", "$", "#", "@", "`"]);
|
|
186
|
+
const JS_OPERATOR_MAP = {
|
|
187
|
+
"===": "eq", "!==": "neq", "==": "eq", "!=": "neq",
|
|
188
|
+
"&&": "and", "||": "or", "<<": "shl", ">>": "shr", ">>>": "ushr",
|
|
189
|
+
"++": "\\add / add\\", "--": "\\sub / sub\\", "**": "pow",
|
|
190
|
+
"+=": "add be", "-=": "sub be", "*=": "mul be", "/=": "div be",
|
|
191
|
+
"%=": "mod be", "**=": "pow be",
|
|
192
|
+
"&=": "band be", "|=": "bor be", "^=": "bxor be",
|
|
193
|
+
"<<=": "shl be", ">>=": "shr be", ">>>=": "ushr be",
|
|
194
|
+
"&&=": "and be", "||=": "or be", "??=": "coal be",
|
|
195
|
+
};
|
|
196
|
+
|
|
160
197
|
function lint(source, ruleOverrides = {}) {
|
|
161
198
|
const rules = { ...defaultRules, ...ruleOverrides };
|
|
162
199
|
const diagnostics = [];
|
|
163
200
|
const tokens = tokenize(source);
|
|
164
|
-
const lines = source.split("\n");
|
|
201
|
+
const lines = source.replace(/\r\n/g, "\n").split("\n");
|
|
165
202
|
|
|
166
203
|
function report(rule, line, col, message) {
|
|
167
204
|
const sev = rules[rule]?.severity || "warn";
|
|
@@ -169,6 +206,14 @@ function lint(source, ruleOverrides = {}) {
|
|
|
169
206
|
diagnostics.push({ rule, severity: sev, line, col, message });
|
|
170
207
|
}
|
|
171
208
|
|
|
209
|
+
// Track declarations for const-reassign
|
|
210
|
+
const constVars = new Set();
|
|
211
|
+
const letVars = new Set();
|
|
212
|
+
// Track use imports for duplicate-use
|
|
213
|
+
const useImports = new Set();
|
|
214
|
+
// Track bracket matching
|
|
215
|
+
const bracketStack = [];
|
|
216
|
+
|
|
172
217
|
// --- Token-level rules ---
|
|
173
218
|
for (let i = 0; i < tokens.length; i++) {
|
|
174
219
|
const tok = tokens[i];
|
|
@@ -183,16 +228,194 @@ function lint(source, ruleOverrides = {}) {
|
|
|
183
228
|
report("no-nil", tok.line, tok.col, rules["no-nil"].message);
|
|
184
229
|
}
|
|
185
230
|
|
|
231
|
+
// no-function
|
|
232
|
+
if (rules["no-function"]?.severity !== "off" && tok.type === "keyword" && tok.value === "function") {
|
|
233
|
+
report("no-function", tok.line, tok.col, rules["no-function"].message);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// no-protected
|
|
237
|
+
if (rules["no-protected"]?.severity !== "off" && tok.type === "keyword" && tok.value === "protected") {
|
|
238
|
+
report("no-protected", tok.line, tok.col, rules["no-protected"].message);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// no-else-if: detect 'else' followed by whitespace then 'if'
|
|
242
|
+
if (rules["no-else-if"]?.severity !== "off" && tok.type === "keyword" && tok.value === "else") {
|
|
243
|
+
let j = i + 1;
|
|
244
|
+
while (j < tokens.length && tokens[j].type === "whitespace") j++;
|
|
245
|
+
if (j < tokens.length && tokens[j].type === "keyword" && tokens[j].value === "if") {
|
|
246
|
+
report("no-else-if", tok.line, tok.col, rules["no-else-if"].message);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// no-js-chars
|
|
251
|
+
if (rules["no-js-chars"]?.severity !== "off" && tok.type === "other" && JS_FORBIDDEN_CHARS.has(tok.value)) {
|
|
252
|
+
const charNames = { "(": "parenthesis", ")": "parenthesis", "{": "brace", "}": "brace",
|
|
253
|
+
"$": "'$'", "#": "'#'", "@": "'@'", "`": "backtick" };
|
|
254
|
+
report("no-js-chars", tok.line, tok.col,
|
|
255
|
+
`JavaScript character ${charNames[tok.value] || `'${tok.value}'`} is not allowed in Purus`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// no-js-chars: detect JS string quotes
|
|
259
|
+
if (rules["no-js-chars"]?.severity !== "off" && tok.type === "other" && (tok.value === '"' || tok.value === "'")) {
|
|
260
|
+
report("no-js-chars", tok.line, tok.col,
|
|
261
|
+
`Use ///.../// strings instead of ${tok.value === '"' ? 'double' : 'single'} quotes`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// no-js-operators
|
|
265
|
+
if (rules["no-js-operators"]?.severity !== "off" && tok.type === "other") {
|
|
266
|
+
// Check multi-char operators by peeking ahead
|
|
267
|
+
const next1 = i + 1 < tokens.length ? tokens[i + 1] : null;
|
|
268
|
+
const next2 = i + 2 < tokens.length ? tokens[i + 2] : null;
|
|
269
|
+
const three = tok.value + (next1?.value || "") + (next2?.value || "");
|
|
270
|
+
const two = tok.value + (next1?.value || "");
|
|
271
|
+
if (JS_OPERATOR_MAP[three] && three.length === 3) {
|
|
272
|
+
report("no-js-operators", tok.line, tok.col,
|
|
273
|
+
`Use '${JS_OPERATOR_MAP[three]}' instead of '${three}'`);
|
|
274
|
+
} else if (JS_OPERATOR_MAP[two] && two.length === 2) {
|
|
275
|
+
report("no-js-operators", tok.line, tok.col,
|
|
276
|
+
`Use '${JS_OPERATOR_MAP[two]}' instead of '${two}'`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// bracket-match (and always track bracket depth)
|
|
281
|
+
if (tok.type === "punct") {
|
|
282
|
+
if (tok.value === "[") {
|
|
283
|
+
bracketStack.push(tok);
|
|
284
|
+
} else if (tok.value === "]") {
|
|
285
|
+
if (bracketStack.length === 0) {
|
|
286
|
+
if (rules["bracket-match"]?.severity !== "off") {
|
|
287
|
+
report("bracket-match", tok.line, tok.col, "Unmatched closing bracket ']'");
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
bracketStack.pop();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Track const/let declarations for const-reassign
|
|
296
|
+
if (tok.type === "keyword" && (tok.value === "const" || tok.value === "let")) {
|
|
297
|
+
let j = i + 1;
|
|
298
|
+
while (j < tokens.length && tokens[j].type === "whitespace") j++;
|
|
299
|
+
if (j < tokens.length && tokens[j].type === "ident") {
|
|
300
|
+
if (tok.value === "const") constVars.add(tokens[j].value);
|
|
301
|
+
else letVars.add(tokens[j].value);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// const-reassign: ident be ... where ident is a known const
|
|
306
|
+
if (rules["const-reassign"]?.severity !== "off" && tok.type === "keyword" && tok.value === "be" && bracketStack.length === 0) {
|
|
307
|
+
let j = i - 1;
|
|
308
|
+
while (j >= 0 && tokens[j].type === "whitespace") j--;
|
|
309
|
+
if (j >= 0 && tokens[j].type === "ident" && constVars.has(tokens[j].value)) {
|
|
310
|
+
// Make sure it's not a declaration (const x be ...)
|
|
311
|
+
let k = j - 1;
|
|
312
|
+
while (k >= 0 && tokens[k].type === "whitespace") k--;
|
|
313
|
+
// Skip type annotation: x of Type be ...
|
|
314
|
+
if (k >= 0 && tokens[k].type === "keyword" && tokens[k].value === "of") {
|
|
315
|
+
k--;
|
|
316
|
+
while (k >= 0 && tokens[k].type === "whitespace") k--;
|
|
317
|
+
if (k >= 0 && (tokens[k].type === "ident" || tokens[k].type === "keyword")) {
|
|
318
|
+
k--;
|
|
319
|
+
while (k >= 0 && tokens[k].type === "whitespace") k--;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const isDecl = k >= 0 && tokens[k].type === "keyword" &&
|
|
323
|
+
(tokens[k].value === "const" || tokens[k].value === "let" || tokens[k].value === "var" ||
|
|
324
|
+
tokens[k].value === "private" || tokens[k].value === "protected" || tokens[k].value === "static");
|
|
325
|
+
if (!isDecl) {
|
|
326
|
+
report("const-reassign", tokens[j].line, tokens[j].col,
|
|
327
|
+
`Cannot reassign const variable '${tokens[j].value}'`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// duplicate-use
|
|
333
|
+
if (rules["duplicate-use"]?.severity !== "off" && tok.type === "keyword" && tok.value === "use") {
|
|
334
|
+
let j = i + 1;
|
|
335
|
+
while (j < tokens.length && tokens[j].type === "whitespace") j++;
|
|
336
|
+
if (j < tokens.length && (tokens[j].type === "ident" || tokens[j].type === "keyword")) {
|
|
337
|
+
const moduleName = tokens[j].value;
|
|
338
|
+
if (useImports.has(moduleName)) {
|
|
339
|
+
report("duplicate-use", tok.line, tok.col,
|
|
340
|
+
`Duplicate 'use' import: '${moduleName}' is already imported`);
|
|
341
|
+
} else {
|
|
342
|
+
useImports.add(moduleName);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// bare-assignment: ident be <value> without const/let/var
|
|
348
|
+
if (rules["bare-assignment"]?.severity !== "off" && tok.type === "keyword" && tok.value === "be" && bracketStack.length === 0) {
|
|
349
|
+
// Walk backwards to find the statement start
|
|
350
|
+
let j = i - 1;
|
|
351
|
+
// Skip whitespace
|
|
352
|
+
while (j >= 0 && tokens[j].type === "whitespace") j--;
|
|
353
|
+
// The token before `be` should be an ident (the variable name)
|
|
354
|
+
if (j >= 0 && tokens[j].type === "ident") {
|
|
355
|
+
// Skip type annotations: ident of Type be ...
|
|
356
|
+
let k = j - 1;
|
|
357
|
+
while (k >= 0 && tokens[k].type === "whitespace") k--;
|
|
358
|
+
if (k >= 0 && tokens[k].type === "keyword" && tokens[k].value === "of") {
|
|
359
|
+
// Skip back past 'of' and its type
|
|
360
|
+
k--;
|
|
361
|
+
while (k >= 0 && tokens[k].type === "whitespace") k--;
|
|
362
|
+
// Skip the identifier before 'of'
|
|
363
|
+
if (k >= 0 && tokens[k].type === "ident") {
|
|
364
|
+
k--;
|
|
365
|
+
while (k >= 0 && tokens[k].type === "whitespace") k--;
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
k = j - 1;
|
|
369
|
+
while (k >= 0 && tokens[k].type === "whitespace") k--;
|
|
370
|
+
}
|
|
371
|
+
// Check if preceded by const/let/var/private/protected/static
|
|
372
|
+
const hasDeclKeyword = k >= 0 && tokens[k].type === "keyword" &&
|
|
373
|
+
(tokens[k].value === "const" || tokens[k].value === "let" || tokens[k].value === "var" ||
|
|
374
|
+
tokens[k].value === "private" || tokens[k].value === "protected" || tokens[k].value === "static");
|
|
375
|
+
// Check if preceded by dot (property access: obj.field be ...)
|
|
376
|
+
const isDotAccess = k >= 0 && tokens[k].type === "punct" &&
|
|
377
|
+
(tokens[k].value === "." || tokens[k].value === "\\.");
|
|
378
|
+
// Check if preceded by ] (computed access: arr[\i] be ...)
|
|
379
|
+
const isBracketAccess = k >= 0 && tokens[k].type === "punct" && tokens[k].value === "]";
|
|
380
|
+
if (!hasDeclKeyword && !isDotAccess && !isBracketAccess) {
|
|
381
|
+
report("bare-assignment", tokens[j].line, tokens[j].col,
|
|
382
|
+
rules["bare-assignment"].message);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
186
387
|
// consistent-naming
|
|
187
388
|
if (rules["consistent-naming"]?.severity !== "off" && tok.type === "ident") {
|
|
188
389
|
const style = rules["consistent-naming"].style || "kebab-case";
|
|
189
390
|
if (style === "kebab-case") {
|
|
190
|
-
// Identifiers should be kebab-case (lowercase with hyphens)
|
|
191
|
-
// Allow PascalCase for class names (starts with uppercase)
|
|
192
|
-
// Allow underscores (they are equivalent to hyphens)
|
|
193
391
|
if (/[A-Z]/.test(tok.value[0])) continue; // Allow PascalCase
|
|
194
392
|
}
|
|
195
393
|
}
|
|
394
|
+
|
|
395
|
+
// no-for-range: for x in range ...
|
|
396
|
+
if (rules["no-for-range"]?.severity !== "off" && tok.type === "keyword" && tok.value === "for") {
|
|
397
|
+
let j = i + 1;
|
|
398
|
+
while (j < tokens.length && tokens[j].type === "whitespace") j++;
|
|
399
|
+
// skip ident
|
|
400
|
+
if (j < tokens.length && tokens[j].type === "ident") {
|
|
401
|
+
j++;
|
|
402
|
+
while (j < tokens.length && tokens[j].type === "whitespace") j++;
|
|
403
|
+
if (j < tokens.length && tokens[j].type === "keyword" && tokens[j].value === "in") {
|
|
404
|
+
j++;
|
|
405
|
+
while (j < tokens.length && tokens[j].type === "whitespace") j++;
|
|
406
|
+
if (j < tokens.length && tokens[j].type === "keyword" && tokens[j].value === "range") {
|
|
407
|
+
report("no-for-range", tok.line, tok.col, rules["no-for-range"].message);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// bracket-match: report unclosed brackets
|
|
415
|
+
if (rules["bracket-match"]?.severity !== "off") {
|
|
416
|
+
for (const open of bracketStack) {
|
|
417
|
+
report("bracket-match", open.line, open.col, "Unmatched opening bracket '['");
|
|
418
|
+
}
|
|
196
419
|
}
|
|
197
420
|
|
|
198
421
|
// --- Line-level rules ---
|