Skip to content

Instantly share code, notes, and snippets.

@adscriven
Last active September 10, 2020 10:54
Show Gist options
  • Save adscriven/6dcdb723e50e768d267986d0efd75c1f to your computer and use it in GitHub Desktop.
Save adscriven/6dcdb723e50e768d267986d0efd75c1f to your computer and use it in GitHub Desktop.
Echo the path to the cursor, in terms of indentation.
" indentpath.vim -- echo the indentation path to the cursor.
" https://gist.github.com/adscriven/6dcdb723e50e768d267986d0efd75c1f
" Public domain. 2020-09-10 Iau
" 2020-09-10 allow echoing lines verbatim with linebreaks
" 2020-09-02 change default separator from | to │
" 2020-09-01 s:fit(): remove unnecessary calculations
" 2020-09-01 s:truncpc(): handle multibyte
" 2020-09-01 s:echolist(): don't add padding to last item
" 2020-09-01 s:echolist(): invoke s:fit multiple times
" [ ] Echo the path after /...<cr>, not just after n.
" [ ] Multibyte is still not handled everywhere.
" [ ] Fix s:curindentline()
" 1. If a line has indentation, or starts in col 1 AND matches
" indentpath_pattern, use that line.
" 2. Otherwise the line is blank, or has char in col 1 AND doesn't
" match indentpath_pattern. So find lines above and below that
" have indentation. Pick the one with maximum indentation.
" 3. Give it a better name ffs.
" [ ] If <plug>[indentpath-n] generates pattern not found error, this
" produces an ugly message that there's an error in key() on line 1.
" Trap the error and :echomsg it properly.
" REQUIREMENTS {{{1
" Vim 8
" INSTALLATION {{{1
" Chuck this file in ~/.vim/plugin
" USAGE {{{1
"
" Example vimrc mappings:
"
" " Display the indentation path on demand.
" nmap <BS> <plug>[indentpath-echo]
" " Display the indentation path on demand.
" nmap \<BS> <plug>[indentpath-echo-verbatim]
" " Display the indentation path after searching.
" nmap n <plug>[indentpath-n]
" nmap N <plug>[indentpath-N]
" DESCRIPTION {{{1
"
" This plugin echoes the path to the cursor in terms of indentation.
" This can be useful when editing long functions to remind you of
" your place in the code. It's also useful for indented lists and
" other structured text.
" So for example, with the text
"
" fun! s:findline(getaline, iscorrectline)
" let n = a:getaline(line('.'))
" while n != 0
"
" if a:iscorrectline(n)
"
" return n
" #
" endif
" let n = a:getaline(n)
" endwhile
" return 0
" endfun
" and the cursor indicated by '#', and a screen width of 80, and using
" the example mapping above, pressing backspace will display:
"
" fun! s:findline(getaline, iscorr | while n != | if a:iscorrectlin
"
" (It may show slightly less if the ruler is displayed in the last line.)
" The text is trimmed to fit the screen without triggering a 'Press ENTER ...'
" prompt. If the cursor is already at the top level of indentation,
" 'TOP' is echoed.
"
" Again, with the example mappings above, pressing backslash then
" backspace will display:
"
" fun! s:findline(getaline, iscorrectline)
" while n != 0
" if a:iscorrectline(n)
" Press ENTER or type command to continue
"
" This is useful if the single-line display loses too much information
" and you want the full context.
" CONFIGURATION {{{1
"
" Configuration is done via the global variables g:indentpath_format,
" g:indentpath_pattern, b:indentpath_format, and b:indentpath_pattern.
" Thus configuration may be done globally, with buffer-local overrides
" for specific filetypes. All variables are optional with fallback
" default.
" indentpath_format
" -----------------
" This controls the path separators, colours, and how the path is
" adjusted to fit on the screen. The default value:
" let g:indentpath_format = {
" \ 'itemhl': 'Normal', The item highlight group.
" \ 'sep': '|', Characters separating items.
" \ 'sephl': 'SpecialKey', The separator highlight group.
" \ 'pad': ' ', Padding either side of a separator.
" \ 'padhl': 'Normal', The padding highlight group.
" \ 'width': 0, The target width for items. 0 is auto.
" \ 'minwidth': 10, The minimum truncated width.
" \ 'fit': 1, Adjust the item width to fit the screen.
" \ 'trunc': 'r', l, r, or empty. Truncate the left or right
" items in the path to fit the screen.
" \ 'verbatim': 0 Show lines as they are, with linebreaks.
" Overrules all other settings.
" You will get a 'Press ENTER ...' prompt.
" \ }
"
" Every property is optional. Some examples:
" let g:indentpath_format = {
" \ 'width': 0,
" \ 'minwidth': 0,
" \ 'fit': 0,
" \ 'trunc': ''
" \ }
" Each path item is displayed verbatim (aside from trimming whitespace).
" let g:indentpath_format = {
" \ 'width': 30,
" \ 'minwidth': 0,
" \ 'fit': 0,
" \ 'trunc': ''
" \ }
" Each path item is displayed as 30 characters wide.
" let g:indentpath_format = {
" \ 'width': 30,
" \ 'minwidth': 0,
" \ 'fit': 0,
" \ 'trunc': 'l'
" \ }
" Each path item is displayed as 30 characters wide, with items
" removed from the LHS if the path is too long to fit on the screen
" without generating a 'Hit ENTER ...' prompt.
" let g:indentpath_format = {
" \ 'width': 30,
" \ 'minwidth': 10,
" \ 'fit': 1,
" \ 'trunc': 'r'
" \ }
" Each path item is displayed as 30 characters wide, unless this
" results in a path too long for the screen. 'fit': 1 will cause the
" items to be reduced in size until the whole path fits. However no
" item will be made shorter than 10 characters. If the path is still
" too long to fit, 'truncate': 'r' will cause items to be removed from
" the RHS of the path until it is short enough.
" let g:indentpath_format = {
" \ 'minwidth': 9,
" \ 'pad': ''
" \ }
" Compact.
" indentpath_pattern
" ------------------
" When looking for parent indentation lines, some lines may need to be
" excluded, for example empty lines, or preprocessor lines for C.
" Which lines to look for are specified as a regular expression which
" is matched against the whole line.
" The default is:
" '^\%(\s*\)\@>\%(\\\|//\|#\|\*\|/\*\|{\)\@!\&\%(^\h\k\*:\)\@!'
" This excludes lines starting with optional whitespace then
" '\', '//', '#', '/*', '*', '*\', or '{'. It also excludes
" lines with a 'LABEL:' in the first column. It should be a reasonable
" effort for VimL, C and perhaps other curly-brace languages.
" Alternatively a whitelist of lines to match may work better for
" specific filetypes, e.g.:
" '^\h\k*(\|^\s*\%(for\|if\|while\|switch\|struct\)\>'
" This includes lines starting with 'funcname(', or optional
" whitespace followed by 'for', 'if', etc., as a whole word.
" Blank lines are always automatically skipped when looking
" for the indentation path.
" To change the lines matched for a certain filetype, set
" b:indentpath_pattern in an ftplugin for that filetype, or use an
" autocommand in your vimrc. E.g.:
" augroup vimrc_indentpath
" autocmd!
" autocmd FileType foo let b:indentpath_pattern =
" \ '^\s*\%(fun\%[ction]\|if\|while\)\>'
" augroup END
" The match is case sensitive. Use '\c' in the pattern for
" a case-insensitive match.
" FIND INDENTATION PATH TO CURSOR {{{1
let s:cpo = &cpo
set cpo&vim
" Defaults.
let s:patvar = 'indentpath_pattern'
let s:defpat = '^\%(\s*\)\@>\%(\\\|//\|#\|\*\|/\*\|{\)\@!\&\%(^\h\k\*:\)\@!'
let s:getpat = {-> get(b:, s:patvar, get(g:, s:patvar, s:defpat))}
" Search for a line.
" getaline(m): search from line m, return next line n, 0 when no more lines
" iscorrectline(n): return 0 if n == 0 or the check fails, 1 if it succeeds
" Returns: n when iscorrectline(n) returns 1.
fun! s:findline(getaline, iscorrectline)
let n = a:getaline(line('.'))
while n != 0
if a:iscorrectline(n)
return n
endif
let n = a:getaline(n)
endwhile
return 0
endfun
let s:prevnonblankline = {n -> prevnonblank(n - 1)}
" Return line n if it's non-blank, or the first non-blank
" line above or below, whichever has the largest indentation.
let s:curindentline = {n -> {nxt, prev -> {in, ip ->
\ in > ip ? nxt : prev
\ }(indent(nxt), indent(prev))}(nextnonblank(n), prevnonblank(n))}
" Make the iscorrectline() predicate for line n.
let s:makeisparentline = {n -> {nmax -> {m ->
\ indent(m) < indent(nmax) && getline(m) =~# s:getpat()
\ }}(s:curindentline(n))}
" Return the indentation path to line n as a list.
" Finds the parent lines of n, in terms of indentation. Returns a list
" of lines, ls, in order of least indentation to most indentation. It
" skips blank lines and lines prefixed with: \ // # * /*
" You need to supply the initial empty list, ls.
let s:indentpath = {n, ls -> n == 0
\ ? ls
\ : s:indentpath(s:findline(s:prevnonblankline, s:makeisparentline(n)),
\ [getline(n)] + ls)}
" DISPLAY INDENTATION PATH {{{1
" Defaults.
let s:fmtvar = 'indentpath_format'
let s:deffmt = {
\ 'itemhl': 'Normal',
\ 'sep': '',
\ 'sephl': 'SpecialKey',
\ 'pad': ' ',
\ 'padhl': 'Normal',
\ 'width': 0,
\ 'minwidth': 10,
\ 'fit': 1,
\ 'trunc': 'r',
\ 'verbatim': 0
\ }
let s:getfmt = {-> extend(extend(copy(s:deffmt), get(g:, s:fmtvar, {})),
\ get(b:, s:fmtvar, {}))}
" Non-mutating.
let s:map = {f, xs -> map(copy(xs), {i, x -> f(x, i)})}
" Trim whitespace from the left of string s.
let s:triml = {s -> substitute(s, '^\s*', '', '')}
" Replace spaces in s with tabs according to &tabstop.
" This has become unwieldy in order to deal with multibyte chars.
" strpart() because I can't slice: s[0:0] == s[0], and s[0:-1] == s
let s:tabstospaces = {s -> {i -> i == -1
\ ? s
\ : s:tabstospaces(substitute(s, "\t", repeat(" ", &ts - i % &ts), ""))
\ }(stridx(s,"\t")==-1?-1:strdisplaywidth(strpart(s,0,stridx(s,"\t"))))}
" Fill width w with string s. Curried for use with s:map.
" strchars(s) < w: append spaces to width w
" strchars(s) == w: return s
" strchars(s) > w: truncate s to w
" w == 0: return s.
let s:fill = {w -> {s -> {len -> len >= w
\ ? s[0:w-1]
\ : s . repeat(' ', w - len)
\ }(strchars(s))}}
" Return screen width available without triggering 'Hit ENTER ...'.
" Subtracts space for the ruler from &columns. You also need to hide the
" ruler before echoing, and restore it afterwards. This may make the
" last path item appear shorter than it is because it's obscured by the
" restored ruler. This is necessary to maximise space without generating
" a prompt, even if the ruler isn't being displayed in the last line.
" XXX: Why this works isn't clear to me; subtract a larger number from
" &columns if you do get a prompt.
let s:room = {-> &columns - 12}
" Truncate string s by percentage pc, with minimum width m.
" Round down since integers and ultimately mustn't exceed s:room().
let s:truncpc = {pc, m, s -> {len ->
\ substitute(strcharpart(s, 0, max([m, float2nr(round(len * pc - 0.5))])),
\ '\s\+$', '', '')
\ }(strchars(s))}
" Truncate each x in xs to fit total width w, with min. item width m.
" XXX: linear scale; exponential would work better so that longer items
" get trimmed more aggressively.
let s:fit = {w, m, xs -> {len -> len <= w
\ ? xs
\ : s:map({x -> s:truncpc(1.0 * w / len, m, x)}, xs)
\ }(strchars(join(xs, '')))}
fun! s:echohl(grp, s)
exe 'echohl ' . a:grp
echon a:s
echohl None
endfun
" Echo list according to format fmt.
fun! s:echolist(list, fmt)
let xs = s:map(s:triml, a:list)
let xs = s:map(s:tabstospaces, xs)
let xs[len(xs) - 1] = substitute(xs[len(xs) - 1], '\s\+$', '', '')
" Fill each item to width, or don't change it if width == 0.
let xs = s:map(s:fill(a:fmt.width), xs)
" Remove unnecessary padding from the last item.
let xs[len(xs)-1] = substitute(xs[len(xs)-1], '\s\+$', '', '')
let fullsep = a:fmt.pad . a:fmt.sep . a:fmt.pad
let Joinlen = {xs -> strchars(join(xs, fullsep))}
let seplen = (len(xs) - 1) * strchars(fullsep)
let r = s:room() - seplen
let len = Joinlen(xs)
let lastlen = 0
" Shorten items if necessary. Some items may reach their minimum
" width, and then the total may not be shortened enough. But perhaps
" there are still some items which can be shortened further. So
" invoke s:fit() repeatedly until the items fit on the screen, or
" there is nothing left to shorten.
while a:fmt.fit && len > s:room() && len != lastlen
let xs = s:fit(r, a:fmt.minwidth, xs)
let lastlen = len
let len = Joinlen(xs)
endwhile
" Functions for truncating the path as a whole.
let trunclist = {}
" Remove items from left of xs, replacing with '...', until
" the joined xs and separators fit within s:room().
let trunclist.l = {xs -> l:Joinlen(['...'] + xs) <= s:room()
\ ? ['...'] + xs
\ : len(xs) == 1
\ ? ['...' . xs[0][-s : room() + 3]]
\ : l:trunclist.l(xs[1:])}
" Similarly, but from the right.
let trunclist.r = {xs -> l:Joinlen(xs + ['...']) <= s:room()
\ ? xs + ['...']
\ : len(xs) == 1
\ ? [xs[0][0 : s:room() - 4] . '...']
\ : l:trunclist.r(xs[0 : -2])}
if a:fmt.trunc =~# '^[lr]$' && Joinlen(xs) > s:room()
let xs = trunclist[a:fmt.trunc](xs)
endif
" Hiding the ruler before echoing allows more room.
let ru = &ruler
set noruler
call s:echohl(a:fmt.itemhl, remove(xs, 0))
for x in xs
call s:echohl(a:fmt.padhl, a:fmt.pad)
call s:echohl(a:fmt.sephl, a:fmt.sep)
call s:echohl(a:fmt.padhl, a:fmt.pad)
call s:echohl(a:fmt.itemhl, x)
endfor
let &ruler = ru
endfun
fun! s:echoindentpath(verbatim)
let xs = s:indentpath(line('.'), [])
" Remove the last item -- we don't need the current line.
let xs = xs[:-2]
let xs = empty(xs) ? ['TOP'] : xs
let fmt = s:getfmt()
if fmt.verbatim || a:verbatim
exe 'echohl ' . fmt.itemhl
echo join(xs, "\n")
echohl None
else
call s:echolist(xs, fmt)
endif
endfun
" PUBLIC INTERFACE {{{1
nnoremap <silent> <plug>[indentpath-echo] :<c-u>call <sid>echoindentpath(0)<cr>
nnoremap <silent> <plug>[indentpath-echo-verbatim]
\ :<c-u>call <sid>echoindentpath(1)<cr>
fun! s:key(c)
exe 'normal!' . v:count1 . a:c . 'zv'
redraw
call s:echoindentpath(0)
endfun
" Automatically echo the indentation path after searching.
nnoremap <silent> <plug>[indentpath-n] :<c-u>call <sid>key('n')<cr>
nnoremap <silent> <plug>[indentpath-N] :<c-u>call <sid>key('N')<cr>
let &cpo = s:cpo
" vim: set fdm=marker :
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment