Skip to content

lsp: implement textDocument/formatting#111

Open
bneiswander wants to merge 19 commits into
purefunctor:mainfrom
bneiswander:lsp-formatting-provider
Open

lsp: implement textDocument/formatting#111
bneiswander wants to merge 19 commits into
purefunctor:mainfrom
bneiswander:lsp-formatting-provider

Conversation

@bneiswander

Copy link
Copy Markdown
Contributor

Summary

This PR adds opt-in LSP document formatting support to purescript-analyzer.

  • Implements textDocument/formatting by running an external formatter command over stdin/stdout.
  • Advertises documentFormattingProvider only when --format-command is provided.
  • Parses --format-command using shell-style quoting (and tolerates an extra outer quoted string) to make editor configuration reliable.

User-Facing Behavior

  • By default, formatting is disabled and the server does not advertise formatting capability.
  • When started with --format-command <cmd...>, the server will respond to textDocument/formatting requests.

Configuration

Examples:

purescript-analyzer --format-command "purs-tidy format"

Quoted args are supported, for example:

purescript-analyzer --format-command "/usr/bin/tr 'a-z' 'A-Z'"

If your editor passes an extra layer of quoting (for example "purs-tidy format" as a single string), the server tolerates it.

Implementation Notes

  • The handler returns a single full-document TextEdit when the formatter output differs.
  • If formatter output is byte-for-byte identical to the input, the handler returns an empty edit list.
  • Formatter failures are returned as LSP request failures with details.

Testing

  • cargo test -p purescript-analyzer --tests

Covered scenarios:

  • Capability advertised only when configured.
  • Full-document edit behavior.
  • No-op formatting returns no edits.
  • Quoted argument parsing and outer-quote tolerance.

@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fbb575e0-0d69-48e1-a0c7-7c8063d40f2f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds optional external document formatter support to the PureScript LSP server. Users set --format-command; the server advertises formatting only when configured, spawns the parsed-shell-args formatter, pipes document text to stdin, and returns formatted stdout as a full-document TextEdit or no edits when unchanged.

Changes

External Formatter Integration

Layer / File(s) Summary
CLI Configuration
compiler-bin/src/cli.rs
Adds format_command: Option<String> field to Config and the --format-command flag with help describing stdin/stdout formatter usage.
External Dependency
compiler-bin/Cargo.toml
Adds shlex = "1.3.0" dependency for parsing shell-quoted formatter command arguments.
Command utilities and timeout helpers
compiler-bin/src/lsp/command.rs
Adds split, piped, write_stdin, wait_with_output_timeout, associated error/cleanup types, and unit tests for quoting behaviour and parsing.
State snapshot and imports
compiler-bin/src/lsp.rs
Adds mod command;, imports (Duration, rowan::TextSize), includes config in StateSnapshot, switches initialization to command::split, and computes/advertises formatting capability from config.format_command.
LSP Formatting Implementation and tests
compiler-bin/src/lsp.rs
Implements formatting handler that parses the configured command, spawns a piped subprocess, writes document content to stdin, enforces a timeout, validates exit status, returns empty edits when unchanged or a single full-document TextEdit when changed, registers the handler, and adds unit tests covering capability gating, edits/no-op, and quoted-command parsing.
Error Handling
compiler-bin/src/lsp/error.rs
Adds FormattingFailed(String) variant to LspError and updates message() to return the embedded formatter message.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • purefunctor

Poem

I’m a rabbit with shlex on my mind,
Quotes untangled, args aligned,
Stdin whispers, stdout sings,
PureScript blossoms from formatter strings,
Hopping edits home with joyful hops. 🐇

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly relates to the changeset, detailing LSP formatting support implementation with configuration examples and testing approach.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
compiler-bin/src/lsp.rs (1)

119-140: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Treat empty --format-command as “disabled”.

Line 119 enables formatting on Some(""), but formatting requests then fail later as empty command. Gate capability on non-empty trimmed content.

