Search code examples
vimmarkdown

Quick way to select inside a Fenced Code Block in Markdown using Vim


How to conveniently select all content inside a Markdown Fenced Code Block using Vim?
For example, given a code block:

```
.wrapper {
  display: flex;
}

.wrapper > div {
  flex: 1;
}
```

Assume the cursor is inside the block.
To select in Vim, I think we can have:

  • go to the beginning of block
?```<CR>j
  • visual select to the end of block
v/```<CR>k

(alternatively, select by paragraphs till the end)

v}}k

I am looking for simpler keystrokes or ideas, perhaps something similar to vip or vi{.


Solution

  • Assuming the cursor is on the opening fence, I think the shortest method would be:

    *kV''j
    

    With the cursor on the closing fence, it would be:

    #jV''k
    

    With the cursor inside the code block, the approach would be different:

    /```/-<CR>V??+<CR>
    

    If that satisfies your needs, then you can stop reading now. If not, maybe we can try to come up with a comprehensive solution!

    Let's start by breaking down the problem:

    • select the content of a fenced code block,
    • do so when the cursor is on the opening fence,
    • do so when the cursor is between fences,
    • do so when the cursor is on the closing fence,
    • do nothing outside of those cases.

    Then let's try our hands at the three cases:

    • The cursor is on the opening fence

      Figuring out if the cursor is on a ``` is trivial:

      function! IsFence()
          return getline('.') == '```'
      endfunction!
      

      But, assuming this Markdown content:

      hello
      
      ```
      one
      two
      ```
      
      ```
      three
      four
      ```
      
      goodbye
      

      how can we figure out if we are on an opening fence or on a closing fence? Well, there are a few ways but the one I chose is to count the number of fences starting with the current one: if it is even, we are on an opening fence.

      function! IsOpeningFence()
          return IsFence() && getline(line('.'),'$')->filter({ _, val -> val =~ '^```'})->len() % 2 == 0
      endfunction
      

      Good!

    • The cursor is on the closing fence

      Now that we can determine if the cursor is on a fence and if it is an opening one, determining if it is a closing fence is trivial:

      function! IsClosingFence()
          return IsFence() && !IsOpeningFence()
      endfunction
      

      Good!

    • The cursor is between fences

      This one is a little bit trickier. There are, again, a few ways to go about it but the one I chose is to ask Vim whether the highlight group markdownCodeBlock is currently active:

      function! IsbetweenFences()
          return synID(line("."), col("."), 0)->synIDattr('name') =~? 'markdownCodeBlock'
      endfunction
      

      Good!

      NOTE: this snippet assumes that you use the built-in Markdown syntax script. If you don't, substitute markdownCodeBlock with whatever works for you.

    Now that we can figure out which case we are in, let's see what we can do for each one.

    • The cursor is on the opening fence

      if IsOpeningFence()
          normal *kV''j
      endif
      
    • The cursor is on the closing fence

      if IsClosingFence()
          normal #jV''k
      endif
      
    • The cursor is between fences

      Our original macro is not as trivial as the others to use with :help normal so we are going to try a different method:

      if IsBetweenFences()
          call search('^```', 'W')
          normal -
          call search('^```', 'Wbs')
          normal +
          normal V''
      endif
      

      where we…

      1. search forward for a ``` at the beginning of a line (the closing fence),
      2. move the cursor on the line above,
      3. search backward, this time, for a ``` at the beginning of a line (the opening fence) while leaving a mark behind us,
      4. move the cursor to the line below,
      5. enter visual-line mode from the current line to the line we marked earlier.

    OK, let's put our decision tree together:

      if IsOpeningFence()
          normal *kV''j
      elseif IsBetweenFences()
          call search('^```', 'W')
          normal -
          call search('^```', 'Wbs')
          normal +
          normal V''
      elseif IsClosingFence()
          normal #jV''k
      else
          return
      endif
    

    and consider the fact that what we do when we are between fences could also work when we are on the opening fence, which makes one branch redundant:

      if isOepeningFence() || IsBetweenFences()
          call search('^```', 'W')
          normal -
          call search('^```', 'Wbs')
          normal +
          normal V''
      elseif IsClosingFence()
          normal #jV''k
      else
          return
      endif
    

    One last thing, it would be neater if we used the same approach in both branches so let's do it:

      if IsOpeningFence() || IsInside()
          call search('^```', 'W')
          normal -
          call search('^```', 'Wbs')
          normal +
          normal V''
      elseif IsClosingFence()
          call search('^```', 'Wbs')
          normal +
          normal V''k
      else
          return
      endif
    

    Great! Let's put it all together in one single function:

    function! SelectInnerCodeBlock()
        function! IsFence()
            return getline('.') =~ '^```'
        endfunction
    
        function! IsOpeningFence()
            return IsFence() && getline(line('.'),'$')->filter({ _, val -> val =~ '^```'})->len() % 2 == 0
        endfunction
    
        function! IsBetweenFences()
            return synID(line("."), col("."), 0)->synIDattr('name') =~? 'markdownCodeBlock'
        endfunction
    
        function! IsClosingFence()
            return IsFence() && !IsOpeningFence()
        endfunction
    
        if IsOpeningFence() || IsBetweenFences()
            call search('^```', 'W')
            normal -
            call search('^```', 'Wbs')
            normal +
            normal V''
        elseif IsClosingFence()
            call search('^```', 'Wbs')
            normal +
            normal V''k
        else
            return
        endif
    endfunction
    

    and map it in visual mode (for use with v) and operator-pending mode (for use with y, d, etc.):

    xnoremap <silent> iC :<C-u>call SelectInnerCodeBlock()<CR>
    onoremap <silent> iC :<C-u>call SelectInnerCodeBlock()<CR>
    

    and see if it works:

    iC

    The last thing we need to do is make sure that our custom pseudo-text object is restricted to Markdown buffers. For that, we move our code to ~/.vim/after/ftplugin/markdown.vim, with a few minor changes:

    • SelectInnerCodeBlock() becomes script-local, see :help :s and :help <SID>,
    • our mappings become buffer-local, see :help <buffer>.
    " in ~/.vim/after/ftplugin/markdown.vim
    function! s:SelectInnerCodeBlock()
        function! IsFence()
            return getline('.') =~ '^```'
        endfunction
    
        function! IsOpeningFence()
            return IsFence() && getline(line('.'),'$')->filter({ _, val -> val =~ '^```'})->len() % 2 == 0
        endfunction
    
        function! IsBetweenFences()
            return synID(line("."), col("."), 0)->synIDattr('name') =~? 'markdownCodeBlock'
        endfunction
    
        function! IsClosingFence()
            return IsFence() && !IsOpeningFence()
        endfunction
    
        if IsOpeningFence() || IsBetweenFences()
            call search('^```', 'W')
            normal -
            call search('^```', 'Wbs')
            normal +
            normal V''
        elseif IsClosingFence()
            call search('^```', 'Wbs')
            normal +
            normal V''k
        else
            return
        endif
    endfunction
    
    xnoremap <buffer> <silent> iC :<C-u>call <SID>SelectInnerCodeBlock()<CR>
    onoremap <buffer> <silent> iC :<C-u>call <SID>SelectInnerCodeBlock()<CR>