A toy code-folding plugin

Since Issue #3644 was fixed last year, Kakoune has had the ability to visually replace a number of lines in a buffer with a shorter number of lines - that is, code folding should be possible. To experiment with this new capability, I made a little toy folding plugin:

declare-option range-specs fold_ranges
declare-option regex fold_pattern
set-face global Folded +u@Default

define-command fold-disable %{
    try %{ remove-highlighter window/fold_ranges }
    remove-hooks window fold-update
    unset-option window fold_ranges
    unset-option window fold_pattern
}

define-command fold-enable -params 1 %{
    # Cleaan up any old config, just in case
    fold-disable

    set-option window fold_pattern %arg{1}
    hook -group fold-update window NormalIdle .* fold-update
    add-highlighter window/fold_ranges replace-ranges fold_ranges
}

define-command fold-update %{
    evaluate-commands -save-regs dl %{
        evaluate-commands -draft %{
            # Select the fold pattern
            # We use ) to make sure the current selection is also the firrst
            execute-keys <percent>s %opt{fold_pattern} <ret> )
            # Put those locations into register d
            set-register d %val{selections_desc}
            # Select the first line of each fold
            # up until any curly brackets
            # (because we can't easily escape them)
            execute-keys <a-:><a-semicolon><semicolon>?[^{}\n]*<ret>
            # Put those labels into register l
            set-register l %val{selections}
            edit -scratch
            execute-keys '"d<a-P>' a<ret><left> '|{Folded}' <esc> '"lP' xH
            set-register d %val{selections}
            delete-buffer
        }
        set-option window fold_ranges %val{timestamp} %reg{d}
    }
}

To use it, run fold-enable with a regular expression that matches the text you want to fold. For example, here’s what I put in my Python filetype hook:

fold-enable '^(class|def).*?(?=\n\w)'

The plugin doesn’t support nested folds, but I don’t know if Kakoune does either. The folding seems to work quite nicely, although you can’t choose to open or close folds - they’re open if a selection overlaps with them, otherwise closed. The one limitation I’ve found so far is that scrolling with the scroll-wheel can be surprising - if a fold is at the top line of the screen, and you try to scroll down, then the new top line of the screen would still be within the same fold, and as a result you don’t scroll at all.

I’m interested to hear if anybody else find some cool tricks!

EDIT 2026-04-07: Moved edit -scratch…delete-buffer inside eval -draft so they don’t mess up the interactive editor state.

7 Likes

Hi, @Screwtapello ! That’s nice! Thanks for sharing.

Would you mind if I make a suggestion? I think fold-enable would be more composable if, instead of receiving a regular expression, it would simply fold the current selections.

I say it would be more composable for two reasons.

First because it would compose nicely with Kakoune keys for manipulating selections. It could, for example, fold any object selection.

Second, because it could then be used to build new commands on top of it. For instance, the user could easily implement the current behaviour on top of the new one:

define-command fold-regex -params 1 %{
    execute-keys "%%s%arg{1}<ret>"
    fold-enable
}

Writing a command to fold the next region enclosed by matching characters would also be extremelly easy:

define-command fold-matching-pair %{
    execute-keys m
    fold-enable
}

And so on…

1 Like

I did think about something like that; the problem is that the folds need to update as you edit the buffer, which means the plugin needs rules for how find foldable areas, not just a specific example of things to fold.

I remember Vim’s folding implementation included a lot of things like automatic folds and manual folds and nested folds; the lack of these things is why I called it a ā€œtoyā€ code-folding plugin.

1 Like

Hmm, I see…

Then, what if fold-enable receives not a regex, but the keys to define a folding region? Since keys are more expressive than regular expressions, we would get a more general solution.

I sketched the following version of fold-enable that receives keys as arguments, based on a simplified version of your code:

declare-option range-specs fold_ranges
declare-option str fold_keys

define-command fold-disable %{
    try %{ remove-highlighter window/fold_ranges }
    remove-hooks window fold-update
    unset-option window fold_ranges
    unset-option window fold_pattern
}

define-command fold-enable -params 1 %{
    fold-disable

    set-option window fold_keys %arg{1}
    hook -group fold-update window NormalIdle .* fold-update
    add-highlighter window/fold_ranges replace-ranges fold_ranges
}