Suggested patch
-    let formatting_enabled = state.config.format_command.is_some();
+    let formatting_enabled = state
+        .config
+        .format_command
+        .as_deref()
+        .is_some_and(|cmd| !cmd.trim().is_empty());
-    let Some(format_command) = snapshot.config.format_command.as_deref() else {
+    let Some(format_command) = snapshot
+        .config
+        .format_command
+        .as_deref()
+        .map(str::trim)
+        .filter(|cmd| !cmd.is_empty())
+    else {
         return Ok(None);
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@compiler-bin/src/lsp.rs` around lines 119 - 140, The capability for document
formatting currently treats Some("") as enabled; update the check that sets
formatting_enabled so it only returns true for a Some value whose trimmed string
is non-empty (inspect state.config.format_command, e.g. via as_ref() and trim()
or as_deref()), and keep using formatting_enabled for
document_formatting_provider and any other gating; this ensures empty or
whitespace-only format_command is treated as disabled.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@compiler-bin/src/lsp.rs`:
- Around line 662-777: The tests call hard-coded Unix paths (e.g., "/bin/cat",
"/usr/bin/tr") via base_config and thus fail on non-Unix systems; make them
OS-portable by marking these tests as Unix-only: add #[cfg(unix)] above each
test fn (formatting_returns_full_document_edit,
formatting_noop_returns_empty_edits, formatting_parses_quoted_args,
formatting_tolerates_outer_quoted_command_string) or, alternatively, change the
base_config calls to pass portable command names found on PATH (e.g., "cat" or
"tr") instead of absolute paths; update the mk_state_with/base_config usage
accordingly so CI on non-Unix platforms skips or uses a portable command.
- Around line 362-375: The formatter child process started via cmd.spawn
(variable child) can hang; wrap the blocking wait_with_output step with a
bounded timeout: after writing input to child.stdin, poll child.try_wait in a
loop with a small sleep (or use a wait_timeout helper) up to a configurable
duration, and if the timeout expires call child.kill() and child.wait() to reap
it, then return an LspError::FormattingFailed indicating timeout; ensure any
errors from kill/wait are mapped into the same error path and preserve context
(include format_command/input context) when replacing the current
wait_with_output map_err handling.

---

Outside diff comments:
In `@compiler-bin/src/lsp.rs`:
- Around line 119-140: The capability for document formatting currently treats
Some("") as enabled; update the check that sets formatting_enabled so it only
returns true for a Some value whose trimmed string is non-empty (inspect
state.config.format_command, e.g. via as_ref() and trim() or as_deref()), and
keep using formatting_enabled for document_formatting_provider and any other
gating; this ensures empty or whitespace-only format_command is treated as
disabled.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e557566d-ca13-49fa-a760-029c7e181425

📥 Commits

Reviewing files that changed from the base of the PR and between ec2d487 and 71855a9.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • compiler-bin/Cargo.toml
  • compiler-bin/src/cli.rs
  • compiler-bin/src/lsp.rs
  • compiler-bin/src/lsp/error.rs

Comment thread compiler-bin/src/lsp.rs Outdated
Comment thread compiler-bin/src/lsp.rs
@bneiswander bneiswander force-pushed the lsp-formatting-provider branch from 39ba571 to aad3419 Compare May 12, 2026 15:32
@bneiswander bneiswander changed the title Lsp formatting provider lsp: implement formatting provider May 15, 2026
@bneiswander bneiswander changed the title lsp: implement formatting provider lsp: implement textDocument/formatting May 15, 2026

@purefunctor purefunctor left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some small comments before this can be merged

Comment thread compiler-bin/src/lsp/error.rs Outdated
Comment thread compiler-bin/src/lsp.rs Outdated
Comment thread compiler-bin/src/lsp.rs
Comment on lines +632 to +831
#[cfg(test)]
mod tests {
use super::*;

use async_lsp::lsp_types::{
ClientCapabilities, DocumentFormattingParams, InitializeParams, Position,
TextDocumentIdentifier, Url, WorkspaceFolder,
};

fn mk_state_with(config: cli::Config) -> State {
// ClientSocket isn't used by initialize/formatting logic in tests.
let client = ClientSocket::new_closed();
State::new(Arc::new(config), client)
}

fn mk_init_params(root: &std::path::Path) -> InitializeParams {
InitializeParams {
process_id: None,
root_path: None,
root_uri: None,
initialization_options: None,
capabilities: ClientCapabilities::default(),
trace: None,
workspace_folders: Some(vec![WorkspaceFolder {
uri: Url::from_file_path(root).unwrap(),
name: "workspace".to_string(),
}]),
work_done_progress_params: WorkDoneProgressParams::default(),
client_info: None,
locale: None,
}
}

fn base_config(format_command: Option<String>) -> cli::Config {
cli::Config {
stdio: true,
log_file: false,
query_log: tracing::level_filters::LevelFilter::OFF,
lsp_log: tracing::level_filters::LevelFilter::INFO,
checking_log: tracing::level_filters::LevelFilter::OFF,
source_command: None,
format_command,
diagnostics_on_open: true,
diagnostics_on_save: true,
diagnostics_on_change: false,
}
}

#[tokio::test]
async fn formatting_capability_not_advertised_without_flag() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut state = mk_state_with(base_config(None));

let initialize_params = mk_init_params(root);
let res = initialize(
&mut state,
extension::CustomInitializeParams { initialize_params, work_done_token: None },
)
.await
.unwrap();

assert!(res.capabilities.document_formatting_provider.is_none());
}

#[tokio::test]
async fn formatting_capability_advertised_with_flag() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
// Capability advertising doesn't execute the formatter; any non-empty value should enable it.
let mut state = mk_state_with(base_config(Some("purs-tidy".to_string())));

let initialize_params = mk_init_params(root);
let res = initialize(
&mut state,
extension::CustomInitializeParams { initialize_params, work_done_token: None },
)
.await
.unwrap();

assert!(res.capabilities.document_formatting_provider.is_some());
}

#[cfg(unix)]
#[test]
fn formatting_returns_full_document_edit() {
let mut state = mk_state_with(base_config(Some("tr a-z A-Z".to_string())));

let uri = Url::parse("file:///test/Main.purs").unwrap();
let input = "module Main where\nfoo = bar\n";
on_change(&mut state, uri.as_str(), input).unwrap();

let snapshot = StateSnapshot {
client: state.client.clone(),
config: Arc::clone(&state.config),
engine: state.engine.snapshot(),
files: Arc::clone(&state.files),
workspace_symbols_cache: Arc::clone(&state.workspace_symbols_cache),
suggestions_cache: Arc::clone(&state.suggestions_cache),
};

let p = DocumentFormattingParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
options: FormattingOptions {
tab_size: 2,
insert_spaces: true,
..FormattingOptions::default()
},
work_done_progress_params: WorkDoneProgressParams::default(),
};

let edits = formatting(snapshot, p).unwrap().unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "MODULE MAIN WHERE\nFOO = BAR\n");
assert_eq!(edits[0].range.start, Position::new(0, 0));
}

#[cfg(unix)]
#[test]
fn formatting_noop_returns_empty_edits() {
let mut state = mk_state_with(base_config(Some("cat".to_string())));

let uri = Url::parse("file:///test/Main.purs").unwrap();
let input = "module Main where\nfoo = bar\n";
on_change(&mut state, uri.as_str(), input).unwrap();

let snapshot = StateSnapshot {
client: state.client.clone(),
config: Arc::clone(&state.config),
engine: state.engine.snapshot(),
files: Arc::clone(&state.files),
workspace_symbols_cache: Arc::clone(&state.workspace_symbols_cache),
suggestions_cache: Arc::clone(&state.suggestions_cache),
};

let p = DocumentFormattingParams {
text_document: TextDocumentIdentifier { uri },
options: FormattingOptions::default(),
work_done_progress_params: WorkDoneProgressParams::default(),
};

let edits = formatting(snapshot, p).unwrap().unwrap();
assert!(edits.is_empty());
}

#[cfg(unix)]
#[test]
fn formatting_parses_quoted_args() {
let mut state = mk_state_with(base_config(Some("tr 'a-z' 'A-Z'".to_string())));

let uri = Url::parse("file:///test/Main.purs").unwrap();
let input = "module Main where\nfoo = bar\n";
on_change(&mut state, uri.as_str(), input).unwrap();

let snapshot = StateSnapshot {
client: state.client.clone(),
config: Arc::clone(&state.config),
engine: state.engine.snapshot(),
files: Arc::clone(&state.files),
workspace_symbols_cache: Arc::clone(&state.workspace_symbols_cache),
suggestions_cache: Arc::clone(&state.suggestions_cache),
};

let p = DocumentFormattingParams {
text_document: TextDocumentIdentifier { uri },
options: FormattingOptions::default(),
work_done_progress_params: WorkDoneProgressParams::default(),
};

let edits = formatting(snapshot, p).unwrap().unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "MODULE MAIN WHERE\nFOO = BAR\n");
}

#[cfg(unix)]
#[test]
fn formatting_tolerates_outer_quoted_command_string() {
let mut state = mk_state_with(base_config(Some("\"tr a-z A-Z\"".to_string())));

let uri = Url::parse("file:///test/Main.purs").unwrap();
let input = "module Main where\nfoo = bar\n";
on_change(&mut state, uri.as_str(), input).unwrap();

let snapshot = StateSnapshot {
client: state.client.clone(),
config: Arc::clone(&state.config),
engine: state.engine.snapshot(),
files: Arc::clone(&state.files),
workspace_symbols_cache: Arc::clone(&state.workspace_symbols_cache),
suggestions_cache: Arc::clone(&state.suggestions_cache),
};

let p = DocumentFormattingParams {
text_document: TextDocumentIdentifier { uri },
options: FormattingOptions::default(),
work_done_progress_params: WorkDoneProgressParams::default(),
};

let edits = formatting(snapshot, p).unwrap().unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "MODULE MAIN WHERE\nFOO = BAR\n");
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could these be made part of tests-integration?

