diff --git a/packages/rstack/THIRD_PARTY_NOTICES.md b/packages/rstack/THIRD_PARTY_NOTICES.md index dbd76f6b..a4f4fa3e 100644 --- a/packages/rstack/THIRD_PARTY_NOTICES.md +++ b/packages/rstack/THIRD_PARTY_NOTICES.md @@ -462,3 +462,40 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## vscode-languageserver + +The language server started by `rs fmt --lsp` includes bundled code from +[vscode-languageserver](https://github.com/microsoft/vscode-languageserver-node). + +License: MIT + +The bundled code also contains MIT-licensed code from: + +- vscode-jsonrpc 9.0.1, copyright Microsoft Corporation +- vscode-languageserver-protocol 3.18.2, copyright Microsoft Corporation +- vscode-languageserver-types 3.18.0, copyright Microsoft Corporation + +Copyright (c) Microsoft Corporation + +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED _AS IS_, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/rstack/package.json b/packages/rstack/package.json index ec8468ed..39f1c7d8 100644 --- a/packages/rstack/package.json +++ b/packages/rstack/package.json @@ -100,7 +100,9 @@ "sort-package-json": "catalog:", "svelte": "catalog:", "tiny-readdir": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vscode-languageserver": "catalog:", + "vscode-languageserver-textdocument": "catalog:" }, "peerDependencies": { "@rspress/core": "^2.0.17" diff --git a/packages/rstack/rslib.config.ts b/packages/rstack/rslib.config.ts index 80c4fe54..a2662b13 100644 --- a/packages/rstack/rslib.config.ts +++ b/packages/rstack/rslib.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from '@rslib/core'; import prettierPkgJson from 'prettier/package.json' with { type: 'json' }; import pkgJson from './package.json' with { type: 'json' }; -const fullyMinifiedChunks = /(?:fmt(?:Plugins)?|sortPackageJsonPlugin|staged)\.js$/; +const fullyMinifiedChunks = /(?:fmt(?:Lsp|Plugins)?|sortPackageJsonPlugin|staged)\.js$/; export default defineConfig({ dts: true, diff --git a/packages/rstack/src/cli/commands.ts b/packages/rstack/src/cli/commands.ts index 3b19d6cd..e6708d92 100644 --- a/packages/rstack/src/cli/commands.ts +++ b/packages/rstack/src/cli/commands.ts @@ -1,4 +1,4 @@ -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { getConfigState } from '../config.ts'; import { insertConfigArg, parseArgs, parseCliArgs } from './args.ts'; import { hasHelpFlag, renderHelp } from './help.ts'; @@ -606,7 +606,12 @@ export async function setupCommands(): Promise { const { args, configPath } = parseCliArgs(process.argv.slice(2)); const command = args[0]; - getConfigState().configPath = configPath; + // Resolved for every command so that a relative `--config` path always means + // the same file: it is anchored to the directory the CLI was invoked in, even + // when the config is later loaded from another directory. The motivating case + // is `rs fmt --lsp`, which loads the config from the LSP workspace root the + // client reports, and that root need not be the process working directory. + getConfigState().configPath = configPath === undefined ? undefined : resolve(configPath); if (!command || command === '-h' || command === '--help') { console.log(renderRootHelp()); diff --git a/packages/rstack/src/config.ts b/packages/rstack/src/config.ts index 51a6c958..f977f6fc 100644 --- a/packages/rstack/src/config.ts +++ b/packages/rstack/src/config.ts @@ -30,10 +30,16 @@ export type LoadedRstackConfig = { export type LoadRstackConfigOptions = { /** * The path to the Rstack config file, can be a relative or absolute path. + * A relative path is resolved from `cwd`. * If `configFilePath` is not provided, the config path set by the CLI is used. - * If neither path is provided, the function will search for the config file in the current working directory. + * If neither path is provided, the function will search for the config file in `cwd`. */ configFilePath?: string; + /** + * The directory the config file is searched in and relative config paths are resolved from. + * Defaults to the current working directory. + */ + cwd?: string; }; type ConfigSession = { @@ -42,6 +48,11 @@ type ConfigSession = { }; type ConfigState = { + /** + * Config file path from the global `--config` flag. Always absolute: the CLI + * resolves it at parse time so it stays independent of later cwd choices + * (`loadRstackConfig` may be called with an LSP workspace root as `cwd`). + */ configPath?: string; }; @@ -161,6 +172,7 @@ export const define: Define = { export const loadRstackConfig = async ({ configFilePath, + cwd, }: LoadRstackConfigOptions = {}): Promise => { const state = getConfigState(); const configPath = configFilePath ?? state.configPath; @@ -175,6 +187,7 @@ export const loadRstackConfig = async ({ loader: 'native', exportName: false, fresh: true, + cwd, ...(configPath !== undefined ? { path: configPath } : { diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index 9344a2a0..2d0b386a 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -25,6 +25,8 @@ interface ParsedFmtCLIArgs { help: boolean; /** Path the stdin content is formatted as; it need not exist on disk. */ stdinFilepath?: string; + /** Serve formatting over the Language Server Protocol instead of exiting. */ + lsp: boolean; } const renderFmtHelp = (): string => @@ -46,6 +48,7 @@ const renderFmtHelp = (): string => ['--with-node-modules', 'Process files inside node_modules'], ['--parallel-workers ', 'Number of parallel workers'], ['--stdin-filepath ', 'Format stdin as if it were saved at '], + ['--lsp', 'Run a language server on stdio'], ['-c, --config ', 'Specify Rstack config file path'], ['-h, --help', 'Display this help message'], ], @@ -66,6 +69,19 @@ const parseMaxWorkers = (value: string | undefined): number | undefined => { return maxWorkers; }; +/** Rejects the mode flags and file arguments that a server-like option replaces. */ +const assertExclusiveMode = (option: string, hasMode: boolean, positionals: string[]): void => { + if (hasMode) { + throw new Error( + `The ${option} option cannot be used with --write, --check, or --list-different.`, + ); + } + + if (positionals.length > 0) { + throw new Error(`The ${option} option cannot be used with file arguments.`); + } +}; + const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { const { values, positionals } = parseArgs({ args, @@ -81,6 +97,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { 'with-node-modules': { type: 'boolean' }, 'parallel-workers': { type: 'string' }, 'stdin-filepath': { type: 'string' }, + lsp: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, }, allowPositionals: true, @@ -110,19 +127,20 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { const maxWorkers = parseMaxWorkers(parallelWorkers); const help = values.help ?? false; const stdinFilepath = values.stdinFilepath; + const lsp = values.lsp ?? false; - if (stdinFilepath !== undefined) { - if (modes.length > 0) { - throw new Error( - 'The --stdin-filepath option cannot be used with --write, --check, or --list-different.', - ); - } + if (lsp) { + assertExclusiveMode('--lsp', modes.length > 0, positionals); - if (positionals.length > 0) { - throw new Error('The --stdin-filepath option cannot be used with file arguments.'); + if (stdinFilepath !== undefined) { + throw new Error('The --lsp option cannot be used with --stdin-filepath.'); } } + if (stdinFilepath !== undefined) { + assertExclusiveMode('--stdin-filepath', modes.length > 0, positionals); + } + return { cache, cacheLocation, @@ -135,6 +153,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { maxWorkers, help, stdinFilepath, + lsp, }; }; @@ -244,7 +263,7 @@ const logFmtResult = ( }; const loadFmtConfig = async (cwd: string): Promise => { - const { configs, filePath } = await loadRstackConfig(); + const { configs, filePath } = await loadRstackConfig({ cwd }); return resolveFmtConfig({ definition: configs.fmt, @@ -266,6 +285,7 @@ const runFmtCLI = async (args: string[]): Promise => { help, ignorePaths, ignoreUnknown, + lsp, maxWorkers, mode, noErrorOnUnmatchedPattern, @@ -278,6 +298,23 @@ const runFmtCLI = async (args: string[]): Promise => { return; } + if (lsp) { + const { runFmtLsp } = await import( + /* rspackChunkName: 'fmtLsp' */ + './lsp/server.ts' + ); + await runFmtLsp({ + // The client's workspace root is not necessarily the directory the + // editor spawned the server in; the server resolves relative + // `--ignore-path` values from this cwd so they stay based on the same + // directory as a relative `--config`. + cwd, + ignorePaths, + loadConfig: loadFmtConfig, + }); + return; + } + if (stdinFilepath !== undefined) { const { runFmtStdin } = await import( /* rspackChunkName: 'fmtStdin' */ diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts index 8b40da10..008689cf 100644 --- a/packages/rstack/src/fmt/discovery.ts +++ b/packages/rstack/src/fmt/discovery.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { createFmtOptionsResolver, type FmtOptionsResolver } from './config.ts'; import { discoverFmtPaths } from './discoverPaths.ts'; import { createIgnoreMatcher } from './ignore.ts'; +import type { FmtPluginResolver } from './plugins.ts'; import type { DiscoverFmtFilesOptions, FmtFileRequest } from './types.ts'; const createFileRequest = ( @@ -12,6 +13,26 @@ const createFileRequest = ( options: resolveOptions(filePath), }); +/** Imports the plugin chunk on first use and shares the resolver across calls. */ +const createLazyPluginResolver = (rootPath: string): (() => Promise) => { + let resolver: Promise | undefined; + + return () => + (resolver ??= import( + /* rspackChunkName: 'fmtPlugins' */ + './plugins.ts' + ).then(({ createFmtPluginResolver }) => createFmtPluginResolver(rootPath))); +}; + +/** Resolves the plugin specifiers of a request whose options configure plugins. */ +const resolveFileRequestPlugins = async ( + file: FmtFileRequest, + getPluginResolver: () => Promise, +): Promise => + file.options.plugins?.length + ? { ...file, options: (await getPluginResolver())(file.options) } + : file; + const createDirMatcher = (dirPath: string): ((filePath: string) => boolean) => { const prefix = dirPath.endsWith(path.sep) ? dirPath : `${dirPath}${path.sep}`; return (filePath) => filePath === dirPath || filePath.startsWith(prefix); @@ -43,21 +64,13 @@ const discoverFmtFiles = async ({ } const resolveOptions = createFmtOptionsResolver(config); - const files = filePaths.map((filePath) => createFileRequest(filePath, resolveOptions)); - if (!files.some((file) => file.options.plugins?.length)) { - return files; - } + const getPluginResolver = createLazyPluginResolver(config.rootPath); - const { createFmtPluginResolver } = await import( - /* rspackChunkName: 'fmtPlugins' */ - './plugins.ts' + return Promise.all( + filePaths.map((filePath) => + resolveFileRequestPlugins(createFileRequest(filePath, resolveOptions), getPluginResolver), + ), ); - const resolvePlugins = createFmtPluginResolver(config.rootPath); - - return files.map((file) => ({ - ...file, - options: resolvePlugins(file.options), - })); }; -export { createFileRequest, discoverFmtFiles }; +export { createFileRequest, createLazyPluginResolver, discoverFmtFiles, resolveFileRequestPlugins }; diff --git a/packages/rstack/src/fmt/ignore.ts b/packages/rstack/src/fmt/ignore.ts index 61ccfd1d..1c46389f 100644 --- a/packages/rstack/src/fmt/ignore.ts +++ b/packages/rstack/src/fmt/ignore.ts @@ -80,3 +80,4 @@ const createIgnoreMatcher = async ({ }; export { createIgnoreMatcher }; +export type { IgnorePredicate }; diff --git a/packages/rstack/src/fmt/lsp/minimalEdit.ts b/packages/rstack/src/fmt/lsp/minimalEdit.ts new file mode 100644 index 00000000..d391d955 --- /dev/null +++ b/packages/rstack/src/fmt/lsp/minimalEdit.ts @@ -0,0 +1,144 @@ +/** A replacement expressed as UTF-16 offsets into the original source. */ +interface MinimalEdit { + start: number; + end: number; + newText: string; +} + +const CARRIAGE_RETURN = 0x0d; +const LINE_FEED = 0x0a; + +const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff; +const isLowSurrogate = (code: number): boolean => code >= 0xdc00 && code <= 0xdfff; + +/** + * True when `index` splits a unit that occupies a single position: a surrogate + * pair or a `\r\n`. Offsets on such an index do not survive the offset → + * position → offset round trip conformant clients apply, so a range boundary + * must never land there. + */ +const splitsIndivisibleUnit = (text: string, index: number): boolean => { + const code = text.charCodeAt(index); + + return ( + (isLowSurrogate(code) && isHighSurrogate(text.charCodeAt(index - 1))) || + (code === LINE_FEED && text.charCodeAt(index - 1) === CARRIAGE_RETURN) + ); +}; + +/** + * Reduces a reformat to the single range that actually changed. + * + * Editors apply the result to a live buffer, so the edit is trimmed on both + * ends instead of replacing the whole document, which keeps selections, folds, + * and undo history intact. Offsets are converted to positions by the caller. + */ +const computeMinimalEdit = (source: string, formatted: string): MinimalEdit | undefined => { + if (source === formatted) { + return undefined; + } + + // Everything before the first difference is identical on both sides, so the + // line terminators cannot disagree here. + let prefixLength = 0; + const maxPrefixLength = Math.min(source.length, formatted.length); + while ( + prefixLength < maxPrefixLength && + source.charCodeAt(prefixLength) === formatted.charCodeAt(prefixLength) + ) { + prefixLength++; + } + + let suffixLength = 0; + const maxSuffixLength = maxPrefixLength - prefixLength; + while (suffixLength < maxSuffixLength) { + const sourceIndex = source.length - suffixLength - 1; + const formattedIndex = formatted.length - suffixLength - 1; + const code = source.charCodeAt(sourceIndex); + if (code !== formatted.charCodeAt(formattedIndex)) { + break; + } + + // Stop before a line feed whose carriage return only exists on one side, + // so the edit replaces the whole terminator instead of inserting a lone + // `\r`. This handles the two sides disagreeing; boundaries landing inside + // a `\r\n` on the source side are widened below. + if ( + code === LINE_FEED && + (source.charCodeAt(sourceIndex - 1) === CARRIAGE_RETURN) !== + (formatted.charCodeAt(formattedIndex - 1) === CARRIAGE_RETURN) + ) { + break; + } + + suffixLength++; + } + + // Widen both ends off any index that splits an indivisible unit; conformant + // clients move such offsets, which would shrink or void the range. + while (splitsIndivisibleUnit(source, prefixLength)) { + prefixLength--; + } + while (splitsIndivisibleUnit(source, source.length - suffixLength)) { + suffixLength--; + } + + return { + start: prefixLength, + end: source.length - suffixLength, + newText: formatted.slice(prefixLength, formatted.length - suffixLength), + }; +}; + +/** An LSP position; `character` counts UTF-16 code units, like JS offsets. */ +interface Position { + line: number; + character: number; +} + +/** A minimal edit expressed as the LSP range/newText the editor applies. */ +interface MinimalTextEdit { + range: { start: Position; end: Position }; + newText: string; +} + +/** + * Reduces a reformat to the single LSP text edit that actually changed. + * + * Positions count UTF-16 code units, like JS offsets, and lines end at `\n`, + * `\r\n`, or a lone `\r`, like the protocol's. `computeMinimalEdit` keeping + * boundaries out of surrogate pairs and `\r\n` is what makes the mapping exact. + */ +const computeMinimalTextEdit = (source: string, formatted: string): MinimalTextEdit | undefined => { + const edit = computeMinimalEdit(source, formatted); + if (!edit) { + return undefined; + } + + let line = 0; + let lineStart = 0; + // Resumes from the previous call's line, so start and end share one scan. A + // `\r` followed by `\n` defers to the `\n` so the pair counts once. + const advanceTo = (offset: number): Position => { + for (let index = lineStart; index < offset; index++) { + const code = source.charCodeAt(index); + if ( + code === LINE_FEED || + (code === CARRIAGE_RETURN && source.charCodeAt(index + 1) !== LINE_FEED) + ) { + line++; + lineStart = index + 1; + } + } + + return { line, character: offset - lineStart }; + }; + + const start = advanceTo(edit.start); + const end = advanceTo(edit.end); + + return { range: { start, end }, newText: edit.newText }; +}; + +export { computeMinimalEdit, computeMinimalTextEdit }; +export type { MinimalEdit, MinimalTextEdit }; diff --git a/packages/rstack/src/fmt/lsp/server.ts b/packages/rstack/src/fmt/lsp/server.ts new file mode 100644 index 00000000..93759f50 --- /dev/null +++ b/packages/rstack/src/fmt/lsp/server.ts @@ -0,0 +1,288 @@ +import { fileURLToPath } from 'node:url'; +import { inspect } from 'node:util'; +import { + createConnection, + MessageType, + ShowMessageNotification, + TextDocumentSyncKind, + type Connection, + type InitializeParams, + type TextEdit, +} from 'vscode-languageserver/node'; +import { createFmtOptionsResolver, type FmtOptionsResolver } from '../config.ts'; +import { + createFileRequest, + createLazyPluginResolver, + resolveFileRequestPlugins, +} from '../discovery.ts'; +import { formatFmtSource } from '../format.ts'; +import { createIgnoreMatcher, type IgnorePredicate } from '../ignore.ts'; +import type { FmtPluginResolver } from '../plugins.ts'; +import type { ResolvedFmtConfig } from '../types.ts'; +import { computeMinimalTextEdit } from './minimalEdit.ts'; + +interface RunFmtLspOptions { + /** Base for relative CLI paths, and the workspace root when the client reports none. */ + cwd: string; + /** Ignore files; relative paths resolve from `cwd`, like a relative `--config`. */ + ignorePaths?: string[]; + /** Loads the project config for a workspace root. */ + loadConfig: (cwd: string) => Promise; +} + +/** The client's workspace root anchors the config; `cwd` keeps anchoring CLI paths. */ +type FmtLspSessionOptions = RunFmtLspOptions & { root: string }; + +interface FmtLspSession { + isIgnored: IgnorePredicate; + resolveOptions: FmtOptionsResolver; + /** Resolves plugin specifiers through the file system; cached per session. */ + getPluginResolver: () => Promise; +} + +const toFilePath = (uri: string): string | undefined => { + try { + const url = new URL(uri); + // Untitled buffers and other schemes have no path to infer options from. + return url.protocol === 'file:' ? fileURLToPath(url) : undefined; + } catch { + return undefined; + } +}; + +/** Formats console arguments the way the library's own `patchConsole` does. */ +const serializeConsoleArguments = (args: unknown[]): string => + args.map((arg) => (typeof arg === 'string' ? arg : inspect(arg))).join(' '); + +/** + * Sends everything written to the console over the connection as log messages. + * + * Anything printed to stdout while the server runs corrupts the protocol + * framing, so every console method that writes to a standard stream is + * rerouted, mirroring the `patchConsole` the library applies only on its own + * `--stdio` argv branch. + */ +const redirectConsoleToConnection = (connection: Connection): void => { + for (const level of ['log', 'info', 'debug', 'warn', 'error'] as const) { + console[level] = (...args: unknown[]): void => + connection.console[level](serializeConsoleArguments(args)); + } + console.dir = (item: unknown, options?: object): void => + connection.console.log(inspect(item, options)); + // `dir` and `dirxml` are the only methods that write to a standard stream + // without going through the five rerouted above; in Node, `dirxml` is `log`. + console.dirxml = (...args: unknown[]): void => + connection.console.log(serializeConsoleArguments(args)); + console.trace = (...args: unknown[]): void => { + const stack = new Error().stack?.replace(/(.+\n){2}/, '') ?? ''; + const message = args.length === 0 ? 'Trace' : `Trace: ${serializeConsoleArguments(args)}`; + connection.console.log(`${message}\n${stack}`); + }; + console.assert = (assertion?: unknown, ...args: unknown[]): void => { + if (assertion) { + return; + } + connection.console.error( + args.length === 0 + ? 'Assertion failed' + : `Assertion failed: ${serializeConsoleArguments(args)}`, + ); + }; + const counters = new Map(); + console.count = (label: unknown = 'default'): void => { + const key = String(label); + const count = (counters.get(key) ?? 0) + 1; + counters.set(key, count); + connection.console.log(`${key}: ${count}`); + }; + console.countReset = (label?: unknown): void => { + if (label === undefined) { + counters.clear(); + } else { + counters.delete(String(label)); + } + }; +}; + +const resolveWorkspaceRoot = (params: InitializeParams): string | undefined => { + const rootUri = params.workspaceFolders?.[0]?.uri ?? params.rootUri; + + return (rootUri ? toFilePath(rootUri) : undefined) ?? params.rootPath ?? undefined; +}; + +/** Loads everything a formatting request needs, once per server lifetime. */ +const createFmtLspSession = async ({ + root, + cwd, + ignorePaths, + loadConfig, +}: FmtLspSessionOptions): Promise => { + const config = await loadConfig(root); + const isIgnored = await createIgnoreMatcher({ config, cwd, ignorePaths }); + + return { + isIgnored, + resolveOptions: createFmtOptionsResolver(config), + getPluginResolver: createLazyPluginResolver(config.rootPath), + }; +}; + +/** Formats an editor buffer, returning nothing when the file is not formatted. */ +const formatDocumentSource = async ( + session: FmtLspSession, + filePath: string, + source: string, +): Promise => { + if (session.isIgnored(filePath)) { + return undefined; + } + + const file = await resolveFileRequestPlugins( + createFileRequest(filePath, session.resolveOptions), + session.getPluginResolver, + ); + const result = await formatFmtSource(file, () => source); + + return result.status === 'formatted' ? result.formatted : undefined; +}; + +/** + * Turns a reformat of an open buffer into the edit the editor applies. + * + * The client can replace the buffer while the formatter runs, so the text is + * re-read afterwards: the edit stays valid exactly as long as the text it was + * computed from is still the text the client holds. + */ +const createDocumentEdits = async ( + getText: () => string | undefined, + format: (source: string) => Promise, +): Promise => { + const source = getText(); + if (source === undefined) { + return []; + } + + const formatted = await format(source); + if (getText() !== source) { + return []; + } + + const edit = formatted === undefined ? undefined : computeMinimalTextEdit(source, formatted); + + return edit ? [edit] : []; +}; + +const startFmtLsp = (options: RunFmtLspOptions, onExit: () => void): void => { + // The streams are passed explicitly because `rs fmt --lsp` carries none of + // the transport flags the default connection looks for. + const connection = createConnection(process.stdin, process.stdout); + // Passing the streams explicitly also skips the console patching the library + // only applies to its own `--stdio` branch. The config file and the prettier + // plugins are loaded in this process, so a stray `console.log` would write + // raw bytes into the JSON-RPC stream and break the client's framing parser. + // This runs before any user code can load. + redirectConsoleToConnection(connection); + // Full document sync: every change carries the whole buffer, so tracking a + // document is replacing one string, and a dropped or reordered change heals + // on the next one. + const documents = new Map(); + + connection.onDidOpenTextDocument(({ textDocument }) => { + documents.set(textDocument.uri, textDocument.text); + }); + connection.onDidChangeTextDocument(({ textDocument, contentChanges }) => { + const change = contentChanges[0]; + if (change) { + documents.set(textDocument.uri, change.text); + } else { + // An empty change list is a protocol violation; dropping the entry keeps + // stale text from standing in for the buffer until the next change. + documents.delete(textDocument.uri); + } + }); + connection.onDidCloseTextDocument(({ textDocument }) => { + documents.delete(textDocument.uri); + }); + + let root = options.cwd; + let sessionPromise: Promise | undefined; + let reportedSessionError: string | undefined; + + // TODO: watch the config file and reset the session when it changes. + const getSession = (): Promise => + (sessionPromise ??= createFmtLspSession({ ...options, root }).catch((error: unknown) => { + // Retry on the next request rather than caching the failure forever. + sessionPromise = undefined; + // A workspace that cannot be set up returns no edits for every document, + // which looks like "nothing to format" in editors that hide the server + // log, so it is shown to the user instead of only being logged. Repeats + // of the same failure stay silent so saving a file cannot spam the editor. + const message = `rs fmt cannot format this workspace: ${String(error)}`; + if (reportedSessionError !== message) { + reportedSessionError = message; + // A notification rather than `window.showErrorMessage`, which sends a + // request the server would then wait on for a response it does not need. + void connection.sendNotification(ShowMessageNotification.type, { + type: MessageType.Error, + message, + }); + } + throw error; + })); + + connection.onExit(onExit); + + connection.onInitialize((params) => { + root = resolveWorkspaceRoot(params) ?? options.cwd; + + return { + // The project config is the single source of truth, so client formatting + // options are ignored and nothing beyond formatting is advertised. Full + // sync spares the client from computing deltas the server never uses. + capabilities: { + documentFormattingProvider: true, + textDocumentSync: TextDocumentSyncKind.Full, + }, + }; + }); + + connection.onDocumentFormatting(async ({ textDocument }): Promise => { + const filePath = toFilePath(textDocument.uri); + if (!filePath) { + return []; + } + + // A formatting failure must never disrupt editing; unsupported, ignored, + // and unparsable documents all resolve to "no edits". + try { + const session = await getSession(); + + return await createDocumentEdits( + () => documents.get(textDocument.uri), + (source) => formatDocumentSource(session, filePath, source), + ); + } catch (error) { + connection.console.error(`Failed to format "${filePath}": ${String(error)}`); + return []; + } + }); + + connection.listen(); +}; + +/** + * Serves document formatting over the Language Server Protocol on stdio. + * + * The connection owns the process lifetime: it answers `shutdown`, exits on + * `exit`, and stops the process when the client closes stdin. Nothing else may + * write to stdout while the server runs. + * + * The returned promise stays pending for as long as the server serves requests, + * so the caller reports a failed startup like every other `rs fmt` failure. + */ +const runFmtLsp = (options: RunFmtLspOptions): Promise => + // A synchronous throw from the executor rejects the promise, so a failed + // startup surfaces to the caller without an explicit try/catch. + new Promise((resolvePromise) => startFmtLsp(options, resolvePromise)); + +export { createDocumentEdits, runFmtLsp }; diff --git a/packages/rstack/src/fmt/stdin.ts b/packages/rstack/src/fmt/stdin.ts index f6b6a5f6..ebc01286 100644 --- a/packages/rstack/src/fmt/stdin.ts +++ b/packages/rstack/src/fmt/stdin.ts @@ -1,6 +1,10 @@ import { resolve } from 'node:path'; import { createFmtOptionsResolver } from './config.ts'; -import { createFileRequest } from './discovery.ts'; +import { + createFileRequest, + createLazyPluginResolver, + resolveFileRequestPlugins, +} from './discovery.ts'; import { formatFmtSource } from './format.ts'; import { createIgnoreMatcher } from './ignore.ts'; import type { ResolvedFmtConfig } from './types.ts'; @@ -79,18 +83,10 @@ const runFmtStdin = async ({ return; } - let file = createFileRequest(absolutePath, createFmtOptionsResolver(config)); - if (file.options.plugins?.length) { - const { createFmtPluginResolver } = await import( - /* rspackChunkName: 'fmtPlugins' */ - './plugins.ts' - ); - file = { - ...file, - options: createFmtPluginResolver(config.rootPath)(file.options), - }; - } - + const file = await resolveFileRequestPlugins( + createFileRequest(absolutePath, createFmtOptionsResolver(config)), + createLazyPluginResolver(config.rootPath), + ); const result = await formatFmtSource(file, () => source); if (result.status === 'unsupported') { diff --git a/packages/rstack/tests/cli/fmt/__snapshots__/files.test.ts.snap b/packages/rstack/tests/cli/fmt/__snapshots__/files.test.ts.snap index 7a669027..a0b768a0 100644 --- a/packages/rstack/tests/cli/fmt/__snapshots__/files.test.ts.snap +++ b/packages/rstack/tests/cli/fmt/__snapshots__/files.test.ts.snap @@ -20,6 +20,7 @@ Options: --with-node-modules Process files inside node_modules --parallel-workers Number of parallel workers --stdin-filepath Format stdin as if it were saved at + --lsp Run a language server on stdio -c, --config Specify Rstack config file path -h, --help Display this help message " diff --git a/packages/rstack/tests/cli/fmt/helpers.ts b/packages/rstack/tests/cli/fmt/helpers.ts index aece7a8a..c33f7ecf 100644 --- a/packages/rstack/tests/cli/fmt/helpers.ts +++ b/packages/rstack/tests/cli/fmt/helpers.ts @@ -22,6 +22,14 @@ type FmtTestHarness = { writeProjectFile: (filePath: string, content: string) => void; }; +/** Environment for spawning the CLI with color output disabled. */ +export const createCliEnv = (): NodeJS.ProcessEnv => { + const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' }; + delete env.FORCE_COLOR; + + return env; +}; + export const normalizeDuration = (output: string): string => output.replace(/\d+m(?: \d+(?:\.\d+)?s)?|\d+(?:\.\d+)?s/g, ''); @@ -67,17 +75,13 @@ export const setupFmtTest = (): FmtTestHarness => { ); }; - const runCLI: RunCLI = (args, input, cwd = projectPath) => { - const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' }; - delete env.FORCE_COLOR; - - return spawnSync(process.execPath, [RSTACK_BIN_PATH, ...args], { + const runCLI: RunCLI = (args, input, cwd = projectPath) => + spawnSync(process.execPath, [RSTACK_BIN_PATH, ...args], { cwd, encoding: 'utf8', - env, + env: createCliEnv(), input, }); - }; const runFmt = (args: string[] = [], cwd = projectPath) => runCLI(['fmt', ...args], undefined, cwd); diff --git a/packages/rstack/tests/cli/fmt/lsp.test.ts b/packages/rstack/tests/cli/fmt/lsp.test.ts new file mode 100644 index 00000000..4309feca --- /dev/null +++ b/packages/rstack/tests/cli/fmt/lsp.test.ts @@ -0,0 +1,390 @@ +import { expect, test } from 'rstack/test'; +import { setupFmtTest } from './helpers.ts'; +import { applyTextEdits, type LspClient, startLspServer, toFileUri } from './lspClient.ts'; + +const { resolveProjectPath, runFmt, writeProjectFile } = setupFmtTest(); + +const TEST_TIMEOUT = 30_000; + +/** Spawn options for the cases where the server is not launched from the project root. */ +type LspServerOptions = { + /** Directory the server process is spawned in; defaults to the project root. */ + cwd?: string; + /** Extra `rs fmt` arguments. */ + args?: string[]; +}; + +const withLspServer = async ( + run: (client: LspClient) => Promise, + { cwd, args }: LspServerOptions = {}, +): Promise => { + const client = startLspServer(cwd ?? resolveProjectPath('.'), args); + + try { + await run(client); + } finally { + await client.stop(); + } +}; + +const openDocument = (client: LspClient, filePath: string, text: string): string => { + const uri = toFileUri(resolveProjectPath(filePath)); + client.openDocument(uri, 'typescript', text); + + return uri; +}; + +/** Creates an empty directory to launch the server from, standing in for an editor's cwd. */ +const createLaunchDir = (): string => { + writeProjectFile('launcher/.keep', ''); + + return resolveProjectPath('launcher'); +}; + +test( + 'advertises document formatting only', + async () => { + await withLspServer(async (client) => { + const { capabilities } = await client.initialize(); + + expect(capabilities.documentFormattingProvider).toBe(true); + // Full document sync; without a sync capability compliant clients would + // never send the document. + expect(capabilities.textDocumentSync).toBe(1); + expect(capabilities.documentRangeFormattingProvider).toBeUndefined(); + expect(capabilities.documentOnTypeFormattingProvider).toBeUndefined(); + }); + }, + TEST_TIMEOUT, +); + +test( + 'formats an open document', + async () => { + await withLspServer(async (client) => { + await client.initialize(); + const source = 'const x=1\n'; + const uri = openDocument(client, 'src/index.ts', source); + + const edits = await client.formatDocument(uri); + + expect(edits.length).toBe(1); + expect(applyTextEdits(source, edits)).toBe('const x = 1;\n'); + }); + }, + TEST_TIMEOUT, +); + +test( + 'formats the in-memory buffer instead of the file on disk', + async () => { + writeProjectFile('src/index.ts', 'const onDisk = "disk";\n'); + + await withLspServer(async (client) => { + await client.initialize(); + const source = 'const inBuffer="buffer"\n'; + const uri = openDocument(client, 'src/index.ts', source); + + const edits = await client.formatDocument(uri); + + expect(applyTextEdits(source, edits)).toBe('const inBuffer = "buffer";\n'); + }); + }, + TEST_TIMEOUT, +); + +test( + 'formats changes sent by the client', + async () => { + await withLspServer(async (client) => { + await client.initialize(); + const uri = openDocument(client, 'src/index.ts', 'const x = 1;\n'); + // Full document sync: the change carries the whole new buffer. + client.notify('textDocument/didChange', { + textDocument: { uri, version: 2 }, + contentChanges: [{ text: 'const x=2;\n' }], + }); + + const edits = await client.formatDocument(uri); + + expect(applyTextEdits('const x=2;\n', edits)).toBe('const x = 2;\n'); + }); + }, + TEST_TIMEOUT, +); + +test( + 'applies define.fmt options and overrides', + async () => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +define.fmt({ + singleQuote: true, + overrides: [ + { + files: '*.test.ts', + options: { + semi: false, + }, + }, + ], +}); +`, + ); + + await withLspServer(async (client) => { + await client.initialize(); + const source = 'const test="test"\n'; + const uri = openDocument(client, 'src/index.test.ts', source); + + const edits = await client.formatDocument(uri); + + expect(applyTextEdits(source, edits)).toBe("const test = 'test'\n"); + }); + }, + TEST_TIMEOUT, +); + +test( + 'keeps stdout clean when the config writes to the console', + async () => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +console.log('hello from config'); +// Bypasses a patched \`console.log\`, so it needs its own reroute. +console.dirxml('xml from config'); + +define.fmt({ singleQuote: true }); +`, + ); + + await withLspServer(async (client) => { + await client.initialize(); + const source = 'const x="log"\n'; + const uri = openDocument(client, 'src/index.ts', source); + + // Raw bytes from the config would have broken the framing parser well + // before this resolves. + const edits = await client.formatDocument(uri); + + expect(applyTextEdits(source, edits)).toBe("const x = 'log';\n"); + }); + }, + TEST_TIMEOUT, +); + +test( + 'loads the config from the workspace root instead of the process cwd', + async () => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +define.fmt({ singleQuote: true }); +`, + ); + const launchDir = createLaunchDir(); + + await withLspServer( + async (client) => { + // The launch directory holds no config, so double quotes here would mean + // the server resolved the config from its own cwd instead of the root. + await client.initialize(resolveProjectPath('.')); + const source = 'const value = "root"\n'; + const uri = openDocument(client, 'src/index.ts', source); + + const edits = await client.formatDocument(uri); + + expect(applyTextEdits(source, edits)).toBe("const value = 'root';\n"); + }, + { cwd: launchDir }, + ); + }, + TEST_TIMEOUT, +); + +test( + 'resolves a relative --config path from the process cwd', + async () => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +define.fmt({ singleQuote: true }); +`, + ); + const launchDir = createLaunchDir(); + writeProjectFile( + 'launcher/custom.config.ts', + `import { define } from 'rstack'; + +define.fmt({ semi: false }); +`, + ); + + await withLspServer( + async (client) => { + await client.initialize(resolveProjectPath('.')); + const source = 'const value="cli"\n'; + const uri = openDocument(client, 'src/index.ts', source); + + const edits = await client.formatDocument(uri); + + // `semi: false` from the CLI config, not `singleQuote: true` from the root. + expect(applyTextEdits(source, edits)).toBe('const value = "cli"\n'); + }, + { cwd: launchDir, args: ['--config', './custom.config.ts'] }, + ); + }, + TEST_TIMEOUT, +); + +test( + 'resolves a relative --ignore-path from the process cwd', + async () => { + writeProjectFile('fmt.ignore', 'src/ignored.ts\n'); + const launchDir = createLaunchDir(); + + await withLspServer( + async (client) => { + await client.initialize(resolveProjectPath('.')); + const ignoredUri = openDocument(client, 'src/ignored.ts', 'const ignored="ignored"\n'); + const formattedUri = openDocument(client, 'src/index.ts', 'const x=1\n'); + + expect(await client.formatDocument(ignoredUri)).toEqual([]); + // The ignore file was read rather than reported as missing. + expect((await client.formatDocument(formattedUri)).length).toBe(1); + expect(client.shownMessages()).toEqual([]); + }, + { cwd: launchDir, args: ['--ignore-path', '../fmt.ignore'] }, + ); + }, + TEST_TIMEOUT, +); + +test( + 'reports an unreadable --ignore-path to the editor', + async () => { + await withLspServer( + async (client) => { + await client.initialize(); + const uri = openDocument(client, 'src/index.ts', 'const x=1\n'); + + expect(await client.formatDocument(uri)).toEqual([]); + + const messages = client.shownMessages(); + expect(messages.length).toBe(1); + // Type 1 is MessageType.Error. + expect(messages[0].type).toBe(1); + expect(messages[0].message).toContain('Failed to read ignore file'); + }, + { args: ['--ignore-path', 'missing.ignore'] }, + ); + }, + TEST_TIMEOUT, +); + +test( + 'returns no edits for a formatted document', + async () => { + await withLspServer(async (client) => { + await client.initialize(); + const uri = openDocument(client, 'src/index.ts', 'const x = 1;\n'); + + expect(await client.formatDocument(uri)).toEqual([]); + }); + }, + TEST_TIMEOUT, +); + +test( + 'returns no edits for an unparsable document', + async () => { + await withLspServer(async (client) => { + await client.initialize(); + const uri = openDocument(client, 'src/index.ts', 'const value = ;\n'); + + expect(await client.formatDocument(uri)).toEqual([]); + // The server stays usable after a parse error. + const validUri = openDocument(client, 'src/valid.ts', 'const x=1\n'); + expect((await client.formatDocument(validUri)).length).toBe(1); + }); + }, + TEST_TIMEOUT, +); + +test( + 'returns no edits for ignored documents', + async () => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +define.fmt({ ignorePatterns: ['src/ignored.ts'] }); +`, + ); + + await withLspServer(async (client) => { + await client.initialize(); + const uri = openDocument(client, 'src/ignored.ts', 'const ignored="ignored"\n'); + + expect(await client.formatDocument(uri)).toEqual([]); + }); + }, + TEST_TIMEOUT, +); + +test( + 'returns no edits for unknown file types', + async () => { + await withLspServer(async (client) => { + await client.initialize(); + const uri = openDocument(client, 'data.unknown', 'value\n'); + + expect(await client.formatDocument(uri)).toEqual([]); + }); + }, + TEST_TIMEOUT, +); + +test( + 'exits after the shutdown handshake', + async () => { + const client = startLspServer(resolveProjectPath('.')); + await client.initialize(); + + expect(await client.stop()).toBe(0); + expect(client.stderr()).toBe(''); + }, + TEST_TIMEOUT, +); + +test.each(['--write', '--check', '--list-different'])( + 'returns exit code 2 for %s with --lsp', + (option) => { + const result = runFmt(['--lsp', option]); + + expect(result.status).toBe(2); + expect(result.stderr).toContain( + 'The --lsp option cannot be used with --write, --check, or --list-different.', + ); + }, +); + +test('returns exit code 2 for file arguments with --lsp', () => { + const result = runFmt(['--lsp', 'src/index.ts']); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('The --lsp option cannot be used with file arguments.'); +}); + +test('returns exit code 2 for --stdin-filepath with --lsp', () => { + const result = runFmt(['--lsp', '--stdin-filepath', 'src/index.ts']); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('The --lsp option cannot be used with --stdin-filepath.'); +}); diff --git a/packages/rstack/tests/cli/fmt/lspClient.ts b/packages/rstack/tests/cli/fmt/lspClient.ts new file mode 100644 index 00000000..31c2bb58 --- /dev/null +++ b/packages/rstack/tests/cli/fmt/lspClient.ts @@ -0,0 +1,189 @@ +import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { RSTACK_BIN_PATH } from '#test-helpers'; +import { createCliEnv } from './helpers.ts'; + +type JsonRpcMessage = { + id?: number; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string }; +}; + +export type Position = { line: number; character: number }; +export type TextEdit = { range: { start: Position; end: Position }; newText: string }; + +export type ShownMessage = { type: number; message: string }; + +export type LspClient = { + notify: (method: string, params: unknown) => void; + /** Initializes the server with `root` as the workspace root; defaults to the spawn cwd. */ + initialize: (root?: string) => Promise<{ capabilities: Record }>; + openDocument: (uri: string, languageId: string, text: string) => void; + formatDocument: (uri: string) => Promise; + /** `window/showMessage` notifications received so far, in order. */ + shownMessages: () => ShownMessage[]; + /** Runs the shutdown handshake and resolves with the exit code. */ + stop: () => Promise; + stderr: () => string; +}; + +const CONTENT_LENGTH_REGEXP = /content-length:\s*(\d+)/i; +/** A header block is `key: value` lines separated by `\r\n` and nothing else. */ +const HEADER_BLOCK_REGEXP = /^[^\r\n:]+:[^\r\n]*(?:\r\n[^\r\n:]+:[^\r\n]*)*$/; + +export const toFileUri = (filePath: string): string => pathToFileURL(filePath).href; + +/** Applies LSP text edits to a document, mirroring an editor. */ +export const applyTextEdits = (text: string, edits: TextEdit[]): string => + TextDocument.applyEdits(TextDocument.create('file:///document', 'plaintext', 1, text), edits); + +const readMessage = (buffer: Buffer): { message: JsonRpcMessage; rest: Buffer } | undefined => { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + return undefined; + } + + const headers = buffer.subarray(0, headerEnd).toString('ascii'); + // Real clients fall out of sync here, so anything that is not a header is a + // failure rather than something to skip over. + if (!HEADER_BLOCK_REGEXP.test(headers)) { + throw new Error(`Unexpected bytes on stdout before a message: ${JSON.stringify(headers)}.`); + } + + const contentLength = CONTENT_LENGTH_REGEXP.exec(headers); + if (!contentLength) { + throw new Error(`Missing Content-Length header in "${headers}".`); + } + + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + Number(contentLength[1]); + if (buffer.length < bodyEnd) { + return undefined; + } + + return { + message: JSON.parse(buffer.subarray(bodyStart, bodyEnd).toString('utf8')) as JsonRpcMessage, + rest: buffer.subarray(bodyEnd), + }; +}; + +/** Speaks JSON-RPC over stdio with a `rs fmt --lsp` process. */ +export const startLspServer = (cwd: string, args: string[] = []): LspClient => { + const childProcess: ChildProcessWithoutNullStreams = spawn( + process.execPath, + [RSTACK_BIN_PATH, 'fmt', '--lsp', ...args], + { cwd, env: createCliEnv(), stdio: 'pipe' }, + ); + let exitCode: number | null | undefined; + + const pending = new Map< + number, + { resolve: (result: unknown) => void; reject: (error: Error) => void } + >(); + let nextId = 0; + let buffer: Buffer = Buffer.alloc(0); + let stderr = ''; + let failure: Error | undefined; + const shownMessages: ShownMessage[] = []; + + const fail = (error: Error): void => { + failure = error; + for (const { reject } of pending.values()) { + reject(error); + } + pending.clear(); + }; + + childProcess.stdout.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + try { + let next = readMessage(buffer); + while (next) { + buffer = next.rest; + const { id, error, method, params, result } = next.message; + if (method === 'window/showMessage') { + shownMessages.push(params as ShownMessage); + } + const handlers = id === undefined ? undefined : pending.get(id); + if (id !== undefined && handlers) { + pending.delete(id); + if (error) { + handlers.reject(new Error(`${error.message} (${error.code})`)); + } else { + handlers.resolve(result); + } + } + next = readMessage(buffer); + } + } catch (error) { + fail(error as Error); + } + }); + childProcess.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + const closed = new Promise((resolve) => { + childProcess.once('close', (code) => { + exitCode = code; + fail(new Error(`The language server exited with code ${code}.\n${stderr}`)); + resolve(code); + }); + }); + + const send = (message: Record): void => { + const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }), 'utf8'); + childProcess.stdin.write(`Content-Length: ${body.byteLength}\r\n\r\n`); + childProcess.stdin.write(body); + }; + + const request = (method: string, params: unknown): Promise => { + if (failure) { + return Promise.reject(failure); + } + + const id = nextId++; + + return new Promise((resolve, reject) => { + pending.set(id, { resolve: resolve as (result: unknown) => void, reject }); + send({ id, method, params }); + }); + }; + + const notify = (method: string, params: unknown): void => { + send({ method, params }); + }; + + return { + notify, + initialize: (root = cwd) => + request('initialize', { + processId: null, + rootUri: toFileUri(root), + capabilities: {}, + workspaceFolders: [{ uri: toFileUri(root), name: 'fixture' }], + }), + openDocument: (uri, languageId, text) => { + notify('textDocument/didOpen', { + textDocument: { uri, languageId, version: 1, text }, + }); + }, + formatDocument: (uri) => + request('textDocument/formatting', { + textDocument: { uri }, + options: { tabSize: 2, insertSpaces: true }, + }), + shownMessages: () => shownMessages, + stop: async () => { + if (exitCode === undefined) { + await request('shutdown', null); + notify('exit', null); + } + + return closed; + }, + stderr: () => stderr, + }; +}; diff --git a/packages/rstack/tests/config/load-config/index.test.ts b/packages/rstack/tests/config/load-config/index.test.ts index 4faeac6c..a738f80e 100644 --- a/packages/rstack/tests/config/load-config/index.test.ts +++ b/packages/rstack/tests/config/load-config/index.test.ts @@ -51,6 +51,20 @@ test('should prefer an explicit config path over the state config path', async ( expect(filePath).toBe(explicitConfigPath); }); +test('should resolve a relative explicit config path from cwd', async () => { + const { configs, filePath } = await loadRstackConfig({ + configFilePath: 'explicit.config.ts', + cwd: import.meta.dirname, + }); + + expect(configs.app).toEqual({}); + expect(filePath).toBe(configPath('explicit.config.ts')); +}); + +test('should search for the config file in cwd', async () => { + await expect(loadRstackConfig({ cwd: import.meta.dirname })).rejects.toThrow('test config error'); +}); + test('should isolate parallel config sessions across top-level await', async () => { const hooks = createTestHooks(); const firstLoad = loadConfigFile('parallel-first.config.ts'); diff --git a/packages/rstack/tests/fmt/lsp/minimalEdit.test.ts b/packages/rstack/tests/fmt/lsp/minimalEdit.test.ts new file mode 100644 index 00000000..cf94e86f --- /dev/null +++ b/packages/rstack/tests/fmt/lsp/minimalEdit.test.ts @@ -0,0 +1,204 @@ +import { expect, test } from 'rstack/test'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { computeMinimalEdit, computeMinimalTextEdit } from '../../../src/fmt/lsp/minimalEdit.ts'; + +/** Applies an edit the way an editor does, to prove it rewrites the document. */ +const applyMinimalEdit = (source: string, formatted: string): string => { + const edit = computeMinimalEdit(source, formatted); + if (!edit) { + return source; + } + + return source.slice(0, edit.start) + edit.newText + source.slice(edit.end); +}; + +/** + * Applies an edit through a real `TextDocument`, the way a conformant client + * does: offsets become positions on the server and positions become offsets + * again on the client, which moves any offset that lands inside a `\r\n`. + */ +const applyMinimalEditThroughPositions = (source: string, formatted: string): string => { + const edit = computeMinimalEdit(source, formatted); + if (!edit) { + return source; + } + + const document = TextDocument.create('file:///a.ts', 'typescript', 1, source); + const start = document.offsetAt(document.positionAt(edit.start)); + const end = document.offsetAt(document.positionAt(edit.end)); + + // A round trip through positions must not move either boundary. + expect([start, end]).toEqual([edit.start, edit.end]); + + return source.slice(0, start) + edit.newText + source.slice(end); +}; + +test('returns no edit for identical sources', () => { + expect(computeMinimalEdit('', '')).toBeUndefined(); + expect(computeMinimalEdit('const x = 1;\n', 'const x = 1;\n')).toBeUndefined(); +}); + +test('replaces the whole document when nothing is shared', () => { + expect(computeMinimalEdit('', 'const x = 1;\n')).toEqual({ + start: 0, + end: 0, + newText: 'const x = 1;\n', + }); + expect(computeMinimalEdit('a\n', '')).toEqual({ start: 0, end: 2, newText: '' }); +}); + +test('trims a shared prefix', () => { + expect(computeMinimalEdit('const x = 1\n', 'const x = 1;\n')).toEqual({ + start: 11, + end: 11, + newText: ';', + }); +}); + +test('trims a shared suffix', () => { + expect(computeMinimalEdit(' const x = 1;\n', 'const x = 1;\n')).toEqual({ + start: 0, + end: 2, + newText: '', + }); +}); + +test('trims both ends around a change in the middle', () => { + const source = 'const a = 1;\nconst b=2;\nconst c = 3;\n'; + const formatted = 'const a = 1;\nconst b = 2;\nconst c = 3;\n'; + + expect(computeMinimalEdit(source, formatted)).toEqual({ + start: 20, + end: 21, + newText: ' = ', + }); + expect(applyMinimalEdit(source, formatted)).toBe(formatted); +}); + +test('replaces the whole line break when a carriage return is removed', () => { + const edit = computeMinimalEdit('a\r\nb\r\n', 'a\nb\n'); + + // Deleting only the carriage return would place the range inside a `\r\n`. + expect(edit).toEqual({ start: 1, end: 6, newText: '\nb\n' }); + expect(applyMinimalEdit('a\r\nb\r\n', 'a\nb\n')).toBe('a\nb\n'); +}); + +test('replaces the whole line break when a carriage return is added', () => { + const edit = computeMinimalEdit('a\nb\n', 'a\r\nb\r\n'); + + // Inserting a lone carriage return before an existing `\n` is not addressable. + expect(edit).toEqual({ start: 1, end: 4, newText: '\r\nb\r\n' }); + expect(applyMinimalEdit('a\nb\n', 'a\r\nb\r\n')).toBe('a\r\nb\r\n'); +}); + +test('keeps matching carriage returns out of the edit', () => { + const source = 'const a=1;\r\nconst b = 2;\r\n'; + const formatted = 'const a = 1;\r\nconst b = 2;\r\n'; + + expect(computeMinimalEdit(source, formatted)).toEqual({ + start: 7, + end: 8, + newText: ' = ', + }); + expect(applyMinimalEdit(source, formatted)).toBe(formatted); +}); + +test('keeps the edit on code point boundaries', () => { + const source = 'const a = "🙂";\n'; + const formatted = 'const a = "😀";\n'; + const edit = computeMinimalEdit(source, formatted); + + expect(edit).toEqual({ start: 11, end: 13, newText: '😀' }); + expect(source.slice(edit?.start, edit?.end)).toBe('🙂'); + expect(applyMinimalEdit(source, formatted)).toBe(formatted); +}); + +test('trims around multibyte characters', () => { + const source = "const emoji='🙂 ok';\n"; + const formatted = 'const emoji = "🙂 ok";\n'; + + expect(applyMinimalEdit(source, formatted)).toBe(formatted); +}); + +test('keeps the edit off the line feed of a `\\r\\n`', () => { + // The lone carriage return makes the loops meet between `\r` and `\n`, and an + // offset there is pulled back to the `\r` by the client, voiding the edit. + const edit = computeMinimalEdit('a\r\r\n', 'a\r\n'); + + expect(edit).toEqual({ start: 2, end: 4, newText: '\n' }); + expect(applyMinimalEditThroughPositions('a\r\r\n', 'a\r\n')).toBe('a\r\n'); +}); + +test('survives a round trip through a real text document', () => { + const source = 'const a=1;\r\nconst b = 2;\r\n'; + const formatted = 'const a = 1;\r\nconst b = 2;\r\n'; + + expect(applyMinimalEditThroughPositions(source, formatted)).toBe(formatted); + expect(applyMinimalEditThroughPositions('a\nb\n', 'a\r\nb\r\n')).toBe('a\r\nb\r\n'); + expect(applyMinimalEditThroughPositions('a\r\nb\r\n', 'a\nb\n')).toBe('a\nb\n'); +}); + +// Line terminators are where offsets stop being interchangeable with positions, +// so every combination of them up to a length no editor would ever hit is +// checked rather than a hand-picked few. +test('addresses every combination of line terminators', () => { + const alphabet = ['a', '\r', '\n']; + const texts: string[] = ['']; + let current = ['']; + for (let length = 0; length < 5; length++) { + current = current.flatMap((text) => alphabet.map((character) => text + character)); + texts.push(...current); + } + + const failures: string[] = []; + for (const source of texts) { + const document = TextDocument.create('file:///a.ts', 'typescript', 1, source); + for (const formatted of texts) { + const edit = computeMinimalEdit(source, formatted); + if (!edit) { + continue; + } + + const start = document.offsetAt(document.positionAt(edit.start)); + const end = document.offsetAt(document.positionAt(edit.end)); + const applied = source.slice(0, start) + edit.newText + source.slice(end); + if (applied !== formatted) { + failures.push(`${JSON.stringify(source)} -> ${JSON.stringify(formatted)}`); + } + + // The hand-rolled position mapping must agree with the reference + // implementation on every terminator combination. + const range = computeMinimalTextEdit(source, formatted)?.range; + const expected = { + start: document.positionAt(edit.start), + end: document.positionAt(edit.end), + }; + if (JSON.stringify(range) !== JSON.stringify(expected)) { + failures.push(`positions ${JSON.stringify(source)} -> ${JSON.stringify(formatted)}`); + } + } + } + + expect(failures).toEqual([]); +}); + +// The protocol counts a lone `\r` as a line break, so the mapping must too. +test('maps positions across lone carriage returns', () => { + expect(computeMinimalTextEdit('a\rb=2;\r', 'a\rb = 2;\r')).toEqual({ + range: { start: { line: 1, character: 1 }, end: { line: 1, character: 2 } }, + newText: ' = ', + }); +}); + +// U+2028 ends a line for JavaScript but never for the protocol. +test('treats a line separator as ordinary content', () => { + const source = 'const a = "\u2028";\nconst b=2;\n'; + const formatted = 'const a = "\u2028";\nconst b = 2;\n'; + + expect(computeMinimalEdit(source, formatted)).toEqual({ + start: 22, + end: 23, + newText: ' = ', + }); + expect(applyMinimalEdit(source, formatted)).toBe(formatted); +}); diff --git a/packages/rstack/tests/fmt/lsp/server.test.ts b/packages/rstack/tests/fmt/lsp/server.test.ts new file mode 100644 index 00000000..c84475a8 --- /dev/null +++ b/packages/rstack/tests/fmt/lsp/server.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from 'rstack/test'; +import { createDocumentEdits } from '../../../src/fmt/lsp/server.ts'; + +test('maps the edit onto the formatted document', async () => { + const edits = await createDocumentEdits( + () => 'const a = 1;\nconst b=2;\n', + async () => 'const a = 1;\nconst b = 2;\n', + ); + + expect(edits).toEqual([ + { + range: { start: { line: 1, character: 7 }, end: { line: 1, character: 8 } }, + newText: ' = ', + }, + ]); +}); + +test('returns no edits for an already formatted document', async () => { + const getText = () => 'const a = 1;\n'; + + expect(await createDocumentEdits(getText, async () => 'const a = 1;\n')).toEqual([]); + expect(await createDocumentEdits(getText, async () => undefined)).toEqual([]); +}); + +test('returns no edits for a document that is not open', async () => { + expect( + await createDocumentEdits( + () => undefined, + async () => '', + ), + ).toEqual([]); +}); + +// The client can replace the buffer while the formatter runs; an edit computed +// from the old text must not reach the new one. +test('returns no edits when the document changes while it is formatted', async () => { + let text = 'const a = 1;\nconst b=2;\n'; + + const edits = await createDocumentEdits( + () => text, + async (source) => { + text = 'const b=2;\n'; + + return source.replace('const b=2;', 'const b = 2;'); + }, + ); + + // Without the staleness check this returns an edit for line 1, which the + // shortened buffer no longer holds. + expect(edits).toEqual([]); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5bf485d..b1d9b06d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,12 @@ catalogs: typescript: specifier: ^7.0.2 version: 7.0.2 + vscode-languageserver: + specifier: 10.1.0 + version: 10.1.0 + vscode-languageserver-textdocument: + specifier: 1.0.12 + version: 1.0.12 yuku-parser: specifier: 0.8.4 version: 0.8.4 @@ -431,6 +437,12 @@ importers: typescript: specifier: 'catalog:' version: 7.0.2 + vscode-languageserver: + specifier: 'catalog:' + version: 10.1.0 + vscode-languageserver-textdocument: + specifier: 'catalog:' + version: 1.0.12 website: devDependencies: @@ -3014,6 +3026,23 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vscode-jsonrpc@9.0.1: + resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.18.2: + resolution: {integrity: sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + + vscode-languageserver@10.1.0: + resolution: {integrity: sha512-9gEWpXkYGXoqG7pBnE8O8hx/yP7+Aabn4+peQ3KDicQv6qunHSWyLTud3OF0w4S2+HfDD+5HqYKiXQW9HAU6mA==} + hasBin: true + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -5746,6 +5775,21 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vscode-jsonrpc@9.0.1: {} + + vscode-languageserver-protocol@3.18.2: + dependencies: + vscode-jsonrpc: 9.0.1 + vscode-languageserver-types: 3.18.0 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.18.0: {} + + vscode-languageserver@10.1.0: + dependencies: + vscode-languageserver-protocol: 3.18.2 + web-namespaces@2.0.1: {} whatwg-mimetype@3.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e9004674..d91cd99d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -56,6 +56,8 @@ catalog: tinypool: '2.1.0' tiny-readdir: 3.1.1 'typescript': '^7.0.2' + 'vscode-languageserver': '10.1.0' + 'vscode-languageserver-textdocument': '1.0.12' yuku-parser: '0.8.4' dedupePeers: true diff --git a/website/docs/en/guide/cli/fmt.mdx b/website/docs/en/guide/cli/fmt.mdx index e2552f1e..1dae5d3a 100644 --- a/website/docs/en/guide/cli/fmt.mdx +++ b/website/docs/en/guide/cli/fmt.mdx @@ -77,7 +77,7 @@ generated/** Here, `config/format.ignore` is located relative to the project root. The `generated/**` rule is relative to `config/`. It therefore ignores `config/generated/**` instead of `generated/**` in the project root. -Loaded rules apply to scanned paths, explicitly passed files, and `--stdin-filepath`. +Loaded rules apply to scanned paths, explicitly passed files, `--stdin-filepath`, and documents formatted through `--lsp`. To load multiple ignore files, repeat the option: @@ -121,6 +121,22 @@ rs fmt -l The option uses the same exit codes as `--check` and cannot be combined with `--write` or `--check`. +### `--lsp` + +Run a language server that formats editor buffers over the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) on stdio: + +```bash +rs fmt --lsp +``` + +The server only advertises document formatting and formats the buffer held by the editor, not the file on disk. Formatting options sent by the editor, such as tab size, are ignored: the Rstack config is the single source of truth. Ignored, unsupported, and unparsable documents produce no edits rather than an error. + +The server loads a single config for the workspace root reported by the editor and does not discover nested configs per file; launch one server per config root when a project contains several. The config is loaded on the first formatting request and reused for the lifetime of the server; restart the server after changing it. Besides the global [`--config`](../configuration#configuration-file) option, only [`--ignore-path`](#--ignore-path-path) affects the server. Relative values for both are resolved from the directory the server was launched in. + +Register `rs fmt --lsp` as a custom language server in any editor with LSP support to format on demand or on save. + +> `--lsp` cannot be combined with file arguments or with `--write`, `--check`, `--list-different`, or `--stdin-filepath`. + ### `--no-cache` Disable the persistent formatting cache for the current invocation: diff --git a/website/docs/en/guide/formatting.mdx b/website/docs/en/guide/formatting.mdx index 7f975351..a6c60054 100644 --- a/website/docs/en/guide/formatting.mdx +++ b/website/docs/en/guide/formatting.mdx @@ -108,7 +108,7 @@ define.fmt({ 2. **Apply default ignore rules and `ignorePatterns`**: By default, the command ignores [lock files](#lock-files), then applies `ignorePatterns`. These rules are evaluated in order, with later rules taking precedence. For example, `!pnpm-lock.yaml` re-includes the otherwise ignored file. 3. **Apply files specified with [`--ignore-path`](./cli/fmt#--ignore-path-path)**: Each ignore file is evaluated separately, and later rules take precedence within that file. Exclusions from different files and `ignorePatterns` are combined: if any source ignores a path, that path remains excluded, even if another source re-includes it. -> Even when a file is passed directly on the command line, the default ignore rules, `ignorePatterns`, and rules from `--ignore-path` still apply. The same is true for paths specified with [`--stdin-filepath`](./cli/fmt#--stdin-filepath-path). +> Even when a file is passed directly on the command line, the default ignore rules, `ignorePatterns`, and rules from `--ignore-path` still apply. The same is true for paths specified with [`--stdin-filepath`](./cli/fmt#--stdin-filepath-path) and for documents formatted through [`--lsp`](./cli/fmt#--lsp). ## Sort package.json fields \{#sort-package-json} diff --git a/website/docs/zh/guide/cli/fmt.mdx b/website/docs/zh/guide/cli/fmt.mdx index adf80c2b..8ea9aa81 100644 --- a/website/docs/zh/guide/cli/fmt.mdx +++ b/website/docs/zh/guide/cli/fmt.mdx @@ -77,7 +77,7 @@ generated/** 这里,`config/format.ignore` 相对项目根目录定位。文件中的 `generated/**` 规则则相对 `config/` 目录解析。因此,它会忽略 `config/generated/**`,而不是项目根目录下的 `generated/**`。 -加载的规则会作用于扫描得到的路径、显式传入的文件和 `--stdin-filepath`。 +加载的规则会作用于扫描得到的路径、显式传入的文件、`--stdin-filepath`,以及通过 `--lsp` 格式化的文档。 如需加载多个 ignore 文件,可以重复传入该选项: @@ -121,6 +121,22 @@ rs fmt -l 此选项与 `--check` 使用相同的退出状态码,且不能与 `--write` 或 `--check` 同时使用。 +### `--lsp` + +启动一个 language server,通过 [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) 在 stdio 上为编辑器提供格式化能力: + +```bash +rs fmt --lsp +``` + +该 server 只声明 document formatting 一项能力,格式化的是编辑器内存中的 buffer,而不是磁盘上的文件。编辑器传入的格式化选项(如缩进宽度)会被忽略,Rstack 配置是唯一的配置来源。被忽略、无法推断 parser 或解析失败的文件不会返回任何编辑操作,而不是返回错误。 + +server 只为编辑器上报的 workspace root 加载一份配置,不会按文件发现嵌套配置;如果项目包含多份配置,请为每个配置根目录各启动一个 server。配置会在第一次格式化请求时加载,并在 server 的整个生命周期内复用;修改配置后需要重启 server。除全局的 [`--config`](../configuration#configuration-file) 选项外,只有 [`--ignore-path`](#--ignore-path-path) 会影响该 server。两者的相对路径都相对于 server 的启动目录解析。 + +在任何支持 LSP 的编辑器中将 `rs fmt --lsp` 注册为自定义 language server,即可手动或在保存时格式化。 + +> `--lsp` 不能与文件参数,或 `--write`、`--check`、`--list-different`、`--stdin-filepath` 同时使用。 + ### `--no-cache` 在当前调用中关闭持久化格式化缓存: diff --git a/website/docs/zh/guide/formatting.mdx b/website/docs/zh/guide/formatting.mdx index 653ccad6..8659e673 100644 --- a/website/docs/zh/guide/formatting.mdx +++ b/website/docs/zh/guide/formatting.mdx @@ -108,7 +108,7 @@ define.fmt({ 2. **应用默认忽略规则和 `ignorePatterns`**:默认忽略 [lock 文件](#lock-files),随后应用 `ignorePatterns`。这些规则按顺序匹配,后面的规则优先。例如,`!pnpm-lock.yaml` 可以重新包含默认忽略的文件。 3. **应用 [`--ignore-path`](./cli/fmt#--ignore-path-path) 指定的文件**:每个 ignore 文件单独匹配,同一文件中后面的规则优先。不同 ignore 文件与 `ignorePatterns` 的排除结果会叠加:只要任一来源忽略某个路径,该路径就会保持排除,即使其他来源尝试重新包含它。 -> 即使命令行直接指定了某个文件,默认忽略规则、`ignorePatterns` 和 `--ignore-path` 中的规则仍然有效。通过 [`--stdin-filepath`](./cli/fmt#--stdin-filepath-path) 指定的路径也是如此。 +> 即使命令行直接指定了某个文件,默认忽略规则、`ignorePatterns` 和 `--ignore-path` 中的规则仍然有效。通过 [`--stdin-filepath`](./cli/fmt#--stdin-filepath-path) 指定的路径,以及通过 [`--lsp`](./cli/fmt#--lsp) 格式化的文档也是如此。 ## 排序 package.json 字段 \{#sort-package-json}