@enum(
    FNode,

    # dummy
    NONE,

    # leaf nodes
    NEWLINE,
    SEMICOLON,
    WHITESPACE,
    PLACEHOLDER,
    NOTCODE,
    INLINECOMMENT,
    TRAILINGCOMMA,
    KEYWORD,
    LITERAL,
    OPERATOR,
    PUNCTUATION,
    IDENTIFIER,
    MACRONAME,
    HASHEQCOMMENT,

    # non-leaf nodes
    Accessor,
    MacroBlock,
    MacroCall,
    MacroStr,
    Macroname,
    GlobalRefDoc,
    TupleN,
    CartesianIterator,
    RefN,
    ModuleN,
    Unary,
    Binary,
    Chain,
    Comparison,
    Conditional,
    Where,
    Vect,
    Braces,
    Brackets,
    Curly,
    Call,
    Parameters,
    Kw,
    Vcat,
    Hcat,
    Ncat,
    TypedVcat,
    TypedNcat,
    TypedHcat,
    Row,
    NRow,
    BracesCat,
    TypedComprehension,
    Comprehension,
    Generator,
    Filter,
    Flatten,
    For,
    While,
    If,
    Begin,
    Try,
    Quote,
    Do,
    Let,
    Block,
    BareModule,
    TopLevel,
    StringN,
    Macro,
    FunctionN,
    Struct,
    Mutable,
    Primitive,
    Abstract,
    Return,
    Local,
    Outer,
    Global,
    Const,
    Import,
    Export,
    Public,
    Using,
    File,
    Quotenode,
    Unknown,
    As,
    NonStdIdentifier, # i.e. var"##iv369"
    ImportPath,
    Juxtapose,
    Break,
    Continue,
    Inert,
    TupleBlock,
)

@enum(NestBehavior, AllowNest, AlwaysNest, NeverNest, NeverNestNode, AllowNestButDontRemove)

struct Metadata
    op_kind::JuliaSyntax.Kind

    # Indicates that the node is a shortcircuiting logical operator (`&&` or `||`) whose
    # value is not used in any way, i.e., it exists solely for the side effects.
    is_standalone_shortcircuit::Bool
    # If the above is true, this field indicates whether it should be expanded into a full
    # if-else statement if `short_circuit_to_if` is true. This mainly exists to catch the
    # case where it's inside a macro/expr, which causes the transformation to be disabled.
    # 
    # Note that because `is_standalone_shortcircuit` has impacts on alignment as well, we
    # need to have a separate flag instead of just setting it to false when inside
    # macros/exprs.
    is_expandable_shortcircuit::Bool

    is_short_form_function::Bool
    is_assignment::Bool

    # Indicates that this is a long-form function def that can be contracted to a short-form
    # one if needed.
    is_contractable_function::Bool

    has_multiline_argument::Bool

    # True for a StringN node built from a NON-triple-quoted multiline literal, e.g.
    #
    #     x = "line one
    #     line two"
    #
    # or the `cmd`/`r"..."`/`raw"..."` equivalents. Unlike `"""..."""` literals, these have
    # no dedent semantics: every interior line (and the closing-delimiter line) is part of
    # the string's value, so the printer must emit those lines verbatim at their original
    # columns and must not re-indent them when the surrounding code shifts. See issue #1251.
    is_opaque_string::Bool
end

function Metadata(k::JuliaSyntax.Kind)
    return Metadata(k, false, false, false, false, true, false, false)
end

"""
Formatted Syntax Tree
"""
mutable struct FST
    typ::FNode

    # Start and end lines of the node
    # in the original source file.
    startline::Int
    endline::Int

    # TODO(penelopeysm): This field should be calculated based on display width. I'm not
    # sure that this is consistently obeyed in the codebase, so it's more of an aspirational
    # comment rather than a true invariant right now.
    indent::Int

    # Display width of the node's content (measured in terminal columns via `textwidth`).
    len::Int
    val::String
    # Note that nodes = () indicates a leaf node, whereas nodes = [] indicates a tree node
    # with no leaves (yet).
    #
    # TODO(penelopeysm): The former case should probably be `nothing`.
    nodes::Union{Tuple{},Vector{FST}}
    nest_behavior::NestBehavior

    # Extra margin caused by parent nodes.
    # i.e. `(f(arg))`
    #
    # `f(arg)` would have `extra_margin` = 1
    # due to `)` after `f(arg)`.
    extra_margin::Int
    line_offset::Int

    metadata::Union{Nothing,Metadata}
end

function show(io::IO, fst::FST)
    show(io, MIME("text/plain"), fst)
end

COLORS = (:blue, :green, :red, :cyan, :magenta, :yellow)

function _print_prefix(io::IO, prefix::Vector{String})
    for (k, seg) in enumerate(prefix)
        printstyled(io, seg; color = COLORS[mod1(k, length(COLORS))])
    end
end

function show(
    io::IO,
    ::MIME"text/plain",
    fst::FST;
    prefix::Vector{String} = String[],
    level::Int = 1,
)
    color = COLORS[mod1(level, length(COLORS))]
    extra_indent = level > 1 ? "    " : "" # to account for [N] prefix

    if !is_leaf(fst)
        nodes = fst.nodes::Vector{FST}
        n = length(nodes)
        printstyled(io, "$(fst.typ) $n"; color = color, bold = true)
        printstyled(
            io,
            " lines=$(fst.startline)-$(fst.endline) indent=$(fst.indent) len=$(fst.len)\n";
            color = color,
        )
        _print_prefix(io, prefix)
        printstyled(io, "nest_behavior=$(fst.nest_behavior)"; color = color)
        printstyled(io, " extra_margin=$(fst.extra_margin)\n"; color = color)

        for (i, node) in enumerate(nodes)
            is_last = i == n
            connector = is_last ? "└── " : "├── "
            continuation = is_last ? "    " : "│   "
            next_color = COLORS[mod1(level + 1, length(COLORS))]
            child_prefix = [prefix; continuation]

            _print_prefix(io, prefix)
            printstyled(io, connector; color = color)
            printstyled(io, "[$i] "; color = next_color, bold = true)
            show(io, MIME("text/plain"), node; prefix = child_prefix, level = level + 1)
        end
    else
        printstyled(io, "$(fst.typ)"; color = color, bold = true)
        printstyled(
            io,
            " lines=$(fst.startline)-$(fst.endline) val=$(repr(fst.val))\n";
            color = color,
        )
        _print_prefix(io, prefix)
        printstyled(io, "$(extra_indent)line_offset=$(fst.line_offset)"; color = color)
        printstyled(io, " indent=$(fst.indent)\n"; color = color)
    end
end

