Skip to content

Commit 69fcea7

Browse files
committed
feat(fmt): add --lsp language server mode
1 parent ac38608 commit 69fcea7

24 files changed

Lines changed: 1461 additions & 52 deletions

packages/rstack/THIRD_PARTY_NOTICES.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,3 +490,71 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
490490
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
491491
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
492492
DEALINGS IN THE SOFTWARE.
493+
494+
## vscode-languageserver
495+
496+
The language server started by `rs fmt --lsp` includes bundled code from
497+
[vscode-languageserver](https://github.com/microsoft/vscode-languageserver-node).
498+
499+
License: MIT
500+
501+
The bundled code also contains MIT-licensed code from:
502+
503+
- vscode-jsonrpc 9.0.1, copyright Microsoft Corporation
504+
- vscode-languageserver-protocol 3.18.2, copyright Microsoft Corporation
505+
- vscode-languageserver-types 3.18.0, copyright Microsoft Corporation
506+
507+
Copyright (c) Microsoft Corporation
508+
509+
All rights reserved.
510+
511+
MIT License
512+
513+
Permission is hereby granted, free of charge, to any person obtaining a copy of
514+
this software and associated documentation files (the "Software"), to deal in
515+
the Software without restriction, including without limitation the rights to
516+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
517+
of the Software, and to permit persons to whom the Software is furnished to do
518+
so, subject to the following conditions:
519+
520+
The above copyright notice and this permission notice shall be included in all
521+
copies or substantial portions of the Software.
522+
523+
THE SOFTWARE IS PROVIDED _AS IS_, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
524+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
525+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
526+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
527+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
528+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
529+
SOFTWARE.
530+
531+
## vscode-languageserver-textdocument
532+
533+
The language server started by `rs fmt --lsp` includes bundled code from
534+
[vscode-languageserver-textdocument](https://github.com/microsoft/vscode-languageserver-node).
535+
536+
License: MIT
537+
538+
Copyright (c) Microsoft Corporation
539+
540+
All rights reserved.
541+
542+
MIT License
543+
544+
Permission is hereby granted, free of charge, to any person obtaining a copy of
545+
this software and associated documentation files (the "Software"), to deal in
546+
the Software without restriction, including without limitation the rights to
547+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
548+
of the Software, and to permit persons to whom the Software is furnished to do
549+
so, subject to the following conditions:
550+
551+
The above copyright notice and this permission notice shall be included in all
552+
copies or substantial portions of the Software.
553+
554+
THE SOFTWARE IS PROVIDED _AS IS_, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
555+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
556+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
557+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
558+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
559+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
560+
SOFTWARE.

packages/rstack/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@
9999
"rslog": "catalog:",
100100
"sort-package-json": "catalog:",
101101
"tiny-readdir": "catalog:",
102-
"typescript": "catalog:"
102+
"typescript": "catalog:",
103+
"vscode-languageserver": "catalog:",
104+
"vscode-languageserver-textdocument": "catalog:"
103105
},
104106
"peerDependencies": {
105107
"@rspress/core": "^2.0.17"

packages/rstack/rslib.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { defineConfig } from '@rslib/core';
22
import prettierPkgJson from 'prettier/package.json' with { type: 'json' };
33
import pkgJson from './package.json' with { type: 'json' };
44

5-
const fullyMinifiedChunks = /(?:fmt(?:Plugins)?|sortPackageJsonPlugin|staged)\.js$/;
5+
const fullyMinifiedChunks = /(?:fmt(?:Lsp|Plugins)?|sortPackageJsonPlugin|staged)\.js$/;
66

77
export default defineConfig({
88
dts: true,

packages/rstack/src/cli/commands.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { join } from 'node:path';
1+
import { join, resolve } from 'node:path';
22
import { getConfigState } from '../config.ts';
33
import { insertConfigArg, parseArgs, parseCliArgs } from './args.ts';
44
import { renderHelp } from './help.ts';
@@ -160,7 +160,12 @@ export async function setupCommands(): Promise<void> {
160160
const { args, configPath } = parseCliArgs(process.argv.slice(2));
161161
const command = args[0];
162162

163-
getConfigState().configPath = configPath;
163+
// Resolved for every command so that a relative `--config` path always means
164+
// the same file: it is anchored to the directory the CLI was invoked in, even
165+
// when the config is later loaded from another directory. The motivating case
166+
// is `rs fmt --lsp`, which loads the config from the LSP workspace root the
167+
// client reports, and that root need not be the process working directory.
168+
getConfigState().configPath = configPath === undefined ? undefined : resolve(configPath);
164169

165170
if (!command || command === '-h' || command === '--help') {
166171
console.log(renderRootHelp());

packages/rstack/src/config.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,16 @@ export type LoadedRstackConfig = {
3030
export type LoadRstackConfigOptions = {
3131
/**
3232
* The path to the Rstack config file, can be a relative or absolute path.
33+
* A relative path is resolved from `cwd`.
3334
* If `configFilePath` is not provided, the config path set by the CLI is used.
34-
* If neither path is provided, the function will search for the config file in the current working directory.
35+
* If neither path is provided, the function will search for the config file in `cwd`.
3536
*/
3637
configFilePath?: string;
38+
/**
39+
* The directory the config file is searched in and relative config paths are resolved from.
40+
* Defaults to the current working directory.
41+
*/
42+
cwd?: string;
3743
};
3844

3945
type ConfigSession = {
@@ -42,6 +48,11 @@ type ConfigSession = {
4248
};
4349

4450
type ConfigState = {
51+
/**
52+
* Config file path from the global `--config` flag. Always absolute: the CLI
53+
* resolves it at parse time so it stays independent of later cwd choices
54+
* (`loadRstackConfig` may be called with an LSP workspace root as `cwd`).
55+
*/
4556
configPath?: string;
4657
};
4758

@@ -161,6 +172,7 @@ export const define: Define = {
161172

162173
export const loadRstackConfig = async ({
163174
configFilePath,
175+
cwd,
164176
}: LoadRstackConfigOptions = {}): Promise<LoadedRstackConfig> => {
165177
const state = getConfigState();
166178
const configPath = configFilePath ?? state.configPath;
@@ -175,6 +187,7 @@ export const loadRstackConfig = async ({
175187
loader: 'native',
176188
exportName: false,
177189
fresh: true,
190+
cwd,
178191
...(configPath !== undefined
179192
? { path: configPath }
180193
: {

packages/rstack/src/fmt/cli.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ interface ParsedFmtCLIArgs {
2525
help: boolean;
2626
/** Path the stdin content is formatted as; it need not exist on disk. */
2727
stdinFilepath?: string;
28+
/** Serve formatting over the Language Server Protocol instead of exiting. */
29+
lsp: boolean;
2830
}
2931

3032
const renderFmtHelp = (): string =>
@@ -46,6 +48,7 @@ const renderFmtHelp = (): string =>
4648
['--with-node-modules', 'Process files inside node_modules'],
4749
['--parallel-workers <count>', 'Number of parallel workers'],
4850
['--stdin-filepath <path>', 'Format stdin as if it were saved at <path>'],
51+
['--lsp', 'Run a language server on stdio'],
4952
['-h, --help', 'Display this help message'],
5053
],
5154
},
@@ -65,6 +68,19 @@ const parseMaxWorkers = (value: string | undefined): number | undefined => {
6568
return maxWorkers;
6669
};
6770

71+
/** Rejects the mode flags and file arguments that a server-like option replaces. */
72+
const assertExclusiveMode = (option: string, hasMode: boolean, positionals: string[]): void => {
73+
if (hasMode) {
74+
throw new Error(
75+
`The ${option} option cannot be used with --write, --check, or --list-different.`,
76+
);
77+
}
78+
79+
if (positionals.length > 0) {
80+
throw new Error(`The ${option} option cannot be used with file arguments.`);
81+
}
82+
};
83+
6884
const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
6985
const { values, positionals } = parseArgs({
7086
args,
@@ -80,6 +96,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
8096
'with-node-modules': { type: 'boolean' },
8197
'parallel-workers': { type: 'string' },
8298
'stdin-filepath': { type: 'string' },
99+
lsp: { type: 'boolean' },
83100
help: { type: 'boolean', short: 'h' },
84101
},
85102
allowPositionals: true,
@@ -109,19 +126,20 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
109126
const maxWorkers = parseMaxWorkers(parallelWorkers);
110127
const help = values.help ?? false;
111128
const stdinFilepath = values.stdinFilepath;
129+
const lsp = values.lsp ?? false;
112130

113-
if (stdinFilepath !== undefined) {
114-
if (modes.length > 0) {
115-
throw new Error(
116-
'The --stdin-filepath option cannot be used with --write, --check, or --list-different.',
117-
);
118-
}
131+
if (lsp) {
132+
assertExclusiveMode('--lsp', modes.length > 0, positionals);
119133

120-
if (positionals.length > 0) {
121-
throw new Error('The --stdin-filepath option cannot be used with file arguments.');
134+
if (stdinFilepath !== undefined) {
135+
throw new Error('The --lsp option cannot be used with --stdin-filepath.');
122136
}
123137
}
124138

139+
if (stdinFilepath !== undefined) {
140+
assertExclusiveMode('--stdin-filepath', modes.length > 0, positionals);
141+
}
142+
125143
return {
126144
cache,
127145
cacheLocation,
@@ -134,6 +152,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
134152
maxWorkers,
135153
help,
136154
stdinFilepath,
155+
lsp,
137156
};
138157
};
139158

@@ -243,7 +262,7 @@ const logFmtResult = (
243262
};
244263

245264
const loadFmtConfig = async (cwd: string): Promise<ResolvedFmtConfig> => {
246-
const { configs, filePath } = await loadRstackConfig();
265+
const { configs, filePath } = await loadRstackConfig({ cwd });
247266

248267
return resolveFmtConfig({
249268
definition: configs.fmt,
@@ -265,6 +284,7 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
265284
help,
266285
ignorePaths,
267286
ignoreUnknown,
287+
lsp,
268288
maxWorkers,
269289
mode,
270290
noErrorOnUnmatchedPattern,
@@ -277,6 +297,23 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
277297
return;
278298
}
279299

300+
if (lsp) {
301+
const { runFmtLsp } = await import(
302+
/* rspackChunkName: 'fmtLsp' */
303+
'./lsp/server.ts'
304+
);
305+
await runFmtLsp({
306+
// The client's workspace root is not necessarily the directory the
307+
// editor spawned the server in; the server resolves relative
308+
// `--ignore-path` values from this cwd so they stay based on the same
309+
// directory as a relative `--config`.
310+
cwd,
311+
ignorePaths,
312+
loadConfig: loadFmtConfig,
313+
});
314+
return;
315+
}
316+
280317
if (stdinFilepath !== undefined) {
281318
const { runFmtStdin } = await import(
282319
/* rspackChunkName: 'fmtStdin' */

packages/rstack/src/fmt/discovery.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import path from 'node:path';
22
import { createFmtOptionsResolver, type FmtOptionsResolver } from './config.ts';
33
import { discoverFmtPaths } from './discoverPaths.ts';
44
import { createIgnoreMatcher } from './ignore.ts';
5+
import type { FmtPluginResolver } from './plugins.ts';
56
import type { DiscoverFmtFilesOptions, FmtFileRequest } from './types.ts';
67

78
const createFileRequest = (
@@ -12,6 +13,26 @@ const createFileRequest = (
1213
options: resolveOptions(filePath),
1314
});
1415

16+
/** Imports the plugin chunk on first use and shares the resolver across calls. */
17+
const createLazyPluginResolver = (rootPath: string): (() => Promise<FmtPluginResolver>) => {
18+
let resolver: Promise<FmtPluginResolver> | undefined;
19+
20+
return () =>
21+
(resolver ??= import(
22+
/* rspackChunkName: 'fmtPlugins' */
23+
'./plugins.ts'
24+
).then(({ createFmtPluginResolver }) => createFmtPluginResolver(rootPath)));
25+
};
26+
27+
/** Resolves the plugin specifiers of a request whose options configure plugins. */
28+
const resolveFileRequestPlugins = async (
29+
file: FmtFileRequest,
30+
getPluginResolver: () => Promise<FmtPluginResolver>,
31+
): Promise<FmtFileRequest> =>
32+
file.options.plugins?.length
33+
? { ...file, options: (await getPluginResolver())(file.options) }
34+
: file;
35+
1536
const createDirMatcher = (dirPath: string): ((filePath: string) => boolean) => {
1637
const prefix = dirPath.endsWith(path.sep) ? dirPath : `${dirPath}${path.sep}`;
1738
return (filePath) => filePath === dirPath || filePath.startsWith(prefix);
@@ -43,21 +64,13 @@ const discoverFmtFiles = async ({
4364
}
4465

4566
const resolveOptions = createFmtOptionsResolver(config);
46-
const files = filePaths.map((filePath) => createFileRequest(filePath, resolveOptions));
47-
if (!files.some((file) => file.options.plugins?.length)) {
48-
return files;
49-
}
67+
const getPluginResolver = createLazyPluginResolver(config.rootPath);
5068

51-
const { createFmtPluginResolver } = await import(
52-
/* rspackChunkName: 'fmtPlugins' */
53-
'./plugins.ts'
69+
return Promise.all(
70+
filePaths.map((filePath) =>
71+
resolveFileRequestPlugins(createFileRequest(filePath, resolveOptions), getPluginResolver),
72+
),
5473
);
55-
const resolvePlugins = createFmtPluginResolver(config.rootPath);
56-
57-
return files.map((file) => ({
58-
...file,
59-
options: resolvePlugins(file.options),
60-
}));
6174
};
6275

63-
export { createFileRequest, discoverFmtFiles };
76+
export { createFileRequest, createLazyPluginResolver, discoverFmtFiles, resolveFileRequestPlugins };

packages/rstack/src/fmt/ignore.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,4 @@ const createIgnoreMatcher = async ({
8080
};
8181

8282
export { createIgnoreMatcher };
83+
export type { IgnorePredicate };

0 commit comments

Comments
 (0)