define-command -hidden fold-update %{
    evaluate-commands -save-regs f %{
        evaluate-commands -draft %{
            try %{
                # Execute the provided keys to get the fold regions.
                # 
                # It may happen that the provided keys may result in no selection,
                # so we need to wrap this in a try block.
                execute-keys %opt{fold_keys}
            }
            set-register f %val{selections_desc}
        }

        edit -scratch
        execute-keys '"f<a-P>' a<ret><left> '|{rgb:888888+rF@Default}...' <esc> 'x_'
        set-register f %val{selections}
        delete-buffer
        try %{
            # Again, if we get no selections when executing the provided keys, then
            # the register `f` wil contain no range-spec. In that case, we should
            # fail gracefully.
            set-option window fold_ranges %val{timestamp} %reg{f}
        }
    }
}

Now, an implementation of fold-regex would be:

define-command fold-regex -params 1 %{
    fold-enable "%%s%arg{1}<ret>"
}

And we could also easily define new commands. A folding command for curly brackets languages would be:

define-command fold-curly-brackets %[
    fold-enable '%s\{<ret>m'
]

Here we start to see the advantage of this approach: there’s no way a simple regex could work with nesting curly brackets. But that’s pretty easy to define using keys.

And for Python, we could use the indent object selection:

define-command fold-python %{
    fold-enable '%s^(class|def)<ret>/:$<ret>j<a-i>i'
}

Hmm, interesting! I’ll try it for a while, and see how it works out.

Also, I tried accepting multiple key sequences concatenating the list of selections from each, so I could select class and def separately, and avoid folds for class methods being absorbed into the fold for the whole class. Unfortunately, it seems like replace-ranges suffers from the same limitations as selections: nested ranges are flattened.

Edit: I cited the wrong key: I meant <S> instead of <a-K>. The text was updated to fix the mistake.

Nested folds can be achieved using a trick: further refining a folding region if it happens to contain the main cursor. That is, if a folding region contains the cursor, Kakoune will show it unfolded. Then, since there’s no reason for it to be marked as a folding region, we can proceed to fold subregions inside of it.

Here is an implementation of the idea. It’s a bit buggy, but it’s enough to prove the concept.

define-command fold-enable -params 1 -docstring %{
    fold-enable <keys>: enable code folding on the current window. Folding regions
    are defined by <keys> as following: given an arbitrary selection of the current
    buffer, <keys> will select subregions of that selection, and that subregions
    will be folded.
} %{
    fold-disable

    set-option window fold_keys %arg{1}
    hook -group fold-update window NormalIdle .* fold-update
    add-highlighter window/fold_ranges replace-ranges fold_ranges
}

define-command fold-curly-brackets %[
    fold-enable 's\{<ret>m'
]

define-command fold-python %{
    fold-enable 's^\s*(class|def)<ret>/:$<ret>j<a-i>i'
}

define-command -hidden fold-update %{
    evaluate-commands -save-regs fg %{
        # Save cursor position so that we can refine folding regions below.
        set-register g %val{selection_desc}

        evaluate-commands -draft %{
            # Execute the provided keys to get the fold regions.
            # 
            # It may happen that the provided keys may result in no selection, so
            # we need to wrap this in a try block.
            try %{
                execute-keys '%' %opt{fold_keys}
                # Since Kakoune always visually unfolds a region when the cursor
                # is over it, it will become apparent that nested regions didn't
                # get folded. So, when the cursor is over a folded region, we must
                # fold the nested regions instead.
                evaluate-commands -itersel %{
                    # Cut off the cursor from the current selection and then execute
                    # the folding keys on the selections left after the cut.
                    selection-difference %reg{g}
                    execute-keys %opt{fold_keys}
                }
            }
            set-register f %val{selections_desc}
        }

        edit -scratch
        execute-keys '"f<a-P>' a<ret><left> '|{rgb:888888+rF@Default}...' <esc> 'x_'
        set-register f %val{selections}
        delete-buffer
        try %{
            # Again, we get no selections when executing the provided keys, then
            # the register `f` wil contain no range-spec. In that case, we should
            # fail gracefully.
            set-option window fold_ranges %val{timestamp} %reg{f}
        }
    }
}

