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 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');
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # Start the clojure-keywords LSP server over stdio.
3
+ exec node "$(dirname "$0")/server.js" --stdio
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
+ }
@@ -0,0 +1,4 @@
1
+ const { Keyword, kw } = require('./keyword');
2
+ const { KeywordMap } = require('./keyword-map');
3
+
4
+ module.exports = { Keyword, kw, KeywordMap };
@@ -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 };