Skip to main content

turbopack_resolve/
typescript.rs

1use std::mem::take;
2
3use anyhow::Result;
4use async_trait::async_trait;
5use serde_json::Value as JsonValue;
6use turbo_rcstr::{RcStr, rcstr};
7use turbo_tasks::{FxIndexMap, ResolvedVc, ValueDefault, Vc, fxindexset};
8use turbo_tasks_fs::{FileContent, FileJsonContent, FileSystemPath, FileSystemPathOption};
9use turbopack_core::{
10    asset::Asset,
11    context::AssetContext,
12    file_source::FileSource,
13    issue::{Issue, IssueExt, IssueSeverity, IssueSource, IssueStage, StyledString},
14    reference_type::{ReferenceType, TypeScriptReferenceSubType},
15    resolve::{
16        AliasPattern, ModuleResolveResult, RequestKey, ResolveErrorMode,
17        error::handle_resolve_error,
18        node::node_cjs_resolve_options,
19        options::{
20            ConditionValue, ImportMap, ImportMapping, ResolveIntoPackage, ResolveModules,
21            ResolveOptions,
22        },
23        origin::ResolveOrigin,
24        parse::Request,
25        pattern::Pattern,
26        resolve,
27    },
28    source::Source,
29};
30
31use crate::ecmascript::get_condition_maps;
32
33#[turbo_tasks::value(shared)]
34struct TsConfigIssue {
35    severity: IssueSeverity,
36    source: IssueSource,
37    message: RcStr,
38}
39
40#[turbo_tasks::function]
41async fn json_only(resolve_options: Vc<ResolveOptions>) -> Result<Vc<ResolveOptions>> {
42    let mut opts = resolve_options.owned().await?;
43    opts.extensions = vec![rcstr!(".json")];
44    Ok(opts.cell())
45}
46
47type TsConfig = (Vc<FileJsonContent>, ResolvedVc<Box<dyn Source>>);
48
49#[tracing::instrument(skip_all)]
50pub async fn read_tsconfigs(
51    mut data: Vc<FileContent>,
52    mut tsconfig: ResolvedVc<Box<dyn Source>>,
53    resolve_options: Vc<ResolveOptions>,
54) -> Result<Vec<TsConfig>> {
55    let mut configs = Vec::new();
56    let resolve_options = json_only(resolve_options);
57    loop {
58        // tsc ignores empty config files.
59        if let FileContent::Content(file) = &*data.await?
60            && file.content().is_empty()
61        {
62            break;
63        }
64
65        let parsed_data = data.parse_json_with_comments();
66        match &*parsed_data.await? {
67            FileJsonContent::Unparsable(e) => {
68                let message = format!("tsconfig is not parseable: invalid JSON: {}", e.message);
69                let source = IssueSource::from_unparsable_json(tsconfig, e);
70                TsConfigIssue {
71                    severity: IssueSeverity::Error,
72                    source,
73                    message: message.into(),
74                }
75                .resolved_cell()
76                .emit();
77            }
78            FileJsonContent::NotFound => {
79                TsConfigIssue {
80                    severity: IssueSeverity::Error,
81                    source: IssueSource::from_source_only(tsconfig),
82                    message: rcstr!("tsconfig not found"),
83                }
84                .resolved_cell()
85                .emit();
86            }
87            FileJsonContent::Content(json) => {
88                configs.push((parsed_data, tsconfig));
89                if let Some(extends) = json["extends"].as_str() {
90                    let resolved = resolve_extends(*tsconfig, extends, resolve_options).await?;
91                    if let Some(source) = resolved {
92                        data = source.content().file_content();
93                        tsconfig = source;
94                        continue;
95                    } else {
96                        TsConfigIssue {
97                            severity: IssueSeverity::Error,
98                            // TODO: this should point at the `extends` property
99                            source: IssueSource::from_source_only(tsconfig),
100                            message: format!("extends: \"{extends}\" doesn't resolve correctly")
101                                .into(),
102                        }
103                        .resolved_cell()
104                        .emit();
105                    }
106                }
107            }
108        }
109        break;
110    }
111    Ok(configs)
112}
113
114/// Resolves tsconfig files according to TS's implementation:
115/// https://github.com/microsoft/TypeScript/blob/611a912d/src/compiler/commandLineParser.ts#L3294-L3326
116#[tracing::instrument(skip_all)]
117async fn resolve_extends(
118    tsconfig: Vc<Box<dyn Source>>,
119    extends: &str,
120    resolve_options: Vc<ResolveOptions>,
121) -> Result<Option<ResolvedVc<Box<dyn Source>>>> {
122    let parent_dir = tsconfig.ident().await?.path.parent();
123    let request = Request::parse_string(extends.into());
124
125    // TS's resolution is weird, and has special behavior for different import
126    // types. There might be multiple alternatives like
127    // "some/path/node_modules/xyz/abc.json" and "some/node_modules/xyz/abc.json".
128    // We only want to use the first one.
129    match &*request.await? {
130        // TS has special behavior for "rooted" paths (absolute paths):
131        // https://github.com/microsoft/TypeScript/blob/611a912d/src/compiler/commandLineParser.ts#L3303-L3313
132        Request::Windows { path: Pattern::Constant(path), .. } |
133        // Server relative is treated as absolute
134        Request::ServerRelative { path: Pattern::Constant(path), .. } => {
135            resolve_extends_rooted_or_relative(parent_dir, request, resolve_options, path).await
136        }
137
138        // TS has special behavior for (explicitly) './' and '../', but not '.' nor '..':
139        // https://github.com/microsoft/TypeScript/blob/611a912d/src/compiler/commandLineParser.ts#L3303-L3313
140        Request::Relative {
141            path: Pattern::Constant(path),
142            ..
143        } if path.starts_with("./") || path.starts_with("../") => {
144            resolve_extends_rooted_or_relative(parent_dir, request, resolve_options, path).await
145        }
146
147        // An empty extends is treated as "./tsconfig"
148        Request::Empty => {
149            let request = Request::parse_string(rcstr!("./tsconfig"));
150            Ok(resolve(parent_dir,
151                ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined), request, resolve_options).await?.first_source())
152        }
153
154        // All other types are treated as module imports, and potentially joined with
155        // "tsconfig.json". This includes "relative" imports like '.' and '..'.
156        _ => {
157            let result = resolve(parent_dir.clone(), ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined), request, resolve_options).await?;
158            if let Some(source) = result.first_source() {
159                return Ok(Some(source));
160            }
161            let request = Request::parse_string(format!("{extends}/tsconfig").into());
162            Ok(resolve(parent_dir, ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined), request, resolve_options).await?.first_source())
163        }
164    }
165}
166
167async fn resolve_extends_rooted_or_relative(
168    lookup_path: FileSystemPath,
169    request: Vc<Request>,
170    resolve_options: Vc<ResolveOptions>,
171    path: &str,
172) -> Result<Option<ResolvedVc<Box<dyn Source>>>> {
173    let result = resolve(
174        lookup_path.clone(),
175        ReferenceType::TypeScript(TypeScriptReferenceSubType::Undefined),
176        request,
177        resolve_options,
178    )
179    .await?;
180
181    let mut result = result.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.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        .await?
195        .first_source();
196    }
197    Ok(result)
198}
199
200pub async fn read_from_tsconfigs<T>(
201    configs: &[TsConfig],
202    accessor: impl Fn(&JsonValue, ResolvedVc<Box<dyn Source>>) -> Option<T>,
203) -> Result<Option<T>> {
204    for (config, source) in configs.iter() {
205        if let FileJsonContent::Content(json) = &*config.await?
206            && let Some(result) = accessor(json, *source)
207        {
208            return Ok(Some(result));
209        }
210    }
211    Ok(None)
212}
213
214/// Resolve options specific to tsconfig.json.
215#[turbo_tasks::value]
216#[derive(Default)]
217pub struct TsConfigResolveOptions {
218    base_url: Option<FileSystemPath>,
219    import_map: Option<ResolvedVc<ImportMap>>,
220    is_module_resolution_nodenext: bool,
221}
222
223#[turbo_tasks::value_impl]
224impl ValueDefault for TsConfigResolveOptions {
225    #[turbo_tasks::function]
226    fn value_default() -> Vc<Self> {
227        Self::default().cell()
228    }
229}
230
231#[turbo_tasks::function]
232async fn try_join_base_url(
233    source: ResolvedVc<Box<dyn Source>>,
234    base_url: RcStr,
235) -> Result<Vc<FileSystemPathOption>> {
236    Ok(Vc::cell(
237        source.ident().await?.path.parent().try_join(&base_url),
238    ))
239}
240
241/// Returns the resolve options
242#[turbo_tasks::function]
243pub async fn tsconfig_resolve_options(
244    tsconfig: FileSystemPath,
245) -> Result<Vc<TsConfigResolveOptions>> {
246    let configs = read_tsconfigs(
247        tsconfig.read(),
248        ResolvedVc::upcast(FileSource::new(tsconfig.clone()).to_resolved().await?),
249        node_cjs_resolve_options(tsconfig.root().owned().await?),
250    )
251    .await?;
252
253    if configs.is_empty() {
254        return Ok(Default::default());
255    }
256
257    let base_url = if let Some(base_url) = read_from_tsconfigs(&configs, |json, source| {
258        json["compilerOptions"]["baseUrl"]
259            .as_str()
260            .map(|base_url| try_join_base_url(*source, base_url.into()))
261    })
262    .await?
263    {
264        base_url.owned().await?
265    } else {
266        None
267    };
268
269    let mut all_paths = FxIndexMap::default();
270    for (content, source) in configs.iter().rev() {
271        if let FileJsonContent::Content(json) = &*content.await?
272            && let JsonValue::Object(paths) = &json["compilerOptions"]["paths"]
273        {
274            let mut context_dir = source.ident().await?.path.parent();
275            if let Some(base_url) = json["compilerOptions"]["baseUrl"].as_str()
276                && let Some(new_context) = context_dir.try_join(base_url)
277            {
278                context_dir = new_context;
279            };
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.await?.is_unresolvable() {
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#[async_trait]
519#[turbo_tasks::value_impl]
520impl Issue for TsConfigIssue {
521    fn severity(&self) -> IssueSeverity {
522        self.severity
523    }
524
525    async fn title(&self) -> anyhow::Result<StyledString> {
526        Ok(StyledString::Text(rcstr!(
527            "An issue occurred while parsing a tsconfig.json file."
528        )))
529    }
530
531    async fn file_path(&self) -> anyhow::Result<FileSystemPath> {
532        self.source.file_path().await
533    }
534
535    async fn description(&self) -> anyhow::Result<Option<StyledString>> {
536        Ok(Some(StyledString::Text(self.message.clone())))
537    }
538
539    fn stage(&self) -> IssueStage {
540        IssueStage::Analysis
541    }
542
543    fn source(&self) -> Option<IssueSource> {
544        Some(self.source)
545    }
546}