Skip to main content

turbopack_resolve/
typescript.rs

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