diff --git a/README.md b/README.md index e05402f..4826df2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,3 @@ # @izaakschroeder/lsp-tools -Tools to work with your LSP. - -```sh -lsp \ - --connect 'stdio://biome/?arg=lsp-proxy' \ - fix \ - --ignore '**/node_modules/**' \ - --rule 'quickfix.suppressRule.biome.*' \ - './src/**/*{.ts,.tsx,.js,.jsx,.mjs,.cjs}' -``` \ No newline at end of file +Tools and libraries to fulfill your LSP needs. diff --git a/packages/lsp-cli/README.md b/packages/lsp-cli/README.md index f0c554c..537b98e 100644 --- a/packages/lsp-cli/README.md +++ b/packages/lsp-cli/README.md @@ -7,8 +7,8 @@ Use the LSP `textDocument/codeAction` to perform specific actions on files. ```sh -lsp fix --connect 'stdio://biome/?arg=lsp-proxy' \ +lsp fix --connect "stdio://$(yarn bin biome)/?arg=lsp-proxy" \ --ignore '**/node_modules/**' \ - --action-kind 'quickfix.suppressRule.biome.*' \ - './src/**/*{.ts,.tsx,.js,.jsx,.mjs,.cjs}' -``` \ No newline at end of file + --action-kind 'quickfix.suppressRule.biome.**' \ + '**/*{.ts,.tsx,.js,.jsx,.mjs,.cjs}' +``` diff --git a/packages/lsp-cli/package.json b/packages/lsp-cli/package.json index 3ad7c01..2c89101 100644 --- a/packages/lsp-cli/package.json +++ b/packages/lsp-cli/package.json @@ -2,33 +2,32 @@ "name": "@izaakschroeder/lsp-cli", "version": "0.0.1", "type": "module", - "main": "./src/index.ts", "bin": { "lsp": "./dist/cli.js" }, + "files": ["./dist/**", "README.md", "package.json"], "scripts": { "build": "rollup -c", "test": "biome check ./" }, - "dependencies": { - "@izaakschroeder/lsp-client": "workspace:^", - "chalk": "5.3.0", - "cli-progress": "3.12.0", - "clipanion": "4.0.0-rc.3", - "ku-progress-bar": "0.6.0", - "picomatch": "4.0.1", - "throat": "6.0.2", - "typanion": "3.14.0" - }, "devDependencies": { "@biomejs/biome": "1.6.1", + "@izaakschroeder/lsp-client": "workspace:^", "@rollup/plugin-commonjs": "25.0.7", "@rollup/plugin-node-resolve": "15.2.3", "@types/node": "20.11.29", "@types/picomatch": "2.3.3", + "chalk": "5.3.0", + "cli-progress": "3.12.0", + "clipanion": "4.0.0-rc.3", "esbuild": "0.20.2", + "fast-deep-equal": "3.1.3", + "ku-progress-bar": "0.6.0", + "picomatch": "4.0.1", "rollup": "4.13.0", "rollup-plugin-esbuild": "6.1.1", + "throat": "6.0.2", + "typanion": "3.14.0", "typescript": "5.4.2" } } diff --git a/packages/lsp-cli/rollup.config.js b/packages/lsp-cli/rollup.config.js index d050297..afaa2c2 100644 --- a/packages/lsp-cli/rollup.config.js +++ b/packages/lsp-cli/rollup.config.js @@ -10,7 +10,9 @@ export default [ exportConditions: ['node'], }), commonjs(), - esbuild(), + esbuild({ + target: 'es2022', + }), ], output: [ { diff --git a/packages/lsp-cli/src/FixCommand.ts b/packages/lsp-cli/src/FixCommand.ts index 2590f94..420dabd 100644 --- a/packages/lsp-cli/src/FixCommand.ts +++ b/packages/lsp-cli/src/FixCommand.ts @@ -10,6 +10,7 @@ import * as t from 'typanion'; import { BaseLspCommand } from './BaseLspCommand'; import { createActionFilter } from './createActionFilter'; +import { createActionRewrite } from './createActionRewrite'; import { fixFile } from './fixFile'; import { glob } from './glob'; @@ -49,7 +50,19 @@ export class FixCommand extends BaseLspCommand { Can be specified more than once to include multiple patterns. `, }); - actionTransform = Option.String('--action-transform'); + actionTextReplace = Option.Array('--action-text-replace', { + description: ` + Pass a \`needle=haystack\` search/replace string to modify the + result after an action has been performed but before it has been + applied to your files. + `, + }); + actionIgnoreDuplicates = Option.Boolean('--action-ignore-duplicates', { + description: ` + If multiple actions are presented that do the same thing, ignore + all but one of them. + `, + }); // TODO(@izaakschroeder): Move this to `BaseLspCommand` // TODO(@izaakschroeder): Do feature detection for workspaces. @@ -115,12 +128,14 @@ export class FixCommand extends BaseLspCommand { const actionFilter = createActionFilter(this.actionKinds); let actionMap = null; - if (this.actionTransform) { - const parentURL = pathToFileURL(process.cwd()); - const resolved = import.meta.resolve(this.actionTransform, parentURL); - actionMap = await import(resolved); + if (this.actionTextReplace) { + actionMap = createActionRewrite(this.actionTextReplace); } - const fixOptions = { actionFilter, actionMap }; + const fixOptions = { + actionFilter, + actionMap, + ignoreDuplicateActions: this.actionIgnoreDuplicates, + }; const exec = createThroat(this.parallel, async (path) => { return await fixFile(lsp, path, fixOptions); diff --git a/packages/lsp-cli/src/createActionRewrite.ts b/packages/lsp-cli/src/createActionRewrite.ts new file mode 100644 index 0000000..c5d0fa6 --- /dev/null +++ b/packages/lsp-cli/src/createActionRewrite.ts @@ -0,0 +1,27 @@ +import type { CodeActionItem } from '@izaakschroeder/lsp-client'; + +export const createActionRewrite = (patterns: string[]) => { + const rewrites = patterns.map((patterns) => { + const [find, replace] = patterns.split('=', 2); + if (!find || !replace) { + throw new Error(); + } + return (action: CodeActionItem) => { + for (const key in action.edit.changes) { + const changes = action.edit.changes[key]; + if (!changes) { + continue; + } + for (const change of changes) { + change.newText = change.newText.replaceAll(find, replace); + } + } + return action; + }; + }); + return (action: CodeActionItem) => { + return rewrites.reduce((prev, cur) => { + return cur(prev); + }, action); + }; +}; diff --git a/packages/lsp-cli/src/fixFile.ts b/packages/lsp-cli/src/fixFile.ts index 6c8af97..c666b54 100644 --- a/packages/lsp-cli/src/fixFile.ts +++ b/packages/lsp-cli/src/fixFile.ts @@ -1,6 +1,7 @@ import * as fs from 'node:fs/promises'; import { extname } from 'node:path'; import { pathToFileURL } from 'node:url'; +import isEqual from 'fast-deep-equal/es6'; import type { CodeActionItem, @@ -24,6 +25,7 @@ const languageMap = { interface FixFileOptions { actionFilter?: ((action: CodeActionItem) => boolean) | null | undefined; actionMap?: ((action: CodeActionItem) => CodeActionItem) | null | undefined; + ignoreDuplicateActions?: boolean | null | undefined; } export const fixFile = async ( @@ -80,8 +82,16 @@ export const fixFile = async ( if (actions.length === 0) { return false; } - let desiredActions = actions.filter((action) => { + let desiredActions = actions.filter((action, i) => { + let ignoredBecauseDuplicate = false; + if (options.ignoreDuplicateActions) { + const otherIndex = actions.findIndex((other) => { + return isEqual(other, action); + }); + ignoredBecauseDuplicate = otherIndex !== i; + } return ( + !ignoredBecauseDuplicate && (options.actionFilter ? options.actionFilter(action) : true) && action.edit.changes[uri] && Object.keys(action.edit.changes).length === 1 diff --git a/packages/lsp-cli/src/glob.ts b/packages/lsp-cli/src/glob.ts index dfe006f..6e585ab 100644 --- a/packages/lsp-cli/src/glob.ts +++ b/packages/lsp-cli/src/glob.ts @@ -13,15 +13,15 @@ export const glob = async ( cb: (path: string) => Promise, ) => { const { ignore, include } = options; - const isIgnored = ignore ? picomatch(ignore) : () => false; - const isIncluded = include ? picomatch(include) : () => true; + const isIgnored = ignore ? picomatch(ignore, { dot: true }) : () => false; + const isIncluded = include ? picomatch(include, { dot: true }) : () => true; const processDir = async (root: string, dir: string) => { const entries = await fs.readdir(dir, { withFileTypes: true }); await Promise.all( entries.map(async (entry) => { const path = join(dir, entry.name); - const relativePath = path.substring(root.length); + const relativePath = path.substring(root.length + 1); if (isIgnored(relativePath)) { return; } diff --git a/packages/lsp-client/src/LspClient.ts b/packages/lsp-client/src/LspClient.ts index 6bc07ea..09009e5 100644 --- a/packages/lsp-client/src/LspClient.ts +++ b/packages/lsp-client/src/LspClient.ts @@ -71,6 +71,9 @@ export class LspClient { } #send(req: JSONRPCPayload) { + if (!this.#connections.length) { + throw new Error('Not connected.'); + } for (const connection of this.#connections) { connection.send(req); } diff --git a/packages/lsp-client/src/createTransport.ts b/packages/lsp-client/src/createTransport.ts index 404a9b2..220d06c 100644 --- a/packages/lsp-client/src/createTransport.ts +++ b/packages/lsp-client/src/createTransport.ts @@ -1,10 +1,15 @@ +import { fileURLToPath } from 'node:url'; import { LspStdioTransport } from './LspStdioTransport'; export const createTransport = (urlString: string) => { const url = new URL(urlString); switch (url.protocol) { case 'stdio:': { - const bin = url.hostname; + if (url.hostname) { + // TODO: Consider resolving non-absolute paths + throw new TypeError('Path must be absolute'); + } + const bin = fileURLToPath(`file://${url.pathname}`); const args = url.searchParams.getAll('arg'); return new LspStdioTransport(bin, args); } diff --git a/yarn.lock b/yarn.lock index c8ccf54..7fbb977 100644 --- a/yarn.lock +++ b/yarn.lock @@ -285,6 +285,7 @@ __metadata: cli-progress: "npm:3.12.0" clipanion: "npm:4.0.0-rc.3" esbuild: "npm:0.20.2" + fast-deep-equal: "npm:3.1.3" ku-progress-bar: "npm:0.6.0" picomatch: "npm:4.0.1" rollup: "npm:4.13.0" @@ -880,6 +881,13 @@ __metadata: languageName: node linkType: hard +"fast-deep-equal@npm:3.1.3": + version: 3.1.3 + resolution: "fast-deep-equal@npm:3.1.3" + checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0 + languageName: node + linkType: hard + "foreground-child@npm:^3.1.0": version: 3.1.1 resolution: "foreground-child@npm:3.1.1"