Skip to main content

fallow_config/workspace/
deno_json.rs

1//! Deno `deno.json` / `deno.jsonc` parsing for workspace discovery and package manifests.
2//!
3//! Deno monorepos declare members via root `workspace` and package identity via
4//! member `name` + `exports`. Import maps live in root (and optionally member)
5//! `imports`. Fallow consumes these the same way it consumes npm `package.json`
6//! workspaces + exports, so Deno projects do not need bridge manifests.
7
8use std::path::{Path, PathBuf};
9
10use rustc_hash::FxHashMap;
11use serde::Deserialize;
12
13use super::package_json::PackageJson;
14
15/// Candidate Deno config filenames, in preference order.
16const DENO_JSON_NAMES: &[&str] = &["deno.json", "deno.jsonc"];
17
18/// Parsed Deno config fields fallow needs for discovery and resolution.
19#[derive(Debug, Clone, Default, Deserialize)]
20struct DenoJson {
21    #[serde(default)]
22    name: Option<String>,
23    #[serde(default)]
24    exports: Option<serde_json::Value>,
25    /// Workspace member globs (`["./apps/*", "./packages/*"]`).
26    #[serde(default)]
27    workspace: Option<DenoWorkspace>,
28    /// Import map entries (`"@std/assert" → "jsr:@std/assert@1"`).
29    #[serde(default)]
30    imports: Option<FxHashMap<String, String>>,
31}
32
33/// Deno `workspace` accepts a bare member array or `{ "members": [...] }`.
34#[derive(Debug, Clone, Deserialize)]
35#[serde(untagged)]
36enum DenoWorkspace {
37    Members(Vec<String>),
38    Detailed {
39        #[serde(default)]
40        members: Vec<String>,
41    },
42}
43
44impl DenoJson {
45    /// Load `deno.json` or `deno.jsonc` from an explicit path.
46    ///
47    /// # Errors
48    ///
49    /// Returns an error string when the file cannot be read or parsed.
50    fn load(path: &Path) -> Result<Self, String> {
51        let content = std::fs::read_to_string(path)
52            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
53        let content = content.trim_start_matches('\u{FEFF}');
54        crate::jsonc::parse_to_value(content)
55            .map_err(|e| format!("Failed to parse {}: {e}", path.display()))
56    }
57
58    /// Load the first existing Deno config in `dir` (`deno.json`, then `deno.jsonc`).
59    fn load_from_dir(dir: &Path) -> Result<Option<(PathBuf, Self)>, (PathBuf, String)> {
60        for name in DENO_JSON_NAMES {
61            let path = dir.join(name);
62            if path.is_file() {
63                let deno = Self::load(&path).map_err(|error| (path.clone(), error))?;
64                return Ok(Some((path, deno)));
65            }
66        }
67        Ok(None)
68    }
69
70    /// Workspace glob patterns from the root Deno config.
71    #[must_use]
72    fn workspace_patterns(&self) -> Vec<String> {
73        match &self.workspace {
74            Some(DenoWorkspace::Members(members)) | Some(DenoWorkspace::Detailed { members }) => {
75                members.clone()
76            }
77            None => Vec::new(),
78        }
79    }
80
81    /// Import map as sorted `(specifier, target)` pairs for stable hashing.
82    #[must_use]
83    fn import_map_entries(&self) -> Vec<(String, String)> {
84        let Some(imports) = &self.imports else {
85            return Vec::new();
86        };
87        let mut entries: Vec<(String, String)> = imports
88            .iter()
89            .map(|(k, v)| (k.clone(), v.clone()))
90            .collect();
91        entries.sort_by(|a, b| a.0.cmp(&b.0));
92        entries
93    }
94
95    /// Project a Deno package config into the `PackageJson` shape used by the
96    /// resolver (`name` + `exports` only).
97    #[must_use]
98    fn to_package_json(&self) -> PackageJson {
99        PackageJson {
100            name: self.name.clone(),
101            exports: self.exports.clone(),
102            ..PackageJson::default()
103        }
104    }
105}
106
107/// Whether `dir` has a Deno package/workspace config file.
108#[must_use]
109pub fn dir_has_deno_json(dir: &Path) -> bool {
110    DENO_JSON_NAMES.iter().any(|name| dir.join(name).is_file())
111}
112
113/// Whether `dir` has either an npm or Deno package manifest.
114#[must_use]
115pub fn dir_has_package_manifest(dir: &Path) -> bool {
116    dir.join("package.json").is_file() || dir_has_deno_json(dir)
117}
118
119/// Load package identity for a workspace member directory.
120///
121/// Prefers `package.json` when present (npm / bridge layouts), while still
122/// validating a colocated Deno config. Falls back to `deno.json` / `deno.jsonc`
123/// so pure Deno packages resolve without bridges.
124///
125/// Returns `(name, package_json_view, dependency_names)`.
126///
127/// # Errors
128///
129/// Returns an error when a present manifest fails to parse.
130pub fn load_member_package_manifest(
131    dir: &Path,
132) -> Result<Option<(String, PackageJson, Vec<String>)>, String> {
133    let pkg_path = dir.join("package.json");
134    if pkg_path.is_file() {
135        let pkg = PackageJson::load(&pkg_path)?;
136        DenoJson::load_from_dir(dir).map_err(|(_path, error)| error)?;
137        let deps = pkg.all_dependency_names();
138        let name = pkg.name.clone().unwrap_or_else(|| dir_name_fallback(dir));
139        return Ok(Some((name, pkg, deps)));
140    }
141
142    match DenoJson::load_from_dir(dir).map_err(|(_path, error)| error)? {
143        Some((_path, deno)) => {
144            let pkg = deno.to_package_json();
145            let name = pkg.name.clone().unwrap_or_else(|| dir_name_fallback(dir));
146            Ok(Some((name, pkg, Vec::new())))
147        }
148        None => Ok(None),
149    }
150}
151
152/// Load a directory's package manifest as [`PackageJson`], preferring
153/// `package.json` and falling back to `deno.json` / `deno.jsonc`.
154#[must_use]
155pub fn load_dir_package_json(dir: &Path) -> Option<PackageJson> {
156    load_member_package_manifest(dir)
157        .ok()
158        .flatten()
159        .map(|(_name, pkg, _deps)| pkg)
160}
161
162/// Load sorted Deno import-map entries and their declaring config path.
163///
164/// # Errors
165///
166/// Returns the chosen config path and parse error when a present config is
167/// malformed.
168#[expect(
169    clippy::type_complexity,
170    reason = "tuple projection keeps the full Deno config model private"
171)]
172pub fn load_deno_import_map(
173    dir: &Path,
174) -> Result<Option<(PathBuf, Vec<(String, String)>)>, (PathBuf, String)> {
175    Ok(DenoJson::load_from_dir(dir)?.map(|(path, deno)| (path, deno.import_map_entries())))
176}
177
178/// Load root Deno workspace patterns and their declaring config path.
179///
180/// # Errors
181///
182/// Returns the chosen config path and parse error when a present config is
183/// malformed.
184#[expect(
185    clippy::type_complexity,
186    reason = "tuple projection keeps the full Deno config model private"
187)]
188pub fn load_root_deno_workspace_patterns(
189    root: &Path,
190) -> Result<Option<(PathBuf, Vec<String>)>, (PathBuf, String)> {
191    Ok(DenoJson::load_from_dir(root)?.map(|(path, deno)| (path, deno.workspace_patterns())))
192}
193
194/// Whether the project has a root Deno config and should not warn about a
195/// missing `node_modules` directory.
196#[must_use]
197pub fn is_deno_without_node_modules(root: &Path) -> bool {
198    dir_has_deno_json(root) && !root.join("package.json").is_file()
199}
200
201fn dir_name_fallback(dir: &Path) -> String {
202    dir.file_name().map_or_else(
203        || dir.to_string_lossy().into_owned(),
204        |name| name.to_string_lossy().into_owned(),
205    )
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn parses_workspace_and_imports() {
214        let dir = tempfile::tempdir().unwrap();
215        std::fs::write(
216            dir.path().join("deno.json"),
217            r#"{
218              "workspace": ["./apps/*", "./packages/*"],
219              "imports": {
220                "@std/assert": "jsr:@std/assert@1",
221                "@std/": "jsr:@std/"
222              }
223            }"#,
224        )
225        .unwrap();
226
227        let (path, deno) = DenoJson::load_from_dir(dir.path()).unwrap().unwrap();
228        assert!(path.ends_with("deno.json"));
229        assert_eq!(
230            deno.workspace_patterns(),
231            vec!["./apps/*".to_string(), "./packages/*".to_string()]
232        );
233        let map = deno.import_map_entries();
234        assert!(
235            map.iter()
236                .any(|(k, v)| k == "@std/assert" && v == "jsr:@std/assert@1")
237        );
238    }
239
240    #[test]
241    fn parses_workspace_object_members_form() {
242        let dir = tempfile::tempdir().unwrap();
243        std::fs::write(
244            dir.path().join("deno.json"),
245            r#"{"workspace":{"members":["./packages/*"]}}"#,
246        )
247        .unwrap();
248
249        let deno = DenoJson::load_from_dir(dir.path()).unwrap().unwrap().1;
250        assert_eq!(deno.workspace_patterns(), vec!["./packages/*".to_string()]);
251    }
252
253    #[test]
254    fn rejects_invalid_workspace_value() {
255        let dir = tempfile::tempdir().unwrap();
256        std::fs::write(dir.path().join("deno.json"), r#"{"workspace":5}"#).unwrap();
257
258        assert!(DenoJson::load_from_dir(dir.path()).is_err());
259    }
260
261    #[test]
262    fn parses_member_name_and_exports() {
263        let dir = tempfile::tempdir().unwrap();
264        std::fs::write(
265            dir.path().join("deno.json"),
266            r#"{
267              "name": "@fallow/core",
268              "exports": {
269                ".": "./mod.ts",
270                "./result": "./result.ts"
271              }
272            }"#,
273        )
274        .unwrap();
275
276        let (_name, pkg, deps) = load_member_package_manifest(dir.path()).unwrap().unwrap();
277        assert_eq!(pkg.name.as_deref(), Some("@fallow/core"));
278        assert!(pkg.exports.is_some());
279        assert!(deps.is_empty());
280    }
281
282    #[test]
283    fn prefers_package_json_over_deno_json() {
284        let dir = tempfile::tempdir().unwrap();
285        std::fs::write(dir.path().join("package.json"), r#"{"name":"from-npm"}"#).unwrap();
286        std::fs::write(dir.path().join("deno.json"), r#"{"name":"from-deno"}"#).unwrap();
287
288        let (name, _, _) = load_member_package_manifest(dir.path()).unwrap().unwrap();
289        assert_eq!(name, "from-npm");
290    }
291
292    #[test]
293    fn import_map_loader_preserves_path_and_sort_order() {
294        let dir = tempfile::tempdir().unwrap();
295        std::fs::write(
296            dir.path().join("deno.json"),
297            r#"{"imports":{"z":"./z.ts","a":"./a.ts"}}"#,
298        )
299        .unwrap();
300
301        let (path, entries) = load_deno_import_map(dir.path()).unwrap().unwrap();
302        assert!(path.ends_with("deno.json"));
303        assert_eq!(
304            entries,
305            vec![
306                ("a".to_string(), "./a.ts".to_string()),
307                ("z".to_string(), "./z.ts".to_string())
308            ]
309        );
310    }
311
312    #[test]
313    fn deno_without_node_modules_requires_no_package_json() {
314        let dir = tempfile::tempdir().unwrap();
315        std::fs::write(dir.path().join("deno.json"), "{}").unwrap();
316        assert!(is_deno_without_node_modules(dir.path()));
317
318        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
319        assert!(!is_deno_without_node_modules(dir.path()));
320    }
321
322    #[test]
323    fn accepts_jsonc_comments() {
324        let dir = tempfile::tempdir().unwrap();
325        std::fs::write(
326            dir.path().join("deno.jsonc"),
327            r#"{
328              // root workspace
329              "workspace": ["./packages/*"],
330            }"#,
331        )
332        .unwrap();
333        let deno = DenoJson::load_from_dir(dir.path()).unwrap().unwrap().1;
334        assert_eq!(deno.workspace_patterns(), vec!["./packages/*".to_string()]);
335    }
336}