Skip to main content

stylua_lib/formatters/
assignment.rs

1#[cfg(feature = "luau")]
2use full_moon::ast::luau::TypeSpecifier;
3#[cfg(feature = "cfxlua")]
4use full_moon::tokenizer::Symbol;
5use full_moon::tokenizer::{Token, TokenReference};
6use full_moon::{
7    ast::{
8        punctuated::{Pair, Punctuated},
9        Assignment, Call, Expression, FunctionArgs, FunctionCall, LocalAssignment, Suffix,
10    },
11    tokenizer::TokenType,
12};
13
14#[cfg(feature = "lua54")]
15use crate::formatters::lua54::format_attribute;
16#[cfg(feature = "luau")]
17use crate::formatters::luau::format_type_specifier;
18use crate::{
19    context::{create_indent_trivia, create_newline_trivia, Context},
20    fmt_symbol,
21    formatters::{
22        expression::{format_expression, format_var, hang_expression},
23        general::{
24            format_punctuated, format_punctuated_multiline, format_token_reference,
25            try_format_punctuated,
26        },
27        trivia::{
28            strip_leading_trivia, strip_trailing_trivia, strip_trivia, FormatTriviaType,
29            UpdateLeadingTrivia, UpdateTrailingTrivia, UpdateTrivia,
30        },
31        trivia_util::{
32            self, prepend_newline_indent, CommentSearch, GetLeadingTrivia, GetTrailingTrivia,
33            HasInlineComments,
34        },
35    },
36    shape::Shape,
37};
38
39/// Calculates the hanging level to use when hanging an expression.
40/// By default, we indent one further, but we DO NOT want to do this if the expression is just parentheses (or a unary operation on them)
41/// https://github.com/JohnnyMorganz/StyLua/issues/274
42pub fn calculate_hang_level(expression: &Expression) -> Option<usize> {
43    match expression {
44        Expression::Parentheses { .. } => None,
45        Expression::UnaryOperator { expression, .. } => calculate_hang_level(expression),
46        #[cfg(feature = "luau")]
47        Expression::TypeAssertion { expression, .. } => calculate_hang_level(expression),
48        _ => Some(1),
49    }
50}
51
52/// Hangs each [`Expression`] in a [`Punctuated`] list.
53/// The Punctuated list is hung multiline at the comma as well, and each subsequent item after the first is
54/// indented by one.
55pub fn hang_punctuated_list(
56    ctx: &Context,
57    punctuated: &Punctuated<Expression>,
58    shape: Shape,
59) -> Punctuated<Expression> {
60    // WE ACTUALLY ONLY CALL THIS WHEN THE PUNCTUATED LIST HAS ONE ELEMENT
61    // SO LETS ENFORCE THIS INVARIANT FOR NOW
62    assert!(punctuated.len() == 1);
63
64    let mut output = Punctuated::new();
65
66    // Format each expression and hang them
67    // We need to format again because we will now take into account the indent increase
68    for (idx, pair) in punctuated.pairs().enumerate() {
69        // TODO: UNCOMMENT THIS IF THE INVARIANT ABOVE IS REMOVED
70        // let shape = if idx == 0 {
71        //     shape
72        // } else if calculate_hang_level(pair.value()).is_some() {
73        //     shape.reset().increment_additional_indent()
74        // } else {
75        //     shape.reset()
76        // };
77
78        let mut value =
79            hang_expression(ctx, pair.value(), shape, calculate_hang_level(pair.value()));
80        if idx != 0 {
81            value =
82                value.update_leading_trivia(FormatTriviaType::Append(vec![create_indent_trivia(
83                    ctx, shape,
84                )]));
85        }
86
87        output.push(Pair::new(
88            value,
89            pair.punctuation().map(|x| {
90                fmt_symbol!(ctx, x, ",", shape).update_trailing_trivia(FormatTriviaType::Append(
91                    vec![create_newline_trivia(ctx)],
92                ))
93            }),
94        ));
95    }
96
97    output
98}
99
100/// Hangs at the equal token, and indents the first item.
101/// Returns the new equal token [`TokenReference`]
102pub fn hang_equal_token(
103    ctx: &Context,
104    equal_token: &TokenReference,
105    shape: Shape,
106    indent_first_item: bool,
107) -> TokenReference {
108    let mut equal_token_trailing_trivia = vec![create_newline_trivia(ctx)];
109    if indent_first_item {
110        equal_token_trailing_trivia.push(create_indent_trivia(
111            ctx,
112            shape.increment_additional_indent(),
113        ))
114    }
115
116    let equal_token_trailing_trivia = equal_token
117        .trailing_trivia()
118        .filter(|x| trivia_util::trivia_is_comment(x))
119        .flat_map(|x| vec![Token::new(TokenType::spaces(1)), x.to_owned()])
120        .chain(equal_token_trailing_trivia.iter().map(|x| x.to_owned()))
121        .collect();
122
123    equal_token.update_trailing_trivia(FormatTriviaType::Replace(equal_token_trailing_trivia))
124}
125
126fn is_complex_function_call(function_call: &FunctionCall) -> bool {
127    let test_function_args = |function_args: &FunctionArgs| match function_args {
128        FunctionArgs::Parentheses { arguments, .. } => {
129            let mut complexity_count = 0;
130
131            for argument in arguments {
132                match argument {
133                    Expression::Function(_) => return true,
134                    Expression::TableConstructor(_) => complexity_count += 1,
135                    // TODO: should we handle embedded expr in Expression::TypeAssertion { expression } here?
136                    _ => (),
137                }
138            }
139
140            complexity_count > 1
141        }
142        _ => false,
143    };
144
145    function_call.suffixes().any(|suffix| match suffix {
146        Suffix::Call(Call::AnonymousCall(function_args)) => test_function_args(function_args),
147        Suffix::Call(Call::MethodCall(method_call)) => test_function_args(method_call.args()),
148        _ => false,
149    })
150}
151
152/// Determines whether we should prevent hanging at the equals token depending on the RHS expression
153fn prevent_equals_hanging(expression: &Expression) -> bool {
154    match expression {
155        Expression::Function(_) => true,
156        Expression::FunctionCall(function_call) => is_complex_function_call(function_call),
157        #[cfg(feature = "luau")]
158        Expression::IfExpression(_) => true,
159        #[cfg(feature = "luau")]
160        Expression::TypeAssertion { expression, .. } => prevent_equals_hanging(expression),
161        _ => false,
162    }
163}
164
165pub fn hang_at_equals_due_to_comments(
166    ctx: &Context,
167    equal_token: &TokenReference,
168    expression: &Expression,
169    shape: Shape,
170) -> (TokenReference, Expression) {
171    // We will hang at the equals token, and then format the expression as necessary
172    let equal_token = hang_equal_token(ctx, equal_token, shape, false);
173
174    let shape = shape.reset().increment_additional_indent();
175
176    // As we know that there is only a single element in the list, we can extract it to work with it
177    // Format the expression given - if it contains comments, make sure to hang the expression
178    // Ignore the leading comments though (as they are solved by hanging at the equals), and the
179    // trailing comments, as they don't affect anything
180    let expression = if strip_trivia(expression).has_inline_comments() {
181        hang_expression(ctx, expression, shape, None)
182    } else {
183        format_expression(ctx, expression, shape)
184    };
185
186    // We need to take all the leading trivia from the expr_list
187    let (expression, leading_comments) = trivia_util::take_leading_comments(&expression);
188
189    // Indent each comment and trail them with a newline
190    let leading_comments = leading_comments
191        .iter()
192        .flat_map(|x| {
193            vec![
194                create_indent_trivia(ctx, shape),
195                x.to_owned(),
196                create_newline_trivia(ctx),
197            ]
198        })
199        .chain(std::iter::once(create_indent_trivia(ctx, shape)))
200        .collect();
201
202    let expression = expression.update_leading_trivia(FormatTriviaType::Replace(leading_comments));
203
204    (equal_token, expression)
205}
206
207/// Attempts different formatting tactics on an expression list being assigned (`= foo, bar`), to find the best
208/// formatting output.
209fn attempt_assignment_tactics(
210    ctx: &Context,
211    expressions: &Punctuated<Expression>,
212    shape: Shape,
213    equal_token: TokenReference,
214) -> (Punctuated<Expression>, TokenReference) {
215    // The next tactic is to see whether there is more than one item in the punctuated list
216    // If there is, we should put it on multiple lines
217    if expressions.len() > 1 {
218        // First try hanging at the equal token, using an infinite width, to see if its enough
219        let hanging_equal_token = hang_equal_token(ctx, &equal_token, shape, true);
220        let hanging_shape = shape.reset().increment_additional_indent();
221        let expr_list = format_punctuated(
222            ctx,
223            expressions,
224            hanging_shape.with_infinite_width(),
225            format_expression,
226        );
227
228        if trivia_util::punctuated_inline_comments(expressions, true)
229            || hanging_shape
230                .take_first_line(&strip_trivia(&expr_list))
231                .over_budget()
232        {
233            // See whether there is more than one item in the punctuated list
234            // Hang the expressions on multiple lines
235            let multiline_expr = format_punctuated_multiline(
236                ctx,
237                expressions,
238                hanging_shape,
239                format_expression,
240                None,
241            );
242
243            // Look through each punctuated expression to see if we need to hang the item further
244            let mut output_expr = Punctuated::new();
245
246            for (idx, (formatted, original)) in
247                multiline_expr.into_pairs().zip(expressions).enumerate()
248            {
249                // Recreate the shape
250                let shape = hanging_shape.reset();
251
252                if formatted.value().has_inline_comments()
253                    || shape
254                        .take_first_line(&strip_leading_trivia(formatted.value()))
255                        .over_budget()
256                {
257                    // Hang the pair, using the original expression for formatting
258                    output_expr.push(formatted.map(|_| {
259                        let expression =
260                            hang_expression(ctx, original, shape, calculate_hang_level(original));
261                        if idx == 0 {
262                            expression
263                        } else {
264                            trivia_util::prepend_newline_indent(ctx, &expression, shape)
265                        }
266                    }))
267                } else {
268                    // Add the pair as it is
269                    output_expr.push(formatted);
270                }
271            }
272
273            (output_expr, hanging_equal_token)
274        } else {
275            (expr_list, hanging_equal_token)
276        }
277    } else {
278        // There is only a single element in the list
279        let expression = expressions.iter().next().unwrap();
280
281        // Special case: there is a comment in between the equals and the expression
282        if trivia_util::token_contains_comments(&equal_token)
283            || expression.has_leading_comments(CommentSearch::Single)
284        {
285            let (equal_token, expression) =
286                hang_at_equals_due_to_comments(ctx, &equal_token, expression, shape);
287
288            // Rebuild expression back into a list
289            let expr_list = std::iter::once(Pair::new(expression, None)).collect();
290
291            return (expr_list, equal_token);
292        }
293
294        let expr_list = format_punctuated(ctx, expressions, shape, format_expression);
295        let formatting_shape = shape.take_first_line(&strip_trailing_trivia(&expr_list));
296
297        // See if we can hang the expression, and if we can, check whether hanging or formatting normally is nicer
298        if trivia_util::can_hang_expression(expression) {
299            // Create an example hanging the expression - we need to create a new context so that we don't overwrite it
300            let hanging_expr_list = hang_punctuated_list(ctx, expressions, shape);
301            let hanging_shape = shape.take_first_line(&strip_trivia(&hanging_expr_list));
302
303            if expression.has_inline_comments()
304                || hanging_shape.used_width() < formatting_shape.used_width()
305            {
306                (hanging_expr_list, equal_token)
307            } else {
308                // TODO: should we hang at equals token?
309                (expr_list, equal_token)
310            }
311        } else if prevent_equals_hanging(expression) {
312            (expr_list, equal_token)
313        } else {
314            // Try both formatting normally, and hanging at the equals token
315            let hanging_equal_token = hang_equal_token(ctx, &equal_token, shape, true);
316            let equal_token_shape = shape.reset().increment_additional_indent();
317            let hanging_equal_token_expr_list =
318                format_punctuated(ctx, expressions, equal_token_shape, format_expression);
319            let equal_token_shape = equal_token_shape
320                .take_first_line(&strip_trailing_trivia(&hanging_equal_token_expr_list));
321
322            // If hanging at the equal token doesn't go over budget, and it produces less lines than hanging normally
323            // then go for that instead
324            if !equal_token_shape.over_budget()
325                && format!("{hanging_equal_token_expr_list}").lines().count() + 1 // Add an extra line since we are hanging
326                    < format!("{expr_list}").lines().count()
327                || formatting_shape.over_budget()
328            {
329                (hanging_equal_token_expr_list, hanging_equal_token)
330            } else {
331                (expr_list, equal_token)
332            }
333        }
334    }
335}
336
337pub fn format_assignment_no_trivia(
338    ctx: &Context,
339    assignment: &Assignment,
340    mut shape: Shape,
341) -> Assignment {
342    // Check if the assignment expressions or equal token contain comments. If they do, we bail out of determining any tactics
343    // and format multiline
344    let contains_comments = trivia_util::token_contains_comments(assignment.equal_token())
345        || trivia_util::punctuated_inline_comments(assignment.expressions(), true);
346
347    // Firstly attempt to format the assignment onto a single line, using an infinite column width shape
348    let mut var_list = try_format_punctuated(
349        ctx,
350        assignment.variables(),
351        shape.with_infinite_width(),
352        format_var,
353        Some(1),
354    );
355    let mut equal_token = fmt_symbol!(ctx, assignment.equal_token(), " = ", shape);
356    let mut expr_list = format_punctuated(
357        ctx,
358        assignment.expressions(),
359        shape.with_infinite_width(),
360        format_expression,
361    );
362
363    // If the var list ended with a comment, we need to hang the equals token
364    if var_list.has_trailing_comments(trivia_util::CommentSearch::Single) {
365        const EQUAL_TOKEN_LEN: usize = "= ".len();
366        shape = shape
367            .reset()
368            .increment_additional_indent()
369            .add_width(EQUAL_TOKEN_LEN);
370        equal_token = prepend_newline_indent(ctx, &equal_token, shape);
371    }
372
373    // Test the assignment to see if its over width
374    let singleline_shape = shape
375        + (strip_leading_trivia(&var_list).to_string().len()
376            + 3
377            + strip_trailing_trivia(&expr_list).to_string().len());
378    if contains_comments || singleline_shape.over_budget() {
379        // We won't attempt anything else with the var_list. Format it normally
380        var_list = try_format_punctuated(ctx, assignment.variables(), shape, format_var, Some(1));
381        let shape = shape + (strip_leading_trivia(&var_list).to_string().len() + 3);
382
383        let (new_expr_list, new_equal_token) =
384            attempt_assignment_tactics(ctx, assignment.expressions(), shape, equal_token);
385        expr_list = new_expr_list;
386        equal_token = new_equal_token;
387    }
388
389    Assignment::new(var_list, expr_list).with_equal_token(equal_token)
390}
391
392pub fn format_assignment(ctx: &Context, assignment: &Assignment, shape: Shape) -> Assignment {
393    let leading_trivia = vec![create_indent_trivia(ctx, shape)];
394    let trailing_trivia = vec![create_newline_trivia(ctx)];
395
396    format_assignment_no_trivia(ctx, assignment, shape).update_trivia(
397        FormatTriviaType::Append(leading_trivia),
398        FormatTriviaType::Append(trailing_trivia),
399    )
400}
401
402fn format_local_no_assignment(
403    ctx: &Context,
404    assignment: &LocalAssignment,
405    shape: Shape,
406) -> LocalAssignment {
407    let local_token = fmt_symbol!(ctx, assignment.local_token(), "local ", shape);
408    let shape = shape + 6; // 6 = "local "
409    let name_list = try_format_punctuated(
410        ctx,
411        assignment.names(),
412        shape,
413        format_token_reference,
414        Some(1),
415    );
416
417    #[cfg(feature = "lua54")]
418    let attributes = assignment
419        .attributes()
420        .map(|x| x.map(|attribute| format_attribute(ctx, attribute, shape)))
421        .collect();
422
423    #[cfg(feature = "luau")]
424    let type_specifiers: Vec<Option<TypeSpecifier>> = assignment
425        .type_specifiers()
426        .map(|x| x.map(|type_specifier| format_type_specifier(ctx, type_specifier, shape)))
427        .collect();
428
429    let local_assignment = LocalAssignment::new(name_list);
430    #[cfg(feature = "lua54")]
431    let local_assignment = local_assignment.with_attributes(attributes);
432    #[cfg(feature = "luau")]
433    let local_assignment = local_assignment.with_type_specifiers(type_specifiers);
434
435    local_assignment
436        .with_local_token(local_token)
437        .with_equal_token(None)
438        .with_expressions(Punctuated::new())
439}
440
441pub fn format_local_assignment_no_trivia(
442    ctx: &Context,
443    assignment: &LocalAssignment,
444    mut shape: Shape,
445) -> LocalAssignment {
446    if assignment.expressions().is_empty() {
447        format_local_no_assignment(ctx, assignment, shape)
448    } else {
449        // Check if the assignment expression or equals token contain comments. If they do, we bail out of determining any tactics
450        // and format multiline
451        let contains_comments = assignment
452            .equal_token()
453            .is_some_and(trivia_util::token_contains_comments)
454            || trivia_util::punctuated_inline_comments(assignment.expressions(), true);
455
456        // Firstly attempt to format the assignment onto a single line, using an infinite column width shape
457        let local_token = fmt_symbol!(ctx, assignment.local_token(), "local ", shape);
458
459        let mut name_list = try_format_punctuated(
460            ctx,
461            assignment.names(),
462            shape.with_infinite_width(),
463            format_token_reference,
464            Some(1),
465        );
466        let mut equal_token = fmt_symbol!(ctx, assignment.equal_token().unwrap(), " = ", shape);
467
468        // In CfxLua, the equal token could actually be an "in" token for table unpacking
469        #[cfg(feature = "cfxlua")]
470        if let TokenType::Symbol { symbol: Symbol::In } =
471            assignment.equal_token().unwrap().token_type()
472        {
473            equal_token = fmt_symbol!(ctx, assignment.equal_token().unwrap(), " in ", shape);
474        }
475
476        let mut expr_list = format_punctuated(
477            ctx,
478            assignment.expressions(),
479            shape.with_infinite_width(),
480            format_expression,
481        );
482
483        #[cfg(feature = "lua54")]
484        let attributes: Vec<Option<_>> = assignment
485            .attributes()
486            .map(|x| x.map(|attribute| format_attribute(ctx, attribute, shape)))
487            .collect();
488
489        #[cfg(feature = "luau")]
490        let type_specifiers: Vec<Option<TypeSpecifier>> = assignment
491            .type_specifiers()
492            .map(|x| x.map(|type_specifier| format_type_specifier(ctx, type_specifier, shape)))
493            .collect();
494
495        #[allow(unused_mut)]
496        let mut type_specifier_len = 0;
497        #[cfg(feature = "lua54")]
498        {
499            type_specifier_len += attributes.iter().fold(0, |acc, x| {
500                acc + x.as_ref().map_or(0, |y| y.to_string().len())
501            });
502        }
503        #[cfg(feature = "luau")]
504        {
505            type_specifier_len += type_specifiers.iter().fold(0, |acc, x| {
506                acc + x.as_ref().map_or(0, |y| y.to_string().len())
507            });
508        }
509
510        #[cfg(feature = "luau")]
511        let var_list_ends_with_comments = match type_specifiers.last() {
512            Some(Some(specifier)) => {
513                specifier.has_trailing_comments(trivia_util::CommentSearch::Single)
514            }
515            _ => name_list.has_trailing_comments(trivia_util::CommentSearch::Single),
516        };
517        #[cfg(not(feature = "luau"))]
518        let var_list_ends_with_comments =
519            name_list.has_trailing_comments(trivia_util::CommentSearch::Single);
520
521        // If the var list ended with a comment, we need to hang the equals token
522        if var_list_ends_with_comments {
523            const EQUAL_TOKEN_LEN: usize = "= ".len();
524            shape = shape
525                .reset()
526                .increment_additional_indent()
527                .add_width(EQUAL_TOKEN_LEN);
528            equal_token = prepend_newline_indent(ctx, &equal_token, shape);
529        }
530
531        // Test the assignment to see if its over width
532        let singleline_shape = shape
533            + (strip_leading_trivia(&name_list).to_string().len()
534                + 6 // 6 = "local "
535                + 3 // 3 = " = "
536                + type_specifier_len
537                + strip_trailing_trivia(&expr_list).to_string().len());
538
539        if contains_comments || singleline_shape.over_budget() {
540            // We won't attempt anything else with the name_list. Format it normally
541            name_list = try_format_punctuated(
542                ctx,
543                assignment.names(),
544                shape,
545                format_token_reference,
546                Some(1),
547            );
548            let shape = shape
549                + (strip_leading_trivia(&name_list).to_string().len() + 6 + 3 + type_specifier_len);
550
551            let (new_expr_list, new_equal_token) =
552                attempt_assignment_tactics(ctx, assignment.expressions(), shape, equal_token);
553            expr_list = new_expr_list;
554            equal_token = new_equal_token;
555        }
556
557        let local_assignment = LocalAssignment::new(name_list);
558        #[cfg(feature = "lua54")]
559        let local_assignment = local_assignment.with_attributes(attributes);
560        #[cfg(feature = "luau")]
561        let local_assignment = local_assignment.with_type_specifiers(type_specifiers);
562        local_assignment
563            .with_local_token(local_token)
564            .with_equal_token(Some(equal_token))
565            .with_expressions(expr_list)
566    }
567}
568
569pub fn format_local_assignment(
570    ctx: &Context,
571    assignment: &LocalAssignment,
572    shape: Shape,
573) -> LocalAssignment {
574    // Calculate trivia
575    let leading_trivia = vec![create_indent_trivia(ctx, shape)];
576    let trailing_trivia = vec![create_newline_trivia(ctx)];
577
578    format_local_assignment_no_trivia(ctx, assignment, shape).update_trivia(
579        FormatTriviaType::Append(leading_trivia),
580        FormatTriviaType::Append(trailing_trivia),
581    )
582}