@alint-js/cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -207,6 +207,43 @@ alint --format json src > alint-output.json
207
207
  alint output inspect alint-output.json
208
208
  ```
209
209
 
210
+ ### Editor Integration
211
+
212
+ > **Work in progress.** `alint lsp` currently publishes cached diagnostics when an editor opens a
213
+ > workspace, and nothing more. Saving a file does not refresh it, changing `alint.config.ts` does
214
+ > not reload it, and the run commands below are advertised but not yet implemented. Restart the
215
+ > server to pick up either kind of change.
216
+
217
+ `alint lsp` runs alint as a language server over stdin and stdout.
218
+
219
+ It is cache-first. The server reads diagnostics that earlier runs already stored and **never calls
220
+ a model on its own**, so opening a workspace costs nothing. A cold cache therefore shows nothing —
221
+ run `alint` once to populate it.
222
+
223
+ Point any LSP editor at the command. In Neovim:
224
+
225
+ ```lua
226
+ vim.lsp.config.alint = {
227
+ cmd = { 'alint', 'lsp' },
228
+ root_markers = { 'alint.config.ts' },
229
+ }
230
+ ```
231
+
232
+ The server declares two commands, `alint.runFile` and `alint.runWorkspace`, which will start runs
233
+ that call models and spend tokens. Both return `MethodNotFound` today.
234
+
235
+ A VS Code extension lives in `apps/vscode`. It starts the server and shows the diagnostics; it is
236
+ not published to the marketplace yet. To run it from a checkout:
237
+
238
+ ```bash
239
+ pnpm -F @alint-js/vscode build
240
+ code --extensionDevelopmentPath=apps/vscode /path/to/your/project
241
+ ```
242
+
243
+ It resolves the alint executable from the `alint.path` setting, then `node_modules/.bin/alint` in
244
+ the workspace folder, then `PATH`. The workspace install wins so that the server and the alint you
245
+ run in a terminal share one cache.
246
+
210
247
  ## Concepts
211
248
 
212
249
  `alint` keeps the familiar lint shape: select targets, apply named rules, report diagnostics, and return an exit code that CI can understand. The difference is that a rule can reach its judgment through model calls or a tool-using agent when syntax-only checks are not enough.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as executeCli } from "../cli-DFWnPI6m.mjs";
2
+ import { t as executeCli } from "../cli-ChymU1Wr.mjs";
3
3
  import process from "node:process";
4
4
  //#region src/bin/index.ts
5
5
  executeCli(process.argv, {
@@ -18,7 +18,7 @@ import Gitignore from "gitignore-fs";
18
18
  import { Minimatch, minimatch } from "minimatch";
19
19
  import { errorMessageFrom as errorMessageFrom$1 } from "@moeru/std";
20
20
  //#region package.json
21
- var version = "0.4.0";
21
+ var version = "0.5.0";
22
22
  //#endregion
23
23
  //#region src/cli/output.ts
24
24
  function escapeLineValue(value) {
@@ -47,6 +47,7 @@ function dispatchCommand(context, node, args, options, path) {
47
47
  const [subcommand, ...restArgs] = args;
48
48
  const child = node.children?.find((item) => item.name === subcommand || item.alias?.includes(subcommand ?? ""));
49
49
  if (child) return dispatchCommand(context, child, restArgs, options, [...path, child.name]);
50
+ if (subcommand === void 0 && node.children?.length && !node.action) return Promise.resolve(reportMissingSubcommand(context, path, node.children));
50
51
  if (!node.action) return Promise.resolve(reportUnknownCommand(context, path, args));
51
52
  if (node.exactArguments === true) {
52
53
  const unexpectedArgument = args[node.arguments?.split(/\s+/u).filter(Boolean).length ?? 0];
@@ -170,6 +171,19 @@ function registerRootCommand(cli, node, context, setPendingResult) {
170
171
  return setPendingResult(node.children ? dispatchCommand(context, node, args[0] ?? [], options, [node.name]) : Promise.resolve(node.action?.(context, ...args.slice(0, -1), options) ?? 0));
171
172
  });
172
173
  }
174
+ function reportMissingSubcommand(context, path, children) {
175
+ const command = path.join(" ");
176
+ context.io.stderr.write([
177
+ `missing subcommand for "${command}"`,
178
+ "",
179
+ "available subcommands:",
180
+ ...children.map((child) => ` ${child.name}`),
181
+ "",
182
+ `run \`alint ${command} --help\` for more information`,
183
+ ""
184
+ ].join("\n"));
185
+ return 2;
186
+ }
173
187
  function reportUnknownCommand(context, path, args) {
174
188
  context.io.stderr.write(`unknown command: ${formatUnknownCommand(path, args)}\n`);
175
189
  return 2;
@@ -3088,6 +3102,96 @@ async function* walkFiles(root, options) {
3088
3102
  }
3089
3103
  }
