@geoql/doctor-language-server 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) 2026 Vinayak Kulkarni
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.
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+
3
+ import module from 'node:module';
4
+
5
+ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {
6
+ try {
7
+ module.enableCompileCache();
8
+ } catch {
9
+ // Ignore compile-cache errors.
10
+ }
11
+ }
12
+
13
+ const { startLanguageServer } = await import('../dist/index.mjs');
14
+
15
+ startLanguageServer();
@@ -0,0 +1,207 @@
1
+ import { AuditReport, Diagnostic, ProjectInfo, Severity } from "@geoql/doctor-core";
2
+ import { Connection } from "vscode-languageserver/node";
3
+ import { Diagnostic as Diagnostic$1, DiagnosticSeverity, Position, Range } from "vscode-languageserver";
4
+
5
+ //#region src/server.d.ts
6
+ /**
7
+ * Builds and wires the Doctor language server onto a connection. Exposed
8
+ * separately from {@link startLanguageServer} so the I/O shell stays thin and
9
+ * the pure transforms (mapper, positions, severity, group, scheduler, cache,
10
+ * selection) carry the behavior — and the coverage.
11
+ */
12
+ declare const createServer: (connection: Connection) => void;
13
+ /** Entry point: starts the server over stdio. */
14
+ declare const startLanguageServer: () => void;
15
+ //#endregion
16
+ //#region src/cache.d.ts
17
+ /**
18
+ * Per-document version cache. The LSP `TextDocument.version` increments on
19
+ * every edit, so a request to audit a `(uri, version)` pair already audited
20
+ * (or in flight) is redundant. `shouldAudit` is the pure decision; the
21
+ * server records the version it acted on via `markAudited`.
22
+ */
23
+ interface VersionCache {
24
+ /** Whether `uri` at `version` warrants a fresh audit (not already seen). */
25
+ readonly shouldAudit: (uri: string, version: number) => boolean;
26
+ /** Records that `uri` was audited at `version`. */
27
+ readonly markAudited: (uri: string, version: number) => void;
28
+ /** Forgets a document (on close) so a later reopen always re-audits. */
29
+ readonly forget: (uri: string) => void;
30
+ }
31
+ /** Pure decision: audit only when this version is newer than the last seen. */
32
+ declare const shouldAuditVersion: (lastAuditedVersion: number | undefined, version: number) => boolean;
33
+ declare const createVersionCache: () => VersionCache;
34
+ //#endregion
35
+ //#region src/scheduler.d.ts
36
+ /**
37
+ * Per-key debounced scheduler. Each editor scope (a document URI, or a
38
+ * workspace root) gets one in-flight audit at a time: rapid edits to the same
39
+ * document collapse into a single trailing run, and a monotonic generation
40
+ * per key supersedes stale work so a slow audit can never clobber a fresher
41
+ * result. Save scans pass `debounceMs: 0` to run immediately.
42
+ */
43
+ interface SchedulerOptions {
44
+ /** Trailing debounce window for an enqueued key, in ms. Default 400. */
45
+ readonly debounceMs?: number;
46
+ /** Runs the audit for a key; rejections are surfaced via `onError`. */
47
+ readonly performScan: (key: string, token: CancellationToken) => Promise<void>;
48
+ /** Called when a `performScan` promise rejects. */
49
+ readonly onError?: (error: unknown, key: string) => void;
50
+ }
51
+ /** Cooperative cancellation: `true` once a newer enqueue superseded the run. */
52
+ interface CancellationToken {
53
+ readonly isCancelled: boolean;
54
+ }
55
+ interface Scheduler {
56
+ /** Queue (or re-queue) an audit for `key`, debounced. `delayMs` overrides the default. */
57
+ readonly enqueue: (key: string, delayMs?: number) => void;
58
+ /** Cancel any pending/in-flight work for `key`. */
59
+ readonly cancel: (key: string) => void;
60
+ /** Pending timers + in-flight runs; for tests and idle checks. */
61
+ readonly pendingCount: () => number;
62
+ /** Stop all timers and abandon queued work. */
63
+ readonly dispose: () => void;
64
+ }
65
+ declare const createScheduler: (options: SchedulerOptions) => Scheduler;
66
+ //#endregion
67
+ //#region src/group.d.ts
68
+ interface GroupDiagnosticsInput {
69
+ readonly report: AuditReport;
70
+ /**
71
+ * Returns the live text for a diagnostic's file (open buffer first, then
72
+ * disk) so range fallback can extend to end-of-line; `null` when unknown.
73
+ */
74
+ readonly textForFile: (file: string) => string | null;
75
+ /**
76
+ * URIs that previously held diagnostics and must be republished even when
77
+ * the new audit found nothing in them — otherwise stale squiggles linger.
78
+ */
79
+ readonly previousUris?: Iterable<string>;
80
+ }
81
+ /**
82
+ * Transforms an `AuditReport` into a per-URI map of LSP diagnostics. Every
83
+ * URI in `previousUris` that has no findings this pass is included with an
84
+ * empty array so the server can clear it. Grouping is keyed by the resolved
85
+ * `file://` URI of each diagnostic's `file`.
86
+ */
87
+ declare const groupDiagnosticsByUri: (input: GroupDiagnosticsInput) => Map<string, Diagnostic$1[]>;
88
+ //#endregion
89
+ //#region src/mapper.d.ts
90
+ interface MapDiagnosticInput {
91
+ readonly diagnostic: Diagnostic;
92
+ /** Text of the diagnostic's file for end-of-line range fallback; `null` → single column. */
93
+ readonly text: string | null;
94
+ }
95
+ /**
96
+ * Converts a core doctor `Diagnostic` into an LSP `Diagnostic` with a precise
97
+ * 0-based range, `source: 'doctor'`, `code: ruleId`, a `codeDescription`
98
+ * pointing at the rule's docs page, and the recommendation appended to the
99
+ * message when present.
100
+ */
101
+ declare const toLspDiagnostic: (input: MapDiagnosticInput) => Diagnostic$1;
102
+ //#endregion
103
+ //#region src/positions.d.ts
104
+ /**
105
+ * doctor-core reports diagnostics with 1-indexed `line` / `column` (and an
106
+ * optional 1-indexed `endLine` / `endColumn`), but LSP positions are
107
+ * 0-indexed. These helpers convert between the two against the document text
108
+ * so squiggles land exactly on the offending token.
109
+ */
110
+ /** Converts a 1-indexed line/column pair to a 0-indexed LSP `Position`. */
111
+ declare const toZeroBasedPosition: (line: number, column: number) => Position;
112
+ /**
113
+ * Builds an LSP `Range` from doctor-core's 1-indexed coordinates. When an
114
+ * explicit `endLine` / `endColumn` is present it is used directly; otherwise
115
+ * the range extends to the end of the start line (when document text is
116
+ * known) so the squiggle is visible, falling back to a single-character span
117
+ * when the text is unavailable.
118
+ */
119
+ declare const rangeFromLineColumn: (text: string | null, line: number, column: number, endLine?: number, endColumn?: number) => Range;
120
+ /** Whether two ranges overlap (touching endpoints count as overlap). */
121
+ declare const rangesOverlap: (first: Range, second: Range) => boolean;
122
+ //#endregion
123
+ //#region src/severity.d.ts
124
+ /**
125
+ * Maps doctor-core's internal severity vocabulary (`error | warn | info`) to
126
+ * the LSP `DiagnosticSeverity` enum: `error → Error`, `warn → Warning`,
127
+ * `info → Information`. Doctor never emits LSP `Hint`.
128
+ */
129
+ declare const toLspSeverity: (severity: Severity) => DiagnosticSeverity;
130
+ /**
131
+ * Human-readable label for an LSP diagnostic's severity. Covers all four LSP
132
+ * severities so a value outside doctor's vocabulary isn't mislabeled.
133
+ */
134
+ declare const severityLabel: (severity: DiagnosticSeverity | undefined) => string;
135
+ //#endregion
136
+ //#region src/selection.d.ts
137
+ /** Editor-facing project flavor the audit runs as. */
138
+ type DoctorProjectKind = 'vue' | 'nuxt' | 'unknown';
139
+ /**
140
+ * Picks which doctor plugin set applies to a project from its detected
141
+ * framework. Nuxt projects get the Nuxt + Vue rule passes; plain Vue
142
+ * projects get the Vue passes; an unrecognized project is `unknown` and the
143
+ * server skips auditing it (no framework → no applicable rules).
144
+ */
145
+ declare const selectProjectKind: (project: Pick<ProjectInfo, "framework">) => DoctorProjectKind;
146
+ /** Whether a detected project is one doctor can audit (Vue or Nuxt). */
147
+ declare const isAuditableProject: (project: Pick<ProjectInfo, "framework">) => boolean;
148
+ //#endregion
149
+ //#region src/uri.d.ts
150
+ /**
151
+ * Resolves a doctor-core diagnostic's `file` (relative to the audit root, or
152
+ * already absolute) to a `file://` URI the editor can address. Paths are
153
+ * normalized to forward slashes by `vscode-uri`, so casing/encoding stay
154
+ * consistent across platforms.
155
+ */
156
+ declare const diagnosticFileToUri: (rootDir: string, file: string) => string;
157
+ /** Converts a `file://` URI back to an absolute fs path. */
158
+ declare const uriToFsPath: (uri: string) => string;
159
+ //#endregion
160
+ //#region src/constants.d.ts
161
+ /** Display name used in client-facing messages and progress titles. */
162
+ declare const SERVER_DISPLAY_NAME = "Doctor";
163
+ /** `Diagnostic.source` shown next to every published diagnostic. */
164
+ declare const DIAGNOSTIC_SOURCE = "doctor";
165
+ /**
166
+ * Debounce window between an open document's last edit and the rescan it
167
+ * triggers. Long enough that fast typing collapses into a single audit,
168
+ * short enough to still feel live. Save scans run with no debounce.
169
+ */
170
+ declare const DOCUMENT_CHANGE_DEBOUNCE_MS = 400;
171
+ /** Delay after `initialized` before the first background workspace scan. */
172
+ declare const INITIAL_WORKSPACE_SCAN_DELAY_MS = 300;
173
+ /**
174
+ * Source file extensions the server scans on open / change / save. Mirrors
175
+ * doctor-core's default include set so editor scanning covers the same
176
+ * files the CLI audits.
177
+ */
178
+ declare const SCANNABLE_EXTENSIONS: readonly [".vue", ".ts", ".tsx", ".js", ".jsx"];
179
+ //#endregion
180
+ //#region src/index.d.ts
181
+ /** Public API surface of `@geoql/doctor-language-server`. */
182
+ declare const api: {
183
+ readonly createServer: (connection: import("vscode-languageserver").Connection) => void;
184
+ readonly startLanguageServer: () => void;
185
+ readonly toLspDiagnostic: (input: MapDiagnosticInput) => import("vscode-languageserver").Diagnostic;
186
+ readonly rangeFromLineColumn: (text: string | null, line: number, column: number, endLine?: number, endColumn?: number) => import("vscode-languageserver").Range;
187
+ readonly rangesOverlap: (first: import("vscode-languageserver").Range, second: import("vscode-languageserver").Range) => boolean;
188
+ readonly toZeroBasedPosition: (line: number, column: number) => import("vscode-languageserver").Position;
189
+ readonly toLspSeverity: (severity: import("@geoql/doctor-core").Severity) => import("vscode-languageserver").DiagnosticSeverity;
190
+ readonly severityLabel: (severity: import("vscode-languageserver").DiagnosticSeverity | undefined) => string;
191
+ readonly groupDiagnosticsByUri: (input: GroupDiagnosticsInput) => Map<string, import("vscode-languageserver").Diagnostic[]>;
192
+ readonly selectProjectKind: (project: Pick<import("@geoql/doctor-core").ProjectInfo, "framework">) => DoctorProjectKind;
193
+ readonly isAuditableProject: (project: Pick<import("@geoql/doctor-core").ProjectInfo, "framework">) => boolean;
194
+ readonly diagnosticFileToUri: (rootDir: string, file: string) => string;
195
+ readonly uriToFsPath: (uri: string) => string;
196
+ readonly createScheduler: (options: SchedulerOptions) => Scheduler;
197
+ readonly createVersionCache: () => VersionCache;
198
+ readonly shouldAuditVersion: (lastAuditedVersion: number | undefined, version: number) => boolean;
199
+ readonly DIAGNOSTIC_SOURCE: "doctor";
200
+ readonly SERVER_DISPLAY_NAME: "Doctor";
201
+ readonly DOCUMENT_CHANGE_DEBOUNCE_MS: 400;
202
+ readonly INITIAL_WORKSPACE_SCAN_DELAY_MS: 300;
203
+ readonly SCANNABLE_EXTENSIONS: readonly [".vue", ".ts", ".tsx", ".js", ".jsx"];
204
+ };
205
+ //#endregion
206
+ export { type CancellationToken, DIAGNOSTIC_SOURCE, DOCUMENT_CHANGE_DEBOUNCE_MS, type DoctorProjectKind, type GroupDiagnosticsInput, INITIAL_WORKSPACE_SCAN_DELAY_MS, type MapDiagnosticInput, SCANNABLE_EXTENSIONS, SERVER_DISPLAY_NAME, type Scheduler, type SchedulerOptions, type VersionCache, api, createScheduler, createServer, createVersionCache, diagnosticFileToUri, groupDiagnosticsByUri, isAuditableProject, rangeFromLineColumn, rangesOverlap, selectProjectKind, severityLabel, shouldAuditVersion, startLanguageServer, toLspDiagnostic, toLspSeverity, toZeroBasedPosition, uriToFsPath };
207
+ //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs ADDED
@@ -0,0 +1,407 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { audit, detectProject, docsUrl } from "@geoql/doctor-core";
3
+ import { TextDocumentSyncKind, TextDocuments, createConnection } from "vscode-languageserver/node";
4
+ import { TextDocument } from "vscode-languageserver-textdocument";
5
+ import { DiagnosticSeverity } from "vscode-languageserver";
6
+ import { isAbsolute, resolve } from "node:path";
7
+ import { URI } from "vscode-uri";
8
+ //#region src/constants.ts
9
+ /** Display name used in client-facing messages and progress titles. */
10
+ const SERVER_DISPLAY_NAME = "Doctor";
11
+ /** `Diagnostic.source` shown next to every published diagnostic. */
12
+ const DIAGNOSTIC_SOURCE = "doctor";
13
+ /**
14
+ * Debounce window between an open document's last edit and the rescan it
15
+ * triggers. Long enough that fast typing collapses into a single audit,
16
+ * short enough to still feel live. Save scans run with no debounce.
17
+ */
18
+ const DOCUMENT_CHANGE_DEBOUNCE_MS = 400;
19
+ /** Delay after `initialized` before the first background workspace scan. */
20
+ const INITIAL_WORKSPACE_SCAN_DELAY_MS = 300;
21
+ /**
22
+ * Source file extensions the server scans on open / change / save. Mirrors
23
+ * doctor-core's default include set so editor scanning covers the same
24
+ * files the CLI audits.
25
+ */
26
+ const SCANNABLE_EXTENSIONS = [
27
+ ".vue",
28
+ ".ts",
29
+ ".tsx",
30
+ ".js",
31
+ ".jsx"
32
+ ];
33
+ //#endregion
34
+ //#region src/cache.ts
35
+ /** Pure decision: audit only when this version is newer than the last seen. */
36
+ const shouldAuditVersion = (lastAuditedVersion, version) => lastAuditedVersion === void 0 || version > lastAuditedVersion;
37
+ const createVersionCache = () => {
38
+ const lastAudited = /* @__PURE__ */ new Map();
39
+ return {
40
+ shouldAudit: (uri, version) => shouldAuditVersion(lastAudited.get(uri), version),
41
+ markAudited: (uri, version) => {
42
+ lastAudited.set(uri, version);
43
+ },
44
+ forget: (uri) => {
45
+ lastAudited.delete(uri);
46
+ }
47
+ };
48
+ };
49
+ //#endregion
50
+ //#region src/positions.ts
51
+ /**
52
+ * doctor-core reports diagnostics with 1-indexed `line` / `column` (and an
53
+ * optional 1-indexed `endLine` / `endColumn`), but LSP positions are
54
+ * 0-indexed. These helpers convert between the two against the document text
55
+ * so squiggles land exactly on the offending token.
56
+ */
57
+ /** Converts a 1-indexed line/column pair to a 0-indexed LSP `Position`. */
58
+ const toZeroBasedPosition = (line, column) => ({
59
+ line: Math.max(0, (line || 1) - 1),
60
+ character: Math.max(0, (column || 1) - 1)
61
+ });
62
+ /**
63
+ * Builds an LSP `Range` from doctor-core's 1-indexed coordinates. When an
64
+ * explicit `endLine` / `endColumn` is present it is used directly; otherwise
65
+ * the range extends to the end of the start line (when document text is
66
+ * known) so the squiggle is visible, falling back to a single-character span
67
+ * when the text is unavailable.
68
+ */
69
+ const rangeFromLineColumn = (text, line, column, endLine, endColumn) => {
70
+ const start = toZeroBasedPosition(line, column);
71
+ if (endLine !== void 0 && endColumn !== void 0) return {
72
+ start,
73
+ end: toZeroBasedPosition(endLine, endColumn)
74
+ };
75
+ if (text !== null) {
76
+ const lineText = text.split("\n")[start.line] ?? "";
77
+ const endCharacter = Math.max(start.character + 1, lineText.replace(/\r$/, "").length);
78
+ return {
79
+ start,
80
+ end: {
81
+ line: start.line,
82
+ character: endCharacter
83
+ }
84
+ };
85
+ }
86
+ return {
87
+ start,
88
+ end: {
89
+ line: start.line,
90
+ character: start.character + 1
91
+ }
92
+ };
93
+ };
94
+ const isBefore = (first, second) => first.line < second.line || first.line === second.line && first.character < second.character;
95
+ /** Whether two ranges overlap (touching endpoints count as overlap). */
96
+ const rangesOverlap = (first, second) => !isBefore(first.end, second.start) && !isBefore(second.end, first.start);
97
+ //#endregion
98
+ //#region src/severity.ts
99
+ /**
100
+ * Maps doctor-core's internal severity vocabulary (`error | warn | info`) to
101
+ * the LSP `DiagnosticSeverity` enum: `error → Error`, `warn → Warning`,
102
+ * `info → Information`. Doctor never emits LSP `Hint`.
103
+ */
104
+ const toLspSeverity = (severity) => {
105
+ switch (severity) {
106
+ case "error": return DiagnosticSeverity.Error;
107
+ case "warn": return DiagnosticSeverity.Warning;
108
+ case "info": return DiagnosticSeverity.Information;
109
+ }
110
+ };
111
+ /**
112
+ * Human-readable label for an LSP diagnostic's severity. Covers all four LSP
113
+ * severities so a value outside doctor's vocabulary isn't mislabeled.
114
+ */
115
+ const severityLabel = (severity) => {
116
+ switch (severity) {
117
+ case DiagnosticSeverity.Error: return "error";
118
+ case DiagnosticSeverity.Information: return "info";
119
+ case DiagnosticSeverity.Hint: return "hint";
120
+ default: return "warning";
121
+ }
122
+ };
123
+ //#endregion
124
+ //#region src/mapper.ts
125
+ /**
126
+ * Converts a core doctor `Diagnostic` into an LSP `Diagnostic` with a precise
127
+ * 0-based range, `source: 'doctor'`, `code: ruleId`, a `codeDescription`
128
+ * pointing at the rule's docs page, and the recommendation appended to the
129
+ * message when present.
130
+ */
131
+ const toLspDiagnostic = (input) => {
132
+ const { diagnostic, text } = input;
133
+ return {
134
+ range: rangeFromLineColumn(text, diagnostic.line, diagnostic.column, diagnostic.endLine, diagnostic.endColumn),
135
+ severity: toLspSeverity(diagnostic.severity),
136
+ code: diagnostic.ruleId,
137
+ codeDescription: { href: docsUrl(diagnostic.ruleId) },
138
+ source: DIAGNOSTIC_SOURCE,
139
+ message: diagnostic.message
140
+ };
141
+ };
142
+ //#endregion
143
+ //#region src/uri.ts
144
+ /**
145
+ * Resolves a doctor-core diagnostic's `file` (relative to the audit root, or
146
+ * already absolute) to a `file://` URI the editor can address. Paths are
147
+ * normalized to forward slashes by `vscode-uri`, so casing/encoding stay
148
+ * consistent across platforms.
149
+ */
150
+ const diagnosticFileToUri = (rootDir, file) => {
151
+ const absolute = isAbsolute(file) ? file : resolve(rootDir, file);
152
+ return URI.file(absolute).toString();
153
+ };
154
+ /** Converts a `file://` URI back to an absolute fs path. */
155
+ const uriToFsPath = (uri) => URI.parse(uri).fsPath;
156
+ //#endregion
157
+ //#region src/group.ts
158
+ /**
159
+ * Transforms an `AuditReport` into a per-URI map of LSP diagnostics. Every
160
+ * URI in `previousUris` that has no findings this pass is included with an
161
+ * empty array so the server can clear it. Grouping is keyed by the resolved
162
+ * `file://` URI of each diagnostic's `file`.
163
+ */
164
+ const groupDiagnosticsByUri = (input) => {
165
+ const { report, textForFile, previousUris } = input;
166
+ const byUri = /* @__PURE__ */ new Map();
167
+ for (const uri of previousUris ?? []) byUri.set(uri, []);
168
+ const textCache = /* @__PURE__ */ new Map();
169
+ const readText = (file) => {
170
+ const cached = textCache.get(file);
171
+ if (cached !== void 0) return cached;
172
+ const text = textForFile(file);
173
+ textCache.set(file, text);
174
+ return text;
175
+ };
176
+ for (const diagnostic of report.diagnostics) appendDiagnostic(byUri, report.rootDir, diagnostic, readText);
177
+ return byUri;
178
+ };
179
+ const appendDiagnostic = (byUri, rootDir, diagnostic, readText) => {
180
+ const uri = diagnosticFileToUri(rootDir, diagnostic.file);
181
+ const lsp = toLspDiagnostic({
182
+ diagnostic,
183
+ text: readText(diagnostic.file)
184
+ });
185
+ const existing = byUri.get(uri);
186
+ if (existing) existing.push(lsp);
187
+ else byUri.set(uri, [lsp]);
188
+ };
189
+ //#endregion
190
+ //#region src/selection.ts
191
+ /**
192
+ * Picks which doctor plugin set applies to a project from its detected
193
+ * framework. Nuxt projects get the Nuxt + Vue rule passes; plain Vue
194
+ * projects get the Vue passes; an unrecognized project is `unknown` and the
195
+ * server skips auditing it (no framework → no applicable rules).
196
+ */
197
+ const selectProjectKind = (project) => {
198
+ const framework = project.framework;
199
+ if (framework === "nuxt") return "nuxt";
200
+ if (framework === "vue") return "vue";
201
+ return "unknown";
202
+ };
203
+ /** Whether a detected project is one doctor can audit (Vue or Nuxt). */
204
+ const isAuditableProject = (project) => selectProjectKind(project) !== "unknown";
205
+ //#endregion
206
+ //#region src/scheduler.ts
207
+ const createScheduler = (options) => {
208
+ const defaultDebounceMs = options.debounceMs ?? 400;
209
+ let disposed = false;
210
+ let generation = 0;
211
+ let running = 0;
212
+ const timers = /* @__PURE__ */ new Map();
213
+ const latestGeneration = /* @__PURE__ */ new Map();
214
+ const run = (key, id) => {
215
+ running += 1;
216
+ Promise.resolve(options.performScan(key, { get isCancelled() {
217
+ return disposed || latestGeneration.get(key) !== id;
218
+ } })).catch((error) => {
219
+ options.onError?.(error, key);
220
+ }).finally(() => {
221
+ running -= 1;
222
+ });
223
+ };
224
+ const enqueue = (key, delayMs) => {
225
+ if (disposed) return;
226
+ const id = generation += 1;
227
+ latestGeneration.set(key, id);
228
+ const existing = timers.get(key);
229
+ if (existing) clearTimeout(existing);
230
+ const timer = setTimeout(() => {
231
+ timers.delete(key);
232
+ run(key, id);
233
+ }, delayMs ?? defaultDebounceMs);
234
+ if (typeof timer.unref === "function") timer.unref();
235
+ timers.set(key, timer);
236
+ };
237
+ const cancel = (key) => {
238
+ const timer = timers.get(key);
239
+ if (timer) {
240
+ clearTimeout(timer);
241
+ timers.delete(key);
242
+ }
243
+ latestGeneration.set(key, generation += 1);
244
+ };
245
+ const dispose = () => {
246
+ disposed = true;
247
+ for (const timer of timers.values()) clearTimeout(timer);
248
+ timers.clear();
249
+ };
250
+ const pendingCount = () => timers.size + running;
251
+ return {
252
+ enqueue,
253
+ cancel,
254
+ pendingCount,
255
+ dispose
256
+ };
257
+ };
258
+ //#endregion
259
+ //#region src/server.ts
260
+ const SERVER_VERSION = "0.1.0";
261
+ /**
262
+ * Builds and wires the Doctor language server onto a connection. Exposed
263
+ * separately from {@link startLanguageServer} so the I/O shell stays thin and
264
+ * the pure transforms (mapper, positions, severity, group, scheduler, cache,
265
+ * selection) carry the behavior — and the coverage.
266
+ */
267
+ const createServer = (connection) => {
268
+ const documents = new TextDocuments(TextDocument);
269
+ const versionCache = createVersionCache();
270
+ let workspaceRoot = null;
271
+ const publishedUris = /* @__PURE__ */ new Set();
272
+ const readText = (fsPath) => {
273
+ const fileUri = `file://${fsPath}`;
274
+ const open = documents.get(fileUri);
275
+ if (open) return open.getText();
276
+ try {
277
+ return readFileSync(fsPath, "utf8");
278
+ } catch {
279
+ return null;
280
+ }
281
+ };
282
+ const runAudit = async (rootDir, scopeFsPath) => {
283
+ if (!isAuditableProject(await detectProject(rootDir))) return;
284
+ const byUri = groupDiagnosticsByUri({
285
+ report: await audit({
286
+ rootDir,
287
+ ...scopeFsPath !== null ? { scopeFiles: [scopeFsPath] } : {}
288
+ }),
289
+ textForFile: (file) => readText(file.startsWith("/") ? file : `${rootDir}/${file}`),
290
+ previousUris: publishedUris
291
+ });
292
+ for (const [uri, diagnostics] of byUri) {
293
+ connection.sendDiagnostics({
294
+ uri,
295
+ diagnostics
296
+ });
297
+ if (diagnostics.length > 0) publishedUris.add(uri);
298
+ else publishedUris.delete(uri);
299
+ }
300
+ };
301
+ const scheduler = createScheduler({
302
+ debounceMs: 400,
303
+ performScan: async (key) => {
304
+ if (workspaceRoot === null) return;
305
+ const scope = key === workspaceRoot ? null : uriToFsPath(key);
306
+ await runAudit(workspaceRoot, scope);
307
+ },
308
+ onError: (error, key) => connection.console.error(`Audit of ${key} failed: ${error instanceof Error ? error.message : String(error)}`)
309
+ });
310
+ connection.onInitialize((params) => {
311
+ workspaceRoot = resolveWorkspaceRoot(params);
312
+ return {
313
+ capabilities: { textDocumentSync: {
314
+ openClose: true,
315
+ change: TextDocumentSyncKind.Incremental,
316
+ save: { includeText: false }
317
+ } },
318
+ serverInfo: {
319
+ name: SERVER_DISPLAY_NAME,
320
+ version: SERVER_VERSION
321
+ }
322
+ };
323
+ });
324
+ connection.onInitialized(() => {
325
+ if (workspaceRoot !== null) {
326
+ const root = workspaceRoot;
327
+ setTimeout(() => scheduler.enqueue(root, 300), 0);
328
+ }
329
+ });
330
+ documents.onDidOpen((event) => {
331
+ if (versionCache.shouldAudit(event.document.uri, event.document.version)) {
332
+ versionCache.markAudited(event.document.uri, event.document.version);
333
+ scheduler.enqueue(event.document.uri);
334
+ }
335
+ });
336
+ documents.onDidChangeContent((event) => {
337
+ if (versionCache.shouldAudit(event.document.uri, event.document.version)) {
338
+ versionCache.markAudited(event.document.uri, event.document.version);
339
+ scheduler.enqueue(event.document.uri);
340
+ }
341
+ });
342
+ documents.onDidSave((event) => {
343
+ scheduler.enqueue(event.document.uri, 0);
344
+ });
345
+ documents.onDidClose((event) => {
346
+ versionCache.forget(event.document.uri);
347
+ scheduler.cancel(event.document.uri);
348
+ });
349
+ connection.onShutdown(() => {
350
+ scheduler.dispose();
351
+ });
352
+ documents.listen(connection);
353
+ connection.listen();
354
+ };
355
+ const resolveWorkspaceRoot = (params) => {
356
+ if (params.workspaceFolders && params.workspaceFolders.length > 0) return uriToFsPath(params.workspaceFolders[0].uri);
357
+ if (params.rootUri) return uriToFsPath(params.rootUri);
358
+ if (params.rootPath) return params.rootPath;
359
+ return null;
360
+ };
361
+ /**
362
+ * stdout is the LSP message channel — any stray write corrupts the protocol
363
+ * stream. Route accidental `console.log` / `info` / `debug` to stderr.
364
+ */
365
+ const protectStdoutChannel = () => {
366
+ const toStderr = (...args) => {
367
+ process.stderr.write(`${args.map((arg) => String(arg)).join(" ")}\n`);
368
+ };
369
+ console.log = toStderr;
370
+ console.info = toStderr;
371
+ console.debug = toStderr;
372
+ };
373
+ /** Entry point: starts the server over stdio. */
374
+ const startLanguageServer = () => {
375
+ protectStdoutChannel();
376
+ createServer(createConnection(process.stdin, process.stdout));
377
+ };
378
+ //#endregion
379
+ //#region src/index.ts
380
+ /** Public API surface of `@geoql/doctor-language-server`. */
381
+ const api = {
382
+ createServer,
383
+ startLanguageServer,
384
+ toLspDiagnostic,
385
+ rangeFromLineColumn,
386
+ rangesOverlap,
387
+ toZeroBasedPosition,
388
+ toLspSeverity,
389
+ severityLabel,
390
+ groupDiagnosticsByUri,
391
+ selectProjectKind,
392
+ isAuditableProject,
393
+ diagnosticFileToUri,
394
+ uriToFsPath,
395
+ createScheduler,
396
+ createVersionCache,
397
+ shouldAuditVersion,
398
+ DIAGNOSTIC_SOURCE,
399
+ SERVER_DISPLAY_NAME,
400
+ DOCUMENT_CHANGE_DEBOUNCE_MS: 400,
401
+ INITIAL_WORKSPACE_SCAN_DELAY_MS: 300,
402
+ SCANNABLE_EXTENSIONS
403
+ };
404
+ //#endregion
405
+ export { DIAGNOSTIC_SOURCE, DOCUMENT_CHANGE_DEBOUNCE_MS, INITIAL_WORKSPACE_SCAN_DELAY_MS, SCANNABLE_EXTENSIONS, SERVER_DISPLAY_NAME, api, createScheduler, createServer, createVersionCache, diagnosticFileToUri, groupDiagnosticsByUri, isAuditableProject, rangeFromLineColumn, rangesOverlap, selectProjectKind, severityLabel, shouldAuditVersion, startLanguageServer, toLspDiagnostic, toLspSeverity, toZeroBasedPosition, uriToFsPath };
406
+
407
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/constants.ts","../src/cache.ts","../src/positions.ts","../src/severity.ts","../src/mapper.ts","../src/uri.ts","../src/group.ts","../src/selection.ts","../src/scheduler.ts","../src/server.ts","../src/index.ts"],"sourcesContent":["/** Display name used in client-facing messages and progress titles. */\nexport const SERVER_DISPLAY_NAME = 'Doctor';\n\n/** `Diagnostic.source` shown next to every published diagnostic. */\nexport const DIAGNOSTIC_SOURCE = 'doctor';\n\n/**\n * Debounce window between an open document's last edit and the rescan it\n * triggers. Long enough that fast typing collapses into a single audit,\n * short enough to still feel live. Save scans run with no debounce.\n */\nexport const DOCUMENT_CHANGE_DEBOUNCE_MS = 400;\n\n/** Delay after `initialized` before the first background workspace scan. */\nexport const INITIAL_WORKSPACE_SCAN_DELAY_MS = 300;\n\n/**\n * Source file extensions the server scans on open / change / save. Mirrors\n * doctor-core's default include set so editor scanning covers the same\n * files the CLI audits.\n */\nexport const SCANNABLE_EXTENSIONS = [\n '.vue',\n '.ts',\n '.tsx',\n '.js',\n '.jsx',\n] as const;\n","/**\n * Per-document version cache. The LSP `TextDocument.version` increments on\n * every edit, so a request to audit a `(uri, version)` pair already audited\n * (or in flight) is redundant. `shouldAudit` is the pure decision; the\n * server records the version it acted on via `markAudited`.\n */\nexport interface VersionCache {\n /** Whether `uri` at `version` warrants a fresh audit (not already seen). */\n readonly shouldAudit: (uri: string, version: number) => boolean;\n /** Records that `uri` was audited at `version`. */\n readonly markAudited: (uri: string, version: number) => void;\n /** Forgets a document (on close) so a later reopen always re-audits. */\n readonly forget: (uri: string) => void;\n}\n\n/** Pure decision: audit only when this version is newer than the last seen. */\nexport const shouldAuditVersion = (\n lastAuditedVersion: number | undefined,\n version: number,\n): boolean => lastAuditedVersion === undefined || version > lastAuditedVersion;\n\nexport const createVersionCache = (): VersionCache => {\n const lastAudited = new Map<string, number>();\n return {\n shouldAudit: (uri, version) =>\n shouldAuditVersion(lastAudited.get(uri), version),\n markAudited: (uri, version) => {\n lastAudited.set(uri, version);\n },\n forget: (uri) => {\n lastAudited.delete(uri);\n },\n };\n};\n","import type { Position, Range } from 'vscode-languageserver';\n\n/**\n * doctor-core reports diagnostics with 1-indexed `line` / `column` (and an\n * optional 1-indexed `endLine` / `endColumn`), but LSP positions are\n * 0-indexed. These helpers convert between the two against the document text\n * so squiggles land exactly on the offending token.\n */\n\n/** Converts a 1-indexed line/column pair to a 0-indexed LSP `Position`. */\nexport const toZeroBasedPosition = (\n line: number,\n column: number,\n): Position => ({\n line: Math.max(0, (line || 1) - 1),\n character: Math.max(0, (column || 1) - 1),\n});\n\n/**\n * Builds an LSP `Range` from doctor-core's 1-indexed coordinates. When an\n * explicit `endLine` / `endColumn` is present it is used directly; otherwise\n * the range extends to the end of the start line (when document text is\n * known) so the squiggle is visible, falling back to a single-character span\n * when the text is unavailable.\n */\nexport const rangeFromLineColumn = (\n text: string | null,\n line: number,\n column: number,\n endLine?: number,\n endColumn?: number,\n): Range => {\n const start = toZeroBasedPosition(line, column);\n\n if (endLine !== undefined && endColumn !== undefined) {\n return { start, end: toZeroBasedPosition(endLine, endColumn) };\n }\n\n if (text !== null) {\n const lines = text.split('\\n');\n const lineText = lines[start.line] ?? '';\n const endCharacter = Math.max(\n start.character + 1,\n lineText.replace(/\\r$/, '').length,\n );\n return { start, end: { line: start.line, character: endCharacter } };\n }\n\n return { start, end: { line: start.line, character: start.character + 1 } };\n};\n\nconst isBefore = (first: Position, second: Position): boolean =>\n first.line < second.line ||\n (first.line === second.line && first.character < second.character);\n\n/** Whether two ranges overlap (touching endpoints count as overlap). */\nexport const rangesOverlap = (first: Range, second: Range): boolean =>\n !isBefore(first.end, second.start) && !isBefore(second.end, first.start);\n","import { DiagnosticSeverity } from 'vscode-languageserver';\nimport type { Severity } from '@geoql/doctor-core';\n\n/**\n * Maps doctor-core's internal severity vocabulary (`error | warn | info`) to\n * the LSP `DiagnosticSeverity` enum: `error → Error`, `warn → Warning`,\n * `info → Information`. Doctor never emits LSP `Hint`.\n */\nexport const toLspSeverity = (severity: Severity): DiagnosticSeverity => {\n switch (severity) {\n case 'error':\n return DiagnosticSeverity.Error;\n case 'warn':\n return DiagnosticSeverity.Warning;\n case 'info':\n return DiagnosticSeverity.Information;\n }\n};\n\n/**\n * Human-readable label for an LSP diagnostic's severity. Covers all four LSP\n * severities so a value outside doctor's vocabulary isn't mislabeled.\n */\nexport const severityLabel = (\n severity: DiagnosticSeverity | undefined,\n): string => {\n switch (severity) {\n case DiagnosticSeverity.Error:\n return 'error';\n case DiagnosticSeverity.Information:\n return 'info';\n case DiagnosticSeverity.Hint:\n return 'hint';\n default:\n return 'warning';\n }\n};\n","import { docsUrl } from '@geoql/doctor-core';\nimport type { Diagnostic as CoreDiagnostic } from '@geoql/doctor-core';\nimport type { Diagnostic as LspDiagnostic } from 'vscode-languageserver';\nimport { DIAGNOSTIC_SOURCE } from './constants.js';\nimport { rangeFromLineColumn } from './positions.js';\nimport { toLspSeverity } from './severity.js';\n\nexport interface MapDiagnosticInput {\n readonly diagnostic: CoreDiagnostic;\n /** Text of the diagnostic's file for end-of-line range fallback; `null` → single column. */\n readonly text: string | null;\n}\n\n/**\n * Converts a core doctor `Diagnostic` into an LSP `Diagnostic` with a precise\n * 0-based range, `source: 'doctor'`, `code: ruleId`, a `codeDescription`\n * pointing at the rule's docs page, and the recommendation appended to the\n * message when present.\n */\nexport const toLspDiagnostic = (input: MapDiagnosticInput): LspDiagnostic => {\n const { diagnostic, text } = input;\n const range = rangeFromLineColumn(\n text,\n diagnostic.line,\n diagnostic.column,\n diagnostic.endLine,\n diagnostic.endColumn,\n );\n\n return {\n range,\n severity: toLspSeverity(diagnostic.severity),\n code: diagnostic.ruleId,\n codeDescription: { href: docsUrl(diagnostic.ruleId) },\n source: DIAGNOSTIC_SOURCE,\n message: diagnostic.message,\n };\n};\n","import { isAbsolute, resolve } from 'node:path';\nimport { URI } from 'vscode-uri';\n\n/**\n * Resolves a doctor-core diagnostic's `file` (relative to the audit root, or\n * already absolute) to a `file://` URI the editor can address. Paths are\n * normalized to forward slashes by `vscode-uri`, so casing/encoding stay\n * consistent across platforms.\n */\nexport const diagnosticFileToUri = (rootDir: string, file: string): string => {\n const absolute = isAbsolute(file) ? file : resolve(rootDir, file);\n return URI.file(absolute).toString();\n};\n\n/** Converts a `file://` URI back to an absolute fs path. */\nexport const uriToFsPath = (uri: string): string => URI.parse(uri).fsPath;\n","import type {\n AuditReport,\n Diagnostic as CoreDiagnostic,\n} from '@geoql/doctor-core';\nimport type { Diagnostic as LspDiagnostic } from 'vscode-languageserver';\nimport { toLspDiagnostic } from './mapper.js';\nimport { diagnosticFileToUri } from './uri.js';\n\nexport interface GroupDiagnosticsInput {\n readonly report: AuditReport;\n /**\n * Returns the live text for a diagnostic's file (open buffer first, then\n * disk) so range fallback can extend to end-of-line; `null` when unknown.\n */\n readonly textForFile: (file: string) => string | null;\n /**\n * URIs that previously held diagnostics and must be republished even when\n * the new audit found nothing in them — otherwise stale squiggles linger.\n */\n readonly previousUris?: Iterable<string>;\n}\n\n/**\n * Transforms an `AuditReport` into a per-URI map of LSP diagnostics. Every\n * URI in `previousUris` that has no findings this pass is included with an\n * empty array so the server can clear it. Grouping is keyed by the resolved\n * `file://` URI of each diagnostic's `file`.\n */\nexport const groupDiagnosticsByUri = (\n input: GroupDiagnosticsInput,\n): Map<string, LspDiagnostic[]> => {\n const { report, textForFile, previousUris } = input;\n const byUri = new Map<string, LspDiagnostic[]>();\n\n for (const uri of previousUris ?? []) {\n byUri.set(uri, []);\n }\n\n const textCache = new Map<string, string | null>();\n const readText = (file: string): string | null => {\n const cached = textCache.get(file);\n if (cached !== undefined) return cached;\n const text = textForFile(file);\n textCache.set(file, text);\n return text;\n };\n\n for (const diagnostic of report.diagnostics) {\n appendDiagnostic(byUri, report.rootDir, diagnostic, readText);\n }\n\n return byUri;\n};\n\nconst appendDiagnostic = (\n byUri: Map<string, LspDiagnostic[]>,\n rootDir: string,\n diagnostic: CoreDiagnostic,\n readText: (file: string) => string | null,\n): void => {\n const uri = diagnosticFileToUri(rootDir, diagnostic.file);\n const lsp = toLspDiagnostic({ diagnostic, text: readText(diagnostic.file) });\n const existing = byUri.get(uri);\n if (existing) existing.push(lsp);\n else byUri.set(uri, [lsp]);\n};\n","import type { Framework, ProjectInfo } from '@geoql/doctor-core';\n\n/** Editor-facing project flavor the audit runs as. */\nexport type DoctorProjectKind = 'vue' | 'nuxt' | 'unknown';\n\n/**\n * Picks which doctor plugin set applies to a project from its detected\n * framework. Nuxt projects get the Nuxt + Vue rule passes; plain Vue\n * projects get the Vue passes; an unrecognized project is `unknown` and the\n * server skips auditing it (no framework → no applicable rules).\n */\nexport const selectProjectKind = (\n project: Pick<ProjectInfo, 'framework'>,\n): DoctorProjectKind => {\n const framework: Framework = project.framework;\n if (framework === 'nuxt') return 'nuxt';\n if (framework === 'vue') return 'vue';\n return 'unknown';\n};\n\n/** Whether a detected project is one doctor can audit (Vue or Nuxt). */\nexport const isAuditableProject = (\n project: Pick<ProjectInfo, 'framework'>,\n): boolean => selectProjectKind(project) !== 'unknown';\n","/**\n * Per-key debounced scheduler. Each editor scope (a document URI, or a\n * workspace root) gets one in-flight audit at a time: rapid edits to the same\n * document collapse into a single trailing run, and a monotonic generation\n * per key supersedes stale work so a slow audit can never clobber a fresher\n * result. Save scans pass `debounceMs: 0` to run immediately.\n */\nexport interface SchedulerOptions {\n /** Trailing debounce window for an enqueued key, in ms. Default 400. */\n readonly debounceMs?: number;\n /** Runs the audit for a key; rejections are surfaced via `onError`. */\n readonly performScan: (\n key: string,\n token: CancellationToken,\n ) => Promise<void>;\n /** Called when a `performScan` promise rejects. */\n readonly onError?: (error: unknown, key: string) => void;\n}\n\n/** Cooperative cancellation: `true` once a newer enqueue superseded the run. */\nexport interface CancellationToken {\n readonly isCancelled: boolean;\n}\n\nexport interface Scheduler {\n /** Queue (or re-queue) an audit for `key`, debounced. `delayMs` overrides the default. */\n readonly enqueue: (key: string, delayMs?: number) => void;\n /** Cancel any pending/in-flight work for `key`. */\n readonly cancel: (key: string) => void;\n /** Pending timers + in-flight runs; for tests and idle checks. */\n readonly pendingCount: () => number;\n /** Stop all timers and abandon queued work. */\n readonly dispose: () => void;\n}\n\nexport const createScheduler = (options: SchedulerOptions): Scheduler => {\n const defaultDebounceMs = options.debounceMs ?? 400;\n let disposed = false;\n let generation = 0;\n let running = 0;\n const timers = new Map<string, ReturnType<typeof setTimeout>>();\n const latestGeneration = new Map<string, number>();\n\n const run = (key: string, id: number): void => {\n running += 1;\n const token: CancellationToken = {\n get isCancelled() {\n return disposed || latestGeneration.get(key) !== id;\n },\n };\n Promise.resolve(options.performScan(key, token))\n .catch((error: unknown) => {\n options.onError?.(error, key);\n })\n .finally(() => {\n running -= 1;\n });\n };\n\n const enqueue = (key: string, delayMs?: number): void => {\n if (disposed) return;\n const id = (generation += 1);\n latestGeneration.set(key, id);\n\n const existing = timers.get(key);\n if (existing) clearTimeout(existing);\n\n const delay = delayMs ?? defaultDebounceMs;\n const timer = setTimeout(() => {\n timers.delete(key);\n run(key, id);\n }, delay);\n if (typeof timer.unref === 'function') timer.unref();\n timers.set(key, timer);\n };\n\n const cancel = (key: string): void => {\n const timer = timers.get(key);\n if (timer) {\n clearTimeout(timer);\n timers.delete(key);\n }\n // Bump to a generation no live request carries → supersedes in-flight work.\n latestGeneration.set(key, (generation += 1));\n };\n\n const dispose = (): void => {\n disposed = true;\n for (const timer of timers.values()) clearTimeout(timer);\n timers.clear();\n };\n\n const pendingCount = (): number => timers.size + running;\n\n return { enqueue, cancel, pendingCount, dispose };\n};\n","import { readFileSync } from 'node:fs';\nimport { audit, detectProject } from '@geoql/doctor-core';\nimport {\n TextDocuments,\n TextDocumentSyncKind,\n createConnection,\n type Connection,\n type InitializeParams,\n type InitializeResult,\n type ServerCapabilities,\n} from 'vscode-languageserver/node';\nimport { TextDocument } from 'vscode-languageserver-textdocument';\nimport {\n DOCUMENT_CHANGE_DEBOUNCE_MS,\n INITIAL_WORKSPACE_SCAN_DELAY_MS,\n SERVER_DISPLAY_NAME,\n} from './constants.js';\nimport { createVersionCache } from './cache.js';\nimport { groupDiagnosticsByUri } from './group.js';\nimport { isAuditableProject } from './selection.js';\nimport { createScheduler } from './scheduler.js';\nimport { uriToFsPath } from './uri.js';\n\nconst SERVER_VERSION = process.env.VERSION ?? '0.0.0-dev';\n\n/**\n * Builds and wires the Doctor language server onto a connection. Exposed\n * separately from {@link startLanguageServer} so the I/O shell stays thin and\n * the pure transforms (mapper, positions, severity, group, scheduler, cache,\n * selection) carry the behavior — and the coverage.\n */\nexport const createServer = (connection: Connection): void => {\n const documents = new TextDocuments(TextDocument);\n const versionCache = createVersionCache();\n let workspaceRoot: string | null = null;\n\n // URIs that currently hold published diagnostics, so a clean re-audit can\n // clear stale squiggles instead of leaving them behind.\n const publishedUris = new Set<string>();\n\n const readText = (fsPath: string): string | null => {\n const fileUri = `file://${fsPath}`;\n const open = documents.get(fileUri);\n if (open) return open.getText();\n try {\n return readFileSync(fsPath, 'utf8');\n } catch {\n return null;\n }\n };\n\n const runAudit = async (\n rootDir: string,\n scopeFsPath: string | null,\n ): Promise<void> => {\n const project = await detectProject(rootDir);\n if (!isAuditableProject(project)) return;\n\n const report = await audit({\n rootDir,\n ...(scopeFsPath !== null ? { scopeFiles: [scopeFsPath] } : {}),\n });\n\n const byUri = groupDiagnosticsByUri({\n report,\n textForFile: (file) =>\n readText(file.startsWith('/') ? file : `${rootDir}/${file}`),\n previousUris: publishedUris,\n });\n\n for (const [uri, diagnostics] of byUri) {\n connection.sendDiagnostics({ uri, diagnostics });\n if (diagnostics.length > 0) publishedUris.add(uri);\n else publishedUris.delete(uri);\n }\n };\n\n const scheduler = createScheduler({\n debounceMs: DOCUMENT_CHANGE_DEBOUNCE_MS,\n performScan: async (key) => {\n if (workspaceRoot === null) return;\n const scope = key === workspaceRoot ? null : uriToFsPath(key);\n await runAudit(workspaceRoot, scope);\n },\n onError: (error, key) =>\n connection.console.error(\n `Audit of ${key} failed: ${error instanceof Error ? error.message : String(error)}`,\n ),\n });\n\n connection.onInitialize((params: InitializeParams): InitializeResult => {\n workspaceRoot = resolveWorkspaceRoot(params);\n const capabilities: ServerCapabilities = {\n textDocumentSync: {\n openClose: true,\n change: TextDocumentSyncKind.Incremental,\n save: { includeText: false },\n },\n };\n return {\n capabilities,\n serverInfo: { name: SERVER_DISPLAY_NAME, version: SERVER_VERSION },\n };\n });\n\n connection.onInitialized(() => {\n if (workspaceRoot !== null) {\n const root = workspaceRoot;\n setTimeout(\n () => scheduler.enqueue(root, INITIAL_WORKSPACE_SCAN_DELAY_MS),\n 0,\n );\n }\n });\n\n // Open + save audit the touched file (save with no debounce); per-keystroke\n // edits ride the version cache + debounce so a burst collapses to one run.\n documents.onDidOpen((event) => {\n if (versionCache.shouldAudit(event.document.uri, event.document.version)) {\n versionCache.markAudited(event.document.uri, event.document.version);\n scheduler.enqueue(event.document.uri);\n }\n });\n\n documents.onDidChangeContent((event) => {\n if (versionCache.shouldAudit(event.document.uri, event.document.version)) {\n versionCache.markAudited(event.document.uri, event.document.version);\n scheduler.enqueue(event.document.uri);\n }\n });\n\n documents.onDidSave((event) => {\n scheduler.enqueue(event.document.uri, 0);\n });\n\n documents.onDidClose((event) => {\n versionCache.forget(event.document.uri);\n scheduler.cancel(event.document.uri);\n });\n\n connection.onShutdown(() => {\n scheduler.dispose();\n });\n\n documents.listen(connection);\n connection.listen();\n};\n\nconst resolveWorkspaceRoot = (params: InitializeParams): string | null => {\n if (params.workspaceFolders && params.workspaceFolders.length > 0) {\n return uriToFsPath(params.workspaceFolders[0].uri);\n }\n if (params.rootUri) return uriToFsPath(params.rootUri);\n if (params.rootPath) return params.rootPath;\n return null;\n};\n\n/**\n * stdout is the LSP message channel — any stray write corrupts the protocol\n * stream. Route accidental `console.log` / `info` / `debug` to stderr.\n */\nconst protectStdoutChannel = (): void => {\n const toStderr = (...args: unknown[]): void => {\n process.stderr.write(`${args.map((arg) => String(arg)).join(' ')}\\n`);\n };\n console.log = toStderr;\n console.info = toStderr;\n console.debug = toStderr;\n};\n\n/** Entry point: starts the server over stdio. */\nexport const startLanguageServer = (): void => {\n protectStdoutChannel();\n const connection = createConnection(process.stdin, process.stdout);\n createServer(connection);\n};\n","import { createServer, startLanguageServer } from './server.js';\nimport {\n createVersionCache,\n shouldAuditVersion,\n type VersionCache,\n} from './cache.js';\nimport {\n createScheduler,\n type CancellationToken,\n type Scheduler,\n type SchedulerOptions,\n} from './scheduler.js';\nimport { groupDiagnosticsByUri, type GroupDiagnosticsInput } from './group.js';\nimport { toLspDiagnostic, type MapDiagnosticInput } from './mapper.js';\nimport {\n rangeFromLineColumn,\n rangesOverlap,\n toZeroBasedPosition,\n} from './positions.js';\nimport { severityLabel, toLspSeverity } from './severity.js';\nimport {\n isAuditableProject,\n selectProjectKind,\n type DoctorProjectKind,\n} from './selection.js';\nimport { diagnosticFileToUri, uriToFsPath } from './uri.js';\nimport {\n DIAGNOSTIC_SOURCE,\n DOCUMENT_CHANGE_DEBOUNCE_MS,\n INITIAL_WORKSPACE_SCAN_DELAY_MS,\n SCANNABLE_EXTENSIONS,\n SERVER_DISPLAY_NAME,\n} from './constants.js';\n\n/** Public API surface of `@geoql/doctor-language-server`. */\nexport const api = {\n createServer,\n startLanguageServer,\n toLspDiagnostic,\n rangeFromLineColumn,\n rangesOverlap,\n toZeroBasedPosition,\n toLspSeverity,\n severityLabel,\n groupDiagnosticsByUri,\n selectProjectKind,\n isAuditableProject,\n diagnosticFileToUri,\n uriToFsPath,\n createScheduler,\n createVersionCache,\n shouldAuditVersion,\n DIAGNOSTIC_SOURCE,\n SERVER_DISPLAY_NAME,\n DOCUMENT_CHANGE_DEBOUNCE_MS,\n INITIAL_WORKSPACE_SCAN_DELAY_MS,\n SCANNABLE_EXTENSIONS,\n} as const;\n\nexport {\n createServer,\n startLanguageServer,\n createVersionCache,\n shouldAuditVersion,\n type VersionCache,\n createScheduler,\n type CancellationToken,\n type Scheduler,\n type SchedulerOptions,\n groupDiagnosticsByUri,\n type GroupDiagnosticsInput,\n toLspDiagnostic,\n type MapDiagnosticInput,\n rangeFromLineColumn,\n rangesOverlap,\n toZeroBasedPosition,\n severityLabel,\n toLspSeverity,\n isAuditableProject,\n selectProjectKind,\n type DoctorProjectKind,\n diagnosticFileToUri,\n uriToFsPath,\n DIAGNOSTIC_SOURCE,\n DOCUMENT_CHANGE_DEBOUNCE_MS,\n INITIAL_WORKSPACE_SCAN_DELAY_MS,\n SCANNABLE_EXTENSIONS,\n SERVER_DISPLAY_NAME,\n};\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@geoql/doctor-language-server",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "LSP server that runs the @geoql/doctor audit engine over Vue 3 + Nuxt 4 files and publishes diagnostics to any LSP-capable editor. TypeScript, ESM, stdio.",
6
+ "keywords": [
7
+ "audit",
8
+ "doctor",
9
+ "geoql",
10
+ "language-server",
11
+ "lint",
12
+ "lsp",
13
+ "nuxt",
14
+ "vue",
15
+ "vue3"
16
+ ],
17
+ "homepage": "https://github.com/geoql/doctor#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/geoql/doctor/issues"
20
+ },
21
+ "license": "MIT",
22
+ "author": {
23
+ "name": "Vinayak Kulkarni",
24
+ "email": "inbox.vinayak@gmail.com",
25
+ "url": "https://vinayakkulkarni.dev"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/geoql/doctor.git",
30
+ "directory": "packages/doctor-language-server"
31
+ },
32
+ "bin": {
33
+ "doctor-language-server": "./bin/doctor-language-server.mjs"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "bin"
38
+ ],
39
+ "type": "module",
40
+ "types": "./dist/index.d.mts",
41
+ "exports": {
42
+ ".": {
43
+ "import": "./dist/index.mjs",
44
+ "types": "./dist/index.d.mts"
45
+ },
46
+ "./package.json": "./package.json"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "dependencies": {
52
+ "vscode-languageserver": "^10.0.0",
53
+ "vscode-languageserver-textdocument": "^1.0.12",
54
+ "vscode-uri": "^3.1.0",
55
+ "@geoql/doctor-core": "^1.1.1"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^25.9.3",
59
+ "typescript": "^6.0.3",
60
+ "vitest": "^4.1.9"
61
+ },
62
+ "engines": {
63
+ "node": ">=24"
64
+ },
65
+ "scripts": {
66
+ "lint": "vp lint src",
67
+ "lint:fix": "vp lint src --fix",
68
+ "format": "vp fmt",
69
+ "format:check": "vp fmt --check",
70
+ "build": "vp pack",
71
+ "test": "vitest run"
72
+ }
73
+ }