char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somsupport data urls1c8ffed

main
21.1 KiB523 linesraw
1#[cfg(test)]
2mod tests;
3
4use avril_core::ModuleLoader;
5use avril_lsp::{Documents, ProjectInput, ProjectService, Refresh, ScriptKind, Source};
6use avril_runtime::{caps::Capabilities, registry::Options};
7use notify::Watcher;
8use serde_json::json;
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    path::{Path, PathBuf},
12    sync::{
13        Arc, Mutex,
14        atomic::{AtomicBool, Ordering},
15    },
16};
17
18use crate::cli::ParseError;
19use crate::fragments::{Arg, reject};
20
21struct LiveProject {
22    root: PathBuf,
23    caps: Arc<Capabilities>,
24    options: Options,
25    explicit_lock: bool,
26    automatic_types: bool,
27    dirty: Arc<AtomicBool>,
28    rewatch: Arc<AtomicBool>,
29    watcher: Option<notify::RecommendedWatcher>,
30    watched: BTreeMap<PathBuf, bool>,
31    tracked_config: Arc<Mutex<BTreeSet<PathBuf>>>,
32}
33impl LiveProject {
34    fn watch(&mut self, path: &Path, recursive: bool) {
35        let Some(watcher) = &mut self.watcher else {
36            return;
37        };
38        if self
39            .watched
40            .get(path)
41            .is_some_and(|current| *current || !recursive)
42            || self
43                .watched
44                .iter()
45                .any(|(root, recursive)| *recursive && path.starts_with(root))
46        {
47            return;
48        }
49        if watcher
50            .watch(
51                path,
52                if recursive {
53                    notify::RecursiveMode::Recursive
54                } else {
55                    notify::RecursiveMode::NonRecursive
56                },
57            )
58            .is_ok()
59        {
60            self.watched.insert(path.to_owned(), recursive);
61        }
62    }
63}
64impl ProjectService for LiveProject {
65    fn changed(&mut self) -> bool {
66        let dirty = self.dirty.swap(false, Ordering::AcqRel);
67        if self.rewatch.swap(false, Ordering::AcqRel) {
68            // A watch follows an inode, not a directory recreated at the same path.
69            for (path, recursive) in std::mem::take(&mut self.watched) {
70                if let Some(watcher) = &mut self.watcher {
71                    let _ = watcher.unwatch(&path);
72                }
73                self.watch(&path, recursive);
74            }
75        }
76        dirty
77    }
78    fn refresh(&mut self, documents: Documents) -> Refresh<'_> {
79        Box::pin(async move { self.input(documents, &[]).await })
80    }
81}
82
83impl LiveProject {
84    fn input(&mut self, documents: Documents, extra_roots: &[String]) -> Refresh<'_> {
85        let extra_roots = extra_roots.to_vec();
86        Box::pin(async move {
87            if self.caps.unrestricted_js_exec() {
88                self.watch(&self.root.clone(), true);
89            }
90            let authority = avril_runtime::authority::SourceAuthority::execute(self.caps.clone());
91            let anchor = self.root.join(".avril-lsp.js");
92            // Broken configs are reported on their modules by the type collector;
93            // they must not prevent unrelated open documents from being checked.
94            let root = avril_runtime::modules::ModuleGraph::project_root_for_repl(
95                &authority,
96                anchor.to_str().ok_or("Project path must be UTF-8")?,
97            )
98            .unwrap_or_else(|_| self.root.clone());
99            let mut options = self.options.clone();
100            if !self.explicit_lock {
101                options.lock = root.join("avril.lock");
102            }
103            let lock_path = options.lock.clone();
104            options.project_root = root.clone();
105            if self.caps.unrestricted_js_exec() {
106                if let Some(parent) = options.lock.ancestors().skip(1).find(|p| p.is_dir()) {
107                    self.watch(parent, false);
108                }
109                if let Some(vendor) = &options.vendor {
110                    self.watch(vendor, true);
111                }
112            }
113            let discovered = if extra_roots.is_empty() && self.caps.unrestricted_js_exec() {
114                let workspace = self.root.clone();
115                let cache = options.cache.clone();
116                let vendor = options.vendor.clone();
117                tokio::task::spawn_blocking(move || -> avril_lsp::Result<Vec<String>> {
118                    let mut sources = Vec::new();
119                    for entry in ignore::WalkBuilder::new(workspace)
120                        .require_git(false)
121                        .filter_entry(move |entry| {
122                            entry.path() != cache
123                                && vendor.as_ref().is_none_or(|path| entry.path() != path)
124                                && entry.file_name() != "node_modules"
125                        })
126                        .build()
127                    {
128                        let entry = entry?;
129                        if entry.file_type().is_some_and(|kind| kind.is_file())
130                            && matches!(
131                                entry.path().extension().and_then(|s| s.to_str()),
132                                Some("ts" | "tsx" | "js" | "jsx" | "mts" | "cts" | "mjs" | "cjs")
133                            )
134                        {
135                            sources.push(
136                                entry
137                                    .path()
138                                    .to_str()
139                                    .ok_or("Module path must be UTF-8")?
140                                    .to_owned(),
141                            );
142                        }
143                    }
144                    sources.sort();
145                    Ok(sources)
146                })
147                .await??
148            } else {
149                Vec::new()
150            };
151            let graph = avril_runtime::modules::ModuleGraph::open(
152                tokio::runtime::Handle::current(),
153                authority,
154                options,
155            )?;
156            graph.set_automatic_types(self.automatic_types);
157            graph.set_typescript_version(avril_lsp::BACKEND_VERSION)?;
158            let mut overlays = BTreeMap::new();
159            for (uri, text) in documents {
160                let path = url::Url::parse(&uri)?
161                    .to_file_path()
162                    .map_err(|_| "Only local source documents can be edited")?;
163                overlays.insert(
164                    path.to_str().ok_or("Module path must be UTF-8")?.to_owned(),
165                    text,
166                );
167            }
168            let mut roots: Vec<_> = overlays.keys().cloned().collect();
169            roots.extend(extra_roots.iter().cloned());
170            roots.extend(discovered);
171            let types = graph.type_graph(&roots, &overlays).await?;
172            let mut tracked = types.config_inputs.clone();
173            tracked.insert(lock_path);
174            tracked.extend(
175                types
176                    .sources
177                    .keys()
178                    .filter(|name| graph.is_workspace_source(name))
179                    .map(PathBuf::from),
180            );
181            *self.tracked_config.lock().unwrap() = tracked;
182            let uri = |name: &str| -> avril_lsp::Result<String> {
183                avril_lsp::canonical_uri(&graph.module_url(name).ok_or("Invalid module identity")?)
184            };
185            let mut input = ProjectInput {
186                script_kinds: BTreeMap::new(),
187                sources: crate::stdlib::declarations()
188                    .map(|(name, text)| Source {
189                        uri: format!("avril:/runtime/stdlib/{name}"),
190                        text: text.into(),
191                        read_only: true,
192                    })
193                    .collect(),
194                // Dynamic import must remain import-mode even in CommonJS files.
195                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"}),
196                imports: BTreeMap::new(),
197                import_candidates: Vec::new(),
198                per_file_options: BTreeMap::new(),
199                diagnostics: BTreeMap::new(),
200                type_references: BTreeMap::new(),
201                path_references: BTreeMap::new(),
202                globals: BTreeSet::new(),
203            };
204            if self.caps.unrestricted_js_exec() {
205                for path in &types.config_inputs {
206                    if let Some(parent) = path.ancestors().skip(1).find(|p| p.is_dir()) {
207                        self.watch(parent, false);
208                    }
209                }
210            }
211            for (name, text) in &types.sources {
212                let identity = uri(name)?;
213                let local = graph.is_workspace_source(name);
214                if avril_runtime::modules::data_url(name).is_some() {
215                    let kind = if types.source_types[name].is_typescript() {
216                        ScriptKind::TypeScript
217                    } else {
218                        ScriptKind::JavaScript
219                    };
220                    input.script_kinds.insert(identity.clone(), kind);
221                }
222                input.sources.push(Source {
223                    uri: identity.clone(),
224                    text: text.clone(),
225                    read_only: !local,
226                });
227                if let Some(options) = types.compiler_options.get(name) {
228                    input.per_file_options.insert(identity, json!(options));
229                }
230                if local && self.caps.unrestricted_js_exec() {
231                    // Parent watches detect new/moved configs as well as source changes.
232                    for parent in Path::new(name).ancestors().skip(1) {
233                        self.watch(parent, false);
234                    }
235                }
236            }
237            for (name, imports) in &types.edges {
238                for ((mode, specifier), target) in imports {
239                    if target.starts_with("avril:") {
240                        continue;
241                    } // Ambient builtin declarations.
242                    let mut target = target;
243                    let mut seen = BTreeSet::new();
244                    while let Some(next) = types.redirects.get(target) {
245                        if !seen.insert(target) || !types.sources.contains_key(next) {
246                            break;
247                        }
248                        target = next;
249                    }
250                    if types.sources.contains_key(target) {
251                        let targets = input
252                            .imports
253                            .entry((uri(name)?, specifier.clone()))
254                            .or_default();
255                        if mode.is_require() {
256                            targets.require = Some(uri(target)?);
257                        } else {
258                            targets.import = Some(uri(target)?);
259                        }
260                    }
261                }
262            }
263            for (name, errors) in &types.unresolved {
264                for (specifier, error) in errors {
265                    input
266                        .diagnostics
267                        .entry(uri(name)?)
268                        .or_default()
269                        .push(format!("{specifier}: {error}"));
270                }
271            }
272            for errors in [
273                &types.unavailable,
274                &types.package_errors,
275                &types.compiler_errors,
276            ] {
277                for (name, error) in errors {
278                    input
279                        .diagnostics
280                        .entry(uri(name)?)
281                        .or_default()
282                        .push(error.clone());
283                }
284            }
285            input.globals = types
286                .globals
287                .iter()
288                .filter(|name| types.sources.contains_key(*name))
289                .map(|name| uri(name))
290                .collect::<avril_lsp::Result<_>>()?;
291            for (references, targets) in [
292                (&types.type_references, &mut input.type_references),
293                (&types.path_references, &mut input.path_references),
294            ] {
295                for ((name, reference), target) in references {
296                    if types.sources.contains_key(target) {
297                        targets.insert((uri(name)?, reference.clone()), uri(target)?);
298                    }
299                }
300            }
301            for ((from, specifier), target) in
302                graph.type_import_candidates(&types, overlays.keys()).await
303            {
304                input
305                    .import_candidates
306                    .push((uri(&from)?, uri(&target)?, specifier));
307            }
308            // Acquisition may populate the cache, but editor refreshes never write locks,
309            // snapshots, tsconfig files, or application sources.
310            Ok(input)
311        })
312    }
313}
314
315/// Builds the type-checking generation for one on-disk entry module, without
316/// editor overlays or filesystem watches.
317pub(crate) async fn check_input(
318    root: PathBuf,
319    caps: Arc<Capabilities>,
320    options: Options,
321    explicit_lock: bool,
322    automatic_types: bool,
323    entry: &str,
324) -> avril_lsp::Result<ProjectInput> {
325    let mut project = LiveProject {
326        root,
327        caps,
328        options,
329        explicit_lock,
330        automatic_types,
331        dirty: Arc::new(AtomicBool::new(false)),
332        rewatch: Arc::new(AtomicBool::new(false)),
333        watcher: None,
334        watched: BTreeMap::new(),
335        tracked_config: Arc::new(Mutex::new(BTreeSet::new())),
336    };
337    project.input(BTreeMap::new(), &[entry.to_owned()]).await
338}
339
340pub struct Args {
341    pub typescript: PathBuf,
342    pub project: Option<String>,
343    pub entry: crate::fragments::Entry,
344}
345
346/// `avril lsp` argument parsing failed.
347#[derive(Debug, thiserror::Error)]
348pub enum LspParseError {
349    #[error(transparent)]
350    Generic(#[from] ParseError),
351    #[error("lsp requires --typescript=PATH to the native backend")]
352    MissingBackend,
353}
354
355pub fn parse(args: &[String]) -> std::result::Result<Args, LspParseError> {
356    let cwd = crate::fragments::working_dir()?;
357    let mut parser = crate::fragments::Parser::new("lsp", args.to_vec(), &cwd)?;
358    let (mut typescript, mut project) = (None, None);
359    while let Some(arg) = parser.next()? {
360        match arg {
361            Arg::Positional(arg) => parser.command.check(&arg).map_err(ParseError::Check)?,
362            Arg::Flag(arg) if parser.path_once(&arg, "--typescript", &mut typescript)? => {}
363            Arg::Flag(arg) if parser.path_once(&arg, "--project", &mut project)? => {}
364            Arg::Flag(arg) => return Err(reject(&arg, &[]).into()),
365        }
366    }
367    let typescript = typescript.ok_or(LspParseError::MissingBackend)?;
368    // The language server takes no entry module: open documents are the roots.
369    let entry = parser.synthetic(".avril-lsp.js")?;
370    Ok(Args {
371        typescript: PathBuf::from(typescript),
372        project,
373        entry,
374    })
375}
376
377pub fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
378    let Args {
379        typescript: binary,
380        project,
381        entry,
382    } = args;
383    let root = std::fs::canonicalize(project.as_deref().unwrap_or("."))?;
384    if !root.is_dir() {
385        return Err("--project must name a workspace directory".into());
386    }
387    let dirty = Arc::new(AtomicBool::new(false));
388    let rewatch = Arc::new(AtomicBool::new(false));
389    let tracked_config = Arc::new(Mutex::new(BTreeSet::<PathBuf>::new()));
390    let mut ignore_builder = ignore::gitignore::GitignoreBuilder::new(&root);
391    let _ = ignore_builder.add(root.join(".gitignore"));
392    let _ = ignore_builder.add(root.join(".ignore"));
393    let ignored = ignore_builder.build()?;
394    let watcher = notify::recommended_watcher({
395        let dirty = dirty.clone();
396        let rewatch = rewatch.clone();
397        let cache =
398            avril_runtime::caps::PathTarget::for_creation(&entry.packages.cache)?.into_path();
399        let tracked_config = tracked_config.clone();
400        let root = root.clone();
401        let mut ignored = ignored.clone();
402        move |event: notify::Result<notify::Event>| {
403            // Cache acquisition is an output of refreshing, not an input change.
404            // Watch directories coarsely so arbitrary extends/lock filenames and
405            // missing-file creation or directory renames cannot leave stale graphs.
406            if event.map_or(true, |e| {
407                // Reading ignore rules generates access events of its own.
408                if matches!(e.kind, notify::EventKind::Access(_)) && !e.need_rescan() {
409                    return false;
410                }
411                let configs = tracked_config.lock().unwrap();
412                if e.paths
413                    .iter()
414                    .any(|path| path == &root.join(".gitignore") || path == &root.join(".ignore"))
415                {
416                    let mut builder = ignore::gitignore::GitignoreBuilder::new(&root);
417                    let _ = builder.add(root.join(".gitignore"));
418                    let _ = builder.add(root.join(".ignore"));
419                    if let Ok(updated) = builder.build() {
420                        ignored = updated;
421                    }
422                }
423                let relevant = e.need_rescan()
424                    || e.paths.iter().any(|path| {
425                        if configs.contains(path) {
426                            return true;
427                        }
428                        if path.starts_with(&cache)
429                            || (path.starts_with(&root)
430                                && ignored
431                                    .matched_path_or_any_parents(path, path.is_dir())
432                                    .is_ignore())
433                            || path.strip_prefix(&root).unwrap_or(path).components().any(
434                                |component| {
435                                    matches!(
436                                        component.as_os_str().to_str(),
437                                        Some(".git" | ".jj" | ".hg" | ".svn" | "node_modules")
438                                    )
439                                },
440                            )
441                        {
442                            return false;
443                        }
444                        if path.starts_with(root.join(".avril").join("npm")) {
445                            return false;
446                        }
447                        let directory_event = matches!(
448                            e.kind,
449                            notify::EventKind::Create(notify::event::CreateKind::Folder)
450                                | notify::EventKind::Remove(notify::event::RemoveKind::Folder)
451                                | notify::EventKind::Modify(notify::event::ModifyKind::Name(_))
452                        );
453                        if directory_event
454                            && (path.starts_with(&root)
455                                || configs.iter().any(|config| config.starts_with(path)))
456                        {
457                            return true;
458                        }
459                        let name = path
460                            .file_name()
461                            .and_then(|name| name.to_str())
462                            .unwrap_or_default();
463                        if !path.starts_with(&root) {
464                            return false;
465                        }
466                        if matches!(
467                            name,
468                            "package.json" | "avril.conf" | "avril.lock" | ".gitignore" | ".ignore"
469                        ) || name.starts_with("tsconfig")
470                        {
471                            return true;
472                        }
473                        matches!(
474                            path.extension().and_then(|extension| extension.to_str()),
475                            Some("ts" | "tsx" | "js" | "jsx" | "mts" | "cts" | "mjs" | "cjs")
476                        )
477                    });
478                if relevant
479                    && (e.need_rescan()
480                        || matches!(
481                            e.kind,
482                            notify::EventKind::Remove(notify::event::RemoveKind::Folder)
483                                | notify::EventKind::Modify(notify::event::ModifyKind::Name(_))
484                        ))
485                {
486                    rewatch.store(true, Ordering::Release);
487                }
488                relevant
489            }) {
490                dirty.store(true, Ordering::Release);
491            }
492        }
493    })?;
494    let mut service = LiveProject {
495        root: root.clone(),
496        caps: entry.caps,
497        options: entry.packages,
498        explicit_lock: entry.explicit_lock,
499        automatic_types: entry.automatic_types,
500        dirty,
501        rewatch,
502        watcher: Some(watcher),
503        watched: BTreeMap::new(),
504        tracked_config,
505    };
506    if service.caps.unrestricted_js_exec() {
507        service.watch(&root, true);
508    }
509    let runtime = tokio::runtime::Builder::new_multi_thread()
510        .worker_threads(2)
511        .enable_all()
512        .build()?;
513    let result = runtime.block_on(avril_lsp::serve(
514        tokio::io::stdin(),
515        tokio::io::stdout(),
516        binary,
517        Box::new(service),
518    ));
519    // An editor can send exit without closing stdin; its blocking read cannot
520    // be cancelled. Backend teardown is independent of that read.
521    runtime.shutdown_background();
522    result.map_err(|e| -> Box<dyn std::error::Error> { e })
523}