@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.
Files changed (55) hide show
  1. package/README.md +74 -2
  2. package/bin/markuplint-language-server.mjs +3 -0
  3. package/lib/const.d.ts +41 -0
  4. package/lib/const.js +44 -0
  5. package/lib/i18n.d.ts +19 -0
  6. package/lib/i18n.js +63 -0
  7. package/lib/index.d.ts +41 -0
  8. package/lib/index.js +40 -0
  9. package/lib/lsp.d.ts +8 -0
  10. package/lib/lsp.js +8 -0
  11. package/lib/protocol.d.ts +10 -0
  12. package/lib/protocol.js +9 -0
  13. package/lib/server/client-channel.d.ts +38 -0
  14. package/lib/server/client-channel.js +89 -0
  15. package/lib/server/code-actions.d.ts +36 -0
  16. package/lib/server/code-actions.js +101 -0
  17. package/lib/server/convert-diagnostics.d.ts +28 -0
  18. package/lib/server/convert-diagnostics.js +82 -0
  19. package/lib/server/debug.d.ts +3 -0
  20. package/lib/server/debug.js +7 -0
  21. package/lib/server/document-events.d.ts +78 -0
  22. package/lib/server/document-events.js +299 -0
  23. package/lib/server/get-accessibility-by-location.d.ts +6 -0
  24. package/lib/server/get-accessibility-by-location.js +19 -0
  25. package/lib/server/get-module.d.ts +127 -0
  26. package/lib/server/get-module.js +257 -0
  27. package/lib/server/index.d.ts +1 -0
  28. package/lib/server/index.js +5 -0
  29. package/lib/server/initialize-params.d.ts +32 -0
  30. package/lib/server/initialize-params.js +28 -0
  31. package/lib/server/module-notices.d.ts +28 -0
  32. package/lib/server/module-notices.js +41 -0
  33. package/lib/server/parser-load-failure.d.ts +36 -0
  34. package/lib/server/parser-load-failure.js +85 -0
  35. package/lib/server/server.d.ts +20 -0
  36. package/lib/server/server.js +96 -0
  37. package/lib/server/suppression-support.d.ts +57 -0
  38. package/lib/server/suppression-support.js +233 -0
  39. package/lib/server/v2.d.ts +12 -0
  40. package/lib/server/v2.js +109 -0
  41. package/lib/server/v3.d.ts +19 -0
  42. package/lib/server/v3.js +208 -0
  43. package/lib/server/v4.d.ts +85 -0
  44. package/lib/server/v4.js +292 -0
  45. package/lib/server/v5.d.ts +89 -0
  46. package/lib/server/v5.js +339 -0
  47. package/lib/types.d.ts +88 -0
  48. package/lib/types.js +1 -0
  49. package/lib/utils/get-file-path.d.ts +15 -0
  50. package/lib/utils/get-file-path.js +45 -0
  51. package/lib/utils/resolve-working-directory.d.ts +44 -0
  52. package/lib/utils/resolve-working-directory.js +266 -0
  53. package/locales/en.json +7 -0
  54. package/locales/ja.json +21 -0
  55. 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
+ }