"""
    ParseState(stream::ParseStream)

ParseState is an internal data structure wrapping `ParseStream` to carry parser
context as we recursively descend into the parse tree. For example, normally
`x -y` means `(x) - (y)`, but when parsing matrix literals we're in
`space_sensitive` mode, and `[x -y]` means [(x) (-y)].
"""
struct ParseState
    stream::ParseStream

    # Disable range colon for parsing ternary conditional operator
    range_colon_enabled::Bool
    # In space-sensitive mode "x -y" is 2 expressions, not a subtraction
    space_sensitive::Bool
    # Seeing `for` stops parsing macro arguments and makes a generator
    for_generator::Bool
    # Treat 'end' like a normal symbol instead of a reserved word
    end_symbol::Bool
    # Treat newline like ordinary whitespace instead of as a potential separator
    whitespace_newline::Bool
    # Enable parsing `where` with high precedence
    where_enabled::Bool
end

# Normal context
function ParseState(stream::ParseStream)
    ParseState(stream, true, false, false, false, false, true)
end

function ParseState(ps::ParseState; range_colon_enabled=nothing,
                    space_sensitive=nothing, for_generator=nothing,
                    end_symbol=nothing, whitespace_newline=nothing,
                    where_enabled=nothing)
    ParseState(ps.stream,
        range_colon_enabled === nothing ? ps.range_colon_enabled : range_colon_enabled,
        space_sensitive === nothing ? ps.space_sensitive : space_sensitive,
        for_generator === nothing ? ps.for_generator : for_generator,
        end_symbol === nothing ? ps.end_symbol : end_symbol,
        whitespace_newline === nothing ? ps.whitespace_newline : whitespace_newline,
        where_enabled === nothing ? ps.where_enabled : where_enabled)
end

# Functions to change parse state

function normal_context(ps::ParseState)
    ParseState(ps;
               range_colon_enabled=true,
               space_sensitive=false,
               where_enabled=true,
               for_generator=false,
               end_symbol=false,
               whitespace_newline=false)
end

function with_space_sensitive(ps::ParseState)
    ParseState(ps,
               space_sensitive=true,
               whitespace_newline=false)
end

# Convenient wrappers for ParseStream

function Base.peek(ps::ParseState, n=1; skip_newlines=nothing)
    skip_nl = isnothing(skip_newlines) ? ps.whitespace_newline : skip_newlines
    peek(ps.stream, n; skip_newlines=skip_nl)
end

function peek_token(ps::ParseState, n=1; skip_newlines=nothing)
    skip_nl = isnothing(skip_newlines) ? ps.whitespace_newline : skip_newlines
    peek_token(ps.stream, n, skip_newlines=skip_nl)
end

function peek_full_token(ps::ParseState, n=1; skip_newlines=nothing, kws...)
    skip_nl = isnothing(skip_newlines) ? ps.whitespace_newline : skip_newlines
    peek_full_token(ps.stream, n; skip_newlines=skip_nl, kws...)
end

function peek_behind(ps::ParseState, args...; kws...)
    peek_behind(ps.stream, args...; kws...)
end

function peek_behind_pos(ps::ParseState, args...; kws...)
    peek_behind_pos(ps.stream, args...; kws...)
end

function bump(ps::ParseState, flags=EMPTY_FLAGS; skip_newlines=nothing, kws...)
    skip_nl = isnothing(skip_newlines) ? ps.whitespace_newline : skip_newlines
    bump(ps.stream, flags; skip_newlines=skip_nl, kws...)
end

function bump_trivia(ps::ParseState, args...; kws...)
    bump_trivia(ps.stream, args...; kws...)
end

function bump_invisible(ps::ParseState, args...; kws...)
    bump_invisible(ps.stream, args...; kws...)
end

function bump_glue(ps::ParseState, args...; kws...)
    bump_glue(ps.stream, args...; kws...)
end

function reset_node!(ps::ParseState, args...; kws...)
    reset_node!(ps.stream, args...; kws...)
end

function steal_token_bytes!(ps::ParseState, args...)
    steal_token_bytes!(ps.stream, args...)
end

function Base.position(ps::ParseState, args...)
    position(ps.stream, args...)
end

function emit(ps::ParseState, args...; kws...)
    emit(ps.stream, args...; kws...)
end

function emit_diagnostic(ps::ParseState, args...; kws...)
    emit_diagnostic(ps.stream, args...; kws...)
end

function unsafe_textbuf(ps::ParseState)
    unsafe_textbuf(ps.stream)
end

function first_child_position(ps::ParseState, pos::ParseStreamPosition)
    first_child_position(ps.stream, pos)
end

function last_child_position(ps::ParseState, pos::ParseStreamPosition)
    last_child_position(ps.stream, pos)
end
#-------------------------------------------------------------------------------
# Parser Utils

# Bump an expected closing token.  If not found, discard unexpected tokens
# until we find it or another closing token.
#
# Crude recovery heuristic: bump any tokens which aren't block or bracket
# closing tokens.
function bump_closing_token(ps, closing_kind, alternative_closer_hint=nothing)
    # todo: Refactor with recover() ?
    if peek(ps) == closing_kind
        bump_trivia(ps)
        bump(ps, TRIVIA_FLAG)
        return
    end
    errmsg = "Expected `$(untokenize(closing_kind))`"
    if !isnothing(alternative_closer_hint)
        errmsg *= alternative_closer_hint
    end
    # We didn't find the closing token. Read ahead in the stream
    mark = position(ps)
    emit_diagnostic(ps, mark, mark, error=errmsg)
    while true
        k = peek(ps)
        if is_closing_token(ps, k) && !(k in KSet", ;")
            break
        end
        bump(ps)
    end
    # mark as trivia => ignore in AST.
    emit(ps, mark, K"error", TRIVIA_FLAG)
    if peek(ps) == closing_kind
        bump(ps, TRIVIA_FLAG)
    end
end

# Read tokens until we find an expected closing token.
# Bump the big pile of resulting tokens as a single nontrivia error token
function recover(is_closer::Function, ps, flags=EMPTY_FLAGS; mark = position(ps), error="unexpected tokens")
    while true
        k = peek(ps)
        if k == K"EndMarker"
            bump_invisible(ps, K"error", TRIVIA_FLAG,
                           error="premature end of input")
            break
        elseif is_closer(ps, k)
            break
        end
        bump(ps)
    end
    emit(ps, mark, K"error", flags, error=error)
end

@noinline function min_supported_version_err(ps, mark, message, min_ver)
    major, minor = ps.stream.version
    msg = "$message not supported in Julia version $major.$minor < $(min_ver.major).$(min_ver.minor)"
    emit(ps, mark, K"error", error=msg)
end

# Emit an error if the version is less than `min_ver`
function min_supported_version(min_ver, ps, mark, message)
    if ps.stream.version < (min_ver.major, min_ver.minor)
        min_supported_version_err(ps, mark, message, min_ver)
    end
end

# flisp: disallow-space
function bump_disallowed_space(ps)
    if preceding_whitespace(peek_token(ps))
        bump_trivia(ps, TRIVIA_FLAG, skip_newlines=false,
                    error="whitespace is not allowed here")
    end
end

function bump_semicolon_trivia(ps)
    while peek(ps) in KSet"; NewlineWs"
        bump(ps, TRIVIA_FLAG)
    end
end

#-------------------------------------------------------------------------------
# Parsing-specific predicates on tokens/kinds
#
# All these take either a raw kind or a token.

function is_closing_token(ps::ParseState, k)
    k = kind(k)
    return k in KSet"else elseif catch finally , ) ] } ; EndMarker" ||
        (k == K"end" && !ps.end_symbol)
end

function is_block_continuation_keyword(ps::ParseState, k)
    is_block_continuation_keyword(k) && !(ps.end_symbol && k == K"end")
end

function is_closer_or_newline(ps::ParseState, k)
    is_closing_token(ps,k) || k == K"NewlineWs"
end

# Closer for "Non-delimited reserved words" like `return` which aren't closed with an `end`
function is_nd_resword_closer(ps::ParseState, k)
    return is_closer_or_newline(ps, k) ||
           (k == K":"   && !ps.range_colon_enabled)
end

function is_initial_reserved_word(ps::ParseState, k)
    k = kind(k)
    is_iresword = k in KSet"begin while if for try return break continue function
                            macro quote let local global const do struct typegroup module
                            baremodule using import export"
    # `begin` means firstindex(a) inside a[...]
    if k == K"begin" && ps.end_symbol
        return false
    end
    # `typegroup` is only a keyword in Julia >= 1.14
    # N.B: In some cases, we parse as typegroup anyway for error recovery - see below
    if k == K"typegroup" && ps.stream.version < (1, 14)
        return false
    end
    return is_iresword
end

function is_reserved_word(k)
    k = kind(k)
    is_keyword(k) && !is_contextual_keyword(k)
end

# Return true if the next word (or word pair) is reserved, introducing a
# syntactic structure.
function peek_initial_reserved_words(ps::ParseState)
    k = peek(ps)
    if is_initial_reserved_word(ps, k)
        return true
    elseif is_contextual_keyword(k)
        k2 = peek(ps, 2, skip_newlines=false)
        return (k == K"mutable"   && k2 == K"struct") ||
               (k == K"primitive" && k2 == K"type")   ||
               (k == K"abstract"  && k2 == K"type")
    elseif k == K"typegroup" && ps.stream.version < (1, 14)
        # On older versions, typegroup is an identifier. But if followed by
        # a type definition keyword (which would be a syntax error in old
        # Julia due to juxtaposition), parse as typegroup for error recovery.
        k2 = peek(ps, 2, skip_newlines=false)
        return k2 in KSet"struct mutable abstract primitive @ \" \"\"\""
    else
        return false
    end
end

function is_block_form(k)
    kind(k) in KSet"block quote if for while let function macro
                    abstract primitive struct typegroup try module"
end

function is_syntactic_unary_op(k)
    kind(k) in KSet"$ & ::"
end

function is_type_operator(t, isdot)
    kind(t) in KSet"<: >:" && !isdot
end

function is_unary_op(t, isdot)
    k = kind(t)
    (k in KSet"<: >:" && !isdot) ||
    k in KSet"+ - ! ~ ¬ √ ∛ ∜ ⋆ ± ∓" # dotop allowed
end

# Operators that are both unary and binary
function is_both_unary_and_binary(t, isdot)
    k = kind(t)
    k in KSet"+ - ⋆ ± ∓" || (k in KSet"$ & ~" && !isdot)
end

function is_string_macro_suffix(k)
    k == K"Identifier" || is_keyword(k) || is_word_operator(k) || is_number(k)
end

# flisp: invalid-identifier?
function is_valid_identifier(k)
    k = kind(k)
    !(is_syntactic_operator(k) || k in KSet"? .'")
end

# The expression is a call after stripping `where` and `::`
function was_eventually_call(ps::ParseState)
    stream = ps.stream
    p = peek_behind_pos(ps)
    while true
        b = peek_behind(stream, p)
        if b.kind == K"call"
            return true
        elseif b.kind == K"where" || b.kind == K"parens" ||
                (b.kind == K"::" && has_flags(b.flags, INFIX_FLAG))
            if b.kind == K"::"
                p_last = last_child_position(ps, p)
                if p == p_last
                    return false
                end
            end
            p = first_child_position(ps, p)
        else
            return false
        end
    end
end


#-------------------------------------------------------------------------------
# Parser
#
# The definitions and top-level comments here were copied to match the
# structure of Julia's previous flisp-based parser to make both codebases
# mutually understandable and make porting changes simple.
#
# The `parse_*` functions are listed here roughly in order of increasing
# precedence (lowest to highest binding power). A few helper functions are
# interspersed.

# parse left-to-right binary operator
# produces structures like (+ (+ (+ 2 3) 4) 5)
#
# flisp: parse-LtoR
function parse_LtoR(ps::ParseState, down, is_op)
    mark = position(ps)
    down(ps)
    while true
        isdot, isassign, tk = peek_dotted_op_token(ps)
        (is_op(tk) && !isassign) || break
        isdot && bump(ps, TRIVIA_FLAG) # TODO: NOTATION_FLAG
        bump(ps, remap_kind=K"Identifier")
        down(ps)
        emit(ps, mark, isdot ? K"dotcall" : K"call", INFIX_FLAG)
    end
end

# parse right-to-left binary operator
# produces structures like (=> a (=> b (=> c d)))
#
# flisp: parse-RtoL
function parse_RtoL(ps::ParseState, down, is_op, self)
    mark = position(ps)
    down(ps)
    isdot, isassign, tk = peek_dotted_op_token(ps)
    if is_op(tk) && !isassign
        bump_dotted(ps, isdot, tk, remap_kind=K"Identifier")
        self(ps)
        emit(ps, mark, isdot ? K"dotcall" : K"call", INFIX_FLAG)
    end
end

# parse block-like structures
#
# `delimiters` are a set of token kinds acting as delimiters; `closing_tokens`
# stop the parsing.
#
# Returns true if the block was nontrivial and a node needs to be emitted by
# the caller.
#
# flisp: parse-Nary
function parse_Nary(ps::ParseState, down, delimiters, closing_tokens)
    bump_trivia(ps)
    k = peek(ps)
    if k in closing_tokens
        return true
    end
    n_delims = 0
    if k in delimiters
        # allow leading delimiters
        # ; a  ==>  (block a)
    else
        # a ; b  ==>  (block a b)
        down(ps)
    end
    while peek(ps) in delimiters
        bump(ps, TRIVIA_FLAG)
        n_delims += 1
        k = peek(ps)
        if k == K"EndMarker" || k in closing_tokens
            break
        elseif k in delimiters
            # ignore empty delimited sections
            # a;;;b  ==>  (block a b)
            continue
        end
        down(ps)
    end
    return n_delims != 0
end

# Parse a sequence of top level statements separated by newlines, all wrapped
# in a toplevel node.
#
#   a \n b ==>  (toplevel a b)
#
# Note that parse_stmts can also emit toplevel nodes for semicolon-separated
# statements, so it's possible for these to be nested one level deep.
#
#   a;b \n c;d  ==>  (toplevel (toplevel a b) (toplevel c d))
function parse_toplevel(ps::ParseState)
    mark = position(ps)
    while true
        if peek(ps, skip_newlines=true) == K"EndMarker"
            # Allow end of input if there is nothing left but whitespace
            # a \n \n ==> (toplevel a)
            # Empty files
            #  ==> (toplevel)
            bump_trivia(ps)
            break
        else
            bump_trivia(ps)
            parse_stmts(ps)
        end
    end
    emit(ps, mark, K"toplevel")
    nothing
end

# Parse a newline or semicolon-delimited list of expressions.
# Repeated delimiters are allowed but ignored
# a;b;c     ==>  (block a b c)
# a;;;b;;   ==>  (block a b)
# ;a        ==>  (block a)
# \n a      ==>  (block a)
# a \n b    ==>  (block a b)
#
# flisp: parse-block
function parse_block(ps::ParseState, down=parse_eq, mark=position(ps))
    parse_block_inner(ps::ParseState, down)
    emit(ps, mark, K"block")
end

# Parse a block, but leave emitting the block up to the caller.
function parse_block_inner(ps::ParseState, down::F) where {F <: Function}
    parse_Nary(ps, down, KSet"NewlineWs ;", KSet"end else elseif catch finally")
end

# ";" at the top level produces a sequence of top level expressions
#
# a;b;c   ==>  (toplevel a b c)
# a;;;b;; ==>  (toplevel a b)
# "x" a ; "y" b ==>  (toplevel (doc (string "x") a) (doc (string "y") b))
#
# flisp: parse-stmts
function parse_stmts(ps::ParseState)
    mark = position(ps)
    do_emit = parse_Nary(ps, parse_public, (K";",), (K"NewlineWs",))
    # check for unparsed junk after an expression
    junk_mark = position(ps)
    while peek(ps) ∉ KSet"EndMarker NewlineWs"
        # Error recovery
        bump(ps)
    end
    if junk_mark != position(ps)
        # x y  ==>  x (error-t y)
        emit(ps, junk_mark, K"error", TRIVIA_FLAG,
             error="extra tokens after end of expression")
    end
    if do_emit
        emit(ps, mark, K"toplevel", TOPLEVEL_SEMICOLONS_FLAG)
    end
end

# Parse `public foo, bar`
#
# We *only* call this from toplevel contexts (file and module level) for
# compatibility. In the future we should probably make public a full fledged
# keyword like `export`.
function parse_public(ps::ParseState)
    if ps.stream.version >= (1, 11) && peek(ps) == K"public"
        if peek(ps, 2) ∈ KSet"( = ["
            # this branch is for compatibility with use of public as a non-keyword.
            # it should be removed at some point.
            emit_diagnostic(ps, warning="using public as an identifier is deprecated")
        else
            return parse_resword(ps)
        end
    end
    parse_docstring(ps)
end

# Parse docstrings attached by a space or single newline
#
# flisp: parse-docstring
function parse_docstring(ps::ParseState, down=parse_eq)
    mark = position(ps)
    down(ps)
    if peek_behind(ps).kind == K"string"
        is_doc = true
        k = peek(ps)
        if is_closing_token(ps, k)
            # "notdoc" ] ==> (string "notdoc")
            is_doc = false
        elseif k == K"NewlineWs"
            k2 = peek(ps, 2)
            if is_closing_token(ps, k2) || k2 == K"NewlineWs"
                # "notdoc" \n]      ==> (string "notdoc")
                # "notdoc" \n\n foo ==> (string "notdoc")
                is_doc = false
            else
                # Allow a single newline
                # "doc" \n foo ==> (doc (string "doc") foo)
                bump(ps, TRIVIA_FLAG) # NewlineWs
            end
        else
            # "doc" foo    ==> (doc (string "doc") foo)
            # "doc $x" foo ==> (doc (string "doc " x) foo)
            # Allow docstrings with embedded trailing whitespace trivia
            # """\n doc\n """ foo ==> (doc (string-s "doc\n") foo)
        end
        if is_doc
            down(ps)
            emit(ps, mark, K"doc")
        end
    end
end

# Parse assignments with comma separated lists on each side
# a = b         ==>  (= a b)
# a .= b        ==>  (.= a b)
# a += b        ==>  (+= a b)
# a .+= b       ==>  (.+= a b)
# a, b = c, d   ==>  (= (tuple a b) (tuple c d))
# x, = xs       ==>  (= (tuple x) xs)
#
# flisp: parse-eq
function parse_eq(ps::ParseState)
    parse_assignment(ps, parse_comma)
end

# parse_eq_star is used where commas are special, for example in an argument list
#
# flisp: parse-eq*
function parse_eq_star(ps::ParseState)
    k = peek(ps)
    k2 = peek(ps,2)
    if (is_literal(k) || k == K"Identifier") && k2 in KSet", ) } ]"
        # optimization: skip checking the whole precedence stack if we have a
        # simple token followed by a common closing token
        bump(ps)
    else
        parse_assignment(ps, parse_pair)
    end
end

# a = b  ==>  (= a b)
#
# flisp: parse-assignment
function parse_assignment(ps::ParseState, down)
    mark = position(ps)
    down(ps)
    parse_assignment_with_initial_ex(ps, mark, down)
end

