turbopack_resolve/
typescript.rs

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