3090
3104
  //#endregion
3105
+ //#region src/cli/runtime/runner.ts
3106
+ /**
3107
+ * Merges the setup config runner with the project config runner. No CLI flag is applied.
3108
+ *
3109
+ * `runner.cache` selects the cache file. The lint command and the language server must merge it
3110
+ * the same way, or the editor reads a different file and reports nothing.
3111
+ */
3112
+ function mergeRunnerConfigs(setupRunner, configRunner) {
3113
+ const runner = {
3114
+ ...setupRunner ?? {},
3115
+ ...configRunner ?? {},
3116
+ cache: mergeCacheConfig(setupRunner?.cache, configRunner?.cache),
3117
+ ruleConcurrency: configRunner?.ruleConcurrency ?? setupRunner?.ruleConcurrency,
3118
+ stats: mergeStatsConfig(setupRunner?.stats, configRunner?.stats),
3119
+ timeoutMs: configRunner?.timeoutMs ?? setupRunner?.timeoutMs
3120
+ };
3121
+ return Object.values(runner).some((value) => value !== void 0) ? runner : void 0;
3122
+ }
3123
+ /** The last `runner` block in the flattened config wins, key by key. */
3124
+ function resolveConfigRunner(config) {
3125
+ return normalizeConfig(config).reduce((merged, item) => item.runner ? {
3126
+ ...merged,
3127
+ ...item.runner
3128
+ } : merged, void 0);
3129
+ }
3130
+ function mergeCacheConfig(setupCache, configCache) {
3131
+ if (configCache === void 0) return setupCache;
3132
+ if (typeof configCache === "boolean") return configCache;
3133
+ if (typeof setupCache === "object") return {
3134
+ ...setupCache,
3135
+ ...configCache
3136
+ };
3137
+ return configCache;
3138
+ }
3139
+ function mergeStatsConfig(setupStats, configStats) {
3140
+ if (configStats === void 0) return setupStats;
3141
+ if (typeof configStats === "boolean") return configStats;
3142
+ if (typeof setupStats === "object") return {
3143
+ ...setupStats,
3144
+ ...configStats
3145
+ };
3146
+ return configStats;
3147
+ }
3148
+ //#endregion
3149
+ //#region src/cli/runtime/session.ts
3150
+ /**
3151
+ * Loads config, setup config, and model adapters once, then runs any number of times.
3152
+ *
3153
+ * The lint command and the language server both use this function. A second implementation would
3154
+ * resolve config or cwd differently, and the editor would then read a different cache file.
3155
+ */
3156
+ async function createRunSession(io, options = {}) {
3157
+ const [{ defaultModel, setupConfig: fileSetupConfig }, config] = await Promise.all([loadRunSetupConfig(io), loadAlintConfig(io.cwd, options.configPath)]);
3158
+ const runtime = await (options.startModelAdapters ?? startModelAdapters)(fileSetupConfig, io);
3159
+ const { setupConfig } = runtime;
3160
+ let shutdown;
3161
+ return {
3162
+ config,
3163
+ cwd: io.cwd,
3164
+ defaultModel,
3165
+ run: async (runOptions) => {
3166
+ const targets = await findLintTargets({
3167
+ config,
3168
+ cwd: io.cwd,
3169
+ errorOnUnmatchedPattern: true,
3170
+ globInputPaths: true,
3171
+ inputs: runOptions.files ?? []
3172
+ });
3173
+ return runAlint({
3174
+ cacheOnly: runOptions.cacheOnly,
3175
+ config,
3176
+ cwd: io.cwd,
3177
+ defaultModel,
3178
+ directories: targets.directories,
3179
+ files: targets.files,
3180
+ modelOverride: runOptions.modelOverride,
3181
+ outputLanguage: runOptions.outputLanguage,
3182
+ progress: runOptions.progress,
3183
+ projectTargets: runOptions.projectTargets,
3184
+ runner: runOptions.runner,
3185
+ setupConfig,
3186
+ signal: runOptions.signal
3187
+ });
3188
+ },
3189
+ runner: mergeRunnerConfigs(setupConfig.runner, resolveConfigRunner(config)),
3190
+ setupConfig,
3191
+ shutdown: () => shutdown ??= runtime.shutdown()
3192
+ };
3193
+ }
3194
+ //#endregion
3091
3195
  //#region src/cli/commands/lint/errors.ts
3092
3196
  const colors$1 = createColors({ force: true });