function parse_assignment_with_initial_ex(ps::ParseState, mark, down::T) where {T} # where => specialize on `down`
    isdot, is_compound_assignment, t = peek_dotted_op_token(ps)
    k = kind(t)

    if !is_prec_assignment(t) && !is_compound_assignment
        return
    end

    if k == K"~"
        if ps.space_sensitive && preceding_whitespace(t) && !preceding_whitespace(peek_token(ps, 2))
            # Unary ~ in space sensitive context is not assignment precedence
            # [a ~b]  ==>  (hcat a (call-pre ~ b))
            return
        end
        # ~ is currently the only assignment-precedence operator which is parsed as a call.
        # TODO: Make the other non-syntactic assignments such as `≔ ⩴ ≕` into calls as well?
        # a ~ b      ==>  (call-i a ~ b)
        # a .~ b     ==>  (dotcall-i a ~ b)
        # [a ~ b c]  ==>  (hcat (call-i a ~ b) c)
        # [a~b]      ==>  (vect (call-i a ~ b))
        bump_dotted(ps, isdot, t, remap_kind=K"Identifier")
        bump_trivia(ps)
        parse_assignment(ps, down)
        emit(ps, mark, isdot ? K"dotcall" : K"call", INFIX_FLAG)
    else
        # f() = 1  ==>  (function-= (call f) 1)
        # f() .= 1 ==>  (.= (call f) 1)
        # a .= b   ==>  (.= a b)
        is_short_form_func = k == K"=" && !isdot && was_eventually_call(ps)
        if is_compound_assignment
            # x += y   ==>  (op= x + y)
            # x .+= y  ==>  (.op= x + y)
            bump_trivia(ps)
            bump_dotted(ps, isdot, t, remap_kind=K"Identifier")
            bump(ps, TRIVIA_FLAG) # bump the =
            k = K"op=" # Set k for the emit below
        else
            bump_dotted(ps, isdot, t, TRIVIA_FLAG)
        end
        bump_trivia(ps)
        # Syntax Edition TODO: We'd like to call `down` here when
        # is_short_form_func is true, to prevent `f() = 1 = 2` from parsing.
        parse_assignment(ps, down)
        emit(ps, mark,
             is_short_form_func ? K"function" : (isdot ? dotted(k) : k),
             is_short_form_func ? SHORT_FORM_FUNCTION_FLAG : flags(t))
    end
end

# parse_comma is needed for commas outside parens, for example a = b,c
#
# flisp: parse-comma
function parse_comma(ps::ParseState, do_emit=true)
    mark = position(ps)
    n_commas = 0
    parse_pair(ps)
    while true
        if peek(ps) != K","
            if do_emit && n_commas >= 1
                emit(ps, mark, K"tuple")
            end
            return n_commas
        end
        bump(ps, TRIVIA_FLAG)
        n_commas += 1
        if kind(peek_token(ps)) == K"="
            # Allow trailing comma before `=`
            # x, = xs  ==>  (tuple x)
            continue
        end
        parse_pair(ps)
    end
end

# flisp: parse-pair
# a => b  ==>  (call-i a => b)
# a .=> b ==>  (dotcall-i a => b)
function parse_pair(ps::ParseState)
    parse_RtoL(ps, parse_cond, is_prec_pair, parse_pair)
end

# Parse short form conditional expression
# a ? b : c ==> (? a b c)
#
# flisp: parse-cond
function parse_cond(ps::ParseState)
    mark = position(ps)
    parse_arrow(ps)
    t = peek_token(ps)
    if kind(t) != K"?"
        return
    end
    if !preceding_whitespace(t)
        # a? b : c  => (? a (error-t) b c)
        bump_invisible(ps, K"error", TRIVIA_FLAG,
                       error="space required before `?` operator")
    end
    bump(ps, TRIVIA_FLAG) # ?
    t = peek_token(ps)
    if !preceding_whitespace(t)
        # a ?b : c  ==>  (? a (error-t) b c)
        bump_invisible(ps, K"error", TRIVIA_FLAG,
                       error="space required after `?` operator")
    end
    parse_eq_star(ParseState(ps, range_colon_enabled=false))
    t = peek_token(ps)
    if !preceding_whitespace(t)
        # a ? b: c  ==>  (? a b (error-t) c)
        bump_invisible(ps, K"error", TRIVIA_FLAG,
                       error="space required before `:` in `?` expression")
    end
    if kind(t) == K":"
        bump(ps, TRIVIA_FLAG)
    else
        # a ? b c  ==>  (? a b (error-t) c)
        bump_invisible(ps, K"error", TRIVIA_FLAG, error="`:` expected in `?` expression")
    end
    t = peek_token(ps; skip_newlines = true)
    if !preceding_whitespace(t)
        # a ? b :c  ==>  (? a b (error-t) c)
        bump_invisible(ps, K"error", TRIVIA_FLAG,
                       error="space required after `:` in `?` expression")
    end

    # FIXME: This is a very specific case. Error recovery should be handled more
    # generally elsewhere.
    if is_block_continuation_keyword(ps, kind(t))
        # a "continuation keyword" is likely to belong to the surrounding code, so
        # we abort early

        # if true; x ? true elseif true end  ==> (if true (block (if x true (error-t) (error-t))) (elseif true (block)))
        # if true; x ? true end  ==> (if true (block (if x true (error-t) (error-t))))
        # if true; x ? true\n end  ==> (if true (block (if x true (error-t) (error-t))))
        # if true; x ? true : elseif true end  ==> (if true (block (if x true (error-t))) (elseif true (block)))
        bump_invisible(ps, K"error", TRIVIA_FLAG, error="unexpected `$(kind(t))`")
        emit(ps, mark, K"if")
        return
    else
        # A[x ? y : end] ==> (ref A (? x y end))
    end
    parse_eq_star(ps)
    emit(ps, mark, K"?")
end

# Parse arrows.  Like parse_RtoL, but specialized for --> syntactic operator
#
# flisp: parse-arrow
function parse_arrow(ps::ParseState)
    mark = position(ps)
    parse_or(ps)
    isdot, isassign, t = peek_dotted_op_token(ps)
    k = kind(t)
    if is_prec_arrow(t)
        if kind(t) == K"-->" && !isdot
            # x --> y   ==>  (--> x y)           # The only syntactic arrow
            bump(ps, TRIVIA_FLAG)
            parse_arrow(ps)
            emit(ps, mark, k, flags(t))
        else
            # x → y     ==>  (call-i x → y)
            # x <--> y  ==>  (call-i x <--> y)
            # x .--> y  ==>  (dotcall-i x --> y)
            # x -->₁ y  ==>  (call-i x -->₁ y)
            bump_dotted(ps, isdot, t, remap_kind=K"Identifier")
            parse_arrow(ps)
            emit(ps, mark, isdot ? K"dotcall" : K"call", INFIX_FLAG)
        end
    end
end

function dotted(k)
    if k == K"||"
        return K".||"
    elseif k == K"&&"
        return K".&&"
    elseif k == K"="
        return K".="
    elseif k == K"op="
        return K".op="
    else
        error("Unexpected dotted operator: $k")
    end
end

# Like parse_RtoL, but specialized for the version test of dotted operators.
function parse_lazy_cond(ps::ParseState, down, is_op, self)
    mark = position(ps)
    down(ps)
    (isdot, isassign, t) = peek_dotted_op_token(ps)
    k = kind(t)
    if is_op(t)
        bump_dotted(ps, isdot, t, TRIVIA_FLAG)
        self(ps)
        emit(ps, mark, isdot ? dotted(k) : k, flags(t))
        if isdot
            min_supported_version(v"1.7", ps, mark, "dotted operators `.||` and `.&&`")
        end
    end
end

# x || y || z   ==>   (|| x (|| y z))
#v1.6: x .|| y  ==>   (error (.|| x y))
#v1.7: x .|| y  ==>   (.|| x y)
#
# flisp: parse-or
function parse_or(ps::ParseState)
    parse_lazy_cond(ps, parse_and, is_prec_lazy_or, parse_or)
end

# x && y && z   ==>   (&& x (&& y z))
#v1.6: x .&& y  ==>   (error (.&& x y))
#v1.7: x .&& y  ==>   (.&& x y)
#
# flisp: parse-and
function parse_and(ps::ParseState)
    parse_lazy_cond(ps, parse_comparison, is_prec_lazy_and, parse_and)
end

# Parse binary comparisons and comparison chains
#
# flisp: parse-comparison
function parse_comparison(ps::ParseState, subtype_comparison=false)
    mark = position(ps)
    if subtype_comparison && is_reserved_word(peek(ps))
        # Recovery
        # struct try end  ==>  (struct (error (try)) (block))
        name = untokenize(peek(ps))
        bump(ps)
        emit(ps, mark, K"error", error="Invalid type name `$name`")
    else
        parse_pipe_lt(ps)
    end
    n_comparisons = 0
    op_pos = NO_POSITION
    op_dotted = false
    (initial_dot, initial_isassign, initial_tok) = peek_dotted_op_token(ps)
    while ((isdot, isassign, t) = peek_dotted_op_token(ps); is_prec_comparison(t))
        n_comparisons += 1
        op_dotted = isdot
        op_pos = bump_dotted(ps, isdot, t, emit_dot_node=true, remap_kind=K"Identifier")
        parse_pipe_lt(ps)
    end
    if n_comparisons == 1
        if is_type_operator(initial_tok, initial_dot)
            # Type comparisons are syntactic
            # x <: y  ==>  (<: x y)
            # x >: y  ==>  (>: x y)
            reset_node!(ps, op_pos, flags=TRIVIA_FLAG)
            emit(ps, mark, kind(initial_tok))
        else
            # Normal binary comparisons
            # x < y    ==>  (call-i x < y)
            # x .< y   ==>  (dotcall-i x < y)
            if op_dotted
                # Reset the extra (non-terminal) K"." (e.g. in `(. <)`) node to just `. <`
                reset_node!(ps, op_pos, kind=K"TOMBSTONE", flags=TRIVIA_FLAG)
            end
            emit(ps, mark, op_dotted ? K"dotcall" : K"call", INFIX_FLAG)
        end
    elseif n_comparisons > 1
        # Comparison chains
        # x < y < z    ==> (comparison x < y < z)
        # x == y < z   ==> (comparison x == y < z)
        # x .< y .< z  ==> (comparison x (. <) y (. <) z)
        # x .< y < z   ==> (comparison x (. <) y < z)
        emit(ps, mark, K"comparison")
    end
end

# x <| y <| z  ==>  (call-i x <| (call-i y <| z))
# flisp: parse-pipe<
function parse_pipe_lt(ps::ParseState)
    parse_RtoL(ps, parse_pipe_gt, is_prec_pipe_lt, parse_pipe_lt)
end

# x |> y |> z  ==>  (call-i (call-i x |> y) |> z)
# x .|> y      ==>  (dotcall-i x |> y)
# flisp: parse-pipe>
function parse_pipe_gt(ps::ParseState)
    parse_LtoR(ps, parse_range, is_prec_pipe_gt)
end

# parse ranges and postfix ...
# colon is strange; 3 arguments with 2 colons yields one call:
# 1:2       ==> (call-i 1 : 2)
# 1:2:3     ==> (call-i 1 : 2 3)
# Chaining gives
# a:b:c:d:e ==> (call-i (call-i a : b c) : d e)
#
# flisp: parse-range
function parse_range(ps::ParseState)
    mark = position(ps)
    parse_invalid_ops(ps)

    # The compound-assignment flag (`a ..= b` etc.) is intentionally ignored
    # here; such forms are rejected in `parse_assignment_with_initial_ex`.
    (initial_dot, _, initial_tok) = peek_dotted_op_token(ps)
    initial_kind = kind(initial_tok)
    if initial_kind != K":" && (is_prec_colon(initial_tok) || (initial_dot && initial_kind == K"."))
        # a..b     ==>   (call-i a (DotsIdentifier-2) b)
        # a … b    ==>   (call-i a … b)
        # a .… b    ==>  (dotcall-i a … b)
        bump_dotted(ps, initial_dot, initial_tok, remap_kind=K"Identifier")
        parse_invalid_ops(ps)
        emit(ps, mark, (initial_dot && initial_kind != K".") ? K"dotcall" : K"call", INFIX_FLAG)
    elseif initial_kind == K":" && ps.range_colon_enabled
        # a ? b : c:d   ==>   (? a b (call-i c : d))
        n_colons = 0
        while peek(ps) == K":"
            if ps.space_sensitive &&
                    preceding_whitespace(peek_token(ps)) &&
                    !preceding_whitespace(peek_token(ps, 2))
                # Tricky cases in space sensitive mode
                # [1 :a]      ==>  (hcat 1 (quote-: a))
                # [1 2:3 :a]  ==>  (hcat 1 (call-i 2 : 3) (quote-: a))
                break
            end
            t2 = peek_token(ps,2)
            if kind(t2) in KSet"< >" && !preceding_whitespace(t2)
                # Error heuristic: we found `:>` or `:<` which are invalid lookalikes
                # for `<:` and `>:`. Attempt to recover by treating them as a
                # comparison operator.
                # a :> b   ==>  (call-i a (error : >) b)
                bump_trivia(ps, skip_newlines=false)
                emark = position(ps)
                bump(ps, remap_kind=K"Identifier") # K":"
                ks = untokenize(peek(ps))
                bump(ps, remap_kind=K"Identifier") # K"<" or K">"
                emit(ps, emark, K"error",
                     error="Invalid `:$ks` found, maybe replace with `$ks:`")
                parse_invalid_ops(ps)
                emit(ps, mark, K"call", INFIX_FLAG)
                break
            end
            n_colons += 1
            bump(ps, n_colons == 1 ? EMPTY_FLAGS : TRIVIA_FLAG; remap_kind=K"Identifier")
            had_newline = peek(ps) == K"NewlineWs"
            t = peek_token(ps)
            if is_closing_token(ps, kind(t))
                # 1: }    ==>  (call-i 1 : (error))
                # 1:2: }  ==>  (call-i 1 : 2 (error))
                bump_invisible(ps, K"error",
                               error="missing last argument in range expression")
                emit(ps, mark, K"call", INFIX_FLAG)
                emit_diagnostic(ps, error="found unexpected closing token")
                return
            end
            if had_newline
                # Error message for people coming from python
                # 1:\n2   ==> (call-i 1 : (error))
                # (1:\n2) ==> (parens (call-i 1 : 2))
                emit_diagnostic(ps, whitespace=true,
                                error="line break after `:` in range expression")
                bump_invisible(ps, K"error")
                emit(ps, mark, K"call", INFIX_FLAG)
                return
            end
            parse_invalid_ops(ps)
            if n_colons == 2
                emit(ps, mark, K"call", INFIX_FLAG)
                n_colons = 0
            end
        end
        if n_colons > 0
            emit(ps, mark, K"call", INFIX_FLAG)
        end
    end

    # x...     ==>  (... x)
    # x:y...   ==>  (... (call-i x : y))
    # x..y...  ==>  (... (call-i x .. y))   # flisp parser fails here
    if peek(ps) == K"." && peek(ps, 2) == K"." && peek(ps, 3) == K"."
        bump(ps, TRIVIA_FLAG)
        bump(ps, TRIVIA_FLAG) # second dot
        bump(ps, TRIVIA_FLAG) # third dot
        emit(ps, mark, K"...")
    end
end

# Parse invalid binary operators
#
# Having this is unnecessary, but it improves error messages and the
# error-containing parse tree.
#
# a--b  ==>  (call-i a (error) b)
function parse_invalid_ops(ps::ParseState)
    mark = position(ps)
    parse_expr(ps)
    while ((isdot, isassign, t) = peek_dotted_op_token(ps); kind(t) in KSet"ErrorInvalidOperator Error**")
        bump_trivia(ps)
        bump_dotted(ps, isdot, t)
        parse_expr(ps)
        emit(ps, mark, isdot ? K"dotcall" : K"call", INFIX_FLAG)
    end
end

# a - b - c  ==>  (call-i (call-i a - b) - c)
# a + b + c  ==>  (call-i a + b c)
# a .+ b     ==>  (dotcall-i a + b)
#
# flisp: parse-expr
function parse_expr(ps::ParseState)
    parse_with_chains(ps, parse_term, is_prec_plus, KSet"+ ++")
end

# a * b * c  ==>  (call-i a * b c)
#
# flisp: parse-term
function parse_term(ps::ParseState)
    parse_with_chains(ps, parse_rational, is_prec_times, KSet"*")
end

# Parse left to right, combining any of `chain_ops` into one call
#
# flisp: parse-with-chains
function parse_with_chains(ps::ParseState, down, is_op, chain_ops)
    mark = position(ps)
    down(ps)
    while ((isdot, isassign, t) = peek_dotted_op_token(ps); is_op(t) && !isassign)
        if ps.space_sensitive && preceding_whitespace(t) &&
                is_both_unary_and_binary(t, isdot) &&
                !preceding_whitespace(peek_token(ps, 2))
            # The following is two elements of a hcat
            # [x +y]     ==>  (hcat x (call-pre + y))
            # [x+y +z]   ==>  (hcat (call-i x + y) (call-pre + z))
            # Conversely the following are infix calls
            # [x +₁y]    ==>  (vect (call-i x +₁ y))
            # [x+y+z]    ==>  (vect (call-i x + y z))
            # [x+y + z]  ==>  (vect (call-i x + y z))
            break
        end
        bump_dotted(ps, isdot, t, remap_kind=K"Identifier")
        down(ps)
        if kind(t) in chain_ops && !isdot
            # a + b + c    ==>  (call-i a + b c)
            # a + b .+ c   ==>  (dotcall-i (call-i a + b) + c)
            parse_chain(ps, down, kind(t))
        end
        # a +₁ b +₁ c  ==>  (call-i (call-i a +₁ b) +₁ c)
        # a .+ b .+ c  ==>  (dotcall-i (dotcall-i a + b) + c)
        emit(ps, mark, isdot ? K"dotcall" : K"call", INFIX_FLAG)
    end
end

# parse left to right chains of a given binary operator
#
# flisp: parse-chain
function parse_chain(ps::ParseState, down, op_kind)
    while true
        isdot, isassign, t = peek_dotted_op_token(ps)
        if kind(t) != op_kind || isdot
            break
        end
        if ps.space_sensitive && preceding_whitespace(t) &&
            is_both_unary_and_binary(t, false) &&
            !preceding_whitespace(peek_token(ps, 2))
            # [x +y]  ==>  (hcat x (call-pre + y))
            break
        end
        bump(ps, TRIVIA_FLAG)
        down(ps)
    end
end

# flisp: parse-rational
# x // y // z  ==>  (call-i (call-i x // y) // z)
function parse_rational(ps::ParseState)
    parse_LtoR(ps, parse_shift, is_prec_rational)
end

# flisp: parse-shift
# x >> y >> z  ==>  (call-i (call-i x >> y) >> z)
function parse_shift(ps::ParseState)
    parse_LtoR(ps, parse_unary_subtype, is_prec_bitshift)
end

# parse `<: A where B` as `<: (A where B)` (issue #21545)
#
# flisp: parse-unary-subtype
function parse_unary_subtype(ps::ParseState)
    t = peek_token(ps)
    if is_type_operator(t, false)
        k2 = peek(ps, 2)
        if is_closing_token(ps, k2) || k2 in KSet"NewlineWs ="
            # return operator by itself
            # <: )  ==>  <:
            # <: \n ==>  <:
            # <: =  ==>  <:
            bump(ps)
        elseif k2 in KSet"{ ("
            # parse <:{T}(x::T) or <:(x::T) like other unary operators
            # <:{T}(x::T)  ==>  (call (curly <: T) (:: x T))
            # <:(x::T)     ==>  (<:-pre (parens (:: x T)))
            parse_where(ps, parse_juxtapose)
        else
            # <: x          ==>  (<:-pre x)
            # <: A where B  ==>  (<:-pre (where A B))
            # <: <: x       ==>  (<:-pre (<:-pre x))
            mark = position(ps)
            bump(ps, TRIVIA_FLAG)
            parse_unary_subtype(ps)
            emit(ps, mark, kind(t), PREFIX_OP_FLAG)
        end
    else
        parse_where(ps, parse_juxtapose)
    end
