mnv9script

# Language:     MNV script
# Maintainer:   github user lacygoill
# Last Change:  2025 Oct 09
#
# Includes changes from The MNV Project:

# NOTE: Whenever you change the code, make sure the tests are still passing:
#
#     $ cd runtime/indent/
#     $ make clean; make test || mnvdiff testdir/mnv.{ok,fail}

# Config {{{1

const TIMEOUT: number = get(g:, 'mnv_indent', {})
    ->get('searchpair_timeout', 100)

def IndentMoreInBracketBlock(): number # {{{2
    if get(g:, 'mnv_indent', {})
            ->get('more_in_bracket_block', false)
        return shiftwidth()
    endif
    return 0
enddef

def IndentMoreLineContinuation(): number # {{{2
    var n: any = get(g:, 'mnv_indent', {})
        # We inspect `g:mnv_indent_cont` to stay backward compatible.
        ->get('line_continuation', get(g:, 'mnv_indent_cont', shiftwidth() * 3))

    if n->typename() == 'string'
        return n->eval()
    endif
    return n
enddef
# }}}2

# Init {{{1
var patterns: list<string>
# Tokens {{{2
# BAR_SEPARATION {{{3

const BAR_SEPARATION: string = '[^|\\]\@1<=|'

# OPENING_BRACKET {{{3

const OPENING_BRACKET: string = '[[{(]'

# CLOSING_BRACKET {{{3

const CLOSING_BRACKET: string = '[]})]'

# NON_BRACKET {{{3

const NON_BRACKET: string = '[^[\]{}()]'

# LIST_OR_DICT_CLOSING_BRACKET {{{3

const LIST_OR_DICT_CLOSING_BRACKET: string = '[]}]'

# LIST_OR_DICT_OPENING_BRACKET {{{3

const LIST_OR_DICT_OPENING_BRACKET: string = '[[{]'

# CHARACTER_UNDER_CURSOR {{{3

const CHARACTER_UNDER_CURSOR: string = '\%.c.'

# INLINE_COMMENT {{{3

# TODO: It is not required for an inline comment to be surrounded by whitespace.
# But it might help against false positives.
# To be more reliable, we should inspect the syntax, and only require whitespace
# before  the `#`  comment leader.   But that  might be  too costly  (because of
# `synstack()`).
const INLINE_COMMENT: string = '\s[#"]\%(\s\|[{}]\{3}\)'

# INLINE_MNV9_COMMENT {{{3

const INLINE_MNV9_COMMENT: string = '\s#'

# COMMENT {{{3

# TODO: Technically, `"\s` is wrong.
#
# First, whitespace is not required.
# Second, in MNV9, a string might appear at the start of the line.
# To be sure, we should also inspect the syntax.
# We can't use `INLINE_COMMENT` here. {{{
#
#     const COMMENT: string = $'^\s*{INLINE_COMMENT}'
#                                    ^------------^
#                                          ✘
#
# Because  `INLINE_COMMENT` asserts  the  presence of  a  whitespace before  the
# comment leader.  This assertion is not satisfied for a comment starting at the
# start of the line.
#}}}
const COMMENT: string = '^\s*\%(#\|"\\\=\s\).*$'

# DICT_KEY {{{3

const DICT_KEY: string = '^\s*\%('
    .. '\%(\w\|-\)\+'
    .. '\|'
    .. '"[^"]*"'
    .. '\|'
    .. "'[^']*'"
    .. '\|'
    .. '\[[^]]\+\]'
    .. '\)'
    .. ':\%(\s\|$\)'

# END_OF_COMMAND {{{3

const END_OF_COMMAND: string = $'\s*\%($\|||\@!\|{INLINE_COMMENT}\)'

# END_OF_LINE {{{3

const END_OF_LINE: string = $'\s*\%($\|{INLINE_COMMENT}\)'

# END_OF_MNV9_LINE {{{3

const END_OF_MNV9_LINE: string = $'\s*\%($\|{INLINE_MNV9_COMMENT}\)'

# OPERATOR {{{3

const OPERATOR: string = '\%(^\|\s\)\%([-+*/%]\|\.\.\|||\|&&\|??\|?\|<<\|>>\|\%([=!]=\|[<>]=\=\|[=!]\~\|is\|isnot\)[?#]\=\)\%(\s\|$\)\@=\%(\s*[|<]\)\@!'
    # assignment operators
    .. '\|' .. '\s\%([-+*/%]\|\.\.\)\==\%(\s\|$\)\@='
    # support `:` when used inside conditional operator `?:`
    .. '\|' .. '\%(\s\|^\):\%(\s\|$\)'

# HEREDOC_OPERATOR {{{3

const HEREDOC_OPERATOR: string = '\s=<<\s\@=\%(\s\+\%(trim\|eval\)\)\{,2}'

# PATTERN_DELIMITER {{{3

# A better regex would be:
#
#     [^-+*/%.:#[:blank:][:alnum:]\"|]\|->\@!\%(=\s\)\@!\|[+*/%]\%(=\s\)\@!
#
# But sometimes, it can be too costly and cause `E363` to be given.
const PATTERN_DELIMITER: string = '[-+*/%]\%(=\s\)\@!'
# }}}2
# Syntaxes {{{2
# BLOCKS {{{3

const BLOCKS: list<list<string>> = [
    ['if', 'el\%[se]', 'elseif\=', 'en\%[dif]'],
    ['for', 'endfor\='],
    ['wh\%[ile]', 'endw\%[hile]'],
    ['try', 'cat\%[ch]', 'fina\|finally\=', 'endt\%[ry]'],
    ['def', 'enddef'],
    ['fu\%[nction](\@!', 'endf\%[unction]'],
    ['class', 'endclass'],
    ['interface', 'endinterface'],
    ['enum', 'endenum'],
    ['aug\%[roup]\%(\s\+[eE][nN][dD]\)\@!\s\+\S\+', 'aug\%[roup]\s\+[eE][nN][dD]'],
]

