@mrclrchtr/supi-lsp 7.4.0 → 8.0.1

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.
Files changed (32) hide show
  1. package/README.md +29 -6
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +1 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/query-result.ts +22 -4
  5. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  6. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +110 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/config.ts +6 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +2 -1
  9. package/package.json +3 -3
  10. package/src/api.ts +25 -3
  11. package/src/client/client-diagnostic-host.ts +2 -1
  12. package/src/client/client-diagnostic-refresh.ts +5 -2
  13. package/src/client/client-diagnostics.ts +39 -6
  14. package/src/client/client-document-sync.ts +82 -12
  15. package/src/client/client-semantic-input-barrier.ts +28 -5
  16. package/src/client/client.ts +606 -30
  17. package/src/config/capabilities.ts +9 -0
  18. package/src/config/config.ts +69 -8
  19. package/src/config/operation-support.ts +196 -0
  20. package/src/config/server-config.ts +15 -0
  21. package/src/config/types.ts +9 -1
  22. package/src/manager/manager-project-info.ts +2 -0
  23. package/src/manager/manager-workspace-symbol.ts +31 -2
  24. package/src/manager/manager.ts +72 -2
  25. package/src/provider/lsp-refactor-provider.ts +9 -43
  26. package/src/provider/refactor-planning.ts +102 -50
  27. package/src/provider/semantic-edit-normalizer.ts +49 -3
  28. package/src/session/runtime-controller.ts +78 -18
  29. package/src/session/runtime-registry.ts +46 -2
  30. package/src/session/workspace-lsp-runtime.ts +14 -0
  31. package/src/workspace-path-policy.ts +30 -4
  32. package/src/config/lsp-settings.ts +0 -22
package/README.md CHANGED
@@ -46,7 +46,7 @@ SuPi advertises static and dynamic pull support. It advertises server-requested
46
46
 
47
47
  A server gets pull diagnostics when it declares a valid `diagnosticProvider` during initialization. A server also gets pull diagnostics after it registers `textDocument/diagnostic`. The pull support stays active until the server removes the registration. SuPi validates registration parameters. Invalid parameters do not enable pull support. SuPi ignores other registration methods.
48
48
 
49
- When a server sends `workspace/diagnostic/refresh`, SuPi returns `null` immediately. It then invalidates diagnostic evidence only and refreshes the owning client's tracked documents in the background. Unchanged open documents keep their input synchronization; SuPi sends no no-op `didChange`, `didClose`, or `didOpen`. It uses native pull or the tested TypeScript request adapter when available. Push-only routes keep partial or unconfirmed evidence. Actual disk changes use the normal document synchronization path. Overlapping refresh requests share one active pass and one newer pending demand; an active diagnostic transport stays owned until it settles. The refresh covers open, cached, and failed tracked documents. SuPi does not add workspace-wide `workspace/diagnostic` pulls.
49
+ When a server sends `workspace/diagnostic/refresh`, SuPi returns `null` immediately. It then invalidates diagnostic evidence only and refreshes the owning client's tracked documents in the background. Unchanged open documents keep their input synchronization; SuPi sends no no-op `didChange`, `didClose`, or `didOpen`. It uses native pull or the tested TypeScript request adapter when available. Push-only routes keep partial or unconfirmed evidence. Actual disk changes use the normal document synchronization path, followed by one `didSave` when the server requested saves; repeated observations of the same content send no duplicate save. Overlapping refresh requests share one active pass and one newer pending demand; an active diagnostic transport stays owned until it settles. The refresh covers open, cached, and failed tracked documents. SuPi does not add workspace-wide `workspace/diagnostic` pulls.
50
50
 
51
51
  Protocol support is separate from the configured mode. A server may support pull diagnostics and still use SuPi's push mode because the built-in configuration does not enable pull mode.
52
52
 
@@ -108,25 +108,27 @@ Gopls pull diagnostics stay opt-in while golang/go#70199 is open; without the op
108
108
 
109
109
  Automatic LSP work uses one path policy that does not change for each workspace runtime. It covers project discovery, route startup, warm-up, sentinel and source-file lists, created-file tracking, runtime guidance, and diagnostic summaries not tied to one request.
110
110
 