function FST(typ::FNode, indent::Int)
    FST(typ, 0, 0, indent, 0, "", FST[], AllowNest, 0, -1, nothing)
end

function FST(
    typ::FNode,
    line_offset::Int,
    startline::Int,
    endline::Int,
    val::AbstractString,
)
    FST(
        typ,
        startline,
        endline,
        0,
        textwidth(val),
        val,
        (),
        AllowNest,
        0,
        line_offset,
        nothing,
    )
end

function Base.setindex!(fst::FST, node::FST, ind::Int)
    nodes = fst.nodes::Vector{FST}
    fst.len -= nodes[ind].len
    nodes[ind] = node
    fst.len += node.len
end
Base.getindex(fst::FST, inds...) = (fst.nodes::Vector{FST})[inds...]
Base.lastindex(fst::FST) = length(fst.nodes::Vector{FST})
Base.firstindex(fst::FST) = 1
Base.length(fst::FST) = fst.len
function Base.iterate(fst::FST, state = 1)
    nodes = fst.nodes::Vector{FST}
    if state > length(nodes::Vector{FST})
        return nothing
    end
    return nodes[state], state + 1
end

function Base.insert!(fst::FST, ind::Int, node::FST)
    insert!(fst.nodes::Vector{FST}, ind, node)
    fst.len += node.len
    return
end

# nesting behaviors
must_nest(fst::FST) = fst.nest_behavior === AlwaysNest
cant_nest(fst::FST) = fst.nest_behavior === NeverNest
function any_descendant(f, fst::FST)
    is_leaf(fst) && return false
    for n in fst.nodes::Vector{FST}
        f(n) && return true
        any_descendant(f, n) && return true
    end
    return false
end
can_nest(fst::FST) = fst.nest_behavior in (AllowNest, AllowNestButDontRemove)
can_remove(fst::FST) = fst.nest_behavior !== AllowNestButDontRemove

function Newline(; length = 0, nest_behavior = AllowNest)
    FST(NEWLINE, 0, 0, 0, length, "\n", (), nest_behavior, 0, -1, nothing)
end
Semicolon() = FST(SEMICOLON, 0, 0, 0, 1, ";", (), AllowNest, 0, -1, nothing)
TrailingComma() = FST(TRAILINGCOMMA, 0, 0, 0, 0, "", (), AllowNest, 0, -1, nothing)
Whitespace(n) = FST(WHITESPACE, 0, 0, 0, n, " "^n, (), AllowNest, 0, -1, nothing)
Placeholder(n) = FST(PLACEHOLDER, 0, 0, 0, n, " "^n, (), AllowNest, 0, -1, nothing)
function Notcode(startline, endline)
    FST(NOTCODE, startline, endline, 0, 0, "", (), AllowNest, 0, -1, nothing)
end
function InlineComment(line)
    FST(INLINECOMMENT, line, line, 0, 0, "", (), AllowNest, 0, -1, nothing)
end

is_leaf(cst::JuliaSyntax.GreenNode) = !haschildren(cst)
is_leaf(fst::FST) = typeof(fst.nodes) === Tuple{}

function is_punc(cst::JuliaSyntax.GreenNode)
    punctuation = KSet", ( ) [ ] { } @"
    return kind(cst) in punctuation || (kind(cst) === K"." && !haschildren(cst))
end
is_punc(fst::FST) = fst.typ === PUNCTUATION

is_end(x::JuliaSyntax.GreenNode) = kind(x) === K"end"
is_end(x::FST) = x.typ === KEYWORD && x.val == "end"

is_colon(x::FST) = x.typ === OPERATOR && x.val == ":"

is_comma(fst::FST) = fst.typ === TRAILINGCOMMA || (is_punc(fst) && fst.val == ",")
is_comment(fst::FST) = fst.typ in (INLINECOMMENT, NOTCODE, HASHEQCOMMENT)

is_identifier(x) = kind(x) === K"Identifier" && !haschildren(x)

"""
    block_has_statements(cst) -> Bool

Whether the `block` node `cst` contains any statements.

Semicolons don't count. They are only statement separators and are dropped when the block
is printed (see `p_block`), so `function f(); end` is just as empty as `function f() end`
and both are printed the same way. Counting the `;` as a statement would instead give
`function f()\\nend` on the first pass, which the empty-block handling then collapses to
`function f() end` on the second — i.e. not idempotent.
"""
function block_has_statements(cst)
    haschildren(cst) || return false
    any(c -> !Shims.is_really_whitespace(c) && kind(c) !== K";", children(cst))
end

function is_multiline(fst::FST)
    fst.endline > fst.startline &&
        fst.typ in (StringN, Vcat, TypedVcat, Ncat, TypedNcat, MacroStr)
end

is_macrocall(fst::FST) = fst.typ in (MacroCall, MacroBlock)

function is_macrodoc(fst::FST)::Bool
    fst.typ === GlobalRefDoc ||
        (fst.typ === MacroBlock && fst[1].typ === Macroname && fst[1][1].val == "@doc")
end

function is_macrostr(t::JuliaSyntax.GreenNode)::Bool
    kind(t) == K"macrocall" && haschildren(t) && contains_macrostr(t[1])
end

function contains_macrostr(t::JuliaSyntax.GreenNode)::Bool
    if kind(t) in KSet"StringMacroName CmdMacroName"
        return true
    elseif kind(t) === "quote" && haschildren(t)
        return contains_macrostr(t[1])
    elseif kind(t) === K"." && haschildren(t)
        return any(contains_macrostr, reverse(children(t)))
    end
    return false
end

function defines_function(x::JuliaSyntax.GreenNode)
    if kind(x) in KSet"function macro" && haschildren(x)
        return true
    elseif is_assignment(x) && haschildren(x)
        childs = children(x)
        idx = findfirst(n -> !JuliaSyntax.is_whitespace(n), childs)
        return !isnothing(idx) && Shims.is_caller_in_function_def(childs[idx])
    end
    return false
end

is_if(cst::JuliaSyntax.GreenNode) = kind(cst) in KSet"if elseif else" && haschildren(cst)

function is_try(cst::JuliaSyntax.GreenNode)
    kind(cst) in KSet"try catch finally" && haschildren(cst)
end

function is_custom_leaf(fst::FST)
    fst.typ in
    (NEWLINE, WHITESPACE, PLACEHOLDER, NOTCODE, INLINECOMMENT, TRAILINGCOMMA, HASHEQCOMMENT)
end

contains_comment(nodes::Vector{FST}) = findfirst(is_comment, nodes) !== nothing
function contains_comment(fst::FST)
    !is_leaf(fst) && contains_comment(fst.nodes::Vector{FST})
