@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +237 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@puruslang/linter",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Linter for the Purus language",
5
5
  "license": "Apache-2.0",
6
6
  "main": "src/index.js",
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
- "is", "as", "of", "typeof", "instanceof", "type",
15
- "new", "delete", "this", "await",
16
- "class", "extends", "super", "static", "private", "get", "set",
17
- "true", "false", "null", "nil", "undefined", "nan",
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
- while (i < len && /[0-9]/.test(source[i])) { i++; col++; }
124
- if (i < len && source[i] === "." && i + 1 < len && /[0-9]/.test(source[i + 1])) {
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 ---