111
- The policy excludes these directories by default: `.git`, `.cache`, `.pi`, `.pnpm`, `node_modules`, `dist`, `build`, `out`, `coverage`, `.next`, `.nuxt`, `.turbo`, and `__pycache__`. It also applies `lsp.exclude` patterns and root or nested `.gitignore` rules. Patterns use gitignore syntax, including rules relative to each directory and `!` rules. Built-in exclusions cannot be enabled again. Symbolic-link directories are not visited. Other dot-directories, such as `.github` and `.storybook`, remain allowed.
111
+ The policy excludes these directories by default: `.git`, `.cache`, `.pi`, `.pnpm`, `node_modules`, `dist`, `build`, `out`, `coverage`, `.next`, `.nuxt`, `.turbo`, and `__pycache__`. It also applies `code-intelligence.exclude` patterns and root or nested `.gitignore` rules. Patterns use gitignore syntax, including rules relative to each directory and `!` rules. Built-in exclusions cannot be enabled again. Symbolic-link directories are not visited. Other dot-directories, such as `.github` and `.storybook`, remain allowed.
112
112
 
113
- Set `lsp.exclude` in project or global SuPi configuration:
113
+ Set `code-intelligence.exclude` in project or global SuPi configuration. A project list replaces the global list; `[]` clears configured patterns. Use `/supi-settings` to edit this shared list when the settings extension is installed. Reload or restart Pi after saving:
114
114
 
115
115
  ```json
116
116
  {
117
- "lsp": {
117
+ "code-intelligence": {
118
118
  "exclude": ["generated/**", "!generated/keep.ts"]
119
119
  }
120
120
  }
121
121
  ```
122
122
 
123
+ An old `lsp.exclude` value is a migration error. Move it to `code-intelligence.exclude`; SuPi does not edit personal configuration for you.
124
+
123
125
  An exact semantic request can still route an excluded file when a compatible server is available. This does not add the file to automatic work. Configured diagnostic suppression still applies to diagnostic output.
124
126
 
125
127
  Tsconfig and jsconfig filtering applies only to TypeScript and JavaScript-family files (`.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, and `.cjs`). Other-language diagnostics are not filtered by a nearby TypeScript or JavaScript project config. Automatic path exclusions and configured diagnostic suppression still apply. A single-file health result omits the `Tsconfig` coverage line for other languages.
126
128
 
127
129
  ### Custom server configuration
128
130
 
129
- A custom server needs a command and at least one file type:
131
+ A custom server needs a command and at least one file type. Advanced server settings are JSON objects. They replace inherited settings as a whole; `initializationOptions` remains a separate initialize value:
130
132
 
131
133
  ```json
132
134
  {
@@ -137,6 +139,7 @@ A custom server needs a command and at least one file type:
137
139
  "args": ["--stdio"],
138
140
  "fileTypes": ["custom"],
139
141
  "env": { "CUSTOM_LSP_LOG": "debug" },
142
+ "settings": { "custom": { "mode": "project" } },
140
143
  "initializationOptions": { "mode": "project" }
141
144
  }
142
145
  }
@@ -144,6 +147,14 @@ A custom server needs a command and at least one file type:
144
147
  }
145
148
  ```
146
149
 
150
+ Server settings use one snapshot for the active workspace runtime:
151
+
152
+ - An omitted `settings` field inherits the earlier object. `settings: {}` clears inherited settings.
153
+ - `workspace/configuration` reads that snapshot. A request without a section receives the complete object; an unknown section receives `null`.
154
+ - A configured snapshot is also sent through `workspace/didChangeConfiguration` after initialization.
155
+ - SuPi validates the JSON object shape, not server-specific setting names or values.
156
+ - Saved changes apply after reload or restart. A saved value does not prove that the server accepted it.
157
+
147
158
  File types do not include a leading dot. If `rootMarkers` is omitted, the server uses the session root.
148
159
 
149
160
  Disable one language with `lsp.servers.<language>.enabled: false`. Use `/supi-ci-status` from `@mrclrchtr/supi-code-intelligence` to see detected, running, and missing servers.
@@ -155,6 +166,18 @@ Disable one language with `lsp.servers.<language>.enabled: false`. Use `/supi-ci
155
166
  - explicit ready, pending, inactive, disabled, and unavailable registry states
156
167
  - a `SemanticProvider` adapter for `supi-code-runtime`
157
168
  - precise rename and code-action edit conversion