3093
3197
  function formatCancelledError(error, color) {
@@ -3155,63 +3259,37 @@ function groupFailuresByRule(failures) {
3155
3259
  }
3156
3260
  //#endregion
3157
3261
  //#region src/cli/commands/lint/runner.ts
3158
- function resolveConfigRunner(config) {
3159
- return normalizeConfig(config).reduce((merged, item) => item.runner ? {
3160
- ...merged,
3161
- ...item.runner
3162
- } : merged, void 0);
3163
- }
3164
- function resolveRunnerConfig(setupConfig, config, options) {
3165
- const cache = resolveRunnerCacheConfig(setupConfig.runner?.cache, config.runner?.cache, options);
3166
- const stats = resolveRunnerStatsConfig(setupConfig.runner?.stats, config.runner?.stats, options);
3262
+ /**
3263
+ * Applies this invocation's flags on top of the project's resolved runner.
3264
+ *
3265
+ * The setup/config merge itself lives in `runtime/runner.ts` because the language server needs the
3266
+ * same base without any flags; only the overrides below are CLI-shaped.
3267
+ */
3268
+ function resolveRunnerConfig(baseRunner, options) {
3167
3269
  const ruleConcurrency = parsePositiveIntegerOption(options.ruleConcurrency, "--rule-concurrency");
3168
3270
  const timeoutMs = parsePositiveIntegerOption(options.timeoutMs, "--timeout-ms");
3169
3271
  const runner = {
3170
- ...setupConfig.runner ?? {},
3171
- ...config.runner ?? {},
3172
- cache,
3173
- ruleConcurrency: ruleConcurrency ?? config.runner?.ruleConcurrency ?? setupConfig.runner?.ruleConcurrency,
3174
- stats,
3175
- timeoutMs: timeoutMs ?? config.runner?.timeoutMs ?? setupConfig.runner?.timeoutMs
3272
+ ...baseRunner ?? {},
3273
+ cache: resolveCacheOption(baseRunner?.cache, options),
3274
+ ruleConcurrency: ruleConcurrency ?? baseRunner?.ruleConcurrency,
3275
+ stats: options.stats === false ? false : baseRunner?.stats,
3276
+ timeoutMs: timeoutMs ?? baseRunner?.timeoutMs
3176
3277
  };
3177
3278
  return Object.values(runner).some((value) => value !== void 0) ? runner : void 0;
3178
3279
  }
3179
- function mergeRunnerCacheConfig(setupCache, configCache) {
3180
- if (configCache === void 0) return setupCache;
3181
- if (typeof configCache === "boolean") return configCache;
3182
- if (typeof setupCache === "object") return {
3183
- ...setupCache,
3184
- ...configCache
3185
- };
3186
- return configCache;
3187
- }
3188
- function mergeRunnerStatsConfig(setupStats, configStats) {
3189
- if (configStats === void 0) return setupStats;
3190
- if (typeof configStats === "boolean") return configStats;
3191
- if (typeof setupStats === "object") return {
3192
- ...setupStats,
3193
- ...configStats
3194
- };
3195
- return configStats;
3196
- }
3197
3280
  function parsePositiveIntegerOption(value, label) {
3198
3281
  if (value === void 0) return;
3199
3282
  const parsed = Number(value);
3200
3283
  if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${label} must be a positive integer.`);
3201
3284
  return parsed;
3202
3285
  }
3203
- function resolveRunnerCacheConfig(setupCache, configCache, options) {
3286
+ function resolveCacheOption(baseCache, options) {
3204
3287
  if (options.cache === false) return false;
3205
- const configuredCache = mergeRunnerCacheConfig(setupCache, configCache);
3206
- if (options.cacheLocation !== void 0) return typeof configuredCache === "object" ? {
3207
- ...configuredCache,
3288
+ if (options.cacheLocation !== void 0) return typeof baseCache === "object" ? {
3289
+ ...baseCache,
3208
3290
  location: options.cacheLocation
3209
3291
  } : { location: options.cacheLocation };
3210
- return configuredCache;
3211
- }
3212
- function resolveRunnerStatsConfig(setupStats, configStats, options) {
3213
- if (options.stats === false) return false;
3214
- return mergeRunnerStatsConfig(setupStats, configStats);
3292
+ return baseCache;
3215
3293
  }
3216
3294
  //#endregion
3217
3295
  //#region src/cli/stats/ci.ts
@@ -3717,61 +3795,43 @@ function isNodeError(error) {
3717
3795
  }
3718
3796
  async function runLintCommand(files, options, io, interceptConsoleOutput) {
3719
3797
  if (options.config) await assertConfigExists(io.cwd, options.config);
3720
- const [{ defaultModel, setupConfig }, config] = await Promise.all([loadRunSetupConfig(io), loadAlintConfig(io.cwd, options.config)]);
3721
- let lintTargets;
3798
+ const session = await createRunSession(io, { configPath: options.config });
3722
3799
  try {
3723
- lintTargets = await findLintTargets({
3724
- config,
3800
+ const runner = resolveRunnerConfig(session.runner, options);
3801
+ const progress = shouldEnableProgress(options, io) ? createCliProgressReporter({
3802
+ color: io.stderr.isTTY === true,
3803
+ columns: io.stderr.columns ?? 80,
3725
3804
  cwd: io.cwd,
3726
- errorOnUnmatchedPattern: true,
3727
- globInputPaths: true,
3728
- inputs: files
3729
- });
3730
- } catch (error) {
3731
- if (error instanceof NoFilesFoundError) {
3732
- io.stderr.write(`${error.message}\n`);
3733
- return 2;
3734
- }
3735
- throw error;
3736
- }
3737
- const runner = resolveRunnerConfig(setupConfig, { runner: resolveConfigRunner(config) }, options);
3738
- const progress = shouldEnableProgress(options, io) ? createCliProgressReporter({
3739
- color: io.stderr.isTTY === true,
3740
- columns: io.stderr.columns ?? 80,
3741
- cwd: io.cwd,
3742
- isTty: io.stderr.isTTY === true,
3743
- rows: io.stderr.rows,
3744
- write: (chunk) => io.stderr.write(chunk)
3745
- }) : void 0;
3746
- const restoreProgressConsole = progress ? interceptConsoleOutput({ write: progress.write }) : void 0;
3747
- const statsTarget = resolveStatsWrite(runner?.stats, io.env);
3748
- const statsCollector = statsTarget ? createStatsCollector() : void 0;
3749
- const persistStats = async (runResult) => {
3750
- if (statsTarget && statsCollector) await writeRunStats(statsTarget, statsCollector, runResult, io.cwd);
3751
- };
3752
- const writeResult = (runResult) => {
3753
- io.stdout.write(formatDiagnostics(options.format, runResult, { color: io.stdout.isTTY === true }));
3754
- };
3755
- const runtime = await startModelAdapters(setupConfig, io);
3756
- let result;
3757
- try {
3805
+ isTty: io.stderr.isTTY === true,
3806
+ rows: io.stderr.rows,
3807
+ write: (chunk) => io.stderr.write(chunk)
3808
+ }) : void 0;
3809
+ const restoreProgressConsole = progress ? interceptConsoleOutput({ write: progress.write }) : void 0;
3810
+ const statsTarget = resolveStatsWrite(runner?.stats, io.env);
3811
+ const statsCollector = statsTarget ? createStatsCollector() : void 0;
3812
+ const persistStats = async (runResult) => {
3813
+ if (statsTarget && statsCollector) await writeRunStats(statsTarget, statsCollector, runResult, io.cwd);
3814
+ };
3815
+ const writeResult = (runResult) => {
3816
+ io.stdout.write(formatDiagnostics(options.format, runResult, { color: io.stdout.isTTY === true }));
3817
+ };
3818
+ let result;
3758
3819
  try {
3759
- result = await runAlint({
3820
+ result = await session.run({
3760
3821
  cacheOnly: options.cacheOnly,
3761
- config,
3762
- cwd: io.cwd,
3763
- defaultModel,
3764
- directories: lintTargets.directories,
3765
- files: lintTargets.files,
3822
+ files,
3766
3823
  modelOverride: options.model,
3767
3824
  outputLanguage: options.outputLanguage,
3768
3825
  progress: mergeProgressReporters(progress?.reporter, statsCollector?.reporter),
3769
- runner,
3770
- setupConfig: runtime.setupConfig
3826
+ runner
3771
3827
  });
3772
3828
  } catch (error) {
3773
3829
  restoreProgressConsole?.();
3774
3830
  progress?.dispose();
3831
+ if (error instanceof NoFilesFoundError) {
3832
+ io.stderr.write(`${error.message}\n`);
3833
+ return 2;
3834
+ }
3775
3835
  if (error instanceof AlintRunError) {
3776
3836
  await persistStats(error.result);
3777
3837
  writeResult(error.result);
@@ -3792,7 +3852,7 @@ async function runLintCommand(files, options, io, interceptConsoleOutput) {
3792
3852
  writeResult(result);
3793
3853
  return result.diagnostics.some((diagnostic) => diagnostic.severity === "error") ? 1 : 0;
3794
3854
  } finally {
3795
- await runtime.shutdown();
3855
+ await session.shutdown();
3796
3856
  }
3797
3857
  }
3798
3858
  function shouldEnableProgress(options, io) {
@@ -3800,6 +3860,19 @@ function shouldEnableProgress(options, io) {
3800
3860
  return options.format === "stylish" && io.stderr.isTTY === true;
3801
3861
  }
3802
3862
  //#endregion
3863
+ //#region src/cli/commands/lsp/index.ts
3864
+ const lsp = defineCommand({
3865
+ action: async (context) => (await import("./server-DgJa6R5l.mjs")).startLspServer(context.io),
3866
+ description: "Run alint as a language server over stdio",
3867
+ examples: [["# Point an editor at the project's own alint", "alint lsp"].join("\n")],
3868
+ help: ["Serve alint diagnostics over the Language Server Protocol on stdin/stdout.", "The server is cache-first: it publishes findings that are already cached and never calls a model on its own. Runs that spend tokens happen only through the `alint.runFile` and `alint.runWorkspace` commands."].join("\n\n"),
3869
+ name: "lsp",
3870
+ options: [{
3871
+ description: "Use stdio transport (default; accepted for client compatibility)",
3872
+ flags: "--stdio"
3873
+ }]
3874
+ });
3875
+ //#endregion
3803
3876
  //#region src/cli/commands/output-inspect/inspect.ts
3804
3877
  const inspect$1 = defineCommand({
3805
3878
  action: (context, file, options) => inspectOutputFile(context.io, file, options),
@@ -4459,6 +4532,7 @@ const commandTree = [
4459
4532
  }
4460
4533
  ]
4461
4534
  }),
4535
+ lsp,
4462
4536
  lint
4463
4537
  ];
4464
4538
  //#endregion
@@ -4545,4 +4619,4 @@ function shouldCaptureHelp(argv) {
4545
4619
  return argv.includes("--help") || argv.includes("-h");
4546
4620
  }
4547
4621
  //#endregion
4548
- export { formatJson as i, formatDiagnostics as n, formatStylish as r, executeCli as t };
4622
+ export { formatJson as a, formatStylish as i, createRunSession as n, formatDiagnostics as r, executeCli as t };
package/dist/index.d.mts CHANGED
@@ -6,11 +6,19 @@ interface CliIo {
6
6
  cwd: string;
7
7
  env?: NodeJS.ProcessEnv;
8
8
  stderr: CliWritable;
9
- stdin?: {
10
- isTTY?: boolean;
11
- };
9
+ stdin?: CliReadable;
12
10
  stdout: CliWritable;
13
11
  }
12
+ /**
13
+ * A readable stream plus the TTY flag that the interactive commands read.
14
+ *
15
+ * `StreamMessageReader` in `vscode-jsonrpc` requires a full `NodeJS.ReadableStream`, so a narrower
16
+ * structural type does not compile. `PassThrough` satisfies it, which is how the protocol tests
17
+ * supply an in-memory stream.
18
+ */
19
+ interface CliReadable extends NodeJS.ReadableStream {
20
+ isTTY?: boolean;
21
+ }
14
22
  interface CliWritable {
15
23
  columns?: number;
16
24
  isTTY?: boolean;
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { i as formatJson, n as formatDiagnostics, r as formatStylish, t as executeCli } from "./cli-DFWnPI6m.mjs";
1
+ import { a as formatJson, i as formatStylish, r as formatDiagnostics, t as executeCli } from "./cli-ChymU1Wr.mjs";
2
2
  import { ignorePatternsAIAgents, ignorePatternsBuildOutputs, ignorePatternsCaches, ignorePatternsCommon, ignorePatternsEslintDefaults, ignorePatternsGenerated } from "@alint-js/config";
3
3
  import { defineConfig } from "@alint-js/core";
4
4
  export { defineConfig, executeCli, formatDiagnostics, formatJson, formatStylish, ignorePatternsAIAgents, ignorePatternsBuildOutputs, ignorePatternsCaches, ignorePatternsCommon, ignorePatternsEslintDefaults, ignorePatternsGenerated };
@@ -0,0 +1,368 @@
1
+ import { n as createRunSession } from "./cli-ChymU1Wr.mjs";
2
+ import { realpath } from "node:fs/promises";
3
+ import { AlintRunCancelledError, AlintRunError } from "@alint-js/core";
4
+ import { join, relative, sep } from "node:path";
5
+ import { errorMessageFrom } from "@moeru/std";
6
+ import { Writable } from "node:stream";
7
+ import { DidChangeWatchedFilesNotification } from "vscode-languageserver";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ //#region src/cli/commands/lsp/diagnostics.ts
10
+ /**
11
+ * End column for a whole-line range.
12
+ *
13
+ * NOTICE: LSP clamps a character above the line length back to the line length, so the server can
14
+ * mark a whole line without reading the file. Reading every file would cost a second pass over the
15
+ * workspace. The value must stay inside `uinteger` (0..2^31-1), so it is not `MAX_SAFE_INTEGER`.
16
+ * https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
17
+ */
18
+ const WHOLE_LINE = 2147483647;
19
+ /** alint reports 1-based lines and 0-based columns. LSP uses 0-based for both. */
20
+ function toLspDiagnostic(diagnostic) {
21
+ return {
22
+ code: diagnostic.ruleId,
23
+ message: diagnostic.message,
24
+ range: toRange(diagnostic),
25
+ severity: diagnostic.severity === "error" ? 1 : 2,
26
+ source: "alint"
27
+ };
28
+ }
29
+ function toLineIndex(line) {
30
+ return Math.max(line - 1, 0);
31
+ }
32
+ function toRange(diagnostic) {
33
+ const { loc } = diagnostic;
34
+ if (!loc) return {
35
+ end: {
36
+ character: 0,
37
+ line: 0
38
+ },
39
+ start: {
40
+ character: 0,
41
+ line: 0
42
+ }
43
+ };
44
+ const line = toLineIndex(loc.start.line);
45
+ if (!loc.end) return {
46
+ end: {
47
+ character: WHOLE_LINE,
48
+ line
49
+ },
50
+ start: {
51
+ character: 0,
52
+ line
53
+ }
54
+ };
55
+ return {
56
+ end: {
57
+ character: loc.end.column,
58
+ line: toLineIndex(loc.end.line)
59
+ },
60
+ start: {
61
+ character: loc.start.column,
62
+ line
63
+ }
64
+ };
65
+ }
66
+ //#endregion
67
+ //#region src/cli/commands/lsp/folder.ts
68
+ /** One config write arrives as several filesystem events. Wait for them before running a pass. */
69
+ const WORKSPACE_PASS_DEBOUNCE_MS = 500;
70
+ /**
71
+ * One workspace folder. It keeps two paths, and they are not interchangeable.
72
+ *
73
+ * Runs use `cwd`, the resolved path, because `process.cwd()` is always canonical and the lint
74
+ * command therefore always runs under a resolved path. Under any other path each target gets a
75
+ * different identity: the cache file is found, no entry matches, and no error is reported.
76
+ *
77
+ * Published diagnostics use `folderPath`, the path the client sent. See `toDocumentUri`.
78
+ */
79
+ async function createFolderSession(options) {
80
+ const folderPath = fileURLToPath(options.folderUri);
81
+ const cwd = await realpath(folderPath);
82
+ const runIo = {
83
+ ...options.io,
84
+ cwd
85
+ };
86
+ const paths = {
87
+ cwd,
88
+ folderPath
89
+ };
90
+ let session = await createRunSession(runIo);
91
+ let diagnostics = /* @__PURE__ */ new Map();
92
+ let scheduled;
93
+ const replaceMap = (next) => {
94
+ const changed = /* @__PURE__ */ new Set([...next.keys(), ...diagnostics.keys()]);
95
+ diagnostics = next;
96
+ return [...changed];
97
+ };
98
+ return {
99
+ cwd,
100
+ get diagnostics() {
101
+ return diagnostics;
102
+ },
103
+ dispose: async () => {
104
+ if (scheduled !== void 0) {
105
+ clearTimeout(scheduled);
106
+ scheduled = void 0;
107
+ }
108
+ await session.shutdown();
109
+ },
110
+ folderUri: options.folderUri,
111
+ refreshFile: async (uri) => {
112
+ const filePath = toRunPath(uri, paths);
113
+ if (filePath === void 0) return [];
114
+ const found = groupByUri(await runFromCache(session, { files: [filePath] }), paths);
115
+ const changed = /* @__PURE__ */ new Set([...found.keys()]);
116
+ const documentUri = toDocumentUri(filePath, paths);
117
+ if (!found.has(documentUri) && diagnostics.delete(documentUri)) changed.add(documentUri);
118
+ for (const [key, value] of found) diagnostics.set(key, value);
119
+ return [...changed];
120
+ },
121
+ refreshFromCache: async () => {
122
+ replaceMap(groupByUri(await runFromCache(session), paths));
123
+ },
124
+ reloadConfig: async () => {
125
+ const replaced = session;
126
+ session = await createRunSession(runIo);
127
+ await replaced.shutdown();
128
+ },
129
+ scheduleWorkspacePass: () => {
130
+ if (scheduled !== void 0) return;
131
+ scheduled = setTimeout(() => {
132
+ scheduled = void 0;
133
+ (async () => {
134
+ try {
135
+ options.onChanged?.(replaceMap(groupByUri(await runFromCache(session), paths)));
136
+ } catch (error) {
137
+ options.io.stderr.write(`alint lsp: workspace pass failed: ${errorMessageFrom(error) ?? "unknown error"}\n`);
138
+ }
139
+ })();
140
+ }, WORKSPACE_PASS_DEBOUNCE_MS);
141
+ }
142
+ };
143
+ }
144
+ /** A project rule reports diagnostics for files it did not target, so group by the named file. */
145
+ function groupByUri(result, paths) {
146
+ const grouped = /* @__PURE__ */ new Map();
147
+ for (const diagnostic of result.diagnostics) {
148
+ const uri = toDocumentUri(diagnostic.filePath, paths);
149
+ const existing = grouped.get(uri);
150
+ if (existing) {
151
+ existing.push(toLspDiagnostic(diagnostic));
152
+ continue;
153
+ }
154
+ grouped.set(uri, [toLspDiagnostic(diagnostic)]);
155
+ }
156
+ return grouped;
157
+ }
158
+ async function runFromCache(session, options = {}) {
159
+ try {
160
+ return await session.run({
161
+ cacheOnly: true,
162
+ files: options.files,
163
+ projectTargets: options.files === void 0 ? void 0 : false,
164
+ runner: {
165
+ ...session.runner,
166
+ stats: false
167
+ }
168
+ });
169
+ } catch (error) {
170
+ if (error instanceof AlintRunError || error instanceof AlintRunCancelledError) return error.result;
171
+ throw error;
172
+ }
173
+ }
174
+ /**
175
+ * Converts a run path back to the path the client sent.
176
+ *
177
+ * An editor matches diagnostics by exact URI. A resolved URI attaches them to a document the editor
178
+ * did not open.
179
+ */
180
+ function toDocumentUri(filePath, paths) {
181
+ if (paths.cwd === paths.folderPath || !filePath.startsWith(`${paths.cwd}${sep}`)) return pathToFileURL(filePath).toString();
182
+ return pathToFileURL(join(paths.folderPath, relative(paths.cwd, filePath))).toString();
183
+ }
184
+ /**
185
+ * Converts a document URI into the path a run uses. The inverse of `toDocumentUri`.
186
+ *
187
+ * Returns undefined for a URI outside this folder. A server with several folders uses that to find
188
+ * the folder that owns the file.
189
+ */
190
+ function toRunPath(uri, paths) {
191
+ const filePath = fileURLToPath(uri);
192
+ if (!filePath.startsWith(`${paths.folderPath}${sep}`)) return;
193
+ return join(paths.cwd, relative(paths.folderPath, filePath));
194
+ }
195
+ //#endregion
196
+ //#region src/cli/commands/lsp/publisher.ts
197
+ /**
198
+ * Collects document URIs and publishes each one once per window, open documents first.
199
+ *
200
+ * An LSP publish carries a whole document, so one publish per finding would send the same document
201
+ * many times. A workspace pass can queue thousands of documents, and the open one must not wait
202
+ * for the rest.
203
+ */
204
+ function createPublisher(options) {
205
+ const open = /* @__PURE__ */ new Set();
206
+ const pending = /* @__PURE__ */ new Set();
207
+ let timer;
208
+ const flush = () => {
209
+ timer = void 0;
210
+ const uris = [...pending];
211
+ pending.clear();
212
+ for (const uri of uris.filter((uri) => open.has(uri))) options.publish(uri);
213
+ for (const uri of uris.filter((uri) => !open.has(uri))) options.publish(uri);
214
+ };
215
+ return {
216
+ dispose: () => {
217
+ if (timer !== void 0) {
218
+ clearTimeout(timer);
219
+ timer = void 0;
220
+ }
221
+ pending.clear();
222
+ },
223
+ isOpen: (uri) => open.has(uri),
224
+ queue: (uri) => {
225
+ pending.add(uri);
226
+ timer ??= setTimeout(flush, options.flushMs);
227
+ },
228
+ setOpen: (uri, isOpen) => {
229
+ if (isOpen) {
230
+ open.add(uri);
231
+ return;
232
+ }
233
+ open.delete(uri);
234
+ }
235
+ };
236
+ }
237
+ //#endregion
238
+ //#region src/cli/commands/lsp/server.ts
239
+ /** Long enough to collect a workspace pass into few messages, short enough to keep a save prompt. */
240
+ const PUBLISH_FLUSH_MS = 200;
241
+ /** Two commands, because a workspace run can cost orders of magnitude more than one file. */
242
+ const RUN_FILE_COMMAND = "alint.runFile";
243
+ const RUN_WORKSPACE_COMMAND = "alint.runWorkspace";
244
+ async function startLspServer(io) {
245
+ const { stdin } = io;
246
+ if (!stdin) {
247
+ io.stderr.write("alint lsp needs a readable stdin to speak JSON-RPC over.\n");
248
+ return 2;
249
+ }
250
+ const { createConnection, TextDocumentSyncKind } = await import("vscode-languageserver/node");
251
+ const connection = createConnection(stdin, toWritableStream(io.stdout));
252
+ const folders = /* @__PURE__ */ new Map();
253
+ let folderUris = [];
254
+ let watchesConfig = false;
255
+ /** A document belongs to one folder at most, so the first folder that holds it is the owner. */
256
+ const diagnosticsFor = (uri) => {
257
+ for (const folder of folders.values()) {
258
+ const diagnostics = folder.diagnostics.get(uri);
259
+ if (diagnostics) return diagnostics;
260
+ }
261
+ return [];
262
+ };
263
+ const publisher = createPublisher({
264
+ flushMs: PUBLISH_FLUSH_MS,
265
+ publish: (uri) => connection.sendDiagnostics({
266
+ diagnostics: diagnosticsFor(uri),
267
+ uri
268
+ })
269
+ });
270
+ const openFolder = async (folderUri) => {
271
+ const folder = await createFolderSession({
272
+ folderUri,
273
+ io,
274
+ onChanged: (uris) => uris.forEach(publisher.queue)
275
+ });
276
+ folders.set(folderUri, folder);
277
+ await folder.refreshFromCache();
278
+ for (const uri of folder.diagnostics.keys()) publisher.queue(uri);
279
+ };
280
+ connection.onInitialize((params) => {
281
+ folderUris = resolveFolderUris(params);
282
+ watchesConfig = params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
283
+ return { capabilities: {
284
+ executeCommandProvider: {
285
+ commands: [RUN_FILE_COMMAND, RUN_WORKSPACE_COMMAND],
286
+ workDoneProgress: true
287
+ },
288
+ textDocumentSync: {
289
+ change: TextDocumentSyncKind.None,
290
+ openClose: true,
291
+ save: true
292
+ },
293
+ workspace: { workspaceFolders: {
294
+ changeNotifications: true,
295
+ supported: true
296
+ } }
297
+ } };
298
+ });
299
+ connection.onInitialized(() => {
300
+ if (watchesConfig) connection.client.register(DidChangeWatchedFilesNotification.type, { watchers: [{ globPattern: "**/alint.config.*" }] });
301
+ Promise.all(folderUris.map(async (folderUri) => {
302
+ try {
303
+ await openFolder(folderUri);
304
+ } catch (error) {
305
+ connection.console.error(`alint: ${folderUri}: ${errorMessageFrom(error) ?? "could not read cached diagnostics"}`);
306
+ }
307
+ }));
308
+ });
309
+ connection.onDidSaveTextDocument(({ textDocument }) => {
310
+ Promise.all([...folders.values()].map(async (folder) => {
311
+ try {
312
+ for (const uri of await folder.refreshFile(textDocument.uri)) publisher.queue(uri);
313
+ } catch (error) {
314
+ connection.console.error(`alint: ${textDocument.uri}: ${errorMessageFrom(error) ?? "could not refresh the saved file"}`);
315
+ }
316
+ }));
317
+ });
318
+ connection.onDidOpenTextDocument(({ textDocument }) => {
319
+ publisher.setOpen(textDocument.uri, true);
320
+ publisher.queue(textDocument.uri);
321
+ });
322
+ connection.onDidCloseTextDocument(({ textDocument }) => {
323
+ publisher.setOpen(textDocument.uri, false);
324
+ });
325
+ connection.onDidChangeWatchedFiles(() => {
326
+ Promise.all([...folders.values()].map(async (folder) => {
327
+ try {
328
+ await folder.reloadConfig();
329
+ folder.scheduleWorkspacePass();
330
+ } catch (error) {
331
+ connection.console.error(`alint: ${folder.folderUri}: ${errorMessageFrom(error) ?? "could not reload the config"}`);
332
+ }
333
+ }));
334
+ connection.console.info("alint: config changed; cached diagnostics were cleared and need a new run");
335
+ });
336
+ connection.onShutdown(async () => {
337
+ publisher.dispose();
338
+ await Promise.all([...folders.values()].map((folder) => folder.dispose()));
339
+ folders.clear();
340
+ });
341
+ connection.listen();
342
+ return new Promise((resolve) => {
343
+ connection.onExit(() => resolve(0));
344
+ });
345
+ }
346
+ /**
347
+ * A client can send `workspaceFolders`, the older `rootUri`, or both.
348
+ *
349
+ * Only a `file:` URI has a directory. A remote or untitled root has no cwd and no cache.
350
+ */
351
+ function resolveFolderUris(params) {
352
+ const uris = params.workspaceFolders?.map((folder) => folder.uri) ?? (params.rootUri === null ? [] : [params.rootUri]);
353
+ return [...new Set(uris)].filter((uri) => uri.startsWith("file://"));
354
+ }
355
+ /**
356
+ * Adapts the CLI string output to the byte stream that `createConnection` requires.
357
+ *
358
+ * `String(chunk)` cannot split a UTF-8 sequence here. The message writer sends one ASCII header,
359
+ * then one complete body, so every chunk is whole.
360
+ */
361
+ function toWritableStream(sink) {
362
+ return new Writable({ write(chunk, _encoding, callback) {
363
+ sink.write(String(chunk));
364
+ callback();
365
+ } });
366
+ }
367
+ //#endregion
368
+ export { startLspServer };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alint-js/cli",
3
3
  "type": "module",
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.mts",
@@ -28,9 +28,10 @@
28
28
  "pathe": "^2.0.3",
29
29
  "table": "^6.9.0",
30
30
  "tinyrainbow": "^3.1.1",
31
- "@alint-js/model-adapter-acp": "0.4.0",
32
- "@alint-js/core": "0.4.0",
33
- "@alint-js/config": "0.4.0"
31
+ "vscode-languageserver": "^10.1.0",
32
+ "@alint-js/config": "0.5.0",
33
+ "@alint-js/core": "0.5.0",
34
+ "@alint-js/model-adapter-acp": "0.5.0"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@pnpm/find-workspace-dir": "^1000.1.5",