use crate::lexer::{Lexer, Token, TokenKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LuauxSite {
pub token_index: usize,
pub offset: usize,
}
const EXPRESSION_ENDING_KEYWORDS: &[&str] = &["end", "true", "false", "nil"];
const STATEMENT_KEYWORDS: &[&str] = &[
"local", "function", "return", "if", "while", "for", "do", "end", "else", "elseif", "repeat",
"until", "then", "in", "break", "continue",
];
const TYPE_INTRODUCING_SYMBOLS: &[&str] = &[":", "::", "->", "|", "&"];
const TYPE_PARAMETER_KEYWORDS: &[&str] = &["function"];
pub struct Scanner<'a> {
src: &'a str,
depth: i32,
type_context: Option<i32>,
in_type_declaration: bool,
previous: Option<Token>,
previous_was_luaux: bool,
}
impl<'a> Scanner<'a> {
pub fn new(src: &'a str) -> Self {
Self {
src,
depth: 0,
type_context: None,
in_type_declaration: false,
previous: None,
previous_was_luaux: false,
}
}
pub fn feed(&mut self, token: Token, lookahead: &Lexer<'a>) -> bool {
if token.is_trivia() {
return false;
}
let text = token.text(self.src);
if matches!(text, ")" | "]" | "}") {
self.depth -= 1;
}
if let Some(entry) = self.type_context {
if self.depth < entry || (self.depth == entry && ends_type_expression(&token, text)) {
self.type_context = None;
}
}
if token.kind == TokenKind::Name && STATEMENT_KEYWORDS.contains(&text) {
self.in_type_declaration = false;
self.type_context = None;
}
if token.kind == TokenKind::Name
&& text == "type"
&& self.starts_type_declaration(&token, lookahead)
{
self.in_type_declaration = true;
}
if token.kind == TokenKind::Symbol && text == "<" && self.opens_luaux() {
return true;
}
if self.enters_type_context(&token, text, lookahead) {
self.type_context = Some(self.depth);
if text == "=" {
self.in_type_declaration = false;
}
}
if matches!(text, "(" | "[" | "{") {
self.depth += 1;
}
self.previous = Some(token);
self.previous_was_luaux = false;
false
}
pub fn note_luaux_region(&mut self) {
self.previous = None;
self.previous_was_luaux = true;
}
fn opens_luaux(&self) -> bool {
if self.type_context.is_some() {
return false;
}
if self.previous_was_luaux {
return false;
}
let Some(previous) = &self.previous else {
return true;
};
let text = previous.text(self.src);
if previous.kind == TokenKind::Symbol && TYPE_INTRODUCING_SYMBOLS.contains(&text) {
return false;
}
if previous.kind == TokenKind::Name && TYPE_PARAMETER_KEYWORDS.contains(&text) {
return false;
}
!can_end_expression(previous, text)
}
fn starts_type_declaration(&self, token: &Token, lookahead: &Lexer<'a>) -> bool {
let at_statement_start = match &self.previous {
None => true,
Some(previous) => {
let text = previous.text(self.src);
(previous.kind == TokenKind::Name && text == "export")
|| text == ";"
|| self.src[previous.end..token.start].contains('\n')
}
};
if !at_statement_start {
return false;
}
let mut after = lookahead.clone();
let named = after
.peek_significant()
.is_some_and(|next| next.kind == TokenKind::Name && !is_keyword(next.text(self.src)));
if !named {
return false;
}
let Some(name) = after.peek_significant() else {
return false;
};
after.seek(name.end);
after
.peek_significant()
.is_some_and(|next| matches!(next.text(self.src), "=" | "<"))
}
fn enters_type_context(&self, token: &Token, text: &str, lookahead: &Lexer<'a>) -> bool {
if token.kind != TokenKind::Symbol {
return false;
}
if matches!(text, "::" | "->" | "|" | "&") {
return true;
}
if text == "=" && self.in_type_declaration {
return true;
}
text == ":"
&& lookahead
.peek_significant()
.is_some_and(|next| next.kind == TokenKind::Symbol && next.text(self.src) == "(")
}
}
pub fn find_luaux_sites(src: &str, tokens: &[Token]) -> Vec<LuauxSite> {
let mut scanner = Scanner::new(src);
let mut sites = Vec::new();
for (index, token) in tokens.iter().enumerate() {
let lookahead = Lexer::at(src, token.end);
if scanner.feed(*token, &lookahead) {
sites.push(LuauxSite {
token_index: index,
offset: token.start,
});
scanner.note_luaux_region();
}
}
sites
}
fn can_end_expression(token: &Token, text: &str) -> bool {
match token.kind {
TokenKind::Number | TokenKind::Str | TokenKind::InterpStr => true,
TokenKind::Name => !is_keyword(text) || EXPRESSION_ENDING_KEYWORDS.contains(&text),
TokenKind::Symbol => matches!(text, ")" | "]" | "}" | "..."),
TokenKind::Comment | TokenKind::Whitespace => {
debug_assert!(false, "trivia should be filtered before classification");
false
}
}
}
fn ends_type_expression(token: &Token, text: &str) -> bool {
if token.kind == TokenKind::Symbol {
return matches!(text, "=" | "," | ";");
}
token.kind == TokenKind::Name && STATEMENT_KEYWORDS.contains(&text)
}
fn is_keyword(text: &str) -> bool {
matches!(
text,
"and"
| "break"
| "do"
| "else"
| "elseif"
| "end"
| "false"
| "for"
| "function"
| "if"
| "in"
| "local"
| "nil"
| "not"
| "or"
| "repeat"
| "return"
| "then"
| "true"
| "until"
| "while"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lexer::tokenize;
fn count(src: &str) -> usize {
let tokens = tokenize(src).expect("lex");
find_luaux_sites(src, &tokens).len()
}
#[test]
fn detects_after_assignment() {
assert_eq!(count("local x = <Frame/>"), 1);
}
#[test]
fn detects_after_return_and_paren() {
assert_eq!(count("return (<Frame/>)"), 1);
assert_eq!(count("f(<Frame/>)"), 1);
}
#[test]
fn detects_in_call_arguments_and_tables() {
assert_eq!(count("table.insert(t, (<Frame/>))"), 1);
assert_eq!(count("local t = { <Frame/>, <Frame/> }"), 2);
}
#[test]
fn detects_after_logical_operators() {
assert_eq!(count("local x = cond and <Frame/> or nil"), 1);
}
#[test]
fn detects_fragments() {
assert_eq!(count("local x = (<></>)"), 2);
}
#[test]
fn reports_the_opening_tag_first() {
let src = "local x = (<></>)";
let tokens = tokenize(src).expect("lex");
let sites = find_luaux_sites(src, &tokens);
assert_eq!(sites[0].offset, src.find('<').unwrap());
}
#[test]
fn ignores_comparison_after_identifier() {
assert_eq!(count("if a < b then end"), 0);
assert_eq!(count("while i < #list do end"), 0);
}
#[test]
fn ignores_comparison_after_literals_and_closers() {
assert_eq!(count("if 1 < 2 then end"), 0);
assert_eq!(count("if f() < 2 then end"), 0);
assert_eq!(count("if t[1] < 2 then end"), 0);
assert_eq!(count("if {} < 2 then end"), 0);
assert_eq!(count("if 'a' < 'b' then end"), 0);
assert_eq!(count("if `a` < `b` then end"), 0);
}
#[test]
fn ignores_chained_comparison() {
assert_eq!(count("local c = a < b < c"), 0);
}
#[test]
fn ignores_less_than_or_equal() {
assert_eq!(count("if a <= b then end"), 0);
}
#[test]
fn ignores_generic_type_arguments() {
assert_eq!(count("local m: Map<string, Frame> = f()"), 0);
assert_eq!(count("type A = Array<number>"), 0);
assert_eq!(count("local x = y :: Map<string, number>"), 0);
}
#[test]
fn ignores_generic_function_declarations() {
assert_eq!(count("local function f<T>(v: T): T return v end"), 0);
assert_eq!(count("type Fn<T> = (T) -> T"), 0);
}
#[test]
fn ignores_generic_function_types() {
assert_eq!(count("local f: <T>(T) -> T = g"), 0);
assert_eq!(count("local x = y :: <T>(T) -> T"), 0);
assert_eq!(count("type F = <T>(T) -> T"), 0);
assert_eq!(count("export type F = <T>(T) -> T"), 0);
assert_eq!(count("type F = () -> <T>(T) -> T"), 0);
assert_eq!(count("type F = number | <T>(T) -> T"), 0);
}
#[test]
fn ignores_explicit_type_instantiation() {
assert_eq!(count("assert(identity<<number>>(1) == 1)"), 0);
assert_eq!(
count("local a, b = typePacks<<(string, number)>>(1, 'a')"),
0
);
assert_eq!(
count("local a, b = t:methodTypePacks<<(string, number)>>(1, 'a')"),
0
);
}
#[test]
fn ignores_generic_function_expressions() {
assert_eq!(count("repeat continue until function<t0>() end"), 0);
assert_eq!(
count("for l0 in pcall, function<A...>(...): any end do end"),
0
);
}
#[test]
fn ignores_parenthesised_generic_function_types() {
assert_eq!(
count("return source :: (<T>(initial_value: T) -> Source<T>) & (<T>() -> Source<T>)"),
0
);
assert_eq!(
count("export type Context<T> = (() -> T) & (<U>(T, () -> U) -> U)"),
0
);
assert_eq!(
count("local t = { useDeferredValue: (<T>(value: T) -> T)? }"),
0
);
assert_eq!(
count("return untrack :: ( <T>(fn: () -> T) -> T ) & ( (fn: () -> ()) -> () )"),
0
);
}
#[test]
fn method_calls_are_not_type_context() {
assert_eq!(count("obj:method(<Frame/>)"), 1);
assert_eq!(count("local x = t:render(<Frame/>, <Frame/>)"), 2);
}
#[test]
fn type_context_ends_at_assignment_and_separators() {
assert_eq!(count("local x: Frame = <Frame/>"), 1);
assert_eq!(
count("local x: () -> Frame = function() return <Frame/> end"),
1
);
assert_eq!(count("f(a :: T, <Frame/>)"), 1);
assert_eq!(count("local t = { a = 1 :: number, b = <Frame/> }"), 1);
}
#[test]
fn type_context_ends_at_statement_keywords() {
assert_eq!(
count("function Component(props: Props): Frame return <Frame/> end"),
1
);
}
#[test]
fn type_keyword_is_contextual() {
assert_eq!(count("local k = type(x)\nlocal e = <Frame/>"), 1);
assert_eq!(count("local type = 1\nlocal e = <Frame/>"), 1);
}
#[test]
fn type_declaration_does_not_leak_into_later_statements() {
assert_eq!(count("type F = <T>(T) -> T\nlocal e = <Frame/>"), 1);
}
#[test]
fn type_declaration_recognised_after_a_block_end() {
assert_eq!(
count("local function f() end\ntype Create = <Name>(Name) -> Name"),
0
);
assert_eq!(count("local x = 1\ntype F = <T>(T) -> T"), 0);
}
#[test]
fn type_builtin_on_the_same_line_is_not_a_declaration() {
assert_eq!(count("local f = type\nfoo = <Frame/>"), 1);
}
#[test]
fn ignores_angle_brackets_in_strings_and_comments() {
assert_eq!(count("local s = '<Frame/>'"), 0);
assert_eq!(count("-- local x = <Frame/>"), 0);
assert_eq!(count("--[[ local x = <Frame/> ]]"), 0);
assert_eq!(count("local s = [[ <Frame/> ]]"), 0);
assert_eq!(count("local s = `<Frame/>`"), 0);
}
#[test]
fn sees_through_trivia_to_the_previous_token() {
assert_eq!(count("local x = -- note\n <Frame/>"), 1);
assert_eq!(count("if a --[[ c ]] < b then end"), 0);
}
}