end

# flisp: parse-where-chain
function parse_where_chain(ps0::ParseState, mark)
    ps = ParseState(ps0, where_enabled=false)
    while peek(ps) == K"where"
        bump(ps, TRIVIA_FLAG) # where
        bump_trivia(ps)
        k = peek(ps)
        if k == K"{"
            # x where \n {T}  ==>  (where x (braces T))
            # x where {T,S}  ==>  (where x (braces T S))
            # Also various nonsensical forms permitted
            # x where {T S}  ==>  (where x (bracescat (row T S)))
            # x where {y for y in ys}  ==>  (where x (braces (generator y (iteration (in y ys)))))
            m = position(ps)
            bump(ps, TRIVIA_FLAG)
            ckind, cflags, dim = parse_cat(ps, K"}", ps.end_symbol)
            emit_braces(ps, m, ckind, cflags, dim)
            emit(ps, mark, K"where")
        else
            # x where T     ==>  (where x T)
            # x where \n T  ==>  (where x T)
            # x where T<:S  ==>  (where x (<: T S))
            parse_comparison(ps)
            emit(ps, mark, K"where")
        end
    end
end

# flisp: parse-where
function parse_where(ps::ParseState, down)
    # `where` needs to be below unary for the following to work
    # +(x::T,y::T) where {T} = x
    mark = position(ps)
    down(ps)
    if ps.where_enabled && peek(ps) == K"where"
        parse_where_chain(ps, mark)
    end
end

# Juxtaposition. Kinda ugh but so useful for units and field identities like `im`
#
# flisp: parse-juxtapose
function parse_juxtapose(ps::ParseState)
    mark = position(ps)
    parse_unary(ps)
    n_terms = 1
    while true
        t = peek_token(ps)
        k = kind(t)
        prev_k = peek_behind(ps).kind
        is_juxtapose = false
        if !preceding_whitespace(t) &&
                (is_number(prev_k) ||
                    (!is_number(k) &&  # disallow "x.3" and "f(2)2"
                     k != K"@"     &&  # disallow "x@y"
                     !(is_block_form(prev_k)         ||
                       is_syntactic_unary_op(prev_k) ||
                       is_initial_reserved_word(ps, prev_k) )))  &&
                (!is_operator(k) || is_radical_op(k))            &&
                !is_closing_token(ps, k) &&
                k != K"ErrorInvalidOperator" && k != K"Error**"
            if prev_k == K"string" || is_string_delim(t)
                bump_invisible(ps, K"error", TRIVIA_FLAG,
                               error="cannot juxtapose string literal")
                # JuliaLang/julia#20575
                # Error, but assume juxtapose for recovery
                # "a""b"  ==>  (juxtapose (string "a") (error-t) (string "b"))
                # "a"x    ==>  (juxtapose (string "a") (error-t) x)
                # "$y"x   ==>  (juxtapose (string y) (error-t) x)
                # "a"begin end  ==> (juxtapose (string \"a\") (error-t) (block))
                is_juxtapose = true
            elseif !is_initial_reserved_word(ps, k)
                # 2x       ==>  (juxtapose 2 x)
                # 2(x)     ==>  (juxtapose 2 (parens x))
                # (2)(3)x  ==>  (juxtapose (parens 2) (parens 3) x)
                # (x-1)y   ==>  (juxtapose (parens (call-i x - 1)) y)
                # x'y      ==>  (juxtapose (call-post x ') y)
                # 1√x      ==>  (juxtapose 1 (call-pre √ x))
                is_juxtapose = true
            end
        end
        if !is_juxtapose
            # x.3       ==>  x
            # f(2)2     ==>  (call f 2)
            # x' y      ==>  (call-post x ')
            # x 'y      ==>  x
            # x@y       ==>  x
            break
        end
        if is_radical_op(t)
            parse_unary(ps)
        else
            parse_factor(ps)
        end
        n_terms += 1
    end
    if n_terms > 1
        emit(ps, mark, K"juxtapose")
    end
end

# Parse numeric literal prefixes, calls to unary operators and prefix
# calls involving arbitrary operators with bracketed arglists (as opposed to
# infix notation)
#
# flisp: parse-unary, parse-unary-call
function parse_unary(ps::ParseState)
    mark = position(ps)
    bump_trivia(ps)
    (op_dotted, op_isassign, op_t) = peek_dotted_op_token(ps)
    op_k = kind(op_t)
    if (
            !is_operator(op_k)           ||
            is_word_operator(op_k)       ||
            (op_k in KSet": ' .'")       ||
            (is_syntactic_unary_op(op_k) && !op_dotted) ||
            is_syntactic_operator(op_k)
        )
        # `op_t` is not an initial operator
        # :T      ==>  (quote-: T)
        # in::T   ==>  (:: in T)
        # isa::T  ==>  (:: isa T)
        parse_factor(ps)
        return
    end
    t2 = peek_token(ps, 2+op_dotted)
    k2 = kind(t2)
    if op_k in KSet"- +" && !op_dotted
        if !preceding_whitespace(t2) && (k2 in KSet"Integer Float Float32" ||
                                         (op_k == K"+" && k2 in KSet"BinInt HexInt OctInt"))

            t3 = peek_token(ps, 3)
            if is_prec_power(t3) || kind(t3) in KSet"[ {"
                # `[`, `{` (issue #18851) and `^` have higher precedence than
                # unary negation
                # -2^x      ==>  (call-pre - (call-i 2 ^ x))
                # -2[1, 3]  ==>  (call-pre - (ref 2 1 3))
                bump(ps, remap_kind=K"Identifier")
                parse_factor(ps)
                emit(ps, mark, K"call", PREFIX_OP_FLAG)
            else
                # We have a signed numeric literal. Glue the operator to the
                # next token to create a signed literal:
                # -2      ==>  -2
                # +2.0    ==>  2.0
                # -1.0f0  ==>  -1.0f0
                # -2*x    ==>  (call-i -2 * x)
                # +0xff   ==>  0xff
                bump_glue(ps, kind(t2), EMPTY_FLAGS)
                parse_factor_with_initial_ex(ps, mark)
            end
            return
        end
    end
    if is_closing_token(ps, k2) || k2 in KSet"NewlineWs ="
        # Standalone operators parsed as `op` or `(. op)`
        # +)   ==>  +
        # +\n  ==>  +
        # + =  ==>  +
        # .+   ==>  (. +)
        # .&   ==>  (. &)
        parse_atom(ps)
    elseif k2 == K"{" || (!is_unary_op(op_t, op_dotted) && k2 == K"(")
        # Call with type parameters or non-unary prefix call
        # +{T}(x::T)  ==>  (call (curly + T) (:: x T))
        # *(x)  ==>  (call * x)
        # .*(x) ==>  (call .* x)
        parse_factor(ps)
    elseif k2 == K"("
        # Cases like +(a;b) are ambiguous: are they prefix calls to + with b as
        # a keyword argument, or is `a;b` a block?  We resolve this with a
        # simple heuristic: if there were any commas (or an initial splat), it
        # was a function call.
        #
        # (The flisp parser only considers commas before `;` and thus gets this
        # last case wrong)
        op_pos = bump_dotted(ps, op_dotted, op_t, emit_dot_node=true, remap_kind=K"Identifier")

        space_before_paren = preceding_whitespace(t2)
        if space_before_paren
            # Setup possible whitespace error between operator and (
            ws_mark = position(ps)
            bump_trivia(ps)
            ws_error_pos = emit(ps, ws_mark, K"TOMBSTONE")
            ws_mark_end = position(ps)
        end

        mark_before_paren = position(ps)
        bump(ps, TRIVIA_FLAG) # (
        initial_semi = peek(ps, skip_newlines=true) == K";"
        opts = parse_brackets(ps, K")") do had_commas, had_splat, num_semis, num_subexprs
            is_paren_call = had_commas || had_splat               ||
                            (initial_semi && num_subexprs > 0)    ||
                            (initial_semi && num_semis == 1)      ||
                            (num_semis == 0 && num_subexprs == 0)
            return (needs_parameters=is_paren_call,
                    is_paren_call=is_paren_call,
                    is_block=!is_paren_call && num_semis > 0)
        end::NamedTuple{(:needs_parameters, :is_paren_call, :is_block, :delim_flags), Tuple{Bool, Bool, Bool, RawFlags}}

        # The precedence between unary + and any following infix ^ depends on
        # whether the parens are a function call or not
        if opts.is_paren_call
            if space_before_paren
                # Whitespace not allowed before prefix function call bracket
                # + (a,b)   ==> (call + (error) a b)
                reset_node!(ps, ws_error_pos, kind=K"error")
                emit_diagnostic(ps, ws_mark, ws_mark_end,
                                error="whitespace not allowed between prefix function call and argument list")
            end
            # Prefix function calls for operators which are both binary and unary
            # +(a,b)    ==>  (call + a b)
            # +(a=1,)   ==>  (call + (= a 1))
            # +(a...)   ==>  (call + (... a))
            # +(a;b,c)  ==>  (call + a (parameters b c))
            # +(;a)     ==>  (call + (parameters a))
            # +()       ==>  (call +)
            # Prefix calls have higher precedence than ^
            # +(a,b)^2  ==>  (call-i (call + a b) ^ 2)
            # +(a,b)(x)^2  ==>  (call-i (call (call + a b) x) ^ 2)
            if is_type_operator(op_t, op_dotted)
                # <:(a,)  ==>  (<: a)
                emit(ps, mark, op_k, opts.delim_flags)
                reset_node!(ps, op_pos, flags=TRIVIA_FLAG, kind=op_k)
            else
                emit(ps, mark, K"call", opts.delim_flags)
            end
            parse_call_chain(ps, mark)
            parse_factor_with_initial_ex(ps, mark)
        else
            # Unary function calls with brackets as grouping, not an arglist
            # .+(a)    ==>  (dotcall-pre + (parens a))
            if opts.is_block
                # +(a;b)   ==>  (call-pre + (block-p a b))
                emit(ps, mark_before_paren, K"block", PARENS_FLAG)
            else
                emit(ps, mark_before_paren, K"parens")
            end
            # Not a prefix operator call but a block; `=` is not `kw`
            # +(a=1)  ==>  (call-pre + (parens (= a 1)))
            # Unary operators have lower precedence than ^
            # +(a)^2  ==>  (call-pre + (call-i (parens a) ^ 2))
            # .+(a)^2  ==>  (dotcall-pre + (call-i (parens a) ^ 2))
            # +(a)(x,y)^2  ==>  (call-pre + (call-i (call (parens a) x y) ^ 2))
            parse_call_chain(ps, mark_before_paren)
            parse_factor_with_initial_ex(ps, mark_before_paren)
            if is_type_operator(op_t, op_dotted)
                # <:(a)  ==>  (<:-pre (parens a))
                emit(ps, mark, op_k, PREFIX_OP_FLAG)
                reset_node!(ps, op_pos, flags=TRIVIA_FLAG, kind=op_k)
            else
                if op_dotted
                    emit(ps, mark, K"dotcall", PREFIX_OP_FLAG)
                    # Reset the extra (non-terminal) K"." (e.g. in `(. +)`) node to just `. +`
                    reset_node!(ps, op_pos, kind=K"TOMBSTONE")
                else
                    emit(ps, mark, K"call", PREFIX_OP_FLAG)
                end
            end
        end
    else
        if is_unary_op(op_t, op_dotted)
            # Normal unary calls
            # +x  ==>  (call-pre + x)
            # √x  ==>  (call-pre √ x)
            # .~x ==>  (dotcall-pre ~ x)
            # Things which are not quite negative literals
            # -0x1 ==> (call-pre - 0x01)
            # - 2  ==> (call-pre - 2)
            # .-2  ==> (dotcall-pre - 2)
            op_pos = bump_dotted(ps, op_dotted, op_t, remap_kind=K"Identifier")
        else
            # /x     ==>  (call-pre (error /) x)
            # +₁ x   ==>  (call-pre (error +₁) x)
            # .<: x  ==>  (dotcall-pre (error (. <:)) x)
            bump_dotted(ps, op_dotted, op_t, emit_dot_node=true, remap_kind=K"Identifier")
            op_pos = emit(ps, mark, K"error", error="not a unary operator")
        end
        parse_unary(ps)
        if is_type_operator(op_t, op_dotted)
            reset_node!(ps, op_pos, flags=TRIVIA_FLAG)
            emit(ps, mark, op_k, PREFIX_OP_FLAG)
        else
            emit(ps, mark, op_dotted ? K"dotcall" : K"call", PREFIX_OP_FLAG)
        end
    end
end

# handle ^ and .^
#
# x^y    ==>  (call-i x ^ y)
# x^y^z  ==>  (call-i x ^ (call-i y ^ z))
# x .^ y ==>  (dotcall-i x ^ y)
# begin x end::T  ==>  (:: (block x) T)
#
# flisp: parse-factor
function parse_factor(ps::ParseState)
    mark = position(ps)
    parse_call(ps)
    parse_factor_with_initial_ex(ps, mark)
end

# flisp: parse-factor-with-initial-ex
function parse_factor_with_initial_ex(ps::ParseState, mark)
    parse_decl_with_initial_ex(ps, mark)
    if ((isdot, isassign, t) = peek_dotted_op_token(ps); is_prec_power(t) && !isassign)
        bump_dotted(ps, isdot, t, remap_kind=K"Identifier")
        parse_factor_after(ps)
        emit(ps, mark, isdot ? K"dotcall" : K"call", INFIX_FLAG)
    end
end

# flisp: parse-factor-after
function parse_factor_after(ps::ParseState)
    parse_RtoL(ps, parse_juxtapose, is_prec_power, parse_factor_after)
end

# Parse type declarations and lambda syntax
# a::b      ==>   (::-i a b)
# a->b      ==>   (-> a b)
#
# flisp: parse-decl-with-initial-ex
function parse_decl_with_initial_ex(ps::ParseState, mark)
    while peek(ps) == K"::"
        # a::b::c   ==>   (::-i (::-i a b) c)
        bump(ps, TRIVIA_FLAG)
        parse_where(ps, parse_call)
        emit(ps, mark, K"::", INFIX_FLAG)
    end
    if peek(ps) == K"->"
        kb = peek_behind(ps).kind
        if kb == K"tuple"
            # (x,y) -> z
            # (x) -> y
            # (x; a=1) -> y
        elseif kb == K"where"
            # `where` and `->` have the "wrong" precedence when writing anon functions.
            # So ignore this case to allow use of grouping brackets with `where`.
            # This needs to worked around in lowering :-(
            # (x where T) -> y  ==>  (-> (x where T) y)
        else
            # x -> y    ==>  (-> (tuple x) y)
            # a::b->c   ==>  (-> (tuple (::-i a b)) c)
            emit(ps, mark, K"tuple")
        end
        bump(ps, TRIVIA_FLAG)
        # -> is unusual: it binds tightly on the left and loosely on the right.
        parse_eq_star(ps)
        emit(ps, mark, K"->")
    end
end

# parse function call, indexing, dot, and transpose expressions
# also handles looking for syntactic reserved words
#
# flisp: parse-call
function parse_call(ps::ParseState)
    if peek_initial_reserved_words(ps)
        parse_resword(ps)
    else
        mark = position(ps)
        # f(x)   ==>  (call f x)
        # $f(x)  ==>  (call ($ f) x)
        parse_unary_prefix(ps)
        parse_call_chain(ps, mark)
    end
end

# parse syntactic unary operators
#
# &a   ==>  (& a)
# ::a  ==>  (::-pre a)
# $a   ==>  ($ a)
#
# flisp: parse-unary-prefix
function parse_unary_prefix(ps::ParseState, has_unary_prefix=false)
    mark = position(ps)
    (isdot, isassign, t) = peek_dotted_op_token(ps)
    k = kind(t)
    if is_syntactic_unary_op(k) && !isdot
        k2 = peek(ps, 2)
        if k in KSet"& $" && (is_closing_token(ps, k2) || k2 == K"NewlineWs")
            # &)   ==>  &
            # $\n  ==>  $
            bump(ps)
        else
            bump(ps, TRIVIA_FLAG)
            if k in KSet"& ::"
                # &a   ==>  (& a)
                parse_where(ps, parse_call)
            else
                # $a   ==>  ($ a)
                # $$a  ==>  ($ ($ a))
                # $&a  ==>  ($ (& a))
                parse_unary_prefix(ps, true)
            end
            # Only need PREFIX_OP_FLAG for ::
            f = k == K"::" ? PREFIX_OP_FLAG : EMPTY_FLAGS
            emit(ps, mark, k, f)
        end
    else
        # .&(x,y)  ==>  (call .& x y)
        parse_atom(ps, true, has_unary_prefix)
    end
end

function maybe_parsed_macro_name(ps, processing_macro_name, last_identifier_orig_kind, mark)
    if processing_macro_name
        emit(ps, mark, K"macro_name")
        maybe_parsed_special_macro(ps, last_identifier_orig_kind)
    end
    return false
end

function maybe_parsed_special_macro(ps, last_identifier_orig_kind)
    is_special = last_identifier_orig_kind == K"VERSION" ||
                 last_identifier_orig_kind == K"goto"
    if is_special && ps.stream.version >= (1, 14)
        # Encode the current parser version into an invisible token
        bump_invisible(ps, K"VERSION",
            set_numeric_flags(ps.stream.version[2] * 10))
    end
end

