use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use rustc_hash::FxHashMap;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tower_lsp::jsonrpc::Result as RpcResult;
use tower_lsp::lsp_types::{
CodeAction, CodeActionKind, CodeActionOptions, CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability,
CodeActionResponse, CodeDescription, Diagnostic as LspDiagnostic, DiagnosticSeverity, DidChangeConfigurationParams,
DidChangeTextDocumentParams, DidChangeWatchedFilesParams, DidChangeWatchedFilesRegistrationOptions,
DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentFormattingParams,
DocumentOnTypeFormattingOptions, DocumentOnTypeFormattingParams, DocumentRangeFormattingParams, FileSystemWatcher,
GlobPattern, Hover, HoverContents, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult,
InitializedParams, MarkupContent, MarkupKind, MessageType, NumberOrString, OneOf, Position, PositionEncodingKind,
Range, Registration, ServerCapabilities, ServerInfo, TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit,
Url, WorkDoneProgressOptions, WorkspaceEdit,
};
use tower_lsp::{Client, LanguageServer, LspService, Server};
use mdwright_config::Config;
use mdwright_document::{Document, LineIndex, ParseError, ParseOptions};
use mdwright_format::{CheckpointTable, FmtOptions, format_document, format_range_with_checkpoints};
use mdwright_lint::{
Diagnostic as MdwrightDiagnostic, RuleSet, Severity as MdwrightSeverity, apply_safe_fixes, rule_doc_url, stdlib,
};
const LSP_MAX_INPUT_BYTES: usize = 10_000_000;
pub async fn serve() {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::new(MdwrightLs::new);
Server::new(stdin, stdout, socket).serve(service).await;
}
#[cfg(test)]
#[must_use]
pub(crate) fn build_service_for_tests() -> (LspService<impl LanguageServer + 'static>, tower_lsp::ClientSocket) {
LspService::new(MdwrightLs::new)
}
struct MdwrightLs {
client: Client,
state: Arc<Mutex<State>>,
}
struct State {
docs: FxHashMap<Url, OpenDoc>,
config: Arc<Config>,
root: Option<PathBuf>,
revision: u64,
utf8: bool,
}
struct OpenDoc {
text: String,
version: i32,
line_index: Arc<LineIndex>,
recognition: RecognitionState,
diagnostics: Vec<MdwrightDiagnostic>,
lint_task: Option<JoinHandle<()>>,
}
#[derive(Clone)]
enum RecognitionState {
Parsed {
document: Arc<Document>,
checkpoints: Arc<CheckpointTable>,
},
ParseFailed(ParseError),
TooLarge {
len: usize,
cap: usize,
},
}
impl RecognitionState {
fn recognize(text: &str, parse_options: ParseOptions) -> Self {
if text.len() > LSP_MAX_INPUT_BYTES {
return Self::TooLarge {
len: text.len(),
cap: LSP_MAX_INPUT_BYTES,
};
}
match Document::parse_with_options(text, parse_options) {
Ok(document) => {
let document = Arc::new(document);
let checkpoints = Arc::new(CheckpointTable::from_document(&document));
Self::Parsed { document, checkpoints }
}
Err(err) => Self::ParseFailed(err),
}
}
fn document(&self) -> Option<Arc<Document>> {
match self {
Self::Parsed { document, .. } => Some(Arc::clone(document)),
Self::ParseFailed(_) | Self::TooLarge { .. } => None,
}
}
fn parsed(&self) -> Option<(Arc<Document>, Arc<CheckpointTable>)> {
match self {
Self::Parsed { document, checkpoints } => Some((Arc::clone(document), Arc::clone(checkpoints))),
Self::ParseFailed(_) | Self::TooLarge { .. } => None,
}
}
}
impl OpenDoc {
fn new(text: String, version: i32, parse_options: ParseOptions) -> Self {
let line_index = Arc::new(LineIndex::new(&text));
let recognition = RecognitionState::recognize(&text, parse_options);
Self {
text,
version,
line_index,
recognition,
diagnostics: Vec::new(),
lint_task: None,
}
}
fn replace(&mut self, text: String, version: i32, parse_options: ParseOptions) {
self.line_index = Arc::new(LineIndex::new(&text));
self.recognition = RecognitionState::recognize(&text, parse_options);
self.text = text;
self.version = version;
if let Some(prev) = self.lint_task.take() {
prev.abort();
}
}
fn rebuild_recognition(&mut self, parse_options: ParseOptions) {
self.line_index = Arc::new(LineIndex::new(&self.text));
self.recognition = RecognitionState::recognize(&self.text, parse_options);
if let Some(prev) = self.lint_task.take() {
prev.abort();
}
}
}
impl MdwrightLs {
fn new(client: Client) -> Self {
let state = State {
docs: FxHashMap::default(),
config: Arc::new(Config::defaults()),
root: None,
revision: 0,
utf8: false,
};
Self {
client,
state: Arc::new(Mutex::new(state)),
}
}
}
#[tower_lsp::async_trait]
impl LanguageServer for MdwrightLs {
async fn initialize(&self, params: InitializeParams) -> RpcResult<InitializeResult> {
let utf8 = client_supports_utf8(¶ms);
let root = workspace_root(¶ms);
let config = Arc::new(discover_or_default(root.as_deref()));
{
let mut state = self.state.lock().await;
state.utf8 = utf8;
state.root = root;
state.config = config;
state.revision = state.revision.wrapping_add(1);
}
let position_encoding = if utf8 { Some(PositionEncodingKind::UTF8) } else { None };
let format_provider = if utf8 { Some(OneOf::Left(true)) } else { None };
let range_format_provider = if utf8 { Some(OneOf::Left(true)) } else { None };
let on_type_format_provider = if utf8 {
Some(DocumentOnTypeFormattingOptions {
first_trigger_character: "\n".into(),
more_trigger_character: None,
})
} else {
None
};
let code_action_provider = if utf8 {
Some(CodeActionProviderCapability::Options(CodeActionOptions {
code_action_kinds: Some(vec![CodeActionKind::QUICKFIX, CodeActionKind::SOURCE_FIX_ALL]),
resolve_provider: Some(false),
work_done_progress_options: WorkDoneProgressOptions::default(),
}))
} else {
None
};
Ok(InitializeResult {
capabilities: ServerCapabilities {
position_encoding,
text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
document_formatting_provider: format_provider,
document_range_formatting_provider: range_format_provider,
document_on_type_formatting_provider: on_type_format_provider,
code_action_provider,
hover_provider: Some(HoverProviderCapability::Simple(true)),
..ServerCapabilities::default()
},
server_info: Some(ServerInfo {
name: "mdwright".into(),
version: Some(env!("CARGO_PKG_VERSION").into()),
}),
})
}
async fn initialized(&self, _: InitializedParams) {
if !self.state.lock().await.utf8 {
self.client
.log_message(
MessageType::WARNING,
"mdwright LSP: client did not negotiate UTF-8 position encoding; \
formatting and code-action providers are disabled to avoid corrupting \
non-ASCII sources. Upgrade to a client that supports UTF-8 \
(VS Code 1.74+, Helix, Zed, neovim 0.10+) to enable them.",
)
.await;
}
let watchers = vec![
FileSystemWatcher {
glob_pattern: GlobPattern::String("**/.mdwright.toml".into()),
kind: None,
},
FileSystemWatcher {
glob_pattern: GlobPattern::String("**/mdwright.toml".into()),
kind: None,
},
FileSystemWatcher {
glob_pattern: GlobPattern::String("**/pyproject.toml".into()),
kind: None,
},
];
let registration = Registration {
id: "mdwright-config-watch".into(),
method: "workspace/didChangeWatchedFiles".into(),
register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).ok(),
};
let client = self.client.clone();
tokio::spawn(async move {
let _ignore = client.register_capability(vec![registration]).await;
});
}
async fn shutdown(&self) -> RpcResult<()> {
{
let mut state = self.state.lock().await;
for doc in state.docs.values_mut() {
if let Some(task) = doc.lint_task.take() {
task.abort();
}
}
}
Ok(())
}
async fn did_open(&self, params: DidOpenTextDocumentParams) {
let uri = params.text_document.uri;
let text = params.text_document.text;
let version = params.text_document.version;
let mut state = self.state.lock().await;
let parse_options = state.config.parse_options();
let doc = OpenDoc::new(text, version, parse_options);
state.docs.insert(uri.clone(), doc);
let (text, version, line_index, recognition, rules, revision) = {
let Some(entry) = state.docs.get(&uri) else { return };
(
entry.text.clone(),
entry.version,
Arc::clone(&entry.line_index),
entry.recognition.clone(),
build_rules(&state.config),
state.revision,
)
};
drop(state);
self.lint_and_publish(uri, text, version, line_index, recognition, rules, revision)
.await;
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
let uri = params.text_document.uri;
let version = params.text_document.version;
let Some(change) = params.content_changes.into_iter().next() else {
return;
};
let text = change.text;
let mut state = self.state.lock().await;
let parse_options = state.config.parse_options();
let entry = state
.docs
.entry(uri.clone())
.or_insert_with(|| OpenDoc::new(String::new(), version, parse_options));
if version < entry.version {
tracing::warn!(
uri = %uri,
incoming_version = version,
current_version = entry.version,
"ignored stale textDocument/didChange"
);
return;
}
entry.replace(text, version, parse_options);
let text_snapshot = entry.text.clone();
let line_index = Arc::clone(&entry.line_index);
let recognition = entry.recognition.clone();
let rules = build_rules(&state.config);
let revision = state.revision;
let state_handle = Arc::clone(&self.state);
let client = self.client.clone();
let uri_for_task = uri.clone();
let task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(300)).await;
run_lint_pass(
&client,
&state_handle,
uri_for_task,
text_snapshot,
version,
line_index,
recognition,
rules,
revision,
)
.await;
});
if let Some(entry) = state.docs.get_mut(&uri) {
entry.lint_task = Some(task);
}
}
async fn did_save(&self, params: DidSaveTextDocumentParams) {
let uri = params.text_document.uri;
let state = self.state.lock().await;
let Some(entry) = state.docs.get(&uri) else {
return;
};
let text = entry.text.clone();
let version = entry.version;
let line_index = Arc::clone(&entry.line_index);
let recognition = entry.recognition.clone();
let rules = build_rules(&state.config);
let revision = state.revision;
drop(state);
self.lint_and_publish(uri, text, version, line_index, recognition, rules, revision)
.await;
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
let uri = params.text_document.uri;
let mut state = self.state.lock().await;
if let Some(mut doc) = state.docs.remove(&uri)
&& let Some(task) = doc.lint_task.take()
{
task.abort();
}
drop(state);
self.client.publish_diagnostics(uri, Vec::new(), None).await;
}
async fn did_change_configuration(&self, _: DidChangeConfigurationParams) {
self.refresh_config_and_relint().await;
}
async fn did_change_watched_files(&self, _: DidChangeWatchedFilesParams) {
self.refresh_config_and_relint().await;
}
async fn formatting(&self, params: DocumentFormattingParams) -> RpcResult<Option<Vec<TextEdit>>> {
let uri = params.text_document.uri;
let state = self.state.lock().await;
if !state.utf8 {
return Ok(None);
}
let Some(doc) = state.docs.get(&uri) else {
return Ok(None);
};
let opts = state.config.fmt_options().clone();
let source = doc.text.clone();
let line_index = Arc::clone(&doc.line_index);
let Some(parsed) = doc.recognition.document() else {
tracing::warn!("formatting skipped because document is not recognised");
return Ok(None);
};
drop(state);
let formatted = format_document(&parsed, &opts);
if formatted == source {
return Ok(Some(Vec::new()));
}
let range = whole_doc_range(&line_index, &source);
Ok(Some(vec![TextEdit {
range,
new_text: formatted,
}]))
}
async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> RpcResult<Option<Vec<TextEdit>>> {
let uri = params.text_document.uri;
let lsp_range = params.range;
let state = self.state.lock().await;
if !state.utf8 {
return Ok(None);
}
let Some(doc) = state.docs.get(&uri) else {
return Ok(None);
};
let opts = state.config.fmt_options().clone();
let source = doc.text.clone();
let line_index = Arc::clone(&doc.line_index);
let Some((parsed, table)) = doc.recognition.parsed() else {
tracing::warn!("range formatting skipped because document is not recognised");
return Ok(None);
};
drop(state);
Ok(format_range_edits(
&source,
&parsed,
&line_index,
&table,
&opts,
lsp_range,
))
}
async fn on_type_formatting(&self, params: DocumentOnTypeFormattingParams) -> RpcResult<Option<Vec<TextEdit>>> {
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let state = self.state.lock().await;
if !state.utf8 {
return Ok(None);
}
let Some(doc) = state.docs.get(&uri) else {
return Ok(None);
};
let opts = state.config.fmt_options().clone();
let source = doc.text.clone();
let line_index = Arc::clone(&doc.line_index);
let Some((parsed, table)) = doc.recognition.parsed() else {
tracing::warn!("on-type formatting skipped because document is not recognised");
return Ok(None);
};
drop(state);
let zero_width = Range {
start: position,
end: position,
};
Ok(format_range_edits(
&source,
&parsed,
&line_index,
&table,
&opts,
zero_width,
))
}
async fn code_action(&self, params: CodeActionParams) -> RpcResult<Option<CodeActionResponse>> {
let uri = params.text_document.uri;
let requested_range = params.range;
let state = self.state.lock().await;
if !state.utf8 {
return Ok(None);
}
let Some(doc) = state.docs.get(&uri) else {
return Ok(None);
};
let line_index = Arc::clone(&doc.line_index);
let source = doc.text.clone();
let diags = doc.diagnostics.clone();
let parsed = doc.recognition.document();
drop(state);
let mut actions: Vec<CodeActionOrCommand> = Vec::new();
let req_start = byte_of_position(&line_index, &source, requested_range.start);
let req_end = byte_of_position(&line_index, &source, requested_range.end);
for diag in &diags {
let Some(fix) = diag.fix.as_ref() else { continue };
if !fix.safe {
continue;
}
if !overlaps(diag.span.start, diag.span.end, req_start, req_end) {
continue;
}
let Some(range) = to_lsp_range(&line_index, &source, &diag.span) else {
continue;
};
let lsp_diag = mdwright_to_lsp(&line_index, &source, diag);
let edit = WorkspaceEdit {
changes: Some(
std::iter::once((
uri.clone(),
vec![TextEdit {
range,
new_text: fix.replacement.clone(),
}],
))
.collect(),
),
..WorkspaceEdit::default()
};
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
title: format!("Fix `{}`: {}", diag.rule, diag.message),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![lsp_diag]),
edit: Some(edit),
is_preferred: Some(true),
..CodeAction::default()
}));
}
if diags.iter().any(|d| d.fix.as_ref().is_some_and(|f| f.safe)) {
let Some(parsed) = parsed else {
return Ok(if actions.is_empty() { None } else { Some(actions) });
};
let (new_text, applied) = apply_safe_fixes(&parsed, &diags);
if applied > 0 && new_text != source {
let range = whole_doc_range(&line_index, &source);
let edit = WorkspaceEdit {
changes: Some(std::iter::once((uri.clone(), vec![TextEdit { range, new_text }])).collect()),
..WorkspaceEdit::default()
};
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
title: format!("Apply all mdwright safe fixes ({applied})"),
kind: Some(CodeActionKind::SOURCE_FIX_ALL),
edit: Some(edit),
..CodeAction::default()
}));
}
}
if actions.is_empty() {
Ok(None)
} else {
Ok(Some(actions))
}
}
async fn hover(&self, params: HoverParams) -> RpcResult<Option<Hover>> {
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let state = self.state.lock().await;
let Some(doc) = state.docs.get(&uri) else {
return Ok(None);
};
let source = doc.text.clone();
let line_index = Arc::clone(&doc.line_index);
let diags = doc.diagnostics.clone();
drop(state);
let Some(cursor) = byte_of_position(&line_index, &source, position) else {
return Ok(None);
};
let Some(diag) = diags
.iter()
.find(|d| d.span.start <= cursor && cursor < d.span.end.max(d.span.start.saturating_add(1)))
else {
return Ok(None);
};
let Some(rule) = stdlib::by_name(&diag.rule) else {
return Ok(None);
};
let explain = rule.explain().trim();
let body = if explain.is_empty() {
rule.description().to_owned()
} else {
format!("**{}**\n\n{explain}\n\n<{}>", rule.name(), rule_doc_url(rule.name()))
};
Ok(Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: body,
}),
range: to_lsp_range(&line_index, &source, &diag.span),
}))
}
}
impl MdwrightLs {
async fn lint_and_publish(
&self,
uri: Url,
text: String,
version: i32,
line_index: Arc<LineIndex>,
recognition: RecognitionState,
rules: Arc<RuleSet>,
revision: u64,
) {
run_lint_pass(
&self.client,
&self.state,
uri,
text,
version,
line_index,
recognition,
rules,
revision,
)
.await;
}
async fn refresh_config_and_relint(&self) {
let root = self.state.lock().await.root.clone();
let new_config = Arc::new(discover_or_default(root.as_deref()));
let parse_options = new_config.parse_options();
let to_relint: Vec<(Url, String, i32, Arc<LineIndex>, RecognitionState)> = {
let mut state = self.state.lock().await;
state.config = Arc::clone(&new_config);
state.revision = state.revision.wrapping_add(1);
state
.docs
.iter_mut()
.map(|(uri, doc)| {
doc.rebuild_recognition(parse_options);
(
uri.clone(),
doc.text.clone(),
doc.version,
Arc::clone(&doc.line_index),
doc.recognition.clone(),
)
})
.collect()
};
let rules = build_rules(&new_config);
let revision = self.state.lock().await.revision;
for (uri, text, version, line_index, recognition) in to_relint {
self.lint_and_publish(
uri,
text,
version,
line_index,
recognition,
Arc::clone(&rules),
revision,
)
.await;
}
}
}
async fn run_lint_pass(
client: &Client,
state: &Arc<Mutex<State>>,
uri: Url,
text: String,
version: i32,
line_index: Arc<LineIndex>,
recognition: RecognitionState,
rules: Arc<RuleSet>,
revision: u64,
) {
let (diags, lsp_diags) = match recognition {
RecognitionState::Parsed { document, .. } => {
let diags = rules.check(&document);
let lsp_diags: Vec<LspDiagnostic> = diags.iter().map(|d| mdwright_to_lsp(&line_index, &text, d)).collect();
(diags, lsp_diags)
}
RecognitionState::ParseFailed(err) => (Vec::new(), vec![parse_error_lsp_diag(&err)]),
RecognitionState::TooLarge { len, cap } => (Vec::new(), vec![input_too_large_lsp_diag(len, cap)]),
};
{
let mut state = state.lock().await;
if state.revision != revision {
return;
}
if let Some(doc) = state.docs.get_mut(&uri) {
if doc.version != version {
return;
}
doc.diagnostics = diags;
} else {
return;
}
}
client.publish_diagnostics(uri, lsp_diags, Some(version)).await;
}
fn parse_error_lsp_diag(err: &ParseError) -> LspDiagnostic {
LspDiagnostic {
range: Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
severity: Some(DiagnosticSeverity::ERROR),
source: Some("mdwright".to_owned()),
message: err.to_string(),
..LspDiagnostic::default()
}
}
fn input_too_large_lsp_diag(len: usize, cap: usize) -> LspDiagnostic {
LspDiagnostic {
range: Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
severity: Some(DiagnosticSeverity::ERROR),
source: Some("mdwright".to_owned()),
message: format!("Markdown input is {len} bytes; exceeds the mdwright LSP limit of {cap} bytes"),
..LspDiagnostic::default()
}
}
fn client_supports_utf8(params: &InitializeParams) -> bool {
params
.capabilities
.general
.as_ref()
.and_then(|g| g.position_encodings.as_ref())
.is_some_and(|encs| encs.contains(&PositionEncodingKind::UTF8))
}
fn workspace_root(params: &InitializeParams) -> Option<PathBuf> {
if let Some(folders) = params.workspace_folders.as_ref()
&& let Some(folder) = folders.first()
&& let Ok(path) = folder.uri.to_file_path()
{
return Some(path);
}
#[allow(deprecated)]
if let Some(uri) = params.root_uri.as_ref()
&& let Ok(path) = uri.to_file_path()
{
return Some(path);
}
None
}
fn discover_or_default(root: Option<&std::path::Path>) -> Config {
let cwd = root
.map(std::path::Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."));
Config::discover(&cwd).unwrap_or_else(|_| Config::defaults())
}
fn build_rules(cfg: &Config) -> Arc<RuleSet> {
let mut rules = cfg
.lint_rule_selection()
.resolve(RuleSet::stdlib_all())
.unwrap_or_else(|_| RuleSet::stdlib_defaults());
if !cfg.extra_info_strings().is_empty() && rules.contains("info-string-typo") {
rules.remove("info-string-typo");
if let Err(err) = rules.add(Box::new(stdlib::InfoStringTypo::with_extra(
cfg.extra_info_strings().to_vec(),
))) {
tracing::warn!(error = %err, "failed to re-register info-string-typo with config extras");
}
}
Arc::new(rules)
}
fn mdwright_to_lsp(line_index: &LineIndex, source: &str, diag: &MdwrightDiagnostic) -> LspDiagnostic {
let range = to_lsp_range(line_index, source, &diag.span).unwrap_or(Range {
start: Position { line: 0, character: 0 },
end: Position { line: 0, character: 0 },
});
LspDiagnostic {
range,
severity: Some(match diag.severity() {
MdwrightSeverity::Error => DiagnosticSeverity::WARNING,
MdwrightSeverity::Warning => DiagnosticSeverity::WARNING,
MdwrightSeverity::Advisory => DiagnosticSeverity::HINT,
}),
code: Some(NumberOrString::String(diag.rule.to_string())),
code_description: Url::parse(&rule_doc_url(&diag.rule))
.ok()
.map(|href| CodeDescription { href }),
source: Some("mdwright".into()),
message: diag.message.clone(),
..LspDiagnostic::default()
}
}
fn to_lsp_range(line_index: &LineIndex, source: &str, span: &std::ops::Range<usize>) -> Option<Range> {
let (sl, sc) = line_index.locate(source, span.start).ok()?;
let (el, ec) = line_index.locate(source, span.end).ok()?;
Some(Range {
start: Position {
line: u32::try_from(sl.saturating_sub(1)).ok()?,
character: u32::try_from(sc.saturating_sub(1)).ok()?,
},
end: Position {
line: u32::try_from(el.saturating_sub(1)).ok()?,
character: u32::try_from(ec.saturating_sub(1)).ok()?,
},
})
}
fn whole_doc_range(line_index: &LineIndex, source: &str) -> Range {
let end = match line_index.locate(source, source.len()) {
Ok((l, c)) => Position {
line: u32::try_from(l.saturating_sub(1)).unwrap_or(0),
character: u32::try_from(c.saturating_sub(1)).unwrap_or(0),
},
Err(_) => Position { line: 0, character: 0 },
};
Range {
start: Position { line: 0, character: 0 },
end,
}
}
fn byte_of_position(line_index: &LineIndex, source: &str, position: Position) -> Option<usize> {
line_index.byte_of_position_0based(source, position.line as usize, position.character as usize)
}
fn overlaps(a_start: usize, a_end: usize, b_start: Option<usize>, b_end: Option<usize>) -> bool {
let (Some(bs), Some(be)) = (b_start, b_end) else {
return true;
};
a_start < be.max(bs.saturating_add(1)) && bs < a_end.max(a_start.saturating_add(1))
}
fn format_range_edits(
source: &str,
doc: &Document,
line_index: &LineIndex,
table: &CheckpointTable,
opts: &FmtOptions,
lsp_range: Range,
) -> Option<Vec<TextEdit>> {
let lo = byte_of_position(line_index, source, lsp_range.start)?;
let hi = byte_of_position(line_index, source, lsp_range.end).unwrap_or(source.len());
let (lo, hi) = if hi < lo { (hi, lo) } else { (lo, hi) };
let formatted = format_range_with_checkpoints(doc, opts, table, lo..hi);
let req_lo = u32::try_from(lo).unwrap_or(0);
let req_hi = u32::try_from(hi).unwrap_or(u32::MAX);
let snapped = table.snap_to_block_boundaries(req_lo..req_hi);
let slice_lo = snapped.start as usize;
let slice_hi = snapped.end as usize;
if slice_lo >= source.len() && formatted.is_empty() {
return Some(Vec::new());
}
let current = source.get(slice_lo..slice_hi).unwrap_or("");
if current == formatted {
return Some(Vec::new());
}
let edit_range = to_lsp_range(line_index, source, &(slice_lo..slice_hi))?;
Some(vec![TextEdit {
range: edit_range,
new_text: formatted,
}])
}
#[cfg(test)]
mod tests {
use super::{LineIndex, Position, overlaps, whole_doc_range};
#[test]
fn overlap_half_open_ranges() {
assert!(overlaps(5, 10, Some(7), Some(8)));
assert!(overlaps(5, 10, Some(5), Some(5)));
assert!(!overlaps(5, 10, Some(0), Some(5)));
assert!(!overlaps(5, 10, Some(0), Some(4)));
assert!(!overlaps(5, 10, Some(11), Some(20)));
assert!(overlaps(5, 10, None, None));
}
#[test]
fn whole_doc_range_empty_source() {
let src = "";
let idx = LineIndex::new(src);
let r = whole_doc_range(&idx, src);
assert_eq!(r.start, Position { line: 0, character: 0 });
assert_eq!(r.end, Position { line: 0, character: 0 });
}
}