Skip to content

Commit acb4993

Browse files
committed
feat(fmt): add --lsp language server mode
1 parent 92f686d commit acb4993

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
@@ -462,3 +462,71 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
462462
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
463463
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
464464
DEALINGS IN THE SOFTWARE.
465+
466+
## vscode-languageserver
467+
468+
The language server started by `rs fmt --lsp` includes bundled code from
469+
[vscode-languageserver](https://github.com/microsoft/vscode-languageserver-node).
470+
471+
License: MIT
472+
473+
The bundled code also contains MIT-licensed code from:
474+
475+
- vscode-jsonrpc 9.0.1, copyright Microsoft Corporation
476+
- vscode-languageserver-protocol 3.18.2, copyright Microsoft Corporation
477+
- vscode-languageserver-types 3.18.0, copyright Microsoft Corporation
478+
479+
Copyright (c) Microsoft Corporation
480+
481+
All rights reserved.
482+
483+
MIT License
484+
485+
Permission is hereby granted, free of charge, to any person obtaining a copy of
486+
this software and associated documentation files (the "Software"), to deal in
487+
the Software without restriction, including without limitation the rights to
488+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
489+
of the Software, and to permit persons to whom the Software is furnished to do
490+
so, subject to the following conditions:
491+
492+
The above copyright notice and this permission notice shall be included in all
493+
copies or substantial portions of the Software.
494+
495+
THE SOFTWARE IS PROVIDED _AS IS_, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
496+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
497+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
498+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
499+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
500+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
501+
SOFTWARE.
502+
503+
## vscode-languageserver-textdocument
504+
505+
The language server started by `rs fmt --lsp` includes bundled code from
506+
[vscode-languageserver-textdocument](https://github.com/microsoft/vscode-languageserver-node).
507+
508+
License: MIT
509+
510+
Copyright (c) Microsoft Corporation
511+
512+
All rights reserved.
513+
514+
MIT License
515+
516+
Permission is hereby granted, free of charge, to any person obtaining a copy of
517+
this software and associated documentation files (the "Software"), to deal in
518+
the Software without restriction, including without limitation the rights to
519+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
520+
of the Software, and to permit persons to whom the Software is furnished to do
521+
so, subject to the following conditions:
522+
523+
The above copyright notice and this permission notice shall be included in all
524+
copies or substantial portions of the Software.
525+
526+
THE SOFTWARE IS PROVIDED _AS IS_, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
527+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
528+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
529+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
530+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
531+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
532+
SOFTWARE.

‎packages/rstack/package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@
100100
"sort-package-json": "catalog:",
101101
"svelte": "catalog:",
102102
"tiny-readdir": "catalog:",
103-
"typescript": "catalog:"
103+
"typescript": "catalog:",
104+
"vscode-languageserver": "catalog:",
105+
"vscode-languageserver-textdocument": "catalog:"
104106
},
105107
"peerDependencies": {
106108
"@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 { hasHelpFlag, renderHelp } from './help.ts';
@@ -606,7 +606,12 @@ export async function setupCommands(): Promise<void> {
606606
const { args, configPath } = parseCliArgs(process.argv.slice(2));
607607
const command = args[0];
608608

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

611616
if (!command || command === '-h' || command === '--help') {
612617
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
['-c, --config <path>', 'Specify Rstack config file path'],
5053
['-h, --help', 'Display this help message'],
5154
],
@@ -66,6 +69,19 @@ const parseMaxWorkers = (value: string | undefined): number | undefined => {
6669
return maxWorkers;
6770
};
6871

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

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

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

140+
if (stdinFilepath !== undefined) {
141+
assertExclusiveMode('--stdin-filepath', modes.length > 0, positionals);
142+
}
143+
126144
return {
127145
cache,
128146
cacheLocation,
@@ -135,6 +153,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
135153
maxWorkers,
136154
help,
137155
stdinFilepath,
156+
lsp,
138157
};
139158
};
140159

@@ -244,7 +263,7 @@ const logFmtResult = (
244263
};
245264

246265
const loadFmtConfig = async (cwd: string): Promise<ResolvedFmtConfig> => {
247-
const { configs, filePath } = await loadRstackConfig();
266+
const { configs, filePath } = await loadRstackConfig({ cwd });
248267

249268
return resolveFmtConfig({
250269
definition: configs.fmt,
@@ -266,6 +285,7 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
266285
help,
267286
ignorePaths,
268287
ignoreUnknown,
288+
lsp,
269289
maxWorkers,
270290
mode,
271291
noErrorOnUnmatchedPattern,
@@ -278,6 +298,23 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
278298
return;
279299
}
280300

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