Skip to content

Commit 4d3ce2e

Browse files
committed
feat(language_server): autodetect root .oxfmtrc.json (#14466)
> This PR implements autodetection of the root .oxfmtrc.json configuration file for the language server's formatter functionality. The language server can now automatically discover and use formatter configuration files located at the workspace root. It does not support a custom config path, the next PR in this stack will implement it. When a user does change the content of `.oxfmtrc.json`, he needs to restart the server at the moment. Need to improve the file watcher implementation, created another stack with tests for current implementation.
1 parent 2c228ae commit 4d3ce2e

File tree

8 files changed

+76
-14
lines changed

8 files changed

+76
-14
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"semicolons": "as-needed"
3+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// semicolon is on character 8-9, which will be removed
2+
debugger;

crates/oxc_language_server/src/formatter/server_formatter.rs

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
1+
use std::path::Path;
2+
3+
use log::warn;
14
use oxc_allocator::Allocator;
25
use oxc_data_structures::rope::{Rope, get_line_column};
3-
use oxc_formatter::{FormatOptions, Formatter, get_supported_source_type};
6+
use oxc_formatter::{FormatOptions, Formatter, Oxfmtrc, get_supported_source_type};
47
use oxc_parser::{ParseOptions, Parser};
58
use tower_lsp_server::{
69
UriExt,
710
lsp_types::{Position, Range, TextEdit, Uri},
811
};
912

10-
pub struct ServerFormatter;
13+
use crate::FORMAT_CONFIG_FILE;
14+
15+
pub struct ServerFormatter {
16+
options: FormatOptions,
17+
}
1118

1219
impl ServerFormatter {
13-
pub fn new() -> Self {
14-
Self {}
20+
pub fn new(root_uri: &Uri) -> Self {
21+
let root_path = root_uri.to_file_path().unwrap();
22+
23+
Self { options: Self::get_format_options(&root_path) }
1524
}
1625

17-
#[expect(clippy::unused_self)]
1826
pub fn run_single(&self, uri: &Uri, content: Option<String>) -> Option<Vec<TextEdit>> {
1927
let path = uri.to_file_path()?;
2028
let source_type = get_supported_source_type(&path)?;
@@ -46,8 +54,7 @@ impl ServerFormatter {
4654
return None;
4755
}
4856

49-
let options = FormatOptions::default();
50-
let code = Formatter::new(&allocator, options).build(&ret.program);
57+
let code = Formatter::new(&allocator, self.options.clone()).build(&ret.program);
5158

5259
// nothing has changed
5360
if code == source_text {
@@ -67,6 +74,33 @@ impl ServerFormatter {
6774
replacement.to_string(),
6875
)])
6976
}
77+
78+
fn get_format_options(root_path: &Path) -> FormatOptions {
79+
let config_path = FORMAT_CONFIG_FILE;
80+
let config = root_path.join(config_path); // normalize_path when supporting `oxc.fmt.configPath`
81+
let oxfmtrc = if config.try_exists().is_ok_and(|exists| exists) {
82+
if let Ok(oxfmtrc) = Oxfmtrc::from_file(&config) {
83+
oxfmtrc
84+
} else {
85+
warn!("Failed to initialize oxfmtrc config: {}", config.to_string_lossy());
86+
Oxfmtrc::default()
87+
}
88+
} else {
89+
warn!(
90+
"Config file not found: {}, fallback to default config",
91+
config.to_string_lossy()
92+
);
93+
Oxfmtrc::default()
94+
};
95+
96+
match oxfmtrc.into_format_options() {
97+
Ok(options) => options,
98+
Err(err) => {
99+
warn!("Failed to parse oxfmtrc config: {err}, fallback to default config");
100+
FormatOptions::default()
101+
}
102+
}
103+
}
70104
}
71105

72106
/// Returns the minimal text edit (start, end, replacement) to transform `source_text` into `formatted_text`
@@ -201,4 +235,10 @@ mod tests {
201235
Tester::new("fixtures/formatter/basic", Some(FormatOptions { experimental: true }))
202236
.format_and_snapshot_single_file("basic.ts");
203237
}
238+
239+
#[test]
240+
fn test_root_config_detection() {
241+
Tester::new("fixtures/formatter/root_config", Some(FormatOptions { experimental: true }))
242+
.format_and_snapshot_single_file("semicolons-as-needed.ts");
243+
}
204244
}