169
+ - operation-specific code-action kind filters and standard lazy edit resolution
170
+ - negotiated operation support in project-server status
171
+
172
+ Operation-specific planners request `source.organizeImports` for import updates and
173
+ `source.removeUnused` for dead-code removal. They match the returned `CodeAction.kind`,
174
+ not its title. Edit-only actions are validated before planning. Command actions,
175
+ resource operations, snippets, annotations, malformed actions, and stale responses
176
+ stay unavailable. Distinct matching plans return an ambiguous result instead of
177
+ selecting the first action. Rename uses `prepareRename` when the server advertises
178
+ it and otherwise uses direct standard rename. Standard LSP does not provide
179
+ a safe exact extraction-name contract, so `extract_function` and `extract_variable`
180
+ plans report a SuPi planning limitation instead of selecting a generated name.
158
181
 
159
182
  Clients, `LspManager`, and the default runtime implementation remain internal.
160
183
 
@@ -164,7 +187,7 @@ Clients, `LspManager`, and the default runtime implementation remain internal.
164
187
 
165
188
  - language-server detection and startup
166
189
  - shutdown
167
- - settings and missing-server inventory
190
+ - server configuration and missing-server inventory
168
191
  - publishing workspace runtime state and aggregate lifecycle transitions
169
192
  - projecting concrete client readiness into semantic capability state
170
193
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-code-runtime",
3
- "version": "7.4.0",
3
+ "version": "8.0.1",
4
4
  "description": "Shared workspace context and capability contracts for code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,7 +14,7 @@ export type {
14
14
  StructuralProvider,
15
15
  StructuralResult,
16
16
  } from "./capability/types.ts";
17
- export type { CodeQueryResult } from "./query-result.ts";
17
+ export type { CodeQueryPartialMetadata, CodeQueryResult } from "./query-result.ts";
18
18
  // Shared query-result constructors and contract
