Skip to main content

bynk_project/
consistency.rs

1use std::collections::{BTreeMap, HashMap};
2use std::path::{Path, PathBuf};
3
4use bynk_syntax::error::CompileError;
5
6use crate::discovery::ParsedFile;
7use crate::paths::unit_path_matches;
8use crate::roots::UnitKind;
9
10/// Within a multi-file unit (i.e., 2+ files in the same directory that share
11/// a qualified name), every file must declare exactly the same name.
12///
13/// In v0.4 the same directory may contain multiple *single-file* units (one
14/// commons and one context, say), provided each file's path matches the
15/// last segment of its declared qualified name. Mixed-name files in one
16/// directory are only flagged when they collide on the same name (handled by
17/// [`check_group_kind_consistency`]) or when path/name alignment fails.
18///
19/// Each error is paired with the project-relative `identity_path` of the file
20/// its **primary** span belongs to (#696) so the CLI can render it against that
21/// file's source. The "first file" it compares against is a *different* file, so
22/// its location is carried as a note (not a label, which would underline this
23/// file's own text).
24pub fn check_directory_name_consistency(
25    parsed: &[ParsedFile],
26) -> Result<(), Vec<(PathBuf, CompileError)>> {
27    let mut errors: Vec<(PathBuf, CompileError)> = Vec::new();
28    // For each unit (group of files sharing the same name), verify they all
29    // live in the same directory. Tests are excluded — their files are
30    // grouped by target, not by their own physical layout.
31    let mut by_name: HashMap<String, Vec<usize>> = HashMap::new();
32    for (i, pf) in parsed.iter().enumerate() {
33        if matches!(pf.kind, UnitKind::Test | UnitKind::Integration) {
34            continue;
35        }
36        by_name.entry(pf.unit.name().joined()).or_default().push(i);
37    }
38    for indices in by_name.values() {
39        if indices.len() < 2 {
40            continue;
41        }
42        let first_dir = parsed[indices[0]]
43            .source_path
44            .parent()
45            .unwrap_or(Path::new(""))
46            .to_path_buf();
47        for &idx in indices.iter().skip(1) {
48            let dir = parsed[idx]
49                .source_path
50                .parent()
51                .unwrap_or(Path::new(""))
52                .to_path_buf();
53            if dir != first_dir {
54                errors.push((
55                    parsed[idx].identity_path.clone(),
56                    CompileError::new(
57                        "bynk.project.inconsistent_commons_name",
58                        parsed[idx].unit.span(),
59                        format!(
60                            "files declaring `{}` are spread across different directories: `{}` vs `{}`",
61                            parsed[idx].unit.name().joined(),
62                            first_dir.display(),
63                            dir.display(),
64                        ),
65                    )
66                    // The first file is a *different* file than the one this error
67                    // is attributed to (`parsed[idx]`), so its span can't be a
68                    // label here — it would underline this file's own text (#696).
69                    // Carry its location as a note instead.
70                    .with_note(format!(
71                        "the first file declaring `{}` is `{}`",
72                        parsed[idx].unit.name().joined(),
73                        parsed[indices[0]].identity_path.to_string_lossy().replace('\\', "/"),
74                    ))
75                    .with_note(
76                        "all files of a multi-file commons or context must live in the same directory",
77                    ),
78                ));
79            }
80        }
81    }
82    if errors.is_empty() {
83        Ok(())
84    } else {
85        Err(errors)
86    }
87}
88
89/// Within a multi-file unit (files sharing a qualified name), every file must
90/// agree on kind. Handled by [`check_group_kind_consistency`]; this check is
91/// the v0.4-style directory-level guard which now defers to it.
92pub fn check_directory_kind_consistency(_parsed: &[ParsedFile]) -> Result<(), Vec<CompileError>> {
93    Ok(())
94}
95
96/// Each file's relative path must match its declared qualified name. Two
97/// arrangements are valid:
98/// - **Single-file**: `a/b/c.bynk` declaring `a.b.c`.
99/// - **Multi-file**: `a/b/c/<any>.bynk` declaring `a.b.c`.
100pub fn check_path_name_alignment(
101    parsed: &[ParsedFile],
102) -> Result<(), Vec<(PathBuf, CompileError)>> {
103    let mut errors: Vec<(PathBuf, CompileError)> = Vec::new();
104    for pf in parsed {
105        if matches!(pf.kind, UnitKind::Test | UnitKind::Integration) {
106            // Test files are not required to match their target's path.
107            continue;
108        }
109        let name = pf.unit.name().joined();
110        let name_parts: Vec<&str> = name.split('.').collect();
111        let rel = &pf.source_path;
112        if !unit_path_matches(rel, &name) {
113            errors.push((
114                pf.identity_path.clone(),
115                CompileError::new(
116                    "bynk.project.inconsistent_commons_name",
117                    pf.unit.span(),
118                    format!(
119                        "file `{}` declares `{name}`, but its path doesn't match — expected either `{}.bynk` (single-file) or `{}/...bynk` (multi-file)",
120                        rel.display(),
121                        name_parts.join("/"),
122                        name_parts.join("/"),
123                    ),
124                )
125                .with_note(
126                    "the source-tree layout determines a unit's identity: each commons or context's qualified name must match its path",
127                ),
128            ));
129        }
130    }
131    if errors.is_empty() {
132        Ok(())
133    } else {
134        Err(errors)
135    }
136}
137
138/// Files grouped by qualified name must agree on kind (even across directories).
139pub fn check_group_kind_consistency(
140    parsed: &[ParsedFile],
141    groups: &BTreeMap<String, Vec<usize>>,
142) -> Result<(), Vec<(PathBuf, CompileError)>> {
143    let mut errors: Vec<(PathBuf, CompileError)> = Vec::new();
144    for (name, indices) in groups {
145        if indices.len() < 2 {
146            continue;
147        }
148        let first_kind = parsed[indices[0]].kind;
149        for &idx in indices.iter().skip(1) {
150            if parsed[idx].kind != first_kind {
151                errors.push((
152                    parsed[idx].identity_path.clone(),
153                    CompileError::new(
154                        "bynk.project.kind_conflict",
155                        parsed[idx].unit.span(),
156                        format!(
157                            "name `{name}` is declared as both a {} and a {}",
158                            first_kind.display(),
159                            parsed[idx].kind.display(),
160                        ),
161                    )
162                    // A kind conflict is *always* cross-file (two files sharing a
163                    // name), so the "first declared" site lives in another file
164                    // (#696) — carry it as a note, not a label that would
165                    // underline this file's own declaration.
166                    .with_note(format!(
167                        "first declared as a {} in `{}`",
168                        first_kind.display(),
169                        parsed[indices[0]]
170                            .identity_path
171                            .to_string_lossy()
172                            .replace('\\', "/"),
173                    )),
174                ));
175            }
176        }
177    }
178    if errors.is_empty() {
179        Ok(())
180    } else {
181        Err(errors)
182    }
183}