use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Layer {
Model = 0,
Cst = 1,
Build = 2,
Index = 3,
Lsp = 4,
}
fn layer_map() -> HashMap<&'static str, Layer> {
use Layer::*;
HashMap::from([
("file_analysis", Model),
("witnesses", Model),
("conventions", Model),
("graph", Model),
("cst", Cst),
("builder", Build),
("plugin", Build),
("pod", Build),
("cpanfile", Build),
("query_cache", Build),
("module_index", Index),
("module_resolver", Index),
("module_cache", Index),
("file_store", Index),
("resolve", Index),
("document", Index),
("timings", Index),
("builtins_pod", Index),
("backend", Lsp),
("symbols", Lsp),
("cursor_context", Lsp),
("plugin_cli", Lsp),
("main", Lsp),
("layering_tests", Lsp),
])
}
fn module_sources() -> Vec<(&'static str, Vec<PathBuf>)> {
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
let mut out: Vec<(&'static str, Vec<PathBuf>)> = Vec::new();
let map = layer_map();
for entry in fs::read_dir(&src).expect("read src/") {
let path = entry.expect("dir entry").path();
let Some(stem) = path.file_stem().and_then(|s| s.to_str()).map(str::to_string)
else {
continue;
};
if path.is_dir() {
if stem == "plugin" {
let files = fs::read_dir(&path)
.expect("read plugin/")
.filter_map(|e| {
let p = e.ok()?.path();
(p.extension()? == "rs").then_some(p)
})
.collect();
out.push(("plugin", files));
}
continue;
}
if path.extension().is_none_or(|e| e != "rs") || stem.ends_with("_tests")
|| stem.ends_with("_test")
{
continue;
}
let name: &'static str = map
.keys()
.copied()
.find(|k| *k == stem)
.unwrap_or_else(|| panic!("unassigned module src/{stem}.rs — add it to layer_map()"));
out.push((name, vec![path]));
}
out
}
fn crate_refs(text: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = text.as_bytes();
let needle = b"crate::";
let mut i = 0;
while let Some(j) = text[i..].find("crate::").map(|j| i + j) {
i = j + needle.len();
let rest = &bytes[i..];
let end = rest
.iter()
.position(|c| !(c.is_ascii_alphanumeric() || *c == b'_'))
.unwrap_or(rest.len());
if end > 0 {
out.push(text[i..i + end].to_string());
}
}
out
}
#[test]
fn imports_flow_down_only() {
let map = layer_map();
let mut violations = Vec::new();
for (module, files) in module_sources() {
let my_layer = map[module];
for f in &files {
let text = fs::read_to_string(f).expect("read source");
for target in crate_refs(&text) {
let Some(&target_layer) = map.get(target.as_str()) else {
continue; };
if target_layer > my_layer {
violations.push(format!(
"{} ({:?}) imports crate::{} ({:?}) — data flows down only",
f.display(),
my_layer,
target,
target_layer,
));
}
}
}
}
assert!(violations.is_empty(), "layer violations:\n{}", violations.join("\n"));
}
#[test]
fn model_layer_cannot_walk_trees() {
let map = layer_map();
let mut violations = Vec::new();
for (module, files) in module_sources() {
if map[module] != Layer::Model {
continue;
}
for f in &files {
let text = fs::read_to_string(f).expect("read source");
for (ln, line) in text.lines().enumerate() {
let mut i = 0;
while let Some(j) = line[i..].find("tree_sitter::").map(|j| i + j) {
i = j + "tree_sitter::".len();
let rest = &line[i..];
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
let name = &rest[..end];
if name != "Point" {
violations.push(format!(
"{}:{}: tree_sitter::{} — the model is Point-only",
f.display(),
ln + 1,
name,
));
}
}
for forbidden in ["TreeCursor", "child_by_field_name", "named_child("] {
if line.contains(forbidden) {
violations.push(format!(
"{}:{}: `{}` — tree walking belongs in the builder",
f.display(),
ln + 1,
forbidden,
));
}
}
}
if text.contains("crate::cst") {
violations.push(format!(
"{}: imports crate::cst — the typed view is for tree consumers",
f.display(),
));
}
}
}
assert!(violations.is_empty(), "rule #2 violations:\n{}", violations.join("\n"));
}
#[test]
fn grammar_stays_in_the_builder_layer() {
let map = layer_map();
let mut violations = Vec::new();
for (module, files) in module_sources() {
let layer = map[module];
if layer == Layer::Build || layer == Layer::Cst {
continue;
}
for f in &files {
let text = fs::read_to_string(f).expect("read source");
for (ln, line) in text.lines().enumerate() {
if line.contains("ts_parser_perl::") {
violations.push(format!(
"{}:{}: names the grammar directly — route through builder::create_parser",
f.display(),
ln + 1,
));
}
}
}
}
assert!(violations.is_empty(), "grammar violations:\n{}", violations.join("\n"));
}