emmylua_ls 0.25.1

A language server for emmylua.
Documentation
use emmylua_code_analysis::file_path_to_uri;
use log::{info, warn};
use lsp_types::{
    DidChangeWatchedFilesParams, DidChangeWatchedFilesRegistrationOptions, FileEvent,
    FileSystemWatcher, GlobPattern, Registration, RegistrationParams, Unregistration,
    UnregistrationParams, WatchKind,
};
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
use std::{
    path::{Path, PathBuf},
    sync::mpsc::channel,
    time::Duration,
};

use crate::{
    context::{ClientProxy, ServerContextSnapshot},
    handlers::text_document::on_did_change_watched_files,
};
use emmylua_code_analysis::{WorkspaceFileMatcher, WorkspaceFolder};

const WATCH_FILES_REGISTRATION_ID: &str = "emmylua_watch_files";
const WATCHED_CONFIG_GLOBS: [&str; 4] = [
    "**/.editorconfig",
    "**/.luarc.json",
    "**/.emmyrc.json",
    "**/.emmyrc.lua",
];
const WATCHED_CONFIG_FILE_NAMES: [&str; 4] = [
    ".editorconfig",
    ".luarc.json",
    ".emmyrc.json",
    ".emmyrc.lua",
];

pub async fn register_files_watch(context: ServerContextSnapshot) {
    let supports_dynamic_watch = context
        .lsp_features()
        .supports_dynamic_watched_files_registration();
    let (watch_roots, workspace_folders, match_file_pattern) = {
        let workspace_manager = context.workspace_manager().read().await;
        (
            reduce_watch_roots(workspace_manager.match_file_pattern.watch_roots()),
            workspace_manager.workspace_folders.clone(),
            workspace_manager.match_file_pattern.clone(),
        )
    };
    let watch_plan =
        build_watch_registration_plan(supports_dynamic_watch, &workspace_folders, watch_roots);

    if watch_plan.use_lsp_client {
        register_files_watch_use_lsp_client(
            context.client(),
            match_file_pattern.source_file_globs(),
        );
    } else if supports_dynamic_watch {
        unregister_files_watch_use_lsp_client(context.client());
    }

    if watch_plan.notify_roots.is_empty() {
        let mut workspace_manager = context.workspace_manager().write().await;
        workspace_manager.watcher = None;
        return;
    }

    info!("use notify to watch files: {:?}", watch_plan.notify_roots);
    let registered = register_files_watch_use_fsnotify(
        context.clone(),
        watch_plan.notify_roots,
        match_file_pattern,
    )
    .await;
    if !registered {
        if watch_plan.use_lsp_client {
            warn!(
                "notify registration failed for external roots; continuing with client watched files"
            );
        } else {
            warn!("notify registration failed; no watched-file backend is available");
        }
    }
}

#[derive(Debug)]
struct WatchRegistrationPlan {
    use_lsp_client: bool,
    notify_roots: Vec<PathBuf>,
}

fn build_watch_registration_plan(
    supports_dynamic_watch: bool,
    workspace_folders: &[WorkspaceFolder],
    watch_roots: Vec<PathBuf>,
) -> WatchRegistrationPlan {
    if !supports_dynamic_watch {
        return WatchRegistrationPlan {
            use_lsp_client: false,
            notify_roots: watch_roots,
        };
    }

    WatchRegistrationPlan {
        use_lsp_client: !workspace_folders.is_empty(),
        notify_roots: watch_roots
            .into_iter()
            .filter(|root| !is_root_covered_by_workspace_folders(root, workspace_folders))
            .collect(),
    }
}

fn is_root_covered_by_workspace_folders(
    path: &Path,
    workspace_folders: &[WorkspaceFolder],
) -> bool {
    workspace_folders
        .iter()
        .any(|workspace| is_path_covered_by_workspace(path, &workspace.root))
}

fn register_files_watch_use_lsp_client(client: &ClientProxy, source_file_globs: &[String]) {
    unregister_files_watch_use_lsp_client(client);

    let options = DidChangeWatchedFilesRegistrationOptions {
        watchers: lsp_file_watchers(&build_watch_file_globs(source_file_globs)),
    };

    let registration = Registration {
        id: WATCH_FILES_REGISTRATION_ID.to_string(),
        method: "workspace/didChangeWatchedFiles".to_string(),
        register_options: Some(serde_json::to_value(options).unwrap()),
    };
    client.dynamic_register_capability(RegistrationParams {
        registrations: vec![registration],
    });
}