19
19
  export {
20
20
  completedCodeQuery,
@@ -1,3 +1,12 @@
1
+ /** Structured metadata for a partial query result. */
2
+ export interface CodeQueryPartialMetadata {
3
+ /** Automatic path-policy evidence removed from a collected result. */
4
+ readonly automaticPathExclusion?: {
5
+ readonly excludedCount: number;
6
+ readonly examples: readonly string[];
7
+ };
8
+ }
9
+
1
10
  /**
2
11
  * Result of a read-only code-provider query.
3
12
  *
@@ -8,7 +17,12 @@
8
17
  */
9
18
  export type CodeQueryResult<T> =
10
19
  | { readonly kind: "completed"; readonly data: T }
11
- | { readonly kind: "partial"; readonly data: T; readonly reason: string }
20
+ | {
21
+ readonly kind: "partial";
22
+ readonly data: T;
23
+ readonly reason: string;
24
+ readonly metadata?: CodeQueryPartialMetadata;
25
+ }
12
26
  | { readonly kind: "unavailable"; readonly reason: string };
13
27
 
14
28
  /** Construct a successfully completed code-query result, including empty data. */
@@ -17,8 +31,12 @@ export function completedCodeQuery<T>(data: T): CodeQueryResult<T> {
17
31
  }
18
32
 
19
33
  /** Construct a usable but incomplete code-query result. */
20
- export function partialCodeQuery<T>(data: T, reason: string): CodeQueryResult<T> {
21
- return { kind: "partial", data, reason };
34
+ export function partialCodeQuery<T>(
35
+ data: T,
36
+ reason: string,
37
+ metadata?: CodeQueryPartialMetadata,
38
+ ): CodeQueryResult<T> {
39
+ return { kind: "partial", data, reason, ...(metadata ? { metadata } : {}) };
22
40
  }
23
41
 
24
42
  /** Construct a code-query result that could not be established. */
@@ -35,7 +53,7 @@ export function mapCodeQueryResult<T, U>(
35
53
  case "completed":
36
54
  return completedCodeQuery(map(result.data));
37
55
  case "partial":
38
- return partialCodeQuery(map(result.data), result.reason);
56
+ return partialCodeQuery(map(result.data), result.reason, result.metadata);
39
57
  case "unavailable":
40
58
  return unavailableCodeQuery(result.reason);
41
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "7.4.0",
3
+ "version": "8.0.1",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,6 +70,116 @@ export interface SupiConfigOptions {
70
70
  homeDir?: string;
71
71
  }
72
72
 
73
+ /** Options for loading automatic code-intelligence exclusions. */
74
+ export interface AutomaticExclusionOptions extends SupiConfigOptions {
75
+ /** Whether the project config is trusted and can apply. Defaults to true. */
76
+ projectTrusted?: boolean;
77
+ }
78
+
79
+ /**
80
+ * Load the one configured exclusion list shared by automatic LSP work and
81
+ * broad AST searches.
82
+ *
83
+ * Project values replace global values. An explicit empty project list clears
84
+ * the global list. The old `lsp.exclude` key is rejected in every applicable
85
+ * scope so automatic work cannot start without its former protection.
86
+ */
87
+ export function loadAutomaticExclusionPatterns(
88
+ cwd: string,
89
+ options: AutomaticExclusionOptions = {},
90
+ ): readonly string[] {
91
+ const projectTrusted = options.projectTrusted !== false;
92
+ const globalCodeIntelligence = loadSupiConfigSectionForScope("code-intelligence", cwd, {
93
+ scope: "global",
94
+ homeDir: options.homeDir,
95
+ });
96
+ const projectCodeIntelligence = projectTrusted
97
+ ? loadSupiConfigSectionForScope("code-intelligence", cwd, { scope: "project" })
98
+ : null;
99
+ const globalLsp = loadSupiConfigSectionForScope("lsp", cwd, {
100
+ scope: "global",
101
+ homeDir: options.homeDir,
102
+ });
103
+ const projectLsp = projectTrusted
104
+ ? loadSupiConfigSectionForScope("lsp", cwd, { scope: "project" })
105
+ : null;
106
+
107
+ const legacyPath = findLegacyExclusionPath(cwd, options, globalLsp, projectLsp);
108
+ if (legacyPath) {
109
+ throw new Error(
110
+ `Unsupported ${legacyPath.scope} configuration at ${legacyPath.path}: "lsp.exclude" was removed. Move its value to "code-intelligence.exclude". Automatic LSP and broad AST work did not start; reload after migration.`,
111
+ );
112
+ }
113
+
114
+ const source = findExclusionSource(cwd, options, {
115
+ projectTrusted,
116
+ globalCodeIntelligence,
117
+ projectCodeIntelligence,
118
+ });
119
+ return validateExclusionSource(source);
120
+ }
121
+
122
+ interface ExclusionSource {
123
+ value: unknown;
124
+ path: string;
125
+ }
126
+
127
+ interface LegacyExclusionPath {
128
+ scope: "global" | "project";
129
+ path: string;
130
+ }
131
+
132
+ function findLegacyExclusionPath(
133
+ cwd: string,
134
+ options: AutomaticExclusionOptions,
135
+ globalLsp: Record<string, unknown> | null,
136
+ projectLsp: Record<string, unknown> | null,
137
+ ): LegacyExclusionPath | null {
138
+ if (globalLsp && Object.hasOwn(globalLsp, "exclude")) {
139
+ return { scope: "global", path: getSupiConfigPath("global", cwd, options) };
140
+ }
141
+ if (projectLsp && Object.hasOwn(projectLsp, "exclude")) {
142
+ return { scope: "project", path: getSupiConfigPath("project", cwd) };
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function findExclusionSource(
148
+ cwd: string,
149
+ options: AutomaticExclusionOptions,
150
+ sections: {
151
+ projectTrusted: boolean;
152
+ globalCodeIntelligence: Record<string, unknown> | null;
153
+ projectCodeIntelligence: Record<string, unknown> | null;
154
+ },
155
+ ): ExclusionSource | null {
156
+ const { projectTrusted, globalCodeIntelligence, projectCodeIntelligence } = sections;
157
+ if (
158
+ projectTrusted &&
159
+ projectCodeIntelligence &&
160
+ Object.hasOwn(projectCodeIntelligence, "exclude")
161
+ ) {
162
+ return { value: projectCodeIntelligence.exclude, path: getSupiConfigPath("project", cwd) };
163
+ }
164
+ if (globalCodeIntelligence && Object.hasOwn(globalCodeIntelligence, "exclude")) {
165
+ return {
166
+ value: globalCodeIntelligence.exclude,
167
+ path: getSupiConfigPath("global", cwd, options),
168
+ };
169
+ }
170
+ return null;
171
+ }
172
+
173
+ function validateExclusionSource(source: ExclusionSource | null): readonly string[] {
174
+ if (!source) return Object.freeze([]);
175
+ if (!Array.isArray(source.value) || !source.value.every((entry) => typeof entry === "string")) {
176
+ throw new Error(
177
+ `Invalid "code-intelligence.exclude" in ${source.path}: expected an array of gitignore-style strings. Automatic LSP and broad AST work did not start.`,
178
+ );
179
+ }
180
+ return Object.freeze([...source.value]);
181
+ }
182
+
73
183
  /**
74
184
  * Load and merge config for a given extension section.
75
185
  *
@@ -1,7 +1,12 @@
1
1
  // supi-core config domain — config loading.
2
- export type { SupiConfigLocation, SupiConfigOptions } from "./config/config.ts";
2
+ export type {
3
+ AutomaticExclusionOptions,
4
+ SupiConfigLocation,
5
+ SupiConfigOptions,
6
+ } from "./config/config.ts";
3
7
  export {
4
8
  getSupiConfigPath,
9
+ loadAutomaticExclusionPatterns,
5
10
  loadSupiConfig,
6
11
  loadSupiConfigForScope,
7
12
  loadSupiConfigSectionForScope,
@@ -151,6 +151,7 @@ export interface CustomField extends BaseField {
151
151
  cwd: string,
152
152
  action: SettingsAction,
153
153
  helpers: ConfigHelpers,
154
+ ctx?: ExtensionContext,
154
155
  ) => void | Promise<void>;
155
156
  }
156
157
 
@@ -404,7 +405,7 @@ async function applyConfigAction(
404
405
  const helpers = createConfigHelpers(options.section, scope, cwd, options.homeDir);
405
406
  let storedValue: unknown;
406
407
  if (field.kind === "custom") {
407
- await field.persist(scope, cwd, action, helpers);
408
+ await field.persist(scope, cwd, action, helpers, ctx);
408
409
  storedValue = action.kind === "set" ? action.value : undefined;
409
410
  } else if (action.kind === "set") {
410
411
  storedValue = parseTypedValue(action.value, field);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-lsp",
3
- "version": "7.4.0",
3
+ "version": "8.0.1",
4
4
  "description": "Language Server Protocol runtime for SuPi code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,8 +37,8 @@
37
37
  "vscode-jsonrpc": "^9.0.0",
38
38
  "vscode-languageserver-protocol": "^3.18.0",
39
39
  "vscode-languageserver-types": "^3.18.0",
40
- "@mrclrchtr/supi-core": "7.4.0",
41
- "@mrclrchtr/supi-code-runtime": "7.4.0"
40
+ "@mrclrchtr/supi-code-runtime": "8.0.1",
41
+ "@mrclrchtr/supi-core": "8.0.1"
42
42
  },
43
43
  "bundledDependencies": [
44
44
  "@mrclrchtr/supi-code-runtime",
package/src/api.ts CHANGED
@@ -1,9 +1,22 @@
1
1
  // Public API surface for the LSP session-scoped service.
2
2
 
3
3
  export { isMissingFileError } from "./client/client-file-state.ts";
4
- export { type LoadConfigOptions, loadConfig } from "./config/config.ts";
5
- export type { LspSettings } from "./config/lsp-settings.ts";
6
- export { loadLspSettings } from "./config/lsp-settings.ts";
4
+ export {
5
+ getExplicitlyDisabledLanguages,
6
+ type LoadConfigOptions,
7
+ loadConfig,
8
+ } from "./config/config.ts";
9
+ export {
10
+ codeActionKindsForOperation,
11
+ getServerOperationSupport,
12
+ isCodeActionKindInRange,
13
+ REFACTOR_CODE_ACTION_KINDS,
14
+ supportsCodeActionResolve,
15
+ supportsCodeActions,
16
+ supportsPrepareRename,
17
+ supportsRename,
18
+ supportsRequestedCodeActionKinds,
19
+ } from "./config/operation-support.ts";
7
20
  export {
8
21
  clearTsconfigCache,
9
22
  type FileScopeDecision,
@@ -21,6 +34,8 @@ export type {
21
34
  DocumentSymbol,
22
35
  FileEvent,
23
36
  Hover,
37
+ JsonObject,
38
+ JsonValue,
24
39
  Location,
25
40
  LocationLink,
26
41
  LspConfig,
@@ -29,6 +44,10 @@ export type {
29
44
  ProjectServerInfo,
30
45
  ProjectServerStatusReason,
31
46
  Range,
47
+ ServerAdvertisement,
48
+ ServerOperationName,
49
+ ServerOperationSupport,
50
+ ServerOperationSupportMap,
32
51
  SymbolInformation,
33
52
  WorkspaceEdit,
34
53
  WorkspaceSymbol,
@@ -52,6 +71,7 @@ export {
52
71
  export { raceReadinessValue, raceRequestControl } from "./session/readiness.ts";
53
72
  export type {
54
73
  LspControllerState,
74
+ LspRuntimeControllerOptions,
55
75
  LspRuntimeTransition,
56
76
  LspRuntimeTransitionKind,
57
77
  LspRuntimeTransitionListener,
@@ -77,6 +97,7 @@ export type {
77
97
  StartupRetryNextAction,
78
98
  StartupRetryOutcome,
79
99
  StartupRetryReport,
100
+ WorkspaceCodeActionRequestOptions,
80
101
  WorkspaceDiagnosticReport,
81
102
  WorkspaceDiagnosticSnapshot,
82
103
  WorkspaceDiagnosticSummaryEntry,
@@ -98,6 +119,7 @@ export { MAX_BULK_TRACK_FILES } from "./session/workspace-lsp-runtime.ts";
98
119
  export {
99
120
  AUTOMATIC_LSP_EXCLUDED_DIRECTORIES,
100
121
  type AutomaticLspPathPolicy,
122
+ type AutomaticLspPathPolicyOptions,
101
123
  createAutomaticLspPathPolicy,
102
124
  createDefaultAutomaticLspPathPolicy,
103
125
  } from "./workspace-path-policy.ts";
@@ -1,4 +1,5 @@
1
1
  import type { DiagnosticRequestAdapter } from "./client-diagnostic-request.ts";
2
+ import type { NormalizedDocumentSync } from "./client-document-sync.ts";
2
3
 
3
4
  /** Transport and capability operations required by diagnostic state. */
4
5
  export interface ClientDiagnosticsHost {
@@ -9,6 +10,6 @@ export interface ClientDiagnosticsHost {
9
10
  isOperational(): boolean;
10
11
  /** Native pull and server-specific request evidence share this priority adapter. */
11
12
  diagnosticRequestAdapter: DiagnosticRequestAdapter;
12
- usesIncrementalDocumentSync(): boolean;
13
+ documentSync(): NormalizedDocumentSync;
13
14
  sendNotification(method: string, params: unknown): void;
14
15
  }
@@ -289,7 +289,9 @@ interface ClientDiagnosticRefreshOptions {
289
289
  /** Invalidate route evidence before applying a disk content change. */
290
290
  readonly noteInputContentChange: () => number;
291
291
  /** Keep the shared semantic barrier's verified disk observation current. */
292
- readonly observeDiskContent: (uri: string, content: string) => void;
292
+ readonly observeDiskContent: (uri: string, content: string) => boolean;
293
+ /** Send one save for an externally observed disk change. */
294
+ readonly noteExternalDiskChange: (uri: string, content: string) => void;
293
295
  readonly clearFile: (uri: string) => void;
294
296
  readonly invalidateEvidence: (uri: string) => void;
295
297
  readonly markUnversionedSyncMoment: (uri: string) => void;
@@ -343,9 +345,10 @@ function prepareRefreshDocuments(
343
345
  nextVersion: (uri) => nextDocumentVersion(options.versionHistory, uri),
344
346
  nextSynchronizationId: options.nextSynchronizationId,
345
347
  evidenceRevision,
348
+ documentSync: options.host.documentSync(),
346
349
  noteInputContentChange: options.noteInputContentChange,
347
350
  observeDiskContent: options.observeDiskContent,
348
- incrementalSync: options.host.usesIncrementalDocumentSync(),
351
+ noteExternalDiskChange: options.noteExternalDiskChange,
349
352
  sendNotification: (method, params) => options.host.sendNotification(method, params),
350
353
  uriToFile,
351
354
  preloadedContent: classification.preloadedContent,
@@ -1,4 +1,5 @@
1
1
  // biome-ignore-all lint/style/noExcessiveLinesPerFile: one client's document sync, diagnostics, refresh, and sync-file flow stay in one cohesive class.
2
+ import { readFileSync } from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import {
4
5
  type CodeQueryResult,
@@ -116,6 +117,8 @@ export class ClientDiagnostics {
116
117
  isOperational: () => this.host.isOperational(),
117
118
  getOpenDocuments: () => this.#getSemanticInputDocuments(),
118
119
  applyDocumentUpdates: (updates) => this.#applySemanticInputUpdates(updates),
120
+ noteExternalDiskChange: (filePath, content) =>
121
+ this.#noteExternalDiskChange(filePath, content),
119
122
  closeMissingDocument: (filePath) => this.didClose(filePath),
120
123
  markUnreadableDocument: (filePath) => this.markFailedFile(filePath),
121
124
  });
@@ -200,7 +203,7 @@ export class ClientDiagnostics {
200
203
  nextVersion: () => nextDocumentVersion(this.#versionHistory, uri),
201
204
  nextSynchronizationId: () => ++this.#nextSynchronizationId,
202
205
  evidenceRevision: this.#evidenceRevision,
203
- incrementalSync: this.host.usesIncrementalDocumentSync(),
206
+ documentSync: this.host.documentSync(),
204
207
  waiters: this.#waiters,
205
208
  sendNotification: (method, params) => this.host.sendNotification(method, params),
206
209
  markUnversionedSyncMoment: () => this.#unversionedPushSyncMoments.set(uri, Date.now()),
@@ -213,10 +216,6 @@ export class ClientDiagnostics {
213
216
  this.#inputBarrier.initializeDocumentContent(uri, content);
214
217
  }
215
218
 
216
- #observeDiskContent(uri: string, content: string): void {
217
- this.#inputBarrier.observeDiskContent(uri, content);
218
- }
219
-
220
219
  #forgetDocumentContent(uri: string): void {
221
220
  this.#inputBarrier.forgetDocumentContent(uri);
222
221
  }
@@ -333,6 +332,38 @@ export class ClientDiagnostics {
333
332
  this.#synchronizeTrackedDocument(uri, filePath, content, doc);
334
333
  }
335
334
 
335
+ /** Send a save after a disk change that the input barrier observed. */
336
+ #noteExternalDiskChange(filePath: string, content: string): void {
337
+ if (!this.host.isOperational()) return;
338
+ const documentSync = this.host.documentSync();
339
+ if (!documentSync.save) return;
340
+ this.host.sendNotification("textDocument/didSave", {
341
+ textDocument: { uri: fileToUri(filePath) },
342
+ ...(documentSync.includeText ? { text: content } : {}),
343
+ });
344
+ }
345
+
346
+ /** Synchronize and notify one tracked document after an observed disk write. */
347
+ noteWorkspaceWrite(filePath: string): void {
348
+ if (!this.host.isOperational()) return;
349
+
350
+ const uri = fileToUri(filePath);
351
+ const document = this.#openDocs.get(uri);
352
+ if (!document) return;
353
+
354
+ let content: string;
355
+ try {
356
+ content = readFileSync(filePath, "utf-8");
357
+ } catch {
358
+ return;
359
+ }
360
+ const diskChanged = this.#inputBarrier.observeDiskContent(uri, content);
361
+ if (document.contentFingerprint !== fingerprintDocumentContent(content)) {
362
+ this.didChange(filePath, content);
363
+ }
364
+ if (diskChanged) this.#noteExternalDiskChange(filePath, content);
365
+ }
366
+
336
367
  didClose(filePath: string): void {
337
368
  const uri = fileToUri(filePath);
338
369
  const wasOpen = this.#openDocs.has(uri);
@@ -527,7 +558,9 @@ export class ClientDiagnostics {
527
558
  isRelatedUriTracked: (uri) => this.#openDocs.has(uri) || this.#versionHistory.has(uri),
528
559
  nextSynchronizationId: () => ++this.#nextSynchronizationId,
529
560
  noteInputContentChange: () => this.#noteInputContentChange(),
530
- observeDiskContent: (uri, content) => this.#observeDiskContent(uri, content),
561
+ observeDiskContent: (uri, content) => this.#inputBarrier.observeDiskContent(uri, content),
562
+ noteExternalDiskChange: (uri, content) =>
563
+ this.#noteExternalDiskChange(uriToFile(uri), content),
531
564
  invalidateEvidence: (uri) => {
532
565
  this.#advanceInputRevision(false, true, "failure");
533
566
  this.#cancelDiagnosticRequest(uri);