end

function get_args(t::JuliaSyntax.GreenNode)
    nodes = JuliaSyntax.GreenNode[]
    !haschildren(t) && return nodes
    childs = children(t)
    childs isa Tuple{} && return nodes

    k = kind(t)
    ret = if k == K"where"
        if is_leaf(childs[end])
            JuliaSyntax.GreenNode[childs[end]]
        else
            get_args(childs[end])
        end
    else
        _idx = findfirst(n -> kind(n) in KSet"( { [", childs)
        idx = isnothing(_idx) ? 1 : _idx + 1
        get_args(childs[idx:end])
    end
    append!(nodes, ret)
    return nodes
end

function get_args(args::Vector{JuliaSyntax.GreenNode{T}}) where {T}
    result = JuliaSyntax.GreenNode[]
    for c in args
        if !(
            is_punc(c) ||
            kind(c) == K";" ||
            JuliaSyntax.is_whitespace(c) ||
            kind(c) in KSet"` ``` \" \"\"\""
        )
            if kind(c) === K"parameters"
                append!(result, get_args(c))
            else
                push!(result, c)
            end
        end
    end
    return result
end

get_args(_) = ()
n_args(x) = length(get_args(x))

is_arg(fst::FST) = !(fst.typ in (PUNCTUATION, SEMICOLON) || is_custom_leaf(fst))

function n_args(fst::FST)
    is_leaf(fst) && return 0
    nodes = fst.nodes::Vector{FST}
    idx = findfirst(is_opener, nodes) |> x -> isnothing(x) ? 1 : x
    count(is_arg, nodes[idx:end])
end

function is_prev_newline(fst::FST)
    return if fst.typ === NEWLINE
        true
    elseif is_leaf(fst) || length(fst.nodes::Vector) == 0
        false
    else
        is_prev_newline(fst[end])
    end
end
function remove_prev_newline!(fst::FST)
    if is_leaf(fst) || length(fst.nodes::Vector) == 0
        error("Cannot remove previous newline from a leaf node or empty FST")
    elseif fst[end].typ === NEWLINE
        pop!(fst.nodes::Vector{FST})
        # no need to decrement fst.len because Newline nodes have len 0
    else
        remove_prev_newline!(fst[end])
        # likewise no need to decrement fst.len
    end
    return nothing
end

"""
    length_to(x::FST, ntyps; start::Int = 1)

Returns the length to any node type in `ntyps` based off the `start` index.
"""
function length_to(fst::FST, ntyps; start::Int = 1)
    if fst.typ in ntyps
        return 0, true
    end
    if is_leaf(fst)
        return length(fst), false
    end
    len = 0
    nodes = fst.nodes::Vector
    for i in start:length(nodes)
        l, found = length_to(nodes[i], ntyps)
        len += l
        if found
            return len, found
        end
    end
    return len, false
end

function is_closer(fst::FST)
    fst.typ === PUNCTUATION && (fst.val == "}" || fst.val == ")" || fst.val == "]")
end
is_closer(t::JuliaSyntax.GreenNode) = kind(t) in KSet"} ) ]"

function is_opener(fst::FST)
    fst.typ === PUNCTUATION && (fst.val == "{" || fst.val == "(" || fst.val == "[")
end
is_opener(t::JuliaSyntax.GreenNode) = kind(t) in KSet"{ ( ["

function is_iterable(t::JuliaSyntax.GreenNode)
    kind(t) in
    KSet"parens tuple vect vcat braces curly comprehension typed_comprehension macrocall ref typed_vcat import using export public" ||
        Shims.is_function_call(t)
end

function is_iterable(x::FST)
    is_named_iterable(x) || is_unnamed_iterable(x) || is_import_expr(x)
end
is_iterable(::Nothing) = false

function is_unnamed_iterable(x::FST)
    return x.typ in (TupleN, Vect, Vcat, Ncat, Braces, Comprehension, Brackets)
end

function is_named_iterable(x::FST)
    return x.typ in (Call, Curly, TypedComprehension, MacroCall, RefN, TypedVcat, TypedNcat)
end

function is_import_expr(x::FST)
    return x.typ in (Import, Using, Export, Public)
end

"""
    is_indent_nest(fst::FST)

Whether nesting the binary call `fst` at its operator indents the RHS by one level from the
start of the line:

```julia
aaaaaaaaaaaa =>
    rhs
```

as opposed to bringing it back to the column the LHS starts at, which is what every other
operator does:

```julia
aaaaaaaaaaaa in
rhs
```

True for assignments, `=>`, `->` and standalone short-circuits.
"""
function is_indent_nest(fst::FST)
    md = fst.metadata
    return (!isnothing(md) && (md::Metadata).is_short_form_function) ||
           is_assignment(fst) ||
           op_kind(fst) in KSet"=> ->" ||
           (!isnothing(md) && (md::Metadata).is_standalone_shortcircuit)
end

"""
    is_column_aligned(fst::FST)

Whether YASStyle lays out the continuation lines of `fst` relative to the column at which
`fst` itself starts, rather than relative to the start of the line it appears on.

Iterables are column-aligned, because their arguments line up with the bracket that opens
them, as is `where`, whose type parameters line up with the `{`:

```julia
f(aaaaaaaaaaaaaaaaaaaaaa,
  bbbbbbbbbbbbbbbbbbbbbb)

Foo{A} where {Aaaaaaaaaaaaaaa<:Xxxxxxxxxxxx,
              Bbbbbbbbbbbbbbb<:Yyyyyyyyyyyy}
```

Block-like constructs are not, because their bodies are indented by one level from the
start of the line, regardless of where `function f()` starts:

```julia
function f()
    body
end
```

For nested constructs we might need to inspect their children. For example in

```julia
@mymacro [aaaaaaaaaaaaaaaaaaaa,
          bbbbbbbbbbbbbbbbbbbb]
```

the second line is indented according to the `[` iff the last argument of the
macro is column-aligned.

A binary call is column-aligned unless its operator is one of those that indents the RHS
from the start of the line instead (see [`is_indent_nest`](@ref)).

`Chain`, `Comparison` and `Conditional` are column-aligned in YASStyle too, but they are
deliberately not listed here because the only caller (`n_binaryopcall!`) accounts for their
full width before it will consider undoing a nesting, so it never has to move one of them
that has already been split over several lines.
"""
function is_column_aligned(fst::FST)
    return if is_leaf(fst)
        false
    elseif fst.typ === Binary
        nodes = fst.nodes::Vector{FST}
        if any(n -> n.typ === NEWLINE, nodes)
            # It has been split across its own operator, so `n_binaryopcall!` decided
            # where the RHS goes.
            !is_indent_nest(fst)
        else
            # It is still on one line, so any continuation lines belong to the RHS
            # operand. In `@mac aaaa = [bbbbb, ccccc]` it is the vector literal, not the
            # assignment, that the second line is aligned against.
            is_column_aligned(nodes[end])
        end
    elseif fst.typ === MacroBlock
        # The trailing argument is the one that continuation lines follow on from.
        nodes = fst.nodes::Vector{FST}
        !isempty(nodes) && is_column_aligned(nodes[end])
    elseif fst.typ === Unary
        # Either `!x` or `x...`; in both cases it's the operand that matters, not the
        # operator.
        nodes = fst.nodes::Vector{FST}
        idx = findfirst(n -> n.typ !== OPERATOR, nodes)
        idx !== nothing && is_column_aligned(nodes[idx])
    elseif fst.typ === Where # `where {...}`
        true
    elseif is_iterable(fst)
        true
    else
        false
    end
end

"""
Returns whether `fst` can be an iterable argument. For example in
the case of a function call, which is of type `Call`:

```julia
(a, b, c; k1=v1)
```

This would return `true` for `a`, `b`, `c` and `k1=v1` and `false` for all other nodes.
"""
function is_iterable_arg(fst::FST)
    !(fst.typ in (PUNCTUATION, KEYWORD, OPERATOR, SEMICOLON) || is_custom_leaf(fst))
end

function is_comprehension(x::JuliaSyntax.GreenNode)
    kind(x) in KSet"comprehension typed_comprehension"
end

function is_comprehension(x::FST)
    x.typ in (Comprehension, TypedComprehension)
end

function first_nontrivial_child_is_block(cst::JuliaSyntax.GreenNode, style::AbstractStyle)
    JuliaSyntax.is_leaf(cst) && return false
    for c in children(cst)
        if JuliaSyntax.is_whitespace(c)
            continue
        else
            return is_block(c, style)
        end
    end
    return false
end

function is_block(x::JuliaSyntax.GreenNode, style::AbstractStyle)
    is_if(x) ||
        kind(x) in KSet"do try for while let" ||
        (
            kind(x) == K"block" &&
            haschildren(x) &&
            !JuliaSyntax.has_flags(x, JuliaSyntax.PARENS_FLAG)
        ) ||
        (kind(x) == K"quote" && haschildren(x) && is_block(x[1], style)) ||
        # For BlueStyle, chained ternaries will be expanded into blocks so we want to make
        # formatting decisions with that in mind.
        (style isa BlueStyle && is_chained_ternary(x))
end

# A chained ternary `a ? b : c ? d : e` is a K"?" node whose "else" branch (the last
# non-whitespace child) is itself a K"?" node.
function is_chained_ternary(cst::JuliaSyntax.GreenNode)
    kind(cst) !== K"?" && return false
    haschildren(cst) || return false
    rhs = findlast(c -> !JuliaSyntax.is_whitespace(c), children(cst))
    return rhs !== nothing && kind(cst[rhs]) === K"?" && haschildren(cst[rhs])
end

function is_block(x::FST)
    x.typ in (Block, If, Do, Try, Begin, For, While, Let) ||
        (x.typ === Quote && x[1].val == "quote")
end

function is_typedef(fst::FST)
    fst.typ in (Struct, Mutable, Primitive, Abstract)
end

function is_opcall(x::JuliaSyntax.GreenNode)
    if is_binary(x) || kind(x) == K"comparison" || is_chain(x) || unary_info(x) !== nothing
        return true
    end
    if kind(x) === K"parens" && haschildren(x)
        childs = children(x)
        idx = findfirst(
            n -> !JuliaSyntax.is_whitespace(kind(n)) && !(kind(n) in KSet"( )"),
            childs,
        )
        if isnothing(idx)
            return false
        end
        return is_opcall(childs[idx])
    end
    return false
end

function is_gen(x::JuliaSyntax.GreenNode)
    kind(x) in KSet"generator filter"
end

function is_gen(x::FST)
    x.typ in (Generator, Filter, Flatten)
end

function _callinfo(x::JuliaSyntax.GreenNode)
    if !haschildren(x)
        return 0, 0
    end
    k = kind(x)
    if k === K"call" && JuliaSyntax.is_infix_op_call(x)
        args = count(n -> !JuliaSyntax.is_whitespace(n), children(x))
        return div(args - 1, 2), div(args + 1, 2)
    elseif k === K"dotcall" && JuliaSyntax.is_infix_op_call(x)
        args = count(n -> !JuliaSyntax.is_whitespace(n), children(x))
        nops = div(args - 1, 3)
        return nops, nops + 1
    elseif k === K"op="
        return 1, 2
    elseif JuliaSyntax.is_operator(x) && haschildren(x)
        args = count(n -> !JuliaSyntax.is_whitespace(n), children(x))
        if args >= 3
            return div(args - 1, 2), div(args + 1, 2)
        end
    end
    n_operators = 0
    n_args = 0

    for c in children(x)
        if JuliaSyntax.is_whitespace(c) || is_punc(c)
            continue
        elseif haschildren(c) || (!haschildren(c) && !JuliaSyntax.is_operator(c))
            n_args += 1
        elseif k == K"dotcall" && JuliaSyntax.is_operator(c) && kind(c) == K"."
            continue
        elseif JuliaSyntax.is_operator(c)
            n_operators += 1
        end
    end
    return n_operators, n_args
end

"""
    unary_info(x::JuliaSyntax.GreenNode)::Union{Bool,Nothing}

Returns:

- `true` if `x` is a prefix unary operator application, such as `+x` or `<:x`

- `false` if `x` is a postfix unary operator application, such as `x'` or `x...`;

- `nothing` if `x` is not an application of a unary operator.
"""
function unary_info(x::JuliaSyntax.GreenNode)
    return if JuliaSyntax.is_prefix_op_call(x)
        # `+x`
        true
    elseif JuliaSyntax.is_postfix_op_call(x)
        # `x'` or `x'ᵀ`
        false
    elseif JuliaSyntax.is_operator(x) && haschildren(x)
        # `<:x` or `x...`
        childs_no_whitespace = filter(c -> !JuliaSyntax.is_whitespace(c), children(x))
        if length(childs_no_whitespace) != 2
            # Not unary at all
            nothing
        elseif JuliaSyntax.is_operator(childs_no_whitespace[1])
            # `<:x`
            true
        elseif JuliaSyntax.is_operator(childs_no_whitespace[end])
            # `x...`
            false
        else
            error("unreachable: unary operation node with no child operator")
        end
    else
        # Not unary at all
        nothing
    end
end

function is_binary(x)
    # TODO(penelopeysm): There is a bug in `is_binary` in that it returns true for
    # `<:(args...)` and `>:(args...)` as well. This is currently papered over in pretty() by
    # checking Shims.is_function_call(x) first, which will catch those cases, before
    # checking is_binary.
    #
    if !JuliaSyntax.is_infix_op_call(x) && !(JuliaSyntax.is_operator(x) && haschildren(x))
        # "Genuine" operators are caught by is_infix_op_call.
        #
        # The second predicate catches things like:
        #   - assignments `x = y`
        #   - field access `x.y`
        #   - logic operators `x && y` or `x || y`
        #   - membership `x in y`
        #   - anonymous functions `x -> y`
        return false
    end
    nops, nargs = _callinfo(x)
    return nops == 1 && nargs == 2
end

function is_chain(x::JuliaSyntax.GreenNode)
    if !(kind(x) in KSet"call dotcall")
        return false
    end
    nops, nargs = _callinfo(x)
    return nops > 1 && nargs > 2
end

function is_assignment(x::FST)
    if x.typ === Binary
        if isnothing(x.metadata)
            return false
        else
            return (x.metadata::Metadata).is_assignment
        end
    end

    if (
        x.typ === Const ||
        x.typ === Local ||
        x.typ === Global ||
        x.typ === Outer ||
        x.typ === MacroBlock
    ) && is_assignment(x[end])
        return true
    end

    return false
end

function is_assignment(t::JuliaSyntax.GreenNode)
    return JuliaSyntax.is_syntactic_assignment(t) && haschildren(t)
end
is_assignment(::Nothing) = false

function is_pairarrow(cst::JuliaSyntax.GreenNode)::Bool
    op = get_op(cst)
    isnothing(op) ? false : kind(op) === K"=>"
end

function is_function_or_macro_def(cst::JuliaSyntax.GreenNode)
    JuliaSyntax.is_leaf(cst) && return false
    k = kind(cst)
    # Long-form definition
    k in KSet"function macro" && return true
    # Short-form definition
    if JuliaSyntax.is_operator(cst) && k === K"="
        idx = findfirst(n -> !JuliaSyntax.is_whitespace(n), children(cst))
        if isnothing(idx)
            return false
        end
        return Shims.is_caller_in_function_def(cst[idx])
    end

    return false
end

function is_short_function_def(cst::JuliaSyntax.GreenNode)
    kind(cst) === K"function" &&
        JuliaSyntax.has_flags(cst, JuliaSyntax.SHORT_FORM_FUNCTION_FLAG)
end

function has_leading(n::JuliaSyntax.GreenNode, kinds::NTuple{N,JuliaSyntax.Kind}) where {N}
    return kind(n) in kinds ||
           (haschildren(n) && length(children(n)) > 0 && has_leading(n[1], kinds))
end

function has_trailing(n::JuliaSyntax.GreenNode, kinds::NTuple{N,JuliaSyntax.Kind}) where {N}
    return kind(n) in kinds ||
           (haschildren(n) && length(children(n)) > 0 && has_trailing(n[end], kinds))
end

function remove_empty_notcode(fst::FST)
    fst.typ in (Binary, Conditional, Comparison, Chain) || is_iterable(fst)
end

"""
    has_delimiters(cst::JuliaSyntax.GreenNode)

`cst` is assumed to be a single child node. Returns true if the node is of the syntactic
form `{...}, [...], or (...)`.
"""
function has_delimiters(cst::JuliaSyntax.GreenNode)
    kind(cst) in KSet"tuple vect braces bracescat comprehension parens"
end

function should_allow_nesting_call_args(args, disallow_single_arg_nesting::Bool)
    return length(args) > 0 && !(length(args) == 1 &&
             # If the argument has delimiters, it can itself be nested, so we
             # don't need to nest the call expression.
             (has_delimiters(args[1]) || disallow_single_arg_nesting))
end

function is_binaryop_nestable(::AbstractStyle, cst::JuliaSyntax.GreenNode)
    if (is_assignment(cst) || is_pairarrow(cst) || defines_function(cst)) &&
       haschildren(cst)
        childs = children(cst)
        idx = findlast(n -> !JuliaSyntax.is_whitespace(n), childs)::Int
        return !is_str_or_cmd(childs[idx])
    end
    true
end

function nest_rhs(cst::JuliaSyntax.GreenNode, style::AbstractStyle)::Bool
    # TODO(penelopeysm): I don't entirely understand this one here tbh: it seems quite
    # unprincipled for us to not use `is_block(cst, style)`
    if defines_function(cst) && haschildren(cst)
        for c in children(cst)
            if is_if(c) || kind(c) in KSet"do try for while let" && haschildren(c)
                return true
            end
            if style isa BlueStyle && is_chained_ternary(c)
                return true
            end
        end
    end
    false
end

function get_op(cst::JuliaSyntax.GreenNode)::Union{JuliaSyntax.GreenNode,Nothing}
    if JuliaSyntax.is_operator(cst)
        return cst
    end
    if (
        is_binary(cst) ||
        kind(cst) in KSet"comparison dotcall call" ||
        is_chain(cst) ||
        unary_info(cst) !== nothing
    ) && haschildren(cst)
        for c in children(cst)
            if kind(cst) === K"dotcall" && kind(c) === K"."
                continue
            elseif JuliaSyntax.is_operator(c) && !haschildren(c)
                return c
            end
        end
    end
    return nothing
end

function op_kind(cst::JuliaSyntax.GreenNode)::JuliaSyntax.Kind
    op = get_op(cst)
    isnothing(op) ? K"None" : kind(op)
end

function op_kind(fst::FST)::JuliaSyntax.Kind
    return isnothing(fst.metadata) ? K"None" : (fst.metadata::Metadata).op_kind
end

"""
    is_opaque_string(fst::FST)::Bool

Whether `fst` is a `StringN` node built from a non-triple-quoted multiline literal, whose
interior lines must be emitted verbatim (see the `is_opaque_string` field of `Metadata`).
"""
function is_opaque_string(fst::FST)::Bool
    return fst.typ === StringN &&
           !isnothing(fst.metadata) &&
           (fst.metadata::Metadata).is_opaque_string
end

# """
#     is_standalone_shortcircuit(cst::JuliaSyntax.GreenNode)
#
# Returns `true` if the `cst` is a short-circuit expression (uses `&&`, `||`)
# and is *standalone*, meaning it's not directly associated with another statement or
# expression.
#
# ### Examples
#
# ```julia
# # this IS a standalone short-circuit
# a && b
#
# # this IS NOT a standalone short-circuit
# if a && b
# end
#
# # this IS NOT a standalone short-circuit
# var = a && b
#
# # this IS NOT a standalone short-circuit
# @macro a && b
#
# # operation inside parenthesis IS NOT a standalone short-circuit
# # operation outside parenthesis IS a standalone short-circuit
# (a && b) && c
# ```
# """

"""
    eq_to_in_normalization!(fst::FST, always_for_in::Bool, for_in_replacement::String)
    eq_to_in_normalization!(fst::FST, always_for_in::Nothing, for_in_replacement::String)

Transforms

```julia
for i = iter body end

=>

for i in iter body end
```

AND

```julia
for i in 1:10 body end

=>

for i = 1:10 body end
```

`always_for_in=nothing` disables this normalization behavior.

- <https://github.com/JuliaEditorSupport/JuliaFormatter.jl/issues/34>
"""
function eq_to_in_normalization!(fst::FST, always_for_in::Bool, for_in_replacement::String)
    if fst.typ === Binary
        idx = findfirst(n -> n.typ === OPERATOR, fst.nodes::Vector)
        if isnothing(idx)
            return
        end
        op = fst[idx]

        # surround op with ws
        if for_in_replacement != "=" && fst[idx-1].typ !== WHITESPACE
            insert!(fst, idx, Whitespace(1))
            insert!(fst, idx + 2, Whitespace(1))
        end

        old_op_len = op.len
        if always_for_in
            op.val = for_in_replacement
            op.len = textwidth(op.val)
        elseif op.val == "=" && op_kind(fst[end]) !== K":"
            op.val = "in"
            op.len = textwidth(op.val)
        elseif op.val == "in" && op_kind(fst[end]) === K":"
            op.val = "="
            op.len = textwidth(op.val)
        end
        fst.len += op.len - old_op_len
        if !isnothing(fst.metadata)
            metadata = fst.metadata::Metadata
            opkind = JuliaSyntax.Kind(op.val)
            fst.metadata = Metadata(
                opkind,
                metadata.is_standalone_shortcircuit,
                metadata.is_expandable_shortcircuit,
                metadata.is_short_form_function,
                opkind === K"=",
                metadata.is_contractable_function,
                metadata.has_multiline_argument,
                metadata.is_opaque_string,
            )
        end
    elseif fst.typ === Block || fst.typ === Brackets || fst.typ === Filter
        past_if = false
        for n in fst.nodes::Vector
            if n.typ === KEYWORD && n.val == "if"
                # [x for x in xs if x in 1:length(ys)]
                # we do not want to convert the binary operations after an "if" keyword.
                past_if = true
            end
            if past_if
                break
            end
            eq_to_in_normalization!(n, always_for_in, for_in_replacement)
        end
    end
end
eq_to_in_normalization!(::FST, ::Nothing, ::String) = nothing

# Check if the caller of a call is in `list`
# Note that this also works for JuliaSyntax.GreenNode
function caller_in_list(fst::FST, list::Vector{String})
    if is_leaf(fst[1]) && (fst[1].val) in list
        return true
    elseif !is_leaf(fst[1]) && is_leaf(fst[1][1]) && (fst[1][1].val) in list
        return true
    end

    return false
end

function caller_in_list(caller::AbstractString, list::Vector{String})
    return caller in list
end

function is_str_or_cmd(t::JuliaSyntax.GreenNode)
    kind(t) in KSet"doc string cmdstring String CmdString"
end

function is_lazy_op(t::Union{JuliaSyntax.GreenNode,JuliaSyntax.Kind})
    kind(t) in KSet"|| &&"
end

function has_more_args_to_come(::Tuple{}, _, _)
    return false
end

"""
    has_more_args_to_come

Searches `childs[start_index:end]` for the first non-whitespace node and returns whether it
is not of kind `stop_kind`.

If the first non-whitespace node is of kind `stop_kind`, that implies that there are no more
arguments to process in the current argument list / indexing expression / etc.
"""
function has_more_args_to_come(
    childs::Vector{JuliaSyntax.GreenNode{T}},
    start_index::Int,
    stop_kind::JuliaSyntax.Kind,
) where {T}
    j = start_index
    while j <= length(childs)
        if !JuliaSyntax.is_whitespace(childs[j])
            return kind(childs[j]) !== stop_kind
        end
        j += 1
    end
    return false
end

function next_node_is(k::JuliaSyntax.Kind, nn::JuliaSyntax.GreenNode)
    kind(nn) === k || (haschildren(nn) && next_node_is(k, nn[1]))
end

function next_node_is(f::Function, nn::JuliaSyntax.GreenNode)
    f(nn) || (haschildren(nn) && next_node_is(f, nn[1]))
end

function ends_with_macro_or_global(fst::FST)
    # Detects constructs such as
    #    @macro foo
    #    arg = @macro foo
    #    arg => @macro foo
    #    () -> global x = true
    # which cause parse errors if a comma is inserted after them.
    return if fst.typ === MacroCall || fst.typ === MacroBlock || fst.typ === Global
        true
    elseif is_leaf(fst)
        false
    elseif fst.typ === Kw || fst.typ === Binary
        length(fst.nodes) > 0 && ends_with_macro_or_global(fst[end])
    else
        false
    end
end
function skip_trailing_comma(fst::FST)
    prev_node = fst[end]
    return if is_comma(prev_node) && fst.typ === TupleN && n_args(fst) == 1
        # e.g. `(x,)` -- removing the comma changes the meaning
        true
    elseif (
        prev_node.typ === Generator ||
        prev_node.typ === Filter ||
        prev_node.typ === Flatten ||
        prev_node.typ === SEMICOLON ||
        prev_node.typ === HASHEQCOMMENT ||
        # Things that end with macros can't have a trailing comma inserted after them
        # as that causes Julia to fail to parse.
        # https://github.com/JuliaEditorSupport/JuliaFormatter.jl/issues/1017
        ends_with_macro_or_global(prev_node)
    )
        true
    else
        false
    end
end

"""
    add_node!(
        t::FST,
        n::FST,
        s::State;
        join_lines::Bool = false,
        max_padding::Int = -1,
        override_join_lines_based_on_source::Bool = false,
    )

Appends `n` to `t`.

- `join_lines`: if `true`, `n` is placed on the same line as the previous node. If `false`
  (the default), a NEWLINE is inserted before `n` so it appears on the next line.

- `max_padding`: controls how `n`'s length contributes to `t.len`, if `n` is not the first
  node being added.

  - When negative (the default), `n`'s length is added: `t.len += length(n)`. This is
    appropriate for nodes that continue the current line (e.g. an argument in a call).

  - When non-negative, we calculate `npad = length(n) + max_padding` (i.e., the horizontal
    extent of `n` plus some specified padding) and set `t.len = max(t.len, npad)`. For
    example, in `p_if`, when adding the `if`/`elseif`/`else` keywords, we use `max_padding =
    0`. This means that that `t.len` extends only up to the end of the keyword. That's
    because the keyword starts a new line and we don't want it to inflate the length of the
    parent.

  For the first node that's added, it always contributes its full length to `t.len`
  regardless of the value of `max_padding`.

- `override_join_lines_based_on_source`: when the `join_lines_based_on_source` option is
  `true`, this flag overrides it so that `n` is added as if `join_lines_based_on_source`
  were `false`.
"""
function add_node!(
    t::FST,
    n::FST,
    s::State;
    join_lines::Bool = false,
    max_padding::Int = -1,
    override_join_lines_based_on_source::Bool = false,
)
    tnodes = t.nodes::Vector{FST}

    if n.typ === NONE
        if length(tnodes::Vector{FST}) == 0
            t.startline = n.startline
            t.endline = n.endline
        end
        return
    end

    if n.typ === TRAILINGCOMMA
        en = (tnodes::Vector{FST})[end]
        if skip_trailing_comma(t)
            # do not insert trailing comma
            false
        elseif s.opts.trailing_comma === nothing
            # preserve original source code
            false
        elseif !s.opts.trailing_comma::Bool
            # remove preexisting comma in FST
            if is_comma(en)
                t[end] = Whitespace(0)
            end
        elseif is_comma(en)
            t[end] = n
        elseif en.typ === Parameters && length(en.nodes) > 0 && is_comma(en[end])
            en[end] = n
        elseif en.typ === Parameters && length(en.nodes) > 0 && en[end].typ === SEMICOLON
            # do nothing
        else
            t.len += length(n)
            n.startline = t.endline
            n.endline = t.endline
            push!(tnodes::Vector{FST}, n)
        end
        return
    elseif n.typ === NOTCODE
        n.indent = s.indent
        push!(tnodes::Vector{FST}, n)
        return
    elseif n.typ === INLINECOMMENT
        push!(tnodes::Vector{FST}, n)
        return
    elseif is_custom_leaf(n) && n.typ !== HASHEQCOMMENT
        t.len += length(n)
        # Treat the node as extending the line of the previous node (...?)
        n.startline = t.endline
        n.endline = t.endline
        push!(tnodes::Vector{FST}, n)
        return
    end

    if n.typ === Block && length(n) == 0
        push!(tnodes::Vector{FST}, n)
        return
    elseif s.opts.import_to_using && n.typ === Import && can_transform_syntax(s, true)
        usings = import_to_usings(n, s)
        if length(usings) > 0
            for (i, nn) in enumerate(usings)
                # The first using replaces the original import, so it should
                # inherit the caller's join_lines (e.g. stay on the same line
                # in `@eval import Foo` -> `@eval using Foo: Foo`).
                # Subsequent usings always start on a new line.
                jl = i == 1 ? join_lines : false
                if !jl && length(tnodes) > 0 && (tnodes[end]::FST).typ === WHITESPACE
                    tnodes[end]::FST = Whitespace(0)
                end
                add_node!(t, nn, s; join_lines = jl, max_padding = 0)
            end
            return
        end
    elseif n.typ === Binary && n[1].typ === Binary && n[1][end].typ === Where
        # normalize FST representation for Where
        binaryop_to_whereop!(n, s)
    end

    # Handle whitespace around HASHEQCOMMENT nodes.
    if n.typ === HASHEQCOMMENT && !isempty(tnodes)
        if t.endline >= n.startline
            # The comment is on the same line as the previous node in the source
            # (e.g. `a #= hi =#`). Force join to keep it there.
            join_lines = true
        elseif max_padding == -1
            # The caller didn't explicitly request a new line (max_padding = -1 is the
            # default, used by e.g. add_hasheq_comment! and the hcat handler). Force
            # join_lines = true to preserve the old behavior for those call sites.
            join_lines = true
        end
        # Add a space before a `#= =#` comment to avoid it being
        # glued to the previous node when printed, but only when the comment
        # stays on the same line (join_lines = true).
        #
        # TODO(penelopeysm): The PLACEHOLDER check catches cases where there is a
        # Placeholder(1) before the comment, which can be turned into a Whitespace(1).
        # I'm not sure if this check is therefore overly broad since it also catches
        # Placeholder(0) nodes.
        if join_lines
            nt = (tnodes[end]::FST).typ
            if nt !== WHITESPACE &&
               nt !== NEWLINE &&
               nt !== PLACEHOLDER &&
               !is_opener(tnodes[end])
                add_node!(t, Whitespace(1), s)
            end
        end
    end

    if length(tnodes::Vector{FST}) == 0
        t.startline = n.startline
        t.endline = n.endline
        t.len += length(n)
        t.line_offset = n.line_offset
        push!(tnodes, n)
        return
    end

    # if `max_padding` >= 0 `n` should appear on the next line
    # even if it's contrary to the original source.
    if s.opts.join_lines_based_on_source &&
       !override_join_lines_based_on_source &&
       max_padding == -1 &&
       !(
           n.typ === SEMICOLON ||
           is_comma(n) ||
           is_block(t) ||
           t.typ === FunctionN ||
           t.typ === Macro ||
           is_typedef(t) ||
           t.typ === ModuleN ||
           t.typ === BareModule ||
           is_end(n)
       )
        # join based on position in original file
        join_lines = t.endline == n.startline
    end

    # Keep a space after an inline `#= =#` comment when the following code stays on the
    # same line (e.g. `f(x, #= c =# z)`), instead of gluing them together.
    if join_lines &&
       (tnodes[end]::FST).typ === HASHEQCOMMENT &&
       !is_closer(n) &&
       !is_comma(n) &&
       n.typ !== SEMICOLON
        add_node!(t, Placeholder(1), s)
    end

    if !is_prev_newline(tnodes[end]::FST)
        current_line = t.endline
        notcode_startline = current_line + 1
        notcode_endline = n.startline - 1
        nt = (tnodes[end]::FST).typ

        if notcode_startline <= notcode_endline
            # If there are comments in between node elements
            # nesting is forced in an effort to preserve them.

            rm_block_nl =
                s.opts.remove_extra_newlines &&
                t.typ !== ModuleN &&
                (n.typ === Block || is_end(n))

            # Force nesting
            nest = true
            # Unless it's not worth nesting (e.g., inside a function call, or binary op)
            if remove_empty_notcode(t) || rm_block_nl
                nest = false
                # ... Unless there are comments, in which case we can't nest
                for l in notcode_startline:notcode_endline
                    if hascomment(s.doc, l)
                        nest = true
                        break
                    end
                end
            end
            if nest || hascomment(s.doc, current_line)
                t.nest_behavior = AlwaysNest
            end

            # If the previous node type is WHITESPACE - reset it.
            # This fixes cases similar to the one shown in issue #51.
            if nt === WHITESPACE
                tnodes[end]::FST = Whitespace(0)
            end

            if hascomment(s.doc, current_line)
                add_node!(t, InlineComment(current_line), s)
            end

            # A newline is mandatory if we're about to emit a Notcode node or an inline
            # comment (both of which occupy the rest of the line), and it's what the caller
            # asked for if `join_lines` is false. Otherwise the caller wants `n` on the same
            # line as the previous node and the only thing in between is a blank line that
            # we are dropping anyway, so there is nothing to separate them with. For
            # example, YASStyle adds the closer of `[\n a,\n b,\n\n]` with
            # `override_join_lines_based_on_source = true` -- i.e. 'always glue `]` to the
            # last argument' -- and breaking the line here would strand it as `[a,\n b\n ]`.
            force_newline = !join_lines || nest || hascomment(s.doc, current_line)

            if nt !== PLACEHOLDER && force_newline
                add_node!(t, Newline(; nest_behavior = AlwaysNest), s)
            elseif hascomment(s.doc, current_line) && nt === PLACEHOLDER
                # swap PLACEHOLDER (will be NEWLINE) with INLINECOMMENT node
                idx = length(tnodes::Vector{FST})
                tnodes[idx-1], tnodes[idx] = tnodes[idx], tnodes[idx-1]
            elseif !join_lines
                # We know that `n` belongs on its own line, but we are not adding a Notcode
                # node (which would come with its own NEWLINE), so unless we do something
                # here the only thing separating `n` from the previous node is the
                # PLACEHOLDER. That relies on the nesting pass later converting that
                # PLACEHOLDER into a NEWLINE, which is not guaranteed: with
                # `join_lines_based_on_source` (MinimalStyle, YAS, SciML) placeholders are
                # only converted when the margin is exceeded (see `nest_if_over_margin!`),
                # so the placeholder survives and `n` gets glued onto the previous line.
                # Emit the NEWLINE explicitly -- exactly as the `!join_lines` branch below
                # does when there is no blank line to divert us into this branch.
                #
                # Without this, the blank lines in
                #
                #     [               f(              [a
                #         a,              a,
                #         b,          )               b]
                #                                     ^ two-element vcat
                #     ]
                #
                # respectively produce `[\n a,\n b,]`, `f(\n a,)` and `[a b]` (a 1x2 hcat!),
                # instead of just being dropped. Likewise
                #
                #     [a
                #         []
                #                    <- blank line
                #     if b
                #         c
                #     end
                #     ]
                #
                # glues `[]` and `if b` together.
                add_node!(t, Newline(; nest_behavior = AlwaysNest), s)
            end

            if nest
                add_node!(t, Notcode(notcode_startline, notcode_endline), s)
                add_node!(t, Newline(; nest_behavior = AlwaysNest), s)
            end
        elseif !join_lines
            if hascomment(s.doc, current_line) && current_line != n.startline
                add_node!(t, InlineComment(current_line), s)
            end
            add_node!(t, Newline(; nest_behavior = AlwaysNest), s)
        elseif nt === PLACEHOLDER &&
               current_line != n.startline &&
               hascomment(s.doc, current_line)
            t.nest_behavior = AlwaysNest
            add_node!(t, InlineComment(current_line), s)
            # swap PLACEHOLDER (will be NEWLINE) with INLINECOMMENT node
            idx = length(tnodes)
            tnodes[idx-1], tnodes[idx] = tnodes[idx], tnodes[idx-1]
        elseif hascomment(s.doc, current_line) && current_line != n.startline
            if nt === WHITESPACE
                # Avoid printing excess whitespace before the comment, since
                # print_inlinecomment will handle the spacing before the comment.
                tnodes[end] = Whitespace(0)
            end
            add_node!(t, InlineComment(current_line), s)
            add_node!(t, Newline(; nest_behavior = AlwaysNest), s)
        end
    end

    if n.startline < t.startline || t.startline == 0
        t.startline = n.startline
    end
    if n.endline > t.endline || t.endline == 0
        t.endline = n.endline
    end

    if !join_lines && is_end(n)
        # end keyword isn't useful w.r.t margin lengths
    elseif t.typ === StringN
        if !s.opts.v2_stable_multiline_strings
            # The length of this node is the length of the longest string. The length of the
            # string is only considered "in the positive" when it's past the hits the
            # initial """ offset, i.e. `t.indent`.
            t.len = max(t.len, n.indent + length(n) - t.indent)
        else
            # Only the first line (the opening `"""`) contributes to the parent's line
            # width. Note that the first line will be the first node that's added to the
            # StringN FST, so its width will be added in the part above which checks
            #
            #    if length(tnodes::Vector{FST}) == 0 ... end
            #
            # Using only the first-line width makes `len` independent of the string's
            # indentation context, which prevents idempotence bugs where shifting the string
            # changes its `len`, which changes ancestor nesting decisions.
            false
        end
    elseif is_multiline(n) ||
           (!isnothing(t.metadata) && (t.metadata::Metadata).has_multiline_argument)
        if isnothing(t.metadata)
            t.metadata = Metadata(K"None", false, false, false, false, true, true, false)
        else
            metadata = t.metadata::Metadata
            t.metadata = Metadata(
                metadata.op_kind,
                metadata.is_standalone_shortcircuit,
                metadata.is_expandable_shortcircuit,
                metadata.is_short_form_function,
                metadata.is_assignment,
                metadata.is_contractable_function,
                true,
                metadata.is_opaque_string,
            )
        end
        if is_iterable(t) && n_args(t) > 1
            t.nest_behavior = AlwaysNest
        end
        t.len += length(n)
    elseif max_padding >= 0
        t.len = max(t.len, length(n) + max_padding)
    else
        t.len += length(n)
    end

    if n.typ === Parameters
        if n.nest_behavior === AlwaysNest
            t.nest_behavior = n.nest_behavior
        end
        # no args before kwargs
        placeholder_ind = findfirst(n -> n.typ === PLACEHOLDER, tnodes)
        if placeholder_ind == length(tnodes)
            t[placeholder_ind] = Whitespace(0)
        end
        for nn in n.nodes
            push!(tnodes, nn)
            if n.startline < t.startline || t.startline == 0
                t.startline = n.startline
            end
            if n.endline > t.endline || t.endline == 0
                t.endline = n.endline
            end
        end
        return
    end

    push!(tnodes, n)
    return
end