# Parses a chain of suffixes at function call precedence, leftmost binding
# tightest. This handles
#  * Bracketed calls like a() b[] c{}
#  * Field access like a.b.c
#    - Various dotted syntax like f.() and f.:x
#  * Adjoint suffix like a'
#  * String macros like a"str" b"""str""" c`str` d```str```
#
# f(a).g(b) ==> (call (. (call f a) g) b)
#
# flisp: parse-call-chain, parse-call-with-initial-ex
function parse_call_chain(ps::ParseState, mark, is_macrocall=false)
    if is_number(peek_behind(ps).kind) && peek(ps) == K"("
        # juxtaposition with numbers is multiply, not call
        # 2(x) ==> (* 2 x)
        return
    end
    processing_macro_name = is_macrocall
    saw_misplaced_atsym = false
    misplaced_atsym_mark = nothing
    # source range of the @-prefixed part of a macro
    macro_atname_range = nothing
    # $A.@x  ==>  (macrocall (. ($ A) (macro_name x)))
    maybe_strmac = true
    last_identifier_pos = peek_behind_pos(ps)
    last_identifier_orig_kind = peek_behind(ps, last_identifier_pos).orig_kind
    while true
        maybe_strmac_1 = false
        t = peek_token(ps)
        k = kind(t)
        if !is_macrocall && ps.space_sensitive && preceding_whitespace(t) &&
                k in KSet"( [ { \" \"\"\" ` ```"
            # [f (x)]  ==>  (hcat f (parens x))
            # [f x]    ==>  (hcat f x)
            break
        elseif is_macrocall && (preceding_whitespace(t) || !(k in KSet"( [ { ' ."))
            # Macro calls with space-separated arguments
            # @foo a b    ==> (macrocall (macro_name foo) a b)
            # @foo (x)    ==> (macrocall (macro_name foo) (parens x))
            # @foo (x,y)  ==> (macrocall (macro_name foo) (tuple-p x y))
            # [@foo x]    ==> (vect (macrocall (macro_name foo) x))
            # [@foo]      ==> (vect (macrocall (macro_name foo)))
            # @var"#" a   ==> (macrocall (macro_name (var #)) a)
            # A.@x y      ==> (macrocall (. A (macro_name x)) y)
            # A.@var"#" a ==> (macrocall (. A (macro_name (var #))) a)
            # @+x y       ==> (macrocall (macro_name +) x y)
            # A.@.x       ==> (macrocall (. A (macro_name .)) x)
            maybe_parsed_macro_name(
                ps, processing_macro_name, last_identifier_orig_kind, mark)
            let ps = with_space_sensitive(ps)
                # Space separated macro arguments
                # A.@foo a b    ==> (macrocall (. A (macro_name foo)) a b)
                # @A.foo a b    ==> (macrocall (macro_name (. A foo)) a b)
                n_args = parse_space_separated_exprs(ps)
                is_doc_macro = last_identifier_orig_kind == K"doc"
                if is_doc_macro && n_args == 1
                    # Parse extended @doc args on next line
                    # @doc x\ny      ==>  (macrocall (macro_name doc) x y)
                    # A.@doc x\ny    ==>  (macrocall (. A (macro_name doc)) x y)
                    # @A.doc x\ny    ==>  (macrocall (macro_name (. A doc)) x y)
                    # @doc x y\nz    ==>  (macrocall (macro_name doc) x y)
                    #
                    # Excluded cases
                    # @doc x\n\ny    ==>  (macrocall (macro_name doc) x)
                    # @doc x\nend    ==>  (macrocall (macro_name doc) x)
                    k2 = peek(ps, 2)
                    if peek(ps) == K"NewlineWs" && !is_closing_token(ps, k2) &&
                            k2 != K"NewlineWs"
                        bump(ps) # newline
                        parse_eq(ps)
                    end
                end
                emit(ps, mark, K"macrocall")
            end
            break
        elseif k == K"("
            # f(a,b)  ==>  (call f a b)
            # f(a=1; b=2) ==> (call f (= a 1) (parameters (= b 2)))
            # f(a; b; c)  ==> (call f a (parameters b) (parameters c))
            # (a=1)()  ==>  (call (parens (= a 1)))
            # f (a)    ==>  (call f (error-t) a)
            processing_macro_name = maybe_parsed_macro_name(
                ps, processing_macro_name, last_identifier_orig_kind, mark)
            bump_disallowed_space(ps)
            bump(ps, TRIVIA_FLAG)
            opts = parse_call_arglist(ps, K")")
            if peek(ps) == K"do"
                # f(x) do y body end  ==>  (call f x (do (tuple y) (block body)))
                parse_do(ps)
            end
            emit(ps, mark, is_macrocall ? K"macrocall" : K"call",
                 # TODO: Add PARENS_FLAG to all calls which use them?
                 (is_macrocall ? PARENS_FLAG : EMPTY_FLAGS)|opts.delim_flags)
            if is_macrocall
                # @x(a, b)   ==>  (macrocall-p (macro_name x) a b)
                # A.@x(y)    ==>  (macrocall-p (. A (macro_name x)) y)
                # A.@x(y).z  ==>  (. (macrocall-p (. A (macro_name x)) y) z)
                is_macrocall = false
                # @f()()     ==>  (call (macrocall-p (macro_name f)))
                macro_atname_range = nothing
            end
        elseif k == K"["
            processing_macro_name = maybe_parsed_macro_name(
                ps, processing_macro_name, last_identifier_orig_kind, mark)
            m = position(ps)
            # a [i]  ==>  (ref a (error-t) i)
            bump_disallowed_space(ps)
            bump(ps, TRIVIA_FLAG)
            ckind, cflags, dim = parse_cat(ParseState(ps, end_symbol=true),
                                      K"]", ps.end_symbol)
            if is_macrocall
                # @S[a,b]  ==>  (macrocall (macro_name S) (vect a b))
                # @S[a b]  ==>  (macrocall (macro_name S) (hcat a b))
                # @S[a; b] ==>  (macrocall (macro_name S) (vcat a b))
                # A.@S[a]  ==>  (macrocall (. A (macro_name S)) (vect a))
                # @S[a].b  ==>  (. (macrocall (macro_name S) (vect a)) b)
                #v1.7: @S[a ;; b]  ==>  (macrocall (macro_name S) (ncat-2 a b))
                #v1.6: @S[a ;; b]  ==>  (macrocall (macro_name S) (error (ncat-2 a b)))
                emit(ps, m, ckind, cflags | set_numeric_flags(dim))
                check_ncat_compat(ps, m, ckind)
                emit(ps, mark, K"macrocall")
                is_macrocall = false
                macro_atname_range = nothing
            else
                # a[i]    ==>  (ref a i)
                # a[i,j]  ==>  (ref a i j)
                # (a=1)[] ==>  (ref (parens (= a 1)))
                # a[end]  ==>  (ref a end)
                # T[x   y]  ==>  (typed_hcat T x y)
                # T[x ; y]  ==>  (typed_vcat T x y)
                # T[a b; c d]  ==>  (typed_vcat T (row a b) (row c d))
                # T[x for x in xs]  ==>  (typed_comprehension T (generator x (iteration (in x xs))))
                #v1.8: T[a ; b ;; c ; d]  ==>  (typed_ncat-2 T (nrow-1 a b) (nrow-1 c d))
                outk = ckind == K"vect"          ? K"ref"                  :
                       ckind == K"hcat"          ? K"typed_hcat"           :
                       ckind == K"vcat"          ? K"typed_vcat"           :
                       ckind == K"comprehension" ? K"typed_comprehension"  :
                       ckind == K"ncat"          ? K"typed_ncat"           :
                       internal_error("unrecognized kind in parse_cat ", string(ckind))
                emit(ps, mark, outk, cflags | set_numeric_flags(dim))
                check_ncat_compat(ps, mark, ckind)
            end
        elseif k == K"."
            # Check if this is a dotted operator, not field access
            k2 = peek(ps, 2)
            if is_operator(k2) && !is_word_operator(k2) && k2 != K":" && k2 != K"$" && k2 != K"'" && k2 != K"?"
                # This is a dotted operator like .=, .+, etc., not field access
                # Let the appropriate parser handle it
                break
            end
            # x .y  ==>  (. x (error-t) y)
            bump_disallowed_space(ps)
            emark = position(ps)
            if !isnothing(macro_atname_range)
                # Allow `@` in macrocall only in first and last position
                # A.B.@x  ==>  (macrocall (. (. A B) (macro_name x)))
                # @A.B.x  ==>  (macrocall (macro_name (. (. A B) x)))
                # A.@B.x  ==>  (macrocall (. (. A (error-t) B) (macro_name (error-t) x)))
                emit_diagnostic(ps, macro_atname_range...,
                    error="`@` must appear on first or last macro name component")
                # Recover by treating the `@` as if it had been on the last identifier
                saw_misplaced_atsym = true
                reset_node!(ps, macro_atname_range[2], kind=K"TOMBSTONE")
                reset_node!(ps, macro_atname_range[1], kind=K"error")
            end
            bump(ps, TRIVIA_FLAG)
            k = peek(ps)
            if k == K"("
                if is_macrocall
                    # Recover by pretending we do have the syntax
                    processing_macro_name = maybe_parsed_macro_name(
                        ps, processing_macro_name, last_identifier_orig_kind, mark)
                    # @M.(x)  ==> (macrocall (dotcall (macro_name M) (error-t) x))
                    bump_invisible(ps, K"error", TRIVIA_FLAG)
                    emit_diagnostic(ps, mark,
                                    error="dot call syntax not supported for macros")
                end
                # f.(a,b)   ==>  (dotcall f a b)
                # f. (x)    ==>  (dotcall f (error-t) x)
                bump_disallowed_space(ps)
                bump(ps, TRIVIA_FLAG)
                opts = parse_call_arglist(ps, K")")
                emit(ps, mark, K"dotcall", opts.delim_flags)
            elseif k == K":"
                # A.:+  ==>  (. A (quote-: +))
                # A.: +  ==>  (. A (error-t) (quote-: +))
                m = position(ps)
                bump(ps, TRIVIA_FLAG)
                bump_disallowed_space(ps)
                parse_atom(ps, false)
                emit(ps, m, K"quote", COLON_QUOTE)
                emit(ps, mark, K".")
            elseif k == K"$"
                # f.$x      ==>  (. f ($ x))
                # f.$(x+y)  ==>  (. f ($ (call + x y)))
                # A.$B.@x   ==>  (macrocall (. (. A ($ B)) (macro_name x)))
                # @A.$x a   ==>  (macrocall (macro_name (. A (error x))) a)
                m = position(ps)
                bump(ps, TRIVIA_FLAG)
                parse_atom(ps)
                if is_macrocall
                    emit(ps, m, K"error", error="invalid macro name")
                else
                    emit(ps, m, K"$")
                end
                last_identifier_orig_kind = K"$"
                emit(ps, mark, K".")
            elseif k == K"@"
                # A macro call after some prefix A has been consumed
                # A.@x    ==>  (macrocall (. A (macro_name x)))
                # A.@x a  ==>  (macrocall (. A (macro_name x)) a)
                m = position(ps)
                if is_macrocall
                    # @A.B.@x a ==> (macrocall (. (. A B) (error-t) (macro_name x)) a)
                    bump(ps, TRIVIA_FLAG, error="repeated `@` in macro module path")
                else
                    bump(ps, TRIVIA_FLAG)
                end
                parse_macro_name(ps)
                last_identifier_pos = peek_behind_pos(ps)
                last_identifier_orig_kind = peek_behind(ps, last_identifier_pos).orig_kind
                !is_macrocall && emit(ps, m, K"macro_name")
                macro_atname_range = (m, position(ps))
                is_macrocall = true
                emit(ps, mark, K".")
                maybe_parsed_special_macro(ps, last_identifier_orig_kind)
            elseif k == K"'"
                # f.'  =>  (dotcall-post f (error '))
                bump(ps, remap_kind=K"Identifier")  # bump '
                # TODO: Reclaim dotted postfix operators :-)
                emit(ps, emark, K"error",
                     error="the .' operator for transpose is discontinued")
                emit(ps, mark, K"dotcall", POSTFIX_OP_FLAG)
            elseif k == K"[" || k == K"{"
                # f.[x]  ==>  (error f x)
                # f.{x}  ==>  (error f x)
                # Parse as broadcasted brackets, then wrap in error
                close = k == K"[" ? K"]" : K"}"
                bump(ps, TRIVIA_FLAG)
                parse_cat(ParseState(ps, end_symbol=true), close, ps.end_symbol)
                emit(ps, mark, K"error", error="brackets are not allowed after `.`")
            else
                if saw_misplaced_atsym
                    # If we saw a misplaced `@` earlier, this might be the place
                    # where it should have been. Opportunistically bump the
                    # zero-width error token here. If that's not right, we'll
                    # reset it later.
                    if misplaced_atsym_mark !== nothing
                        reset_node!(ps, misplaced_atsym_mark[1], kind=K"TOMBSTONE")
                        reset_node!(ps, misplaced_atsym_mark[2], kind=K"TOMBSTONE")
                    end
                    macro_name_mark = position(ps)
                    bump_invisible(ps, K"error", TRIVIA_FLAG)
                    aterror_mark = position(ps)
                end
                # Field/property syntax
                # f.x.y ==> (. (. f x) y)
                parse_atom(ps, false)
                if saw_misplaced_atsym
                    emit(ps, macro_name_mark, K"macro_name")
                    misplaced_atsym_mark = (aterror_mark, position(ps))
                end
                last_identifier_pos = peek_behind_pos(ps)
                last_identifier_orig_kind = peek_behind(ps, last_identifier_pos).orig_kind
                maybe_strmac_1 = true
                emit(ps, mark, K".")
            end
        elseif is_prec_quote(t) && !preceding_whitespace(t)
            # f'  ==> (call-post f ')
            # f'ᵀ ==> (call-post f 'ᵀ)
            bump(ps, remap_kind=K"Identifier")
            emit(ps, mark, K"call", POSTFIX_OP_FLAG)
        elseif k == K"{"
            processing_macro_name = maybe_parsed_macro_name(
                ps, processing_macro_name, last_identifier_orig_kind, mark)
            # Type parameter curlies and macro calls
            m = position(ps)
            # S {a} ==> (curly S (error-t) a)
            bump_disallowed_space(ps)
            bump(ps, TRIVIA_FLAG)
            opts = parse_call_arglist(ps, K"}")
            if is_macrocall
                # @S{a,b} ==> (macrocall (macro_name S) (braces a b))
                # A.@S{a}  ==> (macrocall (. A (macro_name S)) (braces a))
                # @S{a}.b  ==> (. (macrocall (macro_name S) (braces a)) b)
                emit(ps, m, K"braces", opts.delim_flags)
                emit(ps, mark, K"macrocall")
                min_supported_version(v"1.6", ps, mark, "macro call without space before `{}`")
                is_macrocall = false
                macro_atname_range = nothing
            else
                # S{a,b} ==> (curly S a b)
                emit(ps, mark, K"curly", opts.delim_flags)
            end
        elseif k in KSet" \" \"\"\" ` ``` " &&
                !preceding_whitespace(t) && maybe_strmac &&
                (# Must mirror the logic in lex_quote() for consistency
                 origk = last_identifier_orig_kind;
                 origk == K"Identifier" || is_contextual_keyword(origk) || is_word_operator(origk))
            # Custom string and command literals
            # x"str" ==> (macrocall @x_str (string-r "str"))
            # x`str` ==> (macrocall @x_cmd (cmdstring-r "str"))
            # x""    ==> (macrocall @x_str (string-r ""))
            # x``    ==> (macrocall @x_cmd (cmdstring-r ""))
            # Triple quoted processing for custom strings
            # r"""\nx"""          ==> (macrocall @r_str (string-s-r "x"))
            # r"""\n x\n y"""     ==> (macrocall @r_str (string-s-r "x\n" "y"))
            # r"""\n x\\n y"""    ==> (macrocall @r_str (string-s-r "x\\\n" "y"))
            #
            # Use a special token kind for string and cmd macro names so the
            # names can be expanded later as necessary.
            name_kind = is_string_delim(k) ? K"StrMacroName" : K"CmdMacroName"
            reset_node!(ps, last_identifier_pos, kind=name_kind)
            parse_string(ps, true)
            t = peek_token(ps)
            k = kind(t)
            if !preceding_whitespace(t) && is_string_macro_suffix(k)
                # Macro suffixes can include keywords and numbers
                # x"s"y    ==> (macrocall @x_str (string-r "s") "y")
                # x"s"end  ==> (macrocall @x_str (string-r "s") "end")
                # x"s"in   ==> (macrocall @x_str (string-r "s") "in")
                # x"s"2    ==> (macrocall @x_str (string-r "s") 2)
                # x"s"10.0 ==> (macrocall @x_str (string-r "s") 10.0)
                suffix_kind = (k == K"Identifier" || is_keyword(k) ||
                               is_word_operator(k)) ? K"String" : k
                bump(ps, remap_kind=suffix_kind)
            end
            emit(ps, mark, K"macrocall")
        else
            break
        end
        maybe_strmac = maybe_strmac_1
    end
end

# Parse the `A<:B` part of type definitions like `struct A<:B end`
#
# flisp: parse-subtype-spec
function parse_subtype_spec(ps::ParseState)
    # Wart: why isn't the flisp parser more strict here?
    # <: is the only operator which isn't a syntax error, but
    # parse_comparison allows all sorts of things.
    parse_comparison(ps, true)
end

# flisp: parse-struct-field
function parse_struct_field(ps::ParseState)
    mark = position(ps)
    const_field = peek(ps) == K"const"
    if const_field
        bump(ps, TRIVIA_FLAG)
    end
    parse_eq(ps)
    if const_field
        # Const fields https://github.com/JuliaLang/julia/pull/43305
        #v1.8: struct A const a end  ==>  (struct A (block (const x)))
        #v1.7: struct A const a end  ==>  (struct A (block (error (const x))))
        emit(ps, mark, K"const")
        min_supported_version(v"1.8", ps, mark, "`const` struct field")
    end
end

