Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Luau: Fixed sort requires not recognizing imports declared with `const`, e.g. `const Foo = require(path)`

## [2.5.2] - 2026-05-16

### Fixed
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,9 @@ In editors, `Format Selection` is supported.
StyLua has built-in support for sorting require statements. We group consecutive require statements into a single "block",
and then requires are sorted only within that block. Blocks of requires do not move around the file.

StyLua only considers requires of the form `local NAME = require(EXPR)`, and sorts lexicographically based on `NAME`.
(StyLua can also sort Roblox services of the form `local NAME = game:GetService(EXPR)`)
StyLua only considers requires of the form `local NAME = require(EXPR)`, or `const NAME = require(EXPR)` in Luau,
and sorts lexicographically based on `NAME`.
(StyLua can also sort Roblox services of the form `local NAME = game:GetService(EXPR)`, or `const NAME = game:GetService(EXPR)` in Luau)

Requires sorting is off by default. To enable it, add the following to your `stylua.toml`:

Expand Down
176 changes: 109 additions & 67 deletions src/sort_requires.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
//! The following assumptions are made when using this codemod:
//! - All requires are pure and have no side effects: resorting the requires is not an issue
//! - Only requires at the top level block are to be sorted
//! - Requires are of the form `local NAME = require(REQUIRE)`, with only a single require per local assignment
//! - Requires are of the form `local NAME = require(REQUIRE)` or, in Luau,
//! `const NAME = require(REQUIRE)`, with only a single require per assignment
//!
//! Requires sorting works in the following way:
//! - We group consecutive requires into a "block".
Expand All @@ -16,9 +17,9 @@
//! - Blocks remain in-place in the file.

use full_moon::{
ast::{Ast, Block, Call, Expression, Prefix, Stmt, Suffix},
ast::{punctuated::Punctuated, Ast, Block, Call, Expression, Prefix, Stmt, Suffix},
node::Node,
tokenizer::{TokenReference, TokenType},
tokenizer::{Token, TokenReference, TokenType},
};

