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