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]`, `[lsp]` or `[workers]` that the table
123    /// doesn't have. (`[paths]` reports [`Self::UnknownKey`]; `[fmt]` is
124    /// checked by its own reader, which owns its keys.)
125    UnknownTableKey { table: &'static str, key: String },
126    /// #1890: a key that must be a list of strings holds something else: a
127    /// bare string, a number, or a list with a non-string entry
128    /// (`[workers] compatibility_flags = ["a", 1]`).
129    NotAStringList {
130        table: &'static str,
131        key: &'static str,
132    },
133}
134
135/// #1665: the tables `bynk.toml` may hold, each with the keys it accepts.
136/// `[fmt]`'s keys are `bynk-fmt`'s to check (`FmtConfig`, `deny_unknown_fields`),
137/// so its list here is empty and unchecked.
138pub const MANIFEST_TABLES: &[(&str, &[&str])] = &[
139    ("project", &["name", "version"]),
140    ("paths", &["include", "exclude"]),
141    ("fmt", &[]),
142    ("lsp", &["diagnostics_mode", "diagnostics_debounce_ms"]),
143    ("workers", &["compatibility_flags"]),
144];
145
146/// Tables a user might write for a feature that is designed but not built,
147/// with the issue that tracks it.
148const PLANNED_TABLES: &[(&str, &str)] = &[
149    ("dependencies", "#843"),
150    ("dev-dependencies", "#843"),
151    ("workspace", "#843"),
152    ("deploy", "#551"),
153];
154
155/// The manifest's tables as
156/// `` `[project]`, `[paths]`, `[fmt]`, `[lsp]` <conj> `[workers]` ``, from
157/// [`MANIFEST_TABLES`], so a message can't fall behind it.
158fn table_list(conj: &str) -> String {
159    let names: Vec<String> = MANIFEST_TABLES
160        .iter()
161        .map(|(t, _)| format!("`[{t}]`"))
162        .collect();
163    match names.split_last() {
164        Some((last, rest)) if !rest.is_empty() => format!("{} {conj} {last}", rest.join(", ")),
165        _ => names.join(""),
166    }
167}
168
169/// The closest of `candidates` to `name`, if any is within two edits.
170fn did_you_mean<'a>(name: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
171    candidates
172        .into_iter()
173        .map(|c| (edit_distance(name, c), c))
174        .filter(|(d, _)| *d <= 2)
175        .min_by_key(|(d, _)| *d)
176        .map(|(_, c)| c)
177}
178
179/// Levenshtein distance over chars.
180fn edit_distance(a: &str, b: &str) -> usize {
181    let b: Vec<char> = b.chars().collect();
182    let mut row: Vec<usize> = (0..=b.len()).collect();
183    for (i, ca) in a.chars().enumerate() {
184        let mut prev = row[0];
185        row[0] = i + 1;
186        for (j, cb) in b.iter().enumerate() {
187            let cur = row[j + 1];
188            row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1);
189            prev = cur;
190        }
191    }
192    row[b.len()]
193}
194
195impl std::fmt::Display for ProjectPathsError {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            ProjectPathsError::Malformed => write!(f, "`bynk.toml` is not valid TOML"),
199            ProjectPathsError::UnknownKey(k) => {
200                write!(
201                    f,
202                    "`[paths]` has no key named `{k}` — did you mean `include` or `exclude`?"
203                )
204            }
205            ProjectPathsError::UnknownTable(name) => {
206                write!(f, "`bynk.toml` has no table named `[{name}]` — ")?;
207                if let Some((_, issue)) = PLANNED_TABLES.iter().find(|(t, _)| *t == name) {
208                    write!(f, "it is planned but not yet supported ({issue})")
209                } else if let Some(t) = did_you_mean(name, MANIFEST_TABLES.iter().map(|(t, _)| *t))
210                {
211                    write!(f, "did you mean `[{t}]`?")
212                } else {
213                    write!(f, "the tables are {}", table_list("and"))
214                }
215            }
216            ProjectPathsError::TopLevelKey(key) => {
217                // #1770 review: a table's own name written as a plain value
218                // (`paths = "src"`).
219                if MANIFEST_TABLES.iter().any(|(t, _)| t == key) {
220                    return write!(
221                        f,
222                        "`{key}` in `bynk.toml` must be a table — write it as `[{key}]`"
223                    );
224                }
225                write!(f, "`bynk.toml` has a key `{key}` outside any table — ")?;
226                match MANIFEST_TABLES
227                    .iter()
228                    .find(|(_, keys)| keys.contains(&key.as_str()))
229                {
230                    Some((table, _)) => write!(f, "did you mean it under `[{table}]`?"),
231                    None => write!(f, "keys belong in {}", table_list("or")),
232                }
233            }
234            ProjectPathsError::UnknownTableKey { table, key } => {
235                let keys = MANIFEST_TABLES
236                    .iter()
237                    .find(|(t, _)| t == table)
238                    .map(|(_, k)| *k)
239                    .unwrap_or_default();
240                write!(f, "`[{table}]` has no key named `{key}` — ")?;
241                match did_you_mean(key, keys.iter().copied()) {
242                    Some(k) => write!(f, "did you mean `{k}`?"),
243                    None => {
244                        let list: Vec<String> = keys.iter().map(|k| format!("`{k}`")).collect();
245                        write!(f, "its keys are {}", list.join(", "))
246                    }
247                }
248            }
249            ProjectPathsError::NotAStringList { table, key } => write!(
250                f,
251                "`[{table}]` `{key}` must be a list of strings, like `{key} = [\"nodejs_compat\"]`"
252            ),
253        }
254    }
255}
256
257/// #1665: check `bynk.toml`'s table set and the keys of `[project]` and
258/// `[lsp]` ([`MANIFEST_TABLES`]). Every other reader takes only its own table
259/// and ignores the rest, so an unknown table (`[dependencies]`, a typo'd
260/// `[pahts]`) used to build cleanly with none of its intended behaviour.
261///
262/// Kept apart from [`try_read_project_paths_with`] on purpose: the CLIs call
263/// both and refuse an unknown table, while the language server reads `[paths]`
264/// alone and keeps serving, so an extra table can't cost an editor its
265/// `include` layout. `Ok` when there is no `bynk.toml`. A manifest that doesn't
266/// parse is [`ProjectPathsError::Malformed`].
267pub fn check_manifest(
268    project_root: &Path,
269    overlay: &HashMap<PathBuf, String>,
270) -> Result<(), ProjectPathsError> {
271    let toml_path = project_root.join("bynk.toml");
272    let Ok(content) = read_source(&toml_path, overlay) else {
273        return Ok(());
274    };
275    check_manifest_str(&content)
276}
277
278/// [`check_manifest`] over a manifest's text.
279pub fn check_manifest_str(content: &str) -> Result<(), ProjectPathsError> {
280    let doc = content
281        .parse::<toml::Table>()
282        .map_err(|_| ProjectPathsError::Malformed)?;
283    for (name, value) in &doc {
284        // #1770 review: a planned table names its issue whatever shape it is
285        // written in (`[[dependencies]]`, `dependencies = [...]`).
286        if PLANNED_TABLES.iter().any(|(t, _)| t == name) {
287            return Err(ProjectPathsError::UnknownTable(name.clone()));
288        }
289        let Some((table, keys)) = MANIFEST_TABLES.iter().find(|(t, _)| t == name) else {
290            return Err(if value.is_table() {
291                ProjectPathsError::UnknownTable(name.clone())
292            } else {
293                ProjectPathsError::TopLevelKey(name.clone())
294            });
295        };
296        // #1770 review: a known table's name holding a plain value
297        // (`paths = "src"`) would otherwise read as an absent table.
298        let Some(entries) = value.as_table() else {
299            return Err(ProjectPathsError::TopLevelKey(name.clone()));
300        };
301        if keys.is_empty() {
302            continue;
303        }
304        if *table == "workers" {
305            workers_config_from(entries)?;
306        }
307        for key in entries.keys() {
308            if !keys.contains(&key.as_str()) {
309                return Err(match *table {
310                    "paths" => ProjectPathsError::UnknownKey(key.clone()),
311                    _ => ProjectPathsError::UnknownTableKey {
312                        table,
313                        key: key.clone(),
314                    },
315                });
316            }
317        }
318    }
319    Ok(())
320}
321
322/// Read `bynk.toml`'s `[paths]` section, surfacing a malformed manifest — a
323/// parse failure or an unrecognised `[paths]` key — as an error instead of
324/// silently falling back to the conventional layout (the previous
325/// `read_project_paths` total form's behaviour, R3.8 — deleted in favour of
326/// this at all 18 of its callers, #1113).
327///
328/// R3.9 (#1113): `[paths] include` is no longer capped at one or two trees —
329/// [`crate::roots::Roots::trees`] walks every entry, so this no longer rejects
330/// a longer list.
331pub fn try_read_project_paths(project_root: &Path) -> Result<ProjectPaths, ProjectPathsError> {
332    let toml_path = project_root.join("bynk.toml");
333    let overlay = match fs::read_to_string(&toml_path) {
334        Ok(text) => HashMap::from([(toml_path, text)]),
335        Err(_) => HashMap::new(),
336    };
337    try_read_project_paths_with(project_root, &overlay)
338}
339
340/// Like [`try_read_project_paths`], but honours `overlay` for `bynk.toml`
341/// itself, the same way `discovery::read_source` does for every other file.
342pub fn try_read_project_paths_with(
343    project_root: &Path,
344    overlay: &HashMap<PathBuf, String>,
345) -> Result<ProjectPaths, ProjectPathsError> {
346    let toml_path = project_root.join("bynk.toml");
347    let Ok(content) = read_source(&toml_path, overlay) else {
348        return Ok(ProjectPaths::conventional(project_root));
349    };
350    let Ok(doc) = content.parse::<toml::Table>() else {
351        return Err(ProjectPathsError::Malformed);
352    };
353    let paths = doc.get("paths").and_then(|v| v.as_table());
354    if let Some(t) = paths {
355        for k in t.keys() {
356            if k != "include" && k != "exclude" {
357                return Err(ProjectPathsError::UnknownKey(k.clone()));
358            }
359        }
360    }
361    let list = |key: &str| -> Vec<PathBuf> {
362        match paths.and_then(|t| t.get(key)) {
363            Some(toml::Value::Array(items)) => items
364                .iter()
365                .filter_map(|v| v.as_str())
366                .map(PathBuf::from)
367                .collect(),
368            Some(toml::Value::String(s)) => vec![PathBuf::from(s)],
369            _ => Vec::new(),
370        }
371    };
372    let mut include = list("include");
373    let exclude = list("exclude");
374    if include.is_empty() {
375        include = ProjectPaths::conventional(project_root).include;
376    }
377    Ok(ProjectPaths { include, exclude })
378}
379
380/// #1890: the project's `[workers]` table, settings for the Cloudflare
381/// Workers a `workers`-target build emits.
382#[derive(Debug, Clone, Default, PartialEq, Eq)]
383pub struct WorkersConfig {
384    /// Extra Cloudflare compatibility flags, in manifest order, for every
385    /// Worker's `wrangler.toml`, after the compiler's default set. The names
386    /// pass through unvalidated, since Cloudflare adds flags faster than a
387    /// list here could follow. Duplicates are kept here: the emitter drops
388    /// them and warns.
389    pub compatibility_flags: Vec<String>,
390}
391
392/// Read `bynk.toml`'s `[workers]` table, honouring `overlay` for `bynk.toml`
393/// the way [`try_read_project_paths_with`] does. No manifest, or no
394/// `[workers]` table, is the empty [`WorkersConfig`]. A manifest that doesn't
395/// parse is [`ProjectPathsError::Malformed`], and a `compatibility_flags`
396/// that isn't a list of strings is [`ProjectPathsError::NotAStringList`]. The
397/// shape is checked here as well as in [`check_manifest`], because some
398/// callers (the e2e harness) read the table without the whole-manifest check.
399pub fn try_read_workers_config_with(
400    project_root: &Path,
401    overlay: &HashMap<PathBuf, String>,
402) -> Result<WorkersConfig, ProjectPathsError> {
403    let toml_path = project_root.join("bynk.toml");
404    let Ok(content) = read_source(&toml_path, overlay) else {
405        return Ok(WorkersConfig::default());
406    };
407    let doc = content
408        .parse::<toml::Table>()
409        .map_err(|_| ProjectPathsError::Malformed)?;
410    match doc.get("workers").and_then(|v| v.as_table()) {
411        Some(t) => workers_config_from(t),
412        None => Ok(WorkersConfig::default()),
413    }
414}
415
416/// [`try_read_workers_config_with`], reading `bynk.toml` from disk.
417pub fn try_read_workers_config(project_root: &Path) -> Result<WorkersConfig, ProjectPathsError> {
418    let toml_path = project_root.join("bynk.toml");
419    let overlay = match fs::read_to_string(&toml_path) {
420        Ok(text) => HashMap::from([(toml_path, text)]),
421        Err(_) => HashMap::new(),
422    };
423    try_read_workers_config_with(project_root, &overlay)
424}
425
426/// #1890: the `bynk.project.duplicate_compatibility_flag` warning for one
427/// flag `[workers] compatibility_flags` repeats, against `manifest` (the
428/// project's `bynk.toml`). `default` says the repeat is of a flag every
429/// Worker already has, rather than of an earlier entry in the list. Built
430/// here, with the manifest's other diagnostics, rather than in `bynk-emit`,
431/// which only finds the duplicate while merging the list (R3.5 keeps
432/// diagnostics out of emission).
433pub fn duplicate_compatibility_flag_warning(
434    flag: &str,
435    default: bool,
436    manifest: PathBuf,
437) -> crate::AttributedError {
438    let message = if default {
439        format!(
440            "`bynk.toml`'s `[workers] compatibility_flags` lists `{flag}`, which every Bynk Worker already has"
441        )
442    } else {
443        format!("`bynk.toml`'s `[workers] compatibility_flags` lists `{flag}` more than once")
444    };
445    let mut error = bynk_syntax::CompileError::new(
446        "bynk.project.duplicate_compatibility_flag",
447        bynk_syntax::span::Span::default(),
448        message,
449    );
450    error
451        .notes
452        .push("the duplicate is dropped; remove it from `bynk.toml`".to_string());
453    crate::AttributedError::new(Some(manifest), error)
454}
455
456/// The `[workers]` table's typed content. Only the value shapes are checked
457/// here; an unknown key is [`check_manifest_str`]'s to refuse.
458fn workers_config_from(table: &toml::Table) -> Result<WorkersConfig, ProjectPathsError> {
459    let not_a_list = || ProjectPathsError::NotAStringList {
460        table: "workers",
461        key: "compatibility_flags",
462    };
463    let compatibility_flags = match table.get("compatibility_flags") {
464        None => Vec::new(),
465        Some(toml::Value::Array(items)) => items
466            .iter()
467            .map(|v| v.as_str().map(str::to_owned).ok_or_else(not_a_list))
468            .collect::<Result<_, _>>()?,
469        Some(_) => return Err(not_a_list()),
470    };
471    Ok(WorkersConfig {
472        compatibility_flags,
473    })
474}
475
476pub fn commons_dir_for(name: &str) -> PathBuf {
477    let parts: Vec<&str> = name.split('.').collect();
478    let mut p = PathBuf::new();
479    for part in parts {
480        p.push(part);
481    }
482    p
483}
484
485pub fn ts_output_path(source: &Path) -> PathBuf {
486    let mut out = source.to_path_buf();
487    out.set_extension("ts");
488    out
489}
490
491/// v0.8: directory name of a Worker for a given context, with dots replaced
492/// by dashes (`commerce.payment` → `commerce-payment`).
493pub fn worker_dir_name(context: &str) -> String {
494    context.replace('.', "-")
495}
496
497/// v0.8: project-relative synthetic source path of the workers-mode
498/// handlers file for a given context. Used so the emitter's relative-import
499/// machinery resolves correctly against the workers layout.
500pub fn worker_handlers_source_path(context: &str) -> PathBuf {
501    PathBuf::from(format!(
502        "workers/{}/handlers.bynk",
503        worker_dir_name(context)
504    ))
505}
506
507/// v0.8: project-relative output path of the workers-mode handlers file.
508pub fn worker_handlers_output_path(context: &str) -> PathBuf {
509    PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
510}
511
512/// #1820: project-relative synthetic source path of one file's module in a
513/// context split across files, on Workers. Each file is its own module under
514/// `workers/<dir>/handlers/`, and `handlers.ts` re-exports them all, so the
515/// Worker's entry point and composition root, and every consumer, still
516/// import the context from `handlers.ts`. A file keeps its stem
517/// (`shop/orders/place.bynk` → `handlers/place.bynk`). Distinct files map to
518/// distinct modules because every file of a split unit sits directly in its
519/// `<name>/` directory: a `<name>.bynk` beside it is rejected
520/// (`bynk.project.file_and_directory`), as is a file in any other directory
521/// (`bynk.project.inconsistent_commons_name`). The emitted module is
522/// [`ts_output_path`] of this path.
523pub fn worker_file_source_path(context: &str, source: &Path) -> PathBuf {
524    let stem = source.file_stem().unwrap_or_default().to_string_lossy();
525    PathBuf::from(format!(
526        "workers/{}/handlers/{stem}.bynk",
527        worker_dir_name(context)
528    ))
529}
530
531/// The src-stripped stem components of a path (`learner/uln.bynk` → `["learner",
532/// "uln"]`), dropping the extension and any non-`Normal` components.
533fn stem_parts(rel_path: &Path) -> Vec<String> {
534    rel_path
535        .with_extension("")
536        .components()
537        .filter_map(|c| match c {
538            Component::Normal(s) => Some(s.to_string_lossy().to_string()),
539            _ => None,
540        })
541        .collect()
542}
543
544/// v0.9.1: shared between source-unit and test-unit path validation. The
545/// caller decides which root to strip from the file path before calling.
546///
547/// A file belongs to `qualified_name` when it is either the single file
548/// `<name>.bynk` (`single_file_match`: stem parts == name parts) or one file of
549/// the directory layout `<name>/*.bynk` (`multi_file_match`: parent-dir parts ==
550/// name parts). These two branches are the single source of truth the v0.132
551/// barrel trigger reads via [`is_multi_file_layout`].
552pub fn unit_path_matches(rel_path: &Path, qualified_name: &str) -> bool {
553    let name_parts: Vec<&str> = qualified_name.split('.').collect();
554    let stem_parts = stem_parts(rel_path);
555    let single_file_match = stem_parts.len() == name_parts.len()
556        && stem_parts
557            .iter()
558            .zip(name_parts.iter())
559            .all(|(a, b)| a == b);
560    single_file_match || is_multi_file_parts(&stem_parts, &name_parts)
561}
562
563/// True when `stem_parts` is one file of the `<name>/*.bynk` directory layout —
564/// the file's parent-directory parts equal the name parts.
565fn is_multi_file_parts(stem_parts: &[String], name_parts: &[&str]) -> bool {
566    if stem_parts.is_empty() {
567        return false;
568    }
569    let parent_parts = &stem_parts[..stem_parts.len() - 1];
570    parent_parts.len() == name_parts.len()
571        && parent_parts
572            .iter()
573            .zip(name_parts.iter())
574            .all(|(a, b)| a == b)
575}
576
577/// v0.132: does `rel_path` (src-stripped) place `qualified_name` under a
578/// directory of that name — the `multi_file_match` branch of
579/// [`unit_path_matches`]?
580///
581/// This is the layout where production emits `out/<name>/*.ts` per file and no
582/// aggregate `out/<name>.ts`, so the test path's `import * as ns from
583/// "./<name>.js"` dangles and needs an aggregating barrel. A single-file commons
584/// (`<name>.bynk`) already owns `out/<name>.ts` and returns false, so a barrel
585/// keyed on this predicate can never collide with it.
586pub fn is_multi_file_layout(rel_path: &Path, qualified_name: &str) -> bool {
587    let name_parts: Vec<&str> = qualified_name.split('.').collect();
588    is_multi_file_parts(&stem_parts(rel_path), &name_parts)
589}
590
591/// #302: the qualified name a file moved from `old_rel` to `new_rel` should now
592/// declare, preserving whichever [`unit_path_matches`] arrangement `old_rel`
593/// used to satisfy against `old_name` — the dotted stem for a single-file
594/// unit, or the dotted parent-directory for one file of a multi-file unit.
595/// Returns `None` if `old_rel`/`old_name` don't actually satisfy either
596/// arrangement (a pre-existing inconsistency the caller should not guess at).
597///
598/// `old_name` is matched as a **suffix** of `old_rel`'s stem/parent, not the
599/// whole thing: the LSP's caller passes project-relative paths, which (unlike
600/// the `source_path` `unit_path_matches` itself is checked against) still
601/// carry a leading `include`-root segment (e.g. `src/`) that the qualified
602/// name never mentions. Whatever prefix length that suffix match implies for
603/// `old_rel` is applied unchanged to `new_rel` — correct as long as the file
604/// stays under the same `include` root, which a rename/move normally does.
605pub fn renamed_unit_name(old_rel: &Path, old_name: &str, new_rel: &Path) -> Option<String> {
606    let name_parts: Vec<&str> = old_name.split('.').collect();
607    let old_stem = stem_parts(old_rel);
608    let new_stem = stem_parts(new_rel);
609
610    let suffix_matches = |haystack: &[String]| {
611        haystack.len() >= name_parts.len() && {
612            let prefix_len = haystack.len() - name_parts.len();
613            haystack[prefix_len..]
614                .iter()
615                .zip(name_parts.iter())
616                .all(|(a, b)| a == b)
617        }
618    };
619
620    if suffix_matches(&old_stem) {
621        let prefix_len = old_stem.len() - name_parts.len();
622        return (new_stem.len() >= prefix_len).then(|| new_stem[prefix_len..].join("."));
623    }
624    if !old_stem.is_empty() {
625        let old_parent = &old_stem[..old_stem.len() - 1];
626        if suffix_matches(old_parent) {
627            let prefix_len = old_parent.len() - name_parts.len();
628            if new_stem.is_empty() {
629                return None;
630            }
631            let new_parent = &new_stem[..new_stem.len() - 1];
632            return (new_parent.len() >= prefix_len).then(|| new_parent[prefix_len..].join("."));
633        }
634    }
635    None
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use std::path::{Path, PathBuf};
642
643    // -- is_unpinned_range ----------------------------------------------------
644    #[test]
645    fn is_unpinned_range_true_for_wildcards_and_digitless() {
646        assert!(is_unpinned_range(""));
647        assert!(is_unpinned_range("*"));
648        assert!(is_unpinned_range("x"));
649        assert!(is_unpinned_range("X"));
650        assert!(is_unpinned_range("latest"));
651        assert!(is_unpinned_range("LATEST"));
652        assert!(is_unpinned_range("  *  ")); // trimmed before the checks
653        assert!(is_unpinned_range("workspace:*")); // no ascii digit
654        assert!(is_unpinned_range("beta"));
655    }
656
657    #[test]
658    fn is_unpinned_range_false_when_a_digit_is_present() {
659        assert!(!is_unpinned_range("1.0.0"));
660        assert!(!is_unpinned_range("^1.2"));
661        assert!(!is_unpinned_range("~0.1"));
662        assert!(!is_unpinned_range(">=2"));
663        assert!(!is_unpinned_range("18"));
664    }
665
666    // -- read_project_paths_with (#57, in-memory test seam) -------------------
667    #[test]
668    fn read_project_paths_with_honours_a_virtual_bynk_toml() {
669        let root = PathBuf::from("/nonexistent-bynk-test-root-57");
670        let mut overlay = HashMap::new();
671        overlay.insert(
672            root.join("bynk.toml"),
673            "[paths]\ninclude = [\"app\"]\nexclude = [\"vendor\"]\n".to_string(),
674        );
675        let paths = read_project_paths_with(&root, &overlay);
676        assert_eq!(paths.include, vec![PathBuf::from("app")]);
677        assert_eq!(paths.exclude, vec![PathBuf::from("vendor")]);
678    }
679
680    #[test]
681    fn read_project_paths_with_falls_back_to_conventional_with_no_overlay_entry() {
682        // No overlay entry and no real file at this (nonexistent) root — same
683        // fallback a missing on-disk `bynk.toml` gives.
684        let root = PathBuf::from("/nonexistent-bynk-test-root-57-empty");
685        let paths = read_project_paths_with(&root, &HashMap::new());
686        let conventional = ProjectPaths::conventional(&root);
687        assert_eq!(paths.include, conventional.include);
688        assert_eq!(paths.exclude, conventional.exclude);
689    }
690
691    /// R3.9 (#1113): three or more `[paths] include` entries all round-trip —
692    /// `try_read_project_paths_with` no longer caps the list at two.
693    #[test]
694    fn read_project_paths_with_honours_three_or_more_include_entries() {
695        let root = PathBuf::from("/nonexistent-bynk-test-root-1113-many-includes");
696        let mut overlay = HashMap::new();
697        overlay.insert(
698            root.join("bynk.toml"),
699            "[paths]\ninclude = [\"src\", \"tests\", \"examples\"]\n".to_string(),
700        );
701        let paths = try_read_project_paths_with(&root, &overlay).expect("must parse");
702        assert_eq!(
703            paths.include,
704            vec![
705                PathBuf::from("src"),
706                PathBuf::from("tests"),
707                PathBuf::from("examples"),
708            ]
709        );
710    }
711
712    // -- render_package_json --------------------------------------------------
713    #[test]
714    fn render_package_json_renders_sorted_dependencies() {
715        let mut deps = std::collections::BTreeMap::new();
716        deps.insert("zod".to_string(), "^3.22.4".to_string());
717        deps.insert("hono".to_string(), "^4.0.0".to_string());
718        let out = render_package_json(&deps);
719        // BTreeMap ordering keeps the file byte-stable across builds.
720        assert!(
721            out.find("\"hono\"").unwrap() < out.find("\"zod\"").unwrap(),
722            "dependencies render in sorted order:\n{out}"
723        );
724        assert!(out.contains("\"hono\": \"^4.0.0\""), "{out}");
725    }
726
727    /// A package name and version range reach here from adapter declarations in
728    /// Bynk source, so they are arbitrary text. This module used to escape only
729    /// `"` and `\`, which let a control character through as a literal — and a
730    /// literal control character inside a JSON string is a parse error, so the
731    /// emitted `package.json` was invalid rather than merely odd.
732    #[test]
733    fn render_package_json_escapes_the_control_range() {
734        let mut deps = std::collections::BTreeMap::new();
735        deps.insert("pkg\nname".to_string(), "^1.0\u{1}0".to_string());
736        let out = render_package_json(&deps);
737        assert!(out.contains("\"pkg\\nname\""), "{out}");
738        assert!(out.contains("\"^1.0\\u00010\""), "{out}");
739        // No raw control character survives into the rendered document (the
740        // pretty-printer's own newlines are all that remain).
741        assert!(
742            !out.lines().any(|l| l.chars().any(|c| (c as u32) < 0x20)),
743            "a raw control character reached the output:\n{out:?}"
744        );
745    }
746
747    #[test]
748    fn render_package_json_escapes_structural_characters() {
749        let mut deps = std::collections::BTreeMap::new();
750        deps.insert("a\"b".to_string(), "c\\d".to_string());
751        let out = render_package_json(&deps);
752        assert!(out.contains(r#""a\"b": "c\\d""#), "{out}");
753    }
754
755    // -- normalize_rel --------------------------------------------------------
756    #[test]
757    fn normalize_rel_resolves_dot_and_parent() {
758        assert_eq!(
759            normalize_rel(Path::new("./tokens.binding.ts")),
760            PathBuf::from("tokens.binding.ts")
761        );
762        assert_eq!(normalize_rel(Path::new("a/./b")), PathBuf::from("a/b"));
763        assert_eq!(normalize_rel(Path::new("a/../b")), PathBuf::from("b"));
764        assert_eq!(normalize_rel(Path::new("a/b/../../c")), PathBuf::from("c"));
765        assert_eq!(normalize_rel(Path::new("a/b")), PathBuf::from("a/b"));
766    }
767
768    #[test]
769    fn normalize_rel_drops_root_and_pops_through_empty() {
770        // RootDir / Prefix components are dropped.
771        assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
772        // A leading `..` pops an empty stack (a no-op), so it vanishes.
773        assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
774    }
775
776    // -- commons_dir_for / ts_output_path -------------------------------------
777    #[test]
778    fn commons_dir_for_splits_dotted_name_into_dirs() {
779        assert_eq!(commons_dir_for("a.b.c"), PathBuf::from("a/b/c"));
780        assert_eq!(commons_dir_for("foo"), PathBuf::from("foo"));
781    }
782
783    #[test]
784    fn ts_output_path_sets_ts_extension() {
785        assert_eq!(
786            ts_output_path(Path::new("foo.bynk")),
787            PathBuf::from("foo.ts")
788        );
789        assert_eq!(
790            ts_output_path(Path::new("a/b.bynk")),
791            PathBuf::from("a/b.ts")
792        );
793        assert_eq!(ts_output_path(Path::new("foo")), PathBuf::from("foo.ts"));
794    }
795
796    // -- worker path helpers --------------------------------------------------
797    #[test]
798    fn worker_paths_dasherise_and_root_under_workers() {
799        assert_eq!(worker_dir_name("commerce.payment"), "commerce-payment");
800        assert_eq!(worker_dir_name("plain"), "plain");
801        assert_eq!(
802            worker_handlers_source_path("commerce.payment"),
803            PathBuf::from("workers/commerce-payment/handlers.bynk")
804        );
805        assert_eq!(
806            worker_handlers_output_path("commerce.payment"),
807            PathBuf::from("workers/commerce-payment/handlers.ts")
808        );
809    }
810
811    #[test]
812    fn a_split_context_file_has_its_own_module_under_handlers() {
813        assert_eq!(
814            worker_file_source_path("shop.orders", Path::new("shop/orders/place.bynk")),
815            PathBuf::from("workers/shop-orders/handlers/place.bynk")
816        );
817    }
818
819    // -- unit_path_matches ----------------------------------------------------
820    #[test]
821    fn unit_path_matches_single_file_layout() {
822        assert!(unit_path_matches(Path::new("a/b/c.bynk"), "a.b.c"));
823        assert!(unit_path_matches(Path::new("foo.bynk"), "foo"));
824    }
825
826    #[test]
827    fn unit_path_matches_multi_file_layout() {
828        // `a/b/c/<any>.bynk` declaring `a.b.c` (the directory is the unit).
829        assert!(unit_path_matches(Path::new("a/b/c/handlers.bynk"), "a.b.c"));
830        assert!(unit_path_matches(Path::new("a/b/c/anything.bynk"), "a.b.c"));
831    }
832
833    #[test]
834    fn unit_path_matches_rejects_misalignment() {
835        assert!(!unit_path_matches(Path::new("a/b.bynk"), "a.b.c"));
836        assert!(!unit_path_matches(Path::new("x/y/z.bynk"), "a.b.c"));
837    }
838
839    // -- is_multi_file_layout (v0.132 barrel trigger) -------------------------
840    #[test]
841    fn is_multi_file_layout_true_only_for_directory_layout() {
842        // Directory layout: `<name>/*.bynk` — the branch with no `out/<name>.ts`.
843        assert!(is_multi_file_layout(Path::new("thing/a.bynk"), "thing"));
844        assert!(is_multi_file_layout(Path::new("thing/b.bynk"), "thing"));
845        // Dotted commons split across `src/a/b/*.bynk`.
846        assert!(is_multi_file_layout(Path::new("a/b/one.bynk"), "a.b"));
847    }
848
849    #[test]
850    fn is_multi_file_layout_false_for_single_file_and_misalignment() {
851        // Single file `<name>.bynk` already owns `out/<name>.ts` — no barrel.
852        assert!(!is_multi_file_layout(Path::new("thing.bynk"), "thing"));
853        // Dotted single file `a/b.bynk` for `a.b` — the file *is* `out/a/b.ts`.
854        assert!(!is_multi_file_layout(Path::new("a/b.bynk"), "a.b"));
855        // Wrong directory — not this unit's file.
856        assert!(!is_multi_file_layout(Path::new("other/a.bynk"), "thing"));
857    }
858
859    // -- renamed_unit_name (#302) ----------------------------------------------
860    #[test]
861    fn renamed_unit_name_single_file() {
862        assert_eq!(
863            renamed_unit_name(
864                Path::new("a/b/c.bynk"),
865                "a.b.c",
866                Path::new("a/b/renamed.bynk")
867            ),
868            Some("a.b.renamed".to_string())
869        );
870        assert_eq!(
871            renamed_unit_name(Path::new("foo.bynk"), "foo", Path::new("bar.bynk")),
872            Some("bar".to_string())
873        );
874    }
875
876    #[test]
877    fn renamed_unit_name_multi_file_member_rename_is_a_no_op() {
878        // Renaming one member file within the same directory doesn't change
879        // the unit's name — the qualified name is the directory, not the
880        // filename.
881        assert_eq!(
882            renamed_unit_name(
883                Path::new("a/b/c/old.bynk"),
884                "a.b.c",
885                Path::new("a/b/c/new.bynk")
886            ),
887            Some("a.b.c".to_string())
888        );
889    }
890
891    #[test]
892    fn renamed_unit_name_multi_file_directory_move() {
893        assert_eq!(
894            renamed_unit_name(
895                Path::new("a/b/c/handlers.bynk"),
896                "a.b.c",
897                Path::new("a/b/renamed/handlers.bynk")
898            ),
899            Some("a.b.renamed".to_string())
900        );
901    }
902
903    #[test]
904    fn renamed_unit_name_none_on_preexisting_misalignment() {
905        assert_eq!(
906            renamed_unit_name(Path::new("x/y/z.bynk"), "a.b.c", Path::new("x/y/w.bynk")),
907            None
908        );
909    }
910
911    #[test]
912    fn renamed_unit_name_tolerates_a_shared_include_root_prefix() {
913        // The LSP passes project-relative paths (ADR 0198), which still carry
914        // a split project's `src`/`tests` root segment — `unit_path_matches`
915        // itself is only ever checked against the root-stripped `source_path`.
916        // `old_name` must match as a *suffix*, and the same leading-segment
917        // count is preserved onto `new_rel`.
918        assert_eq!(
919            renamed_unit_name(
920                Path::new("src/billing/charge.bynk"),
921                "billing.charge",
922                Path::new("src/billing/pay.bynk")
923            ),
924            Some("billing.pay".to_string())
925        );
926        // Multi-file arrangement under the same prefix.
927        assert_eq!(
928            renamed_unit_name(
929                Path::new("src/a/b/c/handlers.bynk"),
930                "a.b.c",
931                Path::new("src/a/b/renamed/handlers.bynk")
932            ),
933            Some("a.b.renamed".to_string())
934        );
935    }
936}
937
938#[cfg(test)]
939mod manifest_tests {
940    use super::*;
941
942    fn err(manifest: &str) -> String {
943        check_manifest_str(manifest)
944            .expect_err("the manifest is refused")
945            .to_string()
946    }
947
948    /// #1665: every table the docs describe, with every key, is accepted.
949    #[test]
950    fn the_documented_tables_are_accepted() {
951        check_manifest_str(
952            "[project]\nname = \"p\"\nversion = \"0.1.0\"\n\n[paths]\ninclude = [\"src\"]\nexclude = []\n\n\
953             [fmt]\nindent = \"tab\"\nmax_line_width = 100\n\n[lsp]\ndiagnostics_mode = \"live\"\n\
954             diagnostics_debounce_ms = 300\n\n[workers]\ncompatibility_flags = [\"nodejs_compat\"]\n",
955        )
956        .expect("accepted");
957        check_manifest_str("").expect("an empty manifest is accepted");
958    }
959
960    #[test]
961    fn a_planned_table_names_its_issue() {
962        assert!(err("[dependencies]\nx = \"1\"\n").contains("not yet supported (#843)"));
963        assert!(err("[deploy]\ngroups = []\n").contains("not yet supported (#551)"));
964        assert!(err("[workspace]\nmembers = []\n").contains("(#843)"));
965    }
966
967    #[test]
968    fn a_typod_table_suggests_the_nearest() {
969        assert!(err("[pahts]\n").contains("did you mean `[paths]`?"));
970        assert!(err("[fnt]\n").contains("did you mean `[fmt]`?"));
971        assert!(err("[zzzzzz]\n").contains("the tables are"));
972    }
973
974    #[test]
975    fn an_unknown_key_in_project_or_lsp_suggests_the_nearest() {
976        assert!(err("[project]\nnmae = \"p\"\n").contains("did you mean `name`?"));
977        assert!(
978            err("[lsp]\ndiagnostics_mod = \"live\"\n").contains("did you mean `diagnostics_mode`?")
979        );
980        assert!(err("[project]\nlicense = \"MIT\"\n").contains("its keys are `name`, `version`"));
981    }
982
983    #[test]
984    fn a_paths_key_keeps_its_own_error() {
985        assert!(matches!(
986            check_manifest_str("[paths]\nout = \"out\"\n"),
987            Err(ProjectPathsError::UnknownKey(k)) if k == "out"
988        ));
989    }
990
991    #[test]
992    fn a_top_level_key_points_at_its_table() {
993        assert!(err("name = \"p\"\n").contains("did you mean it under `[project]`?"));
994    }
995
996    /// #1770 review: a known table's name holding a plain value is refused,
997    /// not read as an absent table.
998    #[test]
999    fn a_table_name_with_a_plain_value_must_be_a_table() {
1000        assert!(err("paths = \"src\"\n").contains("must be a table — write it as `[paths]`"));
1001        assert!(err("lsp = 3\n").contains("`[lsp]`"));
1002        assert!(err("fmt = true\n").contains("`[fmt]`"));
1003        // An inline table is a table.
1004        check_manifest_str("paths = { include = [\"src\"] }\n").expect("accepted");
1005    }
1006
1007    /// #1770 review: a planned table keeps its issue whatever shape it takes.
1008    #[test]
1009    fn a_planned_table_in_any_shape_names_its_issue() {
1010        assert!(err("[[dependencies]]\nname = \"acme\"\n").contains("(#843)"));
1011        assert!(err("dependencies = [\"acme\"]\n").contains("(#843)"));
1012    }
1013
1014    /// `[fmt]` keys are `bynk-fmt`'s to check, not this one's.
1015    #[test]
1016    fn fmt_keys_are_left_to_the_formatter() {
1017        check_manifest_str("[fmt]\nanything = 1\n").expect("not this check's concern");
1018    }
1019
1020    /// #1890: `[workers] compatibility_flags` must be a list of strings; the
1021    /// names themselves pass through.
1022    #[test]
1023    fn workers_compatibility_flags_must_be_a_list_of_strings() {
1024        let msg = "`[workers]` `compatibility_flags` must be a list of strings";
1025        assert!(err("[workers]\ncompatibility_flags = \"nodejs_compat\"\n").contains(msg));
1026        assert!(err("[workers]\ncompatibility_flags = [\"a\", 1]\n").contains(msg));
1027        assert!(err("[workers]\ncompatibility_flags = [[\"a\"]]\n").contains(msg));
1028        assert!(
1029            err("[workers]\ncompatibility_flag = []\n")
1030                .contains("did you mean `compatibility_flags`?")
1031        );
1032        check_manifest_str("[workers]\ncompatibility_flags = [\"no_such_flag_yet\"]\n")
1033            .expect("flag names are not validated");
1034        check_manifest_str("[workers]\n").expect("an empty `[workers]` is accepted");
1035    }
1036
1037    #[test]
1038    fn workers_config_reads_the_flags_in_order() {
1039        let root = PathBuf::from("/nonexistent-bynk-test-root-1890");
1040        let read = |manifest: &str| {
1041            let overlay = HashMap::from([(root.join("bynk.toml"), manifest.to_string())]);
1042            try_read_workers_config_with(&root, &overlay)
1043        };
1044        assert_eq!(
1045            read("[workers]\ncompatibility_flags = [\"b\", \"a\", \"b\"]\n")
1046                .unwrap()
1047                .compatibility_flags,
1048            vec!["b", "a", "b"],
1049            "order and duplicates are kept; deduplication is the emitter's"
1050        );
1051        assert_eq!(
1052            read("[project]\nname = \"p\"\n").unwrap(),
1053            WorkersConfig::default()
1054        );
1055        assert_eq!(
1056            try_read_workers_config_with(&root, &HashMap::new()).unwrap(),
1057            WorkersConfig::default(),
1058            "no manifest at all"
1059        );
1060        assert!(matches!(
1061            read("[workers]\ncompatibility_flags = [true]\n"),
1062            Err(ProjectPathsError::NotAStringList { .. })
1063        ));
1064        assert!(matches!(
1065            read("[workers\n"),
1066            Err(ProjectPathsError::Malformed)
1067        ));
1068    }
1069
1070    #[test]
1071    fn a_malformed_manifest_is_malformed() {
1072        assert!(matches!(
1073            check_manifest_str("[project\n"),
1074            Err(ProjectPathsError::Malformed)
1075        ));
1076    }
1077}