# MODIFIERS {{{3

# some keywords can be prefixed by modifiers (e.g. `def` can be prefixed by `export`)
const MODIFIERS: dict<string> = {
    def: ['export', 'static'],
    class: ['export', 'abstract', 'export abstract'],
    interface: ['export'],
    enum: ['export'],
}
#     ...
#     class: ['export', 'abstract', 'export abstract'],
#     ...
#     →
#     ...
#     class: '\%(export\|abstract\|export\s\+abstract\)\s\+',
#     ...
->map((_, mods: list<string>): string =>
    '\%(' .. mods
    ->join('\|')
    ->substitute('\s\+', '\\s\\+', 'g')
    .. '\)' .. '\s\+')

# HIGHER_ORDER_COMMAND {{{3

patterns =<< trim eval END
    argdo\>!\=
    bufdo\>!\=
    [cl]f\=do\>!\=
    folddoc\%[losed]\>
    foldd\%[oopen]\>
    tabdo\=\>
    windo\>
    au\%[tocmd]\>!\=.*
    com\%[mand]\>!\=.*
    g\%[lobal]!\={PATTERN_DELIMITER}.*
    v\%[global]!\={PATTERN_DELIMITER}.*
END

const HIGHER_ORDER_COMMAND: string = $'\%(^\|{BAR_SEPARATION}\)\s*\<\%({patterns->join('\|')}\)\%(\s\|$\)\@='

# START_MIDDLE_END {{{3

# Let's derive this constant from `BLOCKS`:
#
#     [['if', 'el\%[se]', 'elseif\=', 'en\%[dif]'],
#      ['for', 'endfor\='],
#      ...,
#      [...]]
#     →
#     {
#      'for': ['for', '', 'endfor\='],
#      'endfor': ['for', '', 'endfor\='],
#      'if': ['if', 'el\%[se]\|elseif\=', 'en\%[dif]'],
#      'else': ['if', 'el\%[se]\|elseif\=', 'en\%[dif]'],
#      'elseif': ['if', 'el\%[se]\|elseif\=', 'en\%[dif]'],
#      'endif': ['if', 'el\%[se]\|elseif\=', 'en\%[dif]'],
#      ...
#     }
var START_MIDDLE_END: dict<list<string>>

def Unshorten(kwd: string): string
    return BlockStartKeyword(kwd)
enddef

def BlockStartKeyword(line: string): string
    var kwd: string = line->matchstr('\l\+')
    return fullcommand(kwd, false)
enddef

{
    for kwds: list<string> in BLOCKS
        var [start: string, middle: string, end: string] = [kwds[0], '', kwds[-1]]
        if MODIFIERS->has_key(start->Unshorten())
            start = $'\%({MODIFIERS[start]}\)\={start}'
        endif
        if kwds->len() > 2
            middle = kwds[1 : -2]->join('\|')
        endif
        for kwd: string in kwds
            START_MIDDLE_END->extend({[kwd->Unshorten()]: [start, middle, end]})
        endfor
    endfor
}

START_MIDDLE_END = START_MIDDLE_END
    ->map((_, kwds: list<string>) =>
        kwds->map((_, kwd: string) => kwd == ''
        ? ''
        : $'\%(^\|{BAR_SEPARATION}\|\<sil\%[ent]\|{HIGHER_ORDER_COMMAND}\)\s*'
        .. $'\<\%({kwd}\)\>\%(\s\|$\|!\)\@=\%(\s*{OPERATOR}\)\@!'))

lockvar! START_MIDDLE_END

# ENDS_BLOCK {{{3

const ENDS_BLOCK: string = '^\s*\%('
    .. BLOCKS
    ->copy()
    ->map((_, kwds: list<string>): string => kwds[-1])
    ->join('\|')
    .. '\|' .. CLOSING_BRACKET
    .. $'\){END_OF_COMMAND}'

# ENDS_BLOCK_OR_CLAUSE {{{3

patterns = BLOCKS
    ->copy()
    ->map((_, kwds: list<string>) => kwds[1 :])
    ->flattennew()
    # `catch` and `elseif` need to be handled as special cases
    ->filter((_, pat: string): bool => pat->Unshorten() !~ '^\%(catch\|elseif\)\>')

const ENDS_BLOCK_OR_CLAUSE: string = '^\s*\%(' .. patterns->join('\|') .. $'\){END_OF_COMMAND}'
    .. $'\|^\s*cat\%[ch]\%(\s\+\({PATTERN_DELIMITER}\).*\1\)\={END_OF_COMMAND}'
    .. $'\|^\s*elseif\=\>\%(\s\|$\)\@=\%(\s*{OPERATOR}\)\@!'

# STARTS_NAMED_BLOCK {{{3

patterns = []
{
    for kwds: list<string> in BLOCKS
        for kwd: string in kwds[0 : -2]
            if MODIFIERS->has_key(kwd->Unshorten())
                patterns->add($'\%({MODIFIERS[kwd]}\)\={kwd}')
            else
                patterns->add(kwd)
            endif
        endfor
    endfor
}

const STARTS_NAMED_BLOCK: string = $'^\s*\%(sil\%[ent]!\=\s\+\)\=\%({patterns->join('\|')}\)\>\%(\s\|$\|!\)\@='

# STARTS_CURLY_BLOCK {{{3