@codecov

codecov Bot commented May 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.80776% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.59%. Comparing base (8c514a0) to head (6a53a87).
⚠️ Report is 44 commits behind head on main.

Files with missing lines Patch % Lines
compiler-bin/src/lsp/command.rs 93.52% 8 Missing and 1 partial ⚠️
compiler-bin/src/lsp.rs 97.75% 1 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #111      +/-   ##
==========================================
- Coverage   83.35%   82.59%   -0.76%     
==========================================
  Files         132      140       +8     
  Lines       24024    25173    +1149     
  Branches    24024    25173    +1149     
==========================================
+ Hits        20025    20792     +767     
- Misses       2183     2543     +360     
- Partials     1816     1838      +22     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@bneiswander bneiswander force-pushed the lsp-formatting-provider branch from 44abe78 to 90abda5 Compare May 18, 2026 13:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@compiler-bin/src/lsp/command.rs`:
- Around line 84-86: cleanup_child currently calls child.wait() which can block
indefinitely; replace that with a bounded wait_timeout (e.g.,
Duration::from_secs(1)) and set ChildCleanup.wait_error based on the
wait_timeout outcome: if wait_timeout returns Ok(Some(status)) keep wait_error
as None, if Ok(None) treat it as a timeout and set wait_error to an appropriate
error, and if Err(e) set wait_error to that error. Update the function
cleanup_child and its usage of ChildCleanup so the kill_error still records
child.kill().err() and wait_error reflects the timed wait result (use
wait_timeout on the Child with ~1s).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f67ca95e-57ce-4a8f-94e2-fa6e726f9373

📥 Commits

Reviewing files that changed from the base of the PR and between 44abe78 and 90abda5.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • compiler-bin/Cargo.toml
  • compiler-bin/src/cli.rs
  • compiler-bin/src/lsp.rs
  • compiler-bin/src/lsp/command.rs
  • compiler-bin/src/lsp/error.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • compiler-bin/src/lsp/error.rs
  • compiler-bin/Cargo.toml
  • compiler-bin/src/cli.rs
  • compiler-bin/src/lsp.rs

Comment thread compiler-bin/src/lsp/command.rs
@bneiswander bneiswander force-pushed the lsp-formatting-provider branch from f657b51 to f0dbb1f Compare May 18, 2026 15:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@compiler-bin/src/lsp/formatting.rs`:
- Around line 355-365: The test exited_error_with_stdout_only_omits_newline uses
the shell command "sh" which is Unix-only; annotate the test with a Unix-only
cfg attribute (e.g. add #[cfg(unix)] above the fn
exited_error_with_stdout_only_omits_newline or use #[cfg(target_family =
"unix")]) so it only runs on Unix platforms; keep the test body unchanged and
ensure the attribute is placed immediately above the test function (and keep any
existing #[test] attribute alongside the cfg).
- Around line 26-27: When command::write_stdin(&mut child, input) returns an Err
and you construct FormattingError::WriteStdin, first ensure the spawned
formatter process (child) is reaped: attempt to kill the child (child.kill()) if
necessary and then wait on it (child.wait() or child.wait_with_output()) to
avoid leaving a zombie; perform these cleanup steps before returning the mapped
FormattingError (FormattingErrorKind::WriteStdin { source }). Make the cleanup
best-effort (ignore kill/wait errors but still return the original write error)
and keep this logic adjacent to the existing mapping so it executes when
write_stdin fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 363410e7-7483-47a9-be2a-11cf2dc0c042

📥 Commits

Reviewing files that changed from the base of the PR and between 44abe78 and f0dbb1f.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • compiler-bin/Cargo.toml
  • compiler-bin/src/cli.rs
  • compiler-bin/src/lsp.rs
  • compiler-bin/src/lsp/command.rs
  • compiler-bin/src/lsp/error.rs
  • compiler-bin/src/lsp/formatting.rs
  • justfile
  • tests-compatibility/src/lib.rs
  • tests-integration/Cargo.toml
✅ Files skipped from review due to trivial changes (3)
  • tests-integration/Cargo.toml
  • compiler-bin/Cargo.toml
  • justfile
🚧 Files skipped from review as they are similar to previous changes (2)
  • compiler-bin/src/lsp/error.rs
  • compiler-bin/src/cli.rs

Comment thread compiler-bin/src/lsp/formatting.rs Outdated
Comment thread compiler-bin/src/lsp/formatting.rs
@bneiswander bneiswander requested a review from purefunctor May 18, 2026 20:15
@bneiswander bneiswander force-pushed the lsp-formatting-provider branch from 19a9cc8 to 6a53a87 Compare May 20, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants