Skip to main content

bynk_project/
paths.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5use crate::discovery::read_source;
6use crate::json::json_string;
7
8/// v0.17 [DECISION L] stub: a version range is *unpinned* — and rejected — when
9/// it is empty, `*`/`x`/`latest`, or otherwise carries no concrete version
10/// number. A pinned range names at least one digit (`^5`, `~1.2`, `1.2.3`,
11/// `>=1.0 <2`). No allow-list or registry check yet.
12pub fn is_unpinned_range(range: &str) -> bool {
13    let r = range.trim();
14    if r.is_empty() || r == "*" || r.eq_ignore_ascii_case("x") || r.eq_ignore_ascii_case("latest") {
15        return true;
16    }
17    !r.chars().any(|c| c.is_ascii_digit())
18}
19
20/// Render a minimal `package.json` carrying the adapter-declared dependencies.
21pub fn render_package_json(deps: &std::collections::BTreeMap<String, String>) -> String {
22    let mut out = String::from("{\n  \"dependencies\": {\n");
23    let entries: Vec<String> = deps
24        .iter()
25        .map(|(pkg, range)| format!("    {}: {}", json_string(pkg), json_string(range)))
26        .collect();
27    out.push_str(&entries.join(",\n"));
28    out.push_str("\n  }\n}\n");
29    out
30}
31
32/// Normalise a relative path by resolving `.` and `..` components, so a binding
33/// clause like `./tokens.binding.ts` beside `src/tokens.bynk` yields the output
34/// path `tokens.binding.ts`.
35pub fn normalize_rel(p: &Path) -> PathBuf {
36    let mut out: Vec<std::ffi::OsString> = Vec::new();
37    for c in p.components() {
38        match c {
39            Component::CurDir => {}
40            Component::ParentDir => {
41                out.pop();
42            }
43            Component::Normal(s) => out.push(s.to_os_string()),
44            Component::RootDir | Component::Prefix(_) => {}
45        }
46    }
47    out.iter().collect()
48}
49
50/// v0.113 (DECISION S): the project's source tree, read from `bynk.toml`'s
51/// `[paths]` section. Test-ness is a property of the `suite` declaration, not of
52/// a directory, so the layout is a flat **`include`** list of trees to compile
53/// and an **`exclude`** list of subtrees to skip — not the role-named
54/// `src`/`tests` split. Each `include` entry is a root walked for `.bynk` files;
55/// a file's identity path is relative to the `include` root that contains it.
56#[derive(Debug, Clone)]
57pub struct ProjectPaths {
58    /// Trees to compile, relative to the project root. Defaults to the
59    /// conventional roots that exist (`src`, and `tests` when present), else the
60    /// project root itself.
61    pub include: Vec<PathBuf>,
62    /// Subtrees to skip during discovery (monorepo, vendored, or generated
63    /// `.bynk`), relative to the project root.
64    pub exclude: Vec<PathBuf>,
65}
66
67impl ProjectPaths {
68    /// The default layout when `bynk.toml` declares no `[paths] include`: the
69    /// conventional `src`/`tests` roots that exist under `project_root`, or the
70    /// project root itself when neither does. This keeps a conventional
71    /// `src/`(+`tests/`) project working with no config, and lets a flat project
72    /// (`.bynk` at the root, no `src/`) compile with no config either.
73    pub fn conventional(project_root: &Path) -> Self {
74        let mut include = Vec::new();
75        for role in ["src", "tests"] {
76            if project_root.join(role).is_dir() {
77                include.push(PathBuf::from(role));
78            }
79        }
80        if include.is_empty() {
81            include.push(PathBuf::from("."));
82        }
83        ProjectPaths {
84            include,
85            exclude: Vec::new(),
86        }
87    }
88}
89
90/// Like [`try_read_project_paths`], but honours `overlay` for `bynk.toml`
91/// itself — the in-memory test seam's (#57) one remaining disk read outside
92/// `discovery::read_source`, now routed through the same helper so a test
93/// can supply a virtual `bynk.toml` with no on-disk file at all. `#[cfg(test)]`
94/// because that's its only consumer today; drop the gate if a non-test caller
95/// needs it (`try_read_project_paths_with`, which this wraps, has none of that
96/// restriction — production code already reaches it through the always-on
97/// `try_read_project_paths`).
98#[cfg(test)]
99pub(crate) fn read_project_paths_with(
100    project_root: &Path,
101    overlay: &HashMap<PathBuf, String>,
102) -> ProjectPaths {
103    try_read_project_paths_with(project_root, overlay)
104        .unwrap_or_else(|_| ProjectPaths::conventional(project_root))
105}
106
107/// A problem in `bynk.toml` that [`try_read_project_paths`] surfaces instead
108/// of silently falling back to the conventional layout.
109#[derive(Debug)]
110pub enum ProjectPathsError {
111    /// `bynk.toml` exists but does not parse as TOML (e.g. a trailing comma).
112    Malformed,
113    /// `[paths]` has a key other than `include`/`exclude` — most likely a typo
114    /// (`inculde`) that was silently read as "no include list".
115    UnknownKey(String),
116    /// #1665: a top-level entry that isn't one of the manifest's tables
117    /// ([`MANIFEST_TABLES`]): a typo (`[pahts]`), or a table for a planned
118    /// feature (`[dependencies]`) that would otherwise read as working.
119    UnknownTable(String),
120    /// #1665: a key at the top level of `bynk.toml`, outside any table.
121    TopLevelKey(String),
122    /// #1665: a key in `[project]` or `[lsp]` that the table doesn't have.
123    /// (`[paths]` reports [`Self::UnknownKey`]; `[fmt]` is checked by its own
124    /// reader, which owns its keys.)
125    UnknownTableKey { table: &'static str, key: String },
126}
127
128/// #1665: the tables `bynk.toml` may hold, each with the keys it accepts.
129/// `[fmt]`'s keys are `bynk-fmt`'s to check (`FmtConfig`, `deny_unknown_fields`),
130/// so its list here is empty and unchecked.
131pub const MANIFEST_TABLES: &[(&str, &[&str])] = &[
132    ("project", &["name", "version"]),
133    ("paths", &["include", "exclude"]),
134    ("fmt", &[]),
135    ("lsp", &["diagnostics_mode", "diagnostics_debounce_ms"]),
136];
137
138/// Tables a user might write for a feature that is designed but not built,
139/// with the issue that tracks it.
140const PLANNED_TABLES: &[(&str, &str)] = &[
141    ("dependencies", "#843"),
142    ("dev-dependencies", "#843"),
143    ("workspace", "#843"),
144    ("deploy", "#551"),
145];
146
147/// The manifest's tables as `` `[project]`, `[paths]`, `[fmt]` <conj> `[lsp]` ``,
148/// from [`MANIFEST_TABLES`], so a message can't fall behind it.
149fn table_list(conj: &str) -> String {
150    let names: Vec<String> = MANIFEST_TABLES
151        .iter()
152        .map(|(t, _)| format!("`[{t}]`"))
153        .collect();
154    match names.split_last() {
155        Some((last, rest)) if !rest.is_empty() => format!("{} {conj} {last}", rest.join(", ")),
156        _ => names.join(""),
157    }
158}
159
160/// The closest of `candidates` to `name`, if any is within two edits.
161fn did_you_mean<'a>(name: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
162    candidates
163        .into_iter()
164        .map(|c| (edit_distance(name, c), c))
165        .filter(|(d, _)| *d <= 2)
166        .min_by_key(|(d, _)| *d)
167        .map(|(_, c)| c)
168}
169
170/// Levenshtein distance over chars.
171fn edit_distance(a: &str, b: &str) -> usize {
172    let b: Vec<char> = b.chars().collect();
173    let mut row: Vec<usize> = (0..=b.len()).collect();
174    for (i, ca) in a.chars().enumerate() {
175        let mut prev = row[0];
176        row[0] = i + 1;
177        for (j, cb) in b.iter().enumerate() {
178            let cur = row[j + 1];
179            row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1);
180            prev = cur;
181        }
182    }
183    row[b.len()]
184}
185
186impl std::fmt::Display for ProjectPathsError {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        match self {
189            ProjectPathsError::Malformed => write!(f, "`bynk.toml` is not valid TOML"),
190            ProjectPathsError::UnknownKey(k) => {
191                write!(
192                    f,
193                    "`[paths]` has no key named `{k}` — did you mean `include` or `exclude`?"
194                )
195            }
196            ProjectPathsError::UnknownTable(name) => {
197                write!(f, "`bynk.toml` has no table named `[{name}]` — ")?;
198                if let Some((_, issue)) = PLANNED_TABLES.iter().find(|(t, _)| *t == name) {
199                    write!(f, "it is planned but not yet supported ({issue})")
200                } else if let Some(t) = did_you_mean(name, MANIFEST_TABLES.iter().map(|(t, _)| *t))
201                {
202                    write!(f, "did you mean `[{t}]`?")
203                } else {
204                    write!(f, "the tables are {}", table_list("and"))
205                }
206            }
207            ProjectPathsError::TopLevelKey(key) => {
208                // #1770 review: a table's own name written as a plain value
209                // (`paths = "src"`).
210                if MANIFEST_TABLES.iter().any(|(t, _)| t == key) {
211                    return write!(
212                        f,
213                        "`{key}` in `bynk.toml` must be a table — write it as `[{key}]`"
214                    );
215                }
216                write!(f, "`bynk.toml` has a key `{key}` outside any table — ")?;
217                match MANIFEST_TABLES
218                    .iter()
219                    .find(|(_, keys)| keys.contains(&key.as_str()))
220                {
221                    Some((table, _)) => write!(f, "did you mean it under `[{table}]`?"),
222                    None => write!(f, "keys belong in {}", table_list("or")),
223                }
224            }
225            ProjectPathsError::UnknownTableKey { table, key } => {
226                let keys = MANIFEST_TABLES
227                    .iter()
228                    .find(|(t, _)| t == table)
229                    .map(|(_, k)| *k)
230                    .unwrap_or_default();
231                write!(f, "`[{table}]` has no key named `{key}` — ")?;
232                match did_you_mean(key, keys.iter().copied()) {
233                    Some(k) => write!(f, "did you mean `{k}`?"),
234                    None => {
235                        let list: Vec<String> = keys.iter().map(|k| format!("`{k}`")).collect();
236                        write!(f, "its keys are {}", list.join(", "))
237                    }
238                }
239            }
240        }
241    }
242}
243
244/// #1665: check `bynk.toml`'s table set and the keys of `[project]` and
245/// `[lsp]` ([`MANIFEST_TABLES`]). Every other reader takes only its own table
246/// and ignores the rest, so an unknown table (`[dependencies]`, a typo'd
247/// `[pahts]`) used to build cleanly with none of its intended behaviour.
248///
249/// Kept apart from [`try_read_project_paths_with`] on purpose: the CLIs call
250/// both and refuse an unknown table, while the language server reads `[paths]`
251/// alone and keeps serving, so an extra table can't cost an editor its
252/// `include` layout. `Ok` when there is no `bynk.toml`. A manifest that doesn't
253/// parse is [`ProjectPathsError::Malformed`].
254pub fn check_manifest(
255    project_root: &Path,
256    overlay: &HashMap<PathBuf, String>,
257) -> Result<(), ProjectPathsError> {
258    let toml_path = project_root.join("bynk.toml");
259    let Ok(content) = read_source(&toml_path, overlay) else {
260        return Ok(());
261    };
262    check_manifest_str(&content)
263}
264
265/// [`check_manifest`] over a manifest's text.
266pub fn check_manifest_str(content: &str) -> Result<(), ProjectPathsError> {
267    let doc = content
268        .parse::<toml::Table>()
269        .map_err(|_| ProjectPathsError::Malformed)?;
270    for (name, value) in &doc {
271        // #1770 review: a planned table names its issue whatever shape it is
272        // written in (`[[dependencies]]`, `dependencies = [...]`).
273        if PLANNED_TABLES.iter().any(|(t, _)| t == name) {
274            return Err(ProjectPathsError::UnknownTable(name.clone()));
275        }
276        let Some((table, keys)) = MANIFEST_TABLES.iter().find(|(t, _)| t == name) else {
277            return Err(if value.is_table() {
278                ProjectPathsError::UnknownTable(name.clone())
279            } else {
280                ProjectPathsError::TopLevelKey(name.clone())
281            });
282        };
283        // #1770 review: a known table's name holding a plain value
284        // (`paths = "src"`) would otherwise read as an absent table.
285        let Some(entries) = value.as_table() else {
286            return Err(ProjectPathsError::TopLevelKey(name.clone()));
287        };
288        if keys.is_empty() {
289            continue;
290        }
291        for key in entries.keys() {
292            if !keys.contains(&key.as_str()) {
293                return Err(match *table {
294                    "paths" => ProjectPathsError::UnknownKey(key.clone()),
295                    _ => ProjectPathsError::UnknownTableKey {
296                        table,
297                        key: key.clone(),
298                    },
299                });
300            }
301        }
302    }
303    Ok(())
304}
305
306/// Read `bynk.toml`'s `[paths]` section, surfacing a malformed manifest — a
307/// parse failure or an unrecognised `[paths]` key — as an error instead of
308/// silently falling back to the conventional layout (the previous
309/// `read_project_paths` total form's behaviour, R3.8 — deleted in favour of
310/// this at all 18 of its callers, #1113).
311///
312/// R3.9 (#1113): `[paths] include` is no longer capped at one or two trees —
313/// [`crate::roots::Roots::trees`] walks every entry, so this no longer rejects
314/// a longer list.
315pub fn try_read_project_paths(project_root: &Path) -> Result<ProjectPaths, ProjectPathsError> {
316    let toml_path = project_root.join("bynk.toml");
317    let overlay = match fs::read_to_string(&toml_path) {
318        Ok(text) => HashMap::from([(toml_path, text)]),
319        Err(_) => HashMap::new(),
320    };
321    try_read_project_paths_with(project_root, &overlay)
322}
323
324/// Like [`try_read_project_paths`], but honours `overlay` for `bynk.toml`
325/// itself, the same way `discovery::read_source` does for every other file.
326pub fn try_read_project_paths_with(
327    project_root: &Path,
328    overlay: &HashMap<PathBuf, String>,
329) -> Result<ProjectPaths, ProjectPathsError> {
330    let toml_path = project_root.join("bynk.toml");
331    let Ok(content) = read_source(&toml_path, overlay) else {
332        return Ok(ProjectPaths::conventional(project_root));
333    };
334    let Ok(doc) = content.parse::<toml::Table>() else {
335        return Err(ProjectPathsError::Malformed);
336    };
337    let paths = doc.get("paths").and_then(|v| v.as_table());
338    if let Some(t) = paths {
339        for k in t.keys() {
340            if k != "include" && k != "exclude" {
341                return Err(ProjectPathsError::UnknownKey(k.clone()));
342            }
343        }
344    }
345    let list = |key: &str| -> Vec<PathBuf> {
346        match paths.and_then(|t| t.get(key)) {
347            Some(toml::Value::Array(items)) => items
348                .iter()
349                .filter_map(|v| v.as_str())
350                .map(PathBuf::from)
351                .collect(),
352            Some(toml::Value::String(s)) => vec![PathBuf::from(s)],
353            _ => Vec::new(),
354        }
355    };
356    let mut include = list("include");
357    let exclude = list("exclude");
358    if include.is_empty() {
359        include = ProjectPaths::conventional(project_root).include;
360    }
361    Ok(ProjectPaths { include, exclude })
362}
363
364pub fn commons_dir_for(name: &str) -> PathBuf {
365    let parts: Vec<&str> = name.split('.').collect();
366    let mut p = PathBuf::new();
367    for part in parts {
368        p.push(part);
369    }
370    p
371}
372
373pub fn ts_output_path(source: &Path) -> PathBuf {
374    let mut out = source.to_path_buf();
375    out.set_extension("ts");
376    out
377}
378
379/// v0.8: directory name of a Worker for a given context, with dots replaced
380/// by dashes (`commerce.payment` → `commerce-payment`).
381pub fn worker_dir_name(context: &str) -> String {
382    context.replace('.', "-")
383}
384
385/// v0.8: project-relative synthetic source path of the workers-mode
386/// handlers file for a given context. Used so the emitter's relative-import
387/// machinery resolves correctly against the workers layout.
388pub fn worker_handlers_source_path(context: &str) -> PathBuf {
389    PathBuf::from(format!(
390        "workers/{}/handlers.bynk",
391        worker_dir_name(context)
392    ))
393}
394
395/// v0.8: project-relative output path of the workers-mode handlers file.
396pub fn worker_handlers_output_path(context: &str) -> PathBuf {
397    PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
398}
399
400/// The src-stripped stem components of a path (`learner/uln.bynk` → `["learner",
401/// "uln"]`), dropping the extension and any non-`Normal` components.
402fn stem_parts(rel_path: &Path) -> Vec<String> {
403    rel_path
404        .with_extension("")
405        .components()
406        .filter_map(|c| match c {
407            Component::Normal(s) => Some(s.to_string_lossy().to_string()),
408            _ => None,
409        })
410        .collect()
411}
412
413/// v0.9.1: shared between source-unit and test-unit path validation. The
414/// caller decides which root to strip from the file path before calling.
415///
416/// A file belongs to `qualified_name` when it is either the single file
417/// `<name>.bynk` (`single_file_match`: stem parts == name parts) or one file of
418/// the directory layout `<name>/*.bynk` (`multi_file_match`: parent-dir parts ==
419/// name parts). These two branches are the single source of truth the v0.132
420/// barrel trigger reads via [`is_multi_file_layout`].
421pub fn unit_path_matches(rel_path: &Path, qualified_name: &str) -> bool {
422    let name_parts: Vec<&str> = qualified_name.split('.').collect();
423    let stem_parts = stem_parts(rel_path);
424    let single_file_match = stem_parts.len() == name_parts.len()
425        && stem_parts
426            .iter()
427            .zip(name_parts.iter())
428            .all(|(a, b)| a == b);
429    single_file_match || is_multi_file_parts(&stem_parts, &name_parts)
430}
431
432/// True when `stem_parts` is one file of the `<name>/*.bynk` directory layout —
433/// the file's parent-directory parts equal the name parts.
434fn is_multi_file_parts(stem_parts: &[String], name_parts: &[&str]) -> bool {
435    if stem_parts.is_empty() {
436        return false;
437    }
438    let parent_parts = &stem_parts[..stem_parts.len() - 1];
439    parent_parts.len() == name_parts.len()
440        && parent_parts
441            .iter()
442            .zip(name_parts.iter())
443            .all(|(a, b)| a == b)
444}
445
446/// v0.132: does `rel_path` (src-stripped) place `qualified_name` under a
447/// directory of that name — the `multi_file_match` branch of
448/// [`unit_path_matches`]?
449///
450/// This is the layout where production emits `out/<name>/*.ts` per file and no
451/// aggregate `out/<name>.ts`, so the test path's `import * as ns from
452/// "./<name>.js"` dangles and needs an aggregating barrel. A single-file commons
453/// (`<name>.bynk`) already owns `out/<name>.ts` and returns false, so a barrel
454/// keyed on this predicate can never collide with it.
455pub fn is_multi_file_layout(rel_path: &Path, qualified_name: &str) -> bool {
456    let name_parts: Vec<&str> = qualified_name.split('.').collect();
457    is_multi_file_parts(&stem_parts(rel_path), &name_parts)
458}
459
460/// #302: the qualified name a file moved from `old_rel` to `new_rel` should now
461/// declare, preserving whichever [`unit_path_matches`] arrangement `old_rel`
462/// used to satisfy against `old_name` — the dotted stem for a single-file
463/// unit, or the dotted parent-directory for one file of a multi-file unit.
464/// Returns `None` if `old_rel`/`old_name` don't actually satisfy either
465/// arrangement (a pre-existing inconsistency the caller should not guess at).
466///
467/// `old_name` is matched as a **suffix** of `old_rel`'s stem/parent, not the
468/// whole thing: the LSP's caller passes project-relative paths, which (unlike
469/// the `source_path` `unit_path_matches` itself is checked against) still
470/// carry a leading `include`-root segment (e.g. `src/`) that the qualified
471/// name never mentions. Whatever prefix length that suffix match implies for
472/// `old_rel` is applied unchanged to `new_rel` — correct as long as the file
473/// stays under the same `include` root, which a rename/move normally does.
474pub fn renamed_unit_name(old_rel: &Path, old_name: &str, new_rel: &Path) -> Option<String> {
475    let name_parts: Vec<&str> = old_name.split('.').collect();
476    let old_stem = stem_parts(old_rel);
477    let new_stem = stem_parts(new_rel);
478
479    let suffix_matches = |haystack: &[String]| {
480        haystack.len() >= name_parts.len() && {
481            let prefix_len = haystack.len() - name_parts.len();
482            haystack[prefix_len..]
483                .iter()
484                .zip(name_parts.iter())
485                .all(|(a, b)| a == b)
486        }
487    };
488
489    if suffix_matches(&old_stem) {
490        let prefix_len = old_stem.len() - name_parts.len();
491        return (new_stem.len() >= prefix_len).then(|| new_stem[prefix_len..].join("."));
492    }
493    if !old_stem.is_empty() {
494        let old_parent = &old_stem[..old_stem.len() - 1];
495        if suffix_matches(old_parent) {
496            let prefix_len = old_parent.len() - name_parts.len();
497            if new_stem.is_empty() {
498                return None;
499            }
500            let new_parent = &new_stem[..new_stem.len() - 1];
501            return (new_parent.len() >= prefix_len).then(|| new_parent[prefix_len..].join("."));
502        }
503    }
504    None
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use std::path::{Path, PathBuf};
511
512    // -- is_unpinned_range ----------------------------------------------------
513    #[test]
514    fn is_unpinned_range_true_for_wildcards_and_digitless() {
515        assert!(is_unpinned_range(""));
516        assert!(is_unpinned_range("*"));
517        assert!(is_unpinned_range("x"));
518        assert!(is_unpinned_range("X"));
519        assert!(is_unpinned_range("latest"));
520        assert!(is_unpinned_range("LATEST"));
521        assert!(is_unpinned_range("  *  ")); // trimmed before the checks
522        assert!(is_unpinned_range("workspace:*")); // no ascii digit
523        assert!(is_unpinned_range("beta"));
524    }
525
526    #[test]
527    fn is_unpinned_range_false_when_a_digit_is_present() {
528        assert!(!is_unpinned_range("1.0.0"));
529        assert!(!is_unpinned_range("^1.2"));
530        assert!(!is_unpinned_range("~0.1"));
531        assert!(!is_unpinned_range(">=2"));
532        assert!(!is_unpinned_range("18"));
533    }
534
535    // -- read_project_paths_with (#57, in-memory test seam) -------------------
536    #[test]
537    fn read_project_paths_with_honours_a_virtual_bynk_toml() {
538        let root = PathBuf::from("/nonexistent-bynk-test-root-57");
539        let mut overlay = HashMap::new();
540        overlay.insert(
541            root.join("bynk.toml"),
542            "[paths]\ninclude = [\"app\"]\nexclude = [\"vendor\"]\n".to_string(),
543        );
544        let paths = read_project_paths_with(&root, &overlay);
545        assert_eq!(paths.include, vec![PathBuf::from("app")]);
546        assert_eq!(paths.exclude, vec![PathBuf::from("vendor")]);
547    }
548
549    #[test]
550    fn read_project_paths_with_falls_back_to_conventional_with_no_overlay_entry() {
551        // No overlay entry and no real file at this (nonexistent) root — same
552        // fallback a missing on-disk `bynk.toml` gives.
553        let root = PathBuf::from("/nonexistent-bynk-test-root-57-empty");
554        let paths = read_project_paths_with(&root, &HashMap::new());
555        let conventional = ProjectPaths::conventional(&root);
556        assert_eq!(paths.include, conventional.include);
557        assert_eq!(paths.exclude, conventional.exclude);
558    }
559
560    /// R3.9 (#1113): three or more `[paths] include` entries all round-trip —
561    /// `try_read_project_paths_with` no longer caps the list at two.
562    #[test]
563    fn read_project_paths_with_honours_three_or_more_include_entries() {
564        let root = PathBuf::from("/nonexistent-bynk-test-root-1113-many-includes");
565        let mut overlay = HashMap::new();
566        overlay.insert(
567            root.join("bynk.toml"),
568            "[paths]\ninclude = [\"src\", \"tests\", \"examples\"]\n".to_string(),
569        );
570        let paths = try_read_project_paths_with(&root, &overlay).expect("must parse");
571        assert_eq!(
572            paths.include,
573            vec![
574                PathBuf::from("src"),
575                PathBuf::from("tests"),
576                PathBuf::from("examples"),
577            ]
578        );
579    }
580
581    // -- render_package_json --------------------------------------------------
582    #[test]
583    fn render_package_json_renders_sorted_dependencies() {
584        let mut deps = std::collections::BTreeMap::new();
585        deps.insert("zod".to_string(), "^3.22.4".to_string());
586        deps.insert("hono".to_string(), "^4.0.0".to_string());
587        let out = render_package_json(&deps);
588        // BTreeMap ordering keeps the file byte-stable across builds.
589        assert!(
590            out.find("\"hono\"").unwrap() < out.find("\"zod\"").unwrap(),
591            "dependencies render in sorted order:\n{out}"
592        );
593        assert!(out.contains("\"hono\": \"^4.0.0\""), "{out}");
594    }
595
596    /// A package name and version range reach here from adapter declarations in
597    /// Bynk source, so they are arbitrary text. This module used to escape only
598    /// `"` and `\`, which let a control character through as a literal — and a
599    /// literal control character inside a JSON string is a parse error, so the
600    /// emitted `package.json` was invalid rather than merely odd.
601    #[test]
602    fn render_package_json_escapes_the_control_range() {
603        let mut deps = std::collections::BTreeMap::new();
604        deps.insert("pkg\nname".to_string(), "^1.0\u{1}0".to_string());
605        let out = render_package_json(&deps);
606        assert!(out.contains("\"pkg\\nname\""), "{out}");
607        assert!(out.contains("\"^1.0\\u00010\""), "{out}");
608        // No raw control character survives into the rendered document (the
609        // pretty-printer's own newlines are all that remain).
610        assert!(
611            !out.lines().any(|l| l.chars().any(|c| (c as u32) < 0x20)),
612            "a raw control character reached the output:\n{out:?}"
613        );
614    }
615
616    #[test]
617    fn render_package_json_escapes_structural_characters() {
618        let mut deps = std::collections::BTreeMap::new();
619        deps.insert("a\"b".to_string(), "c\\d".to_string());
620        let out = render_package_json(&deps);
621        assert!(out.contains(r#""a\"b": "c\\d""#), "{out}");
622    }
623
624    // -- normalize_rel --------------------------------------------------------
625    #[test]
626    fn normalize_rel_resolves_dot_and_parent() {
627        assert_eq!(
628            normalize_rel(Path::new("./tokens.binding.ts")),
629            PathBuf::from("tokens.binding.ts")
630        );
631        assert_eq!(normalize_rel(Path::new("a/./b")), PathBuf::from("a/b"));
632        assert_eq!(normalize_rel(Path::new("a/../b")), PathBuf::from("b"));
633        assert_eq!(normalize_rel(Path::new("a/b/../../c")), PathBuf::from("c"));
634        assert_eq!(normalize_rel(Path::new("a/b")), PathBuf::from("a/b"));
635    }
636
637    #[test]
638    fn normalize_rel_drops_root_and_pops_through_empty() {
639        // RootDir / Prefix components are dropped.
640        assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
641        // A leading `..` pops an empty stack (a no-op), so it vanishes.
642        assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
643    }
644
645    // -- commons_dir_for / ts_output_path -------------------------------------
646    #[test]
647    fn commons_dir_for_splits_dotted_name_into_dirs() {
648        assert_eq!(commons_dir_for("a.b.c"), PathBuf::from("a/b/c"));
649        assert_eq!(commons_dir_for("foo"), PathBuf::from("foo"));
650    }
651
652    #[test]
653    fn ts_output_path_sets_ts_extension() {
654        assert_eq!(
655            ts_output_path(Path::new("foo.bynk")),
656            PathBuf::from("foo.ts")
657        );
658        assert_eq!(
659            ts_output_path(Path::new("a/b.bynk")),
660            PathBuf::from("a/b.ts")
661        );
662        assert_eq!(ts_output_path(Path::new("foo")), PathBuf::from("foo.ts"));
663    }
664
665    // -- worker path helpers --------------------------------------------------
666    #[test]
667    fn worker_paths_dasherise_and_root_under_workers() {
668        assert_eq!(worker_dir_name("commerce.payment"), "commerce-payment");
669        assert_eq!(worker_dir_name("plain"), "plain");
670        assert_eq!(
671            worker_handlers_source_path("commerce.payment"),
672            PathBuf::from("workers/commerce-payment/handlers.bynk")
673        );
674        assert_eq!(
675            worker_handlers_output_path("commerce.payment"),
676            PathBuf::from("workers/commerce-payment/handlers.ts")
677        );
678    }
679
680    // -- unit_path_matches ----------------------------------------------------
681    #[test]
682    fn unit_path_matches_single_file_layout() {
683        assert!(unit_path_matches(Path::new("a/b/c.bynk"), "a.b.c"));
684        assert!(unit_path_matches(Path::new("foo.bynk"), "foo"));
685    }
686
687    #[test]
688    fn unit_path_matches_multi_file_layout() {
689        // `a/b/c/<any>.bynk` declaring `a.b.c` (the directory is the unit).
690        assert!(unit_path_matches(Path::new("a/b/c/handlers.bynk"), "a.b.c"));
691        assert!(unit_path_matches(Path::new("a/b/c/anything.bynk"), "a.b.c"));
692    }
693
694    #[test]
695    fn unit_path_matches_rejects_misalignment() {
696        assert!(!unit_path_matches(Path::new("a/b.bynk"), "a.b.c"));
697        assert!(!unit_path_matches(Path::new("x/y/z.bynk"), "a.b.c"));
698    }
699
700    // -- is_multi_file_layout (v0.132 barrel trigger) -------------------------
701    #[test]
702    fn is_multi_file_layout_true_only_for_directory_layout() {
703        // Directory layout: `<name>/*.bynk` — the branch with no `out/<name>.ts`.
704        assert!(is_multi_file_layout(Path::new("thing/a.bynk"), "thing"));
705        assert!(is_multi_file_layout(Path::new("thing/b.bynk"), "thing"));
706        // Dotted commons split across `src/a/b/*.bynk`.
707        assert!(is_multi_file_layout(Path::new("a/b/one.bynk"), "a.b"));
708    }
709
710    #[test]
711    fn is_multi_file_layout_false_for_single_file_and_misalignment() {
712        // Single file `<name>.bynk` already owns `out/<name>.ts` — no barrel.
713        assert!(!is_multi_file_layout(Path::new("thing.bynk"), "thing"));
714        // Dotted single file `a/b.bynk` for `a.b` — the file *is* `out/a/b.ts`.
715        assert!(!is_multi_file_layout(Path::new("a/b.bynk"), "a.b"));
716        // Wrong directory — not this unit's file.
717        assert!(!is_multi_file_layout(Path::new("other/a.bynk"), "thing"));
718    }
719
720    // -- renamed_unit_name (#302) ----------------------------------------------
721    #[test]
722    fn renamed_unit_name_single_file() {
723        assert_eq!(
724            renamed_unit_name(
725                Path::new("a/b/c.bynk"),
726                "a.b.c",
727                Path::new("a/b/renamed.bynk")
728            ),
729            Some("a.b.renamed".to_string())
730        );
731        assert_eq!(
732            renamed_unit_name(Path::new("foo.bynk"), "foo", Path::new("bar.bynk")),
733            Some("bar".to_string())
734        );
735    }
736
737    #[test]
738    fn renamed_unit_name_multi_file_member_rename_is_a_no_op() {
739        // Renaming one member file within the same directory doesn't change
740        // the unit's name — the qualified name is the directory, not the
741        // filename.
742        assert_eq!(
743            renamed_unit_name(
744                Path::new("a/b/c/old.bynk"),
745                "a.b.c",
746                Path::new("a/b/c/new.bynk")
747            ),
748            Some("a.b.c".to_string())
749        );
750    }
751
752    #[test]
753    fn renamed_unit_name_multi_file_directory_move() {
754        assert_eq!(
755            renamed_unit_name(
756                Path::new("a/b/c/handlers.bynk"),
757                "a.b.c",
758                Path::new("a/b/renamed/handlers.bynk")
759            ),
760            Some("a.b.renamed".to_string())
761        );
762    }
763
764    #[test]
765    fn renamed_unit_name_none_on_preexisting_misalignment() {
766        assert_eq!(
767            renamed_unit_name(Path::new("x/y/z.bynk"), "a.b.c", Path::new("x/y/w.bynk")),
768            None
769        );
770    }
771
772    #[test]
773    fn renamed_unit_name_tolerates_a_shared_include_root_prefix() {
774        // The LSP passes project-relative paths (ADR 0198), which still carry
775        // a split project's `src`/`tests` root segment — `unit_path_matches`
776        // itself is only ever checked against the root-stripped `source_path`.
777        // `old_name` must match as a *suffix*, and the same leading-segment
778        // count is preserved onto `new_rel`.
779        assert_eq!(
780            renamed_unit_name(
781                Path::new("src/billing/charge.bynk"),
782                "billing.charge",
783                Path::new("src/billing/pay.bynk")
784            ),
785            Some("billing.pay".to_string())
786        );
787        // Multi-file arrangement under the same prefix.
788        assert_eq!(
789            renamed_unit_name(
790                Path::new("src/a/b/c/handlers.bynk"),
791                "a.b.c",
792                Path::new("src/a/b/renamed/handlers.bynk")
793            ),
794            Some("a.b.renamed".to_string())
795        );
796    }
797}
798
799#[cfg(test)]
800mod manifest_tests {
801    use super::*;
802
803    fn err(manifest: &str) -> String {
804        check_manifest_str(manifest)
805            .expect_err("the manifest is refused")
806            .to_string()
807    }
808
809    /// #1665: every table the docs describe, with every key, is accepted.
810    #[test]
811    fn the_documented_tables_are_accepted() {
812        check_manifest_str(
813            "[project]\nname = \"p\"\nversion = \"0.1.0\"\n\n[paths]\ninclude = [\"src\"]\nexclude = []\n\n\
814             [fmt]\nindent = \"tab\"\nmax_line_width = 100\n\n[lsp]\ndiagnostics_mode = \"live\"\n\
815             diagnostics_debounce_ms = 300\n",
816        )
817        .expect("accepted");
818        check_manifest_str("").expect("an empty manifest is accepted");
819    }
820
821    #[test]
822    fn a_planned_table_names_its_issue() {
823        assert!(err("[dependencies]\nx = \"1\"\n").contains("not yet supported (#843)"));
824        assert!(err("[deploy]\ngroups = []\n").contains("not yet supported (#551)"));
825        assert!(err("[workspace]\nmembers = []\n").contains("(#843)"));
826    }
827
828    #[test]
829    fn a_typod_table_suggests_the_nearest() {
830        assert!(err("[pahts]\n").contains("did you mean `[paths]`?"));
831        assert!(err("[fnt]\n").contains("did you mean `[fmt]`?"));
832        assert!(err("[zzzzzz]\n").contains("the tables are"));
833    }
834
835    #[test]
836    fn an_unknown_key_in_project_or_lsp_suggests_the_nearest() {
837        assert!(err("[project]\nnmae = \"p\"\n").contains("did you mean `name`?"));
838        assert!(
839            err("[lsp]\ndiagnostics_mod = \"live\"\n").contains("did you mean `diagnostics_mode`?")
840        );
841        assert!(err("[project]\nlicense = \"MIT\"\n").contains("its keys are `name`, `version`"));
842    }
843
844    #[test]
845    fn a_paths_key_keeps_its_own_error() {
846        assert!(matches!(
847            check_manifest_str("[paths]\nout = \"out\"\n"),
848            Err(ProjectPathsError::UnknownKey(k)) if k == "out"
849        ));
850    }
851
852    #[test]
853    fn a_top_level_key_points_at_its_table() {
854        assert!(err("name = \"p\"\n").contains("did you mean it under `[project]`?"));
855    }
856
857    /// #1770 review: a known table's name holding a plain value is refused,
858    /// not read as an absent table.
859    #[test]
860    fn a_table_name_with_a_plain_value_must_be_a_table() {
861        assert!(err("paths = \"src\"\n").contains("must be a table — write it as `[paths]`"));
862        assert!(err("lsp = 3\n").contains("`[lsp]`"));
863        assert!(err("fmt = true\n").contains("`[fmt]`"));
864        // An inline table is a table.
865        check_manifest_str("paths = { include = [\"src\"] }\n").expect("accepted");
866    }
867
868    /// #1770 review: a planned table keeps its issue whatever shape it takes.
869    #[test]
870    fn a_planned_table_in_any_shape_names_its_issue() {
871        assert!(err("[[dependencies]]\nname = \"acme\"\n").contains("(#843)"));
872        assert!(err("dependencies = [\"acme\"]\n").contains("(#843)"));
873    }
874
875    /// `[fmt]` keys are `bynk-fmt`'s to check, not this one's.
876    #[test]
877    fn fmt_keys_are_left_to_the_formatter() {
878        check_manifest_str("[fmt]\nanything = 1\n").expect("not this check's concern");
879    }
880
881    #[test]
882    fn a_malformed_manifest_is_malformed() {
883        assert!(matches!(
884            check_manifest_str("[project\n"),
885            Err(ProjectPathsError::Malformed)
886        ));
887    }
888}