char/avril

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

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

Charlotte Somdeslop(task): unify watchset + glob machinerya29d09f

main
35.7 KiB924 linesraw
1use std::collections::{BTreeMap, HashMap};
2use std::path::PathBuf;
3
4use oxc::syntax::{identifier::is_identifier_name, keyword::is_reserved_keyword};
5use url::Url;
6
7use crate::config::{self, Error, Span, Value, ValueKind, invalid, object};
8use crate::import_map::ImportMap;
9
10#[derive(Debug)]
11pub struct Export {
12    pub target: Url,
13    pub span: Span,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Source {
18    /// A local package directory.
19    Local(PathBuf),
20    /// A whole `jsr:` package, optionally with a version requirement.
21    Jsr(crate::registry::JsrRequest),
22    /// A whole `npm:` package, optionally with a version requirement.
23    Npm(crate::registry::npm::NpmRequest),
24    Http(Url),
25}
26
27#[derive(Debug)]
28pub struct Dependency {
29    pub source: Source,
30    /// The binding, unless it is the name a local dependency declares for itself.
31    pub name: Option<String>,
32    /// Where the binding name is chosen.
33    pub span: Span,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct TaskDependency {
38    pub name: String,
39    pub span: Span,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TaskPattern {
44    pub pattern: String,
45    pub span: Span,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct TaskEnvFile {
50    pub path: String,
51    pub span: Span,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum TaskCommand {
56    /// Run through the platform shell (`sh -c` / `cmd /C`).
57    Shell(String),
58    /// Spawn directly, without a shell.
59    Argv(Vec<String>),
60}
61
62impl TaskCommand {
63    /// A one-line rendering for task listings.
64    pub fn display(&self) -> String {
65        match self {
66            TaskCommand::Shell(command) => command.clone(),
67            TaskCommand::Argv(argv) => argv.join(" "),
68        }
69    }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct Task {
74    pub command: Option<TaskCommand>,
75    pub description: Option<String>,
76    pub dependencies: Vec<TaskDependency>,
77    /// Run `dependencies` all at once instead of sequentially.
78    pub parallel: bool,
79    /// A dotenv file applied to this task and, recursively, its dependencies.
80    pub env_file: Option<TaskEnvFile>,
81    /// Declaring `files` opts the task into result caching.
82    pub files: Option<Vec<TaskPattern>>,
83    pub output: Vec<TaskPattern>,
84    /// Environment variable names whose values join the cache key.
85    pub env: Vec<String>,
86    pub span: Span,
87}
88
89#[derive(Debug, Default)]
90pub struct TypeConfig {
91    pub imports: ImportMap,
92    pub globals: Vec<String>,
93}
94
95#[derive(Debug)]
96pub struct PackageConfig {
97    pub name: Option<String>,
98    pub version: Option<String>,
99    pub description: Option<String>,
100    pub exports: BTreeMap<String, Export>,
101    pub dependencies: Vec<Dependency>,
102    pub tasks: BTreeMap<String, Task>,
103    pub import_map: ImportMap,
104    pub types: TypeConfig,
105    /// Each registry dependency's binding mapped to its package and that
106    /// package's exports: `jsr:` and `npm:` alike.
107    pub registry_map: ImportMap,
108    /// Package-wide defaults for the supported JSX `compilerOptions`.
109    pub compiler_options: serde_json::Map<String, serde_json::Value>,
110    name_span: Option<Span>,
111}
112
113impl PackageConfig {
114    pub fn parse(source: &str, base: &Url) -> Result<Self, Error> {
115        Self::from_value(&config::parse(source)?, base)
116    }
117
118    pub(crate) fn from_value(root: &Value, base: &Url) -> Result<Self, Error> {
119        if !matches!(base.scheme(), "file" | "http" | "https") {
120            return Err(invalid(
121                root.span.clone(),
122                "Package configs require a file or HTTP(S) URL",
123            ));
124        }
125        let directory = base
126            .join("./")
127            .map_err(|error| invalid(root.span.clone(), error.to_string()))?;
128        let mut package = Self {
129            name: None,
130            version: None,
131            description: None,
132            exports: BTreeMap::new(),
133            dependencies: Vec::new(),
134            tasks: BTreeMap::new(),
135            import_map: ImportMap::default(),
136            types: TypeConfig::default(),
137            registry_map: ImportMap::default(),
138            compiler_options: serde_json::Map::new(),
139            name_span: None,
140        };
141        let mut imports = None;
142        let mut scopes = None;
143        let mut type_config = None;
144        for member in object(root)? {
145            match member.key.as_str() {
146                "name" => {
147                    let name = string(&member.value)?;
148                    if !valid_name(name) {
149                        return Err(invalid(
150                            member.value.span.clone(),
151                            "Expected a bare package name, optionally scoped",
152                        ));
153                    }
154                    package.name = Some(name.to_owned());
155                    package.name_span = Some(member.value.span.clone());
156                }
157                "version" => {
158                    let version = string(&member.value)?;
159                    deno_semver::Version::parse_standard(version).map_err(|error| {
160                        invalid(
161                            member.value.span.clone(),
162                            format!("Invalid package version: {error}"),
163                        )
164                    })?;
165                    package.version = Some(version.to_owned());
166                }
167                "description" => package.description = Some(string(&member.value)?.to_owned()),
168                "exports" => {
169                    let entries: Vec<_> = match &member.value.kind {
170                        ValueKind::String(_) => {
171                            vec![(".", &member.value, member.value.span.clone())]
172                        }
173                        _ => object(&member.value)?
174                            .iter()
175                            .map(|entry| (entry.key.as_str(), &entry.value, entry.key_span.clone()))
176                            .collect(),
177                    };
178                    for (key, value, span) in entries {
179                        if key != "."
180                            && (!key.starts_with("./")
181                                || key[2..]
182                                    .split('/')
183                                    .any(|part| part.is_empty() || matches!(part, "." | ".."))
184                                || key.contains(['*', '?', '#', '\\'])
185                                || key.chars().any(char::is_control))
186                        {
187                            return Err(invalid(
188                                span,
189                                "Exports must use '.' or exact './subpath' keys",
190                            ));
191                        }
192                        let target = string(value)?;
193                        if !target.starts_with("./") {
194                            return Err(invalid(
195                                value.span.clone(),
196                                "Export targets must be package-relative files starting with './'",
197                            ));
198                        }
199                        let url = package_url(target, base, &value.span)?;
200                        if !url.as_str().starts_with(directory.as_str())
201                            || url == directory
202                            || url.path().ends_with('/')
203                        {
204                            return Err(invalid(
205                                value.span.clone(),
206                                "Export target must be a file inside its package",
207                            ));
208                        }
209                        package.exports.insert(
210                            key.to_owned(),
211                            Export {
212                                target: url,
213                                span: value.span.clone(),
214                            },
215                        );
216                    }
217                }
218                "imports" => imports = Some(&member.value),
219                "scopes" => scopes = Some(&member.value),
220                "types" => type_config = Some(&member.value),
221                "compilerOptions" => {
222                    for option in object(&member.value)? {
223                        // Every key is retained now: non-JSX values steer type
224                        // checking, so an unconvertible value errors at its own span.
225                        let value = option.value.to_value()?;
226                        check_jsx_option(&option.key, &value)
227                            .map_err(|message| invalid(option.value.span.clone(), message))?;
228                        package.compiler_options.insert(option.key.clone(), value);
229                    }
230                }
231                "deps" => {
232                    let ValueKind::Array(entries) = &member.value.kind else {
233                        return Err(invalid(member.value.span.clone(), "Expected an array"));
234                    };
235                    for entry in entries {
236                        package.dependencies.push(dependency(entry, base)?);
237                    }
238                }
239                "tasks" => {
240                    for entry in object(&member.value)? {
241                        if !valid_task_name(&entry.key) {
242                            return Err(invalid(
243                                entry.key_span.clone(),
244                                "Task names must be non-empty and use only letters, digits, '-', '_', '.', or ':'",
245                            ));
246                        }
247                        package.tasks.insert(entry.key.clone(), task(&entry.value)?);
248                    }
249                }
250                _ => {}
251            }
252        }
253        package.import_map = ImportMap::from_fields(imports, scopes, base)?;
254        if let Some(type_config) = type_config {
255            package.types = parse_type_config(type_config, base)?;
256        }
257        if let Some((name, span)) = package.name.as_ref().zip(package.name_span.as_ref())
258            && let Some(key) = package.import_map.namespace_conflict(name)
259        {
260            return Err(invalid(
261                span.clone(),
262                format!("Package name {name:?} conflicts with import key {key:?}"),
263            ));
264        }
265        let mut bindings = HashMap::new();
266        for dependency in &package.dependencies {
267            if let Some(name) = &dependency.name {
268                package.check_binding(name, &dependency.span, &mut bindings)?;
269            }
270        }
271        let packages: Vec<(String, String)> = package
272            .dependencies
273            .iter()
274            .filter_map(|dependency| match (&dependency.name, &dependency.source) {
275                (Some(name), Source::Jsr(request)) => {
276                    Some((name.clone(), request.specifier.clone()))
277                }
278                (Some(name), Source::Npm(request)) => Some((name.clone(), request.request_key())),
279                _ => None,
280            })
281            .collect();
282        package.registry_map = ImportMap::packages(packages);
283        Ok(package)
284    }
285
286    /// Binds each dependency, asking `declared` for the declared name of any local
287    /// dependency listed by path alone. Errors are reported against this config.
288    pub async fn bindings<F>(
289        &self,
290        mut declared: impl FnMut(Source) -> F,
291    ) -> Result<BTreeMap<String, Source>, Error>
292    where
293        F: std::future::Future<Output = Result<Option<String>, String>>,
294    {
295        let mut spans = HashMap::new();
296        let mut declarers = HashMap::new();
297        let mut bindings = BTreeMap::new();
298        for dependency in &self.dependencies {
299            let (name, declarer) = match (&dependency.name, &dependency.source) {
300                (Some(name), _) => (name.clone(), None),
301                (None, Source::Jsr(_)) => unreachable!("jsr: dependencies are named when parsed"),
302                (None, Source::Npm(_)) => unreachable!("npm: dependencies are named when parsed"),
303                (None, source) => {
304                    let config = match source {
305                        Source::Local(directory) => {
306                            directory.join("avril.conf").display().to_string()
307                        }
308                        Source::Http(directory) => {
309                            directory.join("avril.conf").unwrap().to_string()
310                        }
311                        Source::Jsr(_) => unreachable!(),
312                        Source::Npm(_) => unreachable!(),
313                    };
314                    let name = declared(source.clone())
315                        .await
316                        .map_err(|error| {
317                            invalid(
318                                dependency.span.clone(),
319                                format!(
320                                    "Cannot read the name of dependency {}: {error}",
321                                    config
322                                ),
323                            )
324                        })?
325                        .ok_or_else(|| {
326                            invalid(
327                                dependency.span.clone(),
328                                format!(
329                                    "Dependency {} declares no name; give this entry {{from, name}}",
330                                    config
331                                ),
332                            )
333                        })?;
334                    (name, Some(config))
335                }
336            };
337            self.check_binding(&name, &dependency.span, &mut spans)
338                .map_err(|mut error| {
339                    // A duplicate of an earlier inferred name needs that dependency's context.
340                    if let Some(config) = declarer.as_ref().or_else(|| declarers.get(&name)) {
341                        error.message = format!(
342                            "{}; the dependency's name comes from {}",
343                            error.message, config
344                        );
345                    }
346                    error
347                })?;
348            if let Some(config) = declarer {
349                declarers.insert(name.clone(), config);
350            }
351            bindings.insert(name, dependency.source.clone());
352        }
353        Ok(bindings)
354    }
355
356    fn check_binding(
357        &self,
358        name: &str,
359        span: &Span,
360        bindings: &mut HashMap<String, Span>,
361    ) -> Result<(), Error> {
362        if self.name.as_deref() == Some(name) {
363            return Err(Error {
364                kind: crate::config::ErrorKind::Syntax,
365                message: format!(
366                    "Dependency binding {name:?} conflicts with the package's self name"
367                ),
368                span: span.clone(),
369                previous: self.name_span.clone(),
370            });
371        }
372        if let Some(previous) = bindings.get(name) {
373            return Err(Error {
374                kind: crate::config::ErrorKind::Syntax,
375                message: format!("Duplicate dependency binding {name:?}"),
376                span: span.clone(),
377                previous: Some(previous.clone()),
378            });
379        }
380        if let Some(key) = self.import_map.namespace_conflict(name) {
381            return Err(invalid(
382                span.clone(),
383                format!("Dependency binding {name:?} conflicts with import key {key:?}"),
384            ));
385        }
386        bindings.insert(name.to_owned(), span.clone());
387        Ok(())
388    }
389}
390
391fn valid_task_name(name: &str) -> bool {
392    !name.is_empty()
393        && name
394            .bytes()
395            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, b'-' | b'_' | b'.' | b':'))
396}
397
398fn task(value: &Value) -> Result<Task, Error> {
399    let span = value.span.clone();
400    let command = |value: &Value| -> Result<TaskCommand, Error> {
401        match &value.kind {
402            ValueKind::Array(entries) => {
403                if entries.is_empty() {
404                    return Err(invalid(
405                        value.span.clone(),
406                        "Task command arrays cannot be empty",
407                    ));
408                }
409                let mut argv = Vec::new();
410                for entry in entries {
411                    argv.push(string(entry)?.to_owned());
412                }
413                if argv[0].is_empty() {
414                    return Err(invalid(
415                        entries[0].span.clone(),
416                        "The first element of a task command array cannot be empty",
417                    ));
418                }
419                Ok(TaskCommand::Argv(argv))
420            }
421            _ => {
422                let command = string(value).map_err(|_| {
423                    invalid(
424                        value.span.clone(),
425                        "Expected a string (shell command) or an array of strings (argv) for 'command'",
426                    )
427                })?;
428                if command.trim().is_empty() {
429                    return Err(invalid(value.span.clone(), "Task commands cannot be empty"));
430                }
431                Ok(TaskCommand::Shell(command.to_owned()))
432            }
433        }
434    };
435    match &value.kind {
436        ValueKind::String(_) => Ok(Task {
437            command: Some(command(value)?),
438            description: None,
439            dependencies: Vec::new(),
440            parallel: false,
441            env_file: None,
442            files: None,
443            output: Vec::new(),
444            env: Vec::new(),
445            span,
446        }),
447        ValueKind::Object(fields) => {
448            let mut task = Task {
449                command: None,
450                description: None,
451                dependencies: Vec::new(),
452                parallel: false,
453                env_file: None,
454                files: None,
455                output: Vec::new(),
456                env: Vec::new(),
457                span: span.clone(),
458            };
459            let mut dependency_spans: HashMap<String, Span> = HashMap::new();
460            let mut parallel_span = None;
461            for field in fields {
462                match field.key.as_str() {
463                    "command" => task.command = Some(command(&field.value)?),
464                    "description" => task.description = Some(string(&field.value)?.to_owned()),
465                    "dependencies" => {
466                        let ValueKind::Array(entries) = &field.value.kind else {
467                            return Err(invalid(
468                                field.value.span.clone(),
469                                "Expected an array of task names",
470                            ));
471                        };
472                        for entry in entries {
473                            let name = string(entry)?;
474                            if !valid_task_name(name) {
475                                return Err(invalid(entry.span.clone(), "Expected a task name"));
476                            }
477                            if let Some(previous) = dependency_spans.get(name) {
478                                return Err(Error {
479                                    kind: crate::config::ErrorKind::Syntax,
480                                    message: format!("Duplicate task dependency {name:?}"),
481                                    span: entry.span.clone(),
482                                    previous: Some(previous.clone()),
483                                });
484                            }
485                            dependency_spans.insert(name.to_owned(), entry.span.clone());
486                            task.dependencies.push(TaskDependency {
487                                name: name.to_owned(),
488                                span: entry.span.clone(),
489                            });
490                        }
491                    }
492                    "parallel" => {
493                        let ValueKind::Bool(parallel) = field.value.kind else {
494                            return Err(invalid(field.value.span.clone(), "Expected a boolean"));
495                        };
496                        task.parallel = parallel;
497                        parallel_span = Some(field.value.span.clone());
498                    }
499                    "envFile" => {
500                        let path = string(&field.value)?;
501                        if path.trim().is_empty() {
502                            return Err(invalid(
503                                field.value.span.clone(),
504                                "Task env file paths cannot be empty",
505                            ));
506                        }
507                        task.env_file = Some(TaskEnvFile {
508                            path: path.to_owned(),
509                            span: field.value.span.clone(),
510                        });
511                    }
512                    "files" | "output" => {
513                        let ValueKind::Array(entries) = &field.value.kind else {
514                            return Err(invalid(
515                                field.value.span.clone(),
516                                "Expected an array of glob patterns",
517                            ));
518                        };
519                        let mut patterns = Vec::new();
520                        for entry in entries {
521                            let pattern = string(entry)?;
522                            avril_task::validate_pattern(pattern)
523                                .map_err(|error| invalid(entry.span.clone(), error.to_string()))?;
524                            if field.key == "output" {
525                                avril_task::validate_output_pattern(pattern).map_err(|error| {
526                                    invalid(entry.span.clone(), error.to_string())
527                                })?;
528                            }
529                            patterns.push(TaskPattern {
530                                pattern: pattern.to_owned(),
531                                span: entry.span.clone(),
532                            });
533                        }
534                        if field.key == "files" {
535                            task.files = Some(patterns);
536                        } else {
537                            task.output = patterns;
538                        }
539                    }
540                    "env" => {
541                        let ValueKind::Array(entries) = &field.value.kind else {
542                            return Err(invalid(
543                                field.value.span.clone(),
544                                "Expected an array of environment variable names",
545                            ));
546                        };
547                        let mut seen: HashMap<String, Span> = HashMap::new();
548                        for entry in entries {
549                            let name = string(entry)?;
550                            if !valid_env_name(name) {
551                                return Err(invalid(
552                                    entry.span.clone(),
553                                    "Expected an environment variable name",
554                                ));
555                            }
556                            if let Some(previous) = seen.get(name) {
557                                return Err(Error {
558                                    kind: crate::config::ErrorKind::Syntax,
559                                    message: format!("Duplicate task env name {name:?}"),
560                                    span: entry.span.clone(),
561                                    previous: Some(previous.clone()),
562                                });
563                            }
564                            seen.insert(name.to_owned(), entry.span.clone());
565                            task.env.push(name.to_owned());
566                        }
567                    }
568                    _ => {}
569                }
570            }
571            if task.parallel && task.dependencies.is_empty() {
572                return Err(invalid(
573                    parallel_span.unwrap(),
574                    "Task 'parallel' requires 'dependencies'",
575                ));
576            }
577            if task.files.is_none() && (!task.output.is_empty() || !task.env.is_empty()) {
578                return Err(invalid(
579                    span.clone(),
580                    "Task 'output' and 'env' require 'files': declaring 'files' enables result caching",
581                ));
582            }
583            if task.command.is_none() && task.dependencies.is_empty() {
584                return Err(invalid(
585                    span,
586                    "Tasks must define a 'command' or 'dependencies'",
587                ));
588            }
589            Ok(task)
590        }
591        _ => Err(invalid(
592            span,
593            "Expected a task command string or an object with 'command', 'description', 'dependencies', 'parallel', 'envFile', 'files', 'output', or 'env'",
594        )),
595    }
596}
597
598fn valid_env_name(name: &str) -> bool {
599    let mut bytes = name.bytes();
600    bytes
601        .next()
602        .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == b'_')
603        && bytes.all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
604}
605
606fn dependency(entry: &Value, base: &Url) -> Result<Dependency, Error> {
607    let (from, name) = match &entry.kind {
608        ValueKind::String(_) => (entry, None),
609        ValueKind::Object(_) => {
610            let mut from = None;
611            let mut name = None;
612            for field in object(entry)? {
613                match field.key.as_str() {
614                    "from" => from = Some(&field.value),
615                    "name" => name = Some(&field.value),
616                    _ => {}
617                }
618            }
619            let (Some(from), Some(name)) = (from, name) else {
620                return Err(invalid(
621                    entry.span.clone(),
622                    "Dependency objects require both 'from' and 'name'; a string uses the dependency's own name",
623                ));
624            };
625            (from, Some(name))
626        }
627        _ => {
628            return Err(invalid(
629                entry.span.clone(),
630                "Expected a dependency path, HTTP(S) directory URL, jsr: package, or {from, name}",
631            ));
632        }
633    };
634    let binding = match name {
635        Some(name) => {
636            let binding = string(name)?;
637            if !valid_name(binding) {
638                return Err(invalid(
639                    name.span.clone(),
640                    "Expected a dependency binding name, optionally scoped",
641                ));
642            }
643            Some(binding.to_owned())
644        }
645        None => None,
646    };
647    let target = string(from)?;
648    let (source, implicit) = if target.starts_with("jsr:") {
649        let span = &from.span;
650        let request = crate::registry::parse_jsr_request(target)
651            .map_err(|error| invalid(span.clone(), error.to_string()))?;
652        if request.version_req.tag().is_some() {
653            return Err(invalid(
654                span.clone(),
655                "JSR version tags are not supported; use a semver range",
656            ));
657        }
658        if request.subpath.is_some() || target.ends_with('/') {
659            return Err(invalid(
660                span.clone(),
661                "Dependencies are whole packages; map a subpath in imports instead",
662            ));
663        }
664        crate::registry::validate_name(&request.name)
665            .map_err(|error| invalid(span.clone(), error.to_string()))?;
666        let name = request.name.clone();
667        (Source::Jsr(request), Some(name))
668    } else if target.starts_with("npm:") {
669        let span = &from.span;
670        let request = crate::registry::npm::parse_npm_request(target)
671            .map_err(|error| invalid(span.clone(), error.to_string()))?;
672        if request.subpath.is_some() || target.ends_with('/') {
673            return Err(invalid(
674                span.clone(),
675                "Dependencies are whole packages; map a subpath in imports instead",
676            ));
677        }
678        let name = request.name.clone();
679        (Source::Npm(request), Some(name))
680    } else if Url::parse(target).is_ok_and(|url| matches!(url.scheme(), "http" | "https"))
681        || (matches!(base.scheme(), "http" | "https")
682            && (target.starts_with("./") || target.starts_with("../")))
683    {
684        let url = package_url(target, base, &from.span)?;
685        if !matches!(url.scheme(), "http" | "https")
686            || !url.username().is_empty()
687            || url.password().is_some()
688            || !target.ends_with('/')
689        {
690            return Err(invalid(
691                from.span.clone(),
692                "HTTP(S) dependencies must be credential-free directory URLs ending with '/'",
693            ));
694        }
695        (Source::Http(url), None)
696    } else if target.starts_with("./") || target.starts_with("../") {
697        let path = package_url(target, base, &from.span)?
698            .to_file_path()
699            .map_err(|_| invalid(from.span.clone(), "Expected a local package path"))?;
700        (Source::Local(path), None)
701    } else {
702        return Err(invalid(
703            from.span.clone(),
704            "Dependencies must be jsr: packages, HTTP(S) directory URLs, or config-relative paths starting with './' or '../'",
705        ));
706    };
707    Ok(Dependency {
708        source,
709        name: binding.or(implicit),
710        span: name.unwrap_or(from).span.clone(),
711    })
712}
713
714/// Whether `name` is a bare package name, optionally scoped.
715pub fn valid_name(name: &str) -> bool {
716    let valid_part = |part: &str| {
717        !part.is_empty()
718            && !part.starts_with('.')
719            && part
720                .bytes()
721                .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, b'-' | b'_' | b'.'))
722    };
723    match name.strip_prefix('@') {
724        Some(scoped) => scoped
725            .split_once('/')
726            .is_some_and(|(scope, name)| valid_part(scope) && valid_part(name)),
727        None => valid_part(name),
728    }
729}
730
731/// The `compilerOptions` validated for the runtime; every key is retained
732/// for type checking, in `avril.conf` and `tsconfig.json` alike.
733pub const JSX_OPTIONS: [&str; 4] = ["jsx", "jsxImportSource", "jsxFactory", "jsxFragmentFactory"];
734
735pub fn check_jsx_option(key: &str, value: &serde_json::Value) -> Result<(), String> {
736    if !JSX_OPTIONS.contains(&key) {
737        return Ok(());
738    }
739    let text = value
740        .as_str()
741        .filter(|text| !text.trim().is_empty())
742        .ok_or_else(|| format!("compilerOptions.{key} must be a nonempty string"))?;
743    if key == "jsx"
744        && !matches!(
745            text,
746            "react" | "react-jsx" | "react-jsxdev" | "preserve" | "react-native"
747        )
748    {
749        return Err("Unsupported compilerOptions.jsx".into());
750    }
751    if matches!(key, "jsxFactory" | "jsxFragmentFactory") {
752        let mut parts = text.split('.');
753        let root = parts.next().unwrap();
754        let valid = match root {
755            "this" => true,
756            "import" => parts.next() == Some("meta"),
757            _ => is_identifier_name(root) && !is_reserved_keyword(root),
758        };
759        if !valid || !parts.all(is_identifier_name) {
760            return Err(format!(
761                "compilerOptions.{key} must be an identifier or dotted member expression"
762            ));
763        }
764    }
765    Ok(())
766}
767
768fn parse_type_config(value: &Value, base: &Url) -> Result<TypeConfig, Error> {
769    let members = object(value)?;
770    let mut imports = None;
771    let mut globals = Vec::new();
772    for member in members {
773        match member.key.as_str() {
774            "imports" => imports = Some(&member.value),
775            "globals" => {
776                let ValueKind::Array(entries) = &member.value.kind else {
777                    return Err(invalid(member.value.span.clone(), "Expected an array"));
778                };
779                for entry in entries {
780                    let target = string(entry)?;
781                    let url = if target.starts_with("./")
782                        || target.starts_with("../")
783                        || target.starts_with('/')
784                    {
785                        base.join(target)
786                            .map_err(|error| invalid(entry.span.clone(), error.to_string()))?
787                    } else {
788                        Url::parse(target).map_err(|_| {
789                            invalid(
790                                entry.span.clone(),
791                                "Global type entries must be URLs or relative paths",
792                            )
793                        })?
794                    };
795                    globals.push(url.to_string());
796                }
797            }
798            _ => {}
799        }
800    }
801    Ok(TypeConfig {
802        imports: ImportMap::from_fields(imports, None, base)?,
803        globals,
804    })
805}
806
807fn string(value: &Value) -> Result<&str, Error> {
808    match &value.kind {
809        ValueKind::String(value) => Ok(value),
810        _ => Err(invalid(value.span.clone(), "Expected a string")),
811    }
812}
813
814fn package_url(target: &str, base: &Url, span: &Span) -> Result<Url, Error> {
815    let lower = target.to_ascii_lowercase();
816    if target.contains('\\')
817        || target.chars().any(char::is_control)
818        || lower.contains("%2f")
819        || lower.contains("%5c")
820    {
821        return Err(invalid(span.clone(), "Invalid package path"));
822    }
823    let url = base
824        .join(target)
825        .map_err(|error| invalid(span.clone(), error.to_string()))?;
826    if url.query().is_some() || url.fragment().is_some() {
827        return Err(invalid(
828            span.clone(),
829            "Package paths cannot contain queries or fragments",
830        ));
831    }
832    let decoded = percent_encoding::percent_decode_str(url.path())
833        .decode_utf8()
834        .map_err(|_| invalid(span.clone(), "Package paths must be valid UTF-8"))?;
835    if decoded.contains('\0') {
836        return Err(invalid(
837            span.clone(),
838            "Package paths must be without NUL bytes",
839        ));
840    }
841    Ok(url)
842}
843
844#[cfg(test)]
845mod tests {
846    use super::PackageConfig;
847    use url::Url;
848
849    const BASE: &str = "https://example.test/project/avril.conf";
850
851    #[test]
852    fn type_config_normalizes_global_modules_and_keeps_imports_separate() {
853        let config = PackageConfig::parse(
854            r#"{
855                imports: { "npm:legacy-sdk": "npm:runtime-sdk" },
856                types: {
857                    imports: { "npm:legacy-sdk": "npm:@types/legacy-sdk@^3", "https://example.test/project/src/old": "./types/legacy.d.ts", "legacy/": null },
858                    globals: ["./types/globals.d.ts", "npm:@types/node@^22", "https://types.test/global.d.ts"]
859                }
860            }"#,
861            &Url::parse(BASE).unwrap(),
862        )
863        .unwrap();
864        assert_eq!(
865            config.types.globals,
866            [
867                "https://example.test/project/types/globals.d.ts",
868                "npm:@types/node@^22",
869                "https://types.test/global.d.ts",
870            ]
871        );
872        let referrer = Url::parse("https://example.test/project/src/module.ts").unwrap();
873        assert_eq!(
874            config
875                .types
876                .imports
877                .resolve("npm:legacy-sdk", &referrer)
878                .unwrap(),
879            Some("npm:@types/legacy-sdk@^3".into())
880        );
881        assert_eq!(
882            config
883                .import_map
884                .resolve("npm:legacy-sdk", &referrer)
885                .unwrap(),
886            Some("npm:runtime-sdk".into())
887        );
888        assert_eq!(
889            config.types.imports.resolve("./old", &referrer).unwrap(),
890            Some("https://example.test/project/types/legacy.d.ts".into())
891        );
892        assert!(
893            config
894                .types
895                .imports
896                .resolve("legacy/subpath", &referrer)
897                .is_err()
898        );
899    }
900
901    #[test]
902    fn type_config_defaults_empty_and_rejects_malformed_shapes() {
903        let base = Url::parse(BASE).unwrap();
904        let config = PackageConfig::parse("{}", &base).unwrap();
905        assert!(config.types.globals.is_empty());
906        assert!(
907            config
908                .types
909                .imports
910                .resolve("anything", &base)
911                .unwrap()
912                .is_none()
913        );
914        for source in [
915            r#"{ types: [] }"#,
916            r#"{ types: { imports: [] } }"#,
917            r#"{ types: { globals: "./global.d.ts" } }"#,
918            r#"{ types: { globals: ["bare-module"] } }"#,
919            r#"{ types: { globals: [42] } }"#,
920        ] {
921            assert!(PackageConfig::parse(source, &base).is_err(), "{source}");
922        }
923    }
924}