use brink_format::{DefinitionId, NameId};
use crate::FileId;
use crate::determinism::{LookupMap, LookupSet};
use crate::hir;
use crate::symbols::{ResolutionMap, SymbolIndex, SymbolKind};
use crate::{Diagnostic, DiagnosticCode};
use super::context::{NameTable, ResolutionLookup};
use super::decls::lookup_global;
use super::lir;
#[derive(Clone)]
pub struct ShapeInfo {
pub id: u32,
pub definition_id: DefinitionId,
pub name: NameId,
pub fields: Vec<NameId>,
field_index: LookupMap<String, (u16, Option<DefinitionId>)>,
}
impl ShapeInfo {
#[must_use]
pub fn field(&self, name: &str) -> Option<(u16, Option<DefinitionId>)> {
self.field_index.get(name).copied()
}
}
#[derive(Default, Clone)]
pub struct ShapeTable {
by_def: LookupMap<DefinitionId, ShapeInfo>,
}
impl ShapeTable {
#[must_use]
pub fn get_by_def(&self, id: DefinitionId) -> Option<&ShapeInfo> {
self.by_def.get(&id)
}
#[must_use]
pub fn len(&self) -> usize {
self.by_def.len()
}
}
pub fn build_shape_table(
files: &[(FileId, &hir::HirFile)],
names: &mut NameTable,
index: &SymbolIndex,
resolutions: &ResolutionLookup,
diagnostics: &mut Vec<Diagnostic>,
) -> ShapeTable {
let mut by_def: LookupMap<DefinitionId, ShapeInfo> = LookupMap::new();
let mut next_id: u32 = 0;
for &(file_id, hir_file) in files {
for s in &hir_file.structs {
let Some(definition_id) =
lookup_global(index, file_id, &s.name.text, SymbolKind::Struct)
else {
diagnostics.push(Diagnostic {
file: file_id,
range: s.name.range,
message: DiagnosticCode::E181.title().to_string(),
code: DiagnosticCode::E181,
});
continue;
};
if by_def.contains_key(&definition_id) {
continue;
}
let shape_name = names.intern(&s.name.text);
let mut fields = Vec::with_capacity(s.fields.len());
let mut field_index = LookupMap::with_capacity(s.fields.len());
for (i, f) in s.fields.iter().enumerate() {
let field_name = names.intern(&f.name.text);
fields.push(field_name);
#[expect(
clippy::cast_possible_truncation,
reason = "a struct won't declare anywhere near u16::MAX fields"
)]
let offset = i as u16;
let nested = resolutions.resolve(file_id, f.ty.range());
field_index.insert(f.name.text.clone(), (offset, nested));
}
let id = next_id;
next_id += 1;
by_def.insert(
definition_id,
ShapeInfo {
id,
definition_id,
name: shape_name,
fields,
field_index,
},
);
}
}
ShapeTable { by_def }
}
#[must_use]
pub fn struct_shape_defs(shapes: &ShapeTable) -> Vec<lir::StructShapeDef> {
let mut defs: Vec<Option<lir::StructShapeDef>> = vec![None; shapes.len()];
for info in shapes.by_def.values() {
if let Some(slot) = defs.get_mut(info.id as usize) {
*slot = Some(lir::StructShapeDef {
id: info.id,
name: info.name,
fields: info.fields.clone(),
});
}
}
defs.into_iter().flatten().collect()
}
pub type GlobalShapeMap = LookupMap<DefinitionId, DefinitionId>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StructShapeEntry {
pub name: String,
pub definition_id: DefinitionId,
pub fields: Vec<StructFieldEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StructFieldEntry {
pub name: String,
pub offset: u16,
pub nested: Option<DefinitionId>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StructShapeData {
pub shapes: Vec<StructShapeEntry>,
pub global_shapes: Vec<(DefinitionId, DefinitionId)>,
}
#[must_use]
pub fn build_struct_shape_data(
files: &[(FileId, &hir::HirFile)],
index: &SymbolIndex,
resolutions: &ResolutionMap,
) -> StructShapeData {
let resolutions = ResolutionLookup::build(resolutions);
let mut seen: LookupSet<DefinitionId> = LookupSet::new();
let mut shapes = Vec::new();
for &(file_id, hir_file) in files {
for s in &hir_file.structs {
let Some(definition_id) =
lookup_global(index, file_id, &s.name.text, SymbolKind::Struct)
else {
continue;
};
if seen.contains(&definition_id) {
continue;
}
seen.insert(definition_id);
let mut fields = Vec::with_capacity(s.fields.len());
for (i, f) in s.fields.iter().enumerate() {
#[expect(
clippy::cast_possible_truncation,
reason = "a struct won't declare anywhere near u16::MAX fields"
)]
let offset = i as u16;
let nested = resolutions.resolve(file_id, f.ty.range());
fields.push(StructFieldEntry {
name: f.name.text.clone(),
offset,
nested,
});
}
shapes.push(StructShapeEntry {
name: s.name.text.clone(),
definition_id,
fields,
});
}
}
let mut throwaway = NameTable::new();
let shape_table = rebuild_shape_table(
&StructShapeData {
shapes: shapes.clone(),
global_shapes: Vec::new(),
},
&mut throwaway,
);
let global_map = build_global_shape_map(files, index, &resolutions, &shape_table);
let mut global_shapes: Vec<(DefinitionId, DefinitionId)> = global_map.into_iter().collect();
global_shapes.sort_by_key(|a| a.0.to_raw());
StructShapeData {
shapes,
global_shapes,
}
}
#[must_use]
pub fn rebuild_shape_table(data: &StructShapeData, names: &mut NameTable) -> ShapeTable {
let mut by_def: LookupMap<DefinitionId, ShapeInfo> = LookupMap::new();
for (id, entry) in data.shapes.iter().enumerate() {
let shape_name = names.intern(&entry.name);
let mut fields = Vec::with_capacity(entry.fields.len());
let mut field_index = LookupMap::with_capacity(entry.fields.len());
for f in &entry.fields {
fields.push(names.intern(&f.name));
field_index.insert(f.name.clone(), (f.offset, f.nested));
}
#[expect(
clippy::cast_possible_truncation,
reason = "shape count won't exceed u32::MAX"
)]
let shape_id = id as u32;
by_def.insert(
entry.definition_id,
ShapeInfo {
id: shape_id,
definition_id: entry.definition_id,
name: shape_name,
fields,
field_index,
},
);
}
ShapeTable { by_def }
}
#[must_use]
pub fn rebuild_global_shape_map(data: &StructShapeData) -> GlobalShapeMap {
data.global_shapes.iter().copied().collect()
}
#[must_use]
pub fn build_global_shape_map(
files: &[(FileId, &hir::HirFile)],
index: &SymbolIndex,
resolutions: &ResolutionLookup,
shapes: &ShapeTable,
) -> GlobalShapeMap {
let mut out = LookupMap::new();
for &(file_id, hir_file) in files {
for var in &hir_file.variables {
record_global_annotation(
&var.name.text,
file_id,
var.annotation.as_ref(),
SymbolKind::Variable,
index,
resolutions,
shapes,
&mut out,
);
}
for cst in &hir_file.constants {
record_global_annotation(
&cst.name.text,
file_id,
cst.annotation.as_ref(),
SymbolKind::Constant,
index,
resolutions,
shapes,
&mut out,
);
}
}
out
}
#[expect(
clippy::too_many_arguments,
reason = "mirrors decls::lookup_global's own doc precedent for this shape; adding \
`resolutions` (issue #2249) pushed this one over the 7-arg default"
)]
fn record_global_annotation(
name: &str,
file: FileId,
annotation: Option<&hir::TypeExpr>,
kind: SymbolKind,
index: &SymbolIndex,
resolutions: &ResolutionLookup,
shapes: &ShapeTable,
out: &mut GlobalShapeMap,
) {
let Some(ann) = annotation else {
return;
};
let Some(shape) = resolutions
.resolve(file, ann.range())
.and_then(|id| shapes.get_by_def(id))
else {
return;
};
if let Some(id) = lookup_global(index, file, name, kind) {
out.insert(id, shape.definition_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lir::lower::context::NameTable;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use brink_format::DefinitionTag;
use crate::symbols::{ResolvedRef, SymbolInfo, Visibility};
fn hir_for(src: &str) -> hir::HirFile {
let parsed = brink_syntax::parse(src);
let (hir, _manifest, _diag) = hir::lower(FileId(0), &parsed.tree());
hir
}
fn struct_def_id(file: FileId, name: &str) -> DefinitionId {
let mut hasher = DefaultHasher::new();
file.0.hash(&mut hasher);
name.hash(&mut hasher);
DefinitionId::new(DefinitionTag::StructDef, hasher.finish())
}
fn index_for_structs(hir: &hir::HirFile, file: FileId) -> SymbolIndex {
let mut index = SymbolIndex::default();
for s in &hir.structs {
let already_indexed = index.by_name.get(&s.name.text).is_some_and(|ids| {
ids.iter().any(|id| {
index
.symbols
.get(id)
.is_some_and(|info| info.kind == SymbolKind::Struct && info.file == file)
})
});
if already_indexed {
continue;
}
let def_id = struct_def_id(file, &s.name.text);
index.symbols.insert(
def_id,
SymbolInfo {
kind: SymbolKind::Struct,
file,
range: s.name.range,
id: def_id,
name: s.name.text.clone(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: None,
visibility: Visibility::Public,
},
);
index
.by_name
.entry(s.name.text.clone())
.or_default()
.push(def_id);
}
index
}
fn resolutions_for(files: &[(FileId, &hir::HirFile)], index: &SymbolIndex) -> ResolutionMap {
fn record(
file: FileId,
ty: &hir::TypeExpr,
index: &SymbolIndex,
refs: &mut Vec<ResolvedRef>,
) {
let hir::TypeExpr::Named { name, range } = ty else {
return;
};
let Some(id) = index.by_name.get(name).and_then(|ids| {
ids.iter()
.find(|id| {
index
.symbols
.get(id)
.is_some_and(|info| info.kind == SymbolKind::Struct)
})
.copied()
}) else {
return;
};
refs.push(ResolvedRef {
file,
range: *range,
target: id,
});
}
let mut refs = Vec::new();
for &(file_id, hir_file) in files {
for s in &hir_file.structs {
for f in &s.fields {
record(file_id, &f.ty, index, &mut refs);
}
}
for v in &hir_file.variables {
if let Some(ann) = &v.annotation {
record(file_id, ann, index, &mut refs);
}
}
for c in &hir_file.constants {
if let Some(ann) = &c.annotation {
record(file_id, ann, index, &mut refs);
}
}
}
refs
}
#[test]
fn shape_table_assigns_dense_ids_in_declaration_order() {
let hir = hir_for("STRUCT Alpha = #{v: int}\nSTRUCT Beta = #{v: int, w: int}\nHello.\n");
let index = index_for_structs(&hir, FileId(0));
let files = [(FileId(0), &hir)];
let resolutions = ResolutionLookup::build(&resolutions_for(&files, &index));
let mut names = NameTable::new();
let shapes = build_shape_table(&files, &mut names, &index, &resolutions, &mut Vec::new());
assert_eq!(shapes.len(), 2);
let alpha = shapes
.get_by_def(struct_def_id(FileId(0), "Alpha"))
.expect("Alpha should be in the table");
let beta = shapes
.get_by_def(struct_def_id(FileId(0), "Beta"))
.expect("Beta should be in the table");
assert_eq!(alpha.id, 0, "first declared struct gets shape id 0");
assert_eq!(beta.id, 1, "second declared struct gets shape id 1");
assert_eq!(beta.fields.len(), 2);
assert_eq!(beta.field("v").map(|(offset, _)| offset), Some(0));
assert_eq!(beta.field("w").map(|(offset, _)| offset), Some(1));
assert!(
shapes
.get_by_def(struct_def_id(FileId(0), "Bogus"))
.is_none()
);
}
#[test]
fn shape_table_tracks_nested_struct_typed_fields() {
let hir =
hir_for("STRUCT Inner = #{v: int}\nSTRUCT Outer = #{inner: Inner, n: int}\nHello.\n");
let index = index_for_structs(&hir, FileId(0));
let files = [(FileId(0), &hir)];
let resolutions = ResolutionLookup::build(&resolutions_for(&files, &index));
let mut names = NameTable::new();
let shapes = build_shape_table(&files, &mut names, &index, &resolutions, &mut Vec::new());
let inner = shapes
.get_by_def(struct_def_id(FileId(0), "Inner"))
.expect("Inner should be in the table");
let outer = shapes
.get_by_def(struct_def_id(FileId(0), "Outer"))
.expect("Outer should be in the table");
let (_, nested) = outer.field("inner").expect("Outer declares `inner`");
assert_eq!(
nested,
Some(inner.definition_id),
"a struct-typed field records its nested shape's own identity"
);
let (_, plain_nested) = outer.field("n").expect("Outer declares `n`");
assert_eq!(
plain_nested, None,
"a non-struct-typed field has no nested shape"
);
}
#[test]
fn lookup_global_excludes_a_sole_std_declared_struct_with_no_project_homonym() {
let mut index = SymbolIndex::default();
let std_file = FileId(1);
let referrer_file = FileId(0);
let def_id = DefinitionId::new(DefinitionTag::StructDef, 1);
index.symbols.insert(
def_id,
SymbolInfo {
kind: SymbolKind::Struct,
file: std_file,
range: rowan::TextRange::default(),
id: def_id,
name: "Cue".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some("std::conventions::screenplay".to_string()),
visibility: Visibility::Public,
},
);
index
.by_name
.entry("Cue".to_string())
.or_default()
.push(def_id);
assert!(
lookup_global(&index, referrer_file, "Cue", SymbolKind::Struct).is_none(),
"a struct name only a mounted std module declares must not resolve for a \
referrer that never declares (or imports) it itself — even when it is the \
sole candidate in the bucket"
);
}
#[test]
fn struct_shape_data_roundtrips_to_shape_table() {
let hir = hir_for(
"STRUCT Inner = #{v: int}\n\
STRUCT Outer = #{inner: Inner, n: int, tail: Inner}\n\
STRUCT Alpha = #{a: int, b: int}\nHello.\n",
);
let files = [(FileId(0), &hir)];
let index = index_for_structs(&hir, FileId(0));
let resolutions_map = resolutions_for(&files, &index);
let resolutions = ResolutionLookup::build(&resolutions_map);
let mut direct_names = NameTable::new();
let direct = build_shape_table(
&files,
&mut direct_names,
&index,
&resolutions,
&mut Vec::new(),
);
let data = build_struct_shape_data(&files, &index, &resolutions_map);
let mut throwaway = NameTable::new();
let rebuilt = rebuild_shape_table(&data, &mut throwaway);
assert_eq!(direct.len(), rebuilt.len());
for name in ["Inner", "Outer", "Alpha"] {
let def_id = struct_def_id(FileId(0), name);
let d = direct.get_by_def(def_id).expect("shape in direct table");
let r = rebuilt.get_by_def(def_id).expect("shape in rebuilt table");
assert_eq!(d.id, r.id, "{name} shape id");
assert_eq!(d.fields.len(), r.fields.len(), "{name} field count");
for field in ["inner", "n", "tail", "a", "b", "v"] {
let d_field = d.field(field);
let r_field = r.field(field);
assert_eq!(d_field, r_field, "{name}.{field} offset/nested");
}
}
}
#[test]
fn duplicate_struct_names_keep_the_first_declaration() {
let hir = hir_for("STRUCT Dup = #{a: int}\nSTRUCT Dup = #{b: int, c: int}\nHello.\n");
let index = index_for_structs(&hir, FileId(0));
let files = [(FileId(0), &hir)];
let resolutions = ResolutionLookup::build(&resolutions_for(&files, &index));
let mut names = NameTable::new();
let shapes = build_shape_table(&files, &mut names, &index, &resolutions, &mut Vec::new());
assert_eq!(
shapes.len(),
1,
"the duplicate name occupies one table slot"
);
let dup = shapes
.get_by_def(struct_def_id(FileId(0), "Dup"))
.expect("Dup should be in the table");
assert_eq!(
dup.fields.len(),
1,
"the first declaration's single field `a` wins"
);
assert!(dup.field("a").is_some());
assert!(dup.field("b").is_none());
}
#[test]
fn lookup_global_picks_the_referrers_own_shape_when_names_collide() {
let std_file = FileId(0);
let std_hir = hir_for("STRUCT Cue = #{speaker: string}\nHello.\n");
let project_file = FileId(1);
let project_hir = hir_for("STRUCT Cue = #{speaker: string, voiceover: bool}\nHello.\n");
let mut index = index_for_structs(&std_hir, std_file);
for (id, info) in index_for_structs(&project_hir, project_file).symbols {
index.by_name.entry(info.name.clone()).or_default().push(id);
index.symbols.insert(id, info);
}
let files = [(std_file, &std_hir), (project_file, &project_hir)];
let resolutions = ResolutionLookup::build(&resolutions_for(&files, &index));
let mut names = NameTable::new();
let shapes = build_shape_table(&files, &mut names, &index, &resolutions, &mut Vec::new());
assert_eq!(
shapes.len(),
2,
"std's Cue and the project's Cue both keep a shape id"
);
let from_std = lookup_global(&index, std_file, "Cue", SymbolKind::Struct)
.and_then(|id| shapes.get_by_def(id))
.expect("std's own file resolves its own Cue");
assert_eq!(
from_std.fields.len(),
1,
"std's Cue keeps its own 1-field shape"
);
let from_project = lookup_global(&index, project_file, "Cue", SymbolKind::Struct)
.and_then(|id| shapes.get_by_def(id))
.expect("the project's own file resolves its own Cue");
assert_eq!(
from_project.fields.len(),
2,
"the project's Cue keeps its own 2-field shape, not std's"
);
assert_ne!(
from_std.id, from_project.id,
"the two coexisting shapes have distinct shape ids"
);
}
#[test]
fn build_shape_table_reports_e181_when_every_surviving_candidate_is_std_declared() {
let ghost_file = FileId(7);
let ghost_hir = hir_for("STRUCT Ghost = #{v: int}\nHello.\n");
let mut index = SymbolIndex::default();
let std_file = FileId(9);
let std_def_id = DefinitionId::new(DefinitionTag::StructDef, 1);
index.symbols.insert(
std_def_id,
SymbolInfo {
kind: SymbolKind::Struct,
file: std_file,
range: rowan::TextRange::default(),
id: std_def_id,
name: "Ghost".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some("std::x".to_string()),
visibility: Visibility::Public,
},
);
index
.by_name
.entry("Ghost".to_string())
.or_default()
.push(std_def_id);
let files = [(ghost_file, &ghost_hir)];
let resolutions = ResolutionLookup::build(&resolutions_for(&files, &index));
let mut names = NameTable::new();
let mut diagnostics = Vec::new();
let shapes = build_shape_table(&files, &mut names, &index, &resolutions, &mut diagnostics);
assert_eq!(
shapes.len(),
0,
"the struct still can't resolve its own identity, so it still \
occupies no table slot — E181 makes the drop loud, not stops \
it from happening"
);
assert_eq!(
diagnostics.len(),
1,
"the unresolvable lookup must raise exactly one diagnostic"
);
assert_eq!(diagnostics[0].code, DiagnosticCode::E181);
assert_eq!(diagnostics[0].file, ghost_file);
assert_eq!(
diagnostics[0].range, ghost_hir.structs[0].name.range,
"reported at the struct's own name span"
);
}
#[test]
fn build_struct_shape_data_silently_mirrors_the_same_unresolvable_drop() {
let ghost_file = FileId(7);
let ghost_hir = hir_for("STRUCT Ghost = #{v: int}\nHello.\n");
let mut index = SymbolIndex::default();
let std_file = FileId(9);
let std_def_id = DefinitionId::new(DefinitionTag::StructDef, 1);
index.symbols.insert(
std_def_id,
SymbolInfo {
kind: SymbolKind::Struct,
file: std_file,
range: rowan::TextRange::default(),
id: std_def_id,
name: "Ghost".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some("std::x".to_string()),
visibility: Visibility::Public,
},
);
index
.by_name
.entry("Ghost".to_string())
.or_default()
.push(std_def_id);
let files = [(ghost_file, &ghost_hir)];
let resolutions = resolutions_for(&files, &index);
let data = build_struct_shape_data(&files, &index, &resolutions);
assert!(
data.shapes.is_empty(),
"the mirrored, diagnostic-sink-free path drops the same struct \
— see E181's doc for why that's a documented ruling and not a \
second unremarked silent drop"
);
}
}