crates/oxc_language_server/src/linter/config_walker.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::{
66

77
use ignore::DirEntry;
88

9-
use crate::OXC_CONFIG_FILE;
9+
use crate::LINT_CONFIG_FILE;
1010

1111
pub struct ConfigWalker {
1212
inner: ignore::WalkParallel,
@@ -56,7 +56,7 @@ impl WalkCollector {
5656
}
5757
let Some(file_name) = entry.path().file_name() else { return false };
5858

59-
file_name == OXC_CONFIG_FILE
59+
file_name == LINT_CONFIG_FILE
6060
}
6161
}
6262

crates/oxc_language_server/src/linter/server_linter.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use crate::linter::{
2121
options::{LintOptions as LSPLintOptions, Run},
2222
tsgo_linter::TsgoLinter,
2323
};
24-
use crate::{ConcurrentHashMap, OXC_CONFIG_FILE};
24+
use crate::{ConcurrentHashMap, LINT_CONFIG_FILE};
2525

2626
use super::config_walker::ConfigWalker;
2727

@@ -90,7 +90,7 @@ impl ServerLinter {
9090
let mut nested_ignore_patterns = Vec::new();
9191
let (nested_configs, mut extended_paths) =
9292
Self::create_nested_configs(&root_path, options, &mut nested_ignore_patterns);
93-
let config_path = options.config_path.as_ref().map_or(OXC_CONFIG_FILE, |v| v);
93+
let config_path = options.config_path.as_ref().map_or(LINT_CONFIG_FILE, |v| v);
9494
let config = normalize_path(root_path.join(config_path));
9595
let oxlintrc = if config.try_exists().is_ok_and(|exists| exists) {
9696
if let Ok(oxlintrc) = Oxlintrc::from_file(&config) {

crates/oxc_language_server/src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ use crate::backend::Backend;
1717

1818
type ConcurrentHashMap<K, V> = papaya::HashMap<K, V, FxBuildHasher>;
1919

20-
const OXC_CONFIG_FILE: &str = ".oxlintrc.json";
20+
const LINT_CONFIG_FILE: &str = ".oxlintrc.json";
21+
const FORMAT_CONFIG_FILE: &str = ".oxfmtrc.json";
2122

2223
#[tokio::main]
2324
async fn main() {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
source: crates/oxc_language_server/src/formatter/tester.rs
3+
---
4+
========================================
5+
File: fixtures/formatter/root_config/semicolons-as-needed.ts
6+
========================================
7+
Range: Range {
8+
start: Position {
9+
line: 1,
10+
character: 8,
11+
},
12+
end: Position {
13+
line: 1,
14+
character: 9,
15+
},
16+
}

crates/oxc_language_server/src/worker.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ impl WorkspaceWorker {
7171
*self.server_linter.write().await = Some(ServerLinter::new(&self.root_uri, &options.lint));
7272
if options.format.experimental {
7373
debug!("experimental formatter enabled");
74-
*self.server_formatter.write().await = Some(ServerFormatter::new());
74+
*self.server_formatter.write().await = Some(ServerFormatter::new(&self.root_uri));
7575
}
7676
}
7777

@@ -341,7 +341,7 @@ impl WorkspaceWorker {
341341
if current_option.format.experimental != changed_options.format.experimental {
342342
if changed_options.format.experimental {
343343
debug!("experimental formatter enabled");
344-
*self.server_formatter.write().await = Some(ServerFormatter::new());
344+
*self.server_formatter.write().await = Some(ServerFormatter::new(&self.root_uri));
345345
formatting = true;
346346
} else {
347347
debug!("experimental formatter disabled");

0 commit comments

Comments
 (0)