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}