use crate::{
Expand Down Expand Up @@ -67,6 +68,69 @@ fn get_expression_kind(expression: &Expression) -> Option<GroupKind> {

type StmtSemicolon = (Stmt, Option<TokenReference>);

fn get_sortable_assignment_parts(
names: &Punctuated<TokenReference>,
expressions: &Punctuated<Expression>,
) -> Option<(String, GroupKind, usize)> {
if names.len() != 1 || expressions.len() != 1 {
return None;
}

let name = names.iter().next().unwrap();
let expression = expressions.iter().next().unwrap();
let expression_kind = get_expression_kind(expression)?;
let variable_name =
extract_identifier_from_token(name).expect("require is stored as non-identifier");
let current_line = name.start_position().unwrap().line();

Some((variable_name, expression_kind, current_line))
}

fn get_sortable_assignment(stmt: &StmtSemicolon) -> Option<(String, GroupKind, usize)> {
match &stmt.0 {
Stmt::LocalAssignment(node) => {
get_sortable_assignment_parts(node.names(), node.expressions())
}
#[cfg(feature = "luau")]
Stmt::ConstAssignment(node) => {
get_sortable_assignment_parts(node.names(), node.expressions())
}
_ => None,
}
}

fn get_stmt_leading_trivia(stmt: &StmtSemicolon) -> Vec<Token> {
match &stmt.0 {
Stmt::LocalAssignment(local_assignment) => local_assignment
.local_token()
.leading_trivia()
.cloned()
.collect(),
#[cfg(feature = "luau")]
Stmt::ConstAssignment(const_assignment) => const_assignment
.const_token()
.leading_trivia()
.cloned()
.collect(),
_ => unreachable!(),
}
}

fn set_stmt_leading_trivia(stmt: &mut StmtSemicolon, leading_trivia: Vec<Token>) {
match &mut stmt.0 {
Stmt::LocalAssignment(local_assignment) => {
*local_assignment =
local_assignment.update_leading_trivia(FormatTriviaType::Replace(leading_trivia));
}
#[cfg(feature = "luau")]
Stmt::ConstAssignment(const_assignment) => {
*const_assignment =
const_assignment.update_leading_trivia(FormatTriviaType::Replace(leading_trivia));
}
_ => unreachable!(),
}
}

#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum GroupKind {
Require,
Expand All @@ -82,57 +146,44 @@ fn partition_nodes_into_groups(block: &Block) -> Vec<BlockPartition> {
let mut parts = Vec::new();

for stmt in block.stmts_with_semicolon() {
if let Stmt::LocalAssignment(node) = &stmt.0 {
if node.names().len() == 1 && node.expressions().len() == 1 {
let name = node.names().iter().next().unwrap();
let expression = node.expressions().iter().next().unwrap();

let current_line = name.start_position().unwrap().line();

let expression_kind = get_expression_kind(expression);
if let Some(expression_kind) = expression_kind {
let variable_name = extract_identifier_from_token(name)
.expect("require is stored as non-identifier");

// Check if we need to start a new block:
// Either, the parts list is empty, the last part was a BlockPartition::Other,
// the last part group was a different kind, or,
// there is > 1 line in between the previous require and this one
let create_new_block = match parts.last() {
None => true,
Some(BlockPartition::Other(_)) => true,
Some(BlockPartition::RequiresGroup(other_kind, _))
if *other_kind != expression_kind =>
{
true
}
Some(BlockPartition::RequiresGroup(_, list)) => {
let previous_require =
list.last().expect("unreachable!: empty require group");
let position = previous_require
.1
.end_position()
.expect("unreachable!: previous require stmt has no end position");

let previous_require_line = position.line();
current_line - previous_require_line > 1
}
};

if create_new_block {
parts.push(BlockPartition::RequiresGroup(expression_kind, Vec::new()))
}
if let Some((variable_name, expression_kind, current_line)) = get_sortable_assignment(stmt)
{
// Check if we need to start a new block:
// Either, the parts list is empty, the last part was a BlockPartition::Other,
// the last part group was a different kind, or,
// there is > 1 line in between the previous require and this one
let create_new_block = match parts.last() {
None => true,
Some(BlockPartition::Other(_)) => true,
Some(BlockPartition::RequiresGroup(other_kind, _))
if *other_kind != expression_kind =>
{
true
}
Some(BlockPartition::RequiresGroup(_, list)) => {
let previous_require = list.last().expect("unreachable!: empty require group");
let position = previous_require
.1
.end_position()
.expect("unreachable!: previous require stmt has no end position");

let previous_require_line = position.line();
current_line - previous_require_line > 1
}
};

match parts.last_mut() {
Some(BlockPartition::RequiresGroup(_, map)) => {
map.push((variable_name, stmt.clone()))
}
_ => unreachable!(),
};
if create_new_block {
parts.push(BlockPartition::RequiresGroup(expression_kind, Vec::new()))
}

continue;
match parts.last_mut() {
Some(BlockPartition::RequiresGroup(_, map)) => {
map.push((variable_name, stmt.clone()))
}
}
_ => unreachable!(),
};

continue;
}

// Handle as a non-require
Expand All @@ -154,7 +205,7 @@ fn partition_nodes_into_groups(block: &Block) -> Vec<BlockPartition> {
pub(crate) fn sort_requires(ctx: &Context, input_ast: Ast) -> Ast {
let block = input_ast.nodes();

// Find all `local NAME = require(EXPR)` lines
// Find all `local NAME = require(EXPR)` and Luau `const NAME = require(EXPR)` lines
let parts = partition_nodes_into_groups(block);

// If there is only one non-require partition, or no partitions at all
Expand Down Expand Up @@ -182,18 +233,10 @@ pub(crate) fn sort_requires(ctx: &Context, input_ast: Ast) -> Ast {
// Get the leading trivia of the first statement in the list, as that will be what
// is appended to the new statement
let leading_trivia = match list.first_mut() {
Some((_, (Stmt::LocalAssignment(local_assignment), _))) => {
let trivia = local_assignment
.local_token()
.leading_trivia()
.cloned()
.collect();

// Replace the trivia
*local_assignment = local_assignment
.update_leading_trivia(FormatTriviaType::Replace(vec![]));

trivia
Some((_, stmt)) => {
let leading_trivia = get_stmt_leading_trivia(stmt);
set_stmt_leading_trivia(stmt, Vec::new());
leading_trivia
}
_ => unreachable!(),
};
Expand All @@ -203,9 +246,8 @@ pub(crate) fn sort_requires(ctx: &Context, input_ast: Ast) -> Ast {

// Mutate the first element with our leading trivia
match list.first_mut() {
Some((_, (Stmt::LocalAssignment(local_assignment), _))) => {
*local_assignment = local_assignment
.update_leading_trivia(FormatTriviaType::Replace(leading_trivia))
Some((_, stmt)) => {
set_stmt_leading_trivia(stmt, leading_trivia);
}
_ => unreachable!(),
};
Expand Down
43 changes: 43 additions & 0 deletions tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,49 @@ fn test_sort_requires() {
})
}

#[test]
#[cfg(feature = "luau")]
fn test_sort_requires_luau_const() {
let input = r#"const Zebra = require("Zebra") :: any
local Apple = require("Apple")
const Banana: any = require("Banana")
const Players = game:GetService("Players")
const Lighting = game:GetService("Lighting")
local Workspace = game:GetService("Workspace")

const SplitZ = require("SplitZ")
local value = 1
const SplitA = require("SplitA")
"#;

let output = format_code(
input,
Config {
syntax: LuaVersion::Luau,
sort_requires: SortRequiresConfig { enabled: true },
..Config::default()
},
None,
OutputVerification::None,
)
.unwrap();

assert_eq!(
output,
r#"local Apple = require("Apple")
const Banana: any = require("Banana")
const Zebra = require("Zebra") :: any
const Lighting = game:GetService("Lighting")
const Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")

const SplitZ = require("SplitZ")
local value = 1
const SplitA = require("SplitA")
"#
);
}

#[test]
fn test_crlf_in_multiline_comments() {
// We need to do this outside of insta since it normalises line endings to LF
Expand Down