js-qualified-keywords 0.1.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 +21 -0
- package/README.md +160 -0
- package/babel-plugin/index.js +115 -0
- package/lib/scan.js +227 -0
- package/lsp-server/package.json +14 -0
- package/lsp-server/server.js +360 -0
- package/lsp-server/start.sh +3 -0
- package/package.json +51 -0
- package/runtime/index.js +4 -0
- package/runtime/keyword-map.js +69 -0
- package/runtime/keyword.js +89 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 clojure-keywords-js contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# js-qualified-keywords
|
|
2
|
+
|
|
3
|
+
Clojure-style qualified keywords (`:ns/name`) as a first-class data type in JavaScript.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
const status = :active;
|
|
7
|
+
const schema = {
|
|
8
|
+
[:user/name]: { type: 'string' },
|
|
9
|
+
[:user/email]: { type: 'string' },
|
|
10
|
+
};
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install js-qualified-keywords
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Runtime
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
const { kw, Keyword, KeywordMap } = require('js-qualified-keywords');
|
|
23
|
+
|
|
24
|
+
const k = kw('user', 'name');
|
|
25
|
+
k.ns // "user"
|
|
26
|
+
k.name // "name"
|
|
27
|
+
k.fqn // "user/name"
|
|
28
|
+
k.toString() // ":user/name"
|
|
29
|
+
|
|
30
|
+
// Interned — same args, same instance
|
|
31
|
+
kw('user', 'name') === kw('user', 'name') // true
|
|
32
|
+
|
|
33
|
+
// Parse from string
|
|
34
|
+
Keyword.of(':user/name') // same as kw('user', 'name')
|
|
35
|
+
|
|
36
|
+
// KeywordMap: like Map but keyed by keyword identity
|
|
37
|
+
const m = new KeywordMap();
|
|
38
|
+
m.set(kw('user', 'name'), 'Alice');
|
|
39
|
+
m.get(kw('user', 'name')) // "Alice"
|
|
40
|
+
m.get(':user/name') // "Alice" — string keys work too
|
|
41
|
+
|
|
42
|
+
KeywordMap.fromObject({ ':user/name': 'Alice' }) // build from plain object
|
|
43
|
+
m.toObject() // back to plain object
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Babel Plugin
|
|
47
|
+
|
|
48
|
+
Transforms `:ns/name` syntax into runtime calls so you can write keywords directly in `.js` files.
|
|
49
|
+
|
|
50
|
+
**.babelrc**
|
|
51
|
+
```json
|
|
52
|
+
{ "plugins": ["js-qualified-keywords/babel-plugin"] }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Input:
|
|
56
|
+
```js
|
|
57
|
+
const x = :user/name;
|
|
58
|
+
const o = { [:db/id]: 1 };
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Output:
|
|
62
|
+
```js
|
|
63
|
+
const { kw: _kw } = require('js-qualified-keywords/runtime');
|
|
64
|
+
const x = _kw('user', 'name');
|
|
65
|
+
const o = { [_kw('db', 'id')]: 1 };
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Keywords inside strings and comments are left untouched. The ternary `: ` is not mistaken for a keyword.
|
|
69
|
+
|
|
70
|
+
## LSP Server
|
|
71
|
+
|
|
72
|
+
Provides completion, hover, go-to-definition, find-references, rename, and diagnostics for keyword literals in JS/TS files.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
cd lsp-server && npm install
|
|
76
|
+
node lsp-server/server.js --stdio
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Point your editor's LSP client at that command, scoped to `javascript`, `javascriptreact`, `typescript`, `typescriptreact`.
|
|
80
|
+
|
|
81
|
+
## Emacs (lsp-mode)
|
|
82
|
+
|
|
83
|
+
**1. Install server dependencies (once):**
|
|
84
|
+
```bash
|
|
85
|
+
cd lsp-server && npm install
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**2. Add to your `init.el` / config:**
|
|
89
|
+
```elisp
|
|
90
|
+
(setq js-qualified-keywords-lsp-server-dir "/path/to/js-qualified-keywords/lsp-server")
|
|
91
|
+
(load "/path/to/js-qualified-keywords/editors/emacs/js-qualified-keywords-lsp.el")
|
|
92
|
+
(add-hook 'js-mode-hook #'lsp) ; skip if lsp already starts automatically
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**3. Open any `.js` file** — lsp-mode connects automatically.
|
|
96
|
+
|
|
97
|
+
| Feature | Key |
|
|
98
|
+
|---|---|
|
|
99
|
+
| Hover | `K` |
|
|
100
|
+
| Completion | type `:` or `:ns/` |
|
|
101
|
+
| Go to definition | `M-.` |
|
|
102
|
+
| Find references | `M-?` |
|
|
103
|
+
| Rename | `C-c l r r` |
|
|
104
|
+
| Server status | `M-x lsp-describe-session` |
|
|
105
|
+
|
|
106
|
+
If the server doesn't connect, check the `*lsp-log*` buffer. Most common cause: `node` not on Emacs's `exec-path` — fix with `(add-to-list 'exec-path "/usr/local/bin")`.
|
|
107
|
+
|
|
108
|
+
If you run multiple LSP servers on JS files (e.g. `ts-ls`), both activate independently. To make js-qualified-keywords take priority, raise `:priority` above `-1` in the `.el` file.
|
|
109
|
+
|
|
110
|
+
## VS Code Extension
|
|
111
|
+
|
|
112
|
+
Bundles the LSP server as a VS Code extension.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
cd vscode-extension && npm install
|
|
116
|
+
# Press F5 in VS Code to launch the Extension Development Host
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
To package for distribution:
|
|
120
|
+
```bash
|
|
121
|
+
npx vsce package
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Conventions
|
|
125
|
+
|
|
126
|
+
| Style | Example | Use for |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| Qualified | `:user/name` | domain attributes, schema keys |
|
|
129
|
+
| Simple | `:active` | enums, flags, modes |
|
|
130
|
+
|
|
131
|
+
Use `.kw.js` as the file extension to signal that a file contains keyword syntax (optional, helps tooling).
|
|
132
|
+
|
|
133
|
+
## Publishing to npm
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# 1. Check the name is free
|
|
137
|
+
npm info js-qualified-keywords
|
|
138
|
+
# If taken, use a scoped name: change "name" in package.json to "@yourname/js-qualified-keywords"
|
|
139
|
+
|
|
140
|
+
# 2. Log in (create an account at npmjs.com first if needed)
|
|
141
|
+
npm login
|
|
142
|
+
|
|
143
|
+
# 3. Publish
|
|
144
|
+
npm publish # unscoped package
|
|
145
|
+
npm publish --access public # scoped package (@yourname/...)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Future releases:
|
|
149
|
+
```bash
|
|
150
|
+
npm version patch # 0.1.0 → 0.1.1
|
|
151
|
+
npm version minor # 0.1.0 → 0.2.0
|
|
152
|
+
npm publish
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Before publishing, update the `homepage`, `bugs`, and `repository` URLs in `package.json` to point to your actual repo.
|
|
156
|
+
|
|
157
|
+
## Limitations
|
|
158
|
+
|
|
159
|
+
- **Source maps**: The Babel plugin pre-processes source text before parsing, which changes string lengths (`:user/name` becomes `_kw("user", "name")`). Babel's source maps are generated from the transformed source, so column offsets may be slightly off. Line numbers remain correct.
|
|
160
|
+
- No TypeScript type-level support yet.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* babel-plugin-js-qualified-keywords
|
|
3
|
+
*
|
|
4
|
+
* Transforms Clojure-style keyword syntax in JavaScript:
|
|
5
|
+
*
|
|
6
|
+
* :user/name → _kw("user", "name")
|
|
7
|
+
* :db/id → _kw("db", "id")
|
|
8
|
+
* :active → _kw(null, "active")
|
|
9
|
+
*
|
|
10
|
+
* Strategy:
|
|
11
|
+
* 1. Pre-process source to replace keyword literals with `_kw(...)` calls
|
|
12
|
+
* before Babel's parser sees the code.
|
|
13
|
+
* 2. Babel handles the resulting AST normally.
|
|
14
|
+
* 3. A `require("js-qualified-keywords/runtime")` import is injected when needed.
|
|
15
|
+
*
|
|
16
|
+
* The scanner logic (strings, comments, template literals, regex literals)
|
|
17
|
+
* is shared with lib/scan.js via `walkCode` — one state machine, two
|
|
18
|
+
* consumers.
|
|
19
|
+
*
|
|
20
|
+
* NOTE: Source maps will have slightly wrong column offsets because the
|
|
21
|
+
* preprocessing changes string lengths before Babel sees the source.
|
|
22
|
+
*
|
|
23
|
+
* Usage in .babelrc:
|
|
24
|
+
* { "plugins": ["js-qualified-keywords/babel-plugin"] }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const { KEYWORD_RE, BEFORE_KW_RE, walkCode } = require('../lib/scan');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pre-process source code, replacing keyword literals outside strings,
|
|
31
|
+
* comments, template string parts, and regex literals with `_kw(...)` calls.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} code
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function preprocess(code) {
|
|
37
|
+
// Pass 1: collect code-context positions.
|
|
38
|
+
const codeSet = new Set();
|
|
39
|
+
walkCode(code, (i) => codeSet.add(i));
|
|
40
|
+
|
|
41
|
+
// Pass 2: walk source, replacing keywords at code positions.
|
|
42
|
+
const result = [];
|
|
43
|
+
let i = 0;
|
|
44
|
+
|
|
45
|
+
while (i < code.length) {
|
|
46
|
+
if (!codeSet.has(i)) {
|
|
47
|
+
// Not in code context — copy verbatim.
|
|
48
|
+
result.push(code[i++]);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (code[i] === ':') {
|
|
53
|
+
const before = i > 0 ? code[i - 1] : ' ';
|
|
54
|
+
if (i === 0 || BEFORE_KW_RE.test(before)) {
|
|
55
|
+
KEYWORD_RE.lastIndex = i;
|
|
56
|
+
const match = KEYWORD_RE.exec(code);
|
|
57
|
+
if (match && match.index === i) {
|
|
58
|
+
const ns = match[2] ? match[1] : null;
|
|
59
|
+
const name = match[2] || match[1];
|
|
60
|
+
result.push(ns ? `_kw("${ns}", "${name}")` : `_kw(null, "${name}")`);
|
|
61
|
+
i += match[0].length;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
result.push(code[i++]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return result.join('');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Babel plugin entry point.
|
|
75
|
+
*/
|
|
76
|
+
function clojureKeywordsPlugin({ types: t }) {
|
|
77
|
+
return {
|
|
78
|
+
name: 'js-qualified-keywords',
|
|
79
|
+
|
|
80
|
+
parserOverride(code, opts, parse) {
|
|
81
|
+
const processed = preprocess(code);
|
|
82
|
+
// Store whether this file needs the import on the opts object
|
|
83
|
+
// so it's per-file, not per-session.
|
|
84
|
+
opts.__clojureKeywordsNeedsImport = processed !== code;
|
|
85
|
+
return parse(processed, opts);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
visitor: {
|
|
89
|
+
Program: {
|
|
90
|
+
exit(path, state) {
|
|
91
|
+
const hasKw = path.scope.hasGlobal('_kw');
|
|
92
|
+
const needsImport = state.opts.__clojureKeywordsNeedsImport;
|
|
93
|
+
if (!hasKw && !needsImport) return;
|
|
94
|
+
|
|
95
|
+
// const { kw: _kw } = require("js-qualified-keywords/runtime");
|
|
96
|
+
const importDecl = t.variableDeclaration('const', [
|
|
97
|
+
t.variableDeclarator(
|
|
98
|
+
t.objectPattern([
|
|
99
|
+
t.objectProperty(t.identifier('kw'), t.identifier('_kw')),
|
|
100
|
+
]),
|
|
101
|
+
t.callExpression(t.identifier('require'), [
|
|
102
|
+
t.stringLiteral('js-qualified-keywords/runtime'),
|
|
103
|
+
])
|
|
104
|
+
),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
path.unshiftContainer('body', importDecl);
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
clojureKeywordsPlugin.preprocess = preprocess;
|
|
115
|
+
module.exports = clojureKeywordsPlugin;
|
package/lib/scan.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack-based JavaScript source scanner.
|
|
3
|
+
*
|
|
4
|
+
* Correctly skips strings (single, double, template string parts),
|
|
5
|
+
* comments, and regex literals — including arbitrarily nested template
|
|
6
|
+
* literals.
|
|
7
|
+
*
|
|
8
|
+
* Shared by both the Babel plugin and the LSP server so they operate on
|
|
9
|
+
* identical tokenization rules.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ─── Shared regexes ───────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** Matches a keyword literal starting at position lastIndex. */
|
|
15
|
+
const KEYWORD_RE = /:([a-zA-Z_][\w.-]*)(?:\/([a-zA-Z_][\w.-]*))?/g;
|
|
16
|
+
|
|
17
|
+
/** Characters that can legitimately appear immediately before a keyword. */
|
|
18
|
+
const BEFORE_KW_RE = /[\s\[({,;=!&|?:><%+\-^~]/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Characters that, when they precede a `/`, indicate the start of a
|
|
22
|
+
* regex literal rather than a division operator.
|
|
23
|
+
*
|
|
24
|
+
* This is the standard heuristic used by linters and syntax highlighters:
|
|
25
|
+
* a `/` is a regex when preceded by an operator, punctuator, or keyword
|
|
26
|
+
* boundary — NOT by an identifier, number, or closing bracket.
|
|
27
|
+
*/
|
|
28
|
+
const REGEX_PREDECESSOR_RE = /[=!({[,;:?&|^~+\-*/%<>]\s*$/;
|
|
29
|
+
|
|
30
|
+
// ─── Low-level scanner ───────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Walk `source` character-by-character, calling `onCode(i)` for every
|
|
34
|
+
* character that falls in "code context" (where keyword literals can
|
|
35
|
+
* appear). Characters inside strings, comments, template string parts,
|
|
36
|
+
* and regex literals are skipped.
|
|
37
|
+
*
|
|
38
|
+
* Returns nothing — side-effects happen through `onCode`.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} source
|
|
41
|
+
* @param {(i: number) => void} onCode
|
|
42
|
+
*/
|
|
43
|
+
function walkCode(source, onCode) {
|
|
44
|
+
let i = 0;
|
|
45
|
+
|
|
46
|
+
// Stack: 'code' | 'template' | 'template-expr'
|
|
47
|
+
const stack = ['code'];
|
|
48
|
+
// Parallel to 'template-expr' frames: tracks inner { } depth.
|
|
49
|
+
const exprDepth = [];
|
|
50
|
+
|
|
51
|
+
while (i < source.length) {
|
|
52
|
+
const ctx = stack[stack.length - 1];
|
|
53
|
+
|
|
54
|
+
// ── Template string part — skip verbatim ─────────────────────────
|
|
55
|
+
if (ctx === 'template') {
|
|
56
|
+
if (source[i] === '\\') { i += 2; continue; }
|
|
57
|
+
if (source[i] === '`') { stack.pop(); i++; continue; }
|
|
58
|
+
if (source[i] === '$' && source[i + 1] === '{') {
|
|
59
|
+
stack.push('template-expr');
|
|
60
|
+
exprDepth.push(0);
|
|
61
|
+
i += 2;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
i++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Code / template-expr ─────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
// Single-line comment
|
|
71
|
+
if (source[i] === '/' && source[i + 1] === '/') {
|
|
72
|
+
const end = source.indexOf('\n', i);
|
|
73
|
+
i = end === -1 ? source.length : end;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Multi-line comment
|
|
78
|
+
if (source[i] === '/' && source[i + 1] === '*') {
|
|
79
|
+
const end = source.indexOf('*/', i + 2);
|
|
80
|
+
i = end === -1 ? source.length : end + 2;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Regex literal — must come before the `/` is treated as code.
|
|
85
|
+
// We use a preceding-context heuristic: if the text before `/`
|
|
86
|
+
// ends with an operator/punctuator, this is a regex, not division.
|
|
87
|
+
if (source[i] === '/' && source[i + 1] !== '/' && source[i + 1] !== '*') {
|
|
88
|
+
const preceding = source.slice(Math.max(0, i - 20), i);
|
|
89
|
+
if (i === 0 || REGEX_PREDECESSOR_RE.test(preceding)) {
|
|
90
|
+
// Skip the regex body.
|
|
91
|
+
let j = i + 1;
|
|
92
|
+
while (j < source.length) {
|
|
93
|
+
if (source[j] === '\\') { j += 2; continue; }
|
|
94
|
+
if (source[j] === '/') { j++; break; }
|
|
95
|
+
if (source[j] === '[') {
|
|
96
|
+
// Character class — skip until ]
|
|
97
|
+
j++;
|
|
98
|
+
while (j < source.length) {
|
|
99
|
+
if (source[j] === '\\') { j += 2; continue; }
|
|
100
|
+
if (source[j] === ']') { j++; break; }
|
|
101
|
+
j++;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
j++;
|
|
106
|
+
}
|
|
107
|
+
// Skip regex flags (gimsuvy)
|
|
108
|
+
while (j < source.length && /[gimsuvyd]/.test(source[j])) j++;
|
|
109
|
+
i = j;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Single / double quoted string
|
|
115
|
+
if (source[i] === '"' || source[i] === "'") {
|
|
116
|
+
const quote = source[i++];
|
|
117
|
+
while (i < source.length) {
|
|
118
|
+
if (source[i] === '\\') { i += 2; continue; }
|
|
119
|
+
if (source[i] === quote) { i++; break; }
|
|
120
|
+
i++;
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Template literal start
|
|
126
|
+
if (source[i] === '`') {
|
|
127
|
+
stack.push('template');
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Brace tracking inside template expression
|
|
133
|
+
if (ctx === 'template-expr') {
|
|
134
|
+
if (source[i] === '{') { exprDepth[exprDepth.length - 1]++; onCode(i); i++; continue; }
|
|
135
|
+
if (source[i] === '}') {
|
|
136
|
+
if (exprDepth[exprDepth.length - 1] === 0) { exprDepth.pop(); stack.pop(); }
|
|
137
|
+
else { exprDepth[exprDepth.length - 1]--; onCode(i); }
|
|
138
|
+
i++;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
onCode(i);
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── scanKeywords ─────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Scan JS source for keyword literals, returning their positions.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} source
|
|
154
|
+
* @returns {Array<{ns: string|null, name: string, fqn: string, index: number, length: number}>}
|
|
155
|
+
*/
|
|
156
|
+
function scanKeywords(source) {
|
|
157
|
+
const results = [];
|
|
158
|
+
const codePositions = new Set();
|
|
159
|
+
|
|
160
|
+
walkCode(source, (i) => codePositions.add(i));
|
|
161
|
+
|
|
162
|
+
// Now walk code positions looking for keyword starts.
|
|
163
|
+
for (const i of codePositions) {
|
|
164
|
+
if (source[i] !== ':') continue;
|
|
165
|
+
|
|
166
|
+
const before = i > 0 ? source[i - 1] : ' ';
|
|
167
|
+
if (i !== 0 && !BEFORE_KW_RE.test(before)) continue;
|
|
168
|
+
|
|
169
|
+
KEYWORD_RE.lastIndex = i;
|
|
170
|
+
const match = KEYWORD_RE.exec(source);
|
|
171
|
+
if (!match || match.index !== i) continue;
|
|
172
|
+
|
|
173
|
+
const ns = match[2] ? match[1] : null;
|
|
174
|
+
const name = match[2] || match[1];
|
|
175
|
+
results.push({
|
|
176
|
+
ns, name,
|
|
177
|
+
fqn: ns ? `${ns}/${name}` : name,
|
|
178
|
+
index: i,
|
|
179
|
+
length: match[0].length,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return results;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Position helpers ─────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Build an array of character offsets, one per line.
|
|
190
|
+
* lineOffsets[n] is the index of the first character on line n.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} text
|
|
193
|
+
* @returns {number[]}
|
|
194
|
+
*/
|
|
195
|
+
function computeLineOffsets(text) {
|
|
196
|
+
const offsets = [0];
|
|
197
|
+
for (let i = 0; i < text.length; i++) {
|
|
198
|
+
if (text[i] === '\n') offsets.push(i + 1);
|
|
199
|
+
}
|
|
200
|
+
return offsets;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Convert a character offset to a { line, character } LSP position.
|
|
205
|
+
*
|
|
206
|
+
* @param {number[]} lineOffsets
|
|
207
|
+
* @param {number} offset
|
|
208
|
+
* @returns {{ line: number, character: number }}
|
|
209
|
+
*/
|
|
210
|
+
function offsetToPosition(lineOffsets, offset) {
|
|
211
|
+
let low = 0, high = lineOffsets.length - 1;
|
|
212
|
+
while (low < high) {
|
|
213
|
+
const mid = Math.ceil((low + high) / 2);
|
|
214
|
+
if (lineOffsets[mid] <= offset) low = mid;
|
|
215
|
+
else high = mid - 1;
|
|
216
|
+
}
|
|
217
|
+
return { line: low, character: offset - lineOffsets[low] };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
KEYWORD_RE,
|
|
222
|
+
BEFORE_KW_RE,
|
|
223
|
+
walkCode,
|
|
224
|
+
scanKeywords,
|
|
225
|
+
computeLineOffsets,
|
|
226
|
+
offsetToPosition,
|
|
227
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "js-qualified-keywords-lsp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "LSP server for Clojure-style keywords in JavaScript",
|
|
5
|
+
"private": true,
|
|
6
|
+
"main": "server.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.js --stdio"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"vscode-languageserver": "^9.0.1",
|
|
12
|
+
"vscode-languageserver-textdocument": "^1.0.11"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP Server for Clojure-style Keywords in JavaScript.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Completion — type `:` to see known keywords; `:ns/` to filter by namespace
|
|
6
|
+
* - Hover — shows namespace, name, and usage count
|
|
7
|
+
* - Definition — jumps to first occurrence
|
|
8
|
+
* - References — all usages across the workspace
|
|
9
|
+
* - Rename — rename a keyword everywhere
|
|
10
|
+
* - Diagnostics — hints for keywords used only once (possible typo)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
createConnection,
|
|
15
|
+
TextDocuments,
|
|
16
|
+
ProposedFeatures,
|
|
17
|
+
CompletionItemKind,
|
|
18
|
+
DiagnosticSeverity,
|
|
19
|
+
TextDocumentSyncKind,
|
|
20
|
+
MarkupKind,
|
|
21
|
+
} = require('vscode-languageserver/node');
|
|
22
|
+
const { TextDocument } = require('vscode-languageserver-textdocument');
|
|
23
|
+
const { scanKeywords, computeLineOffsets, offsetToPosition } = require('../lib/scan');
|
|
24
|
+
|
|
25
|
+
const connection = createConnection(ProposedFeatures.all);
|
|
26
|
+
const documents = new TextDocuments(TextDocument);
|
|
27
|
+
|
|
28
|
+
// ─── Keyword Index ────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/** @type {Map<string, Array<{uri:string, line:number, character:number, endCharacter:number}>>} */
|
|
31
|
+
const keywordIndex = new Map();
|
|
32
|
+
|
|
33
|
+
/** @type {Map<string, Set<string>>} namespace -> set of names */
|
|
34
|
+
const namespaceIndex = new Map();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Per-document scan cache. Avoids re-scanning the full document on every
|
|
38
|
+
* hover / definition / references / rename request.
|
|
39
|
+
* @type {Map<string, {version: number, keywords: Array<{ns:string|null, name:string, fqn:string, index:number, length:number}>, lineOffsets: number[]}>}
|
|
40
|
+
*/
|
|
41
|
+
const docCache = new Map();
|
|
42
|
+
|
|
43
|
+
function indexDocument(doc) {
|
|
44
|
+
const uri = doc.uri;
|
|
45
|
+
const text = doc.getText();
|
|
46
|
+
|
|
47
|
+
const keywords = scanKeywords(text);
|
|
48
|
+
const lineOffsets = computeLineOffsets(text);
|
|
49
|
+
|
|
50
|
+
// Cache scan results so getKeywordAtPosition / validateDocument don't re-scan.
|
|
51
|
+
docCache.set(uri, { version: doc.version, keywords, lineOffsets });
|
|
52
|
+
|
|
53
|
+
// Remove stale entries for this file.
|
|
54
|
+
for (const [fqn, locs] of keywordIndex) {
|
|
55
|
+
const filtered = locs.filter(l => l.uri !== uri);
|
|
56
|
+
if (filtered.length === 0) keywordIndex.delete(fqn);
|
|
57
|
+
else keywordIndex.set(fqn, filtered);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Insert new entries.
|
|
61
|
+
for (const { ns, name, fqn, index, length } of keywords) {
|
|
62
|
+
const { line, character } = offsetToPosition(lineOffsets, index);
|
|
63
|
+
const loc = { uri, line, character, endCharacter: character + length };
|
|
64
|
+
|
|
65
|
+
if (!keywordIndex.has(fqn)) keywordIndex.set(fqn, []);
|
|
66
|
+
keywordIndex.get(fqn).push(loc);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Rebuild namespaceIndex from scratch (avoids ghost namespaces).
|
|
70
|
+
namespaceIndex.clear();
|
|
71
|
+
for (const [fqn] of keywordIndex) {
|
|
72
|
+
const slashIdx = fqn.indexOf('/');
|
|
73
|
+
if (slashIdx !== -1) {
|
|
74
|
+
const ns = fqn.slice(0, slashIdx);
|
|
75
|
+
const name = fqn.slice(slashIdx + 1);
|
|
76
|
+
if (!namespaceIndex.has(ns)) namespaceIndex.set(ns, new Set());
|
|
77
|
+
namespaceIndex.get(ns).add(name);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Find the keyword that covers `position` in `doc`, if any.
|
|
84
|
+
* Uses the cached scan results from indexDocument.
|
|
85
|
+
*/
|
|
86
|
+
function getKeywordAtPosition(doc, position) {
|
|
87
|
+
const cache = docCache.get(doc.uri);
|
|
88
|
+
if (!cache) return null;
|
|
89
|
+
|
|
90
|
+
const { keywords, lineOffsets } = cache;
|
|
91
|
+
const offset = lineOffsets[position.line] + position.character;
|
|
92
|
+
|
|
93
|
+
for (const kw of keywords) {
|
|
94
|
+
if (offset >= kw.index && offset <= kw.index + kw.length) {
|
|
95
|
+
const lineStart = lineOffsets[position.line];
|
|
96
|
+
return {
|
|
97
|
+
ns: kw.ns,
|
|
98
|
+
name: kw.name,
|
|
99
|
+
fqn: kw.fqn,
|
|
100
|
+
start: kw.index - lineStart,
|
|
101
|
+
end: kw.index + kw.length - lineStart,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── LSP Capabilities ─────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
connection.onInitialize(() => ({
|
|
111
|
+
capabilities: {
|
|
112
|
+
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
113
|
+
completionProvider: { triggerCharacters: [':', '/'], resolveProvider: false },
|
|
114
|
+
hoverProvider: true,
|
|
115
|
+
definitionProvider: true,
|
|
116
|
+
referencesProvider: true,
|
|
117
|
+
renameProvider: { prepareProvider: true },
|
|
118
|
+
},
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
// ─── Document Sync ────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
documents.onDidChangeContent(({ document }) => {
|
|
124
|
+
indexDocument(document);
|
|
125
|
+
validateDocument(document);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
documents.onDidClose(({ document }) => {
|
|
129
|
+
docCache.delete(document.uri);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ─── Completion ───────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
connection.onCompletion(({ textDocument, position }) => {
|
|
135
|
+
const doc = documents.get(textDocument.uri);
|
|
136
|
+
if (!doc) return [];
|
|
137
|
+
|
|
138
|
+
const lineText = doc.getText({
|
|
139
|
+
start: { line: position.line, character: 0 },
|
|
140
|
+
end: { line: position.line, character: position.character },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const kwMatch = lineText.match(/:([\w.-]*(?:\/[\w.-]*)?)$/);
|
|
144
|
+
if (!kwMatch) return [];
|
|
145
|
+
|
|
146
|
+
const partial = kwMatch[1];
|
|
147
|
+
const replaceStart = position.character - kwMatch[0].length; // includes the ':'
|
|
148
|
+
const items = [];
|
|
149
|
+
|
|
150
|
+
if (partial.includes('/')) {
|
|
151
|
+
const [nsPrefix, namePrefix] = partial.split('/', 2);
|
|
152
|
+
const names = namespaceIndex.get(nsPrefix);
|
|
153
|
+
if (names) {
|
|
154
|
+
for (const name of names) {
|
|
155
|
+
if (name.startsWith(namePrefix || '')) {
|
|
156
|
+
const fqn = `${nsPrefix}/${name}`;
|
|
157
|
+
items.push({
|
|
158
|
+
label: `:${fqn}`,
|
|
159
|
+
kind: CompletionItemKind.Constant,
|
|
160
|
+
detail: `Keyword in namespace ${nsPrefix}`,
|
|
161
|
+
textEdit: {
|
|
162
|
+
range: {
|
|
163
|
+
start: { line: position.line, character: replaceStart },
|
|
164
|
+
end: { line: position.line, character: position.character },
|
|
165
|
+
},
|
|
166
|
+
newText: `:${fqn}`,
|
|
167
|
+
},
|
|
168
|
+
documentation: {
|
|
169
|
+
kind: MarkupKind.Markdown,
|
|
170
|
+
value: `**Keyword** \`:${fqn}\`\n\nUsages: ${(keywordIndex.get(fqn) || []).length}`,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
for (const [fqn, locs] of keywordIndex) {
|
|
178
|
+
if (fqn.startsWith(partial)) {
|
|
179
|
+
items.push({
|
|
180
|
+
label: `:${fqn}`,
|
|
181
|
+
kind: CompletionItemKind.Constant,
|
|
182
|
+
detail: `${locs.length} usage(s)`,
|
|
183
|
+
textEdit: {
|
|
184
|
+
range: {
|
|
185
|
+
start: { line: position.line, character: replaceStart },
|
|
186
|
+
end: { line: position.line, character: position.character },
|
|
187
|
+
},
|
|
188
|
+
newText: `:${fqn}`,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const ns of namespaceIndex.keys()) {
|
|
194
|
+
if (ns.startsWith(partial)) {
|
|
195
|
+
items.push({
|
|
196
|
+
label: `:${ns}/`,
|
|
197
|
+
kind: CompletionItemKind.Module,
|
|
198
|
+
detail: `Namespace (${namespaceIndex.get(ns).size} keywords)`,
|
|
199
|
+
textEdit: {
|
|
200
|
+
range: {
|
|
201
|
+
start: { line: position.line, character: replaceStart },
|
|
202
|
+
end: { line: position.line, character: position.character },
|
|
203
|
+
},
|
|
204
|
+
newText: `:${ns}/`,
|
|
205
|
+
},
|
|
206
|
+
command: { title: 'Trigger Suggest', command: 'editor.action.triggerSuggest' },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return items;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ─── Hover ────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
connection.onHover(({ textDocument, position }) => {
|
|
218
|
+
const doc = documents.get(textDocument.uri);
|
|
219
|
+
if (!doc) return null;
|
|
220
|
+
|
|
221
|
+
const kw = getKeywordAtPosition(doc, position);
|
|
222
|
+
if (!kw) return null;
|
|
223
|
+
|
|
224
|
+
const locs = keywordIndex.get(kw.fqn) || [];
|
|
225
|
+
const nsInfo = kw.ns
|
|
226
|
+
? `**Namespace:** \`${kw.ns}\`\n\n**Name:** \`${kw.name}\``
|
|
227
|
+
: `**Name:** \`${kw.name}\` *(unqualified)*`;
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
contents: {
|
|
231
|
+
kind: MarkupKind.Markdown,
|
|
232
|
+
value: [
|
|
233
|
+
`### Keyword \`:${kw.fqn}\``, '',
|
|
234
|
+
nsInfo, '',
|
|
235
|
+
`**Usages:** ${locs.length} across ${new Set(locs.map(l => l.uri)).size} file(s)`,
|
|
236
|
+
].join('\n'),
|
|
237
|
+
},
|
|
238
|
+
range: {
|
|
239
|
+
start: { line: position.line, character: kw.start },
|
|
240
|
+
end: { line: position.line, character: kw.end },
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ─── Go to Definition ─────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
connection.onDefinition(({ textDocument, position }) => {
|
|
248
|
+
const doc = documents.get(textDocument.uri);
|
|
249
|
+
if (!doc) return null;
|
|
250
|
+
|
|
251
|
+
const kw = getKeywordAtPosition(doc, position);
|
|
252
|
+
if (!kw) return null;
|
|
253
|
+
|
|
254
|
+
const locs = keywordIndex.get(kw.fqn);
|
|
255
|
+
if (!locs || locs.length === 0) return null;
|
|
256
|
+
|
|
257
|
+
const first = locs[0];
|
|
258
|
+
return {
|
|
259
|
+
uri: first.uri,
|
|
260
|
+
range: {
|
|
261
|
+
start: { line: first.line, character: first.character },
|
|
262
|
+
end: { line: first.line, character: first.endCharacter },
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ─── Find References ──────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
connection.onReferences(({ textDocument, position }) => {
|
|
270
|
+
const doc = documents.get(textDocument.uri);
|
|
271
|
+
if (!doc) return [];
|
|
272
|
+
|
|
273
|
+
const kw = getKeywordAtPosition(doc, position);
|
|
274
|
+
if (!kw) return [];
|
|
275
|
+
|
|
276
|
+
return (keywordIndex.get(kw.fqn) || []).map(loc => ({
|
|
277
|
+
uri: loc.uri,
|
|
278
|
+
range: {
|
|
279
|
+
start: { line: loc.line, character: loc.character },
|
|
280
|
+
end: { line: loc.line, character: loc.endCharacter },
|
|
281
|
+
},
|
|
282
|
+
}));
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ─── Rename ───────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
connection.onPrepareRename(({ textDocument, position }) => {
|
|
288
|
+
const doc = documents.get(textDocument.uri);
|
|
289
|
+
if (!doc) return null;
|
|
290
|
+
|
|
291
|
+
const kw = getKeywordAtPosition(doc, position);
|
|
292
|
+
if (!kw) return null;
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
range: {
|
|
296
|
+
start: { line: position.line, character: kw.start },
|
|
297
|
+
end: { line: position.line, character: kw.end },
|
|
298
|
+
},
|
|
299
|
+
placeholder: `:${kw.fqn}`,
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
connection.onRenameRequest(({ textDocument, position, newName }) => {
|
|
304
|
+
const doc = documents.get(textDocument.uri);
|
|
305
|
+
if (!doc) return null;
|
|
306
|
+
|
|
307
|
+
const kw = getKeywordAtPosition(doc, position);
|
|
308
|
+
if (!kw) return null;
|
|
309
|
+
|
|
310
|
+
const newKw = newName.startsWith(':') ? newName : `:${newName}`;
|
|
311
|
+
const changes = {};
|
|
312
|
+
|
|
313
|
+
for (const loc of (keywordIndex.get(kw.fqn) || [])) {
|
|
314
|
+
if (!changes[loc.uri]) changes[loc.uri] = [];
|
|
315
|
+
changes[loc.uri].push({
|
|
316
|
+
range: {
|
|
317
|
+
start: { line: loc.line, character: loc.character },
|
|
318
|
+
end: { line: loc.line, character: loc.endCharacter },
|
|
319
|
+
},
|
|
320
|
+
newText: newKw,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { changes };
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ─── Diagnostics ──────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
function validateDocument(doc) {
|
|
330
|
+
const cache = docCache.get(doc.uri);
|
|
331
|
+
if (!cache) return;
|
|
332
|
+
|
|
333
|
+
const { keywords, lineOffsets } = cache;
|
|
334
|
+
const diagnostics = [];
|
|
335
|
+
|
|
336
|
+
for (const { ns, fqn, index, length } of keywords) {
|
|
337
|
+
if (!ns) continue;
|
|
338
|
+
const locs = keywordIndex.get(fqn) || [];
|
|
339
|
+
if (locs.length !== 1) continue;
|
|
340
|
+
|
|
341
|
+
const { line, character } = offsetToPosition(lineOffsets, index);
|
|
342
|
+
diagnostics.push({
|
|
343
|
+
severity: DiagnosticSeverity.Information,
|
|
344
|
+
range: {
|
|
345
|
+
start: { line, character },
|
|
346
|
+
end: { line, character: character + length },
|
|
347
|
+
},
|
|
348
|
+
message: `Keyword :${fqn} is used only once. Possible typo?`,
|
|
349
|
+
source: 'js-qualified-keywords',
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
connection.sendDiagnostics({ uri: doc.uri, diagnostics });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ─── Start ────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
documents.listen(connection);
|
|
359
|
+
connection.listen();
|
|
360
|
+
connection.console.log('Clojure Keywords LSP server started');
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "js-qualified-keywords",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Clojure-style qualified keywords (:ns/name) as a first-class data type in JavaScript, with Babel plugin and LSP support.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"clojure",
|
|
7
|
+
"keyword",
|
|
8
|
+
"babel-plugin",
|
|
9
|
+
"lsp",
|
|
10
|
+
"data-types"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/YOUR_ORG/clojure-keywords-js#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/YOUR_ORG/clojure-keywords-js/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/YOUR_ORG/clojure-keywords-js.git"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"main": "runtime/index.js",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./runtime/index.js",
|
|
24
|
+
"./runtime": "./runtime/index.js",
|
|
25
|
+
"./babel-plugin": "./babel-plugin/index.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"runtime/",
|
|
29
|
+
"babel-plugin/",
|
|
30
|
+
"lib/",
|
|
31
|
+
"lsp-server/server.js",
|
|
32
|
+
"lsp-server/start.sh",
|
|
33
|
+
"lsp-server/package.json",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node tests/scan.test.js && node tests/babel-plugin.test.js && node tests/runtime.test.js"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@babel/core": "^7.29.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@babel/core": "^7.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"@babel/core": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
package/runtime/index.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KeywordMap — a Map that accepts Keyword instances as keys with value equality.
|
|
3
|
+
*
|
|
4
|
+
* Regular JS Maps use reference equality for object keys, so two Keyword
|
|
5
|
+
* instances with the same ns/name would be treated as different keys.
|
|
6
|
+
* Because Keywords are interned, reference equality IS value equality —
|
|
7
|
+
* but KeywordMap also accepts plain strings like ":user/name" as keys.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { Keyword } = require('./keyword');
|
|
11
|
+
|
|
12
|
+
class KeywordMap {
|
|
13
|
+
#map = new Map();
|
|
14
|
+
|
|
15
|
+
#resolve(key) {
|
|
16
|
+
if (key instanceof Keyword) return key;
|
|
17
|
+
if (typeof key === 'string') return Keyword.of(key);
|
|
18
|
+
throw new TypeError(`KeywordMap key must be a Keyword or string, got ${typeof key}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
set(key, value) {
|
|
22
|
+
this.#map.set(this.#resolve(key), value);
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get(key) {
|
|
27
|
+
return this.#map.get(this.#resolve(key));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
has(key) {
|
|
31
|
+
return this.#map.has(this.#resolve(key));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
delete(key) {
|
|
35
|
+
return this.#map.delete(this.#resolve(key));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get size() { return this.#map.size; }
|
|
39
|
+
|
|
40
|
+
entries() { return this.#map.entries(); }
|
|
41
|
+
keys() { return this.#map.keys(); }
|
|
42
|
+
values() { return this.#map.values(); }
|
|
43
|
+
[Symbol.iterator]() { return this.#map[Symbol.iterator](); }
|
|
44
|
+
|
|
45
|
+
forEach(cb, thisArg) {
|
|
46
|
+
this.#map.forEach((v, k) => cb.call(thisArg, v, k, this));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
toObject() {
|
|
50
|
+
const obj = {};
|
|
51
|
+
for (const [k, v] of this.#map) obj[k.toString()] = v;
|
|
52
|
+
return obj;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
toJSON() { return this.toObject(); }
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a KeywordMap from a plain object whose keys are keyword strings.
|
|
59
|
+
* @param {Record<string, any>} obj
|
|
60
|
+
* @returns {KeywordMap}
|
|
61
|
+
*/
|
|
62
|
+
static fromObject(obj) {
|
|
63
|
+
const m = new KeywordMap();
|
|
64
|
+
for (const [k, v] of Object.entries(obj)) m.set(k, v);
|
|
65
|
+
return m;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { KeywordMap };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clojure-style Keyword for JavaScript.
|
|
3
|
+
* Supports both simple (:name) and qualified (:ns/name) keywords.
|
|
4
|
+
* Keywords are interned — same ns/name always returns the same instance.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const _intern = new Map();
|
|
8
|
+
|
|
9
|
+
class Keyword {
|
|
10
|
+
#ns;
|
|
11
|
+
#name;
|
|
12
|
+
|
|
13
|
+
constructor(ns, name) {
|
|
14
|
+
if (name === undefined) {
|
|
15
|
+
const parsed = Keyword.parse(ns);
|
|
16
|
+
this.#ns = parsed.ns;
|
|
17
|
+
this.#name = parsed.name;
|
|
18
|
+
} else {
|
|
19
|
+
this.#ns = ns;
|
|
20
|
+
this.#name = name;
|
|
21
|
+
}
|
|
22
|
+
Object.freeze(this);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get ns() { return this.#ns; }
|
|
26
|
+
get name() { return this.#name; }
|
|
27
|
+
get fqn() { return this.#ns ? `${this.#ns}/${this.#name}` : this.#name; }
|
|
28
|
+
|
|
29
|
+
toString() { return `:${this.fqn}`; }
|
|
30
|
+
toJSON() { return this.toString(); }
|
|
31
|
+
|
|
32
|
+
equals(other) {
|
|
33
|
+
return other instanceof Keyword && other.ns === this.#ns && other.name === this.#name;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
valueOf() { return this.toString(); }
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Intern a keyword — same ns/name always returns the same instance.
|
|
40
|
+
* @param {string|null} ns - namespace, or a full ":ns/name" string if name omitted
|
|
41
|
+
* @param {string} [name]
|
|
42
|
+
* @returns {Keyword}
|
|
43
|
+
*/
|
|
44
|
+
static of(ns, name) {
|
|
45
|
+
if (name === undefined) {
|
|
46
|
+
const parsed = Keyword.parse(ns);
|
|
47
|
+
ns = parsed.ns;
|
|
48
|
+
name = parsed.name;
|
|
49
|
+
}
|
|
50
|
+
const key = ns ? `${ns}/${name}` : name;
|
|
51
|
+
if (_intern.has(key)) return _intern.get(key);
|
|
52
|
+
const k = new Keyword(ns, name);
|
|
53
|
+
_intern.set(key, k);
|
|
54
|
+
return k;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse a keyword string like ":ns/name", "ns/name", or ":name".
|
|
59
|
+
* @param {string} s
|
|
60
|
+
* @returns {{ ns: string|null, name: string }}
|
|
61
|
+
*/
|
|
62
|
+
static parse(s) {
|
|
63
|
+
if (typeof s !== 'string') throw new TypeError(`Expected string, got ${typeof s}`);
|
|
64
|
+
const str = s.startsWith(':') ? s.slice(1) : s;
|
|
65
|
+
if (!str) throw new Error('Keyword name cannot be empty');
|
|
66
|
+
const slashIdx = str.indexOf('/');
|
|
67
|
+
if (slashIdx === -1) return { ns: null, name: str };
|
|
68
|
+
const ns = str.slice(0, slashIdx);
|
|
69
|
+
const name = str.slice(slashIdx + 1);
|
|
70
|
+
if (!ns) throw new Error('Keyword namespace cannot be empty');
|
|
71
|
+
if (!name) throw new Error('Keyword name cannot be empty');
|
|
72
|
+
return { ns, name };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Clear the intern cache. Mainly useful for testing. */
|
|
76
|
+
static clearCache() { _intern.clear(); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Shorthand factory. Used by the Babel plugin output.
|
|
81
|
+
* @param {string|null} ns
|
|
82
|
+
* @param {string} name
|
|
83
|
+
* @returns {Keyword}
|
|
84
|
+
*/
|
|
85
|
+
function kw(ns, name) {
|
|
86
|
+
return Keyword.of(ns, name);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { Keyword, kw };
|