# parse expressions or blocks introduced by syntactic reserved words.
#
# The caller should use peek_initial_reserved_words to determine whether
# to call parse_resword, or whether contextual keywords like `mutable` are
# simple identifiers.
#
# flisp: parse-resword
function parse_resword(ps::ParseState)
    bump_trivia(ps, skip_newlines=true)
    word = peek(ps)
    v_1_14_break_cont_ret = ps.stream.version >= (1,14)
    if v_1_14_break_cont_ret && (word == K"break" || word == K"continue" || word == K"return")
        # "Non-delimited" reserved words don't have an `end` delimiter and
        # should preserve end_symbol and range_colon_enabled.
        ps = ParseState(ps;
                        space_sensitive=false,
                        where_enabled=true,
                        for_generator=false,
                        whitespace_newline=false)
    else
        ps = normal_context(ps)
    end

    mark = position(ps)
    if word in KSet"begin quote"
        # begin end         ==>  (block)
        # begin a ; b end   ==>  (block a b)
        # begin\na\nb\nend  ==>  (block a b)
        bump(ps, TRIVIA_FLAG)
        parse_block_inner(ps, parse_docstring)
        bump_closing_token(ps, K"end")
        emit(ps, mark, K"block")
        if word == K"quote"
            # quote end       ==>  (quote (block))
            # quote body end  ==>  (quote (block body))
            emit(ps, mark, K"quote")
        end
    elseif word == K"while"
        # while cond body end  ==>  (while cond (block body))
        # while x < y \n a \n b \n end ==> (while (call-i x < y) (block a b))
        bump(ps, TRIVIA_FLAG)
        parse_cond(ps)
        parse_block(ps)
        bump_closing_token(ps, K"end")
        emit(ps, mark, K"while")
    elseif word == K"for"
        # for x in xs end  ==>  (for (iteration (in x xs)) (block))
        # for x in xs, y in ys \n a \n end ==> (for (iteration (in x xs) (in y ys)) (block a))
        bump(ps, TRIVIA_FLAG)
        parse_iteration_specs(ps)
        parse_block(ps)
        bump_closing_token(ps, K"end")
        emit(ps, mark, K"for")
    elseif word == K"let"
        bump(ps, TRIVIA_FLAG)
        m = position(ps)
        if peek(ps) in KSet"NewlineWs ;"
            # let end           ==>  (let (block) (block))
            # let ; end         ==>  (let (block) (block))
            # let ; body end    ==>  (let (block) (block body))
        else
            # let x=1\n end     ==>  (let (block (= x 1)) (block))
            # let x=1 ; end     ==>  (let (block (= x 1)) (block))
            # let x::1 ; end    ==>  (let (block (::-i x 1)) (block))
            # let x ; end       ==>  (let (block x) (block))
            # let x=1,y=2 ; end ==>  (let (block (= x 1) (= y 2) (block)))
            # let x+=1 ; end    ==>  (let (block (op= x + 1)) (block))
            parse_comma_separated(ps, parse_eq_star)
        end
        emit(ps, m, K"block")
        k = peek(ps)
        if k in KSet"NewlineWs ;"
            bump(ps, TRIVIA_FLAG)
        elseif k == K"end"
            # pass
        else
            recover(is_closer_or_newline, ps, TRIVIA_FLAG,
                    error="let variables should end in `;` or newline")
        end
        # let\na\nb\nend    ==>  (let (block) (block a b))
        parse_block(ps)
        bump_closing_token(ps, K"end")
        emit(ps, mark, K"let")
    elseif word == K"if"
        parse_if_elseif(ps)
    elseif word in KSet"global local"
        # global x   ==>  (global x)
        # local x    ==>  (local x)
        bump(ps, TRIVIA_FLAG)
        const_mark = nothing
        if peek(ps) == K"const"
            const_mark = position(ps)
            bump(ps, TRIVIA_FLAG)
        end
        had_assignment = parse_global_local_const_vars(ps)
        if !isnothing(const_mark)
            # global const x = 1  ==>  (global (const (= x 1)))
            # local const x = 1   ==>  (local (const (= x 1)))
            emit(ps, const_mark, K"const")
            if !had_assignment
                # global const x  ==>  (global (error (const x)))
                emit(ps, mark, K"error", error="expected assignment after `const`")
            end
        end
        emit(ps, mark, word)
    elseif word == K"const"
        # const x = 1  ==>  (const (= x 1))
        bump(ps, TRIVIA_FLAG)
        scope_mark = nothing
        scope_k = peek(ps)
        if scope_k in KSet"local global"
            scope_mark = position(ps)
            bump(ps, TRIVIA_FLAG)
        end
        had_assignment = parse_global_local_const_vars(ps)
        if !isnothing(scope_mark)
            # const global x = 1  ==>  (const (global (= x 1)))
            # const local x = 1   ==>  (const (local (= x 1)))
            emit(ps, scope_mark, scope_k)
        end
        emit(ps, mark, K"const")
        if !had_assignment
            # const x .= 1  ==>  (error (const (.= x 1)))
            emit(ps, mark, K"error", error="expected assignment after `const`")
        end
    elseif word in KSet"function macro"
        bump(ps, TRIVIA_FLAG)
        bump_trivia(ps)
        has_body = parse_function_signature(ps, word == K"function")
        if has_body
            # The function body
            # function f() \n a \n b end  ==> (function (call f) (block a b))
            # function f() end            ==> (function (call f) (block))
            parse_block(ps)
            bump_closing_token(ps, K"end")
            emit(ps, mark, word)
        else
            # Function/macro definition with no methods
            # function f end       ==> (function f)
            # (function f \n end)  ==> (parens (function f))
            # function f \n\n end  ==> (function f)
            # function $f end      ==> (function ($ f))
            # macro f end          ==> (macro f)
            bump(ps, TRIVIA_FLAG, skip_newlines=true)
            emit(ps, mark, word)
        end
    elseif word == K"abstract"
        # Abstract type definitions
        # abstract type A end             ==>  (abstract A)
        # abstract type A ; end             ==>  (abstract A)
        # abstract type \n\n A \n\n end   ==>  (abstract A)
        # abstract type A <: B end        ==>  (abstract (<: A B))
        # abstract type A <: B{T,S} end   ==>  (abstract (<: A (curly B T S)))
        # Oddities allowed by parser
        # abstract type A < B end         ==>  (abstract (call-i A < B))
        bump(ps, TRIVIA_FLAG)
        @check peek(ps) == K"type"
        bump(ps, TRIVIA_FLAG)
        parse_subtype_spec(ps)
        bump_semicolon_trivia(ps)
        bump_closing_token(ps, K"end")
        emit(ps, mark, K"abstract")
    elseif word in KSet"struct mutable"
        # struct A <: B \n a::X \n end  ==>  (struct (<: A B) (block (::-i a X)))
        # struct A \n a \n b \n end  ==>  (struct A (block a b))
        #v1.7: struct A const a end  ==>  (struct A (block (error (const a))))
        #v1.8: struct A const a end  ==>  (struct A (block (const a)))
        is_mut = word == K"mutable"
        if is_mut
            # mutable struct A end  ==>  (struct-mut A (block))
            bump(ps, TRIVIA_FLAG)
        else
            # struct A end  ==>  (struct A (block))
        end
        @check peek(ps) == K"struct"
        bump(ps, TRIVIA_FLAG)
        parse_subtype_spec(ps)
        parse_block(ps, ps1->parse_docstring(ps1, parse_struct_field))
        bump_closing_token(ps, K"end")
        emit(ps, mark, K"struct", is_mut ? MUTABLE_FLAG : EMPTY_FLAGS)
    elseif word == K"primitive"
        # primitive type A 32 end             ==> (primitive A 32)
        # primitive type A 32 ; end           ==> (primitive A 32)
        # primitive type A $N end             ==> (primitive A ($ N))
        # primitive type A <: B \n 8 \n end   ==> (primitive (<: A B) 8)
        bump(ps, TRIVIA_FLAG)
        @check peek(ps) == K"type"
        bump(ps, TRIVIA_FLAG)
        let ps = with_space_sensitive(ps)
            parse_subtype_spec(ps)
            parse_cond(ps)
        end
        bump_semicolon_trivia(ps)
        bump_closing_token(ps, K"end")
        emit(ps, mark, K"primitive")
    elseif word == K"typegroup"
        # Grouped type definitions (mutually recursive)
        # typegroup struct A ... end end  ==> (typegroup (block ...))
        bump(ps, TRIVIA_FLAG)
        parse_block(ps, parse_docstring)
        bump_closing_token(ps, K"end")
        emit(ps, mark, K"typegroup")
        min_supported_version(v"1.14", ps, mark, "typegroup")
    elseif word == K"try"
        parse_try(ps)
    elseif word == K"return"
        bump(ps, TRIVIA_FLAG)
        k = peek(ps)
        if v_1_14_break_cont_ret ? is_nd_resword_closer(ps, k) :
                                   (k == K"NewlineWs" || is_closing_token(ps, k))
            # return\nx   ==> (return)
            # (return)    ==> (parens (return))
        else
            # return x    ==>  (return x)
            # return x,y  ==>  (return (tuple x y))
            parse_eq(ps)
        end
        emit(ps, mark, K"return")
    elseif word == K"break" || word == K"continue"
        bump(ps, TRIVIA_FLAG)
        k = peek(ps)
        if v_1_14_break_cont_ret
            if is_nd_resword_closer(ps, k)
                # break            ==>  (break)
                # continue         ==>  (continue)
                emit(ps, mark, word)
            else
                # break label      ==>  (break label)           [1.14+]
                # continue label   ==>  (continue label)        [1.14+]
                bump_trivia(ps)
                emark = position(ps)
                parse_unary_prefix(ps)
                b = peek_behind(ps)
                if b.orig_kind == K"Identifier" || is_contextual_keyword(b.orig_kind) ||
                        b.kind == K"$" || b.kind == K"var"
                    # Label ok. Check for `break label value` syntax
                    if word == K"break"
                        t2 = peek_token(ps)
                        if !is_nd_resword_closer(ps, kind(t2))
                            # break label val  ==>  (break label val)       [1.14+]
                            if !preceding_whitespace(t2)
                                bump_invisible(ps, K"error", TRIVIA_FLAG,
                                               error="expected space after break label")
                            end
                            parse_eq(ps)
                        end
                    end
                else
                    emit(ps, emark, K"error", TRIVIA_FLAG,
                         error="expected identifier for break label")
                end
                emit(ps, mark, word)
                if !is_nd_resword_closer(ps, peek(ps))
                    recover(is_nd_resword_closer, ps, TRIVIA_FLAG,
                            error="unexpected token after $(untokenize(word))")
                end
            end
        else
            if k in KSet"NewlineWs ; ) : EndMarker" || (k == K"end" && !ps.end_symbol)
                # break  ==>  (break)
                emit(ps, mark, word)
            else
                # break label ==> ERROR
                recover(is_closer_or_newline, ps, TRIVIA_FLAG,
                        error="unexpected token after $(untokenize(word))")
                emit(ps, mark, word)
                if k == K"Identifier" || is_contextual_keyword(k) || k == K"$" || k == K"var"
                    min_supported_version(v"1.14", ps, mark, "labeled `break` and `continue`")
                end
            end
        end
    elseif word in KSet"module baremodule"
        # module A end  ==> (module A (block))
        # baremodule A end ==> (module-bare A (block))
        bump(ps, TRIVIA_FLAG)
        if is_reserved_word(peek(ps))
            # module do \n end  ==>  (module (error do) (block))
            bump(ps, error="Invalid module name")
        else
            if ps.stream.version >= (1, 14)
                # Encode the parser version that parsed this module - the runtime
                # will use this to set the same parser version for runtime `include`
                # etc into this module.
                bump_invisible(ps, K"VERSION",
                    set_numeric_flags(ps.stream.version[2] * 10))
            end
            # module $A end  ==>  (module ($ A) (block))
            parse_unary_prefix(ps)
        end
        # module A \n a \n b \n end  ==>  (module A (block a b))
        # module A \n "x"\na \n end  ==>  (module A (block (doc (string "x") a)))
        parse_block(ps, parse_public)
        bump_closing_token(ps, K"end")
        emit(ps, mark, K"module",
             word == K"baremodule" ? BARE_MODULE_FLAG : EMPTY_FLAGS)
    elseif word in KSet"export public"
        # export a         ==>  (export a)
        # export @a        ==>  (export (macro_name a))
        # export a, \n @b  ==>  (export a (macro_name b))
        # export +, ==     ==>  (export + ==)
        # export \n a      ==>  (export a)
        # export \$a, \$(a*b) ==> (export (\$ a) (\$ (parens (call-i a * b))))
        bump(ps, TRIVIA_FLAG)
        parse_comma_separated(ps, x->parse_import_atsym(x, false))
        emit(ps, mark, word)
    elseif word in KSet"import using"
        parse_imports(ps)
    elseif word == K"do"
        bump(ps, TRIVIA_FLAG, error="invalid `do` syntax")
    else
        internal_error("unhandled reserved word ", string(word))
    end
end

# Parse if-elseif-else-end expressions
#
# if a xx elseif b yy else zz end   ==>  (if a (block xx) (elseif b (block yy) (block zz)))
function parse_if_elseif(ps, is_elseif=false, is_elseif_whitespace_err=false)
    mark = position(ps)
    word = peek(ps)
    if is_elseif_whitespace_err
        # Only get here on recovery from error case - pretend we're parsing elseif.
        word = K"elseif"
    else
        bump(ps, TRIVIA_FLAG)
    end
    if peek(ps) in KSet"NewlineWs end"
        # if end      ==>  (if (error) (block))
        # if \n end   ==>  (if (error) (block))
        bump_trivia(ps, error="missing condition in `$(untokenize(word))`")
    else
        # if a end      ==>  (if a (block))
        # if a xx end   ==>  (if a (block xx))
        parse_cond(ps)
    end
    # if a \n\n xx \n\n end   ==>  (if a (block xx))
    parse_block(ps)
    bump_trivia(ps)
    k = peek(ps)
    if k == K"elseif"
        # if a xx elseif b yy end   ==>  (if a (block xx) (elseif b (block yy)))
        parse_if_elseif(ps, true)
    elseif k == K"else"
        emark = position(ps)
        bump(ps, TRIVIA_FLAG)
        if peek(ps) == K"if"
            # Recovery: User wrote `else if` by mistake ?
            # if a xx else if b yy end  ==>  (if a (block xx) (error-t) (elseif b (block yy)))
            bump(ps, TRIVIA_FLAG)
            emit(ps, emark, K"error", TRIVIA_FLAG,
                 error="use `elseif` instead of `else if`")
            parse_if_elseif(ps, true, true)
        else
            # if a xx else yy end   ==>  (if a (block xx) (block yy))
            parse_block(ps)
        end
    end
    if !is_elseif
        bump_closing_token(ps, K"end")
    end
    emit(ps, mark, word)
end

# Like parse_assignment, but specialized so that we can omit the
# tuple when there's commas but no assignment.
function parse_global_local_const_vars(ps)
    mark = position(ps)
    n_commas = parse_comma(ps, false)
    # `isassign` is true for a (possibly dotted) operator followed by `=`, ie a
    # compound assignment like `x += 1` or `x .+= 1`.
    (isdot, isassign, t) = peek_dotted_op_token(ps)

    if is_prec_assignment(t) || isassign
        if n_commas >= 1
            # const x,y = 1,2  ==>  (const (= (tuple x y) (tuple 1 2)))
            emit(ps, mark, K"tuple")
        end
        # const x = 1   ==>  (const (= x 1))
        # global x ~ 1  ==>  (global (call-i x ~ 1))
        # global x += 1 ==>  (global (op= x + 1))
        parse_assignment_with_initial_ex(ps, mark, parse_comma)
    else
        # global x,y   ==>  (global x y)
    end
    return kind(t) == K"=" && !isdot
end

# Parse function and macro definitions
function parse_function_signature(ps::ParseState, is_function::Bool)
    is_anon_func = false
    needs_parse_call = true

    mark = position(ps)
    if !is_function
        # Parse macro name
        parse_unary_prefix(ps)
        kb = peek_behind(ps).orig_kind
        if is_initial_reserved_word(ps, kb)
            # macro while(ex) end  ==> (macro (call (error while) ex) (block))
            emit(ps, mark, K"error", error="invalid macro name")
        else
            # macro f()     end  ==>  (macro (call f) (block))
            # macro (:)(ex) end  ==>  (macro (call (parens :) ex) (block))
            # macro (type)(ex) end  ==>  (macro (call (parens type) ex) (block))
            # macro $f()    end  ==>  (macro (call ($ f)) (block))
            # macro ($f)()  end  ==>  (macro (call (parens ($ f))) (block))
        end
    else
        if peek(ps) != K"("
            # function f() end  ==> (function (call f))
            parse_unary_prefix(ps)
        else
            # When an initial parenthesis is present, we need to distinguish
            # between
            # * The function name in parens, followed by (args...)
            # * An anonymous function argument list in parens
            # * The whole function declaration, in parens
            bump(ps, TRIVIA_FLAG)
            is_empty_tuple = peek(ps, skip_newlines=true) == K")"
            opts = parse_brackets(ps, K")") do had_commas, had_splat, num_semis, num_subexprs
                _parsed_call = was_eventually_call(ps)
                _maybe_grouping_parens = !had_commas && !had_splat && num_semis == 0 && num_subexprs == 1
                # Check if there's a newline between `)` and the next `(` or `.`.
                # We need to find where `)` is and check what immediately follows it.
                # If peek(1, skip_newlines=false) is `)`, we're directly before it.
                # Otherwise there's whitespace/newline before `)`.
                next_token_pos = if peek(ps, 1, skip_newlines=false) == K")"
                    # Directly before ), token after ) is at 2
                    2
                else
                    # There's whitespace before ), so ) is at 2
                    # and what follows ) is at 3
                    3
                end
                token_after_paren = peek(ps, next_token_pos, skip_newlines=false)
                # If token_after_paren is a newline, this is an anonymous function
                has_newline_after_paren = _maybe_grouping_parens && token_after_paren == K"NewlineWs"
                # Get the next significant token to determine if we need to parse a call
                next_kind = peek(ps, 2, skip_newlines=_maybe_grouping_parens && !has_newline_after_paren)
                _needs_parse_call = next_kind ∈ KSet"( ."
                _is_anon_func = (!_needs_parse_call && !_parsed_call) || had_commas
                return (needs_parameters      = _is_anon_func,
                        is_anon_func          = _is_anon_func,
                        parsed_call           = _parsed_call,
                        needs_parse_call      = _needs_parse_call,
                        maybe_grouping_parens = _maybe_grouping_parens)
            end::NamedTuple{(:needs_parameters, :is_anon_func, :parsed_call, :needs_parse_call, :maybe_grouping_parens, :delim_flags),
                            Tuple{Bool, Bool, Bool, Bool, Bool, RawFlags}}
            is_anon_func = opts.is_anon_func
            needs_parse_call = opts.needs_parse_call
            if is_anon_func
                # function (x) body end ==>  (function (tuple-p x) (block body))
                # function (x::f()) end ==>  (function (tuple-p (::-i x (call f))) (block))
                # function (x,y) end    ==>  (function (tuple-p x y) (block))
                # function (x=1) end    ==>  (function (tuple-p (= x 1)) (block))
                # function (;x=1) end   ==>  (function (tuple-p (parameters (= x 1))) (block))
                # function (f(x),) end  ==>  (function (tuple-p (call f x)) (block))
                ambiguous_parens = opts.maybe_grouping_parens &&
                                   peek_behind(ps).kind in KSet"macrocall $"
                emit(ps, mark, K"tuple", PARENS_FLAG|opts.delim_flags)
                if ambiguous_parens
                    # Got something like `(@f(x))`. Is it anon `(@f(x),)` or named sig `@f(x)` ??
                    emit(ps, mark, K"error", error="Ambiguous signature. Add a trailing comma if this is a 1-argument anonymous function; remove parentheses if this is a macro call acting as function signature.")
                end
            elseif is_empty_tuple
                # Weird case which is consistent with parse_paren but will be
                # rejected in lowering
                # function ()(x) end  ==> (function (call (tuple-p) x) (block))
                emit(ps, mark, K"tuple", PARENS_FLAG)
            else
                # function (A).f() end  ==> (function (call (. (parens A) f)) (block))
                # function (:)() end    ==> (function (call (parens :)) (block))
                # function (x::T)() end ==> (function (call (parens (::-i x T))) (block))
                # function (::T)() end  ==> (function (call (parens (::-pre T))) (block))
                # function (:*=(f))() end  ==> (function (call (parens (call (quote-: (op= *)) f))) (block))
                emit(ps, mark, K"parens", PARENS_FLAG)
            end
        end
        if !is_anon_func
            kb = peek_behind(ps).orig_kind
            if is_reserved_word(kb)
                # function begin() end  ==>  (function (call (error begin)) (block))
                emit(ps, mark, K"error", error="invalid function name")
            else
                # function f() end     ==>  (function (call f) (block))
                # function type() end  ==>  (function (call type) (block))
                # function \n f() end  ==>  (function (call f) (block))
                # function $f() end    ==>  (function (call ($ f)) (block))
                # function (::Type{T})(x) end ==> (function (call (parens (::-pre (curly Type T))) x) (block))
            end
        end
    end
    if needs_parse_call
        # Parse function argument list
        # function f(x,y)  end    ==>  (function (call f x y) (block))
        # function f{T}()  end    ==>  (function (call (curly f T)) (block))
        # function A.f()   end    ==>  (function (call (. A f)) (block))
        parse_call_chain(ps, mark)
        sig_kind = peek_behind(ps).kind
        if sig_kind in KSet"Identifier var $" && peek(ps, skip_newlines=true) == K"end"
            # function f end ==> (function f)
            # function $f end ==> (function $f)
            return false
        elseif sig_kind == K"macrocall"
            min_supported_version(v"1.12", ps, mark, "macro call as function signature")
        elseif sig_kind != K"call"
            # function f body end  ==>  (function (error f) (block body))
            emit(ps, mark, K"error",
                error="Invalid signature in $(is_function ? "function" : "macro") definition")
        end
    end
    if is_function && peek(ps) == K"::"
        # Function return type
        # function f()::T    end   ==>  (function (::-i (call f) T) (block))
        # function f()::g(T) end   ==>  (function (::-i (call f) (call g T)) (block))
        bump(ps, TRIVIA_FLAG)
        parse_call(ps)
        emit(ps, mark, K"::", INFIX_FLAG)
    end
    if peek(ps) == K"where"
        # Function signature where syntax
        # function f() where {T} end   ==>  (function (where (call f) (braces T)) (block))
        # function f() where T   end   ==>  (function (where (call f) T) (block))
        parse_where_chain(ps, mark)
    end
    # function f()::S where T end ==> (function (where (::-i (call f) S) T) (block))
    #
    # Ugly cases for compat where extra parentheses existed and we've
    # already parsed at least the call part of the signature
    #
    # function (f() where T) end         ==> (function (where (call f) T) (block))
    # function (f()) where T end         ==> (function (where (call f) T) (block))
    # function (f() where T) where U end ==> (function (where (where (call f) T) U) (block))
    # function (f()::S) end              ==> (function (parens (::-i (call f) S)) (block))
    # function ((f()::S) where T) end    ==> (function (where (parens (::-i (call f) S)) T) (block))
    #
    # TODO: Warn for use of parens? The precedence of `::` and
    # `where` don't work inside parens so this is a bit of a syntax
    # oddity/aberration.
    return true