# TODO: `{` alone on a line is not necessarily the start of a block.
# It  could be  a dictionary  if the  previous line  ends with  a binary/ternary
# operator.  This  can cause  an issue whenever  we use  `STARTS_CURLY_BLOCK` or
# `LINE_CONTINUATION_AT_EOL`.
const STARTS_CURLY_BLOCK: string = '\%('
    .. '^\s*{'
    .. '\|' .. '^.*\zs\s=>\s\+{'
    .. '\|' ..  $'^\%(\s*\|.*{BAR_SEPARATION}\s*\)\%(com\%[mand]\|au\%[tocmd]\).*\zs\s{{'
    .. '\)' .. END_OF_COMMAND

# STARTS_FUNCTION {{{3

const STARTS_FUNCTION: string = $'^\s*\%({MODIFIERS.def}\)\=def\>!\=\s\@='

# ENDS_FUNCTION {{{3

const ENDS_FUNCTION: string = $'^\s*enddef\>{END_OF_COMMAND}'

# ASSIGNS_HEREDOC {{{3

const ASSIGNS_HEREDOC: string = $'^\%({COMMENT}\)\@!.*\%({HEREDOC_OPERATOR}\)\s\+\zs[A-Z]\+{END_OF_LINE}'

# PLUS_MINUS_COMMAND {{{3

# In legacy, the `:+` and `:-` commands are not required to be preceded by a colon.
# As a result, when `+` or `-` is alone on a line, there is ambiguity.
# It might be an operator or a command.
# To not break the indentation in legacy scripts, we might need to consider such
# lines as commands.
const PLUS_MINUS_COMMAND: string = '^\s*[+-]\s*$'

# TRICKY_COMMANDS {{{3

