Skip to main content

chainsaw/lang/
mod.rs

1//! Language-specific parsing and resolution behind a common trait.
2//!
3//! Each supported language implements [`LanguageSupport`] to parse import
4//! statements and resolve specifiers to filesystem paths.
5
6pub mod python;
7pub mod typescript;
8
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::graph::EdgeKind;
14
15/// Opaque error from a language parser.
16///
17/// Callers never need to distinguish parse failure causes — they log
18/// and skip the file — so this is intentionally opaque.
19#[derive(Debug, Clone, PartialEq, Eq)]
20#[repr(transparent)]
21pub struct ParseError(String);
22
23impl ParseError {
24    pub fn new(msg: impl Into<String>) -> Self {
25        Self(msg.into())
26    }
27}
28
29impl std::fmt::Display for ParseError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        f.write_str(&self.0)
32    }
33}
34
35impl std::error::Error for ParseError {}
36
37/// A single import extracted from source code before resolution.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[non_exhaustive]
40pub struct RawImport {
41    pub specifier: String,
42    pub kind: EdgeKind,
43}
44
45/// All imports extracted from a single source file.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[non_exhaustive]
48pub struct ParseResult {
49    pub imports: Vec<RawImport>,
50    pub unresolvable_dynamic: usize,
51}
52
53/// Language-specific import parsing and specifier resolution.
54pub trait LanguageSupport: Send + Sync {
55    fn extensions(&self) -> &'static [&'static str];
56    fn parse(&self, path: &Path, source: &str) -> Result<ParseResult, ParseError>;
57    fn resolve(&self, from_dir: &Path, specifier: &str) -> Option<PathBuf>;
58    fn package_name(&self, resolved_path: &Path) -> Option<String>;
59    fn workspace_package_name(&self, file_path: &Path, project_root: &Path) -> Option<String>;
60}
61
62/// Which language ecosystem a project belongs to.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64#[non_exhaustive]
65pub enum ProjectKind {
66    TypeScript,
67    Python,
68}
69
70/// Detect the project kind from the entry file extension, then walk up
71/// to find the matching project root marker. Returns `None` for
72/// unsupported file extensions.
73pub fn detect_project(entry: &Path) -> Option<(PathBuf, ProjectKind)> {
74    let kind = match entry.extension().and_then(|e| e.to_str()) {
75        Some("ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "mts" | "cts") => {
76            ProjectKind::TypeScript
77        }
78        Some("py") => ProjectKind::Python,
79        _ => return None,
80    };
81
82    let markers: &[&str] = match kind {
83        ProjectKind::TypeScript => &["package.json"],
84        ProjectKind::Python => &["pyproject.toml", "setup.py", "setup.cfg"],
85    };
86
87    let root = find_root_with_markers(entry, markers)
88        .unwrap_or_else(|| entry.parent().unwrap_or(entry).to_path_buf());
89
90    Some((root, kind))
91}
92
93fn find_root_with_markers(entry: &Path, markers: &[&str]) -> Option<PathBuf> {
94    let mut dir = entry.parent()?;
95    loop {
96        for marker in markers {
97            if dir.join(marker).exists() {
98                return Some(dir.to_path_buf());
99            }
100        }
101        dir = dir.parent()?;
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use std::fs;
109    use tempfile::tempdir;
110
111    #[test]
112    fn detect_typescript_from_ts_extension() {
113        let tmp = tempdir().unwrap();
114        let root = tmp.path().canonicalize().unwrap();
115        fs::write(root.join("package.json"), "{}").unwrap();
116        let entry = root.join("src/index.ts");
117        fs::create_dir_all(entry.parent().unwrap()).unwrap();
118        fs::write(&entry, "").unwrap();
119
120        let (detected_root, kind) = detect_project(&entry).unwrap();
121        assert_eq!(kind, ProjectKind::TypeScript);
122        assert_eq!(detected_root, root);
123    }
124
125    #[test]
126    fn detect_python_from_py_extension() {
127        let tmp = tempdir().unwrap();
128        let root = tmp.path().canonicalize().unwrap();
129        fs::write(root.join("pyproject.toml"), "").unwrap();
130        let entry = root.join("src/main.py");
131        fs::create_dir_all(entry.parent().unwrap()).unwrap();
132        fs::write(&entry, "").unwrap();
133
134        let (detected_root, kind) = detect_project(&entry).unwrap();
135        assert_eq!(kind, ProjectKind::Python);
136        assert_eq!(detected_root, root);
137    }
138
139    #[test]
140    fn detect_python_setup_py_fallback() {
141        let tmp = tempdir().unwrap();
142        let root = tmp.path().canonicalize().unwrap();
143        // No pyproject.toml — only setup.py
144        fs::write(root.join("setup.py"), "").unwrap();
145        let entry = root.join("app.py");
146        fs::write(&entry, "").unwrap();
147
148        let (detected_root, kind) = detect_project(&entry).unwrap();
149        assert_eq!(kind, ProjectKind::Python);
150        assert_eq!(detected_root, root);
151    }
152}