# Unfortunatelly, marks don't have an operation for computing the difference, only
# union and intersection. The <S> key does that using regular expressions, but
# there's no way to do that using selections. So I implemented that functionality
# here.
define-command -hidden selection-difference -params 1 %{
    evaluate-commands %{
        lua %val{selection_desc} %arg{1} %{
            local Vec = {}

            function Vec.new(line, column)
                return setmetatable({line = line, column = column}, Vec)
            end

            function Vec.__eq(a, b)
                return a.line == b.line and a.column == b.column
            end

            function Vec.__lt(a, b)
                return a.line < b.line or a.line == b.line and a.column < b.column
            end

            function Vec.__le(a, b)
                return a < b or a == b
            end

            local selection_desc = arg[1]
            local mark_desc = arg[2]

            local a_line, a_col, b_line, b_col =
                selection_desc:match("(%d+).(%d+),(%d+).(%d+)")
            local selection = {
                first = Vec.new(tonumber(a_line), tonumber(a_col)),
                last = Vec.new(tonumber(b_line), tonumber(b_col)),
            }

            local a_line, a_col, b_line, b_col =
                mark_desc:match("(%d+).(%d+),(%d+).(%d+)")
            local mark = {
                first = Vec.new(tonumber(a_line), tonumber(a_col)),
                last = Vec.new(tonumber(b_line), tonumber(b_col)),
            }

            function format_selection_desc(vec1, vec2)
                return string.format("%d.%d,%d.%d", vec1.line, vec1.column, vec2.line, vec2.column)
            end

            -- An heuristics to avoid that the user-provided keys select again the
            -- whole region instead of just a subregion.
            function shrink_selections()
                kak.execute_keys("H<a-;>L<a-;>")
            end

            -- First case: no intersections
            if mark.last < selection.first or selection.last < mark.first then
                return
            end

            -- Second case: selection starts and ends before mark.
            if selection.first < mark.first and selection.last <= mark.last then
                kak.select(format_selection_desc(selection.first, mark.first))
                shrink_selections()
                return
            end

            -- Third case: mark starts and ends before selection.
            if mark.first <= selection.first and mark.last < selection.last then
                kak.select(format_selection_desc(mark.last, selection.last))
                shrink_selections()
                return
            end

            -- Four case: both are equal.
            if mark.first == selection.first and mark.last == selection.last then
                kak.fail("no selections remaining")
            end

            -- Fifth case: mark is within selection and we must split selection in two.
            kak.select(
                format_selection_desc(selection.first, mark.first),
                format_selection_desc(mark.last, selection.last)
            )

            shrink_selections()
        } 
    }
}