fn build_watch_file_globs(source_file_globs: &[String]) -> Vec<String> {
    let mut globs = source_file_globs.to_vec();
    globs.extend(WATCHED_CONFIG_GLOBS.into_iter().map(str::to_string));
    globs.sort();
    globs.dedup();
    globs
}

fn lsp_file_watchers(watch_file_globs: &[String]) -> Vec<FileSystemWatcher> {
    watch_file_globs
        .iter()
        .map(|glob_pattern| FileSystemWatcher {
            glob_pattern: GlobPattern::String(glob_pattern.clone()),
            kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete),
        })
        .collect()
}

fn unregister_files_watch_use_lsp_client(client: &ClientProxy) {
    client.dynamic_unregister_capability(UnregistrationParams {
        unregisterations: vec![Unregistration {
            id: WATCH_FILES_REGISTRATION_ID.to_string(),
            method: "workspace/didChangeWatchedFiles".to_string(),
        }],
    });
}

async fn register_files_watch_use_fsnotify(
    context: ServerContextSnapshot,
    watch_roots: Vec<PathBuf>,
    match_file_pattern: WorkspaceFileMatcher,
) -> bool {
    let (tx, rx) = channel();
    let config = Config::default().with_poll_interval(Duration::from_secs(5));
    let mut watcher = match RecommendedWatcher::new(
        move |res| {
            if let Ok(event) = res {
                match tx.send(event) {
                    Ok(_) => {}
                    Err(e) => {
                        warn!("send notify event failed: {:?}", e);
                    }
                };
            };
        },
        config,
    ) {
        Ok(watcher) => watcher,
        Err(e) => {
            log::error!("create notify watcher failed: {:?}", e);
            return false;
        }
    };

    let mut watched_roots = Vec::new();
    for root in watch_roots {
        match watcher.watch(&root, RecursiveMode::Recursive) {
            Ok(()) => watched_roots.push(root),
            Err(e) => warn!("can not watch {:?}: {:?}", root, e),
        }
    }

    if watched_roots.is_empty() {
        warn!("can not watch any workspace roots with notify");
        return false;
    }

    let mut workspace_manager = context.workspace_manager().write().await;
    workspace_manager.watcher = Some(watcher);
    drop(workspace_manager);

    tokio::spawn(async move {
        loop {
            match rx.recv() {
                Ok(event) => {
                    let typ = match event.kind {
                        notify::event::EventKind::Create(_) => lsp_types::FileChangeType::CREATED,
                        notify::event::EventKind::Modify(_) => lsp_types::FileChangeType::CHANGED,
                        notify::event::EventKind::Remove(_) => lsp_types::FileChangeType::DELETED,
                        _ => {
                            continue;
                        }
                    };
                    let mut file_events = vec![];
                    for path in event.paths.iter() {
                        if is_watched_path(&match_file_pattern, path) {
                            if let Some(uri) = file_path_to_uri(path) {
                                file_events.push(FileEvent { uri, typ });
                            }
                        }
                    }

                    if file_events.is_empty() {
                        continue;
                    }
                    let params = DidChangeWatchedFilesParams {
                        changes: file_events,
                    };
                    on_did_change_watched_files(context.clone(), params).await;
                }
                Err(e) => {
                    warn!("watch files notify error: {:?}", e);
                    break;
                }
            }
        }
    });

    info!("watch files use notify success: {:?}", watched_roots);
    true
}

fn is_watched_path(match_file_pattern: &WorkspaceFileMatcher, path: &Path) -> bool {
    is_config_watch_path(path) || match_file_pattern.is_match(path)
}

fn is_config_watch_path(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| WATCHED_CONFIG_FILE_NAMES.contains(&name))
}

fn is_path_covered_by_workspace(path: &Path, workspace_root: &Path) -> bool {
    path.starts_with(workspace_root)
}

fn reduce_watch_roots<I>(roots: I) -> Vec<PathBuf>
where
    I: IntoIterator<Item = PathBuf>,
{
    let mut roots = roots.into_iter().collect::<Vec<_>>();
    roots.sort();

    let mut reduced_roots: Vec<PathBuf> = Vec::new();
    for root in roots {
        if reduced_roots
            .iter()
            .any(|existing| is_path_covered_by_workspace(&root, existing))
        {
            continue;
        }

        reduced_roots.retain(|existing| !is_path_covered_by_workspace(existing, &root));
        reduced_roots.push(root);
    }

    reduced_roots
}