fix(oxfmt/lsp): reload .prettierignore on watched file change (#27129)

It was not updated when `.prettierignore` changed.
This commit is contained in:
leaysgur committed 2026-09-28 13:42:12 +00:00
1 parent 30eb4635ef
commit e9b2ec58e2
4 files changed
+88 -36

No files matched your search

+46 -35
View File
@@ -63,19 +63,6 @@ impl ServerFormatterBuilder {
// Resolve workspace-level concerns only here.
// Per-file config resolution is deferred to format time.
// NOTE: `.gitignore` is intentionally NOT included here.
// An LSP document is explicitly formatted by the user.
// (CLI also formats explicitly specified git ignored files.)
let ignore_matchers = match resolve_ignore_paths(&root_path, &[])
.and_then(|paths| build_global_ignore_matchers(&root_path, &[], &paths))
{
Ok(matchers) => matchers,
Err(err) => {
warn!("Failed to load .prettierignore: {err}, proceeding without ignore globs");
vec![]
}
};
// If `configPath` is explicitly set, load it eagerly as the single config for all files.
let use_nested_config = options.use_nested_configs();
let explicit_config_path = options.config_path.as_ref().map(PathBuf::from);
@@ -95,7 +82,6 @@ impl ServerFormatterBuilder {
root_path.to_path_buf(),
source_formatter,
JsConfigLoaderCb::clone(&self.js_config_loader),
ignore_matchers,
explicit_config_path,
use_nested_config,
),
@@ -125,8 +111,6 @@ pub struct ServerFormatter {
root_path: PathBuf,
source_formatter: SourceFormatter,
js_config_loader: JsConfigLoaderCb,
/// `.prettierignore` matchers (workspace-level, shared across all scopes).
ignore_matchers: Vec<Gitignore>,
/// Explicit `fmt.configPath` from LSP settings.
/// When set, all files use this single config.
explicit_config_path: Option<PathBuf>,
@@ -139,7 +123,14 @@ pub struct ServerFormatter {
/// without blocking concurrent format requests:
/// in-flight readers clone the `Arc` and continue using the old snapshot,
/// while subsequent reads see the new one.
state: RwLock<Arc<ConfigScopes>>,
state: RwLock<Arc<FormatterState>>,
}
/// Per-rebuild snapshot of everything read from workspace files.
struct FormatterState {
scopes: ConfigScopes,
/// `.prettierignore` matchers (workspace-level, shared across all scopes).
ignore_matchers: Vec<Gitignore>,
}
impl Tool for ServerFormatter {
@@ -191,6 +182,7 @@ impl Tool for ServerFormatter {
};
patterns.push(".editorconfig".to_string());
patterns.push(".prettierignore".to_string());
patterns
}
@@ -205,7 +197,7 @@ impl Tool for ServerFormatter {
//
// `NestedConfigCtx` has no per-entry invalidation API by design (its caches live as long as the `ConfigScopes`).
// Rebuilding is cheap: the ctx itself starts empty (lazy probes),
// and only the root resolver and `.editorconfig` do eager file IO.
// and only the root resolver, `.editorconfig` and `.prettierignore` do eager file IO.
// The trade-off is over-eviction, a config change in one nested dir also drops cached probes elsewhere.
// But format requests are sporadic enough that lazy re-population costs nothing observable.
let new_state = Self::build_state(
@@ -283,7 +275,6 @@ impl ServerFormatter {
root_path: PathBuf,
source_formatter: SourceFormatter,
js_config_loader: JsConfigLoaderCb,
ignore_matchers: Vec<Gitignore>,
explicit_config_path: Option<PathBuf>,
use_nested_config: bool,
) -> Self {
@@ -297,28 +288,28 @@ impl ServerFormatter {
root_path,
source_formatter,
js_config_loader,
ignore_matchers,
explicit_config_path,
use_nested_config,
state: RwLock::new(Arc::new(state)),
}
}
/// Build a fresh [`ConfigScopes`] from scratch.
/// Build a fresh [`FormatterState`] from scratch.
///
/// Called once in [`Self::new`] and again on every watched-file change.
/// `.editorconfig` is re-resolved here so add/remove events are picked up
/// `.editorconfig` and `.prettierignore` are re-resolved here so add/remove events are picked up
/// without a separate code path.
///
/// LSP must keep editing usable even when the user's config is broken,
/// so the root falls back to the default empty config with a warning instead of bubbling the error up.
/// Likewise, a broken `.prettierignore` is skipped with a warning.
fn build_state(
root_path: &Path,
explicit_config_path: Option<&Path>,
use_nested_config: bool,
js_config_loader: &JsConfigLoaderCb,
) -> ConfigScopes {
ConfigScopes::load(
) -> FormatterState {
let scopes = ConfigScopes::load(
root_path,
explicit_config_path,
use_nested_config,
@@ -327,24 +318,43 @@ impl ServerFormatter {
.unwrap_or_else(|err| {
warn!("{err}\nFalling back to default config for {}", root_path.display());
ConfigScopes::with_default_root(root_path, use_nested_config, Some(js_config_loader))
})
});
// NOTE: `.gitignore` is intentionally NOT included here.
// An LSP document is explicitly formatted by the user.
// (CLI also formats explicitly specified git ignored files.)
let ignore_matchers = resolve_ignore_paths(root_path, &[])
.and_then(|paths| build_global_ignore_matchers(root_path, &[], &paths))
.unwrap_or_else(|err| {
warn!("Failed to load .prettierignore: {err}, proceeding without ignore globs");
vec![]
});
FormatterState { scopes, ignore_matchers }
}
/// Snapshot the current state.
/// In-flight reads survive a concurrent rebuild because the old `Arc` keeps the previous snapshot alive.
fn snapshot(&self) -> Arc<FormatterState> {
Arc::clone(&self.state.read().expect("state rwlock poisoned"))
}
/// Resolve config and format a file at the given path.
/// Returns `None` if the file is unsupported or ignored.
fn resolve_and_format(&self, path: &Path, source_text: &str) -> Option<FormatResult> {
// Snapshot the current state.
// In-flight reads survive a concurrent rebuild because the old `Arc` keeps the previous snapshot alive.
let state = Arc::clone(&self.state.read().expect("state rwlock poisoned"));
let resolver = match state.resolve(path) {
fn resolve_and_format(
&self,
scopes: &ConfigScopes,
path: &Path,
source_text: &str,
) -> Option<FormatResult> {
let resolver = match scopes.resolve(path) {
Ok(r) => r,
Err(err) => {
warn!(
"Failed to resolve nested config for {}: {err}, falling back to root",
path.display()
);
Arc::clone(state.root())
Arc::clone(scopes.root())
}
};
@@ -377,11 +387,12 @@ impl ServerFormatter {
}
fn format_file(&self, path: &Path, source_text: &str) -> Option<FormatResult> {
if is_ignored(&self.ignore_matchers, path, false, true) {
let state = self.snapshot();
if is_ignored(&state.ignore_matchers, path, false, true) {
debug!("File is ignored by .prettierignore: {}", path.display());
return None;
}
self.resolve_and_format(path, source_text)
self.resolve_and_format(&state.scopes, path, source_text)
}
fn format_in_memory(
@@ -395,7 +406,7 @@ impl ServerFormatter {
debug!("Unsupported language id for in-memory formatting: {language_id:?}");
return None;
};
self.resolve_and_format(&path, source_text)
self.resolve_and_format(&self.snapshot().scopes, &path, source_text)
}
}
+26
View File
@@ -1,3 +1,5 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
@@ -135,6 +137,30 @@ describe("LSP formatting", () => {
expect(await formatFixture(FIXTURES_DIR, path, "typescript", client)).toMatchSnapshot();
});
it("should reload `.prettierignore` on watched file change", async () => {
const dir = await fs.mkdtemp(join(tmpdir(), "oxfmt-lsp-prettierignore-"));
try {
const filePath = join(dir, "a.ts");
await fs.writeFile(filePath, "const x = 1\n");
const rootUri = pathToFileURL(dir).href;
const fileUri = pathToFileURL(filePath).href;
await using client = createLspConnection();
await client.initialize([{ uri: rootUri, name: "test" }], {}, [
{ workspaceUri: rootUri, options: null },
]);
await client.didOpen(fileUri, "typescript", "const x = 1\n");
expect(await client.format(fileUri)).not.toHaveLength(0);
await fs.writeFile(join(dir, ".prettierignore"), "a.ts\n");
await client.didChangeWatchedFiles([pathToFileURL(join(dir, ".prettierignore")).href]);
// No edits (`null`) once ignored
expect(await client.format(fileUri)).toBeNull();
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
});
describe("initializationOptions", () => {
+7 -1
View File
@@ -42,6 +42,7 @@ describe("LSP initialization", () => {
"**/oxfmt.config.ts",
"**/oxfmt.config.mts",
".editorconfig",
".prettierignore",
],
],
[
@@ -52,9 +53,13 @@ describe("LSP initialization", () => {
"**/oxfmt.config.ts",
"**/oxfmt.config.mts",
".editorconfig",
".prettierignore",
],
],
[{ "fmt.configPath": "./custom-config.json" }, ["custom-config.json", ".editorconfig"]],
[
{ "fmt.configPath": "./custom-config.json" },
["custom-config.json", ".editorconfig", ".prettierignore"],
],
// Vite+ mode: only `vite.config.*`, and nested configs are never discovered
[
undefined,
@@ -66,6 +71,7 @@ describe("LSP initialization", () => {
"vite.config.mts",
"vite.config.cts",
".editorconfig",
".prettierignore",
],
{ VP_VERSION: "1" },
],
+9
View File
@@ -6,9 +6,11 @@ import {
createMessageConnection,
DidChangeConfigurationNotification,
DidChangeTextDocumentNotification,
DidChangeWatchedFilesNotification,
DidOpenTextDocumentNotification,
DocumentFormattingRequest,
ExitNotification,
FileChangeType,
InitializedNotification,
InitializeRequest,
RegistrationRequest,
@@ -20,6 +22,7 @@ import {
import { TextDocument } from "vscode-languageserver-textdocument";
import type {
ClientCapabilities,
FileEvent,
Registration,
TextEdit,
} from "vscode-languageserver-protocol/node";
@@ -60,6 +63,12 @@ export function createLspConnection(env: Record<string, string> = {}) {
await connection.sendNotification(DidChangeConfigurationNotification.type, { settings });
},
async didChangeWatchedFiles(uris: string[]) {
await connection.sendNotification(DidChangeWatchedFilesNotification.type, {
changes: uris.map((uri): FileEvent => ({ uri, type: FileChangeType.Changed })),
});
},
async didOpen(uri: string, languageId: string, text: string) {
await connection.sendNotification(DidOpenTextDocumentNotification.type, {
textDocument: { uri, languageId, version: 1, text },