char/avril

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

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

Charlotte Somexperiment: automatic types for npm packages51266f9

main
14.4 KiB392 linesraw
1use std::cell::RefCell;
2use std::collections::HashSet;
3use std::rc::Rc;
4use std::sync::Arc;
5
6use avril_runtime::caps::{Capabilities, Initial};
7use avril_runtime::config_edit::{self, Addition, Change};
8use avril_runtime::import_map::ImportMap;
9use avril_runtime::package_config;
10use avril_runtime::registry::{
11    Options, PackageStore, ProgressEvent, ProgressSink, npm::resolution::NpmResolver,
12    parse_jsr_request,
13};
14use futures::executor::block_on;
15
16use crate::{cli, cli::ParseError};
17use avril_runtime::modules::ModuleGraph;
18
19pub struct Args {
20    config: Option<String>,
21    pre: bool,
22    offline: bool,
23    automatic_types: bool,
24    jsr_registry: url::Url,
25    npm_registry: url::Url,
26    cache_dir: Option<String>,
27    values: Vec<String>,
28}
29
30/// `avril add` argument parsing failed.
31#[derive(Debug, thiserror::Error)]
32pub enum AddParseError {
33    #[error(transparent)]
34    Generic(#[from] ParseError),
35    #[error("Expected one config path")]
36    DuplicateConfig,
37    #[error("{0}")]
38    Url(#[from] url::ParseError),
39}
40
41pub fn parse(args: &[String]) -> std::result::Result<Args, AddParseError> {
42    let command = cli::command("add").ok_or_else(|| ParseError::Usage(cli::usage()))?;
43    let mut parsed = Args {
44        config: None,
45        pre: false,
46        offline: false,
47        automatic_types: true,
48        jsr_registry: url::Url::parse("https://jsr.io/").unwrap(),
49        npm_registry: url::Url::parse("https://registry.npmjs.org/").unwrap(),
50        cache_dir: None,
51        values: Vec::new(),
52    };
53    for arg in args {
54        if arg.starts_with('-') {
55            command.check(arg).map_err(ParseError::Check)?;
56        }
57        match arg.as_str() {
58            "--pre" => parsed.pre = true,
59            "--offline" => parsed.offline = true,
60            "--no-types-packages" => parsed.automatic_types = false,
61            _ if arg.starts_with('-') => {
62                let (flag, value) = match arg.split_once('=') {
63                    Some((flag, value)) if !value.is_empty() => {
64                        (flag.strip_prefix("--").unwrap_or(flag).to_owned(), value)
65                    }
66                    Some((flag, _)) => {
67                        return Err(ParseError::MissingValue {
68                            flag: flag.strip_prefix("--").unwrap_or(flag).to_owned(),
69                            equals: "=",
70                        }
71                        .into());
72                    }
73                    None => {
74                        return Err(ParseError::MissingValue {
75                            flag: arg.strip_prefix("--").unwrap_or(arg).to_owned(),
76                            equals: "",
77                        }
78                        .into());
79                    }
80                };
81                match flag.as_str() {
82                    "config" => {
83                        if parsed.config.replace(value.to_owned()).is_some() {
84                            return Err(AddParseError::DuplicateConfig);
85                        }
86                    }
87                    "jsr-registry" => parsed.jsr_registry = url::Url::parse(value)?,
88                    "npm-registry" => parsed.npm_registry = url::Url::parse(value)?,
89                    "cache-dir" => parsed.cache_dir = Some(value.to_owned()),
90                    _ => return Err(ParseError::UnexpectedValue { flag }.into()),
91                }
92            }
93            _ => parsed.values.push(arg.clone()),
94        }
95    }
96    Ok(parsed)
97}
98
99/// `avril add`: pins unversioned targets to the newest release, edits the config, then
100/// caches what the new mapping can import, as `avril cache` would.
101pub fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
102    let cwd = std::env::current_dir()?.canonicalize()?;
103    let Args {
104        config,
105        pre,
106        offline,
107        automatic_types,
108        jsr_registry,
109        npm_registry,
110        cache_dir,
111        values,
112    } = args;
113    let cache = cache_dir
114        .map(|dir| cwd.join(dir))
115        .unwrap_or_else(|| cli::default_cache(&cwd));
116    let config = config.map(|config| cwd.join(config));
117    let command = cli::command("add").ok_or_else(cli::usage)?;
118    let (alias, target) = match values.as_slice() {
119        [target] => (None, target.as_str()),
120        [alias, target] => (Some(alias.as_str()), target.as_str()),
121        _ => return Err(command.usage().into()),
122    };
123    let caps = Capabilities::new(Initial::new(false), cwd.clone());
124    let authority = avril_runtime::authority::SourceAuthority::execute(caps.clone());
125    let config = match config {
126        Some(config) => config,
127        None => ModuleGraph::project_root_for_add(
128            &authority,
129            cwd.join("avril.conf")
130                .to_str()
131                .ok_or("Paths must be UTF-8")?,
132        )?
133        .join("avril.conf"),
134    };
135    let directory = config.parent().ok_or("Config needs a parent directory")?;
136    let host = avril_host::Host::new()?;
137    let store = Arc::new(
138        PackageStore::new_with_authority(
139            host.handle(),
140            Options {
141                jsr_registry,
142                npm_registry,
143                cache,
144                lock: directory.join("avril.lock"),
145                project_root: directory.to_owned(),
146                offline,
147                frozen: false,
148                update: false,
149                ephemeral_lock: false,
150                vendor: None,
151            },
152            authority.clone(),
153        )?
154        .with_progress(progress_sink()),
155    );
156    let npm_target = target.starts_with("npm:");
157    let target = match config_edit::unversioned(target)? {
158        Some(name) => {
159            if npm_target {
160                if pre {
161                    return Err(
162                        "--pre does not apply to npm: targets; select a prerelease tag explicitly"
163                            .into(),
164                    );
165                }
166                phase(format!("Selecting version for {target:?}..."));
167                let version = block_on(store.npm_select_version(target))?;
168                config_edit::with_caret(target, &name, &version)
169            } else {
170                phase(format!("Selecting version for {target:?}..."));
171                let version = block_on(store.latest(&name, pre))?.ok_or_else(|| {
172                    if pre {
173                        format!("{name} has no non-yanked versions")
174                    } else {
175                        format!(
176                            "{name} has no non-yanked stable versions; --pre allows prereleases"
177                        )
178                    }
179                })?;
180                config_edit::with_caret(target, &name, &version)
181            }
182        }
183        None if pre => {
184            if npm_target {
185                return Err(
186                    "--pre does not apply to npm: targets; select a prerelease tag explicitly"
187                        .into(),
188                );
189            }
190            return Err("--pre only applies to targets without a version".into());
191        }
192        None => target.to_owned(),
193    };
194    let package = config_edit::whole_package(&target)?;
195    // Packages belong in deps; only an alias for a subpath or prefix needs imports.
196    let addition = match alias {
197        Some(alias)
198            if !config_edit::selects_whole_package(&target)?
199                || !package_config::valid_name(alias) =>
200        {
201            Addition::Import { alias, target: &target }
202        }
203        name => Addition::Dependency { name, package: &package },
204    };
205    let binding = addition.binding()?;
206    let change = config_edit::add_file(&config, &addition)?;
207    report(&change, &binding, addition.target(), &config, &cwd);
208    let prefix = match addition {
209        Addition::Import { alias, target } if alias.ends_with('/') => {
210            Some((alias.to_owned(), target.to_owned()))
211        }
212        Addition::Import { .. } => None,
213        Addition::Dependency { package, .. } => {
214            Some((format!("{binding}/"), format!("{package}/")))
215        }
216    };
217    let type_target = target.clone();
218    let requests = if npm_target {
219        if prefix.is_some() {
220            phase(format!("Preparing exports for {target:?}..."));
221        }
222        npm_add_requests(&store, &target, prefix.as_ref())?
223    } else if let Some((alias, target)) = prefix {
224        let base = url::Url::from_file_path(&config).map_err(|_| "Paths must be absolute")?;
225        let map = ImportMap::parse(
226            &format!(
227                "imports: {{{}: {}}}",
228                serde_json::to_string(&alias)?,
229                serde_json::to_string(&target)?
230            ),
231            &base,
232        )?;
233        let referrer = base.join(".avril-add.js")?;
234        let export =
235            |request: &str| parse_jsr_request(request).map(|request| request.export_name());
236        phase(format!("Preparing exports for {target:?}..."));
237        // The prefix's own normalized subpath, found by probing beneath it.
238        let probe = export(&format!("{target}x"))?;
239        let prefix = &probe["./".len()..probe.len() - 1];
240        let mut requests = Vec::new();
241        for key in block_on(store.exports(&target))? {
242            // Each export the prefix itself or a suffix beneath it reaches under the
243            // runtime's import map rules, which reject escapes and encoded separators.
244            // Normalization drops one trailing slash, so a key ending in '/' needs another.
245            let suffix = key
246                .strip_prefix("./")
247                .and_then(|key| key.strip_prefix(prefix));
248            let mut suffixes = [String::new()].into_iter().chain(
249                suffix
250                    .into_iter()
251                    .flat_map(|suffix| [suffix.to_owned(), format!("{suffix}/")]),
252            );
253            if let Some(request) = suffixes.find_map(|suffix| {
254                let request = map.resolve(&format!("{alias}{suffix}"), &referrer).ok()??;
255                (export(&request).ok()? == key).then_some(request)
256            }) {
257                requests.push(request);
258            }
259        }
260        requests
261    } else {
262        vec![target]
263    };
264    let graph = ModuleGraph::open_shared(authority, store.clone());
265    graph.set_automatic_types(automatic_types);
266    graph.set_typescript_version(avril_lsp::BACKEND_VERSION)?;
267    // Warm through the updated config so joint root contexts match what
268    // the runtime prepares: registry-referrer contexts would pin different
269    // roots than a frozen run looks up once other deps or peers exist.
270    let referrer = if npm_target {
271        config.to_str().ok_or("Paths must be UTF-8")?.to_owned()
272    } else {
273        store.options().jsr_registry.to_string()
274    };
275    let warn_unsupported = Rc::new({
276        let warned = RefCell::new(HashSet::<String>::new());
277        move |specifier: &str, referrer: &str| {
278            if warned.borrow_mut().insert(specifier.to_owned()) {
279                use std::io::Write;
280                let _ = writeln!(
281                    std::io::stderr(),
282                    "Warning: skipping unsupported Node.js builtin import {specifier:?} from {referrer:?}; builtin modules remain unsupported at runtime",
283                );
284            }
285        }
286    });
287    let type_requests = if requests.is_empty() {
288        vec![type_target]
289    } else {
290        requests.clone()
291    };
292    for request in requests {
293        phase(format!("Caching {request:?}..."));
294        block_on(graph.prepare_for_add(request, referrer.clone(), warn_unsupported.clone()))?;
295    }
296    for request in type_requests {
297        phase(format!("Caching types for {request:?}..."));
298        block_on(graph.prepare_types_for_request(&request, &referrer))?;
299    }
300    Ok(())
301}
302
303/// Cacheable requests for a whole npm package or the export prefix a
304/// prefix target names: exact subpaths resolve as a single request,
305/// anything else enumerates ESM exports beneath that scope only.
306fn npm_add_requests(
307    store: &Arc<PackageStore>,
308    target: &str,
309    prefix: Option<&(String, String)>,
310) -> Result<Vec<String>, Box<dyn std::error::Error>> {
311    let Some((_, scope)) = prefix else {
312        return Ok(vec![target.to_owned()]);
313    };
314    let resolver = NpmResolver::new(store.clone());
315    Ok(block_on(resolver.export_requests(scope))?)
316}
317
318fn phase(message: String) {
319    use std::io::Write;
320    let message = crate::progress::color_action(message, avril_ext_console::colors(true));
321    let _ = writeln!(std::io::stderr(), "{message}");
322}
323
324fn progress_sink() -> ProgressSink {
325    Arc::new(|event: &ProgressEvent| match event {
326        ProgressEvent::NpmMetadata { name } => {
327            phase(format!("Fetching npm metadata for {name:?}..."));
328        }
329        ProgressEvent::NpmArchive { name, version } => {
330            phase(format!(
331                "Downloading npm archive for {:?}...",
332                format!("{name}@{version}"),
333            ));
334        }
335        ProgressEvent::NpmMaterialized { name, version } => {
336            phase(format!(
337                "Materialized npm package {:?}...",
338                format!("{name}@{version}"),
339            ));
340        }
341        ProgressEvent::SourceDownload { .. }
342        | ProgressEvent::NpmMaterializing { .. }
343        | ProgressEvent::NpmResolving => {}
344    })
345}
346
347fn report(
348    change: &Change,
349    alias: &str,
350    target: &str,
351    config: &std::path::Path,
352    cwd: &std::path::Path,
353) {
354    let paint = |stderr: bool, style: &str, text: &str| {
355        if avril_ext_console::colors(stderr) {
356            format!("\x1b[{style}m{text}\x1b[0m")
357        } else {
358            text.to_owned()
359        }
360    };
361    let string = |text: &str| paint(false, "32", &serde_json::to_string(text).unwrap());
362    let (status, style, previous) = match change {
363        Change::Added => ("Added", "1;32", String::new()),
364        Change::Updated { previous } => (
365            "Updated",
366            "1;33",
367            format!("{} → ", previous.as_deref().map_or("null".into(), string)),
368        ),
369        Change::Unchanged => ("Unchanged", "1", String::new()),
370    };
371    let mut path = String::new();
372    // Paths may contain terminal controls, like the strings escaped as JSON above.
373    for ch in config
374        .strip_prefix(cwd)
375        .unwrap_or(config)
376        .to_string_lossy()
377        .chars()
378    {
379        if ch.is_control() {
380            path.extend(ch.escape_default());
381        } else {
382            path.push(ch);
383        }
384    }
385    // The status is decoration on stderr; stdout keeps just the resulting mapping.
386    eprint!("{} ", paint(true, style, status));
387    println!(
388        "{}: {previous}{} in {path}",
389        paint(false, "1", &serde_json::to_string(alias).unwrap()),
390        string(target),
391    );
392}