@markuplint/language-server 0.0.0-stage → 5.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/README.md +74 -2
- package/bin/markuplint-language-server.mjs +3 -0
- package/lib/const.d.ts +41 -0
- package/lib/const.js +44 -0
- package/lib/i18n.d.ts +19 -0
- package/lib/i18n.js +63 -0
- package/lib/index.d.ts +41 -0
- package/lib/index.js +40 -0
- package/lib/lsp.d.ts +8 -0
- package/lib/lsp.js +8 -0
- package/lib/protocol.d.ts +10 -0
- package/lib/protocol.js +9 -0
- package/lib/server/client-channel.d.ts +38 -0
- package/lib/server/client-channel.js +89 -0
- package/lib/server/code-actions.d.ts +36 -0
- package/lib/server/code-actions.js +101 -0
- package/lib/server/convert-diagnostics.d.ts +28 -0
- package/lib/server/convert-diagnostics.js +82 -0
- package/lib/server/debug.d.ts +3 -0
- package/lib/server/debug.js +7 -0
- package/lib/server/document-events.d.ts +78 -0
- package/lib/server/document-events.js +299 -0
- package/lib/server/get-accessibility-by-location.d.ts +6 -0
- package/lib/server/get-accessibility-by-location.js +19 -0
- package/lib/server/get-module.d.ts +127 -0
- package/lib/server/get-module.js +257 -0
- package/lib/server/index.d.ts +1 -0
- package/lib/server/index.js +5 -0
- package/lib/server/initialize-params.d.ts +32 -0
- package/lib/server/initialize-params.js +28 -0
- package/lib/server/module-notices.d.ts +28 -0
- package/lib/server/module-notices.js +41 -0
- package/lib/server/parser-load-failure.d.ts +36 -0
- package/lib/server/parser-load-failure.js +85 -0
- package/lib/server/server.d.ts +20 -0
- package/lib/server/server.js +96 -0
- package/lib/server/suppression-support.d.ts +57 -0
- package/lib/server/suppression-support.js +233 -0
- package/lib/server/v2.d.ts +12 -0
- package/lib/server/v2.js +109 -0
- package/lib/server/v3.d.ts +19 -0
- package/lib/server/v3.js +208 -0
- package/lib/server/v4.d.ts +85 -0
- package/lib/server/v4.js +292 -0
- package/lib/server/v5.d.ts +89 -0
- package/lib/server/v5.js +339 -0
- package/lib/types.d.ts +88 -0
- package/lib/types.js +1 -0
- package/lib/utils/get-file-path.d.ts +15 -0
- package/lib/utils/get-file-path.js +45 -0
- package/lib/utils/resolve-working-directory.d.ts +44 -0
- package/lib/utils/resolve-working-directory.js +266 -0
- package/locales/en.json +7 -0
- package/locales/ja.json +21 -0
- package/package.json +58 -5
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { isFatalError } from 'markuplint/suppressions';
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
/**
|
|
8
|
+
* Reads and parses a `package.json`. `require()` is not used for this because it caches the
|
|
9
|
+
* parsed object and a server process outlives installs in the working directories it serves.
|
|
10
|
+
*/
|
|
11
|
+
function readJsonFile(filePath) {
|
|
12
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Convert a module path into a specifier that Node's ESM `import()` accepts.
|
|
16
|
+
*
|
|
17
|
+
* On Windows, raw drive-letter paths like `c:\foo` are rejected by the ESM
|
|
18
|
+
* loader (`ERR_UNSUPPORTED_ESM_URL_SCHEME: Received protocol 'c:'`) because
|
|
19
|
+
* the drive letter is parsed as a URL scheme. They must be converted to
|
|
20
|
+
* `file://` URLs first.
|
|
21
|
+
*
|
|
22
|
+
* Detection is OS-independent so that POSIX CI can exercise the Windows
|
|
23
|
+
* code path.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors `packages/@markuplint/file-resolver/src/general-import.ts` —
|
|
26
|
+
* keep the two in sync when adjusting Windows-path handling. Note:
|
|
27
|
+
* file-resolver currently still relies on `pathToFileURL()` and carries
|
|
28
|
+
* the same Windows / POSIX-absolute mismatch; tracked separately as
|
|
29
|
+
* #3840 (dev / v5).
|
|
30
|
+
*
|
|
31
|
+
* @param modPath - A module specifier — typically the result of `Files.resolve`
|
|
32
|
+
* or `require.resolve`, which may be a bare module name (`markuplint`), a
|
|
33
|
+
* relative path (`./foo`), or an absolute path (Windows or POSIX).
|
|
34
|
+
* @returns A specifier safe to pass to `import()`. Absolute paths are converted
|
|
35
|
+
* to `file://` URLs with each segment percent-encoded; bare and relative
|
|
36
|
+
* specifiers are returned unchanged. UNC paths (`\\server\share\...`) are
|
|
37
|
+
* passed through unchanged as a known limitation.
|
|
38
|
+
* @see https://github.com/markuplint/markuplint/issues/3795
|
|
39
|
+
* @see https://github.com/markuplint/markuplint/issues/3836
|
|
40
|
+
* @see https://github.com/markuplint/markuplint/issues/3842
|
|
41
|
+
* @see https://nodejs.org/api/esm.html#urls
|
|
42
|
+
*/
|
|
43
|
+
export function toImportSpecifier(modPath) {
|
|
44
|
+
const isWindowsAbsolute = /^[a-z]:[/\\]/i.test(modPath);
|
|
45
|
+
const isPosixAbsolute = modPath.startsWith('/');
|
|
46
|
+
if (!isWindowsAbsolute && !isPosixAbsolute) {
|
|
47
|
+
return modPath;
|
|
48
|
+
}
|
|
49
|
+
// Build the `file://` URL explicitly for both branches. Node's
|
|
50
|
+
// `pathToFileURL()` is intentionally avoided: on Windows it resolves a
|
|
51
|
+
// POSIX-style absolute path against the *current drive* and emits
|
|
52
|
+
// `file:///D:/tmp/foo` instead of `file:///tmp/foo` (#3836); on POSIX
|
|
53
|
+
// it likewise mishandles Windows-style drive paths. Constructing the
|
|
54
|
+
// URL ourselves keeps the function OS-independent so POSIX CI exercises
|
|
55
|
+
// the Windows code path. Each segment is percent-encoded so that
|
|
56
|
+
// spaces (`Program Files`), non-ASCII characters (e.g. Japanese
|
|
57
|
+
// usernames), and URL-reserved characters like `#` / `?` do not get
|
|
58
|
+
// reinterpreted as fragment/query delimiters by Node's URL parser.
|
|
59
|
+
if (isWindowsAbsolute) {
|
|
60
|
+
// The drive-letter segment (`c:`) is kept as-is to match the
|
|
61
|
+
// `pathToFileURL` output shape on Windows (`file:///c:/...`).
|
|
62
|
+
const [drive, ...rest] = modPath.replaceAll('\\', '/').split('/');
|
|
63
|
+
const encoded = [drive, ...rest.map(segment => encodeURIComponent(segment))].join('/');
|
|
64
|
+
return `file:///${encoded}`;
|
|
65
|
+
}
|
|
66
|
+
// POSIX absolute path. Splitting `/tmp/foo` by `/` yields `['', 'tmp',
|
|
67
|
+
// 'foo']`; the leading empty element produces the `file:///` prefix
|
|
68
|
+
// after `join('/')`, so the round-trip is exactly `file:///tmp/foo`.
|
|
69
|
+
const segments = modPath.split('/').map(segment => encodeURIComponent(segment));
|
|
70
|
+
return `file://${segments.join('/')}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve and load the markuplint module for a working directory.
|
|
74
|
+
*
|
|
75
|
+
* Attempts to load a markuplint installed upward from `workspace` first.
|
|
76
|
+
* If the local module fails to load (not installed, or import assertion incompatibility
|
|
77
|
+
* on Node.js 22+), falls back to the version installed with the language server.
|
|
78
|
+
*
|
|
79
|
+
* @param workspace - Absolute directory used as the resolution base
|
|
80
|
+
* @param log - Logger function for diagnostic output
|
|
81
|
+
* @param deps - Module system access; defaults to Node's own (injectable for tests)
|
|
82
|
+
* @returns The resolved module metadata including version, type, and optional fallback reason
|
|
83
|
+
*/
|
|
84
|
+
export async function loadModule(workspace, log, deps) {
|
|
85
|
+
const { resolveEntry, importModule, importBundledPackageJson, readPackageJson } = deps ?? nodeLoadDeps();
|
|
86
|
+
let markuplint;
|
|
87
|
+
let isLocalModule = false;
|
|
88
|
+
let pkg;
|
|
89
|
+
let fallbackReason;
|
|
90
|
+
try {
|
|
91
|
+
log(`Getting module for ${workspace}`, 'debug');
|
|
92
|
+
const modPath = resolveEntry(workspace, message => log(message));
|
|
93
|
+
log(`import("${modPath}")`, 'debug');
|
|
94
|
+
// IMPORTANT: modPath may be a Windows absolute path (c:\...).
|
|
95
|
+
// Always convert via toImportSpecifier() before passing to import() —
|
|
96
|
+
// raw drive-letter paths trigger ERR_UNSUPPORTED_ESM_URL_SCHEME.
|
|
97
|
+
markuplint = await importModule(toImportSpecifier(modPath));
|
|
98
|
+
log(`Found package: ${modPath}`, 'debug');
|
|
99
|
+
const packageJsonPath = path.resolve(path.dirname(modPath), '..', 'package.json');
|
|
100
|
+
pkg = readPackageJson(packageJsonPath);
|
|
101
|
+
pkg = pkg.default ?? pkg;
|
|
102
|
+
isLocalModule = true;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
// Deliberately guard-less: this is the boundary to a foreign module of unknown shape, and
|
|
106
|
+
// the fallback to the bundled copy must run whatever the local installation threw
|
|
107
|
+
// (the import assertion SyntaxError included).
|
|
108
|
+
if (isImportAssertionError(error)) {
|
|
109
|
+
log(`Local markuplint is incompatible with Node.js 22+ (import assertion syntax): ${error}`, 'warn');
|
|
110
|
+
fallbackReason = 'import-assertion-compat';
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
log(`Failed to resolve local package: ${error}`, 'error');
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
markuplint = await importModule('markuplint');
|
|
117
|
+
log('Found package: markuplint', 'debug');
|
|
118
|
+
pkg = await importBundledPackageJson().catch(() => {
|
|
119
|
+
log('Failed to resolve package: markuplint/package.json (ERR_PACKAGE_PATH_NOT_EXPORTED)', 'debug');
|
|
120
|
+
const ownPkg = readPackageJson(path.resolve(import.meta.dirname, '..', '..', 'package.json'));
|
|
121
|
+
return {
|
|
122
|
+
version: ownPkg.dependencies.markuplint,
|
|
123
|
+
type: 'module',
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
pkg = pkg.default ?? pkg;
|
|
127
|
+
log('Found package: markuplint/package.json', 'debug');
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
log('Failed to resolve package: markuplint installed with the language server', 'debug');
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const version = pkg.version;
|
|
135
|
+
const moduleType = pkg.type ?? 'commonjs';
|
|
136
|
+
log(`Loaded package: markuplint@${version}(type:${moduleType})`, 'debug');
|
|
137
|
+
return {
|
|
138
|
+
isLocalModule,
|
|
139
|
+
version,
|
|
140
|
+
moduleType,
|
|
141
|
+
markuplint,
|
|
142
|
+
ariaRecommendedVersion: ARIA_RECOMMENDED_VERSION,
|
|
143
|
+
fallbackReason,
|
|
144
|
+
workspace,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function nodeLoadDeps() {
|
|
148
|
+
return {
|
|
149
|
+
resolveEntry: resolveWithRequire,
|
|
150
|
+
importModule: specifier => import(specifier),
|
|
151
|
+
importBundledPackageJson: () => import('markuplint/package.json', { with: { type: 'json' } }),
|
|
152
|
+
readPackageJson: readJsonFile,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Create a resolver that loads the markuplint module per working directory and caches it.
|
|
157
|
+
*
|
|
158
|
+
* Resolution must follow the working directory rather than the workspace root: the modules a
|
|
159
|
+
* config refers to (`parser` / `specs` / `plugins`) are imported by `@markuplint/file-resolver`
|
|
160
|
+
* relative to the loaded markuplint installation, so a parser installed only in a sub-package
|
|
161
|
+
* is reachable only when markuplint itself is loaded from that sub-package. Falling back to the
|
|
162
|
+
* bundled copy there leaves the parser unreachable and the document silently undiagnosed.
|
|
163
|
+
*
|
|
164
|
+
* `onFirstResolve` fires once per distinct directory — the place for one-shot notices such as
|
|
165
|
+
* the bundled-fallback warning — whereas callers may report per-document status freely.
|
|
166
|
+
* A failed load is not cached so the next document in that directory retries.
|
|
167
|
+
*
|
|
168
|
+
* @param options.log - Logger function for diagnostic output
|
|
169
|
+
* @param options.onFirstResolve - Called once per directory when its module is first loaded
|
|
170
|
+
* @param options.load - Module loader (injectable for tests)
|
|
171
|
+
* @returns A resolver keyed by the normalized absolute directory
|
|
172
|
+
* @see https://github.com/markuplint/markuplint/issues/4048
|
|
173
|
+
*/
|
|
174
|
+
export function createModuleResolver(options) {
|
|
175
|
+
const { log, onFirstResolve, load = loadModule } = options;
|
|
176
|
+
const cache = new Map();
|
|
177
|
+
return workspace => {
|
|
178
|
+
const key = path.resolve(workspace);
|
|
179
|
+
const cached = cache.get(key);
|
|
180
|
+
if (cached) {
|
|
181
|
+
return cached;
|
|
182
|
+
}
|
|
183
|
+
const loading = load(key, log);
|
|
184
|
+
cache.set(key, loading);
|
|
185
|
+
void loading.then(mod => {
|
|
186
|
+
try {
|
|
187
|
+
onFirstResolve?.(mod);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (isFatalError(error)) {
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
log(`Module notice failed for ${key}: ${error}`, 'error');
|
|
194
|
+
}
|
|
195
|
+
}, () => {
|
|
196
|
+
cache.delete(key);
|
|
197
|
+
});
|
|
198
|
+
return loading;
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Check whether the error is a SyntaxError caused by the `assert { type: 'json' }`
|
|
203
|
+
* import assertion syntax that was removed in Node.js 22.
|
|
204
|
+
*
|
|
205
|
+
* @param error - The caught error to inspect
|
|
206
|
+
* @returns `true` if the error matches the import assertion SyntaxError pattern
|
|
207
|
+
*/
|
|
208
|
+
function isImportAssertionError(error) {
|
|
209
|
+
return error instanceof SyntaxError && /Unexpected identifier 'assert'/.test(error.message);
|
|
210
|
+
}
|
|
211
|
+
function nodeRequireDeps() {
|
|
212
|
+
return {
|
|
213
|
+
requireResolve: (name, options) => require.resolve(name, { paths: [...options.paths] }),
|
|
214
|
+
readPackageJson: readJsonFile,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Resolve the `markuplint` entry point with Node's CommonJS resolver, walking up from `dir`.
|
|
219
|
+
*
|
|
220
|
+
* Resolution stays in-process: `Files.resolve` from `vscode-languageserver` forks a Node process
|
|
221
|
+
* per call, which was acceptable for a single session-wide lookup but not for one lookup per
|
|
222
|
+
* working directory (every opened file's directory when `workingDirectories` is unset).
|
|
223
|
+
*
|
|
224
|
+
* `require.resolve` rejects a package whose `exports` map has no `require` condition (markuplint
|
|
225
|
+
* ships ESM only), so the entry point is then read from that `package.json` directly. The
|
|
226
|
+
* resolver functions are injectable because the server bundle is CommonJS while the test runner
|
|
227
|
+
* executes this file as ESM, where `require` is not defined.
|
|
228
|
+
*
|
|
229
|
+
* @param dir - Absolute directory to start the `node_modules` walk from
|
|
230
|
+
* @param log - Logger function for diagnostic output
|
|
231
|
+
* @param deps - `require.resolve` and `require` equivalents; defaults to Node's own
|
|
232
|
+
* @returns The absolute path of the markuplint entry point
|
|
233
|
+
*/
|
|
234
|
+
export function resolveWithRequire(dir, log, deps) {
|
|
235
|
+
const { requireResolve, readPackageJson } = deps ?? nodeRequireDeps();
|
|
236
|
+
try {
|
|
237
|
+
const modPath = requireResolve('markuplint', { paths: [dir] });
|
|
238
|
+
log(`require.resolve('markuplint', "${dir}"): ${modPath}`);
|
|
239
|
+
return modPath;
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
log(`require.resolve('markuplint', "${dir}"): ${error}`);
|
|
243
|
+
if (error instanceof Error && 'code' in error && error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
|
|
244
|
+
const modPackageJsonPath = error.message.replace(/^No "exports" main defined in /, '');
|
|
245
|
+
const pkg = readPackageJson(modPackageJsonPath);
|
|
246
|
+
const main = pkg.main ?? pkg.exports?.['.']?.import ?? pkg.exports?.['.']?.require ?? pkg.exports?.['.'] ?? null;
|
|
247
|
+
if (!main) {
|
|
248
|
+
error.message = error.message + ' No main';
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
log(`require("${modPackageJsonPath}") => package.json: ${main}`);
|
|
252
|
+
const modPath = path.resolve(path.dirname(modPackageJsonPath), main);
|
|
253
|
+
return modPath;
|
|
254
|
+
}
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { createConnection, ProposedFeatures } from 'vscode-languageserver/node.js';
|
|
2
|
+
import { bootServer } from './server.js';
|
|
3
|
+
// `createConnection` without streams reads the transport from argv: `--stdio`, `--node-ipc`,
|
|
4
|
+
// `--socket=<port>` or `--pipe=<name>`.
|
|
5
|
+
bootServer(createConnection(ProposedFeatures.all));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { InitializationOptions } from '../types.js';
|
|
2
|
+
import type { WorkspaceFolder } from 'vscode-languageserver/node.js';
|
|
3
|
+
/**
|
|
4
|
+
* The part of the LSP `initialize` request the server reads.
|
|
5
|
+
*/
|
|
6
|
+
export type InitializeInput = {
|
|
7
|
+
readonly locale?: string | null;
|
|
8
|
+
readonly rootUri?: string | null;
|
|
9
|
+
readonly workspaceFolders?: readonly WorkspaceFolder[] | null;
|
|
10
|
+
readonly initializationOptions?: unknown;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* What the server derives from the `initialize` request.
|
|
14
|
+
*/
|
|
15
|
+
export type Initialization = {
|
|
16
|
+
readonly locale: string;
|
|
17
|
+
readonly options: InitializationOptions;
|
|
18
|
+
/** Absolute paths of the workspace folders */
|
|
19
|
+
readonly workspaceFolders: readonly string[];
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Reads the `initialize` request of any LSP client.
|
|
23
|
+
*
|
|
24
|
+
* Only the VS Code extension sends `initializationOptions`, so a missing or `null` value is the
|
|
25
|
+
* normal case rather than an error. Workspace folders come from the options when the client gave
|
|
26
|
+
* them, otherwise from the LSP-standard `workspaceFolders`, and last from the deprecated
|
|
27
|
+
* `rootUri` that older clients still send.
|
|
28
|
+
*
|
|
29
|
+
* @param params - The `initialize` request
|
|
30
|
+
* @returns The locale, the options, and the workspace folders as absolute paths
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveInitialization(params: InitializeInput): Initialization;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
/**
|
|
3
|
+
* Reads the `initialize` request of any LSP client.
|
|
4
|
+
*
|
|
5
|
+
* Only the VS Code extension sends `initializationOptions`, so a missing or `null` value is the
|
|
6
|
+
* normal case rather than an error. Workspace folders come from the options when the client gave
|
|
7
|
+
* them, otherwise from the LSP-standard `workspaceFolders`, and last from the deprecated
|
|
8
|
+
* `rootUri` that older clients still send.
|
|
9
|
+
*
|
|
10
|
+
* @param params - The `initialize` request
|
|
11
|
+
* @returns The locale, the options, and the workspace folders as absolute paths
|
|
12
|
+
*/
|
|
13
|
+
export function resolveInitialization(params) {
|
|
14
|
+
// The shape is the client's promise; nothing here validates it, as with any LSP initialization option.
|
|
15
|
+
const options = (isRecord(params.initializationOptions) ? params.initializationOptions : {});
|
|
16
|
+
return {
|
|
17
|
+
locale: params.locale ?? 'en',
|
|
18
|
+
options,
|
|
19
|
+
workspaceFolders: options.workspaceFolders ?? workspaceFoldersFromProtocol(params),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function workspaceFoldersFromProtocol(params) {
|
|
23
|
+
const uris = params.workspaceFolders ? params.workspaceFolders.map(folder => folder.uri) : [params.rootUri];
|
|
24
|
+
return uris.filter((uri) => !!uri?.startsWith('file:')).map(uri => fileURLToPath(uri));
|
|
25
|
+
}
|
|
26
|
+
function isRecord(value) {
|
|
27
|
+
return typeof value === 'object' && value !== null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Module } from './get-module.js';
|
|
2
|
+
import type { Log, Status } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Client-facing channels the notices are delivered through.
|
|
5
|
+
*/
|
|
6
|
+
export type ModuleNoticeChannels = {
|
|
7
|
+
/** Primary output channel */
|
|
8
|
+
readonly log: Log;
|
|
9
|
+
/** Warning popup */
|
|
10
|
+
readonly popup: (message: string) => void;
|
|
11
|
+
/** Status bar update */
|
|
12
|
+
readonly sendStatus: (status: Status) => void;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Creates the user-facing notices about which markuplint module a document is linted with.
|
|
16
|
+
*
|
|
17
|
+
* `reportStatus` runs on every document open, so the status bar follows the most recently
|
|
18
|
+
* opened document. `onFirstResolve` runs once per working directory; without
|
|
19
|
+
* `workingDirectories` that is once per opened file's directory, so the import assertion
|
|
20
|
+
* popup is additionally capped to once per session while its log line stays per directory.
|
|
21
|
+
*
|
|
22
|
+
* @param channels - Where to deliver the notices
|
|
23
|
+
* @returns Handlers for `createModuleResolver` and `createEventHandlers`
|
|
24
|
+
*/
|
|
25
|
+
export declare function createModuleNotices(channels: ModuleNoticeChannels): {
|
|
26
|
+
onFirstResolve(mod: Module): void;
|
|
27
|
+
reportStatus(mod: Module): void;
|
|
28
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { IMPORT_ASSERTION_COMPAT_WARNING, NO_INSTALL_WARNING } from '../const.js';
|
|
2
|
+
import { t } from '../i18n.js';
|
|
3
|
+
/**
|
|
4
|
+
* Creates the user-facing notices about which markuplint module a document is linted with.
|
|
5
|
+
*
|
|
6
|
+
* `reportStatus` runs on every document open, so the status bar follows the most recently
|
|
7
|
+
* opened document. `onFirstResolve` runs once per working directory; without
|
|
8
|
+
* `workingDirectories` that is once per opened file's directory, so the import assertion
|
|
9
|
+
* popup is additionally capped to once per session while its log line stays per directory.
|
|
10
|
+
*
|
|
11
|
+
* @param channels - Where to deliver the notices
|
|
12
|
+
* @returns Handlers for `createModuleResolver` and `createEventHandlers`
|
|
13
|
+
*/
|
|
14
|
+
export function createModuleNotices(channels) {
|
|
15
|
+
let compatPopupShown = false;
|
|
16
|
+
const noInstallMessage = (mod) => (mod.isLocalModule ? null : t(NO_INSTALL_WARNING, mod.version) + t('. '));
|
|
17
|
+
return {
|
|
18
|
+
onFirstResolve(mod) {
|
|
19
|
+
channels.log(`Found version: ${mod.version} (isLocalModule: ${mod.isLocalModule}) for ${mod.workspace}`, 'info');
|
|
20
|
+
const message = noInstallMessage(mod);
|
|
21
|
+
if (message) {
|
|
22
|
+
channels.log(message, 'warn');
|
|
23
|
+
}
|
|
24
|
+
if (mod.fallbackReason === 'import-assertion-compat') {
|
|
25
|
+
const compatMessage = t(IMPORT_ASSERTION_COMPAT_WARNING, mod.version);
|
|
26
|
+
channels.log(compatMessage, 'warn');
|
|
27
|
+
if (!compatPopupShown) {
|
|
28
|
+
compatPopupShown = true;
|
|
29
|
+
channels.popup(compatMessage);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
reportStatus(mod) {
|
|
34
|
+
channels.sendStatus({
|
|
35
|
+
version: mod.version,
|
|
36
|
+
isLocalModule: mod.isLocalModule,
|
|
37
|
+
message: noInstallMessage(mod),
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Module } from './get-module.js';
|
|
2
|
+
import type { Log } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* What the document was linted with, needed to tell the user where the missing parser must be installed.
|
|
5
|
+
*/
|
|
6
|
+
export type ParserLoadFailureContext = {
|
|
7
|
+
readonly languageId: string;
|
|
8
|
+
readonly isLocalModule: boolean;
|
|
9
|
+
readonly version: string;
|
|
10
|
+
/** The working directory the module was resolved for */
|
|
11
|
+
readonly workspace: string;
|
|
12
|
+
readonly fallbackReason?: Module['fallbackReason'];
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Extracts the package name of the parser that failed to load from an error message.
|
|
16
|
+
*
|
|
17
|
+
* @param message - The error message from `MLEngine#exec()`
|
|
18
|
+
* @returns The package name (scoped when applicable), or `null` if the message is not a parser load failure
|
|
19
|
+
*/
|
|
20
|
+
export declare function extractMissingParserName(message: string): string | null;
|
|
21
|
+
/**
|
|
22
|
+
* Builds the user-facing explanation for a parser load failure.
|
|
23
|
+
*
|
|
24
|
+
* @param error - The rejection from `MLEngine#exec()`
|
|
25
|
+
* @param context - Which markuplint module linted the document
|
|
26
|
+
* @returns The message to show, or `null` if the error is not a parser load failure
|
|
27
|
+
*/
|
|
28
|
+
export declare function describeParserLoadFailure(error: unknown, context: ParserLoadFailureContext): string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Creates the `MLEngine#exec()` rejection handler for a document.
|
|
31
|
+
*
|
|
32
|
+
* @param context - Which markuplint module linted the document
|
|
33
|
+
* @param errorLog - Popup logger
|
|
34
|
+
* @returns A handler that reports parser load failures (and any other recoverable `Error`) and rethrows fatal errors
|
|
35
|
+
*/
|
|
36
|
+
export declare function createParserErrorHandler(context: ParserLoadFailureContext, errorLog: Log): (error: unknown) => void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { isFatalError } from 'markuplint/suppressions';
|
|
2
|
+
import { BUNDLED_PARSER_LOAD_COMPAT_ERROR, BUNDLED_PARSER_LOAD_ERROR, PARSER_NOT_FOUND_ERROR } from '../const.js';
|
|
3
|
+
import { t } from '../i18n.js';
|
|
4
|
+
// `@markuplint/file-resolver` (v3+) rewrites a bare parser specifier that is not importable into an
|
|
5
|
+
// absolute path under the config's directory before giving up, so the reported specifier is
|
|
6
|
+
// usually `<configDir>/@markuplint/xxx-parser`, not the name the user wrote.
|
|
7
|
+
const PARSER_MODULE_NOT_FOUND = /Parser module "(?<spec>[^"]+)" is not found\./;
|
|
8
|
+
// Node's own messages: CommonJS `Cannot find module 'x'`, ESM `Cannot find package 'x' imported from …`.
|
|
9
|
+
// Only a bare package specifier counts here: a path means some file of an installed package is
|
|
10
|
+
// missing (a broken installation), not that a parser package is absent.
|
|
11
|
+
const CANNOT_FIND_PACKAGE = /Cannot find (?:module|package) '(?<spec>(?:@[\w.-]+\/)?[\w.-]+)'/;
|
|
12
|
+
/**
|
|
13
|
+
* Extracts the package name of the parser that failed to load from an error message.
|
|
14
|
+
*
|
|
15
|
+
* @param message - The error message from `MLEngine#exec()`
|
|
16
|
+
* @returns The package name (scoped when applicable), or `null` if the message is not a parser load failure
|
|
17
|
+
*/
|
|
18
|
+
export function extractMissingParserName(message) {
|
|
19
|
+
const rewritten = PARSER_MODULE_NOT_FOUND.exec(message)?.groups?.spec;
|
|
20
|
+
if (rewritten) {
|
|
21
|
+
return packageNameOf(rewritten);
|
|
22
|
+
}
|
|
23
|
+
return CANNOT_FIND_PACKAGE.exec(message)?.groups?.spec ?? null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Recovers the package name from a specifier the config resolver may have turned into a path.
|
|
27
|
+
* A path ending in an unscoped segment is ambiguous (a package name or a parser file such as
|
|
28
|
+
* `./parsers/custom.js`), so only scoped names are recovered from paths.
|
|
29
|
+
*/
|
|
30
|
+
function packageNameOf(spec) {
|
|
31
|
+
const segments = spec.replaceAll('\\', '/').replace(/\/+$/, '').split('/');
|
|
32
|
+
const name = segments.at(-1);
|
|
33
|
+
const scope = segments.at(-2);
|
|
34
|
+
if (!name) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (scope?.startsWith('@')) {
|
|
38
|
+
return `${scope}/${name}`;
|
|
39
|
+
}
|
|
40
|
+
return segments.length === 1 ? name : null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Builds the user-facing explanation for a parser load failure.
|
|
44
|
+
*
|
|
45
|
+
* @param error - The rejection from `MLEngine#exec()`
|
|
46
|
+
* @param context - Which markuplint module linted the document
|
|
47
|
+
* @returns The message to show, or `null` if the error is not a parser load failure
|
|
48
|
+
*/
|
|
49
|
+
export function describeParserLoadFailure(error, context) {
|
|
50
|
+
if (!(error instanceof Error)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const parser = extractMissingParserName(error.message);
|
|
54
|
+
if (!parser) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (context.isLocalModule) {
|
|
58
|
+
return t(PARSER_NOT_FOUND_ERROR, parser, context.languageId);
|
|
59
|
+
}
|
|
60
|
+
if (context.fallbackReason === 'import-assertion-compat') {
|
|
61
|
+
return t(BUNDLED_PARSER_LOAD_COMPAT_ERROR, context.version, parser, context.workspace);
|
|
62
|
+
}
|
|
63
|
+
return t(BUNDLED_PARSER_LOAD_ERROR, context.version, parser, context.workspace);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Creates the `MLEngine#exec()` rejection handler for a document.
|
|
67
|
+
*
|
|
68
|
+
* @param context - Which markuplint module linted the document
|
|
69
|
+
* @param errorLog - Popup logger
|
|
70
|
+
* @returns A handler that reports parser load failures (and any other recoverable `Error`) and rethrows fatal errors
|
|
71
|
+
*/
|
|
72
|
+
export function createParserErrorHandler(context, errorLog) {
|
|
73
|
+
return (error) => {
|
|
74
|
+
if (isFatalError(error)) {
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const message = describeParserLoadFailure(error, context);
|
|
78
|
+
if (message) {
|
|
79
|
+
errorLog(message);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Non-Error values are fatal per isFatalError() and were rethrown above.
|
|
83
|
+
errorLog(error.message);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Connection } from 'vscode-languageserver/node.js';
|
|
2
|
+
/**
|
|
3
|
+
* Bootstrap the LSP language server on a connection and start listening.
|
|
4
|
+
*
|
|
5
|
+
* Any LSP client can use the server, so nothing the client sends beyond the LSP itself is
|
|
6
|
+
* required: `initializationOptions` is optional (see {@link InitializationOptions}), and the
|
|
7
|
+
* `markuplint/*` custom messages are sent only to a client that opts in with `extendedProtocol`
|
|
8
|
+
* (see {@link createClientChannel}). Only the VS Code extension relies on both.
|
|
9
|
+
*
|
|
10
|
+
* The markuplint module is resolved lazily per working directory as documents open; the
|
|
11
|
+
* bundled-fallback notice is logged once per directory and the import assertion popup is shown
|
|
12
|
+
* once per session (see `createModuleNotices`).
|
|
13
|
+
*
|
|
14
|
+
* Diagnostics must not write to stdout: over `--stdio` it carries the protocol, and a client
|
|
15
|
+
* such as Claude Code disconnects the server on any other output. Logs go through the channel,
|
|
16
|
+
* and `debug` writes to stderr.
|
|
17
|
+
*
|
|
18
|
+
* @param connection - The connection to the client, from `createConnection`
|
|
19
|
+
*/
|
|
20
|
+
export declare function bootServer(connection: Connection): void;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { CodeActionKind, TextDocuments, TextDocumentSyncKind } from 'vscode-languageserver/node.js';
|
|
2
|
+
import { TextDocument } from 'vscode-languageserver-textdocument';
|
|
3
|
+
import { setLocale } from '../i18n.js';
|
|
4
|
+
import { createClientChannel } from './client-channel.js';
|
|
5
|
+
import { SOURCE_FIX_ALL_MARKUPLINT } from './code-actions.js';
|
|
6
|
+
import { applySeverityMap } from './convert-diagnostics.js';
|
|
7
|
+
import { verbosely } from './debug.js';
|
|
8
|
+
import { createEventHandlers } from './document-events.js';
|
|
9
|
+
import { createModuleResolver } from './get-module.js';
|
|
10
|
+
import { resolveInitialization } from './initialize-params.js';
|
|
11
|
+
import { createModuleNotices } from './module-notices.js';
|
|
12
|
+
import { configureGitPath } from './suppression-support.js';
|
|
13
|
+
const DEBUG = false;
|
|
14
|
+
/**
|
|
15
|
+
* Bootstrap the LSP language server on a connection and start listening.
|
|
16
|
+
*
|
|
17
|
+
* Any LSP client can use the server, so nothing the client sends beyond the LSP itself is
|
|
18
|
+
* required: `initializationOptions` is optional (see {@link InitializationOptions}), and the
|
|
19
|
+
* `markuplint/*` custom messages are sent only to a client that opts in with `extendedProtocol`
|
|
20
|
+
* (see {@link createClientChannel}). Only the VS Code extension relies on both.
|
|
21
|
+
*
|
|
22
|
+
* The markuplint module is resolved lazily per working directory as documents open; the
|
|
23
|
+
* bundled-fallback notice is logged once per directory and the import assertion popup is shown
|
|
24
|
+
* once per session (see `createModuleNotices`).
|
|
25
|
+
*
|
|
26
|
+
* Diagnostics must not write to stdout: over `--stdio` it carries the protocol, and a client
|
|
27
|
+
* such as Claude Code disconnects the server on any other output. Logs go through the channel,
|
|
28
|
+
* and `debug` writes to stderr.
|
|
29
|
+
*
|
|
30
|
+
* @param connection - The connection to the client, from `createConnection`
|
|
31
|
+
*/
|
|
32
|
+
export function bootServer(connection) {
|
|
33
|
+
const channel = createClientChannel(connection);
|
|
34
|
+
const documents = new TextDocuments(TextDocument);
|
|
35
|
+
documents.listen(connection);
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
37
|
+
let codeActionHandler = () => [];
|
|
38
|
+
connection.onCodeAction(params => codeActionHandler(params));
|
|
39
|
+
connection.onInitialize((params) => {
|
|
40
|
+
const { locale, options, workspaceFolders } = resolveInitialization(params);
|
|
41
|
+
channel.configure(options.extendedProtocol === true);
|
|
42
|
+
setLocale(locale);
|
|
43
|
+
const log = channel.log;
|
|
44
|
+
log('onInitialize');
|
|
45
|
+
const { langConfigs, workingDirectories, gitPath, severityMap } = options;
|
|
46
|
+
configureGitPath(gitPath);
|
|
47
|
+
const sendDiagnostics = ({ diagnostics, ...rest }) => {
|
|
48
|
+
void connection.sendDiagnostics({ ...rest, diagnostics: applySeverityMap(diagnostics, severityMap) });
|
|
49
|
+
};
|
|
50
|
+
connection.onInitialized(() => {
|
|
51
|
+
log('onInitialized');
|
|
52
|
+
if (DEBUG) {
|
|
53
|
+
verbosely();
|
|
54
|
+
}
|
|
55
|
+
log(`Locale: ${locale}`, 'info');
|
|
56
|
+
if (workingDirectories) {
|
|
57
|
+
log(`Working directories: ${JSON.stringify(workingDirectories)}`, 'info');
|
|
58
|
+
}
|
|
59
|
+
const notices = createModuleNotices({
|
|
60
|
+
log,
|
|
61
|
+
popup: channel.warningPopup,
|
|
62
|
+
sendStatus: channel.reportStatus,
|
|
63
|
+
});
|
|
64
|
+
const resolveModule = createModuleResolver({ log, onFirstResolve: notices.onFirstResolve });
|
|
65
|
+
const { onDidOpen, onDidChangeContent, onDidClose, onHover, onCodeAction } = createEventHandlers({
|
|
66
|
+
resolveModule,
|
|
67
|
+
locale,
|
|
68
|
+
langConfigs,
|
|
69
|
+
workingDirectories,
|
|
70
|
+
workspaceFolders,
|
|
71
|
+
log,
|
|
72
|
+
diagnosticsLog: channel.diagnosticsLog,
|
|
73
|
+
errorLog: channel.errorLog,
|
|
74
|
+
sendDiagnostics,
|
|
75
|
+
reportStatus: notices.reportStatus,
|
|
76
|
+
});
|
|
77
|
+
documents.onDidOpen(e => onDidOpen(e.document));
|
|
78
|
+
// eslint-disable-next-line unicorn/no-array-for-each
|
|
79
|
+
documents.all().forEach(onDidOpen);
|
|
80
|
+
documents.onDidChangeContent(e => onDidChangeContent(e.document));
|
|
81
|
+
documents.onDidClose(e => onDidClose(e.document));
|
|
82
|
+
connection.onHover(onHover);
|
|
83
|
+
codeActionHandler = onCodeAction;
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
capabilities: {
|
|
87
|
+
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
88
|
+
hoverProvider: true,
|
|
89
|
+
codeActionProvider: {
|
|
90
|
+
codeActionKinds: [CodeActionKind.QuickFix, SOURCE_FIX_ALL_MARKUPLINT],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
connection.listen();
|
|
96
|
+
}
|