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    manifest_from_probe(dir, DenoJson::load_from_dir(dir))
134}
135
136/// Project a pre-loaded Deno probe plus `package.json` into the
137/// [`load_member_package_manifest`] result shape. Shared so combined probes
138/// pay the `deno.json` / `deno.jsonc` filesystem probe only once.
139fn manifest_from_probe(
140    dir: &Path,
141    deno: Result<Option<(PathBuf, DenoJson)>, (PathBuf, String)>,
142) -> Result<Option<(String, PackageJson, Vec<String>)>, String> {
143    let pkg_path = dir.join("package.json");
144    if pkg_path.is_file() {
145        let pkg = PackageJson::load(&pkg_path)?;
146        deno.map_err(|(_path, error)| error)?;
147        let deps = pkg.all_dependency_names();
148        let name = pkg.name.clone().unwrap_or_else(|| dir_name_fallback(dir));
149        return Ok(Some((name, pkg, deps)));
150    }
151
152    match deno.map_err(|(_path, error)| error)? {
153        Some((_path, deno)) => {
154            let pkg = deno.to_package_json();
155            let name = pkg.name.clone().unwrap_or_else(|| dir_name_fallback(dir));
156            Ok(Some((name, pkg, Vec::new())))
157        }
158        None => Ok(None),
159    }
160}
161
162/// A directory's package-manifest outcome plus its Deno import map, produced
163/// from a single `deno.json` / `deno.jsonc` filesystem probe.
164///
165/// The two fields keep the independent error semantics of the separate
166/// loaders: `manifest` matches [`load_member_package_manifest`] exactly, and
167/// `deno_import_map` matches the success case of [`load_deno_import_map`]
168/// (absent when no Deno config exists or it fails to parse). A malformed
169/// `package.json` therefore does not discard a valid colocated import map.
170#[derive(Debug)]
171pub struct DirManifestProbe {
172    /// `(name, package_json_view, dependency_names)` or the manifest parse error.
173    pub manifest: Result<Option<(String, PackageJson, Vec<String>)>, String>,
174    /// Declaring config path and sorted `(specifier, target)` import-map entries.
175    pub deno_import_map: Option<(PathBuf, Vec<(String, String)>)>,
176}
177
178/// Load a directory's package manifest and Deno import map together, probing
179/// and parsing `deno.json` / `deno.jsonc` once instead of once per consumer.
180#[must_use]
181pub fn probe_dir_manifest(dir: &Path) -> DirManifestProbe {
182    let deno = DenoJson::load_from_dir(dir);
183    let deno_import_map = match &deno {
184        Ok(Some((path, config))) => Some((path.clone(), config.import_map_entries())),
185        _ => None,
186    };
187    let manifest = manifest_from_probe(dir, deno);
188    DirManifestProbe {
189        manifest,
190        deno_import_map,
191    }
192}
193
194/// Load a directory's package manifest as [`PackageJson`], preferring
195/// `package.json` and falling back to `deno.json` / `deno.jsonc`.
196#[must_use]
197pub fn load_dir_package_json(dir: &Path) -> Option<PackageJson> {
198    load_member_package_manifest(dir)
199        .ok()
200        .flatten()
201        .map(|(_name, pkg, _deps)| pkg)
202}
203
204/// Load sorted Deno import-map entries and their declaring config path.
205///
206/// # Errors
207///
208/// Returns the chosen config path and parse error when a present config is
209/// malformed.
210#[expect(
211    clippy::type_complexity,
212    reason = "tuple projection keeps the full Deno config model private"
213)]
214pub fn load_deno_import_map(
215    dir: &Path,
216) -> Result<Option<(PathBuf, Vec<(String, String)>)>, (PathBuf, String)> {
217    Ok(DenoJson::load_from_dir(dir)?.map(|(path, deno)| (path, deno.import_map_entries())))
218}
219
220/// Load root Deno workspace patterns and their declaring config path.
221///
222/// # Errors
223///
224/// Returns the chosen config path and parse error when a present config is
225/// malformed.
226#[expect(
227    clippy::type_complexity,
228    reason = "tuple projection keeps the full Deno config model private"
229)]
230pub fn load_root_deno_workspace_patterns(
231    root: &Path,
232) -> Result<Option<(PathBuf, Vec<String>)>, (PathBuf, String)> {
233    Ok(DenoJson::load_from_dir(root)?.map(|(path, deno)| (path, deno.workspace_patterns())))
234}
235
236/// Whether the project has a root Deno config and should not warn about a
237/// missing `node_modules` directory.
238#[must_use]
239pub fn is_deno_without_node_modules(root: &Path) -> bool {
240    dir_has_deno_json(root) && !root.join("package.json").is_file()
241}
242
243fn dir_name_fallback(dir: &Path) -> String {
244    dir.file_name().map_or_else(
245        || dir.to_string_lossy().into_owned(),
246        |name| name.to_string_lossy().into_owned(),
247    )
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn parses_workspace_and_imports() {
256        let dir = tempfile::tempdir().unwrap();
257        std::fs::write(
258            dir.path().join("deno.json"),
259            r#"{
260              "workspace": ["./apps/*", "./packages/*"],
261              "imports": {
262                "@std/assert": "jsr:@std/assert@1",
263                "@std/": "jsr:@std/"
264              }
265            }"#,
266        )
267        .unwrap();
268
269        let (path, deno) = DenoJson::load_from_dir(dir.path()).unwrap().unwrap();
270        assert!(path.ends_with("deno.json"));
271        assert_eq!(
272            deno.workspace_patterns(),
273            vec!["./apps/*".to_string(), "./packages/*".to_string()]
274        );
275        let map = deno.import_map_entries();
276        assert!(
277            map.iter()
278                .any(|(k, v)| k == "@std/assert" && v == "jsr:@std/assert@1")
279        );
280    }
281
282    #[test]
283    fn parses_workspace_object_members_form() {
284        let dir = tempfile::tempdir().unwrap();
285        std::fs::write(
286            dir.path().join("deno.json"),
287            r#"{"workspace":{"members":["./packages/*"]}}"#,
288        )
289        .unwrap();
290
291        let deno = DenoJson::load_from_dir(dir.path()).unwrap().unwrap().1;
292        assert_eq!(deno.workspace_patterns(), vec!["./packages/*".to_string()]);
293    }
294
295    #[test]
296    fn rejects_invalid_workspace_value() {
297        let dir = tempfile::tempdir().unwrap();
298        std::fs::write(dir.path().join("deno.json"), r#"{"workspace":5}"#).unwrap();
299
300        assert!(DenoJson::load_from_dir(dir.path()).is_err());
301    }
302
303    #[test]
304    fn parses_member_name_and_exports() {
305        let dir = tempfile::tempdir().unwrap();
306        std::fs::write(
307            dir.path().join("deno.json"),
308            r#"{
309              "name": "@fallow/core",
310              "exports": {
311                ".": "./mod.ts",
312                "./result": "./result.ts"
313              }
314            }"#,
315        )
316        .unwrap();
317
318        let (_name, pkg, deps) = load_member_package_manifest(dir.path()).unwrap().unwrap();
319        assert_eq!(pkg.name.as_deref(), Some("@fallow/core"));
320        assert!(pkg.exports.is_some());
321        assert!(deps.is_empty());
322    }
323
324    #[test]
325    fn prefers_package_json_over_deno_json() {
326        let dir = tempfile::tempdir().unwrap();
327        std::fs::write(dir.path().join("package.json"), r#"{"name":"from-npm"}"#).unwrap();
328        std::fs::write(dir.path().join("deno.json"), r#"{"name":"from-deno"}"#).unwrap();
329
330        let (name, _, _) = load_member_package_manifest(dir.path()).unwrap().unwrap();
331        assert_eq!(name, "from-npm");
332    }
333
334    #[test]
335    fn import_map_loader_preserves_path_and_sort_order() {
336        let dir = tempfile::tempdir().unwrap();
337        std::fs::write(
338            dir.path().join("deno.json"),
339            r#"{"imports":{"z":"./z.ts","a":"./a.ts"}}"#,
340        )
341        .unwrap();
342
343        let (path, entries) = load_deno_import_map(dir.path()).unwrap().unwrap();
344        assert!(path.ends_with("deno.json"));
345        assert_eq!(
346            entries,
347            vec![
348                ("a".to_string(), "./a.ts".to_string()),
349                ("z".to_string(), "./z.ts".to_string())
350            ]
351        );
352    }
353
354    #[test]
355    fn deno_without_node_modules_requires_no_package_json() {
356        let dir = tempfile::tempdir().unwrap();
357        std::fs::write(dir.path().join("deno.json"), "{}").unwrap();
358        assert!(is_deno_without_node_modules(dir.path()));
359
360        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
361        assert!(!is_deno_without_node_modules(dir.path()));
362    }
363
364    #[test]
365    fn probe_keeps_import_map_when_package_json_is_malformed() {
366        let dir = tempfile::tempdir().unwrap();
367        std::fs::write(dir.path().join("package.json"), r"{,}").unwrap();
368        std::fs::write(
369            dir.path().join("deno.json"),
370            r#"{"imports":{"@std/assert":"jsr:@std/assert@1"}}"#,
371        )
372        .unwrap();
373
374        let probe = probe_dir_manifest(dir.path());
375        assert!(
376            probe.manifest.is_err(),
377            "malformed package.json must surface as a manifest error"
378        );
379        let (path, entries) = probe
380            .deno_import_map
381            .expect("valid colocated import map survives a broken package.json");
382        assert!(path.ends_with("deno.json"));
383        assert_eq!(
384            entries,
385            vec![("@std/assert".to_string(), "jsr:@std/assert@1".to_string())]
386        );
387    }
388
389    #[test]
390    fn probe_drops_import_map_when_deno_config_is_malformed() {
391        let dir = tempfile::tempdir().unwrap();
392        std::fs::write(dir.path().join("deno.jsonc"), "{ imports: [ }").unwrap();
393
394        let probe = probe_dir_manifest(dir.path());
395        let error = probe
396            .manifest
397            .expect_err("malformed deno.jsonc must surface as a manifest error");
398        assert!(
399            error.contains("deno.jsonc"),
400            "error should name the failing config: {error}"
401        );
402        assert!(probe.deno_import_map.is_none());
403    }
404
405    #[test]
406    fn probe_returns_manifest_and_import_map_when_both_parse() {
407        let dir = tempfile::tempdir().unwrap();
408        std::fs::write(dir.path().join("package.json"), r#"{"name":"from-npm"}"#).unwrap();
409        std::fs::write(
410            dir.path().join("deno.json"),
411            r#"{"imports":{"a":"./a.ts"}}"#,
412        )
413        .unwrap();
414
415        let probe = probe_dir_manifest(dir.path());
416        let (name, _pkg, _deps) = probe
417            .manifest
418            .expect("both configs parse")
419            .expect("manifest present");
420        assert_eq!(
421            name, "from-npm",
422            "package.json identity wins over deno.json"
423        );
424        let (_path, entries) = probe.deno_import_map.expect("import map present");
425        assert_eq!(entries, vec![("a".to_string(), "./a.ts".to_string())]);
426    }
427
428    #[test]
429    fn accepts_jsonc_comments() {
430        let dir = tempfile::tempdir().unwrap();
431        std::fs::write(
432            dir.path().join("deno.jsonc"),
433            r#"{
434              // root workspace
435              "workspace": ["./packages/*"],
436            }"#,
437        )
438        .unwrap();
439        let deno = DenoJson::load_from_dir(dir.path()).unwrap().unwrap().1;
440        assert_eq!(deno.workspace_patterns(), vec!["./packages/*".to_string()]);
441    }
442}