1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
//! BFS discovery of files reachable via INCLUDEs.
use std::collections::HashSet;
use std::io;
use brink_db::{ProjectDb, resolve_include_path};
use tracing::{debug, info};
/// Errors from file discovery.
#[derive(Debug, thiserror::Error)]
pub enum DiscoverError {
/// File I/O error during discovery.
#[error("I/O error: {0}")]
Io(#[from] io::Error),
/// Circular INCLUDE dependency detected.
#[error("circular INCLUDE: {0}")]
CircularInclude(String),
/// A native discovery key is not root-relative (contains a `..`
/// segment). `native_module_path` treats `..` literally, so letting one
/// through would mint a bogus module (`../x.brink` → `story::..::x`) —
/// save-key-identity-critical (issue #1288 review note (a)). Every
/// current `SourceTree` (`RealFs`, `GitRev`, `InMemory`) already
/// produces root-relative, `..`-free keys; this guards against a future
/// implementation that doesn't.
#[error("source key `{0}` is not root-relative (contains `..`)")]
InvalidKey(String),
/// A key `discover_native` was handed does not have the `.brink`
/// extension. `discover_native` must only ever see native source (issue
/// #1371): `tree` is a `&dyn SourceTree`, and nothing at the type level
/// stops a caller from handing it an implementation scoped wider than
/// `.brink` alone — e.g. `brink_source_tree::InMemory`, the tree
/// `brink-web`'s `compile()` builds (`.ink`-keyed) and hands to
/// `brink_environment::Project::load`, not to native discovery — which
/// would let `.ink` text be parsed as brink source. Checked (like
/// [`InvalidKey`](Self::InvalidKey)) before
/// any file is loaded, so a violation rejects the whole discovery, not
/// just the offending key.
#[error("source key `{0}` is not a native `.brink` file")]
NonNativeKey(String),
}
/// Discover all files reachable via INCLUDEs from the entry point.
///
/// Performs BFS: reads each file, parses it via `db.set_file()`, then follows
/// its INCLUDEs. After all files are loaded, rebuilds the include graph and
/// checks for cycles.
pub fn discover<F>(db: &mut ProjectDb, entry: &str, read_file: &mut F) -> Result<(), DiscoverError>
where
F: FnMut(&str) -> Result<String, io::Error>,
{
let mut queue: Vec<String> = vec![entry.to_string()];
let mut seen: HashSet<String> = HashSet::new();
while let Some(path) = queue.pop() {
if !seen.insert(path.clone()) {
continue;
}
let source = read_file(&path)?;
let file_id = db.set_file(&path, source);
// Discover INCLUDEs
if let Some(hir) = db.hir(file_id) {
for include in &hir.includes {
// A bare `INCLUDE` (no path) lowers to an `IncludeSite` with
// an empty `file_path` — the parser already flagged this as
// E037 ("expected file path"). Reading the empty path here
// would surface an `Io` error before that diagnostic ever
// reaches the user, so skip it and let discovery continue.
if include.file_path.is_empty() {
debug!(from = path, "skipping empty INCLUDE path (E037)");
continue;
}
let resolved = resolve_include_path(&path, &include.file_path);
if !seen.contains(&resolved) {
debug!(from = path, include = resolved, "discovered INCLUDE");
queue.push(resolved);
}
}
}
}
// Rebuild include graph now that all files are loaded
db.rebuild_include_graph();
// Detect circular includes
if let Some(cycle) = db.find_cycle() {
let names: Vec<_> = cycle.iter().filter_map(|id| db.file_path(*id)).collect();
return Err(DiscoverError::CircularInclude(names.join(" -> ")));
}
info!(files = seen.len(), "discovery complete");
Ok(())
}
#[cfg(test)]
mod tests {
use brink_db::resolve_include_path;
#[test]
fn resolve_relative_include() {
assert_eq!(
resolve_include_path("src/main.ink", "utils.ink"),
"src/utils.ink"
);
}
#[test]
fn resolve_no_directory() {
assert_eq!(resolve_include_path("story.ink", "other.ink"), "other.ink");
}
#[test]
fn resolve_nested_directory() {
assert_eq!(
resolve_include_path("story.ink", "lib/helpers.ink"),
"lib/helpers.ink"
);
}
#[test]
fn resolve_parent_traversal_normalized() {
// `..` collapses to a clean key so upward includes resolve to real
// files (system-wide; see docs/decision-log.md).
assert_eq!(resolve_include_path("a/b/c.ink", "../d.ink"), "a/d.ink");
}
#[test]
fn resolve_deep_nesting() {
assert_eq!(resolve_include_path("a/b/c.ink", "d/e.ink"), "a/b/d/e.ink");
}
/// #1504(b), reachable form: an editor session that admits an
/// `INCLUDE` target before the entry file itself (`brink-lsp`'s
/// `load_file_from_disk`, which can walk-and-load a
/// sibling ahead of an explicit `did_open` on the entry) mints the
/// entry a different numeric `FileId` than [`super::discover`] does —
/// `discover` always seeds its BFS queue with the entry, so a
/// from-scratch compile always mints the entry `FileId(0)`. The
/// synthesized root terminus used to be keyed by that numeric id
/// (`attach_root_final_gather`), not by anything content-derived, so the
/// container-id set an editor-order load produced diverged from a real
/// compile of the identical tree — the ink-mode sibling of the
/// editor-vs-compile identity parity `discover_native.rs` already guards
/// for native. #1504 re-keyed the terminus on the owning file's *path*
/// (`hir::root_content_scope_path`), so the two REGISTRATION ORDERS now
/// agree; this runs as the regression test for that, narrowly.
///
/// Narrowly, because this test holds the path spelling fixed
/// (`"entry.ink"`/`"sibling.ink"` in both orders) and varies only which
/// `FileId` gets assigned first. It does **not** cover the wider
/// spelling-parity gap flagged in review on #1693 and closed by #1696:
/// this test builds a `Driver` directly, bypassing
/// `brink-compiler/src/driver.rs`'s `prepare_driver` (which now
/// registers a root-relative-key qualifier via `ProjectDb::set_ink_root`)
/// and `brink-lsp`'s `register_native_root` (which now registers the
/// same session root under `set_ink_root` alongside `set_native_root`),
/// so a `Driver` used this directly still qualifies by the raw
/// registered path — see
/// `crates/brink-compiler/tests/issue_1504_root_content_identity.rs`'s
/// `root_content_ids_are_stable_across_entry_path_spellings`, which
/// covers the fixed behavior through `prepare_driver`, and
/// `docs/root-content-identity-findings.md`'s "Known limitation" section
/// for the full history.
#[test]
fn root_content_ids_agree_between_discover_and_editor_order() {
use std::collections::BTreeSet;
use std::collections::HashMap;
use brink_format::DefinitionId;
const ENTRY: &str = "INCLUDE sibling.ink\n* one\n* two\n- gathered\n";
const SIBLING: &str = "=== helper ===\nhelper text\n-> DONE\n";
fn container_ids(container: &brink_ir::lir::Container, out: &mut BTreeSet<DefinitionId>) {
out.insert(container.id);
for child in &container.children {
container_ids(child, out);
}
}
fn ids_via(db: &brink_db::ProjectDb) -> BTreeSet<DefinitionId> {
let mut out = BTreeSet::new();
let program = db
.lir_product()
.and_then(|p| p.program.as_ref())
.expect("lowering succeeds");
container_ids(&program.root, &mut out);
out
}
// (1) Compile order: `discover` seeds the BFS from the entry, so
// `entry.ink` mints `FileId(0)`.
let mut compiled = crate::Driver::new();
let files: HashMap<&str, &str> = [("entry.ink", ENTRY), ("sibling.ink", SIBLING)].into();
compiled
.discover("entry.ink", |path: &str| {
files
.get(path)
.map(|s| (*s).to_string())
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path))
})
.expect("discovery succeeds");
compiled.db_mut().set_entry("entry.ink");
// (2) Editor order: the sibling is admitted first (e.g. a workspace
// walk, or an `INCLUDE` chased before the entry itself is opened) —
// `entry.ink` mints `FileId(1)` instead.
let mut edited = crate::Driver::new();
edited.db_mut().set_file("sibling.ink", SIBLING.to_string());
edited.db_mut().set_file("entry.ink", ENTRY.to_string());
edited.db_mut().set_entry("entry.ink");
assert_eq!(
ids_via(edited.db()),
ids_via(compiled.db()),
"editor-order file registration must mint the SAME root-content \
DefinitionIds a real `discover` compile of the same tree mints"
);
}
}