@puruslang/linter 0.0.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/README.md +58 -0
- package/package.json +36 -0
- package/src/cli.js +82 -0
- package/src/index.js +223 -0
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
[](https://purus.work)
|
|
4
|
+
|
|
5
|
+
**English** | [日本語](./README-ja.md)
|
|
6
|
+
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
[](https://www.npmjs.com/package/purus)
|
|
12
|
+
[](https://www.npmjs.com/package/purus)
|
|
13
|
+
[](https://github.com/otoneko1102/purus/pulse)
|
|
14
|
+
[](https://github.com/otoneko1102/purus/commits/main)
|
|
15
|
+

|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
Purus - _/ˈpuː.rus/_ _**means pure✨ in Latin**_ - is a beautiful, simple, and easy-to-use language. It compiles to _JavaScript_.
|
|
19
|
+
|
|
20
|
+
**It makes your fingers free from the _Shift key_.**
|
|
21
|
+
|
|
22
|
+
With Purus, you can write code almost without pressing the _Shift key_.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
# Global
|
|
28
|
+
npm install -g purus
|
|
29
|
+
|
|
30
|
+
# or Local
|
|
31
|
+
npm install -D purus
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## File Extensions
|
|
35
|
+
|
|
36
|
+
| Extension | Output |
|
|
37
|
+
| --------- | ------ |
|
|
38
|
+
| `.purus` | `.js` |
|
|
39
|
+
| `.cpurus` | `.cjs` |
|
|
40
|
+
| `.mpurus` | `.mjs` |
|
|
41
|
+
|
|
42
|
+
## Tooling
|
|
43
|
+
|
|
44
|
+
- **VS Code Extension** — [Marketplace](https://marketplace.visualstudio.com/items?itemName=otoneko1102.purus): Syntax highlighting, snippets, file icons
|
|
45
|
+
- **Linter** — [`@puruslang/linter`](https://www.npmjs.com/package/@puruslang/linter): Static analysis for Purus
|
|
46
|
+
- **Prettier Plugin** — [`@puruslang/prettier-plugin-purus`](https://www.npmjs.com/package/@puruslang/prettier-plugin-purus): Code formatting
|
|
47
|
+
|
|
48
|
+
## Documentation
|
|
49
|
+
|
|
50
|
+
The documentation is available on [purus.work](https://purus.work).
|
|
51
|
+
|
|
52
|
+
## Author
|
|
53
|
+
|
|
54
|
+
otoneko. https://github.com/otoneko1102
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
Distributed under the Apache 2.0 License. See [LICENSE](./LICENSE) for more information.
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@puruslang/linter",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Linter for the Purus language",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"purus-lint": "src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"linter",
|
|
12
|
+
"purus",
|
|
13
|
+
"static-analysis",
|
|
14
|
+
"altjs",
|
|
15
|
+
"lang",
|
|
16
|
+
"language",
|
|
17
|
+
"noshift",
|
|
18
|
+
"noshift.js"
|
|
19
|
+
],
|
|
20
|
+
"author": "otoneko1102 (https://github.com/otoneko1102)",
|
|
21
|
+
"homepage": "https://purus.work",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/otoneko1102/purus",
|
|
25
|
+
"directory": "linter"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/otoneko1102/purus/issues"
|
|
29
|
+
},
|
|
30
|
+
"funding": {
|
|
31
|
+
"url": "https://github.com/sponsors/otoneko1102"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { lint, defaultRules } = require("./index.js");
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
11
|
+
console.log("purus-lint - Linter for the Purus language");
|
|
12
|
+
console.log("");
|
|
13
|
+
console.log("Usage: purus-lint <file.purus|.cpurus|.mpurus> [options]");
|
|
14
|
+
console.log("");
|
|
15
|
+
console.log("Options:");
|
|
16
|
+
console.log(" --config <file> Path to config JSON file");
|
|
17
|
+
console.log(" --fix (not yet implemented)");
|
|
18
|
+
console.log(" --help Show this help");
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Collect files and options
|
|
23
|
+
let configPath = null;
|
|
24
|
+
const files = [];
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < args.length; i++) {
|
|
27
|
+
if (args[i] === "--config" && i + 1 < args.length) {
|
|
28
|
+
configPath = args[++i];
|
|
29
|
+
} else if (!args[i].startsWith("-")) {
|
|
30
|
+
files.push(args[i]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Load config
|
|
35
|
+
let ruleOverrides = {};
|
|
36
|
+
if (configPath) {
|
|
37
|
+
try {
|
|
38
|
+
ruleOverrides = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(`Error reading config: ${err.message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Also check for .puruslint.json in cwd
|
|
46
|
+
if (!configPath) {
|
|
47
|
+
const defaultConfig = path.join(process.cwd(), ".puruslint.json");
|
|
48
|
+
if (fs.existsSync(defaultConfig)) {
|
|
49
|
+
try {
|
|
50
|
+
ruleOverrides = JSON.parse(fs.readFileSync(defaultConfig, "utf8"));
|
|
51
|
+
} catch {
|
|
52
|
+
// ignore
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let totalIssues = 0;
|
|
58
|
+
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
let source;
|
|
61
|
+
try {
|
|
62
|
+
source = fs.readFileSync(file, "utf8");
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error(`Error: ${err.message}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const diagnostics = lint(source, ruleOverrides);
|
|
69
|
+
totalIssues += diagnostics.length;
|
|
70
|
+
|
|
71
|
+
for (const d of diagnostics) {
|
|
72
|
+
const icon = d.severity === "error" ? "error" : "warn";
|
|
73
|
+
console.log(`${file}:${d.line}:${d.col} ${icon} ${d.message} (${d.rule})`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (totalIssues > 0) {
|
|
78
|
+
console.log(`\n${totalIssues} issue${totalIssues === 1 ? "" : "s"} found.`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
} else if (files.length > 0) {
|
|
81
|
+
console.log("No issues found.");
|
|
82
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const KEYWORDS = new Set([
|
|
4
|
+
"const", "let", "var", "be",
|
|
5
|
+
"fn", "async", "return", "to", "gives",
|
|
6
|
+
"if", "elif", "else", "unless", "then",
|
|
7
|
+
"while", "until", "for", "in", "range",
|
|
8
|
+
"match", "when",
|
|
9
|
+
"try", "catch", "finally", "throw",
|
|
10
|
+
"import", "from", "export", "default", "require", "use", "mod", "pub", "all",
|
|
11
|
+
"add", "sub", "mul", "div", "mod", "neg",
|
|
12
|
+
"eq", "ne", "lt", "gt", "le", "ge",
|
|
13
|
+
"and", "or", "not", "pipe",
|
|
14
|
+
"is", "as", "of", "typeof", "instanceof", "type",
|
|
15
|
+
"new", "delete", "this", "await",
|
|
16
|
+
"true", "false", "null", "nil", "undefined",
|
|
17
|
+
"break", "continue",
|
|
18
|
+
"list", "object",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function tokenize(source) {
|
|
22
|
+
const tokens = [];
|
|
23
|
+
let i = 0;
|
|
24
|
+
let line = 1;
|
|
25
|
+
let col = 1;
|
|
26
|
+
const len = source.length;
|
|
27
|
+
|
|
28
|
+
while (i < len) {
|
|
29
|
+
const startLine = line;
|
|
30
|
+
const startCol = col;
|
|
31
|
+
|
|
32
|
+
// Newline
|
|
33
|
+
if (source[i] === "\n") {
|
|
34
|
+
tokens.push({ type: "newline", value: "\n", line: startLine, col: startCol });
|
|
35
|
+
i++; line++; col = 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (source[i] === "\r") { i++; continue; }
|
|
39
|
+
|
|
40
|
+
// Whitespace
|
|
41
|
+
if (source[i] === " " || source[i] === "\t") {
|
|
42
|
+
let start = i;
|
|
43
|
+
while (i < len && (source[i] === " " || source[i] === "\t")) { i++; col++; }
|
|
44
|
+
tokens.push({ type: "whitespace", value: source.slice(start, i), line: startLine, col: startCol });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Block comment ---
|
|
49
|
+
if (source[i] === "-" && source[i + 1] === "-" && source[i + 2] === "-") {
|
|
50
|
+
let end = source.indexOf("---", i + 3);
|
|
51
|
+
if (end === -1) end = len; else end += 3;
|
|
52
|
+
const val = source.slice(i, end);
|
|
53
|
+
for (const ch of val) { if (ch === "\n") { line++; col = 1; } else { col++; } }
|
|
54
|
+
tokens.push({ type: "block-comment", value: val, line: startLine, col: startCol });
|
|
55
|
+
i = end;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Line comment --
|
|
60
|
+
if (source[i] === "-" && source[i + 1] === "-") {
|
|
61
|
+
let end = source.indexOf("\n", i);
|
|
62
|
+
if (end === -1) end = len;
|
|
63
|
+
tokens.push({ type: "comment", value: source.slice(i, end), line: startLine, col: startCol });
|
|
64
|
+
col += end - i; i = end;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// String ///
|
|
69
|
+
if (source[i] === "/" && source[i + 1] === "/" && source[i + 2] === "/") {
|
|
70
|
+
let j = i + 3; col += 3;
|
|
71
|
+
while (j < len) {
|
|
72
|
+
if (source[j] === "\\" && j + 1 < len) { j += 2; col += 2; continue; }
|
|
73
|
+
if (source[j] === "/" && source[j + 1] === "/" && source[j + 2] === "/") { j += 3; col += 3; break; }
|
|
74
|
+
if (source[j] === "\n") { line++; col = 1; } else { col++; }
|
|
75
|
+
j++;
|
|
76
|
+
}
|
|
77
|
+
tokens.push({ type: "string", value: source.slice(i, j), line: startLine, col: startCol });
|
|
78
|
+
i = j;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Punctuation
|
|
83
|
+
if ("[],;.".includes(source[i])) {
|
|
84
|
+
tokens.push({ type: "punct", value: source[i], line: startLine, col: startCol });
|
|
85
|
+
i++; col++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Word
|
|
90
|
+
if (/[a-zA-Z]/.test(source[i])) {
|
|
91
|
+
let start = i;
|
|
92
|
+
while (i < len && /[a-zA-Z0-9-]/.test(source[i])) { i++; col++; }
|
|
93
|
+
const word = source.slice(start, i);
|
|
94
|
+
tokens.push({ type: KEYWORDS.has(word) ? "keyword" : "ident", value: word, line: startLine, col: startCol });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Number
|
|
99
|
+
if (/[0-9]/.test(source[i])) {
|
|
100
|
+
let start = i;
|
|
101
|
+
while (i < len && /[0-9]/.test(source[i])) { i++; col++; }
|
|
102
|
+
if (i < len && source[i] === "." && i + 1 < len && /[0-9]/.test(source[i + 1])) {
|
|
103
|
+
i++; col++;
|
|
104
|
+
while (i < len && /[0-9]/.test(source[i])) { i++; col++; }
|
|
105
|
+
}
|
|
106
|
+
tokens.push({ type: "number", value: source.slice(start, i), line: startLine, col: startCol });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Shebang
|
|
111
|
+
if (i === 0 && source[i] === "#" && source[i + 1] === "!") {
|
|
112
|
+
let end = source.indexOf("\n", i);
|
|
113
|
+
if (end === -1) end = len;
|
|
114
|
+
tokens.push({ type: "shebang", value: source.slice(i, end), line: startLine, col: startCol });
|
|
115
|
+
col += end - i; i = end;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Other
|
|
120
|
+
tokens.push({ type: "other", value: source[i], line: startLine, col: startCol });
|
|
121
|
+
i++; col++;
|
|
122
|
+
}
|
|
123
|
+
return tokens;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- Rules ---
|
|
127
|
+
|
|
128
|
+
const defaultRules = {
|
|
129
|
+
"no-var": { severity: "warn", message: "Avoid 'var'; use 'const' or 'let' instead" },
|
|
130
|
+
"no-nil": { severity: "warn", message: "Use 'null' instead of 'nil'" },
|
|
131
|
+
"indent-size": { severity: "warn", size: 2 },
|
|
132
|
+
"no-trailing-whitespace": { severity: "warn", message: "Trailing whitespace" },
|
|
133
|
+
"no-unused-import": { severity: "warn" },
|
|
134
|
+
"consistent-naming": { severity: "warn", style: "kebab-case" },
|
|
135
|
+
"max-line-length": { severity: "off", max: 100 },
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
function lint(source, ruleOverrides = {}) {
|
|
139
|
+
const rules = { ...defaultRules, ...ruleOverrides };
|
|
140
|
+
const diagnostics = [];
|
|
141
|
+
const tokens = tokenize(source);
|
|
142
|
+
const lines = source.split("\n");
|
|
143
|
+
|
|
144
|
+
function report(rule, line, col, message) {
|
|
145
|
+
const sev = rules[rule]?.severity || "warn";
|
|
146
|
+
if (sev === "off") return;
|
|
147
|
+
diagnostics.push({ rule, severity: sev, line, col, message });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- Token-level rules ---
|
|
151
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
152
|
+
const tok = tokens[i];
|
|
153
|
+
|
|
154
|
+
// no-var
|
|
155
|
+
if (rules["no-var"]?.severity !== "off" && tok.type === "keyword" && tok.value === "var") {
|
|
156
|
+
report("no-var", tok.line, tok.col, rules["no-var"].message);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// no-nil
|
|
160
|
+
if (rules["no-nil"]?.severity !== "off" && tok.type === "keyword" && tok.value === "nil") {
|
|
161
|
+
report("no-nil", tok.line, tok.col, rules["no-nil"].message);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// consistent-naming
|
|
165
|
+
if (rules["consistent-naming"]?.severity !== "off" && tok.type === "ident") {
|
|
166
|
+
const style = rules["consistent-naming"].style || "kebab-case";
|
|
167
|
+
if (style === "kebab-case") {
|
|
168
|
+
// Identifiers should be kebab-case (lowercase with hyphens)
|
|
169
|
+
// Allow PascalCase for class names (starts with uppercase)
|
|
170
|
+
if (/[A-Z]/.test(tok.value[0])) continue; // Allow PascalCase
|
|
171
|
+
if (/_/.test(tok.value)) {
|
|
172
|
+
report("consistent-naming", tok.line, tok.col,
|
|
173
|
+
`Use kebab-case instead of snake_case: '${tok.value}'`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- Line-level rules ---
|
|
180
|
+
for (let li = 0; li < lines.length; li++) {
|
|
181
|
+
const line = lines[li];
|
|
182
|
+
const lineNum = li + 1;
|
|
183
|
+
|
|
184
|
+
// no-trailing-whitespace
|
|
185
|
+
if (rules["no-trailing-whitespace"]?.severity !== "off") {
|
|
186
|
+
if (line.length > 0 && /\s+$/.test(line) && line.trim().length > 0) {
|
|
187
|
+
report("no-trailing-whitespace", lineNum, line.length,
|
|
188
|
+
rules["no-trailing-whitespace"].message || "Trailing whitespace");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// indent-size
|
|
193
|
+
if (rules["indent-size"]?.severity !== "off") {
|
|
194
|
+
const match = line.match(/^( +)/);
|
|
195
|
+
if (match) {
|
|
196
|
+
const size = rules["indent-size"].size || 2;
|
|
197
|
+
if (match[1].length % size !== 0) {
|
|
198
|
+
report("indent-size", lineNum, 1,
|
|
199
|
+
`Indentation should be a multiple of ${size} spaces (found ${match[1].length})`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Warn on tabs if indent style is spaces
|
|
203
|
+
if (/^\t/.test(line)) {
|
|
204
|
+
report("indent-size", lineNum, 1, "Use spaces for indentation, not tabs");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// max-line-length
|
|
209
|
+
if (rules["max-line-length"]?.severity !== "off") {
|
|
210
|
+
const max = rules["max-line-length"].max || 100;
|
|
211
|
+
if (line.length > max) {
|
|
212
|
+
report("max-line-length", lineNum, max + 1,
|
|
213
|
+
`Line exceeds max length of ${max} (found ${line.length})`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Sort by line, then column
|
|
219
|
+
diagnostics.sort((a, b) => a.line - b.line || a.col - b.col);
|
|
220
|
+
return diagnostics;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = { lint, tokenize, defaultRules };
|