use rnix::ast::{self, AstToken, HasEntry};
use rowan::ast::AstNode;
use sui_intern::Symbol;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Resolution {
Lexical {
sym: Symbol,
},
Dynamic,
}
#[derive(Clone, Debug, Default)]
pub struct ResolveTable {
by_offset: rustc_hash::FxHashMap<u32, Resolution>,
}
impl ResolveTable {
#[must_use]
pub fn new() -> Self {
Self {
by_offset: rustc_hash::FxHashMap::default(),
}
}
#[must_use]
pub fn get(&self, text_offset: u32) -> Resolution {
self.by_offset
.get(&text_offset)
.copied()
.unwrap_or(Resolution::Dynamic)
}
pub fn entries(&self) -> impl Iterator<Item = (u32, Resolution)> + '_ {
self.by_offset.iter().map(|(&off, &res)| (off, res))
}
#[must_use]
pub fn len(&self) -> usize {
self.by_offset.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_offset.is_empty()
}
fn record_lexical(&mut self, text_offset: u32, sym: Symbol) {
self.by_offset
.insert(text_offset, Resolution::Lexical { sym });
}
}
enum Frame {
Binder(Vec<Symbol>),
WithBarrier,
}
#[derive(Default)]
struct StaticEnv {
frames: Vec<Frame>,
}
impl StaticEnv {
fn push_binder(&mut self, names: Vec<Symbol>) {
self.frames.push(Frame::Binder(names));
}
fn push_with(&mut self) {
self.frames.push(Frame::WithBarrier);
}
fn pop(&mut self) {
self.frames.pop();
}
fn resolves_lexically(&self, sym: Symbol) -> bool {
for frame in self.frames.iter().rev() {
match frame {
Frame::Binder(names) => {
if names.contains(&sym) {
return true;
}
}
Frame::WithBarrier => return false,
}
}
false
}
}
#[must_use]
pub fn resolve(root: &ast::Root) -> ResolveTable {
let mut table = ResolveTable::new();
let mut env = StaticEnv::default();
if let Some(expr) = root.expr() {
walk_expr(&expr, &mut env, &mut table);
}
table
}
fn attr_static_sym(attr: &ast::Attr) -> Option<Symbol> {
match attr {
ast::Attr::Ident(ident) => Some(intern_ident(ident)),
ast::Attr::Str(s) => {
let mut text = String::new();
for part in s.parts() {
match part {
ast::InterpolPart::Literal(lit) => text.push_str(lit.syntax().text()),
ast::InterpolPart::Interpolation(_) => return None,
}
}
Some(sui_intern::intern(&text))
}
ast::Attr::Dynamic(_) => None,
}
}
fn intern_ident(ident: &ast::Ident) -> Symbol {
sui_intern::intern(&ident.syntax().text().to_string())
}
fn ident_offset(ident: &ast::Ident) -> u32 {
u32::from(ident.syntax().text_range().start())
}
fn entry_binder_syms<E: HasEntry>(entries: &E) -> Vec<Symbol> {
let mut syms = Vec::new();
for entry in entries.entries() {
match entry {
ast::Entry::AttrpathValue(apv) => {
if let Some(attrpath) = apv.attrpath() {
if let Some(head) = attrpath.attrs().next() {
if let Some(sym) = attr_static_sym(&head) {
syms.push(sym);
}
}
}
}
ast::Entry::Inherit(inherit) => {
for attr in inherit.attrs() {
if let Some(sym) = attr_static_sym(&attr) {
syms.push(sym);
}
}
}
}
}
syms
}
fn walk_entries<E: HasEntry>(entries: &E, env: &mut StaticEnv, table: &mut ResolveTable) {
for entry in entries.entries() {
match entry {
ast::Entry::AttrpathValue(apv) => {
if let Some(attrpath) = apv.attrpath() {
walk_attrpath(&attrpath, env, table);
}
if let Some(value) = apv.value() {
walk_expr(&value, env, table);
}
}
ast::Entry::Inherit(inherit) => {
if let Some(from) = inherit.from() {
if let Some(expr) = from.expr() {
walk_expr(&expr, env, table);
}
}
}
}
}
}
fn walk_attrpath(attrpath: &ast::Attrpath, env: &mut StaticEnv, table: &mut ResolveTable) {
for attr in attrpath.attrs() {
if let ast::Attr::Dynamic(dynamic) = attr {
if let Some(expr) = dynamic.expr() {
walk_expr(&expr, env, table);
}
}
}
}
fn walk_expr(expr: &ast::Expr, env: &mut StaticEnv, table: &mut ResolveTable) {
match expr {
ast::Expr::Ident(ident) => {
let name = ident.syntax().text().to_string();
if matches!(name.as_str(), "true" | "false" | "null") {
return;
}
let sym = sui_intern::intern(&name);
if env.resolves_lexically(sym) {
table.record_lexical(ident_offset(ident), sym);
}
}
ast::Expr::LetIn(letin) => {
let names = entry_binder_syms(letin);
env.push_binder(names);
walk_entries(letin, env, table);
if let Some(body) = letin.body() {
walk_expr(&body, env, table);
}
env.pop();
}
ast::Expr::LegacyLet(legacy) => {
let names = entry_binder_syms(legacy);
env.push_binder(names);
walk_entries(legacy, env, table);
env.pop();
}
ast::Expr::AttrSet(set) => {
if set.rec_token().is_some() {
let names = entry_binder_syms(set);
env.push_binder(names);
walk_entries(set, env, table);
env.pop();
} else {
for entry in set.entries() {
match entry {
ast::Entry::AttrpathValue(apv) => {
if let Some(attrpath) = apv.attrpath() {
walk_attrpath(&attrpath, env, table);
}
if let Some(value) = apv.value() {
walk_expr(&value, env, table);
}
}
ast::Entry::Inherit(inherit) => {
if let Some(from) = inherit.from() {
if let Some(inner) = from.expr() {
walk_expr(&inner, env, table);
}
}
}
}
}
}
}
ast::Expr::Lambda(lambda) => {
let names = param_binder_syms(lambda.param().as_ref(), env, table);
env.push_binder(names);
if let Some(body) = lambda.body() {
walk_expr(&body, env, table);
}
env.pop();
}
ast::Expr::With(with) => {
if let Some(ns) = with.namespace() {
walk_expr(&ns, env, table);
}
env.push_with();
if let Some(body) = with.body() {
walk_expr(&body, env, table);
}
env.pop();
}
ast::Expr::Apply(apply) => {
if let Some(f) = apply.lambda() {
walk_expr(&f, env, table);
}
if let Some(arg) = apply.argument() {
walk_expr(&arg, env, table);
}
}
ast::Expr::Assert(assert) => {
if let Some(c) = assert.condition() {
walk_expr(&c, env, table);
}
if let Some(b) = assert.body() {
walk_expr(&b, env, table);
}
}
ast::Expr::IfElse(ie) => {
if let Some(c) = ie.condition() {
walk_expr(&c, env, table);
}
if let Some(b) = ie.body() {
walk_expr(&b, env, table);
}
if let Some(e) = ie.else_body() {
walk_expr(&e, env, table);
}
}
ast::Expr::BinOp(binop) => {
if let Some(l) = binop.lhs() {
walk_expr(&l, env, table);
}
if let Some(r) = binop.rhs() {
walk_expr(&r, env, table);
}
}
ast::Expr::UnaryOp(unary) => {
if let Some(e) = unary.expr() {
walk_expr(&e, env, table);
}
}
ast::Expr::Paren(paren) => {
if let Some(e) = paren.expr() {
walk_expr(&e, env, table);
}
}
ast::Expr::Root(root) => {
if let Some(e) = root.expr() {
walk_expr(&e, env, table);
}
}
ast::Expr::List(list) => {
for item in list.items() {
walk_expr(&item, env, table);
}
}
ast::Expr::Select(select) => {
if let Some(base) = select.expr() {
walk_expr(&base, env, table);
}
if let Some(attrpath) = select.attrpath() {
walk_attrpath(&attrpath, env, table);
}
if let Some(default) = select.default_expr() {
walk_expr(&default, env, table);
}
}
ast::Expr::HasAttr(has) => {
if let Some(base) = has.expr() {
walk_expr(&base, env, table);
}
if let Some(attrpath) = has.attrpath() {
walk_attrpath(&attrpath, env, table);
}
}
ast::Expr::Str(s) => {
for part in s.parts() {
if let ast::InterpolPart::Interpolation(interp) = part {
if let Some(e) = interp.expr() {
walk_expr(&e, env, table);
}
}
}
}
ast::Expr::PathAbs(_)
| ast::Expr::PathRel(_)
| ast::Expr::PathHome(_)
| ast::Expr::PathSearch(_) => {
walk_path_interpolations(expr, env, table);
}
ast::Expr::Literal(_) | ast::Expr::Error(_) | ast::Expr::CurPos(_) => {}
#[allow(unreachable_patterns)]
_ => {
for child in expr.syntax().children() {
if let Some(child_expr) = ast::Expr::cast(child) {
walk_expr(&child_expr, env, table);
}
}
}
}
}
fn walk_path_interpolations(expr: &ast::Expr, env: &mut StaticEnv, table: &mut ResolveTable) {
for descendant in expr.syntax().descendants() {
if ast::Interpol::can_cast(descendant.kind()) {
if let Some(interp) = ast::Interpol::cast(descendant) {
if let Some(inner) = interp.expr() {
walk_expr(&inner, env, table);
}
}
}
}
}
fn param_binder_syms(
param: Option<&ast::Param>,
env: &mut StaticEnv,
table: &mut ResolveTable,
) -> Vec<Symbol> {
let mut syms = Vec::new();
let Some(param) = param else {
return syms;
};
match param {
ast::Param::IdentParam(ip) => {
if let Some(ident) = ip.ident() {
syms.push(intern_ident(&ident));
}
}
ast::Param::Pattern(pattern) => {
for pat_entry in pattern.pat_entries() {
if let Some(ident) = pat_entry.ident() {
syms.push(intern_ident(&ident));
}
}
if let Some(pat_bind) = pattern.pat_bind() {
if let Some(ident) = pat_bind.ident() {
syms.push(intern_ident(&ident));
}
}
env.push_binder(syms.clone());
for pat_entry in pattern.pat_entries() {
if let Some(default) = pat_entry.default() {
walk_expr(&default, env, table);
}
}
env.pop();
}
}
syms
}
#[cfg(test)]
mod tests {
use super::*;
fn resolve_str(src: &str) -> ResolveTable {
let parse = rnix::Root::parse(src);
assert!(parse.errors().is_empty(), "parse errors: {:?}", parse.errors());
resolve(&parse.tree())
}
fn ref_offset(src: &str, name: &str, nth: usize) -> u32 {
use rnix::SyntaxKind;
let parse = rnix::Root::parse(src);
let mut hits = Vec::new();
for node in parse.syntax().descendants() {
if let Some(ast::Expr::Ident(ident)) = ast::Expr::cast(node.clone()) {
if ident.syntax().text() != name {
continue;
}
let parent = ident.syntax().parent();
let is_binder = parent.as_ref().is_some_and(|p| match p.kind() {
SyntaxKind::NODE_ATTRPATH
| SyntaxKind::NODE_INHERIT
| SyntaxKind::NODE_IDENT_PARAM
| SyntaxKind::NODE_PAT_BIND => true,
SyntaxKind::NODE_PAT_ENTRY => {
p.children()
.find(|c| c.kind() == SyntaxKind::NODE_IDENT)
.is_some_and(|first| first == *ident.syntax())
}
_ => false,
});
if !is_binder {
hits.push(u32::from(ident.syntax().text_range().start()));
}
}
}
hits.sort_unstable();
hits[nth]
}
fn is_lexical(table: &ResolveTable, offset: u32) -> bool {
matches!(table.get(offset), Resolution::Lexical { .. })
}
#[test]
fn let_body_reference_is_lexical() {
let src = "let x = 1; in x";
let t = resolve_str(src);
let off = ref_offset(src, "x", 0); assert!(is_lexical(&t, off), "body `x` should resolve lexically");
}
#[test]
fn recursive_let_sibling_reference_is_lexical() {
let src = "let a = 1; b = a + 1; in b";
let t = resolve_str(src);
let off = ref_offset(src, "a", 0); assert!(is_lexical(&t, off), "sibling `a` should resolve lexically");
}
#[test]
fn free_variable_is_dynamic() {
let src = "let x = 1; in y";
let t = resolve_str(src);
let off = ref_offset(src, "y", 0);
assert_eq!(t.get(off), Resolution::Dynamic, "free `y` must be Dynamic");
}
#[test]
fn reference_under_with_is_dynamic() {
let src = "with pkgs; x";
let t = resolve_str(src);
let off = ref_offset(src, "x", 0);
assert_eq!(
t.get(off),
Resolution::Dynamic,
"a name only a `with` could provide must be Dynamic"
);
}
#[test]
fn lexical_binder_outside_with_is_still_conservatively_dynamic() {
let src = "let x = 1; in with pkgs; x";
let t = resolve_str(src);
let off = ref_offset(src, "x", 0);
assert_eq!(
t.get(off),
Resolution::Dynamic,
"a reference under a with-barrier must be Dynamic (fail-safe)"
);
}
#[test]
fn with_namespace_reference_is_resolved_in_outer_scope() {
let src = "let pkgs = {}; in with pkgs; 1";
let t = resolve_str(src);
let off = ref_offset(src, "pkgs", 0); assert!(
is_lexical(&t, off),
"the with-namespace resolves in the outer (barrier-free) scope"
);
}
#[test]
fn lambda_param_reference_is_lexical() {
let src = "x: x + 1";
let t = resolve_str(src);
let off = ref_offset(src, "x", 0); assert!(is_lexical(&t, off), "lambda param `x` should be lexical");
}
#[test]
fn pattern_formal_reference_is_lexical() {
let src = "{ a, b }: a + b";
let t = resolve_str(src);
assert!(is_lexical(&t, ref_offset(src, "a", 0)));
assert!(is_lexical(&t, ref_offset(src, "b", 0)));
}
#[test]
fn pattern_default_can_reference_sibling_formal() {
let src = "{ a, b ? a }: b";
let t = resolve_str(src);
let off = ref_offset(src, "a", 0); assert!(is_lexical(&t, off), "pattern default sibling ref is lexical");
}
#[test]
fn pattern_at_bind_reference_is_lexical() {
let src = "{ a } @ args: args";
let t = resolve_str(src);
let off = ref_offset(src, "args", 0); assert!(is_lexical(&t, off), "@-bound name is lexical");
}
#[test]
fn rec_attrset_sibling_reference_is_lexical() {
let src = "rec { a = 1; b = a; }";
let t = resolve_str(src);
let off = ref_offset(src, "a", 0); assert!(is_lexical(&t, off), "rec-attrset sibling ref is lexical");
}
#[test]
fn plain_attrset_value_reference_to_key_is_dynamic() {
let src = "{ a = 1; b = a; }";
let t = resolve_str(src);
let off = ref_offset(src, "a", 0);
assert_eq!(
t.get(off),
Resolution::Dynamic,
"non-rec attrset keys are NOT lexical for their own values"
);
}
#[test]
fn nested_let_inner_reference_is_lexical() {
let src = "let a = 1; in let b = a; in b";
let t = resolve_str(src);
let off = ref_offset(src, "a", 0);
assert!(is_lexical(&t, off));
}
#[test]
fn keyword_idents_are_never_recorded() {
let src = "let x = true; in if x then true else false";
let t = resolve_str(src);
for node in rnix::Root::parse(src).syntax().descendants() {
if let Some(ast::Expr::Ident(ident)) = ast::Expr::cast(node) {
let text = ident.syntax().text().to_string();
if matches!(text.as_str(), "true" | "false" | "null") {
let off = u32::from(ident.syntax().text_range().start());
assert_eq!(
t.get(off),
Resolution::Dynamic,
"keyword `{text}` must never be recorded Lexical"
);
}
}
}
}
#[test]
fn recorded_symbol_matches_interned_name() {
let src = "let foo = 1; in foo";
let t = resolve_str(src);
let off = ref_offset(src, "foo", 0);
match t.get(off) {
Resolution::Lexical { sym } => {
assert_eq!(sym, sui_intern::intern("foo"), "recorded sym is intern(name)");
}
Resolution::Dynamic => panic!("expected Lexical"),
}
}
#[test]
fn empty_table_lookup_is_dynamic() {
let t = ResolveTable::new();
assert_eq!(t.get(0), Resolution::Dynamic);
assert_eq!(t.get(9999), Resolution::Dynamic);
assert!(t.is_empty());
}
}