Some considerations:

  • I made a subtle change in the meaning of the keys fold-enable accepts as a parameter. Before, it meant: ā€œthat keys, when executed, will create selections in the entire buffer that will be used as folding regionsā€. Now it means: ā€œgiven an arbitrary pre-defined selection, that keys, when executed, will create selections inside that pre-defined selection that will be used as folding regionsā€. That changes the keys for folding curly brackets from %s\{<ret>m to s\{<ret>m (without the inital %) for example. That change in the meaning of the keys allows me to use the keys both to create the initial folding regions and to refine the region containing the cursor.
  • I missed an operation for combining marks using the difference of selections (instead of unior or intersection). That’s odd, since we can split a selection with a regular expression using the <S> key but can’t do the same using another selection. So I implemented that functionality. For convenience, I used Luar for that, but any tool can do the job.
1 Like

That is actually really smart. I wonder how hard it would be to do something similar without Lua…

My skills writting POSIX shell scripting are not that good since I prefer to use Fish. So I don’t know how hard that would be.

Anyway, the more I think about it, the more convinced I am that Kakoune should have the difference operation for marks built-in.

Think for a moment: there’s an equivalence between some operations on marks and some keys (the former operate on selections, the latter operate on regular expressions):

  • The union operation for marks is equivalent to the ? and <a-?> keys;
  • The intersection is equivalent to the s key;
  • The difference is equivalent to the <S> key (I mentioned <a-K> in the previous message, but that was a mistake);
  • And we can also imagine the equivalent of <a-k> (keep a mark only if it fully contains the selection, or vice-versa) and <a-K> (keep a mark only if it does not fully contain the selection, or vice-versa).

The fact that Kakoune has <S>, <a-k> and <a-K> is a testament that those operations are useful.

There’s definitely scope for Kakoune to have more intricate operations on marks; I think when mawww was implementing them, he was thinking about the mark and the selection as equal-sized sets, and wanting to do pair-wise operations between them. There’s a lot of scope for operations between marks and selections with different numbers of ranges, though.


I spent some time tinkering today, and this is what I came up with:

# Folded ranges in the current window
declare-option -hidden range-specs fold_ranges
# Keys used to select foldable ranges when they need to be recalculated.
declare-option -hidden str fold_keys

# We need some temporary variables while calculating folds.
# We'd use registers, but they cannot be completely empty,
# they always contain at least an empty string,
# which means they can't represent "no matches found".
# On the other hand, str-list options *can* be completely empty.
declare-option -hidden str-list fold_initial_selections
declare-option -hidden str-list fold_bare_ranges
declare-option -hidden str-list fold_subfold_ranges

# How folds are displayed
set-face global Folded +u@Default

define-command fold-disable %{
    try %{ remove-highlighter window/fold_ranges }
    remove-hooks window fold-update
    unset-option window fold_ranges
    unset-option window fold_pattern
}

define-command fold-enable -params 1 %{
    # Clean up any old config, just in case
    fold-disable

    set-option window fold_keys %arg{1}
    hook -group fold-update window NormalIdle .* fold-update
    add-highlighter window/fold_ranges replace-ranges fold_ranges
}

define-command -hidden fold-update %{
    # Reset the values of our temporary variables
    set-option global fold_initial_selections %val{selections_desc}
    set-option global fold_bare_ranges
    set-option global fold_subfold_ranges

    evaluate-commands -draft %{
        try %{
            execute-keys <percent> %opt{fold_keys}

            evaluate-commands %sh{
                ranges_intersect() {
                    fold_start=${1%,*}
                    fold_start_line=${fold_start%.*}
                    fold_start_col=${fold_start#*.}
                    fold_end=${1#*,}
                    fold_end_line=${fold_end%.*}
                    fold_end_col=${fold_end#*.}

                    shift

                    for sel_range in "$@"; do
                        sel_start=${sel_range%,*}
                        sel_start_line=${sel_start%.*}
                        sel_start_col=${sel_start#*.}
                        sel_end=${sel_range#*,}
                        sel_end_line=${sel_end%.*}
                        sel_end_col=${sel_end#*.}

                        if [ $fold_end_line -lt $sel_start_line ]; then
                            continue
                        elif [ $sel_end_line -lt $fold_start_line ]; then
                            continue
                        elif [ $fold_end_line -eq $sel_start_line ] &&
                            [ $fold_end_col -lt $sel_start_col ]; then
                            continue
                        elif [ $sel_end_line -eq $fold_start_line ] &&
                            [ $sel_end_col -lt $fold_start_col ]; then
                            continue
                        else
                            return 0
                        fi
                    done

                    return 1
                }

                for fold_range in $kak_selections_desc ; do
                    if ranges_intersect $fold_range $kak_opt_fold_initial_selections ; then
                        # This potential fold has a selection in it,
                        # let's add it to the list of sub-folds
                        echo set -add global fold_subfold_ranges "$fold_range"
                    else
                        # This potential fold does not overlap with anything,
                        # let's use it.
                        printf "set -add global fold_bare_ranges '%s'\n" \
                            "$fold_range|{Folded}..."
                    fi
                done
            }
        }

        # There might not be any subfold ranges,
        # or the subfold ranges might not contain any subfolds,
        # so we have to wrap this in try %{} too.
        try %{
            echo -debug subfold-ranges: %opt{fold_subfold_ranges}
            select %opt{fold_subfold_ranges}
            echo -debug subfold-ranges: %val{selections_desc}
            execute-keys %opt{fold_keys}

            echo -debug subfolds: %val{selections_desc}

            evaluate-commands %sh{
                for fold_range in $kak_selections_desc; do
                    printf "set -add global fold_bare_ranges '%s'\n" \
                        "$fold_range|{Folded}..."
                done
            }
        }
    }

    # Copy our chosen folds into the range-selection option.
    set-option window fold_ranges %val{timestamp} %opt{fold_bare_ranges}

    # Clear the new values of our temporary variables.
    set-option global fold_initial_selections
    set-option global fold_bare_ranges
    set-option global fold_subfold_ranges
}

For user-interface, it’s basically as you described: fold-enable takes keys that finds folds within the current selection. The differences are:

  • uses POSIX shell instead of Lua
  • uses global variables instead of registers (because they can actually be empty)

After having written all of that, it occurred to me that rather than just doing two steps (top-level folds, then looking for sub-folds inside any top-level folds that intersected with selections) I should probably recurse until no more folds are found. Python generally only has two levels of hierarchy though (classes and methods), so this is good enough for now.

1 Like

Great work in this thread so far!
Thank you to both of you, if it can pave the way to builtin code-folding in Kakoune it would be a very welcomed addition to the editing model.

Beyond code, one common use-case popularized by Org-mode in Emacs is to be able to recursively fold nested prose section by their heading level (h1, h2, h3…)

Here are a few issues around this topic:

That’s really good!

Thanks!

Nice! Reading the discussions in the PRs, it seems people didn’t realise that s, S, ?, <a-?>, <a-k> and <a-K> are for regular expressions what the aforementioned operations on marks are for selections. Once we understand that, it seems to me that it’s much easier to advocate for their inclusion in Kakoune.