tree_sitter_stack_graphs_typescript/
tsconfig.rs

1// -*- coding: utf-8 -*-
2// ------------------------------------------------------------------------------------------------
3// Copyright © 2022, stack-graphs authors.
4// Licensed under either of Apache License, Version 2.0, or MIT license, at your option.
5// Please see the LICENSE-APACHE or LICENSE-MIT files in this distribution for license details.
6// ------------------------------------------------------------------------------------------------
7
8use glob::Pattern;
9use std::collections::HashMap;
10use std::path::Component;
11use std::path::Path;
12use std::path::PathBuf;
13
14use stack_graphs::arena::Handle;
15use stack_graphs::graph::File;
16use stack_graphs::graph::StackGraph;
17use tree_sitter_stack_graphs::BuildError;
18use tree_sitter_stack_graphs::FileAnalyzer;
19
20use crate::util::*;
21
22pub struct TsConfigAnalyzer {}
23
24impl FileAnalyzer for TsConfigAnalyzer {
25    fn build_stack_graph_into<'a>(
26        &self,
27        graph: &mut StackGraph,
28        file: Handle<File>,
29        path: &Path,
30        source: &str,
31        all_paths: &mut dyn Iterator<Item = &'a Path>,
32        globals: &HashMap<String, String>,
33        _cancellation_flag: &dyn tree_sitter_stack_graphs::CancellationFlag,
34    ) -> Result<(), tree_sitter_stack_graphs::BuildError> {
35        // read globals
36        let proj_name = globals.get(crate::PROJECT_NAME_VAR).map(String::as_str);
37
38        // parse source
39        let tsc = TsConfig::parse_str(path, source).map_err(|_| BuildError::ParseError)?;
40
41        // root node
42        let root = StackGraph::root_node();
43
44        // project scope
45        let proj_scope = if let Some(proj_name) = proj_name {
46            let proj_scope_id = graph.new_node_id(file);
47            let proj_scope = graph
48                .add_scope_node(proj_scope_id, false)
49                .expect("no previous node for new id");
50            add_debug_name(graph, proj_scope, "tsconfig.proj_scope");
51
52            // project definition
53            let proj_def = add_ns_pop(graph, file, root, PROJ_NS, proj_name, "tsconfig.proj_def");
54            add_edge(graph, proj_def, proj_scope, 0);
55
56            // project reference
57            let proj_ref = add_ns_push(graph, file, root, PROJ_NS, proj_name, "tsconfig.proj_ref");
58            add_edge(graph, proj_scope, proj_ref, 0);
59
60            proj_scope
61        } else {
62            root
63        };
64
65        // root directory
66        let pkg_def = add_pop(graph, file, proj_scope, PKG_M_NS, "tsconfig.pkg_def");
67        let root_dir_ref = add_module_pushes(
68            graph,
69            file,
70            M_NS,
71            &tsc.root_dir(all_paths),
72            proj_scope,
73            "tsconfig.root_dir.ref",
74        );
75        add_edge(graph, pkg_def, root_dir_ref, 0);
76
77        // auxiliary root directories, map relative imports to module paths
78        for (idx, root_dir) in tsc.root_dirs().iter().enumerate() {
79            let root_dir_def = add_pop(
80                graph,
81                file,
82                proj_scope,
83                REL_M_NS,
84                &format!("tsconfig.root_dirs[{}].def", idx),
85            );
86            let root_dir_ref = add_module_pushes(
87                graph,
88                file,
89                M_NS,
90                root_dir,
91                proj_scope,
92                &format!("tsconfig.root_dirs[{}].ref", idx),
93            );
94            add_edge(graph, root_dir_def, root_dir_ref, 0);
95        }
96
97        // base URL
98        let base_url = tsc.base_url();
99        let base_url_def = add_pop(
100            graph,
101            file,
102            proj_scope,
103            NON_REL_M_NS,
104            "tsconfig.base_url.def",
105        );
106        let base_url_ref = add_module_pushes(
107            graph,
108            file,
109            M_NS,
110            &base_url,
111            proj_scope,
112            "tsconfig.base_url.ref",
113        );
114        add_edge(graph, base_url_def, base_url_ref, 0);
115
116        // path mappings
117        for (from_idx, (from, tos)) in tsc.paths().iter().enumerate() {
118            let is_prefix = from.file_name().map_or(false, |n| n == "*");
119            let from = if is_prefix {
120                match from.parent() {
121                    Some(from) => from,
122                    None => continue,
123                }
124            } else {
125                &from
126            };
127            let from_def = add_module_pops(
128                graph,
129                file,
130                NON_REL_M_NS,
131                from,
132                proj_scope,
133                &format!("tsconfig.paths[{}].from_def", from_idx),
134            );
135            for (to_idx, to) in tos.iter().enumerate() {
136                if is_prefix && !to.file_name().map_or(false, |n| n == "*") {
137                    continue;
138                }
139                let to = if is_prefix {
140                    match to.parent() {
141                        Some(to) => to,
142                        None => continue,
143                    }
144                } else {
145                    &to
146                };
147                let to_ref = add_module_pushes(
148                    graph,
149                    file,
150                    M_NS,
151                    to,
152                    proj_scope,
153                    &format!("tsconfig.paths[{}][{}].to_ref", from_idx, to_idx),
154                );
155                add_edge(graph, from_def, to_ref, 0);
156            }
157        }
158
159        Ok(())
160    }
161}
162
163// -------------------------------------------------------------------------------------------------
164
165const TS_EXT: &str = "ts";
166const TSX_EXT: &str = "tsx";
167const JS_EXT: &str = "js";
168const JSX_EXT: &str = "jsx";
169const D_TS_EXT: &str = "d.ts";
170
171struct TsConfig {
172    project_dir: PathBuf,
173    tsc: tsconfig::TsConfig,
174}
175
176impl TsConfig {
177    fn parse_str(path: &Path, source: &str) -> Result<Self, BuildError> {
178        let project_dir = path.parent().ok_or(BuildError::ParseError)?.to_path_buf();
179        let tsc = tsconfig::TsConfig::parse_str(source).map_err(|_| BuildError::ParseError)?;
180        Ok(Self { project_dir, tsc })
181    }
182}
183
184impl TsConfig {
185    /// Returns whether JS files are considered sources.
186    ///
187    /// See: https://www.typescriptlang.org/tsconfig#allowJs
188    pub(self) fn allow_js(&self) -> bool {
189        self.tsc
190            .compiler_options
191            .as_ref()
192            .map_or(false, |co| co.allow_js.unwrap_or(false))
193    }
194
195    /// Returns the normalized, relative base URL.
196    ///
197    /// See: https://www.typescriptlang.org/tsconfig#baseUrl
198    pub(self) fn base_url(&self) -> PathBuf {
199        self.tsc
200            .compiler_options
201            .as_ref()
202            .map_or(PathBuf::new(), |co| {
203                co.base_url
204                    .as_ref()
205                    .and_then(|p| {
206                        NormalizedRelativePath::from_str(p)
207                            .filter(|p| !p.escapes())
208                            .map(|p| p.into_path_buf())
209                    })
210                    .unwrap_or(PathBuf::default())
211            })
212    }
213
214    /// Returns whether this is a composite project.
215    ///
216    /// See: https://www.typescriptlang.org/tsconfig#composite
217    pub(self) fn composite(&self) -> bool {
218        self.tsc
219            .compiler_options
220            .as_ref()
221            .map_or(false, |co| co.composite.unwrap_or(false))
222    }
223
224    /// Returns the exclude patterns for sources.
225    ///
226    /// See: https://www.typescriptlang.org/tsconfig#exclude
227    pub(self) fn exclude(&self) -> Vec<Pattern> {
228        self.tsc.exclude.as_ref().map_or(vec![], |patterns| {
229            patterns
230                .iter()
231                .flat_map(|p| self.expand_patterns(p))
232                .collect()
233        })
234    }
235
236    /// Returns listed source files.
237    ///
238    /// See: https://www.typescriptlang.org/tsconfig#files
239    pub(self) fn files(&self) -> Vec<PathBuf> {
240        self.tsc
241            .files
242            .as_ref()
243            .map_or(vec![], |e| e.iter().map(PathBuf::from).collect())
244    }
245
246    /// Returns if `files` is defined.
247    fn has_files(&self) -> bool {
248        self.tsc.files.is_some()
249    }
250
251    /// Returns the include patterns for sources.
252    ///
253    /// See: https://www.typescriptlang.org/tsconfig#include
254    pub(self) fn include(&self) -> Vec<Pattern> {
255        if let Some(patterns) = &self.tsc.include {
256            // we have explicit include patterns
257            patterns
258                .iter()
259                .flat_map(|p| self.expand_patterns(p))
260                .collect()
261        } else if self.has_files() {
262            // we have explicit files, so no default patterns
263            vec![]
264        } else {
265            // use default patterns
266            self.expand_patterns("**/*")
267        }
268    }
269
270    /// Expands a pattern without a file extension to patterns for all allowed extensions.
271    fn expand_patterns(&self, pattern: &str) -> Vec<Pattern> {
272        let mut p = PathBuf::from(pattern);
273
274        // if pattern has a file extension, use as is
275        if p.extension().is_some() {
276            return Pattern::new(&pattern).map_or(vec![], |p| vec![p]);
277        }
278
279        // if pattern has no file name, or the last component is `**` directory component, add a `*` file component
280        if p.file_name().map_or(true, |n| n == "**") {
281            p.push("*");
282        }
283
284        // determine accepted file extensions
285        let mut es = vec![TS_EXT, TSX_EXT, D_TS_EXT];
286        if self.allow_js() {
287            es.extend(&[JS_EXT, JSX_EXT]);
288        }
289
290        // compute patterns---invalid patterns are silently ignored
291        es.into_iter()
292            .filter_map(|e| {
293                p.with_extension(e)
294                    .to_str()
295                    .and_then(|p| Pattern::new(p).ok())
296            })
297            .collect()
298    }
299
300    /// Returns path mappings.
301    pub(self) fn paths(&self) -> HashMap<PathBuf, Vec<PathBuf>> {
302        self.tsc
303            .compiler_options
304            .as_ref()
305            .map_or(HashMap::default(), |co| {
306                co.paths.as_ref().map_or(HashMap::default(), |ps| {
307                    let mut m = HashMap::new();
308                    for (key, values) in ps {
309                        let from = match NormalizedRelativePath::from_str(key) {
310                            Some(from) => from,
311                            None => continue,
312                        };
313                        if from.escapes() {
314                            continue;
315                        }
316                        let is_prefix = from.as_path().file_name().map_or(false, |n| n == "*");
317                        let base_url = self.base_url();
318                        let tos = values
319                            .iter()
320                            .filter_map(|v| {
321                                let to = match NormalizedRelativePath::from_path(
322                                    &base_url.as_path().join(v),
323                                ) {
324                                    Some(to) => to,
325                                    None => return None,
326                                };
327                                if from.escapes() {
328                                    return None;
329                                }
330                                if is_prefix
331                                    && !from.as_path().file_name().map_or(false, |n| n == "*")
332                                {
333                                    return None;
334                                }
335                                Some(to.into())
336                            })
337                            .collect();
338                        m.insert(from.into(), tos);
339                    }
340                    m
341                })
342            })
343    }
344
345    /// Return the root directory of this project.
346    ///
347    /// The root directory is:
348    ///  1. The directory specified by the `compilerOptions.rootDir` property.
349    ///  2. The project root, if the `compilerOptions.composite` property is set.
350    ///  3. The longest common path of all non-declaration input files.
351    ///     Currently the `files`, `include`, and `exclude` properties are ignored for this option.
352    ///
353    /// Parameters:
354    ///  - source_paths: an iterable of source paths. The paths must be relative to the same origin as
355    ///                  the tsconfig path, but may include paths outside this project.
356    /// See: https://www.typescriptlang.org/tsconfig#rootDir
357    pub(self) fn root_dir<'a, PI>(&self, source_paths: PI) -> PathBuf
358    where
359        PI: IntoIterator<Item = &'a Path>,
360    {
361        if let Some(root_dir) = self
362            .tsc
363            .compiler_options
364            .as_ref()
365            .and_then(|co| {
366                co.root_dir
367                    .as_ref()
368                    .map(|p| NormalizedRelativePath::from_str(&p))
369            })
370            .flatten()
371            .filter(|p| !p.escapes())
372        {
373            return root_dir.into();
374        }
375
376        if self.composite() {
377            return PathBuf::default();
378        }
379
380        let mut root_dir: Option<PathBuf> = None;
381        for input_path in self.input_files(source_paths) {
382            if input_path
383                .extension()
384                .map(|ext| ext == D_TS_EXT)
385                .unwrap_or(false)
386            {
387                continue;
388            }
389
390            let input_dir = match input_path.parent() {
391                Some(input_dir) => input_dir,
392                None => continue,
393            };
394
395            root_dir = Some(if let Some(root_dir) = root_dir {
396                longest_common_prefix(&root_dir, input_dir).unwrap_or(root_dir)
397            } else {
398                input_dir.to_path_buf()
399            });
400        }
401
402        root_dir.unwrap_or(PathBuf::default())
403    }
404
405    // Get additional relative root directories. Non relative paths are ignored.
406    //
407    // See: https://www.typescriptlang.org/tsconfig#rootDirs
408    pub(self) fn root_dirs(&self) -> Vec<PathBuf> {
409        self.tsc.compiler_options.as_ref().map_or(vec![], |co| {
410            co.root_dirs.as_ref().map_or(vec![], |rs| {
411                rs.iter()
412                    .flat_map(|r| NormalizedRelativePath::from_str(r))
413                    .filter(|r| !r.escapes())
414                    .map(|r| r.into_path_buf())
415                    .collect()
416            })
417        })
418    }
419
420    /// Returns an iterator over the input files of the project, taking `files`, `include`, and `exclude` into account.
421    fn input_files<'a, PI>(&self, source_paths: PI) -> Vec<PathBuf>
422    where
423        PI: IntoIterator<Item = &'a Path>,
424    {
425        let files = self.files();
426        let include = self.include();
427        let exclude = self.exclude();
428
429        source_paths
430            .into_iter()
431            .filter_map(|p| {
432                let p = match p.strip_prefix(&self.project_dir) {
433                    Ok(p) => p,
434                    Err(_) => return None,
435                };
436
437                // normalize path
438                let p = match NormalizedRelativePath::from_path(p) {
439                    Some(p) => p.into_path_buf(),
440                    None => return None,
441                };
442
443                // accept files in the file list
444                for file in &files {
445                    if &p == file {
446                        return Some(p);
447                    }
448                }
449
450                // reject files not in the include patterns
451                if !include.iter().any(|i| i.matches_path(&p)) {
452                    return None;
453                }
454
455                // reject files matching exclude patterns
456                if exclude.iter().any(|e| e.matches_path(&p)) {
457                    return None;
458                }
459
460                // file was included, and not excluded, so accept
461                Some(p)
462            })
463            .collect()
464    }
465}
466
467// -------------------------------------------------------------------------------------------------
468
469/// Computes the longest common prefix shared with the given path.
470fn longest_common_prefix(left: &Path, right: &Path) -> Option<PathBuf> {
471    let mut prefix = PathBuf::new();
472    let mut left_it = left.components();
473    let mut right_it = right.components();
474    loop {
475        match (left_it.next(), right_it.next()) {
476            // prefixes must match
477            (Some(sc @ Component::Prefix(sp)), Some(Component::Prefix(op))) if sp == op => {
478                prefix.push(sc);
479            }
480            (Some(Component::Prefix(_)), _) | (_, Some(Component::Prefix(_))) => {
481                return None;
482            }
483            // roots must match
484            (Some(sc @ Component::RootDir), Some(Component::RootDir)) => {
485                prefix.push(sc);
486            }
487            (Some(Component::RootDir), _) | (_, Some(Component::RootDir)) => {
488                return None;
489            }
490            // right components may match
491            (Some(sc), Some(oc)) if sc == oc => {
492                prefix.push(sc);
493            }
494            // common prefix is done
495            (_, _) => break,
496        }
497    }
498    Some(prefix)
499}
500
501pub(crate) struct NormalizedRelativePath(PathBuf);
502
503impl NormalizedRelativePath {
504    pub(crate) fn from_str(path: &str) -> Option<Self> {
505        Self::from_path(Path::new(path))
506    }
507
508    /// Creates a new normalized, relative path from a path.
509    pub(crate) fn from_path(path: &Path) -> Option<Self> {
510        let mut np = PathBuf::new();
511        let mut normal_components = 0usize;
512        for c in path.components() {
513            match c {
514                Component::Prefix(_) => {
515                    return None;
516                }
517                Component::RootDir => {
518                    return None;
519                }
520                Component::CurDir => {}
521                Component::ParentDir => {
522                    if normal_components > 0 {
523                        // we can pop a normal component
524                        normal_components -= 1;
525                        np.pop();
526                    } else {
527                        // add the `..` to the beginning of this relative path which has no normal components
528                        np.push(c);
529                    }
530                }
531                Component::Normal(_) => {
532                    normal_components += 1;
533                    np.push(c);
534                }
535            }
536        }
537        Some(Self(np))
538    }
539
540    /// Returns if the relative path escapes to the parent.
541    pub(crate) fn escapes(&self) -> bool {
542        self.0
543            .components()
544            .next()
545            .map_or(false, |c| c == Component::ParentDir)
546    }
547
548    pub(crate) fn as_path(&self) -> &Path {
549        &self.0
550    }
551
552    pub(crate) fn into_path_buf(self) -> PathBuf {
553        self.0
554    }
555}
556
557impl AsRef<Path> for NormalizedRelativePath {
558    fn as_ref(&self) -> &Path {
559        &self.0
560    }
561}
562
563impl Into<PathBuf> for NormalizedRelativePath {
564    fn into(self) -> PathBuf {
565        self.0
566    }
567}