#[cfg(test)] mod tests; use avril_core::ModuleLoader; use avril_lsp::{Documents, ProjectInput, ProjectService, Refresh, ScriptKind, Source}; use avril_runtime::{caps::Capabilities, registry::Options}; use notify::Watcher; use serde_json::json; use std::{ collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, }, }; use crate::cli::ParseError; use crate::fragments::{Arg, reject}; struct LiveProject { root: PathBuf, caps: Arc, options: Options, explicit_lock: bool, automatic_types: bool, dirty: Arc, rewatch: Arc, watcher: Option, watched: BTreeMap, tracked_config: Arc>>, } impl LiveProject { fn watch(&mut self, path: &Path, recursive: bool) { let Some(watcher) = &mut self.watcher else { return; }; if self .watched .get(path) .is_some_and(|current| *current || !recursive) || self .watched .iter() .any(|(root, recursive)| *recursive && path.starts_with(root)) { return; } if watcher .watch( path, if recursive { notify::RecursiveMode::Recursive } else { notify::RecursiveMode::NonRecursive }, ) .is_ok() { self.watched.insert(path.to_owned(), recursive); } } } impl ProjectService for LiveProject { fn changed(&mut self) -> bool { let dirty = self.dirty.swap(false, Ordering::AcqRel); if self.rewatch.swap(false, Ordering::AcqRel) { // A watch follows an inode, not a directory recreated at the same path. for (path, recursive) in std::mem::take(&mut self.watched) { if let Some(watcher) = &mut self.watcher { let _ = watcher.unwatch(&path); } self.watch(&path, recursive); } } dirty } fn refresh(&mut self, documents: Documents) -> Refresh<'_> { Box::pin(async move { self.input(documents, &[]).await }) } } impl LiveProject { fn input(&mut self, documents: Documents, extra_roots: &[String]) -> Refresh<'_> { let extra_roots = extra_roots.to_vec(); Box::pin(async move { if self.caps.unrestricted_js_exec() { self.watch(&self.root.clone(), true); } let authority = avril_runtime::authority::SourceAuthority::execute(self.caps.clone()); let anchor = self.root.join(".avril-lsp.js"); // Broken configs are reported on their modules by the type collector; // they must not prevent unrelated open documents from being checked. let root = avril_runtime::modules::ModuleGraph::project_root_for_repl( &authority, anchor.to_str().ok_or("Project path must be UTF-8")?, ) .unwrap_or_else(|_| self.root.clone()); let mut options = self.options.clone(); if !self.explicit_lock { options.lock = root.join("avril.lock"); } let lock_path = options.lock.clone(); options.project_root = root.clone(); if self.caps.unrestricted_js_exec() { if let Some(parent) = options.lock.ancestors().skip(1).find(|p| p.is_dir()) { self.watch(parent, false); } if let Some(vendor) = &options.vendor { self.watch(vendor, true); } } let discovered = if extra_roots.is_empty() && self.caps.unrestricted_js_exec() { let workspace = self.root.clone(); let cache = options.cache.clone(); let vendor = options.vendor.clone(); tokio::task::spawn_blocking(move || -> avril_lsp::Result> { let mut sources = Vec::new(); for entry in ignore::WalkBuilder::new(workspace) .require_git(false) .filter_entry(move |entry| { entry.path() != cache && vendor.as_ref().is_none_or(|path| entry.path() != path) && entry.file_name() != "node_modules" }) .build() { let entry = entry?; if entry.file_type().is_some_and(|kind| kind.is_file()) && matches!( entry.path().extension().and_then(|s| s.to_str()), Some("ts" | "tsx" | "js" | "jsx" | "mts" | "cts" | "mjs" | "cjs") ) { sources.push( entry .path() .to_str() .ok_or("Module path must be UTF-8")? .to_owned(), ); } } sources.sort(); Ok(sources) }) .await?? } else { Vec::new() }; let graph = avril_runtime::modules::ModuleGraph::open( tokio::runtime::Handle::current(), authority, options, )?; graph.set_automatic_types(self.automatic_types); graph.set_typescript_version(avril_lsp::BACKEND_VERSION)?; let mut overlays = BTreeMap::new(); for (uri, text) in documents { let path = url::Url::parse(&uri)? .to_file_path() .map_err(|_| "Only local source documents can be edited")?; overlays.insert( path.to_str().ok_or("Module path must be UTF-8")?.to_owned(), text, ); } let mut roots: Vec<_> = overlays.keys().cloned().collect(); roots.extend(extra_roots.iter().cloned()); roots.extend(discovered); let types = graph.type_graph(&roots, &overlays).await?; let mut tracked = types.config_inputs.clone(); tracked.insert(lock_path); tracked.extend( types .sources .keys() .filter(|name| graph.is_workspace_source(name)) .map(PathBuf::from), ); *self.tracked_config.lock().unwrap() = tracked; let uri = |name: &str| -> avril_lsp::Result { avril_lsp::canonical_uri(&graph.module_url(name).ok_or("Invalid module identity")?) }; let mut input = ProjectInput { script_kinds: BTreeMap::new(), sources: crate::stdlib::declarations() .map(|(name, text)| Source { uri: format!("avril:/runtime/stdlib/{name}"), text: text.into(), read_only: true, }) .collect(), // Dynamic import must remain import-mode even in CommonJS files. compiler_options: json!({"strict":true,"noEmit":true,"skipLibCheck":true,"target":"esnext","module":"preserve","moduleResolution":"bundler","moduleDetection":"force","allowImportingTsExtensions":true,"allowJs":true,"types":[],"jsx":"react-jsx"}), imports: BTreeMap::new(), import_candidates: Vec::new(), per_file_options: BTreeMap::new(), diagnostics: BTreeMap::new(), type_references: BTreeMap::new(), path_references: BTreeMap::new(), globals: BTreeSet::new(), }; if self.caps.unrestricted_js_exec() { for path in &types.config_inputs { if let Some(parent) = path.ancestors().skip(1).find(|p| p.is_dir()) { self.watch(parent, false); } } } for (name, text) in &types.sources { let identity = uri(name)?; let local = graph.is_workspace_source(name); if avril_runtime::modules::data_url(name).is_some() { let kind = if types.source_types[name].is_typescript() { ScriptKind::TypeScript } else { ScriptKind::JavaScript }; input.script_kinds.insert(identity.clone(), kind); } input.sources.push(Source { uri: identity.clone(), text: text.clone(), read_only: !local, }); if let Some(options) = types.compiler_options.get(name) { input.per_file_options.insert(identity, json!(options)); } if local && self.caps.unrestricted_js_exec() { // Parent watches detect new/moved configs as well as source changes. for parent in Path::new(name).ancestors().skip(1) { self.watch(parent, false); } } } for (name, imports) in &types.edges { for ((mode, specifier), target) in imports { if target.starts_with("avril:") { continue; } // Ambient builtin declarations. let mut target = target; let mut seen = BTreeSet::new(); while let Some(next) = types.redirects.get(target) { if !seen.insert(target) || !types.sources.contains_key(next) { break; } target = next; } if types.sources.contains_key(target) { let targets = input .imports .entry((uri(name)?, specifier.clone())) .or_default(); if mode.is_require() { targets.require = Some(uri(target)?); } else { targets.import = Some(uri(target)?); } } } } for (name, errors) in &types.unresolved { for (specifier, error) in errors { input .diagnostics .entry(uri(name)?) .or_default() .push(format!("{specifier}: {error}")); } } for errors in [ &types.unavailable, &types.package_errors, &types.compiler_errors, ] { for (name, error) in errors { input .diagnostics .entry(uri(name)?) .or_default() .push(error.clone()); } } input.globals = types .globals .iter() .filter(|name| types.sources.contains_key(*name)) .map(|name| uri(name)) .collect::>()?; for (references, targets) in [ (&types.type_references, &mut input.type_references), (&types.path_references, &mut input.path_references), ] { for ((name, reference), target) in references { if types.sources.contains_key(target) { targets.insert((uri(name)?, reference.clone()), uri(target)?); } } } for ((from, specifier), target) in graph.type_import_candidates(&types, overlays.keys()).await { input .import_candidates .push((uri(&from)?, uri(&target)?, specifier)); } // Acquisition may populate the cache, but editor refreshes never write locks, // snapshots, tsconfig files, or application sources. Ok(input) }) } } /// Builds the type-checking generation for one on-disk entry module, without /// editor overlays or filesystem watches. pub(crate) async fn check_input( root: PathBuf, caps: Arc, options: Options, explicit_lock: bool, automatic_types: bool, entry: &str, ) -> avril_lsp::Result { let mut project = LiveProject { root, caps, options, explicit_lock, automatic_types, dirty: Arc::new(AtomicBool::new(false)), rewatch: Arc::new(AtomicBool::new(false)), watcher: None, watched: BTreeMap::new(), tracked_config: Arc::new(Mutex::new(BTreeSet::new())), }; project.input(BTreeMap::new(), &[entry.to_owned()]).await } pub struct Args { pub typescript: PathBuf, pub project: Option, pub entry: crate::fragments::Entry, } /// `avril lsp` argument parsing failed. #[derive(Debug, thiserror::Error)] pub enum LspParseError { #[error(transparent)] Generic(#[from] ParseError), #[error("lsp requires --typescript=PATH to the native backend")] MissingBackend, } pub fn parse(args: &[String]) -> std::result::Result { let cwd = crate::fragments::working_dir()?; let mut parser = crate::fragments::Parser::new("lsp", args.to_vec(), &cwd)?; let (mut typescript, mut project) = (None, None); while let Some(arg) = parser.next()? { match arg { Arg::Positional(arg) => parser.command.check(&arg).map_err(ParseError::Check)?, Arg::Flag(arg) if parser.path_once(&arg, "--typescript", &mut typescript)? => {} Arg::Flag(arg) if parser.path_once(&arg, "--project", &mut project)? => {} Arg::Flag(arg) => return Err(reject(&arg, &[]).into()), } } let typescript = typescript.ok_or(LspParseError::MissingBackend)?; // The language server takes no entry module: open documents are the roots. let entry = parser.synthetic(".avril-lsp.js")?; Ok(Args { typescript: PathBuf::from(typescript), project, entry, }) } pub fn run(args: Args) -> Result<(), Box> { let Args { typescript: binary, project, entry, } = args; let root = std::fs::canonicalize(project.as_deref().unwrap_or("."))?; if !root.is_dir() { return Err("--project must name a workspace directory".into()); } let dirty = Arc::new(AtomicBool::new(false)); let rewatch = Arc::new(AtomicBool::new(false)); let tracked_config = Arc::new(Mutex::new(BTreeSet::::new())); let mut ignore_builder = ignore::gitignore::GitignoreBuilder::new(&root); let _ = ignore_builder.add(root.join(".gitignore")); let _ = ignore_builder.add(root.join(".ignore")); let ignored = ignore_builder.build()?; let watcher = notify::recommended_watcher({ let dirty = dirty.clone(); let rewatch = rewatch.clone(); let cache = avril_runtime::caps::PathTarget::for_creation(&entry.packages.cache)?.into_path(); let tracked_config = tracked_config.clone(); let root = root.clone(); let mut ignored = ignored.clone(); move |event: notify::Result| { // Cache acquisition is an output of refreshing, not an input change. // Watch directories coarsely so arbitrary extends/lock filenames and // missing-file creation or directory renames cannot leave stale graphs. if event.map_or(true, |e| { // Reading ignore rules generates access events of its own. if matches!(e.kind, notify::EventKind::Access(_)) && !e.need_rescan() { return false; } let configs = tracked_config.lock().unwrap(); if e.paths .iter() .any(|path| path == &root.join(".gitignore") || path == &root.join(".ignore")) { let mut builder = ignore::gitignore::GitignoreBuilder::new(&root); let _ = builder.add(root.join(".gitignore")); let _ = builder.add(root.join(".ignore")); if let Ok(updated) = builder.build() { ignored = updated; } } let relevant = e.need_rescan() || e.paths.iter().any(|path| { if configs.contains(path) { return true; } if path.starts_with(&cache) || (path.starts_with(&root) && ignored .matched_path_or_any_parents(path, path.is_dir()) .is_ignore()) || path.strip_prefix(&root).unwrap_or(path).components().any( |component| { matches!( component.as_os_str().to_str(), Some(".git" | ".jj" | ".hg" | ".svn" | "node_modules") ) }, ) { return false; } if path.starts_with(root.join(".avril").join("npm")) { return false; } let directory_event = matches!( e.kind, notify::EventKind::Create(notify::event::CreateKind::Folder) | notify::EventKind::Remove(notify::event::RemoveKind::Folder) | notify::EventKind::Modify(notify::event::ModifyKind::Name(_)) ); if directory_event && (path.starts_with(&root) || configs.iter().any(|config| config.starts_with(path))) { return true; } let name = path .file_name() .and_then(|name| name.to_str()) .unwrap_or_default(); if !path.starts_with(&root) { return false; } if matches!( name, "package.json" | "avril.conf" | "avril.lock" | ".gitignore" | ".ignore" ) || name.starts_with("tsconfig") { return true; } matches!( path.extension().and_then(|extension| extension.to_str()), Some("ts" | "tsx" | "js" | "jsx" | "mts" | "cts" | "mjs" | "cjs") ) }); if relevant && (e.need_rescan() || matches!( e.kind, notify::EventKind::Remove(notify::event::RemoveKind::Folder) | notify::EventKind::Modify(notify::event::ModifyKind::Name(_)) )) { rewatch.store(true, Ordering::Release); } relevant }) { dirty.store(true, Ordering::Release); } } })?; let mut service = LiveProject { root: root.clone(), caps: entry.caps, options: entry.packages, explicit_lock: entry.explicit_lock, automatic_types: entry.automatic_types, dirty, rewatch, watcher: Some(watcher), watched: BTreeMap::new(), tracked_config, }; if service.caps.unrestricted_js_exec() { service.watch(&root, true); } let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .build()?; let result = runtime.block_on(avril_lsp::serve( tokio::io::stdin(), tokio::io::stdout(), binary, Box::new(service), )); // An editor can send exit without closing stdin; its blocking read cannot // be cancelled. Backend teardown is independent of that read. runtime.shutdown_background(); result.map_err(|e| -> Box { e }) }