end

# Parse a try block
#
# try \n x \n catch e \n y \n finally \n z end  ==>  (try (block x) (catch e (block y)) (finally (block z)))
#v1.8: try \n x \n catch e \n y \n else z finally \n w end  ==>  (try (block x) (catch e (block y)) (else (block z)) (finally (block w)))
#
# flisp: embedded in parse_resword
function parse_try(ps)
    mark = position(ps)
    bump(ps, TRIVIA_FLAG)
    parse_block(ps)
    has_catch = false
    has_finally = false
    bump_trivia(ps)
    if peek(ps) == K"catch"
        has_catch = true
        parse_catch(ps)
    end
    bump_trivia(ps)
    if peek(ps) == K"else"
        # catch-else syntax: https://github.com/JuliaLang/julia/pull/42211
        #
        #v1.8: try catch ; else end ==> (try (block) (catch □ (block)) (else (block)))
        else_mark = position(ps)
        bump(ps, TRIVIA_FLAG)
        parse_block(ps)
        if !has_catch
            #v1.8: try else x finally y end ==> (try (block) (else (error (block x))) (finally (block y)))
            emit(ps, else_mark, K"error", error="Expected `catch` before `else`")
        end
        #v1.7: try catch ; else end ==> (try (block) (catch □ (block)) (else (error (block))))
        min_supported_version(v"1.8", ps, else_mark, "`else` after `catch`")
        emit(ps, else_mark, K"else")
    end
    bump_trivia(ps)
    if peek(ps) == K"finally"
        finally_mark = position(ps)
        # try x finally y end  ==>  (try (block x) (finally (block y)))
        has_finally = true
        bump(ps, TRIVIA_FLAG)
        parse_block(ps)
        emit(ps, finally_mark, K"finally")
    end
    # Wart: the flisp parser allows finally before catch, the *opposite* order
    # in which these blocks execute.
    bump_trivia(ps)
    if !has_catch && peek(ps) == K"catch"
        # try x finally y catch e z end  ==>  (try (block x) (finally (block y)) (catch e (block z)))
        m = position(ps)
        parse_catch(ps)
        emit_diagnostic(ps, m,
            warning="`catch` after `finally` will execute out of order")
    end
    missing_recovery = !has_catch && !has_finally
    if missing_recovery
        # try x end  ==>  (try (block x) (error-t))
        bump_invisible(ps, K"error", TRIVIA_FLAG)
    end
    bump_closing_token(ps, K"end")
    emit(ps, mark, K"try")
    if missing_recovery
        emit_diagnostic(ps, mark, error="try without catch or finally")
    end
end

function parse_catch(ps::ParseState)
    mark = position(ps)
    bump(ps, TRIVIA_FLAG)
    k = peek(ps)
    if k in KSet"NewlineWs ;" || is_closing_token(ps, k)
        # try x catch end      ==>  (try (block x) (catch □ (block)))
        # try x catch ; y end  ==>  (try (block x) (catch □ (block y)))
        # try x catch \n y end ==>  (try (block x) (catch □ (block y)))
        bump_invisible(ps, K"Placeholder")
    else
        # try x catch e y end   ==>  (try (block x) (catch e (block y)))
        # try x catch $e y end  ==>  (try (block x) (catch ($ e) (block y)))
        m = position(ps)
        parse_eq_star(ps)
        if !(peek_behind(ps).kind in KSet"Identifier var $")
            # try x catch e+3 y end  ==>  (try (block x) (catch (error (call-i e + 3)) (block y)))
            emit(ps, m, K"error", error="a variable name is expected after `catch`")
        end
    end
    parse_block(ps)
    emit(ps, mark, K"catch")
end

# flisp: parse-do
function parse_do(ps::ParseState)
    mark = position(ps)
    bump(ps, TRIVIA_FLAG) # do
    ps = normal_context(ps)
    m = position(ps)
    if peek(ps) in KSet"NewlineWs ;"
        # f() do\nend        ==>  (call f (do (tuple) (block)))
        # f() do ; body end  ==>  (call f (do (tuple) (block body)))
        # this trivia needs to go into the tuple due to the way position()
        # works.
        bump(ps, TRIVIA_FLAG)
    else
        # f() do x, y\n body end  ==>  (call f (do (tuple x y) (block body)))
        parse_comma_separated(ps, parse_range)
    end
    emit(ps, m, K"tuple")
    parse_block(ps)
    bump_closing_token(ps, K"end")
    emit(ps, mark, K"do")
end

function _is_valid_macro_name(peektok)
    return !is_error(peektok.kind) && (peektok.is_leaf || peektok.kind == K"var")
end

# flisp: parse-macro-name
function parse_macro_name(ps::ParseState)
    # @! x   ==>  (macrocall @! x)
    # @.. x  ==>  (macrocall (macro_name ..) x)
    # @$ x   ==>  (macrocall (macro_name $) x)
    # @var"#" x   ==>  (macrocall (macro_name (var #)) x)
    bump_disallowed_space(ps)
    mark = position(ps)
    parse_atom(ps, false)
    b = peek_behind(ps, skip_parens=false)
    if b.kind == K"parens"
        emit_diagnostic(ps, mark,
            warning="parenthesizing macro names is unnecessary")
    elseif !_is_valid_macro_name(b)
        # @[x] y z  ==>  (macrocall (macro_name (error (vect x))) y z)
        emit(ps, mark, K"error", error="invalid macro name")
    end
end

# Parse an identifier, interpolation or @-prefixed symbol
#
# flisp: parse-atsym
function parse_import_atsym(ps::ParseState, allow_quotes=true)
    bump_trivia(ps)
    if peek(ps) == K"@"
        mark = position(ps)
        # export @a       ==>  (export (macro_name a))
        # export @var"'"  ==>  (export (macro_name (var ')))
        # export a, \n @b ==>  (export a (macro_name b))
        bump(ps, TRIVIA_FLAG)
        parse_macro_name(ps)
        emit(ps, mark, K"macro_name")
    else
        # export a  ==>  (export a)
        # export \n a  ==>  (export a)
        # export $a, $(a*b)  ==>  (export ($ a) (parens ($ (call * a b))))
        # export (x::T) ==> (export (error (parens (::-i x T))))
        # export outer  ==> (export outer)
        # export ($f)   ==> (export ($ f))
        mark = position(ps)
        # Syntax Edition TODO: make all the various ways to quote things inside
        # import paths an error and require `var""` in the few remaining cases.
        if allow_quotes && peek(ps) == K":" && !is_closing_token(ps, peek(ps,2))
            # import A.:+  ==>  (import (importpath A (quote-: +)))
            emit_diagnostic(ps, warning="quoting with `:` is not required here")
        end
        parse_unary_prefix(ps)
        pos = position(ps)
        warn_parens = false
        if peek_behind(ps, pos).kind == K"parens"
            # import A.(:+)  ==>  (import (importpath A (parens (quote-: +))))
            pos = first_child_position(ps, pos)
            warn_parens = true
        end
        if allow_quotes && peek_behind(ps, pos).kind == K"quote"
            pos = first_child_position(ps, pos)
            if peek_behind(ps, pos).kind == K"parens"
                # import A.:(+)  ==>  (import (importpath A (quote-: (parens +))))
                pos = first_child_position(ps, pos)
                warn_parens = true
            end
        end
        b = peek_behind(ps, pos)
        if warn_parens && b.kind != K"DotsIdentifier"
            emit_diagnostic(ps, mark, warning="parentheses are not required here")
        end
        ok = (b.is_leaf  && (b.kind == K"Identifier" || is_operator(b.kind))) ||
             (!b.is_leaf && (b.kind in KSet"$ var" || b.kind == K"DotsIdentifier"))
        if !ok
            emit(ps, mark, K"error", error="expected identifier")
        end
    end
end

# Parse import and using syntax
#
# flisp: parse-imports
function parse_imports(ps::ParseState)
    mark = position(ps)
    word = peek(ps)
    @check word in KSet"import using"
    bump(ps, TRIVIA_FLAG)
    emark = position(ps)
    initial_as = parse_import(ps, word, false)
    t = peek_token(ps)
    k = kind(t)
    has_import_prefix = false  # true if we have `prefix:` in `import prefix: stuff`
    has_comma = false
    if k == K":" && !preceding_whitespace(t)
        bump(ps, TRIVIA_FLAG)
        has_import_prefix = true
        if initial_as
            # import A as B: x  ==>  (import (: (error (as (importpath A) B)) (importpath x)))
            emit(ps, emark, K"error", error="`as` before `:` in import/using")
        end
    elseif k == K","
        bump(ps, TRIVIA_FLAG)
        has_comma = true
    end
    if has_import_prefix || has_comma
        # import A, y      ==>  (import (importpath A) (importpath y))
        # import A: x, y   ==>  (import (: (importpath A) (importpath x) (importpath y)))
        # import A: +, ==  ==>  (import (: (importpath A) (importpath +) (importpath ==)))
        has_import_prefix_ = has_import_prefix
        parse_comma_separated(ps, ps1->parse_import(ps1, word, has_import_prefix_))
        if peek(ps) == K":"
            # Error recovery
            # import A: x, B: y ==> (import (: (importpath A) (importpath x) (importpath B) (error-t (importpath y))))
            emark = position(ps)
            bump(ps, TRIVIA_FLAG)
            parse_comma_separated(ps, ps1->parse_import(ps1, word, has_import_prefix_))
            emit(ps, emark, K"error", TRIVIA_FLAG,
                 error="`:` can only be used when importing a single module. Split imports into multiple lines")
        end
    end
    if has_import_prefix
        # import A: x  ==>  (import (: (importpath A) (importpath x)))
        emit(ps, mark, K":")
    end
    # using  A  ==>  (using (importpath A))
    # import A  ==>  (import (importpath A))
    emit(ps, mark, word)
end

# Parse individual module path and renaming with `as`
#
# flisp: parse-import
function parse_import(ps::ParseState, word, has_import_prefix)
    mark = position(ps)
    parse_import_path(ps)
    # import A: x, y   ==>  (import (: (importpath A) (importpath x) (importpath y)))
    if peek(ps) == K"as"
        # import A as B     ==>  (import (as (importpath A) B))
        # import A: x as y  ==>  (import (: (importpath A) (as (importpath x) y)))
        # using  A: x as y  ==>  (using (: (importpath A) (as (importpath x) y)))
        bump(ps, TRIVIA_FLAG)
        parse_import_atsym(ps, false)
        emit(ps, mark, K"as")
        if word == K"using" && !has_import_prefix
            # using A as B     ==>  (using (error (as (importpath A) B)))
            # using A, B as C  ==>  (using (importpath A) (error (as (importpath B) C)))
            emit(ps, mark, K"error",
                 error="`using` with `as` renaming requires a `:` and context module")
        end
        #v1.5: import A as B     ==>  (import (error (as (importpath A) B)))
        min_supported_version(v"1.6", ps, mark, "`import ... as`")
        return true
    else
        return false
    end
end

# flisp: parse-import-path
function parse_import_path(ps::ParseState)
    mark = position(ps)
    bump_trivia(ps)
    # The tokenizer produces conjoined dotted tokens .. and ...
    # When parsing import we must split these into single dots
    # import .A     ==> (import (importpath . A))
    # import ..A    ==> (import (importpath . . A))
    # import ...A   ==> (import (importpath . . . A))
    # import ....A  ==> (import (importpath . . . . A))
    # Dots with spaces are allowed (a misfeature?)
    # import . .A    ==> (import (importpath . . A))
    # Modules with operator symbol names
    # import .⋆  ==>  (import (importpath . ⋆))
    first_dot = true
    while true
        t = peek_token(ps)
        k = kind(t)
        if !first_dot && preceding_whitespace(t)
            emit_diagnostic(ps, whitespace=true,
                            warning="space between dots in import path")
        end
        if k == K"."
            bump(ps)
        else
            break
        end
        first_dot = false
    end
    # import @x     ==>  (import (importpath (macro_name x)))
    # import $A     ==>  (import (importpath ($ A)))
    parse_import_atsym(ps, false)
    while true
        t = peek_token(ps)
        k = kind(t)
        if k == K"."
            # import A.B    ==>  (import (importpath A B))
            # import $A.@x  ==>  (import (importpath ($ A) (macro_name x)))
            # import A.B.C  ==>  (import (importpath A B C))
            # import A.⋆.f  ==>  (import (importpath A ⋆ f))
            next_tok = peek_token(ps, 2)
            if is_operator(kind(next_tok))
                if kind(next_tok) == K"." && peek(ps, 3) == K"."
                    # Import the .. operator
                    # import A...  ==>  (import (importpath A (DotsIdentifier-2)))
                    bump_disallowed_space(ps)
                    bump(ps, TRIVIA_FLAG)
                    dotmark = position(ps)
                    bump(ps, TRIVIA_FLAG)
                    bump(ps, TRIVIA_FLAG)
                    emit(ps, dotmark, K"DotsIdentifier", set_numeric_flags(2))
                    continue
                end
                if preceding_whitespace(t)
                    # Whitespace in import path allowed but discouraged
                    # import A .==  ==>  (import (importpath A ==))
                    emit_diagnostic(ps, whitespace=true,
                                    warning="space between dots in import path")
                end
                bump_trivia(ps)
            else
                bump_disallowed_space(ps)
            end
            bump(ps, TRIVIA_FLAG)
            parse_import_atsym(ps)
        elseif k in KSet"NewlineWs ; , : EndMarker"
            # import A; B  ==>  (import (importpath A))
            break
        else
            # Could we emit a more comprehensible error here?
            break
        end
    end
    emit(ps, mark, K"importpath")
end

# parse comma-separated assignments, like "i=1:n,j=1:m,..."
#
# flisp: parse-comma-separated
function parse_comma_separated(ps::ParseState, down)
    n_subexprs = 0
    while true
        down(ps)
        n_subexprs += 1
        if peek(ps) == K","
            bump(ps, TRIVIA_FLAG)
        else
            break
        end
    end
    return n_subexprs
end

# FIXME(sschaub): for backwards compatibility, allows newline before =/in/∈
# in generator expressions. See issue #37393
function peek_skip_newline_in_gen(ps::ParseState, n=1)
    k = peek(ps, n)
    if ps.for_generator && k == K"NewlineWs"
        k = peek(ps, n+1)
    end
    return k
end

# parse comma-separated "assignment" but allowing `in` and `∈` as assignment operators
#
# i = rhs   ==>  (= i rhs)
# i in rhs  ==>  (= i rhs)
# i ∈ rhs   ==>  (= i rhs)
#
# i = 1:10       ==>  (= i (call : 1 10))
# (i,j) in iter  ==>  (= (tuple-p i j) iter)
#
# flisp: parse-iteration-spec
function parse_iteration_spec(ps::ParseState)
    mark = position(ps)
    # Handle `outer` contextual keyword
    parse_pipe_lt(with_space_sensitive(ps))
    if peek_behind(ps).orig_kind == K"outer"
        if peek_skip_newline_in_gen(ps) in KSet"= in ∈"
            # Not outer keyword
            # outer = rhs        ==>  (iteration (in outer rhs))
            # outer <| x = rhs   ==>  (iteration (in (call-i outer <| x) rhs))
        else
            # outer i = rhs      ==>  (iteration (in (outer i) rhs))
            # outer (x,y) = rhs  ==>  (iteration (in (outer (tuple-p x y)) rhs))
            reset_node!(ps, position(ps), kind=K"outer", flags=TRIVIA_FLAG)
            parse_pipe_lt(ps)
            emit(ps, mark, K"outer")
        end
    end
    if peek_skip_newline_in_gen(ps) in KSet"= in ∈"
        bump(ps, TRIVIA_FLAG)
        parse_pipe_lt(ps)
    else
        # Recovery heuristic
        recover(ps, error="invalid iteration spec: expected one of `=` `in` or `∈`") do ps, k
            k in KSet", NewlineWs" || is_closing_token(ps, k)
        end
        # Or try parse_pipe_lt ???
    end
    emit(ps, mark, K"in")
end

# Parse an iteration spec, or a comma separate list of such for for loops and
# generators
function parse_iteration_specs(ps::ParseState)
    mark = position(ps)
    _n_iters = parse_comma_separated(ps, parse_iteration_spec)
    emit(ps, mark, K"iteration")
end

# flisp: parse-space-separated-exprs
function parse_space_separated_exprs(ps::ParseState)
    ps = with_space_sensitive(ps)
    n_sep = 0
    while true
        k = peek(ps)
        if is_closing_token(ps, k) || k == K"NewlineWs" ||
                (ps.for_generator && k == K"for")
            break
        end
        parse_eq(ps)
        n_sep += 1
    end
    return n_sep
end

# like parse-arglist, but with `for` parsed as a generator
#
# flisp: parse-call-arglist
function parse_call_arglist(ps::ParseState, closer)
    ps = ParseState(ps, for_generator=true)

    parse_brackets(ps, closer, false) do _, _, _, _
        return (needs_parameters=true,)
    end::NamedTuple{(:needs_parameters, :delim_flags), Tuple{Bool, RawFlags}}
end

# Parse the suffix of comma-separated array expressions such as
# [x, suffix].  Consumes `closer`, but does not emit the AST node for the
# surrounding brackets.
#
# flisp: parse-vect
function parse_vect(ps::ParseState, closer, prefix_trailing_comma)
    # [x, y]        ==>  (vect x y)
    # [x, y]        ==>  (vect x y)
    # [x,y ; z]     ==>  (vect x y (parameters z))
    # [x=1, y=2]    ==>  (vect (= x 1) (= y 2))
    # [x=1, ; y=2]  ==>  (vect (= x 1) (parameters (= y 2)))
    opts = parse_brackets(ps, closer) do _, _, _, num_subexprs
        return (needs_parameters=true,
                num_subexprs=num_subexprs)
    end::NamedTuple{(:needs_parameters, :num_subexprs, :delim_flags), Tuple{Bool, Int, RawFlags}}
    delim_flags = opts.delim_flags
    if opts.num_subexprs == 0 && prefix_trailing_comma
        delim_flags |= TRAILING_COMMA_FLAG
    end
    return (K"vect", delim_flags)
end

