use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use futures::future::join_all;
use tokio::sync::{RwLock, mpsc};
use tower_lsp::jsonrpc::Result as JsonRpcResult;
use tower_lsp::lsp_types::*;
use tower_lsp::{Client, LanguageServer};
use crate::config::{Config, is_valid_rule_name};
use crate::lsp::index_worker::IndexWorker;
use crate::lsp::types::{IndexState, IndexUpdate, LspRuleSettings, RumdlLspConfig};
use crate::rule::FixCapability;
use crate::rules;
use crate::workspace_index::WorkspaceIndex;
const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
const MAX_RULE_LIST_SIZE: usize = 100;
const MAX_LINE_LENGTH: usize = 10_000;
#[inline]
fn is_markdown_extension(ext: &str) -> bool {
MARKDOWN_EXTENSIONS.contains(&ext.to_lowercase().as_str())
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct DocumentEntry {
pub(crate) content: String,
pub(crate) version: Option<i32>,
pub(crate) from_disk: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct ConfigCacheEntry {
pub(crate) config: Config,
pub(crate) config_file: Option<PathBuf>,
pub(crate) from_global_fallback: bool,
}
#[derive(Clone)]
pub struct RumdlLanguageServer {
pub(crate) client: Client,
pub(crate) config: Arc<RwLock<RumdlLspConfig>>,
pub(crate) rumdl_config: Arc<RwLock<Config>>,
pub(crate) documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
pub(crate) workspace_index: Arc<RwLock<WorkspaceIndex>>,
pub(crate) index_state: Arc<RwLock<IndexState>>,
pub(crate) update_tx: mpsc::Sender<IndexUpdate>,
pub(crate) client_supports_pull_diagnostics: Arc<RwLock<bool>>,
}
impl RumdlLanguageServer {
pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
let mut initial_config = RumdlLspConfig::default();
if let Some(path) = cli_config_path {
initial_config.config_path = Some(path.to_string());
}
let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
let index_state = Arc::new(RwLock::new(IndexState::default()));
let workspace_roots = Arc::new(RwLock::new(Vec::new()));
let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
let worker = IndexWorker::new(
update_rx,
workspace_index.clone(),
index_state.clone(),
client.clone(),
workspace_roots.clone(),
relint_tx,
);
tokio::spawn(worker.run());
Self {
client,
config: Arc::new(RwLock::new(initial_config)),
rumdl_config: Arc::new(RwLock::new(Config::default())),
documents: Arc::new(RwLock::new(HashMap::new())),
workspace_roots,
config_cache: Arc::new(RwLock::new(HashMap::new())),
workspace_index,
index_state,
update_tx,
client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
}
}
pub(super) async fn get_document_content(&self, uri: &Url) -> Option<String> {
{
let docs = self.documents.read().await;
if let Some(entry) = docs.get(uri) {
return Some(entry.content.clone());
}
}
if let Ok(path) = uri.to_file_path() {
if let Ok(content) = tokio::fs::read_to_string(&path).await {
let entry = DocumentEntry {
content: content.clone(),
version: None,
from_disk: true,
};
let mut docs = self.documents.write().await;
docs.insert(uri.clone(), entry);
log::debug!("Loaded document from disk and cached: {uri}");
return Some(content);
} else {
log::debug!("Failed to read file from disk: {uri}");
}
}
None
}
async fn get_open_document_content(&self, uri: &Url) -> Option<String> {
let docs = self.documents.read().await;
docs.get(uri)
.and_then(|entry| (!entry.from_disk).then(|| entry.content.clone()))
}
}
#[tower_lsp::async_trait]
impl LanguageServer for RumdlLanguageServer {
async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
log::info!("Initializing rumdl Language Server");
if let Some(options) = params.initialization_options
&& let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
{
*self.config.write().await = config;
}
let supports_pull = params
.capabilities
.text_document
.as_ref()
.and_then(|td| td.diagnostic.as_ref())
.is_some();
if supports_pull {
log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
*self.client_supports_pull_diagnostics.write().await = true;
} else {
log::info!("Client does not support pull diagnostics - using push model");
}
let mut roots = Vec::new();
if let Some(workspace_folders) = params.workspace_folders {
for folder in workspace_folders {
if let Ok(path) = folder.uri.to_file_path() {
let path = path.canonicalize().unwrap_or(path);
log::info!("Workspace root: {}", path.display());
roots.push(path);
}
}
} else if let Some(root_uri) = params.root_uri
&& let Ok(path) = root_uri.to_file_path()
{
let path = path.canonicalize().unwrap_or(path);
log::info!("Workspace root: {}", path.display());
roots.push(path);
}
*self.workspace_roots.write().await = roots;
self.load_configuration(false).await;
Ok(InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
open_close: Some(true),
change: Some(TextDocumentSyncKind::FULL),
will_save: Some(false),
will_save_wait_until: Some(true),
save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
include_text: Some(false),
})),
})),
code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
code_action_kinds: Some(vec![
CodeActionKind::QUICKFIX,
CodeActionKind::SOURCE_FIX_ALL,
CodeActionKind::new("source.fixAll.rumdl"),
]),
work_done_progress_options: WorkDoneProgressOptions::default(),
resolve_provider: None,
})),
document_formatting_provider: Some(OneOf::Left(true)),
document_range_formatting_provider: Some(OneOf::Left(true)),
diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
identifier: Some("rumdl".to_string()),
inter_file_dependencies: true,
workspace_diagnostics: false,
work_done_progress_options: WorkDoneProgressOptions::default(),
})),
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec![
"`".to_string(),
"(".to_string(),
"#".to_string(),
"/".to_string(),
".".to_string(),
"-".to_string(),
]),
resolve_provider: Some(false),
work_done_progress_options: WorkDoneProgressOptions::default(),
all_commit_characters: None,
completion_item: None,
}),
definition_provider: Some(OneOf::Left(true)),
references_provider: Some(OneOf::Left(true)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
rename_provider: Some(OneOf::Right(RenameOptions {
prepare_provider: Some(true),
work_done_progress_options: WorkDoneProgressOptions::default(),
})),
workspace: Some(WorkspaceServerCapabilities {
workspace_folders: Some(WorkspaceFoldersServerCapabilities {
supported: Some(true),
change_notifications: Some(OneOf::Left(true)),
}),
file_operations: None,
}),
..Default::default()
},
server_info: Some(ServerInfo {
name: "rumdl".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}),
})
}
async fn initialized(&self, _: InitializedParams) {
let version = env!("CARGO_PKG_VERSION");
let (binary_path, build_time) = std::env::current_exe()
.ok()
.map(|path| {
let path_str = path.to_str().unwrap_or("unknown").to_string();
let build_time = std::fs::metadata(&path)
.ok()
.and_then(|metadata| metadata.modified().ok())
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|duration| {
let secs = duration.as_secs();
chrono::DateTime::from_timestamp(secs as i64, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
})
.unwrap_or_else(|| "unknown".to_string());
(path_str, build_time)
})
.unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
let working_dir = std::env::current_dir()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string());
log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
log::info!("Working directory: {working_dir}");
self.client
.log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
.await;
if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
log::warn!("Failed to trigger initial workspace indexing");
} else {
log::info!("Triggered initial workspace indexing for cross-file analysis");
}
let markdown_patterns = [
"**/*.md",
"**/*.markdown",
"**/*.mdx",
"**/*.mkd",
"**/*.mkdn",
"**/*.mdown",
"**/*.mdwn",
"**/*.qmd",
"**/*.rmd",
];
let config_patterns = [
"**/.rumdl.toml",
"**/rumdl.toml",
"**/pyproject.toml",
"**/.markdownlint.json",
];
let watchers: Vec<_> = markdown_patterns
.iter()
.chain(config_patterns.iter())
.map(|pattern| FileSystemWatcher {
glob_pattern: GlobPattern::String((*pattern).to_string()),
kind: Some(WatchKind::all()),
})
.collect();
let registration = Registration {
id: "markdown-watcher".to_string(),
method: "workspace/didChangeWatchedFiles".to_string(),
register_options: Some(
serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
),
};
if self.client.register_capability(vec![registration]).await.is_err() {
log::debug!("Client does not support file watching capability");
}
}
async fn completion(&self, params: CompletionParams) -> JsonRpcResult<Option<CompletionResponse>> {
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let Some(text) = self.get_document_content(&uri).await else {
return Ok(None);
};
if let Some((start_col, current_text)) = Self::detect_code_fence_language_position(&text, position) {
log::debug!(
"Code fence completion triggered at {}:{}, current text: '{}'",
position.line,
position.character,
current_text
);
let items = self
.get_language_completions(&uri, ¤t_text, start_col, position)
.await;
if !items.is_empty() {
return Ok(Some(CompletionResponse::Array(items)));
}
}
if self.config.read().await.enable_link_completions {
let trigger = params.context.as_ref().and_then(|c| c.trigger_character.as_deref());
let skip_link_check = matches!(trigger, Some("." | "-")) && {
let line_num = position.line as usize;
!text
.lines()
.nth(line_num)
.map(|line| line.contains("]("))
.unwrap_or(false)
};
if !skip_link_check && let Some(link_info) = Self::detect_link_target_position(&text, position) {
let items = if let Some((partial_anchor, anchor_start_col)) = link_info.anchor {
log::debug!(
"Anchor completion triggered at {}:{}, file: '{}', partial: '{}'",
position.line,
position.character,
link_info.file_path,
partial_anchor
);
self.get_anchor_completions(&uri, &link_info.file_path, &partial_anchor, anchor_start_col, position)
.await
} else {
log::debug!(
"File path completion triggered at {}:{}, partial: '{}'",
position.line,
position.character,
link_info.file_path
);
self.get_file_completions(&uri, &link_info.file_path, link_info.path_start_col, position)
.await
};
if !items.is_empty() {
return Ok(Some(CompletionResponse::Array(items)));
}
}
}
Ok(None)
}
async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
let mut roots = self.workspace_roots.write().await;
for removed in ¶ms.event.removed {
if let Ok(path) = removed.uri.to_file_path() {
roots.retain(|r| r != &path);
log::info!("Removed workspace root: {}", path.display());
}
}
for added in ¶ms.event.added {
if let Ok(path) = added.uri.to_file_path()
&& !roots.contains(&path)
{
log::info!("Added workspace root: {}", path.display());
roots.push(path);
}
}
drop(roots);
self.config_cache.write().await.clear();
self.reload_configuration().await;
if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
log::warn!("Failed to trigger workspace rescan after folder change");
}
}
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
log::debug!("Configuration changed: {:?}", params.settings);
let settings_value = params.settings;
let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
} else {
settings_value
};
let mut config_applied = false;
let mut warnings: Vec<String> = Vec::new();
if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
&& (rule_settings.disable.is_some()
|| rule_settings.enable.is_some()
|| rule_settings.line_length.is_some()
|| !rule_settings.rules.is_empty())
{
if let Some(ref disable) = rule_settings.disable {
for rule in disable {
if !is_valid_rule_name(rule) {
warnings.push(format!("Unknown rule in disable list: {rule}"));
}
}
}
if let Some(ref enable) = rule_settings.enable {
for rule in enable {
if !is_valid_rule_name(rule) {
warnings.push(format!("Unknown rule in enable list: {rule}"));
}
}
}
for rule_name in rule_settings.rules.keys() {
if !is_valid_rule_name(rule_name) {
warnings.push(format!("Unknown rule in settings: {rule_name}"));
}
}
log::info!("Applied rule settings from configuration (Neovim style)");
let mut config = self.config.write().await;
config.settings = Some(rule_settings);
drop(config);
config_applied = true;
} else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
&& (full_config.config_path.is_some()
|| full_config.enable_rules.is_some()
|| full_config.disable_rules.is_some()
|| full_config.settings.is_some()
|| !full_config.enable_linting
|| full_config.enable_auto_fix)
{
if let Some(ref rules) = full_config.enable_rules {
for rule in rules {
if !is_valid_rule_name(rule) {
warnings.push(format!("Unknown rule in enableRules: {rule}"));
}
}
}
if let Some(ref rules) = full_config.disable_rules {
for rule in rules {
if !is_valid_rule_name(rule) {
warnings.push(format!("Unknown rule in disableRules: {rule}"));
}
}
}
log::info!("Applied full LSP configuration from settings");
*self.config.write().await = full_config;
config_applied = true;
} else if let serde_json::Value::Object(obj) = rumdl_settings {
let mut config = self.config.write().await;
let mut rules = std::collections::HashMap::new();
let mut disable = Vec::new();
let mut enable = Vec::new();
let mut line_length = None;
for (key, value) in obj {
match key.as_str() {
"disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
Ok(d) => {
if d.len() > MAX_RULE_LIST_SIZE {
warnings.push(format!(
"Too many rules in 'disable' ({} > {}), truncating",
d.len(),
MAX_RULE_LIST_SIZE
));
}
for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
if !is_valid_rule_name(rule) {
warnings.push(format!("Unknown rule in disable: {rule}"));
}
}
disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
}
Err(_) => {
warnings.push(format!(
"Invalid 'disable' value: expected array of strings, got {value}"
));
}
},
"enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
Ok(e) => {
if e.len() > MAX_RULE_LIST_SIZE {
warnings.push(format!(
"Too many rules in 'enable' ({} > {}), truncating",
e.len(),
MAX_RULE_LIST_SIZE
));
}
for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
if !is_valid_rule_name(rule) {
warnings.push(format!("Unknown rule in enable: {rule}"));
}
}
enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
}
Err(_) => {
warnings.push(format!(
"Invalid 'enable' value: expected array of strings, got {value}"
));
}
},
"lineLength" | "line_length" | "line-length" => {
if let Some(l) = value.as_u64() {
match usize::try_from(l) {
Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
Ok(len) => warnings.push(format!(
"Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
)),
Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
}
} else {
warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
}
}
_ if key.starts_with("MD") || key.starts_with("md") => {
let normalized = key.to_uppercase();
if !is_valid_rule_name(&normalized) {
warnings.push(format!("Unknown rule: {key}"));
}
rules.insert(normalized, value);
}
_ => {
warnings.push(format!("Unknown configuration key: {key}"));
}
}
}
let settings = LspRuleSettings {
line_length,
disable: if disable.is_empty() { None } else { Some(disable) },
enable: if enable.is_empty() { None } else { Some(enable) },
rules,
};
log::info!("Applied Neovim-style rule settings (manual parse)");
config.settings = Some(settings);
drop(config);
config_applied = true;
} else {
log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
}
for warning in &warnings {
log::warn!("{warning}");
}
if !warnings.is_empty() {
let message = if warnings.len() == 1 {
format!("rumdl: {}", warnings[0])
} else {
format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
};
self.client.log_message(MessageType::WARNING, message).await;
}
if !config_applied {
log::debug!("No configuration changes applied");
}
self.config_cache.write().await.clear();
let doc_list: Vec<_> = {
let documents = self.documents.read().await;
documents
.iter()
.map(|(uri, entry)| (uri.clone(), entry.content.clone()))
.collect()
};
let tasks = doc_list.into_iter().map(|(uri, text)| {
let server = self.clone();
tokio::spawn(async move {
server.update_diagnostics(uri, text, true).await;
})
});
let _ = join_all(tasks).await;
}
async fn shutdown(&self) -> JsonRpcResult<()> {
log::info!("Shutting down rumdl Language Server");
let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
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 entry = DocumentEntry {
content: text.clone(),
version: Some(version),
from_disk: false,
};
self.documents.write().await.insert(uri.clone(), entry);
if let Ok(path) = uri.to_file_path() {
let _ = self
.update_tx
.send(IndexUpdate::FileChanged {
path,
content: text.clone(),
})
.await;
}
self.update_diagnostics(uri, text, true).await;
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
let uri = params.text_document.uri;
let version = params.text_document.version;
if let Some(change) = params.content_changes.into_iter().next() {
let text = change.text;
let entry = DocumentEntry {
content: text.clone(),
version: Some(version),
from_disk: false,
};
self.documents.write().await.insert(uri.clone(), entry);
if let Ok(path) = uri.to_file_path() {
let _ = self
.update_tx
.send(IndexUpdate::FileChanged {
path,
content: text.clone(),
})
.await;
}
self.update_diagnostics(uri, text, false).await;
}
}
async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
if params.reason != TextDocumentSaveReason::MANUAL {
return Ok(None);
}
let config_guard = self.config.read().await;
let enable_auto_fix = config_guard.enable_auto_fix;
drop(config_guard);
if !enable_auto_fix {
return Ok(None);
}
let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
return Ok(None);
};
match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
Ok(Some(fixed_text)) => {
Ok(Some(vec![TextEdit {
range: Range {
start: Position { line: 0, character: 0 },
end: self.get_end_position(&text),
},
new_text: fixed_text,
}]))
}
Ok(None) => Ok(None),
Err(e) => {
log::error!("Failed to generate fixes in will_save_wait_until: {e}");
Ok(None)
}
}
}
async fn did_save(&self, params: DidSaveTextDocumentParams) {
if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
self.update_diagnostics(params.text_document.uri, entry.content.clone(), true)
.await;
}
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
self.documents.write().await.remove(¶ms.text_document.uri);
self.client
.publish_diagnostics(params.text_document.uri, Vec::new(), None)
.await;
}
async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
let mut config_changed = false;
for change in ¶ms.changes {
if let Ok(path) = change.uri.to_file_path() {
let file_name = path.file_name().and_then(|f| f.to_str());
let extension = path.extension().and_then(|e| e.to_str());
if let Some(name) = file_name
&& CONFIG_FILES.contains(&name)
&& !config_changed
{
log::info!("Config file changed: {}, invalidating config cache", path.display());
let mut cache = self.config_cache.write().await;
cache.clear();
drop(cache);
self.reload_configuration().await;
config_changed = true;
}
if let Some(ext) = extension
&& is_markdown_extension(ext)
{
match change.typ {
FileChangeType::CREATED | FileChangeType::CHANGED => {
if let Ok(content) = tokio::fs::read_to_string(&path).await {
let _ = self
.update_tx
.send(IndexUpdate::FileChanged {
path: path.clone(),
content,
})
.await;
}
}
FileChangeType::DELETED => {
let _ = self
.update_tx
.send(IndexUpdate::FileDeleted { path: path.clone() })
.await;
}
_ => {}
}
}
}
}
if config_changed {
let docs_to_update: Vec<(Url, String)> = {
let docs = self.documents.read().await;
docs.iter()
.filter(|(_, entry)| !entry.from_disk)
.map(|(uri, entry)| (uri.clone(), entry.content.clone()))
.collect()
};
for (uri, text) in docs_to_update {
self.update_diagnostics(uri, text, true).await;
}
}
}
async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
let uri = params.text_document.uri;
let range = params.range;
let requested_kinds = params.context.only;
if let Some(text) = self.get_document_content(&uri).await {
match self.get_code_actions(&uri, &text, range).await {
Ok(actions) => {
let filtered_actions = if let Some(ref kinds) = requested_kinds
&& !kinds.is_empty()
{
actions
.into_iter()
.filter(|action| {
action.kind.as_ref().is_some_and(|action_kind| {
let action_kind_str = action_kind.as_str();
kinds.iter().any(|requested| {
let requested_str = requested.as_str();
action_kind_str.starts_with(requested_str)
})
})
})
.collect()
} else {
actions
};
let response: Vec<CodeActionOrCommand> = filtered_actions
.into_iter()
.map(CodeActionOrCommand::CodeAction)
.collect();
Ok(Some(response))
}
Err(e) => {
log::error!("Failed to get code actions: {e}");
Ok(None)
}
}
} else {
Ok(None)
}
}
async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
log::debug!(
"Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
params.range
);
let formatting_params = DocumentFormattingParams {
text_document: params.text_document,
options: params.options,
work_done_progress_params: params.work_done_progress_params,
};
self.formatting(formatting_params).await
}
async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
let uri = params.text_document.uri;
let options = params.options;
log::debug!("Formatting request for: {uri}");
log::debug!(
"FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
options.insert_final_newline,
options.trim_final_newlines,
options.trim_trailing_whitespace
);
if let Some(text) = self.get_document_content(&uri).await {
let config_guard = self.config.read().await;
let lsp_config = config_guard.clone();
drop(config_guard);
let file_path = uri.to_file_path().ok();
let file_config = if let Some(ref path) = file_path {
self.resolve_config_for_file(path).await
} else {
self.rumdl_config.read().await.clone()
};
let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
let all_rules = rules::all_rules(&rumdl_config);
let flavor = if let Some(ref path) = file_path {
rumdl_config.get_flavor_for_file(path)
} else {
rumdl_config.markdown_flavor()
};
let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
let mut result = text.clone();
match crate::lint(
&text,
&filtered_rules,
false,
flavor,
file_path.clone(),
Some(&rumdl_config),
) {
Ok(warnings) => {
log::debug!(
"Found {} warnings, {} with fixes",
warnings.len(),
warnings.iter().filter(|w| w.fix.is_some()).count()
);
let has_fixes = warnings.iter().any(|w| w.fix.is_some());
if has_fixes {
let fixable_warnings: Vec<_> = warnings
.iter()
.filter(|w| {
if let Some(rule_name) = &w.rule_name {
filtered_rules
.iter()
.find(|r| r.name() == rule_name)
.map(|r| r.fix_capability() != FixCapability::Unfixable)
.unwrap_or(false)
} else {
false
}
})
.cloned()
.collect();
match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
Ok(fixed_content) => {
result = fixed_content;
}
Err(e) => {
log::error!("Failed to apply fixes: {e}");
}
}
}
}
Err(e) => {
log::error!("Failed to lint document: {e}");
}
}
result = Self::apply_formatting_options(result, &options);
if result != text {
log::debug!("Returning formatting edits");
let end_position = self.get_end_position(&text);
let edit = TextEdit {
range: Range {
start: Position { line: 0, character: 0 },
end: end_position,
},
new_text: result,
};
return Ok(Some(vec![edit]));
}
Ok(Some(Vec::new()))
} else {
log::warn!("Document not found: {uri}");
Ok(None)
}
}
async fn goto_definition(&self, params: GotoDefinitionParams) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
log::debug!("Go-to-definition at {uri} {}:{}", position.line, position.character);
Ok(self.handle_goto_definition(&uri, position).await)
}
async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
log::debug!("Find references at {uri} {}:{}", position.line, position.character);
Ok(self.handle_references(&uri, position).await)
}
async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
log::debug!("Hover at {uri} {}:{}", position.line, position.character);
Ok(self.handle_hover(&uri, position).await)
}
async fn prepare_rename(&self, params: TextDocumentPositionParams) -> JsonRpcResult<Option<PrepareRenameResponse>> {
let uri = params.text_document.uri;
let position = params.position;
log::debug!("Prepare rename at {uri} {}:{}", position.line, position.character);
Ok(self.handle_prepare_rename(&uri, position).await)
}
async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let new_name = params.new_name;
log::debug!("Rename at {uri} {}:{} → {new_name}", position.line, position.character);
Ok(self.handle_rename(&uri, position, &new_name).await)
}
async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
let uri = params.text_document.uri;
if let Some(text) = self.get_open_document_content(&uri).await {
match self.lint_document(&uri, &text, true).await {
Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
RelatedFullDocumentDiagnosticReport {
related_documents: None,
full_document_diagnostic_report: FullDocumentDiagnosticReport {
result_id: None,
items: diagnostics,
},
},
))),
Err(e) => {
log::error!("Failed to get diagnostics: {e}");
Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
RelatedFullDocumentDiagnosticReport {
related_documents: None,
full_document_diagnostic_report: FullDocumentDiagnosticReport {
result_id: None,
items: Vec::new(),
},
},
)))
}
}
} else {
Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
RelatedFullDocumentDiagnosticReport {
related_documents: None,
full_document_diagnostic_report: FullDocumentDiagnosticReport {
result_id: None,
items: Vec::new(),
},
},
)))
}
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;