use crate::backend::EmitError;
use crate::config::Config;
use crate::markup::ElementName;
use crate::roblox;
use full_moon::ast;
use full_moon::visitors::Visitor;
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
Intrinsic(String),
Component,
Unresolved(String),
}
pub struct Resolver {
bound: HashSet<String>,
config: Config,
}
impl Resolver {
pub fn new(blanked_source: &str, config: Config) -> Self {
let mut collector = Bindings {
names: HashSet::new(),
};
let parsed = full_moon::parse_fallible(blanked_source, full_moon::LuaVersion::luau());
collector.visit_ast(parsed.ast());
Self {
bound: collector.names,
config,
}
}
pub fn resolve_attribute(
&self,
class: &str,
written: &str,
offset: usize,
) -> Result<String, EmitError> {
let canonical = self
.config
.resolve_property(class, written)
.map_err(|message| EmitError::new(message, offset, written.len()))?;
if roblox::has_property(class, &canonical) || roblox::is_event(class, &canonical) {
return Ok(canonical);
}
Err(EmitError::new(
format!("{class} has no property or event named {written}"),
offset,
written.len(),
)
.maybe_help(suggestion(&roblox::closest_members(class, &canonical))))
}
pub fn bound(&self) -> &HashSet<String> {
&self.bound
}
pub fn create(&self) -> &str {
&self.config.create
}
pub fn resolve(&self, name: &ElementName, offset: usize) -> Result<Resolution, EmitError> {
let simple = match name {
ElementName::Member(_) => return Ok(Resolution::Component),
ElementName::Simple(simple) => simple,
};
match self.config.resolve_element(simple) {
Ok(Some(class)) => return Ok(Resolution::Intrinsic(class.to_string())),
Ok(None) => {}
Err(message) => return Err(EmitError::new(message, offset, simple.len() + 1)),
}
if roblox::is_class(simple) {
return Ok(Resolution::Intrinsic(simple.clone()));
}
if self.bound.contains(simple) {
return Ok(Resolution::Component);
}
let help = match roblox::closest_class(simple) {
Some(class) => format!("did you mean <{class}>?"),
None => String::from("if it is a component, it has to be in scope"),
};
Err(EmitError::new(
format!("<{simple}> is not a Roblox class and is not defined"),
offset,
simple.len() + 1,
)
.with_help(help))
}
}
fn suggestion(candidates: &[&'static str]) -> Option<String> {
match candidates {
[] => None,
[one] => Some(format!("did you mean {one}?")),
[rest @ .., last] => Some(format!("did you mean {} or {last}?", rest.join(", "))),
}
}
pub fn blank_luaux_regions(source: &str, spans: &[(usize, usize)]) -> String {
let mut out = String::with_capacity(source.len());
let mut cursor = 0usize;
for (start, end) in spans.iter().copied() {
if start < cursor {
continue;
}
out.push_str(&source[cursor..start]);
let region = &source[start..end];
let mut filler = String::with_capacity(region.len());
for (index, character) in region.char_indices() {
if character == '\n' {
filler.push('\n');
} else if index < 3 {
filler.push(['n', 'i', 'l'][index]);
} else {
for _ in 0..character.len_utf8() {
filler.push(' ');
}
}
}
out.push_str(&filler);
cursor = end;
}
out.push_str(&source[cursor..]);
out
}
struct Bindings {
names: HashSet<String>,
}
impl Bindings {
fn insert(&mut self, token: &full_moon::tokenizer::TokenReference) {
self.names.insert(token.token().to_string());
}
}
impl Visitor for Bindings {
fn visit_local_assignment(&mut self, node: &ast::LocalAssignment) {
for name in node.names() {
self.insert(name);
}
}
fn visit_local_function(&mut self, node: &ast::LocalFunction) {
self.insert(node.name());
}
fn visit_const_assignment(&mut self, node: &ast::luau::ConstAssignment) {
for name in node.names() {
self.insert(name);
}
}
fn visit_const_function(&mut self, node: &ast::luau::ConstFunction) {
self.insert(node.name());
}
fn visit_function_declaration(&mut self, node: &ast::FunctionDeclaration) {
if let Some(first) = node.name().names().iter().next() {
self.insert(first);
}
}
fn visit_function_body(&mut self, node: &ast::FunctionBody) {
for parameter in node.parameters() {
if let ast::Parameter::Name(name) = parameter {
self.insert(name);
}
}
}
fn visit_numeric_for(&mut self, node: &ast::NumericFor) {
self.insert(node.index_variable());
}
fn visit_generic_for(&mut self, node: &ast::GenericFor) {
for name in node.names() {
self.insert(name);
}
}
fn visit_assignment(&mut self, node: &ast::Assignment) {
for variable in node.variables() {
if let ast::Var::Name(name) = variable {
self.insert(name);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn resolver(source: &str) -> Resolver {
Resolver::new(source, Config::default())
}
fn simple(name: &str) -> ElementName {
ElementName::Simple(name.to_string())
}
#[test]
fn classes_resolve_to_intrinsics() {
let resolver = resolver("");
assert_eq!(
resolver.resolve(&simple("Frame"), 0),
Ok(Resolution::Intrinsic("Frame".into()))
);
assert_eq!(
resolver.resolve(&simple("UICorner"), 0),
Ok(Resolution::Intrinsic("UICorner".into()))
);
}
#[test]
fn bound_names_resolve_to_components() {
for source in [
"local Receipt = require('./Receipt')",
"local function Receipt() end",
"function Receipt() end",
"Receipt = function() end",
"local Receipt",
"const Receipt = require('./Receipt')",
"const function Receipt() end",
] {
assert_eq!(
resolver(source).resolve(&simple("Receipt"), 0),
Ok(Resolution::Component),
"source: {source}"
);
}
}
#[test]
fn parameters_and_loop_variables_count_as_bindings() {
assert_eq!(
resolver("local function f(Row) end").resolve(&simple("Row"), 0),
Ok(Resolution::Component)
);
assert_eq!(
resolver("for _, Row in items do end").resolve(&simple("Row"), 0),
Ok(Resolution::Component)
);
}
#[test]
fn member_names_are_always_components() {
assert_eq!(
resolver("").resolve(&ElementName::Member(vec!["Foo".into(), "Bar".into()]), 0),
Ok(Resolution::Component)
);
}
#[test]
fn unknown_names_are_rejected_with_a_suggestion() {
let error = resolver("")
.resolve(&simple("TextLabl"), 7)
.expect_err("should fail");
assert_eq!(error.help.as_deref(), Some("did you mean <TextLabel>?"));
assert_eq!(error.offset, 7);
assert_eq!(error.length, "TextLabl".len() + 1);
}
#[test]
fn unknown_names_with_no_near_miss_say_so() {
let error = resolver("")
.resolve(&simple("Receipt"), 0)
.expect_err("should fail");
assert!(
error
.help
.as_deref()
.is_some_and(|help| help.contains("has to be in scope")),
"{:?}",
error.help
);
}
#[test]
fn blanking_preserves_offsets_and_lines() {
let source = "local a = <Frame>\n <TextLabel/>\n</Frame>\nlocal b = 2";
let start = source.find('<').expect("markup");
let end = source.find("\nlocal b").expect("end");
let blanked = blank_luaux_regions(source, &[(start, end)]);
assert_eq!(blanked.len(), source.len());
assert_eq!(blanked.lines().count(), source.lines().count());
assert!(blanked.starts_with("local a = nil"));
assert!(blanked.ends_with("local b = 2"));
assert!(full_moon::parse(&blanked).is_ok(), "{blanked}");
}
}