turbopack_resolve/
typescript.rs

1use std::{collections::HashMap, mem::take};
2
3use anyhow::Result;
4use serde_json::Value as JsonValue;
5use turbo_rcstr::{RcStr, rcstr};
6use turbo_tasks::{ResolvedVc, ValueDefault, Vc, fxindexset};
7use turbo_tasks_fs::{FileContent, FileJsonContent, FileSystemPath, FileSystemPathOption};
8use turbopack_core::{
9    asset::Asset,
10    context::AssetContext,
11    file_source::FileSource,
12    issue::{
13        Issue, IssueExt, IssueSeverity, IssueSource, IssueStage, OptionIssueSource,
14        OptionStyledString, StyledString,
15    },
16    reference_type::{ReferenceType, TypeScriptReferenceSubType},
17    resolve::{
18        AliasPattern, ModuleResolveResult, handle_resolve_error,
19        node::node_cjs_resolve_options,
20        options::{
21            ConditionValue, ImportMap, ImportMapping, ResolveIntoPackage, ResolveModules,
22            ResolveOptions,
23        },
24        origin::{ResolveOrigin, ResolveOriginExt},
25        parse::Request,
26        pattern::Pattern,
27        resolve,
28    },
29    source::{OptionSource, Source},
30    source_pos::SourcePos,
31};
32
33use crate::ecmascript::get_condition_maps;
34
35#[turbo_tasks::value(shared)]
36struct TsConfigIssue {
37    severity: IssueSeverity,
38    source: IssueSource,
39    message: RcStr,
40}
41
42#[turbo_tasks::function]
43async fn json_only(resolve_options: Vc<ResolveOptions>) -> Result<Vc<ResolveOptions>> {
44    let mut opts = resolve_options.owned().await?;
45    opts.extensions = vec![rcstr!(".json")];
46    Ok(opts.cell())
47}
48
49type TsConfig = (Vc<FileJsonContent>, ResolvedVc<Box<dyn Source>>);
50
51#[tracing::instrument(skip_all)]
52pub async fn read_tsconfigs(
53    mut data: Vc<FileContent>,
54    mut tsconfig: ResolvedVc<Box<dyn Source>>,
55    resolve_options: Vc<ResolveOptions>,
56) -> Result<Vec<TsConfig>> {
57    let mut configs = Vec::new();
58    let resolve_options = json_only(resolve_options);
59    loop {
60        // tsc ignores empty config files.
61        if let FileContent::Content(file) = &*data.await?
62            && file.content().is_empty()
63        {
64            break;
65        }
66
67        let parsed_data = data.parse_json_with_comments();
68        match &*parsed_data.await? {
69            FileJsonContent::Unparsable(e) => {
70                let message = format!("tsconfig is not parseable: invalid JSON: {}", e.message);
71                let source = match (e.start_location, e.end_location) {
72                    (None, None) => IssueSource::from_source_only(tsconfig),
73                    (Some((line, column)), None) | (None, Some((line, column))) => {
74                        IssueSource::from_line_col(
75                            tsconfig,
76                            SourcePos { line, column },
77                            SourcePos { line, column },
78                        )
79                    }
80                    (Some((start_line, start_column)), Some((end_line, end_column))) => {
81                        IssueSource::from_line_col(
82                            tsconfig,
83                            SourcePos {
84                                line: start_line,
85                                column: start_column,
86                            },
87                            SourcePos {
88                                line: end_line,
89                                column: end_column,
90                            },
91                        )
92                    }
93                };
94                TsConfigIssue {
95                    severity: IssueSeverity::Error,
96                    source,
97                    message: message.into(),
98                }
99                .resolved_cell()
100                .emit();
101            }
102            FileJsonContent::NotFound => {
103                TsConfigIssue {
104                    severity: IssueSeverity::Error,
105                    source: IssueSource::from_source_only(tsconfig),
106                    message: rcstr!("tsconfig not found"),
107                }
108                .resolved_cell()
109                .emit();
110            }
111            FileJsonContent::Content(json) => {
112                configs.push((parsed_data, tsconfig));
113                if let Some(extends) = json["extends"].as_str() {
114                    let resolved = resolve_extends(*tsconfig, extends, resolve_options).await?;
115                    if let Some(source) = *resolved.await? {
116                        data = source.content().file_content();
117                        tsconfig = source;
118                        continue;
119                    } else {
120                        TsConfigIssue {
121                            severity: IssueSeverity::Error,
122                            // TODO: this should point at the `extends` property
123                            source: IssueSource::from_source_only(tsconfig),
124                            message: format!("extends: \"{extends}\" doesn't resolve correctly")
125                                .into(),
126                        }
127                        .resolved_cell()
128                        .emit();
129                    }
130                }
131            }
132        }
133        break;
134    }
135    Ok(configs)
136}
137
138/// Resolves tsconfig files according to TS's implementation:
139/// https://github.com/microsoft/TypeScript/blob/611a912d/src/compiler/commandLineParser.ts#L3294-L3326
140#[tracing::instrument(skip_all)]
141async fn resolve_extends(
142    tsconfig: Vc<Box<dyn Source>>,
143    extends: &str,
144    resolve_options: Vc<ResolveOptions>,
145) -> Result<Vc<OptionSource>> {
146    let parent_dir = tsconfig.ident().path().await?.parent();
147    let request = Request::parse_string(extends.into());
148
149    // TS's resolution is weird, and has special behavior for different import
150    // types. There might be multiple alternatives like
151    // "some/path/node_modules/xyz/abc.json" and "some/node_modules/xyz/abc.json".
152    // We only want to use the first one.
153    match &*request.await? {
154        // TS has special behavior for "rooted" paths (absolute paths):
155        // https://github.com/microsoft/TypeScript/blob/611a912d/src/compiler/commandLineParser.ts#L3303-L3313
156        Request::Windows { path: Pattern::Constant(path), .. } |
157        // Server relative is treated as absolute
158        Request::ServerRelative { path: Pattern::Constant(path), .. } => {
159            resolve_extends_rooted_or_relative(parent_dir, request, resolve_options, path).await
160        }
161
162        // TS has special behavior for (explicitly) './' and '../', but not '.' nor '..':
163        // https://github.com/microsoft/TypeScript/blob/611a912d/src/compiler/commandLineParser.ts#L3303-L3313
164        Request::Relative {
165            path: Pattern::Constant(path),
166            ..
167        } if path.starts_with("./") || path.starts_with("../") => {
168            resolve_extends_rooted_or_relative(parent_dir, request, resolve_options, path).await
169        }
170
171        // An empty extends is treated as "./tsconfig"
172        Request::Empty => {
173            let request = Request::parse_string(rcstr!("./tsconfig"));
174            Ok(resolve(parent_dir,
175                ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined), request, resolve_options).first_source())
176        }
177
178        // All other types are treated as module imports, and potentially joined with
179        // "tsconfig.json". This includes "relative" imports like '.' and '..'.
180        _ => {
181            let mut result = resolve(parent_dir.clone(), ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined), request, resolve_options).first_source();
182            if result.await?.is_none() {
183                let request = Request::parse_string(format!("{extends}/tsconfig").into());
184                result = resolve(parent_dir, ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined), request, resolve_options).first_source();
185            }
186            Ok(result)
187        }
188    }
189}
190
191async fn resolve_extends_rooted_or_relative(
192    lookup_path: FileSystemPath,
193    request: Vc<Request>,
194    resolve_options: Vc<ResolveOptions>,
195    path: &str,
196) -> Result<Vc<OptionSource>> {
197    let mut result = resolve(
198        lookup_path.clone(),
199        ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined),
200        request,
201        resolve_options,
202    )
203    .first_source();
204
205    // If the file doesn't end with ".json" and we can't find the file, then we have
206    // to try again with it.
207    // https://github.com/microsoft/TypeScript/blob/611a912d/src/compiler/commandLineParser.ts#L3305
208    if !path.ends_with(".json") && result.await?.is_none() {
209        let request = Request::parse_string(format!("{path}.json").into());
210        result = resolve(
211            lookup_path.clone(),
212            ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined),
213            request,
214            resolve_options,
215        )
216        .first_source();
217    }
218    Ok(result)
219}
220
221pub async fn read_from_tsconfigs<T>(
222    configs: &[TsConfig],
223    accessor: impl Fn(&JsonValue, ResolvedVc<Box<dyn Source>>) -> Option<T>,
224) -> Result<Option<T>> {
225    for (config, source) in configs.iter() {
226        if let FileJsonContent::Content(json) = &*config.await?
227            && let Some(result) = accessor(json, *source)
228        {
229            return Ok(Some(result));
230        }
231    }
232    Ok(None)
233}
234
235/// Resolve options specific to tsconfig.json.
236#[turbo_tasks::value]
237#[derive(Default)]
238pub struct TsConfigResolveOptions {
239    base_url: Option<FileSystemPath>,
240    import_map: Option<ResolvedVc<ImportMap>>,
241    is_module_resolution_nodenext: bool,
242}
243
244#[turbo_tasks::value_impl]
245impl ValueDefault for TsConfigResolveOptions {
246    #[turbo_tasks::function]
247    fn value_default() -> Vc<Self> {
248        Self::default().cell()
249    }
250}
251
252#[turbo_tasks::function]
253async fn try_join_base_url(
254    source: ResolvedVc<Box<dyn Source>>,
255    base_url: RcStr,
256) -> Result<Vc<FileSystemPathOption>> {
257    Ok(Vc::cell(
258        source.ident().path().await?.parent().try_join(&base_url)?,
259    ))
260}
261
262/// Returns the resolve options
263#[turbo_tasks::function]
264pub async fn tsconfig_resolve_options(
265    tsconfig: FileSystemPath,
266) -> Result<Vc<TsConfigResolveOptions>> {
267    let configs = read_tsconfigs(
268        tsconfig.read(),
269        ResolvedVc::upcast(FileSource::new(tsconfig.clone()).to_resolved().await?),
270        node_cjs_resolve_options(tsconfig.root().owned().await?),
271    )
272    .await?;
273
274    if configs.is_empty() {
275        return Ok(Default::default());
276    }
277
278    let base_url = if let Some(base_url) = read_from_tsconfigs(&configs, |json, source| {
279        json["compilerOptions"]["baseUrl"]
280            .as_str()
281            .map(|base_url| try_join_base_url(*source, base_url.into()))
282    })
283    .await?
284    {
285        base_url.owned().await?
286    } else {
287        None
288    };
289
290    let mut all_paths = HashMap::new();
291    for (content, source) in configs.iter().rev() {
292        if let FileJsonContent::Content(json) = &*content.await?
293            && let JsonValue::Object(paths) = &json["compilerOptions"]["paths"]
294        {
295            let mut context_dir = source.ident().path().await?.parent();
296            if let Some(base_url) = json["compilerOptions"]["baseUrl"].as_str()
297                && let Some(new_context) = context_dir.try_join(base_url)?
298            {
299                context_dir = new_context;
300            };
301            let context_dir = context_dir.clone();
302            for (key, value) in paths.iter() {
303                if let JsonValue::Array(vec) = value {
304                    let entries = vec
305                        .iter()
306                        .filter_map(|entry| {
307                            let entry = entry.as_str();
308
309                            if entry.map(|e| e.ends_with(".d.ts")).unwrap_or_default() {
310                                return None;
311                            }
312
313                            entry.map(|s| {
314                                // tsconfig paths are always relative requests
315                                if s.starts_with("./") || s.starts_with("../") {
316                                    s.into()
317                                } else {
318                                    format!("./{s}").into()
319                                }
320                            })
321                        })
322                        .collect();
323                    all_paths.insert(
324                        RcStr::from(key.as_str()),
325                        ImportMapping::primary_alternatives(entries, Some(context_dir.clone())),
326                    );
327                } else {
328                    TsConfigIssue {
329                        severity: IssueSeverity::Warning,
330                        // TODO: this should point at the invalid key
331                        source: IssueSource::from_source_only(*source),
332                        message: format!(
333                            "compilerOptions.paths[{key}] doesn't contains an array as \
334                             expected\n{key}: {value:#}",
335                            key = serde_json::to_string(key)?,
336                            value = value
337                        )
338                        .into(),
339                    }
340                    .resolved_cell()
341                    .emit()
342                }
343            }
344        }
345    }
346
347    let import_map = if !all_paths.is_empty() {
348        let mut import_map = ImportMap::empty();
349        for (key, value) in all_paths {
350            import_map.insert_alias(AliasPattern::parse(key), value.resolved_cell());
351        }
352        Some(import_map.resolved_cell())
353    } else {
354        None
355    };
356
357    let is_module_resolution_nodenext = read_from_tsconfigs(&configs, |json, _| {
358        json["compilerOptions"]["moduleResolution"]
359            .as_str()
360            .map(|module_resolution| module_resolution.eq_ignore_ascii_case("nodenext"))
361    })
362    .await?
363    .unwrap_or_default();
364
365    Ok(TsConfigResolveOptions {
366        base_url,
367        import_map,
368        is_module_resolution_nodenext,
369    }
370    .cell())
371}
372
373#[turbo_tasks::function]
374pub fn tsconfig() -> Vc<Vec<RcStr>> {
375    Vc::cell(vec![rcstr!("tsconfig.json"), rcstr!("jsconfig.json")])
376}
377
378#[turbo_tasks::function]
379pub async fn apply_tsconfig_resolve_options(
380    resolve_options: Vc<ResolveOptions>,
381    tsconfig_resolve_options: Vc<TsConfigResolveOptions>,
382) -> Result<Vc<ResolveOptions>> {
383    let tsconfig_resolve_options = tsconfig_resolve_options.await?;
384    let mut resolve_options = resolve_options.owned().await?;
385    if let Some(base_url) = &tsconfig_resolve_options.base_url {
386        // We want to resolve in `compilerOptions.baseUrl` first, then in other
387        // locations as a fallback.
388        resolve_options.modules.insert(
389            0,
390            ResolveModules::Path {
391                dir: base_url.clone(),
392                // tsconfig basepath doesn't apply to json requests
393                excluded_extensions: ResolvedVc::cell(fxindexset![rcstr!(".json")]),
394            },
395        );
396    }
397    if let Some(tsconfig_import_map) = tsconfig_resolve_options.import_map {
398        resolve_options.import_map = Some(
399            resolve_options
400                .import_map
401                .map(|import_map| import_map.extend(*tsconfig_import_map))
402                .unwrap_or(*tsconfig_import_map)
403                .to_resolved()
404                .await?,
405        );
406    }
407    resolve_options.enable_typescript_with_output_extension =
408        tsconfig_resolve_options.is_module_resolution_nodenext;
409
410    Ok(resolve_options.cell())
411}
412
413#[turbo_tasks::function]
414pub async fn type_resolve(
415    origin: Vc<Box<dyn ResolveOrigin>>,
416    request: Vc<Request>,
417) -> Result<Vc<ModuleResolveResult>> {
418    let ty = ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined);
419    let context_path = origin.origin_path().await?.parent();
420    let options = origin.resolve_options(ty.clone()).await?;
421    let options = apply_typescript_types_options(options);
422    let types_request = if let Request::Module {
423        module: m,
424        path: p,
425        query: _,
426        fragment: _,
427    } = &*request.await?
428    {
429        let mut m = if let Some(mut stripped) = m.strip_prefix("@") {
430            stripped.replace_constants(&|c| Some(Pattern::Constant(c.replace("/", "__").into())));
431            stripped
432        } else {
433            m.clone()
434        };
435        m.push_front(rcstr!("@types/").into());
436        Some(Request::module(
437            m,
438            p.clone(),
439            RcStr::default(),
440            RcStr::default(),
441        ))
442    } else {
443        None
444    };
445    let result = if let Some(types_request) = types_request {
446        let result1 = resolve(
447            context_path.clone(),
448            ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined),
449            request,
450            options,
451        );
452        if !*result1.is_unresolvable().await? {
453            result1
454        } else {
455            resolve(
456                context_path,
457                ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined),
458                types_request,
459                options,
460            )
461        }
462    } else {
463        resolve(
464            context_path,
465            ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined),
466            request,
467            options,
468        )
469    };
470    let result = as_typings_result(
471        origin
472            .asset_context()
473            .process_resolve_result(result, ty.clone()),
474    );
475    handle_resolve_error(
476        result,
477        ty,
478        origin.origin_path().owned().await?,
479        request,
480        options,
481        false,
482        None,
483    )
484    .await
485}
486
487#[turbo_tasks::function]
488pub async fn as_typings_result(result: Vc<ModuleResolveResult>) -> Result<Vc<ModuleResolveResult>> {
489    let mut result = result.owned().await?;
490    result.primary = IntoIterator::into_iter(take(&mut result.primary))
491        .map(|(mut k, v)| {
492            k.conditions.insert("types".to_string(), true);
493            (k, v)
494        })
495        .collect();
496    Ok(result.cell())
497}
498
499#[turbo_tasks::function]
500async fn apply_typescript_types_options(
501    resolve_options: Vc<ResolveOptions>,
502) -> Result<Vc<ResolveOptions>> {
503    let mut resolve_options = resolve_options.owned().await?;
504    resolve_options.extensions = vec![rcstr!(".tsx"), rcstr!(".ts"), rcstr!(".d.ts")];
505    resolve_options.into_package = resolve_options
506        .into_package
507        .drain(..)
508        .filter_map(|into| {
509            if let ResolveIntoPackage::ExportsField {
510                mut conditions,
511                unspecified_conditions,
512            } = into
513            {
514                conditions.insert(rcstr!("types"), ConditionValue::Set);
515                Some(ResolveIntoPackage::ExportsField {
516                    conditions,
517                    unspecified_conditions,
518                })
519            } else {
520                None
521            }
522        })
523        .collect();
524    resolve_options
525        .into_package
526        .push(ResolveIntoPackage::MainField {
527            field: rcstr!("types"),
528        });
529    for conditions in get_condition_maps(&mut resolve_options) {
530        conditions.insert(rcstr!("types"), ConditionValue::Set);
531    }
532    Ok(resolve_options.into())
533}
534
535#[turbo_tasks::value_impl]
536impl Issue for TsConfigIssue {
537    fn severity(&self) -> IssueSeverity {
538        self.severity
539    }
540
541    #[turbo_tasks::function]
542    fn title(&self) -> Vc<StyledString> {
543        StyledString::Text(rcstr!(
544            "An issue occurred while parsing a tsconfig.json file."
545        ))
546        .cell()
547    }
548
549    #[turbo_tasks::function]
550    fn file_path(&self) -> Vc<FileSystemPath> {
551        self.source.file_path()
552    }
553
554    #[turbo_tasks::function]
555    fn description(&self) -> Vc<OptionStyledString> {
556        Vc::cell(Some(
557            StyledString::Text(self.message.clone()).resolved_cell(),
558        ))
559    }
560
561    #[turbo_tasks::function]
562    fn stage(&self) -> Vc<IssueStage> {
563        IssueStage::Analysis.cell()
564    }
565
566    #[turbo_tasks::function]
567    fn source(&self) -> Vc<OptionIssueSource> {
568        Vc::cell(Some(self.source))
569    }
570}