# Parse generators
#
# We represent generators quite differently from `Expr`:
# * Iteration variables and their iterators are grouped within K"iteration"
#   nodes, as in the short form of `for` loops.
# * The `generator` kind is used for both cartesian and flattened generators
#
# (x for a in as for b in bs) ==> (parens (generator x (iteration (in a as)) (iteration (in b bs))))
# (x for a in as, b in bs) ==> (parens (generator x (iteration (in a as) (in b bs))))
# (x for a in as, b in bs if z)  ==> (parens (generator x (filter (iteration (in a as) (in b bs)) z)))
#
# flisp: parse-generator
function parse_generator(ps::ParseState, mark)
    while (t = peek_token(ps); kind(t) == K"for")
        if !preceding_whitespace(t)
            # ((x)for x in xs)  ==>  (parens (generator (parens x) (error) (iteration (in x xs))))
            bump_invisible(ps, K"error", TRIVIA_FLAG,
                           error="Expected space before `for` in generator")
        end
        bump(ps, TRIVIA_FLAG)
        iter_mark = position(ps)
        parse_iteration_specs(ps)
        if peek(ps) == K"if"
            # (x for a in as if z) ==> (parens (generator x (filter (iteration (in a as)) z)))
            bump(ps, TRIVIA_FLAG)
            parse_cond(ps)
            emit(ps, iter_mark, K"filter")
        end
    end
    emit(ps, mark, K"generator")
end

# flisp: parse-comprehension
function parse_comprehension(ps::ParseState, mark, closer)
    # [x for a in as] ==> (comprehension (generator x (iteration (in a as))))
    ps = ParseState(ps, whitespace_newline=true,
                    space_sensitive=false,
                    end_symbol=false)
    parse_generator(ps, mark)
    bump_closing_token(ps, closer)
    return (K"comprehension", EMPTY_FLAGS)
end

# Parse array concatenation syntax with multiple semicolons
#
# Normal matrix construction syntax
# [x y ; z w]     ==>  (vcat (row x y) (row z w))
# [x y ; z w ; a b]  ==>  (vcat (row x y) (row z w) (row a b))
# [x ; y ; z]     ==>  (vcat x y z)
# [x;]            ==>  (vcat x)
# [x y]           ==>  (hcat x y)
#
# Mismatched rows
# [x y ; z]     ==>  (vcat (row x y) z)
#
# Single elements in rows
#v1.7: [x ; y ;; z ]  ==>  (ncat-2 (nrow-1 x y) z)
#v1.7: [x  y ;;; z ]  ==>  (ncat-3 (row x y) z)
#
# Higher dimensional ncat
# Row major
#v1.7: [x y ; z w ;;; a b ; c d]  ==>
#     (ncat-3 (nrow-1 (row x y) (row z w)) (nrow-1 (row a b) (row c d)))
# Column major
#v1.7: [x ; y ;; z ; w ;;; a ; b ;; c ; d]  ==>
#     (ncat-3 (nrow-2 (nrow-1 x y) (nrow-1 z w)) (nrow-2 (nrow-1 a b) (nrow-1 c d)))
#
# flisp: parse-array
function parse_array(ps::ParseState, mark, closer, end_is_symbol)
    ps = ParseState(ps, end_symbol=end_is_symbol)

    array_order = Ref(:unknown)
    # Outer array parsing loop - parse chain of separators with descending
    # precedence such as
    #v1.7: [a ; b ;; c ;;; d ;;;; e] ==> (ncat-4 (ncat-3 (ncat-2 (ncat-1 a b) c) d) e)
    #
    # Ascending and equal precedence is handled by parse_array_inner.
    #
    # This is a variant of a Pratt parser, but we have a separate outer loop
    # because there's no minimum precedence/binding power - you can always get
    # a lower binding power by adding more semicolons.
    #
    # For an excellent overview of Pratt parsing, see
    # https://matklad.github.io/2020/04/13/simple-but-powerful-pratt-parsing.html
    (dim, binding_power) = parse_array_separator(ps, array_order)
    if binding_power == typemin(Int)
        # [x@y  ==>  (hcat x (error-t ✘ y))
        bump_closing_token(ps, closer)
        return (K"hcat", 0)
    end
    while true
        (next_dim, next_bp) = parse_array_inner(ps, binding_power, array_order)
        if next_bp == typemin(Int)
            break
        end
        if binding_power == 0
            emit(ps, mark, K"row")
        else
            emit(ps, mark, K"nrow", set_numeric_flags(dim))
        end
        dim = next_dim
        binding_power = next_bp
    end
    bump_closing_token(ps, closer)
    return binding_power == -1 ? (K"vcat", 0) :
           binding_power ==  0 ? (K"hcat", 0) :
           (K"ncat", dim)
end

# Parse equal and ascending precedence chains of array concatenation operators -
# semicolons, newlines and whitespace. Invariants:
#
# * The caller must have already consumed
#   - The left hand side
#   - The concatenation operator, providing `binding_power`.
#   So eg, we're here in the input stream, either at an element or closing token
#                |
#          [a ;; b ; c ]
#          [a ;; ]
#
# * The caller must call emit() to delimit the AST node for this binding power.
#
function parse_array_inner(ps, binding_power, array_order)
    mark = NO_POSITION
    dim = -1
    bp = binding_power
    while true
        if bp < binding_power
            return (dim, bp)
        end
        # Allow trailing separators
        # [a ;] ==> (vcat a)
        # [a ; b;;] ==> (ncat-2 (nrow-1 a b))
        if is_closing_token(ps, peek(ps))
            return (typemin(Int), typemin(Int))
        end
        if bp == binding_power
            # Parse one expression
            mark = position(ps)
            parse_eq_star(ps)
            (next_dim, next_bp) = parse_array_separator(ps, array_order)
        else # bp > binding_power
            # Recurse to parse a separator with greater binding power. Eg:
            # [a ;; b ; c ]
            #       |   ^------ the next input is here
            #       '---------- the mark is here
            (next_dim, next_bp) = parse_array_inner(ps, bp, array_order)
            if bp == 0
                emit(ps, mark, K"row")
            else
                emit(ps, mark, K"nrow", set_numeric_flags(dim))
            end
        end
        dim, bp = next_dim, next_bp
    end
end

# Parse a separator in an array concatenation
#
# Here we return a tuple (dim, binding_power) containing
# * Dimension on which the next separator acts
# * Binding power (precedence) of the separator, where whitespace binds
#   tightest:  ... < `;;;` < `;;` < `;`,`\n` < whitespace. We choose binding
#   power of 0 for whitespace and negative numbers for other separators.
#
function parse_array_separator(ps, array_order)
    sep_mismatch_err = "cannot mix space and ;; separators in an array expression, except to wrap a line"
    mark = position(ps)
    t = peek_token(ps, skip_newlines=true)
    if kind(t) == K";"
        # Newlines before semicolons are not significant
        # [a \n ;]     ==> (vcat a)
        bump_trivia(ps)
        n_semis = 1
        while true
            bump(ps, TRIVIA_FLAG)
            t = peek_token(ps)
            if kind(t) != K";"
                break
            end
            if preceding_whitespace(t)
                bump_disallowed_space(ps)
            end
            n_semis += 1
        end
        had_newline = peek(ps) == K"NewlineWs"
        # Newlines after semicolons are not significant
        # [a ; \n]     ==> (vcat a)
        # [a ; \n\n b] ==> (vcat a b)
        #v1.7: [a ;; \n b]  ==> (ncat-2 a b)
        bump_trivia(ps)
        if n_semis == 2
            if array_order[] === :row_major
                if had_newline
                    # In hcat with spaces as separators, `;;` is a line
                    # continuation character
                    #v1.7: [a b ;; \n c]  ==>  (hcat a b c)
                    #v1.7: [a b \n ;; c]  ==>  (ncat-2 (row a b (error-t)) c)
                    return (2, 0)
                else
                    # Can't mix spaces and multiple ;;
                    #v1.7:  [a b ;; c]  ==>  (ncat-2 (row a b (error-t)) c)
                    emit(ps, mark, K"error", TRIVIA_FLAG, error=sep_mismatch_err)
                end
            else
                array_order[] = :column_major
            end
        end
        return (n_semis, -n_semis)
    end
    t = peek_token(ps)
    k = kind(t)
    if k == K"NewlineWs"
        bump_trivia(ps)
        if peek(ps) == K"]"
            # Linebreaks not significant before closing `]`
            # [a b\n\n]  ==>  (hcat a b)
            return (typemin(Int), typemin(Int))
        else
            # Treat a linebreak prior to a value as a semicolon (ie, separator
            # for the first dimension) if no previous semicolons observed
            # [a \n b]  ==> (vcat a b)
            return (1, -1)
        end
    elseif k == K","
        # Treat `,` as semicolon for the purposes of recovery
        # [a; b, c] ==> (vcat a b (error-t) c)
        bump(ps, TRIVIA_FLAG, error="unexpected comma in array expression")
        return (1, -1)
    else
        if preceding_whitespace(t) && !is_closing_token(ps, k)
            if array_order[] === :column_major
                # Can't mix multiple ;'s and spaces
                #v1.7:  [a ;; b c]  ==>  (ncat-2 a (row b (error-t) c))
                bump_trivia(ps, TRIVIA_FLAG, error=sep_mismatch_err)
            else
                array_order[] = :row_major
            end
            return (2, 0)
        else
            # Something else; use typemin to exit array parsing
            return (typemin(Int), typemin(Int))
        end
    end
end

# Parse array concatenation/construction/indexing syntax inside of `[]` or `{}`.
# The opening bracket has been consumed.
#
# flisp: parse-cat
function parse_cat(ps::ParseState, closer, end_is_symbol)
    ps = ParseState(ps, range_colon_enabled=true,
                    space_sensitive=true,
                    where_enabled=true,
                    whitespace_newline=false,
                    for_generator=true)
    k = peek(ps, skip_newlines=true)
    mark = position(ps)
    if k == closer
        # []  ==>  (vect)
        ckind, cflags = parse_vect(ps, closer, false)
        return (ckind, cflags, 0)
    elseif k == K";"
        #v1.8: [;]           ==>  (ncat-1)
        #v1.8: [;;]          ==>  (ncat-2)
        #v1.8: [\n  ;; \n ]  ==>  (ncat-2)
        #v1.7: [;;]          ==>  (ncat-2 (error))
        bump_trivia(ps)
        dim, _ = parse_array_separator(ps, Ref(:unknown))
        min_supported_version(v"1.8", ps, mark, "empty multidimensional array syntax")
        bump_closing_token(ps, closer)
        return (K"ncat", EMPTY_FLAGS, dim)
    end
    parse_eq_star(ps)
    k = peek(ps, skip_newlines=true)
    if k == K"," || (is_closing_token(ps, k) && k != K";")
        prefix_trailing_comma = k == K","
        if prefix_trailing_comma
            # [x,]  ==>  (vect x)
            bump(ps, TRIVIA_FLAG; skip_newlines = true)
        end
        # [x]      ==>  (vect x)
        # [x \n ]  ==>  (vect x)
        # [x       ==>  (vect x (error-t))
        ckind, cflags = parse_vect(ps, closer, prefix_trailing_comma)
        return (ckind, cflags, 0)
    elseif k == K"for"
        # [x for a in as]  ==>  (comprehension (generator x (iteration (in a as))))
        # [x \n\n for a in as]  ==>  (comprehension (generator x (iteration (in a as))))
        ckind, cflags = parse_comprehension(ps, mark, closer)
        return (ckind, cflags, 0)
    else
        # [x y]  ==>  (hcat x y)
        # and other forms; See parse_array.
        ckind, dim = parse_array(ps, mark, closer, end_is_symbol)
        return (ckind, EMPTY_FLAGS, dim)
    end
end

function check_ncat_compat(ps, mark, k)
    # https://github.com/JuliaLang/julia/pull/33697
    if k == K"ncat"
        min_supported_version(v"1.7", ps, mark, "multidimensional array syntax")
    end
end

# Parse un-prefixed parenthesized syntax. This is hard because parentheses are
# *very* overloaded!
#
# flisp: parse-paren / parse-paren-
function parse_paren(ps::ParseState, check_identifiers=true, has_unary_prefix=false)
    ps = ParseState(ps, range_colon_enabled=true,
                    space_sensitive=false,
                    where_enabled=true,
                    whitespace_newline=true)
    mark = position(ps)
    @check peek(ps) == K"("
    bump(ps, TRIVIA_FLAG) # K"("
    (_isdot, isassign, tok) = peek_dotted_op_token(ps)
    k = kind(tok)
    if k == K")"
        # ()  ==>  (tuple-p)
        bump(ps, TRIVIA_FLAG)
        emit(ps, mark, K"tuple", PARENS_FLAG)
    elseif is_syntactic_operator(k) || isassign
        # allow :(=) etc in unchecked contexts, eg quotes
        # :(=)  ==>  (quote-: (parens =))
        parse_atom(ps, check_identifiers)
        bump_closing_token(ps, K")")
        emit(ps, mark, K"parens")
    elseif !check_identifiers && k == K"::" &&
            peek(ps, 2, skip_newlines=true) == K")"
        # allow :(::) as a special case
        # :(::)  ==>  (quote-: (parens ::))
        bump(ps)
        bump(ps, TRIVIA_FLAG, skip_newlines=true)
        emit(ps, mark, K"parens")
    else
        # Deal with all other cases of tuple or block syntax via the generic
        # parse_brackets
        initial_semi = peek(ps) == K";"
        opts = parse_brackets(ps, K")") do had_commas, had_splat, num_semis, num_subexprs
            is_tuple = had_commas || (had_splat && num_semis >= 1) ||
                       (initial_semi && (num_semis == 1 || num_subexprs > 0)) ||
                       (peek(ps, 2) == K"->" && (peek_behind(ps).kind != K"where" && !has_unary_prefix))
            return (needs_parameters=is_tuple,
                    is_tuple=is_tuple,
                    is_block=num_semis > 0)
        end::NamedTuple{(:needs_parameters, :is_tuple, :is_block, :delim_flags), Tuple{Bool, Bool, Bool, RawFlags}}
        if opts.is_tuple
            # Tuple syntax with commas
            # (x,)        ==>  (tuple-p x)
            # (x,y)       ==>  (tuple-p x y)
            # (x=1, y=2)  ==>  (tuple-p (= x 1) (= y 2))
            #
            # Named tuple with initial semicolon
            # (;)         ==>  (tuple-p (parameters))
            # (; a=1)     ==>  (tuple-p (parameters (= a 1)))
            #
            # Extra credit: nested parameters and frankentuples
            # (x...;)         ==> (tuple-p (... x) (parameters))
            # (x...; y)       ==> (tuple-p (... x) (parameters y))
            # (; a=1; b=2)    ==> (tuple-p (parameters (= a 1)) (parameters (= b 2)))
            # (a; b; c,d)     ==> (tuple-p a (parameters b) (parameters c d))
            # (a=1, b=2; c=3) ==> (tuple-p (= a 1) (= b 2) (parameters (= c 3)))
            emit(ps, mark, K"tuple", PARENS_FLAG|opts.delim_flags)
        elseif opts.is_block
            # Blocks
            # (;;)        ==>  (block-p)
            # (a=1;)      ==>  (block-p (= a 1))
            # (a;b;;c)    ==>  (block-p a b c)
            # (a=1; b=2)  ==>  (block-p (= a 1) (= b 2))
            emit(ps, mark, K"block", PARENS_FLAG)
        else
            # Parentheses used for grouping
            # (a * b)     ==>  (parens (call-i * a b))
            # (a=1)       ==>  (parens (= a 1))
            # (x)         ==>  (parens x)
            # (a...)      ==>  (parens (... a))
            emit(ps, mark, K"parens")
        end
    end
end

# Handle bracketed syntax inside any of () [] or {} where there's a mixture
# of commas and semicolon delimiters.
#
# For parentheses this is tricky because there's various cases to disambiguate,
# depending on outside context and the content of the brackets (number of
# semicolons, presence of commas or splats). The `after_parse` function must be
# provided by the caller to disambiguate these cases.
#
# Expressions (X; Y; Z) with more semicolons are also allowed by the flisp
# parser and generally parse as nested parameters blocks. This is invalid Julia
# syntax so the parse tree is pretty strange in these cases!  Some macros
# probably use it though.  Example:
#
# (a,b=1; c,d=2; e,f=3)  ==>  (tuple-p a (= b 1) (parameters c (= d 2)) (parameters e (= f 3)))
#
# flisp: parts of parse-paren- and parse-arglist
function parse_brackets(after_parse::F,
                        ps::ParseState, closing_kind, generator_is_last=true) where {F}
    ps = ParseState(ps, range_colon_enabled=true,
                    space_sensitive=false,
                    where_enabled=true,
                    whitespace_newline=true)
    params_positions = acquire_positions(ps.stream)
    num_subexprs = 0
    num_semis = 0
    had_commas = false
    had_splat = false
    param_start = nothing
    trailing_comma = false
    while true
        k = peek(ps)
        if k == closing_kind
            break
        elseif k == K";"
            # Start of parameters list
            # a, b; c d  ==>  a b (parameters c d)
            if !isnothing(param_start)
                push!(params_positions, emit(ps, param_start, K"TOMBSTONE"))
            end
            num_semis += 1
            param_start = position(ps)
            bump(ps, TRIVIA_FLAG)
            bump_trivia(ps)
        elseif is_closing_token(ps, k)
            trailing_comma = false
            # Error; handled below in bump_closing_token
            break
        else
            mark = position(ps)
            parse_eq_star(ps)
            trailing_comma = false
            num_subexprs += 1
            if num_subexprs == 1
                had_splat = peek_behind(ps).kind == K"..."
            end
            k = peek(ps, skip_newlines=true)
            if k == K"for"
                # Generator syntax
                # (x for a in as)  ==>  (parens (generator x (iteration (in a as))))
                parse_generator(ps, mark)
                if generator_is_last
                    break
                end
                k = peek(ps, skip_newlines=true)
            end
            if k == K","
                had_commas = true
                bump(ps, TRIVIA_FLAG)
                trailing_comma = true
            elseif k == K";" || k == closing_kind
                # Handled above
                continue
            else
                # Error - recovery done when consuming closing_kind
                break
            end
        end
    end
    if !isnothing(param_start) && position(ps) != param_start
        push!(params_positions, emit(ps, param_start, K"TOMBSTONE"))
    end
    opts = after_parse(had_commas, had_splat, num_semis, num_subexprs)
    # Emit nested parameter nodes if necessary
    if opts.needs_parameters
        for pos in params_positions
            reset_node!(ps, pos, kind=K"parameters")
        end
    end
    release_positions(ps.stream, params_positions)
    bump_closing_token(ps, closing_kind, " or `,`")
    return (; opts..., delim_flags=trailing_comma ? TRAILING_COMMA_FLAG : EMPTY_FLAGS)
end

_is_indentation(b::UInt8) = (b == u8" " || b == u8"\t")

