Skip to content

Commit ceb3311

Browse files
authored
feat(lsp): implement textDocument/formatting via hew_parser::fmt (#1614)
* feat(lsp): advertise documentFormattingProvider capability * feat(lsp): implement textDocument/formatting via hew_parser::fmt
1 parent 398f418 commit ceb3311

2 files changed

Lines changed: 138 additions & 13 deletions

File tree

hew-lsp/src/server/handlers/language_features.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ use dashmap::DashMap;
44
use tower_lsp::jsonrpc::Result;
55
use tower_lsp::lsp_types::{
66
CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams, CodeActionResponse,
7-
CompletionParams, CompletionResponse, Diagnostic, DocumentLink, DocumentLinkParams,
8-
DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, FoldingRange, FoldingRangeKind,
9-
FoldingRangeParams, Hover, HoverContents, HoverParams, InlayHint, InlayHintKind,
10-
InlayHintLabel, InlayHintParams, InlayHintTooltip, MarkupContent, MarkupKind,
7+
CompletionParams, CompletionResponse, Diagnostic, DocumentFormattingParams, DocumentLink,
8+
DocumentLinkParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, FoldingRange,
9+
FoldingRangeKind, FoldingRangeParams, Hover, HoverContents, HoverParams, InlayHint,
10+
InlayHintKind, InlayHintLabel, InlayHintParams, InlayHintTooltip, MarkupContent, MarkupKind,
1111
ParameterInformation, ParameterLabel, Position, SemanticTokens, SemanticTokensParams,
1212
SemanticTokensResult, SignatureHelp, SignatureHelpParams, SignatureInformation, TextEdit, Url,
1313
WorkspaceEdit,
@@ -368,3 +368,23 @@ pub(crate) fn folding_range(
368368
.collect();
369369
non_empty(lsp_ranges)
370370
}
371+
372+
pub(crate) fn formatting(
373+
server: &HewLanguageServer,
374+
params: &DocumentFormattingParams,
375+
) -> Option<Vec<TextEdit>> {
376+
let uri = &params.text_document.uri;
377+
let doc = server.documents.get(uri)?;
378+
let formatted = hew_parser::fmt::format_source(&doc.source, &doc.parse_result.program);
379+
if formatted == doc.source {
380+
// Already canonical: signal success with an empty edit list.
381+
return Some(vec![]);
382+
}
383+
// Whole-document replacement. Use offset_range_to_lsp — not doc.source.len() as raw bytes —
384+
// so that the LSP Position::character field is UTF-16 code units, as the spec requires.
385+
let range = offset_range_to_lsp(&doc.source, &doc.line_offsets, 0, doc.source.len());
386+
Some(vec![TextEdit {
387+
range,
388+
new_text: formatted,
389+
}])
390+
}

hew-lsp/src/server/mod.rs

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,15 @@ use tower_lsp::lsp_types::{
8282
use tower_lsp::lsp_types::{
8383
CodeActionKind, CodeActionParams, CodeActionResponse, CompletionOptions, CompletionParams,
8484
CompletionResponse, Diagnostic, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
85-
DidOpenTextDocumentParams, DocumentSymbolParams, DocumentSymbolResponse, ExecuteCommandOptions,
86-
ExecuteCommandParams, FoldingRange, FoldingRangeParams, GotoDefinitionParams,
87-
GotoDefinitionResponse, Hover, HoverParams, HoverProviderCapability, InitializeParams,
88-
InitializeResult, InitializedParams, Location, MessageType, OneOf, Position,
89-
PrepareRenameResponse, Range, ReferenceParams, RenameParams, SemanticTokenModifier,
90-
SemanticTokenType, SemanticTokensFullOptions, SemanticTokensLegend, SemanticTokensOptions,
91-
SemanticTokensParams, SemanticTokensResult, SemanticTokensServerCapabilities,
92-
ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, Url,
93-
WorkDoneProgressOptions, WorkspaceEdit,
85+
DidOpenTextDocumentParams, DocumentFormattingParams, DocumentSymbolParams,
86+
DocumentSymbolResponse, ExecuteCommandOptions, ExecuteCommandParams, FoldingRange,
87+
FoldingRangeParams, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverParams,
88+
HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, Location,
89+
MessageType, OneOf, Position, PrepareRenameResponse, Range, ReferenceParams, RenameParams,
90+
SemanticTokenModifier, SemanticTokenType, SemanticTokensFullOptions, SemanticTokensLegend,
91+
SemanticTokensOptions, SemanticTokensParams, SemanticTokensResult,
92+
SemanticTokensServerCapabilities, ServerCapabilities, TextDocumentSyncCapability,
93+
TextDocumentSyncKind, TextEdit, Url, WorkDoneProgressOptions, WorkspaceEdit,
9494
};
9595
use tower_lsp::lsp_types::{DocumentLink, DocumentLinkOptions, DocumentLinkParams};
9696
use tower_lsp::lsp_types::{
@@ -237,6 +237,7 @@ fn build_server_capabilities() -> ServerCapabilities {
237237
folding_range_provider: Some(
238238
tower_lsp::lsp_types::FoldingRangeProviderCapability::Simple(true),
239239
),
240+
document_formatting_provider: Some(OneOf::Left(true)),
240241
..Default::default()
241242
}
242243
}
@@ -583,6 +584,10 @@ impl LanguageServer for HewLanguageServer {
583584
async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
584585
Ok(handlers::language_features::folding_range(self, &params))
585586
}
587+
588+
async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
589+
Ok(handlers::language_features::formatting(self, &params))
590+
}
586591
}
587592

588593
#[cfg(test)]
@@ -5804,4 +5809,104 @@ machine Traffic {
58045809
"for_each_hew_file should have visited def.hew in the real directory despite the symlink sibling"
58055810
);
58065811
}
5812+
5813+
// ── Formatting handler tests ────────────────────────────────────────
5814+
5815+
/// Source that `format_source` does not change → handler returns `Some(vec![])`.
5816+
#[test]
5817+
fn formatting_already_canonical_returns_empty_edits() {
5818+
// The formatter appends a trailing newline; use a source that is already canonical.
5819+
let source = "fn foo() -> i32 {\n 42\n}\n";
5820+
let parse_result = hew_parser::parse(source);
5821+
let formatted = hew_parser::fmt::format_source(source, &parse_result.program);
5822+
// Pre-condition: the source must actually be canonical for this test to be meaningful.
5823+
assert_eq!(
5824+
source, formatted,
5825+
"test pre-condition failed: source is not already canonical"
5826+
);
5827+
5828+
let lo = compute_line_offsets(source);
5829+
// Mimic the handler: if formatted == source, return empty edits.
5830+
let edits: Option<Vec<TextEdit>> = if formatted == source {
5831+
Some(vec![])
5832+
} else {
5833+
let range = offset_range_to_lsp(source, &lo, 0, source.len());
5834+
Some(vec![TextEdit {
5835+
range,
5836+
new_text: formatted,
5837+
}])
5838+
};
5839+
5840+
assert_eq!(
5841+
edits,
5842+
Some(vec![]),
5843+
"canonical source must yield empty edit list"
5844+
);
5845+
}
5846+
5847+
/// Source with extra whitespace → handler returns a single whole-document `TextEdit`.
5848+
#[test]
5849+
fn formatting_uncanonical_returns_single_edit() {
5850+
// Extra spaces between tokens trigger reformatting.
5851+
let source = "fn foo() -> i32 { 42 }";
5852+
let parse_result = hew_parser::parse(source);
5853+
let formatted = hew_parser::fmt::format_source(source, &parse_result.program);
5854+
assert_ne!(
5855+
source, formatted,
5856+
"test pre-condition failed: source must not be canonical"
5857+
);
5858+
5859+
let lo = compute_line_offsets(source);
5860+
let range = offset_range_to_lsp(source, &lo, 0, source.len());
5861+
let edits: Vec<TextEdit> = vec![TextEdit {
5862+
range,
5863+
new_text: formatted.clone(),
5864+
}];
5865+
5866+
assert_eq!(
5867+
edits.len(),
5868+
1,
5869+
"expected exactly one whole-document TextEdit"
5870+
);
5871+
assert_eq!(
5872+
edits[0].new_text, formatted,
5873+
"TextEdit new_text must equal format_source output"
5874+
);
5875+
}
5876+
5877+
/// Source containing a non-ASCII character — the range's end column must be
5878+
/// UTF-16 code units, not byte count. 'é' is 2 bytes in UTF-8 but 1 UTF-16
5879+
/// code unit; the byte-based end would differ from the correct UTF-16 end.
5880+
#[test]
5881+
fn formatting_unicode_source_uses_correct_range() {
5882+
// Extra spaces before and after '-> i32' trigger reformatting,
5883+
// so the handler produces a non-empty TextEdit. The trailing comment
5884+
// preserves 'é' in the formatted output so the source lengths differ
5885+
// between UTF-8 bytes and UTF-16 code units.
5886+
let source = "fn foo() -> i32 { 42 } // héllo";
5887+
let parse_result = hew_parser::parse(source);
5888+
let formatted = hew_parser::fmt::format_source(source, &parse_result.program);
5889+
assert_ne!(
5890+
source, formatted,
5891+
"test pre-condition failed: source must not be canonical"
5892+
);
5893+
5894+
let lo = compute_line_offsets(source);
5895+
let range = offset_range_to_lsp(source, &lo, 0, source.len());
5896+
5897+
// The source fits on one line. End character must be the UTF-16 length
5898+
// of that line (not the byte length). 'é' is 2 bytes but 1 UTF-16 unit,
5899+
// so byte_len > utf16_len for this source.
5900+
let byte_len = u32::try_from(source.len()).expect("test source fits in u32");
5901+
let utf16_len =
5902+
u32::try_from(source.encode_utf16().count()).expect("test source fits in u32");
5903+
assert!(
5904+
byte_len > utf16_len,
5905+
"test pre-condition: source must have byte_len ({byte_len}) > utf16_len ({utf16_len})"
5906+
);
5907+
assert_eq!(
5908+
range.end.character, utf16_len,
5909+
"end.character must be UTF-16 code units ({utf16_len}), not byte count ({byte_len})"
5910+
);
5911+
}
58075912
}

0 commit comments

Comments
 (0)