# Some  commands  are tricky  because  they  accept  an  argument which  can  be
# conflated with an operator.  Examples:
#
#     argdelete *
#     cd -
#     normal! ==
#     nunmap <buffer> (
#
# TODO: Other commands might accept operators as argument.  Handle them too.
patterns =<< trim eval END
    {'\'}<argd\%[elete]\s\+\*\s*$
    \<[lt]\=cd!\=\s\+-\s*$
    \<norm\%[al]!\=\s\+.*$
    \<reg\%[isters]\%(\s\+\S\+\)\+$
    \%(\<sil\%[ent]!\=\s\+\)\=\<[nvxsoilct]\=\%(nore\|un\)\=map!\=\s
    \<set\%(\%[global]\|\%[local]\)\>.*,$
    {PLUS_MINUS_COMMAND}
END

const TRICKY_COMMANDS: string = patterns->join('\|')
# }}}2
# EOL {{{2
# OPENING_BRACKET_AT_EOL {{{3

const OPENING_BRACKET_AT_EOL: string = OPENING_BRACKET .. END_OF_MNV9_LINE

# CLOSING_BRACKET_AT_EOL {{{3

const CLOSING_BRACKET_AT_EOL: string = CLOSING_BRACKET .. END_OF_MNV9_LINE

# COMMA_AT_EOL {{{3

const COMMA_AT_EOL: string = $',{END_OF_MNV9_LINE}'

# COMMA_OR_DICT_KEY_AT_EOL {{{3

const COMMA_OR_DICT_KEY_AT_EOL: string = $'\%(,\|{DICT_KEY}\){END_OF_MNV9_LINE}'

# LAMBDA_ARROW_AT_EOL {{{3

const LAMBDA_ARROW_AT_EOL: string = $'\s=>{END_OF_MNV9_LINE}'

# LINE_CONTINUATION_AT_EOL {{{3

const LINE_CONTINUATION_AT_EOL: string = '\%('
    .. ','
    .. '\|' .. OPERATOR
    .. '\|' .. '\s=>'
    .. '\|' .. '[^=]\zs[[(]'
    .. '\|' .. DICT_KEY
    # `{` is ambiguous.
    # It can be the start of a dictionary or a block.
    # We only want to match the former.
    .. '\|' .. $'^\%({STARTS_CURLY_BLOCK}\)\@!.*\zs{{'
    .. '\)\s*\%(\s#[^{].*\)\=$'
# }}}2
# SOL {{{2
# BACKSLASH_AT_SOL {{{3

const BACKSLASH_AT_SOL: string = '^\s*\%(\\\|[#"]\\ \)'

# CLOSING_BRACKET_AT_SOL {{{3

const CLOSING_BRACKET_AT_SOL: string = $'^\s*{CLOSING_BRACKET}'

# LINE_CONTINUATION_AT_SOL {{{3

const LINE_CONTINUATION_AT_SOL: string = '^\s*\%('
    .. '\\'
    .. '\|' .. '[#"]\\ '
    .. '\|' .. OPERATOR
    .. '\|' .. '->\s*\h'
    .. '\|' .. '->\s*(' # lambda call: ->((v) => v ? "ON" : "OFF")()
    .. '\|' .. '\.\h'  # dict member
    .. '\|' .. '|'
    # TODO: `}` at the start of a line is not necessarily a line continuation.
    # Could be the end of a block.
    .. '\|' .. CLOSING_BRACKET
    .. '\)'

# RANGE_AT_SOL {{{3

const RANGE_AT_SOL: string = '^\s*:\S'
# }}}1
# Interface {{{1
export def Expr(lnum = v:lnum): number # {{{2
    # line which is indented
    var line_A: dict<any> = {text: getline(lnum), lnum: lnum}
    # line above, on which we'll base the indent of line A
    var line_B: dict<any>

    if line_A->AtStartOf('HereDoc')
        line_A->CacheHeredoc()
    elseif line_A.lnum->IsInside('HereDoc')
        return line_A.text->HereDocIndent()
    elseif line_A.lnum->IsRightBelow('HereDoc')
        var ind: number = b:mnvindent.startindent
        unlet! b:mnvindent
        if line_A.text =~ ENDS_BLOCK_OR_CLAUSE
            return ind - shiftwidth()
        endif
        return ind
    endif

    # Don't move this block after the function header one.
    # Otherwise, we  might clear the cache  too early if the  line following the
    # header is a comment.
    if line_A.text =~ COMMENT
        return CommentIndent()
    endif

    line_B = PrevCodeLine(line_A.lnum)
    if line_A.text =~ BACKSLASH_AT_SOL
        if line_B.text =~ BACKSLASH_AT_SOL
            return Indent(line_B.lnum)
        endif
        return Indent(line_B.lnum) + IndentMoreLineContinuation()
    endif

    if line_A->AtStartOf('FuncHeader')
            && !IsInInterface()
        line_A.lnum->CacheFuncHeader()
    elseif line_A.lnum->IsInside('FuncHeader')
        return b:mnvindent.startindent + 2 * shiftwidth()
    elseif line_A.lnum->IsRightBelow('FuncHeader')
        var startindent: number = b:mnvindent.startindent
        unlet! b:mnvindent
        if line_A.text =~ ENDS_FUNCTION
            return startindent
        endif
        return startindent + shiftwidth()
    endif

    var past_bracket_block: dict<any>
    if exists('b:mnvindent')
            && b:mnvindent->has_key('is_BracketBlock')
        past_bracket_block = RemovePastBracketBlock(line_A)
    endif
    if line_A->AtStartOf('BracketBlock')
        line_A->CacheBracketBlock()
    endif
    if line_A.lnum->IsInside('BracketBlock')
        var is_in_curly_block: bool = IsInCurlyBlock()
        for block: dict<any> in b:mnvindent.block_stack
            if line_A.lnum <= block.startlnum
                continue
            endif
            if !block->has_key('startindent')
                block.startindent = Indent(block.startlnum)
            endif
            if !is_in_curly_block
                return BracketBlockIndent(line_A, block)
            endif
        endfor
    endif
    if line_A.text->ContinuesBelowBracketBlock(line_B, past_bracket_block)
            && line_A.text !~ CLOSING_BRACKET_AT_SOL
        return past_bracket_block.startindent
            + (past_bracket_block.startline =~ STARTS_NAMED_BLOCK ? 2 * shiftwidth() : 0)
    endif

    # Problem: If we press `==` on the line right below the start of a multiline
    # lambda (split after its arrow `=>`), the indent is not correct.
    # Solution: Indent relative to the line above.
    if line_B->EndsWithLambdaArrow()
        return Indent(line_B.lnum) + shiftwidth() + IndentMoreInBracketBlock()
    endif
    # FIXME: Similar issue here:
    #
    #     var x = []
    #         ->filter((_, _) =>
    #             true)
    #         ->items()
    #
    # Press `==` on last line.
    # Expected: The `->items()` line is indented like `->filter(...)`.
    # Actual: It's indented like `true)`.
    # Is it worth fixing? `=ip` gives  the correct indentation, because then the
    # cache is used.

    # Don't move this block before the heredoc one.{{{
    #
    # A heredoc might be assigned on the very first line.
    # And if it is, we need to cache some info.
    #}}}
    # Don't move it before the function header and bracket block ones either.{{{
    #
    # You could, because these blocks of code deal with construct which can only
    # appear  in a  MNV9  script.  And  in  a  MNV9 script,  the  first line  is
    # `mnv9script`.  Or  maybe some legacy code/comment  (see `:help mnv9-mix`).
    # But you  can't find a  MNV9 function header or  MNV9 bracket block  on the
    # first line.
    #
    # Anyway, even if you could, don't.  First, it would be inconsistent.
    # Second, it  could give unexpected results  while we're trying to  fix some
    # failing test.
    #}}}
    if line_A.lnum == 1
        return 0
    endif

    # Don't do that:
    #     if line_A.text !~ '\S'
    #         return -1
    #     endif
    # It would prevent  a line from being automatically indented  when using the
    # normal command `o`.
    # TODO: Can we write a test for this?

    if line_B.text =~ STARTS_CURLY_BLOCK
        return Indent(line_B.lnum) + shiftwidth() + IndentMoreInBracketBlock()
    endif

    if line_A.text =~ CLOSING_BRACKET_AT_SOL
        var start: number = MatchingOpenBracket(line_A)
        if start <= 0
            return -1
        endif
        return Indent(start) + IndentMoreInBracketBlock()

    elseif line_A.text =~ ENDS_BLOCK_OR_CLAUSE
            && !line_B->EndsWithLineContinuation()
        var kwd: string = BlockStartKeyword(line_A.text)
        if !START_MIDDLE_END->has_key(kwd)
            return -1
        endif

        # If the cursor  is after the match  for the end pattern,  we won't find
        # the start of the block.  Let's make sure that doesn't happen.
        cursor(line_A.lnum, 1)

        var [start: string, middle: string, end: string] = START_MIDDLE_END[kwd]
        var block_start: number = SearchPairStart(start, middle, end)
        if block_start > 0
            return Indent(block_start)
        endif
        return -1
    endif

    var base_ind: number
    if line_A->IsFirstLineOfCommand(line_B)
        line_A.isfirst = true
        line_B = line_B->FirstLinePreviousCommand()
        base_ind = Indent(line_B.lnum)

        if line_B->EndsWithCurlyBlock()
                && !line_A->IsInThisBlock(line_B.lnum)
            return base_ind
        endif

    else
        line_A.isfirst = false
        base_ind = Indent(line_B.lnum)

        var line_C: dict<any> = PrevCodeLine(line_B.lnum)
        if !line_B->IsFirstLineOfCommand(line_C) || line_C.lnum <= 0
            return base_ind
        endif
    endif

    return base_ind + Offset(line_A, line_B)
enddef

def g:GetMNVIndent(): number # {{{2
    # for backward compatibility
    return Expr()
enddef
# }}}1
# Core {{{1
def Offset( # {{{2
        # we indent this line ...
        line_A: dict<any>,
        # ... relatively to this line
        line_B: dict<any>,
        ): number

    if line_B->AtStartOf('FuncHeader')
            && IsInInterface()
        return 0
    endif

    # increase indentation inside a block
    if line_B.text =~ STARTS_NAMED_BLOCK
            || line_B->EndsWithCurlyBlock()
        # But don't indent if the line starting the block also closes it.
        if line_B->AlsoClosesBlock()
            return 0
        endif
        # Indent twice for  a line continuation in the block  header itself, so that
        # we can easily  distinguish the end of  the block header from  the start of
        # the block body.
        if (line_B->EndsWithLineContinuation()
                && !line_A.isfirst)
                || (line_A.text =~ LINE_CONTINUATION_AT_SOL
                && line_A.text !~ PLUS_MINUS_COMMAND)
                || line_A.text->Is_IN_KeywordForLoop(line_B.text)
            return 2 * shiftwidth()
        endif
        return shiftwidth()
    endif

    # increase indentation of  a line if it's the continuation  of a command which
    # started on a previous line
    if !line_A.isfirst
            && (line_B->EndsWithLineContinuation()
            || line_A.text =~ LINE_CONTINUATION_AT_SOL)
            && !(line_B->EndsWithComma() && line_A.lnum->IsInside('EnumBlock'))
        return shiftwidth()
    endif

    return 0
enddef

def HereDocIndent(line_A: string): number # {{{2
    # at the end of a heredoc
    if line_A =~ $'^\s*{b:mnvindent.endmarker}$'
        # `END` must be at the very start of the line if the heredoc is not trimmed
        if !b:mnvindent.is_trimmed
            # We can't invalidate the cache just yet.
            # The indent of `END` is meaningless;  it's always 0.  The next line
            # will need to be indented relative to the start of the heredoc.  It
            # must know where it starts; it needs the cache.
            return 0
        endif
        var ind: number = b:mnvindent.startindent
        # invalidate the cache so that it's not used for the next heredoc
        unlet! b:mnvindent
        return ind
    endif

    # In a non-trimmed heredoc, all of leading whitespace is semantic.
    # Leave it alone.
    if !b:mnvindent.is_trimmed
        # But do save the indent of the assignment line.
        if !b:mnvindent->has_key('startindent')
            b:mnvindent.startindent = b:mnvindent.startlnum->Indent()
        endif
        return -1
    endif

    # In a trimmed heredoc, *some* of the leading whitespace is semantic.
    # We want to preserve  it, so we can't just indent  relative to the assignment
    # line.  That's because we're dealing with data, not with code.
    # Instead, we need  to compute by how  much the indent of  the assignment line
    # was increased  or decreased.   Then, we  need to apply  that same  change to
    # every line inside the body.
    var offset: number
    if !b:mnvindent->has_key('offset')
        var old_startindent: number = b:mnvindent.startindent
        var new_startindent: number = b:mnvindent.startlnum->Indent()
        offset = new_startindent - old_startindent

        # If all the non-empty lines in  the body have a higher indentation relative
        # to the assignment, there is no need to indent them more.
        # But if  at least one of  them does have  the same indentation level  (or a
        # lower one), then we want to indent it further (and the whole block with it).
        # This way,  we can clearly distinguish  the heredoc block from  the rest of
        # the code.
        var end: number = search($'^\s*{b:mnvindent.endmarker}$', 'nW')
        var should_indent_more: bool = range(v:lnum, end - 1)
            ->indexof((_, lnum: number): bool => Indent(lnum) <= old_startindent && getline(lnum) != '') >= 0
        if should_indent_more
            offset += shiftwidth()
        endif

        b:mnvindent.offset = offset
        b:mnvindent.startindent = new_startindent
    endif

    return Indent(v:lnum) + b:mnvindent.offset
enddef

def CommentIndent(): number # {{{2
    var line_B: dict<any>
    line_B.lnum = prevnonblank(v:lnum - 1)
    line_B.text = getline(line_B.lnum)
    if line_B.text =~ COMMENT
        return Indent(line_B.lnum)
    endif

    var next: number = NextCodeLine()
    if next == 0
        return 0
    endif
    var mnvindent_save: dict<any> = get(b:, 'mnvindent', {})->deepcopy()
    var ind: number = next->Expr()
    # The previous `Expr()` might have set or deleted `b:mnvindent`.
    # This could  cause issues (e.g.  when indenting  2 commented lines  above a
    # heredoc).  Let's make sure the state of the variable is not altered.
    if mnvindent_save->empty()
        unlet! b:mnvindent
    else
        b:mnvindent = mnvindent_save
    endif
    if getline(next) =~ ENDS_BLOCK
        return ind + shiftwidth()
    endif
    return ind
enddef

def BracketBlockIndent(line_A: dict<any>, block: dict<any>): number # {{{2
    var ind: number = block.startindent

    if line_A.text =~ CLOSING_BRACKET_AT_SOL
        if b:mnvindent.is_on_named_block_line
            ind += 2 * shiftwidth()
        endif
        return ind + IndentMoreInBracketBlock()
    endif

    var startline: dict<any> = {
        text: block.startline,
        lnum: block.startlnum
    }
    if startline->EndsWithComma()
            || startline->EndsWithLambdaArrow()
            || (startline->EndsWithOpeningBracket()
            # TODO: Is that reliable?
            && block.startline !~
            $'^\s*{NON_BRACKET}\+{LIST_OR_DICT_CLOSING_BRACKET},\s\+{LIST_OR_DICT_OPENING_BRACKET}')
        ind += shiftwidth() + IndentMoreInBracketBlock()
    endif

    if b:mnvindent.is_on_named_block_line
        ind += shiftwidth()
    endif

    if block.is_dict
            && line_A.text !~ DICT_KEY
        ind += shiftwidth()
    endif

    return ind
enddef

def CacheHeredoc(line_A: dict<any>) # {{{2
    var endmarker: string = line_A.text->matchstr(ASSIGNS_HEREDOC)
    var endlnum: number = search($'^\s*{endmarker}$', 'nW')
    var is_trimmed: bool = line_A.text =~ $'.*\s\%(trim\%(\s\+eval\)\=\)\s\+[A-Z]\+{END_OF_LINE}'
    b:mnvindent = {
        is_HereDoc: true,
        startlnum: line_A.lnum,
        endlnum: endlnum,
        endmarker: endmarker,
        is_trimmed: is_trimmed,
    }
    if is_trimmed
        b:mnvindent.startindent = Indent(line_A.lnum)
    endif
    RegisterCacheInvalidation()
enddef

def CacheFuncHeader(startlnum: number) # {{{2
    var pos: list<number> = getcurpos()
    cursor(startlnum, 1)
    if search('(', 'W', startlnum) <= 0
        return
    endif
    var endlnum: number = SearchPair('(', '', ')', 'nW')
    setpos('.', pos)
    if endlnum == startlnum
        return
    endif

    b:mnvindent = {
        is_FuncHeader: true,
        startindent: startlnum->Indent(),
        endlnum: endlnum,
    }
    RegisterCacheInvalidation()
enddef

def CacheBracketBlock(line_A: dict<any>) # {{{2
    var pos: list<number> = getcurpos()
    var opening: string = line_A.text->matchstr(CHARACTER_UNDER_CURSOR)
    var closing: string = {'[': ']', '{': '}', '(': ')'}[opening]
    var endlnum: number = SearchPair(opening, '', closing, 'nW')
    setpos('.', pos)
    if endlnum <= line_A.lnum
        return
    endif

    if !exists('b:mnvindent')
        b:mnvindent = {
            is_BracketBlock: true,
            is_on_named_block_line: line_A.text =~ STARTS_NAMED_BLOCK,
            block_stack: [],
        }
    endif

    var is_dict: bool
    var is_curly_block: bool
    if opening == '{'
        if line_A.text =~ STARTS_CURLY_BLOCK
            [is_dict, is_curly_block] = [false, true]
        else
            [is_dict, is_curly_block] = [true, false]
        endif
    endif
    b:mnvindent.block_stack->insert({
        is_dict: is_dict,
        is_curly_block: is_curly_block,
        startline: line_A.text,
        startlnum: line_A.lnum,
        endlnum: endlnum,
    })

    RegisterCacheInvalidation()
enddef

def RegisterCacheInvalidation() # {{{2
    # invalidate the cache so that it's not used for the next `=` normal command
    autocmd_add([{
        cmd: 'unlet! b:mnvindent',
        event: 'ModeChanged',
        group: '__MNVIndent__',
        once: true,
        pattern: '*:n',
        replace: true,
    }])
enddef

def RemovePastBracketBlock(line_A: dict<any>): dict<any> # {{{2
    var stack: list<dict<any>> = b:mnvindent.block_stack

    var removed: dict<any>
    if line_A.lnum > stack[0].endlnum
        removed = stack[0]
    endif

    stack->filter((_, block: dict<any>): bool => line_A.lnum <= block.endlnum)
    if stack->empty()
        unlet! b:mnvindent
    endif
    return removed
enddef
# }}}1
# Util {{{1
# Get {{{2
def Indent(lnum: number): number # {{{3
    if lnum <= 0
        # Don't  return `-1`.  It could cause `Expr()` to return a non-multiple of `'shiftwidth'`.{{{
        #
        # It would be  OK if we were always returning  `Indent()` directly.  But
        # we  don't.  Most  of  the  time, we  include  it  in some  computation
        # like  `Indent(...) + shiftwidth()`.   If  `'shiftwidth'` is  `4`,  and
        # `Indent()` returns `-1`, `Expr()` will end up returning `3`.
        #}}}
        return 0
    endif
    return indent(lnum)
enddef

def MatchingOpenBracket(line: dict<any>): number # {{{3
    var end: string = line.text->matchstr(CLOSING_BRACKET)
    var start: string = {']': '[', '}': '{', ')': '('}[end]
    cursor(line.lnum, 1)
    return SearchPairStart(start, '', end)
enddef

def FirstLinePreviousCommand(line: dict<any>): dict<any> # {{{3
    var line_B: dict<any> = line

    while line_B.lnum > 1
        var code_line_above: dict<any> = PrevCodeLine(line_B.lnum)

        if line_B.text =~ CLOSING_BRACKET_AT_SOL
            var n: number = MatchingOpenBracket(line_B)

            if n <= 0
                break
            endif

            line_B.lnum = n
            line_B.text = getline(line_B.lnum)
            continue

        elseif line_B->IsFirstLineOfCommand(code_line_above)
            break
        endif

        line_B = code_line_above
    endwhile

    return line_B
enddef

def PrevCodeLine(lnum: number): dict<any> # {{{3
    var line: string = getline(lnum)
    if line =~ '^\s*[A-Z]\+$'
        var endmarker: string = line->matchstr('[A-Z]\+')
        var pos: list<number> = getcurpos()
        cursor(lnum, 1)
        var n: number = search(ASSIGNS_HEREDOC, 'bnW')
        setpos('.', pos)
        if n > 0
            line = getline(n)
            if line =~ $'{HEREDOC_OPERATOR}\s\+{endmarker}'
                return {lnum: n, text: line}
            endif
        endif
    endif

    var n: number = prevnonblank(lnum - 1)
    line = getline(n)
    while line =~ COMMENT && n > 1
        n = prevnonblank(n - 1)
        line = getline(n)
    endwhile
    # If we get back to the first line, we return 1 no matter what; even if it's a
    # commented line.   That should not  cause an issue  though.  We just  want to
    # avoid a  commented line above which  there is a  line of code which  is more
    # relevant.  There is nothing above the first line.
    return {lnum: n, text: line}
enddef

def NextCodeLine(): number # {{{3
    var last: number = line('$')
    if v:lnum == last
        return 0
    endif

    var lnum: number = v:lnum + 1
    while lnum <= last
        var line: string = getline(lnum)
        if line != '' && line !~ COMMENT
            return lnum
        endif
        ++lnum
    endwhile
    return 0
enddef

def SearchPair( # {{{3
        start: string,
        middle: string,
        end: string,
        flags: string,
        stopline = 0,
        ): number

    var s: string = start
    var e: string = end
    if start == '[' || start == ']'
        s = s->escape('[]')
    endif
    if end == '[' || end == ']'
        e = e->escape('[]')
    endif
    # MNV_INDENT_TEST_TRACE_START
    return searchpair('\C' .. s, (middle == '' ? '' : '\C' .. middle), '\C' .. e,
        flags, (): bool => InCommentOrString(), stopline, TIMEOUT)
    # MNV_INDENT_TEST_TRACE_END dist#mnvindent#SearchPair
enddef

def SearchPairStart( # {{{3
        start: string,
        middle: string,
        end: string,
        ): number
    return SearchPair(start, middle, end, 'bnW')
enddef

def SearchPairEnd( # {{{3
        start: string,
        middle: string,
        end: string,
        stopline = 0,
        ): number
    return SearchPair(start, middle, end, 'nW', stopline)
enddef
# }}}2
# Test {{{2
def AtStartOf(line_A: dict<any>, syntax: string): bool # {{{3
    if syntax == 'BracketBlock'
        return AtStartOfBracketBlock(line_A)
    endif

    var pat: string = {
        HereDoc: ASSIGNS_HEREDOC,
        FuncHeader: STARTS_FUNCTION
    }[syntax]
    return line_A.text =~ pat
        && (!exists('b:mnvindent') || !b:mnvindent->has_key('is_HereDoc'))
enddef

def AtStartOfBracketBlock(line_A: dict<any>): bool # {{{3
    # We  ignore bracket  blocks  while we're  indenting  a function  header
    # because  it makes  the logic  simpler.  It  might mean  that we  don't
    # indent correctly a  multiline bracket block inside  a function header,
    # but that's  a corner case for  which it doesn't seem  worth making the
    # code more complex.
    if exists('b:mnvindent')
            && !b:mnvindent->has_key('is_BracketBlock')
        return false
    endif

    var pos: list<number> = getcurpos()
    cursor(line_A.lnum, [line_A.lnum, '$']->col())

    if SearchPair(OPENING_BRACKET, '', CLOSING_BRACKET, 'bcW', line_A.lnum) <= 0
        setpos('.', pos)
        return false
    endif
    # Don't restore the cursor position.
    # It needs to be on a bracket for `CacheBracketBlock()` to work as intended.

    return line_A->EndsWithOpeningBracket()
        || line_A->EndsWithCommaOrDictKey()
        || line_A->EndsWithLambdaArrow()
enddef

def ContinuesBelowBracketBlock( # {{{3
        line_A: string,
        line_B: dict<any>,
        block: dict<any>
        ): bool

    return !block->empty()
        && (line_A =~ LINE_CONTINUATION_AT_SOL
        || line_B->EndsWithLineContinuation())
enddef

def IsInside(lnum: number, syntax: string): bool # {{{3
    if syntax == 'EnumBlock'
        var cur_pos = getpos('.')
        cursor(lnum, 1)
        var enum_pos = search('^\C\s*\%(export\s\)\=\s*enum\s\+\S\+', 'bnW')
        var endenum_pos = search('^\C\s*endenum\>', 'bnW')
        setpos('.', cur_pos)

        if enum_pos == 0 && endenum_pos == 0
            return false
        endif
        if (enum_pos > 0 && (endenum_pos == 0 || enum_pos > endenum_pos))
            return true
        endif
        return false
    endif

    if !exists('b:mnvindent')
            || !b:mnvindent->has_key($'is_{syntax}')
        return false
    endif

    if syntax == 'BracketBlock'
        if !b:mnvindent->has_key('block_stack')
                || b:mnvindent.block_stack->empty()
            return false
        endif
        return lnum <= b:mnvindent.block_stack[0].endlnum
    endif

    return lnum <= b:mnvindent.endlnum
enddef

def IsRightBelow(lnum: number, syntax: string): bool # {{{3
    return exists('b:mnvindent')
        && b:mnvindent->has_key($'is_{syntax}')
        && lnum > b:mnvindent.endlnum
enddef

def IsInCurlyBlock(): bool # {{{3
    return b:mnvindent.block_stack
        ->indexof((_, block: dict<any>): bool => block.is_curly_block) >= 0
enddef

def IsInThisBlock(line_A: dict<any>, lnum: number): bool # {{{3
    var pos: list<number> = getcurpos()
    cursor(lnum, [lnum, '$']->col())
    var end: number = SearchPairEnd('{', '', '}')
    setpos('.', pos)

    return line_A.lnum <= end
enddef

def IsInInterface(): bool # {{{3
    return SearchPair('interface', '', 'endinterface', 'nW') > 0
enddef

def IsFirstLineOfCommand(line_1: dict<any>, line_2: dict<any>): bool # {{{3
    if line_1.text->Is_IN_KeywordForLoop(line_2.text)
        return false
    endif

    if line_1.text =~ RANGE_AT_SOL
            || line_1.text =~ PLUS_MINUS_COMMAND
        return true
    endif

    if line_2.text =~ DICT_KEY
            && !line_1->IsInThisBlock(line_2.lnum)
        return true
    endif

    var line_1_is_good: bool = line_1.text !~ COMMENT
        && line_1.text !~ DICT_KEY
        && line_1.text !~ LINE_CONTINUATION_AT_SOL

    var line_2_is_good: bool = !line_2->EndsWithLineContinuation()

    return line_1_is_good && line_2_is_good
enddef

def Is_IN_KeywordForLoop(line_1: string, line_2: string): bool # {{{3
    return line_2 =~ '^\s*for\s'
        && line_1 =~ '^\s*in\s'
enddef

def InCommentOrString(): bool # {{{3
    return synstack('.', col('.'))
        ->indexof((_, id: number): bool => synIDattr(id, 'name') =~ '\ccomment\|string\|heredoc') >= 0
enddef

def AlsoClosesBlock(line_B: dict<any>): bool # {{{3
    # We know that `line_B` opens a block.
    # Let's see if it also closes that block.
    var kwd: string = BlockStartKeyword(line_B.text)
    if !START_MIDDLE_END->has_key(kwd)
        return false
    endif

    var [start: string, middle: string, end: string] = START_MIDDLE_END[kwd]
    var pos: list<number> = getcurpos()
    cursor(line_B.lnum, 1)
    var block_end: number = SearchPairEnd(start, middle, end, line_B.lnum)
    setpos('.', pos)

    return block_end > 0
enddef

def EndsWithComma(line: dict<any>): bool # {{{3
    return NonCommentedMatch(line, COMMA_AT_EOL)
enddef

def EndsWithCommaOrDictKey(line_A: dict<any>): bool # {{{3
    return NonCommentedMatch(line_A, COMMA_OR_DICT_KEY_AT_EOL)
enddef

def EndsWithCurlyBlock(line_B: dict<any>): bool # {{{3
    return NonCommentedMatch(line_B, STARTS_CURLY_BLOCK)
enddef

def EndsWithLambdaArrow(line_A: dict<any>): bool # {{{3
    return NonCommentedMatch(line_A, LAMBDA_ARROW_AT_EOL)
enddef

def EndsWithLineContinuation(line_B: dict<any>): bool # {{{3
    return NonCommentedMatch(line_B, LINE_CONTINUATION_AT_EOL)
enddef

def EndsWithOpeningBracket(line: dict<any>): bool # {{{3
    return NonCommentedMatch(line, OPENING_BRACKET_AT_EOL)
enddef

def EndsWithClosingBracket(line: dict<any>): bool # {{{3
    return NonCommentedMatch(line, CLOSING_BRACKET_AT_EOL)
enddef

def NonCommentedMatch(line: dict<any>, pat: string): bool # {{{3
    # Could happen if there is no code above us, and we're not on the 1st line.
    # In that case, `PrevCodeLine()` returns `{lnum: 0, line: ''}`.
    if line.lnum == 0
        return false
    endif

    # Technically, that's wrong.  A  line might start with a range  and end with a
    # line continuation symbol.  But it's unlikely.  And it's useful to assume the
    # opposite because it  prevents us from conflating a mark  with an operator or
    # the start of a list:
    #
    #              not a comparison operator
    #              v
    #     :'< mark <
    #     :'< mark [
    #              ^
    #              not the start of a list
    if line.text =~ RANGE_AT_SOL
        return false
    endif

    #                    that's not an arithmetic operator
    #                    v
    #     catch /pattern /
    #
    # When `/` is used as a pattern delimiter, it's always present twice.
    # And  usually, the  first occurrence  is  in the  middle of  a sequence  of
    # non-whitespace characters.  If we can find  such a `/`, we assume that the
    # trailing `/` is not an operator.
    # Warning: Here, don't use a too complex pattern.{{{
    #
    # In particular, avoid backreferences.
    # For example, this would be too costly:
    #
    #     if line.text =~ $'\%(\S*\({PATTERN_DELIMITER}\)\S\+\|\S\+\({PATTERN_DELIMITER}\)\S*\)'
    #             .. $'\s\+\1{END_OF_COMMAND}'
    #
    # Sometimes, it could even give `E363`.
    #}}}
    var delim: string = line.text
        ->matchstr($'\s\+\zs{PATTERN_DELIMITER}\ze{END_OF_COMMAND}')
    if !delim->empty()
        delim = $'\V{delim}\m'
        if line.text =~ $'\%(\S*{delim}\S\+\|\S\+{delim}\S*\)\s\+{delim}{END_OF_COMMAND}'
            return false
        endif
    endif
    # TODO: We might still miss some corner cases:{{{
    #
    #                          conflated with arithmetic division
    #                          v
    #     substitute/pat / rep /
    #         echo
    #     ^--^
    #      ✘
    #
    # A better way to handle all these corner cases, would be to inspect the top
    # of the syntax stack:
    #
    #     :echo synID('.', col('.'), v:false)->synIDattr('name')
    #
    # Unfortunately, the legacy syntax plugin is not accurate enough.
    # For example, it doesn't highlight a slash as an operator.
    # }}}

    # `%` at the end of a line is tricky.
    # It might be the modulo operator or the current file (e.g. `edit %`).
    # Let's assume it's the latter.
    if line.text =~ $'%{END_OF_COMMAND}'
        return false
    endif

    if line.text =~ TRICKY_COMMANDS
        return false
    endif

    var pos: list<number> = getcurpos()
    cursor(line.lnum, 1)
    # MNV_INDENT_TEST_TRACE_START
    var match_lnum: number = search(pat, 'cnW', line.lnum, TIMEOUT, (): bool => InCommentOrString())
    # MNV_INDENT_TEST_TRACE_END dist#mnvindent#NonCommentedMatch
    setpos('.', pos)
    return match_lnum > 0
enddef
# }}}1
# mnv:sw=4