# Parse a string, embedded interpolations and deindent triple quoted strings
# by marking indentation characters as whitespace trivia.
#
# flisp: parse-string-literal-, parse-interpolate
function parse_string(ps::ParseState, raw::Bool)
    mark = position(ps)
    delim_k = peek(ps)
    triplestr = delim_k in KSet"\"\"\" ```"
    string_chunk_kind = delim_k in KSet"\" \"\"\"" ? K"String" : K"CmdString"
    indent_ref_i = 0
    indent_ref_len = typemax(Int)
    indent_chunks = acquire_positions(ps.stream)
    txtbuf = unsafe_textbuf(ps)
    chunk_flags = raw ? RAW_STRING_FLAG : EMPTY_FLAGS
    bump(ps, TRIVIA_FLAG)
    first_chunk = true
    n_nontrivia_chunks = 0
    prev_chunk_newline = false
    while true
        t = peek_full_token(ps)
        k = kind(t)
        if k == K"$"
            if raw
                # FIXME: This case is actually a tokenization error:
                # The `K"$"` token should not occur when a raw string
                # is being parsed, but this would require the lexer to know
                # about the parse state. (see also parse_atom)
                break
            end
            if prev_chunk_newline
                # """\n$x\n a"""  ==>  (string-s x "\n" " a")
                indent_ref_i = first_byte(t)
                indent_ref_len = 0
            end
            bump(ps, TRIVIA_FLAG)
            k = peek(ps)
            if k == K"("
                # "a $(x + y) b"  ==>  (string "a " (parens (call-i x + y)) " b")
                # "hi$("ho")"     ==>  (string "hi" (parens (string "ho")))
                m = position(ps)
                bump(ps, TRIVIA_FLAG)
                opts = parse_brackets(ps, K")") do had_commas, _had_splat, num_semis, num_subexprs
                    return (needs_parameters=false,
                            simple_interp=!had_commas && num_semis == 0 && num_subexprs == 1)
                end::NamedTuple{(:needs_parameters, :simple_interp, :delim_flags), Tuple{Bool, Bool, RawFlags}}
                if !opts.simple_interp || peek_behind(ps, skip_parens=false).kind == K"generator"
                    # "$(x,y)" ==> (string (parens (error x y)))
                    emit(ps, m, K"error", error="invalid interpolation syntax")
                end
                emit(ps, m, K"parens")
            elseif k == K"var"
                # var identifiers disabled in strings
                # "$var"  ==>  (string var)
                bump(ps, remap_kind=K"Identifier")
            elseif k == K"Identifier" || is_keyword(k) || is_word_operator(k)
                # "a $foo b"  ==> (string "a " foo " b")
                # "$outer"    ==> (string outer)
                # "$in"       ==> (string in)
                parse_atom(ps)
            else
                bump_invisible(ps, K"error",
                    error="identifier or parenthesized expression expected after \$ in string")
            end
            first_chunk = false
            n_nontrivia_chunks += 1
            prev_chunk_newline = false
        elseif k == string_chunk_kind
            if triplestr && first_chunk && span(t) <= 2 &&
                    begin
                        s = span(t)
                        b = txtbuf[last_byte(t)]
                        # Test whether the string is a single logical newline
                        (s == 1 && (b == u8"\n" || b == u8"\r")) ||
                        (s == 2 && (txtbuf[first_byte(t)] == u8"\r" && b == u8"\n"))
                    end
                # First line of triple string is a newline only: mark as trivia.
                # """\nx"""    ==> (string-s "x")
                # """\n\nx"""  ==> (string-s "\n" "x")
                bump(ps, TRIVIA_FLAG)
                first_chunk = false
                prev_chunk_newline = true
            else
                if triplestr
                    # Triple-quoted dedenting:
                    # Various newlines (\n \r \r\n) and whitespace (' ' \t)
                    # """\n x\n y"""      ==> (string-s "x\n" "y")
                    # ```\n x\n y```      ==> (macrocall :(Core.var"@cmd") (cmdstring-s-r "x\n" "y"))
                    # """\r x\r y"""      ==> (string-s "x\n" "y")
                    # """\r\n x\r\n y"""  ==> (string-s "x\n" "y")
                    # Spaces or tabs or mixtures acceptable
                    # """\n\tx\n\ty"""    ==> (string-s "x\n" "y")
                    # """\n \tx\n \ty"""  ==> (string-s "x\n" "y")
                    #
                    # Mismatched tab vs space not deindented
                    # Find minimum common prefix in mismatched whitespace
                    # """\n\tx\n y"""     ==> (string-s "\tx\n" " y")
                    # """\n x\n  y"""   ==> (string-s "x\n" " y")
                    # """\n  x\n y"""   ==> (string-s " x\n" "y")
                    # """\n \tx\n  y""" ==> (string-s "\tx\n" " y")
                    # """\n  x\n \ty""" ==> (string-s " x\n" "\ty")
                    #
                    # Empty lines don't affect dedenting
                    # """\n x\n\n y"""    ==> (string-s "x\n" "\n" "y")
                    # Non-empty first line doesn't participate in deindentation
                    # """ x\n y"""    ==> (string-s " x\n" "y")
                    #
                    # Dedenting and interpolations
                    # """\n  $a\n  $b"""    ==> (string-s a "\n" b)
                    # """\n  $a \n  $b"""   ==> (string-s a " \n" b)
                    # """\n  $a\n  $b\n"""  ==> (string-s "  " a "\n" "  " b "\n")
                    #
                    if prev_chunk_newline && (b = txtbuf[first_byte(t)];
                                              b != u8"\n" && b != u8"\r")
                        # Compute length of longest common prefix of mixed
                        # spaces and tabs, in bytes
                        #
                        # Initial whitespace is never regarded as indentation
                        # in any triple quoted string chunk, as it's always
                        # preceded in the source code by a visible token of
                        # some kind; either a """ delimiter or $()
                        # interpolation.
                        if indent_ref_i == 0
                            # No indentation found yet. Find indentation we'll
                            # use as a reference
                            i = first_byte(t) - 1
                            while i < last_byte(t) && _is_indentation(txtbuf[i+1])
                                i += 1
                            end
                            indent_ref_i = first_byte(t)
                            indent_ref_len = i - first_byte(t) + 1
                        else
                            # Matching the current indentation with reference,
                            # shortening length if necessary.
                            j = 0
                            while j < span(t) && j < indent_ref_len
                                if txtbuf[j + first_byte(t)] != txtbuf[j + indent_ref_i]
                                    break
                                end
                                j += 1
                            end
                            indent_ref_len = min(indent_ref_len, j)
                        end
                        # Prepare a place for indentiation trivia, if necessary
                        push!(indent_chunks, bump_invisible(ps, K"TOMBSTONE"))
                    end
                    b = txtbuf[last_byte(t)]
                    prev_chunk_newline = b == UInt8('\n') || b == UInt8('\r')
                end
                bump(ps, chunk_flags)
                first_chunk = false
                n_nontrivia_chunks += 1
            end
        elseif  k == K"ErrorInvalidInterpolationTerminator" ||
                k == K"ErrorBidiFormatting" ||
                k == K"ErrorInvalidUTF8"
            # Treat these errors as string chunks
            bump(ps)
            n_nontrivia_chunks += 1
        else
            break
        end
    end
    had_end_delim = peek(ps) == delim_k
    if triplestr && prev_chunk_newline && had_end_delim
        # Newline at end of string
        # """\n x\n y\n"""    ==> (string-s " x\n" " y\n")
        indent_ref_len = 0
    end
    if triplestr && indent_ref_len > 0
        for pos in indent_chunks
            reset_node!(ps, pos, kind=K"Whitespace", flags=TRIVIA_FLAG)
            rhs_empty = steal_token_bytes!(ps, pos, indent_ref_len)
            if rhs_empty
                # Empty chunks after dedent are removed
                # """\n \n """        ==> (string-s "\n")
                n_nontrivia_chunks -= 1
            end
        end
    end
    release_positions(ps.stream, indent_chunks)
    if had_end_delim
        if n_nontrivia_chunks == 0
            # Empty strings, or empty after triple quoted processing
            # "" ==> (string "")
            # """\n  """ ==> (string-s "")
            bump_invisible(ps, string_chunk_kind, chunk_flags)
        end
        bump(ps, TRIVIA_FLAG)
    else
        # Missing delimiter recovery
        # "str   ==> (string "str" (error-t))
        bump_invisible(ps, K"error", TRIVIA_FLAG, error="unterminated string literal")
    end
    # String interpolations
    # "$x$y$z"  ==> (string x y z)
    # "$(x)"    ==> (string (parens x))
    # "$x"      ==> (string x)
    # """$x"""  ==> (string-s x)
    #
    # Strings with embedded whitespace trivia
    # "a\\\nb"      ==> (string "a" "b")
    # "a\\\rb"      ==> (string "a" "b")
    # "a\\\r\nb"    ==> (string "a" "b")
    # "a\\\n \tb"   ==> (string "a" "b")
    #
    # Strings with only a single valid string chunk
    # "str"     ==> (string "str")
    # "a\\\n"   ==> (string "a")
    # "a\\\r"   ==> (string "a")
    # "a\\\r\n" ==> (string "a")
    string_kind = delim_k in KSet"\" \"\"\"" ? K"string" : K"cmdstring"
    str_flags = (triplestr ? TRIPLE_STRING_FLAG : EMPTY_FLAGS) |
                (raw       ? RAW_STRING_FLAG : EMPTY_FLAGS)
    emit(ps, mark, string_kind, str_flags)
end

function emit_braces(ps, mark, ckind, cflags, dim=0)
    if ckind == K"hcat"
        # {x y}  ==>  (bracescat (row x y))
        emit(ps, mark, K"row", cflags & ~TRAILING_COMMA_FLAG)
    elseif ckind == K"ncat"
        # {x ;;; y}  ==>  (bracescat (nrow-3 x y))
        emit(ps, mark, K"nrow", set_numeric_flags(dim))
    end
    check_ncat_compat(ps, mark, ckind)
    outk = ckind in KSet"vect comprehension" ? K"braces" : K"bracescat"
    delim_flags = outk == K"braces" ? (cflags & TRAILING_COMMA_FLAG) : EMPTY_FLAGS
    emit(ps, mark, outk, delim_flags)
end

# parse numbers, identifiers, parenthesized expressions, lists, vectors, etc.
#
# If `check_identifiers` is true, identifiers are disallowed from being one of
# the syntactic operators or closing tokens.
#
# flisp: parse-atom
function parse_atom(ps::ParseState, check_identifiers=true, has_unary_prefix=false)
    bump_trivia(ps)
    mark = position(ps)
    (leading_dot, leading_isassign, leading_tok) = peek_dotted_op_token(ps)
    leading_kind = kind(leading_tok)
    # todo: Reorder to put most likely tokens first?
    if leading_dot
        is_operator(leading_kind) && @goto is_operator
        bump(ps, remap_kind=K"Identifier")
        if check_identifiers
            # .   ==> (error .)
            emit(ps, mark, K"error", error="invalid identifier")
        end
    elseif kind(leading_tok) == K"." && peek(ps, 2) == K"." && peek(ps, 3) == K"."
        # ...
        bump(ps, TRIVIA_FLAG)
        bump(ps, TRIVIA_FLAG)
        bump(ps, TRIVIA_FLAG)
        emit(ps, mark, K"DotsIdentifier", set_numeric_flags(3))
        if check_identifiers
            # ...   ==> (error ...)
            emit(ps, mark, K"error", error="invalid identifier")
        end
    elseif is_error(leading_kind)
        # Errors for bad tokens are emitted in validate_tokens() rather than
        # here.
        bump(ps)
    elseif leading_kind == K"'"
        # char literal
        bump(ps, TRIVIA_FLAG)
        k = peek(ps)
        if k == K"'"
            # ''  ==>  (char (error))
            bump_invisible(ps, K"error", error="empty character literal")
            bump(ps, TRIVIA_FLAG)
        elseif k == K"EndMarker"
            # '   ==>  (char (error))
            bump_invisible(ps, K"error", error="unterminated character literal")
        else
            if k == K"Char"
                bump(ps)
            elseif is_error(k)
                bump(ps)
            else
                # FIXME: This case is actually a tokenization error.
                # Make a best-effort attempt to workaround this for now by
                # remapping the kind. This needs to be fixed by rewinding the
                # tokenizer's buffer and re-tokenizing the next token as a
                # char. (A lot of work for a very obscure edge case)
                #
                # x in'c'  ==>  (call-i x in (char 'c'))
                bump(ps, remap_kind=K"Char")
            end
            if peek(ps) == K"'"
                # 'a'         ==>  (char 'a')
                # 'α'         ==>  (char 'α')
                # '\xce\xb1'  ==>  (char 'α')
                bump(ps, TRIVIA_FLAG)
            else
                # 'a  ==>  (char 'a' (error-t))
                bump_invisible(ps, K"error", TRIVIA_FLAG,
                               error="unterminated character literal")
            end
        end
        emit(ps, mark, K"char")
    elseif leading_kind == K"Char"
        # FIXME: This is a tokenization error and should be preceded with
        # K"'". However this workaround is better than emitting a bare Char.
        bump(ps, remap_kind=K"Identifier")
    elseif leading_kind == K":"
        # symbol/expression quote
        # :foo  ==>  (quote-: foo)
        t = peek_token(ps, 2)
        k = kind(t)
        if is_closing_token(ps, k) && (!is_keyword(k) || preceding_whitespace(t))
            # : is a literal colon in some circumstances
            # :)     ==>  :
            # : end  ==>  :
            bump(ps) # K":"
            return
        end
        bump(ps, TRIVIA_FLAG) # K":"
        if preceding_whitespace(t)
            # : foo   ==>  (quote-: (error-t) foo)
            # :\nfoo  ==>  (quote-: (error-t) foo)
            bump_trivia(ps, TRIVIA_FLAG,
                        error="whitespace not allowed after `:` used for quoting")
        end
        # Being inside quote makes keywords into identifiers at the
        # first level of nesting
        # :end ==> (quote-: end)
        # :(end) ==> (quote-: (parens (error-t)))
        # Being inside quote makes end non-special again (issue #27690)
        # a[:(end)]  ==>  (ref a (quote-: (error-t end)))
        parse_atom(ParseState(ps, end_symbol=false), false)
        emit(ps, mark, K"quote", COLON_QUOTE)
    elseif check_identifiers && leading_kind == K"=" && kind(peek_token(ps)) == K"=" && !leading_dot
        # =   ==> (error =)
        bump(ps, error="unexpected `=`")
    elseif leading_kind == K"Identifier"
        # xx  ==>  xx
        # x₁  ==>  x₁
        bump(ps)
    elseif is_word_operator(leading_kind)
        # where=1 ==> (= where 1)
        bump(ps, remap_kind=K"Identifier")
    elseif is_operator(leading_kind)
@label is_operator
        # +     ==>  +
        # .+    ==>  (. +)
        is_compound_assignment = !is_prec_assignment(leading_tok) && leading_isassign
        bump_dotted(ps, leading_dot, leading_tok, emit_dot_node=!is_compound_assignment, remap_kind=
                      is_syntactic_operator(leading_kind) ? leading_kind : K"Identifier")

        if is_compound_assignment
            bump(ps, TRIVIA_FLAG) # consume the = but mark as trivia
            emit(ps, mark, leading_dot ? K".op=" : K"op=")
            if check_identifiers
                # +=   ==>  (error (op= +))
                # .+=  ==>  (error (.op= +))
                emit(ps, mark, K"error", error="invalid identifier")
            end
            # Quoted syntactic operators are allowed
            # :+=  ==>  (quote-: (op= +))
            return
        end

        if check_identifiers && !(is_valid_identifier(leading_kind) || (leading_dot && leading_kind == K"."))
            # ?    ==>  (error ?)
            emit(ps, mark, K"error", error="invalid identifier")
        end
    elseif is_keyword(leading_kind)
        if leading_kind == K"var" && (t = peek_token(ps,2);
                                      kind(t) == K"\"" && !preceding_whitespace(t))
            # var"x"     ==> (var x)
            # Raw mode unescaping
            # var""     ==> (var )
            # var"\""   ==> (var ")
            # var"\\""  ==> (var \")
            # var"\\x"  ==> (var \\x)
            #
            # NB: Triple quoted var identifiers are not implemented, but with
            # the complex deindentation rules they seem like a misfeature
            # anyway, maybe?
            # var"""x""" !=> x
            bump(ps, TRIVIA_FLAG)
            bump(ps, TRIVIA_FLAG)
            if peek(ps) == K"String"
                bump(ps, RAW_STRING_FLAG; remap_kind=K"Identifier")
            else
                bump_invisible(ps, K"Identifier", RAW_STRING_FLAG)
            end
            if peek(ps) == K"\""
                bump(ps, TRIVIA_FLAG)
            else
                bump_invisible(ps, K"error", TRIVIA_FLAG,
                               error="unterminated `var\"\"` identifier")
            end
            t = peek_token(ps)
            k = kind(t)
            if preceding_whitespace(t) || is_operator(k) ||
                    k in KSet"( ) [ ] { } , ; @ EndMarker"
                # var"x"+  ==>  x
                # var"x")  ==>  x
                # var"x"(  ==>  x
            elseif is_string_macro_suffix(k)
                # var"x"end  ==>  (var x (error-t))
                # var"x"1    ==>  (var x (error-t))
                # var"x"y    ==>  (var x (error-t))
                bump(ps, TRIVIA_FLAG, error="suffix not allowed after `var\"...\"` syntax")
            elseif k == K"`" || k == K"\"" || k == K"\"\"\"" || k == K"```"
                # Disallow `var"#""str". To allow this we'd need to fix `raw`
                # detection in lex_quote to be consistent with the parser.
                bump_invisible(ps, K"error", TRIVIA_FLAG,
                               error="`var\"...\"` syntax not supported as string macro name")
            end
            emit(ps, mark, K"var")
        elseif check_identifiers && is_closing_token(ps, leading_kind)
            # :(end)  ==>  (quote-: (error end))
            bump(ps, error="invalid identifier")
        else
            # Remap keywords to identifiers.
            # :end  ==>  (quote-: end)
            # :<:   ==> (quote-: <:)
            bump(ps, remap_kind=K"Identifier")
        end
    elseif leading_kind == K"(" # parens or tuple
        parse_paren(ps, check_identifiers, has_unary_prefix)
    elseif leading_kind == K"[" # cat expression
        bump(ps, TRIVIA_FLAG)
        ckind, cflags, dim = parse_cat(ps, K"]", ps.end_symbol)
        emit(ps, mark, ckind, cflags | set_numeric_flags(dim))
        check_ncat_compat(ps, mark, ckind)
    elseif leading_kind == K"{" # cat expression
        bump(ps, TRIVIA_FLAG)
        ckind, cflags, dim = parse_cat(ps, K"}", ps.end_symbol)
        emit_braces(ps, mark, ckind, cflags, dim)
    elseif leading_kind == K"@" # macro call
        # Macro names can be keywords
        # @end x  ==> (macrocall (macro_name end) x)
        bump(ps, TRIVIA_FLAG)
        parse_macro_name(ps)
        parse_call_chain(ps, mark, true)
    elseif is_string_delim(leading_kind)
        parse_string(ps, false)
    elseif leading_kind in KSet"` ```"
        # ``          ==>  (cmdstring-r "")
        # `cmd`       ==>  (cmdstring-r "cmd")
        # ```cmd```   ==>  (cmdstring-s-r "cmd")
        parse_string(ps, true)
    elseif is_literal(leading_kind)
        # 42   ==>  42
        bump(ps)
    elseif is_closing_token(ps, leading_kind)
        # Leave closing token in place for other productions to
        # recover with
        # )  ==>  error
        msg = leading_kind == K"EndMarker" ?
              "premature end of input" :
              "unexpected `$(untokenize(leading_kind))`"
        emit_diagnostic(ps, error=msg)
        bump_invisible(ps, K"error")
    else
        bump(ps, error="invalid syntax atom")
    end
end
