597 lines
16 KiB
VimL
597 lines
16 KiB
VimL
" Vim filetype plugin file
|
|
" filetype: ledger
|
|
" by Johann Klähn; Use according to the terms of the GPL>=2.
|
|
" vim:ts=2:sw=2:sts=2:foldmethod=marker
|
|
|
|
if exists("b:did_ftplugin")
|
|
finish
|
|
endif
|
|
|
|
let b:did_ftplugin = 1
|
|
|
|
let b:undo_ftplugin = "setlocal ".
|
|
\ "foldmethod< foldtext< ".
|
|
\ "include< comments< omnifunc< formatprg<"
|
|
|
|
" don't fill fold lines --> cleaner look
|
|
setl fillchars="fold: "
|
|
setl foldtext=LedgerFoldText()
|
|
setl foldmethod=syntax
|
|
setl include=^!include
|
|
setl comments=b:;
|
|
setl omnifunc=LedgerComplete
|
|
|
|
" set location of ledger binary for checking and auto-formatting
|
|
if ! exists("g:ledger_bin") || empty(g:ledger_bin) || ! executable(split(g:ledger_bin, '\s')[0])
|
|
if executable('ledger')
|
|
let g:ledger_bin = 'ledger'
|
|
else
|
|
unlet g:ledger_bin
|
|
echoerr "ledger command not found. Set g:ledger_bin or extend $PATH ".
|
|
\ "to enable error checking and auto-formatting."
|
|
endif
|
|
endif
|
|
|
|
if exists("g:ledger_bin")
|
|
exe 'setl formatprg='.substitute(g:ledger_bin, ' ', '\\ ', 'g').'\ -f\ -\ print'
|
|
endif
|
|
|
|
" You can set a maximal number of columns the fold text (excluding amount)
|
|
" will use by overriding g:ledger_maxwidth in your .vimrc.
|
|
" When maxwidth is zero, the amount will be displayed at the far right side
|
|
" of the screen.
|
|
if !exists('g:ledger_maxwidth')
|
|
let g:ledger_maxwidth = 0
|
|
endif
|
|
|
|
if !exists('g:ledger_fillstring')
|
|
let g:ledger_fillstring = ' '
|
|
endif
|
|
|
|
" If enabled this will list the most detailed matches at the top {{{
|
|
" of the completion list.
|
|
" For example when you have some accounts like this:
|
|
" A:Ba:Bu
|
|
" A:Bu:Bu
|
|
" and you complete on A:B:B normal behaviour may be the following
|
|
" A:B:B
|
|
" A:Bu:Bu
|
|
" A:Bu
|
|
" A:Ba:Bu
|
|
" A:Ba
|
|
" A
|
|
" with this option turned on it will be
|
|
" A:B:B
|
|
" A:Bu:Bu
|
|
" A:Ba:Bu
|
|
" A:Bu
|
|
" A:Ba
|
|
" A
|
|
" }}}
|
|
if !exists('g:ledger_detailed_first')
|
|
let g:ledger_detailed_first = 0
|
|
endif
|
|
|
|
let s:rx_amount = '\('.
|
|
\ '\%([0-9]\+\)'.
|
|
\ '\%([,.][0-9]\+\)*'.
|
|
\ '\|'.
|
|
\ '[,.][0-9]\+'.
|
|
\ '\)'.
|
|
\ '\s*\%([[:alpha:]¢$€£]\+\s*\)\?'.
|
|
\ '\%(\s*;.*\)\?$'
|
|
|
|
function! LedgerFoldText() "{{{1
|
|
" find amount
|
|
let amount = ""
|
|
let lnum = v:foldstart
|
|
while lnum <= v:foldend
|
|
let line = getline(lnum)
|
|
|
|
" Skip metadata/leading comment
|
|
if line !~ '^\%(\s\+;\|\d\)'
|
|
" No comment, look for amount...
|
|
let groups = matchlist(line, s:rx_amount)
|
|
if ! empty(groups)
|
|
let amount = groups[1]
|
|
break
|
|
endif
|
|
endif
|
|
let lnum += 1
|
|
endwhile
|
|
|
|
let fmt = '%s %s '
|
|
" strip whitespace at beginning and end of line
|
|
let foldtext = substitute(getline(v:foldstart),
|
|
\ '\(^\s\+\|\s\+$\)', '', 'g')
|
|
|
|
" number of columns foldtext can use
|
|
let columns = s:get_columns()
|
|
if g:ledger_maxwidth
|
|
let columns = min([columns, g:ledger_maxwidth])
|
|
endif
|
|
let columns -= s:multibyte_strlen(printf(fmt, '', amount))
|
|
|
|
" add spaces so the text is always long enough when we strip it
|
|
" to a certain width (fake table)
|
|
if strlen(g:ledger_fillstring)
|
|
" add extra spaces so fillstring aligns
|
|
let filen = s:multibyte_strlen(g:ledger_fillstring)
|
|
let folen = s:multibyte_strlen(foldtext)
|
|
let foldtext .= repeat(' ', filen - (folen%filen))
|
|
|
|
let foldtext .= repeat(g:ledger_fillstring,
|
|
\ s:get_columns()/filen)
|
|
else
|
|
let foldtext .= repeat(' ', s:get_columns())
|
|
endif
|
|
|
|
" we don't use slices[:5], because that messes up multibyte characters
|
|
let foldtext = substitute(foldtext, '.\{'.columns.'}\zs.*$', '', '')
|
|
|
|
return printf(fmt, foldtext, amount)
|
|
endfunction "}}}
|
|
|
|
function! LedgerComplete(findstart, base) "{{{1
|
|
if a:findstart
|
|
let lnum = line('.')
|
|
let line = getline('.')
|
|
let b:compl_context = ''
|
|
if line =~ '^\s\+[^[:blank:];]' "{{{2 (account)
|
|
" only allow completion when in or at end of account name
|
|
if matchend(line, '^\s\+\%(\S \S\|\S\)\+') >= col('.') - 1
|
|
" the start of the first non-blank character
|
|
" (excluding virtual-transaction-marks)
|
|
" is the beginning of the account name
|
|
let b:compl_context = 'account'
|
|
return matchend(line, '^\s\+[\[(]\?')
|
|
endif
|
|
elseif line =~ '^\d' "{{{2 (description)
|
|
let pre = matchend(line, '^\d\S\+\%(([^)]*)\|[*?!]\|\s\)\+')
|
|
if pre < col('.') - 1
|
|
let b:compl_context = 'description'
|
|
return pre
|
|
endif
|
|
elseif line =~ '^$' "{{{2 (new line)
|
|
let b:compl_context = 'new'
|
|
endif "}}}
|
|
return -1
|
|
else
|
|
if ! exists('b:compl_cache')
|
|
let b:compl_cache = s:collect_completion_data()
|
|
let b:compl_cache['#'] = changenr()
|
|
endif
|
|
|
|
let results = []
|
|
if b:compl_context == 'account' "{{{2 (account)
|
|
unlet! b:compl_context
|
|
let hierarchy = split(a:base, ':')
|
|
if a:base =~ ':$'
|
|
call add(hierarchy, '')
|
|
endif
|
|
|
|
let results = LedgerFindInTree(b:compl_cache.accounts, hierarchy)
|
|
" sort by alphabet and reverse because it will get reversed one more time
|
|
if g:ledger_detailed_first
|
|
let results = reverse(sort(results, 's:sort_accounts_by_depth'))
|
|
else
|
|
let results = sort(results)
|
|
endif
|
|
call insert(results, a:base)
|
|
elseif b:compl_context == 'description' "{{{2 (description)
|
|
let results = [a:base] + s:filter_items(b:compl_cache.descriptions, a:base)
|
|
elseif b:compl_context == 'new' "{{{2 (new line)
|
|
return [strftime('%Y/%m/%d')]
|
|
endif "}}}
|
|
|
|
" no completion (apart from a:base) found. update cache if file has changed
|
|
if len(results) <= 1 && b:compl_cache['#'] != changenr()
|
|
unlet b:compl_cache
|
|
return LedgerComplete(a:findstart, a:base)
|
|
else
|
|
unlet! b:compl_context
|
|
return results
|
|
endif
|
|
endif
|
|
endf "}}}
|
|
|
|
function! LedgerFindInTree(tree, levels) "{{{1
|
|
if empty(a:levels)
|
|
return []
|
|
endif
|
|
let results = []
|
|
let currentlvl = a:levels[0]
|
|
let nextlvls = a:levels[1:]
|
|
let branches = s:filter_items(keys(a:tree), currentlvl)
|
|
for branch in branches
|
|
call add(results, branch)
|
|
if !empty(nextlvls)
|
|
for result in LedgerFindInTree(a:tree[branch], nextlvls)
|
|
call add(results, branch.':'.result)
|
|
endfor
|
|
endif
|
|
endfor
|
|
return results
|
|
endf "}}}
|
|
|
|
function! LedgerToggleTransactionState(lnum, ...)
|
|
if a:0 == 1
|
|
let chars = a:1
|
|
else
|
|
let chars = ' *'
|
|
endif
|
|
let trans = s:transaction.from_lnum(a:lnum)
|
|
if empty(trans)
|
|
return
|
|
endif
|
|
|
|
let old = has_key(trans, 'state') ? trans['state'] : ' '
|
|
let i = stridx(chars, old) + 1
|
|
let new = chars[i >= len(chars) ? 0 : i]
|
|
|
|
call trans.set_state(new)
|
|
|
|
call setline(trans['head'], trans.format_head())
|
|
endf
|
|
|
|
function! LedgerSetTransactionState(lnum, char) "{{{1
|
|
" modifies or sets the state of the transaction at the cursor,
|
|
" removing the state alltogether if a:char is empty
|
|
let trans = s:transaction.from_lnum(a:lnum)
|
|
if empty(trans)
|
|
return
|
|
endif
|
|
|
|
call trans.set_state(a:char)
|
|
|
|
call setline(trans['head'], trans.format_head())
|
|
endf "}}}
|
|
|
|
function! LedgerSetDate(lnum, type, ...) "{{{1
|
|
let time = a:0 == 1 ? a:1 : localtime()
|
|
let trans = s:transaction.from_lnum(a:lnum)
|
|
if empty(trans)
|
|
return
|
|
endif
|
|
|
|
let formatted = strftime('%Y/%m/%d', time)
|
|
if has_key(trans, 'date') && ! empty(trans['date'])
|
|
let date = split(trans['date'], '=')
|
|
else
|
|
let date = [formatted]
|
|
endif
|
|
|
|
if a:type ==? 'actual'
|
|
let date[0] = formatted
|
|
elseif a:type ==? 'effective'
|
|
if time < 0
|
|
" remove effective date
|
|
let date = [date[0]]
|
|
else
|
|
" set effective date
|
|
if len(date) >= 2
|
|
let date[1] = formatted
|
|
else
|
|
call add(date, formatted)
|
|
endif
|
|
endif
|
|
endif
|
|
|
|
let trans['date'] = join(date, '=')
|
|
|
|
call setline(trans['head'], trans.format_head())
|
|
endf "}}}
|
|
|
|
function! s:collect_completion_data() "{{{1
|
|
let transactions = s:get_transactions()
|
|
let cache = {'descriptions': [], 'tags': {}, 'accounts': {}}
|
|
let accounts = []
|
|
for xact in transactions
|
|
" collect descriptions
|
|
if index(cache.descriptions, xact['description']) < 0
|
|
call add(cache.descriptions, xact['description'])
|
|
endif
|
|
let [t, postings] = xact.parse_body()
|
|
let tagdicts = [t]
|
|
|
|
" collect account names
|
|
for posting in postings
|
|
if has_key(posting, 'tags')
|
|
call add(tagdicts, posting.tags)
|
|
endif
|
|
" remove virtual-transaction-marks
|
|
let name = substitute(posting.account, '\%(^\s*[\[(]\?\|[\])]\?\s*$\)', '', 'g')
|
|
if index(accounts, name) < 0
|
|
call add(accounts, name)
|
|
endif
|
|
endfor
|
|
|
|
" collect tags
|
|
for tags in tagdicts | for [tag, val] in items(tags)
|
|
let values = get(cache.tags, tag, [])
|
|
if index(values, val) < 0
|
|
call add(values, val)
|
|
endif
|
|
let cache.tags[tag] = values
|
|
endfor | endfor
|
|
endfor
|
|
|
|
for account in accounts
|
|
let last = cache.accounts
|
|
for part in split(account, ':')
|
|
let last[part] = get(last, part, {})
|
|
let last = last[part]
|
|
endfor
|
|
endfor
|
|
|
|
return cache
|
|
endf "}}}
|
|
|
|
let s:transaction = {} "{{{1
|
|
function! s:transaction.new() dict
|
|
return copy(s:transaction)
|
|
endf
|
|
|
|
function! s:transaction.from_lnum(lnum) dict "{{{2
|
|
let [head, tail] = s:get_transaction_extents(a:lnum)
|
|
if ! head
|
|
return {}
|
|
endif
|
|
|
|
let trans = copy(s:transaction)
|
|
let trans['head'] = head
|
|
let trans['tail'] = tail
|
|
|
|
" split off eventual comments at the end of line
|
|
let line = split(getline(head), '\ze\s*\%(\t\| \);', 1)
|
|
if len(line) > 1
|
|
let trans['appendix'] = join(line[1:], '')
|
|
endif
|
|
|
|
" parse rest of line
|
|
" FIXME (minor): will not preserve spacing (see 'join(parts)')
|
|
let parts = split(line[0], '\s\+')
|
|
if parts[0] ==# '~'
|
|
let trans['expr'] = join(parts[1:])
|
|
return trans
|
|
elseif parts[0] !~ '^\d'
|
|
" this case is avoided in s:get_transaction_extents(),
|
|
" but we'll check anyway.
|
|
return {}
|
|
endif
|
|
|
|
for part in parts
|
|
if ! has_key(trans, 'date') && part =~ '^\d'
|
|
let trans['date'] = part
|
|
elseif ! has_key(trans, 'code') && part =~ '^([^)]*)$'
|
|
let trans['code'] = part[1:-2]
|
|
elseif ! has_key(trans, 'state') && part =~ '^[[:punct:]]$'
|
|
" the first character by itself is assumed to be the state of the transaction.
|
|
let trans['state'] = part
|
|
else
|
|
" everything after date/code or state belongs to the description
|
|
break
|
|
endif
|
|
call remove(parts, 0)
|
|
endfor
|
|
|
|
let trans['description'] = join(parts)
|
|
return trans
|
|
endf "}}}
|
|
|
|
function! s:transaction.set_state(char) dict "{{{2
|
|
if has_key(self, 'state') && a:char =~ '^\s*$'
|
|
call remove(self, 'state')
|
|
else
|
|
let self['state'] = a:char
|
|
endif
|
|
endf "}}}
|
|
|
|
function! s:transaction.parse_body(...) dict "{{{2
|
|
if a:0 == 2
|
|
let head = a:1
|
|
let tail = a:2
|
|
elseif a:0 == 0
|
|
let head = self['head']
|
|
let tail = self['tail']
|
|
else
|
|
throw "wrong number of arguments for parse_body()"
|
|
return []
|
|
endif
|
|
|
|
if ! head || tail <= head
|
|
return []
|
|
endif
|
|
|
|
let lnum = head
|
|
let tags = {}
|
|
let postings = []
|
|
while lnum <= tail
|
|
let line = split(getline(lnum), '\s*\%(\t\| \);', 1)
|
|
|
|
if line[0] =~ '^\s\+[^[:blank:];]'
|
|
" posting
|
|
" FIXME: replaces original spacing in amount with single spaces
|
|
let parts = split(line[0], '\%(\t\| \)\s*')
|
|
call add(postings, {'account': parts[0], 'amount': join(parts[1:], ' ')})
|
|
end
|
|
|
|
" where are tags to be stored?
|
|
if empty(postings)
|
|
" they belong to the transaction
|
|
let tag_container = tags
|
|
else
|
|
" they belong to last posting
|
|
if ! has_key(postings[-1], 'tags')
|
|
let postings[-1]['tags'] = {}
|
|
endif
|
|
let tag_container = postings[-1]['tags']
|
|
endif
|
|
|
|
let comment = join(line[1:], ' ;')
|
|
if comment =~ '^\s*:'
|
|
" tags without values
|
|
for t in s:findall(comment, ':\zs[^:[:blank:]]\([^:]*[^:[:blank:]]\)\?\ze:')
|
|
let tag_container[t] = ''
|
|
endfor
|
|
elseif comment =~ '^\s*[^:[:blank:]][^:]\+:'
|
|
" tag with value
|
|
let key = matchstr(comment, '^\s*\zs[^:]\+\ze:')
|
|
if ! empty(key)
|
|
let val = matchstr(comment, ':\s*\zs.*\ze\s*$')
|
|
let tag_container[key] = val
|
|
endif
|
|
endif
|
|
let lnum += 1
|
|
endw
|
|
return [tags, postings]
|
|
endf "}}}
|
|
|
|
function! s:transaction.format_head() dict "{{{2
|
|
if has_key(self, 'expr')
|
|
return '~ '.self['expr']
|
|
endif
|
|
|
|
let parts = []
|
|
if has_key(self, 'date') | call add(parts, self['date']) | endif
|
|
if has_key(self, 'code') | call add(parts, '('.self['code'].')') | endif
|
|
if has_key(self, 'state') | call add(parts, self['state']) | endif
|
|
if has_key(self, 'description') | call add(parts, self['description']) | endif
|
|
|
|
let line = join(parts)
|
|
if has_key(self, 'appendix') | let line .= self['appendix'] | endif
|
|
|
|
return line
|
|
endf "}}}
|
|
"}}}
|
|
|
|
" Helper functions {{{1
|
|
|
|
function! s:get_transactions(...) "{{{2
|
|
if a:0 == 2
|
|
let lnum = a:1
|
|
let end = a:2
|
|
elseif a:0 == 0
|
|
let lnum = 1
|
|
let end = line('$')
|
|
else
|
|
throw "wrong number of arguments for get_transactions()"
|
|
return []
|
|
endif
|
|
|
|
" safe view / position
|
|
let view = winsaveview()
|
|
let fe = &foldenable
|
|
set nofoldenable
|
|
|
|
let transactions = []
|
|
call cursor(lnum, 0)
|
|
while lnum && lnum <= end
|
|
let trans = s:transaction.from_lnum(lnum)
|
|
if ! empty(trans)
|
|
call add(transactions, trans)
|
|
call cursor(trans['tail'], 0)
|
|
endif
|
|
let lnum = search('^[~[:digit:]]\S\+', 'cW')
|
|
endw
|
|
|
|
" restore view / position
|
|
let &foldenable = fe
|
|
call winrestview(view)
|
|
|
|
return transactions
|
|
endf "}}}
|
|
|
|
function! s:get_transaction_extents(lnum) "{{{2
|
|
if ! (indent(a:lnum) || getline(a:lnum) =~ '^[~[:digit:]]\S\+')
|
|
" only do something if lnum is in a transaction
|
|
return [0, 0]
|
|
endif
|
|
|
|
" safe view / position
|
|
let view = winsaveview()
|
|
let fe = &foldenable
|
|
set nofoldenable
|
|
|
|
call cursor(a:lnum, 0)
|
|
let head = search('^[~[:digit:]]\S\+', 'bcnW')
|
|
let tail = search('^[^;[:blank:]]\S\+', 'nW')
|
|
let tail = tail > head ? tail - 1 : line('$')
|
|
|
|
" restore view / position
|
|
let &foldenable = fe
|
|
call winrestview(view)
|
|
|
|
return head ? [head, tail] : [0, 0]
|
|
endf "}}}
|
|
|
|
function! s:findall(text, rx) " {{{2
|
|
" returns all the matches in a string,
|
|
" there will be overlapping matches according to :help match()
|
|
let matches = []
|
|
|
|
while 1
|
|
let m = matchstr(a:text, a:rx, 0, len(matches)+1)
|
|
if empty(m)
|
|
break
|
|
endif
|
|
|
|
call add(matches, m)
|
|
endw
|
|
|
|
return matches
|
|
endf "}}}
|
|
|
|
" return length of string with fix for multibyte characters
|
|
function! s:multibyte_strlen(text) "{{{2
|
|
return strlen(substitute(a:text, ".", "x", "g"))
|
|
endfunction "}}}
|
|
|
|
" get # of visible/usable columns in current window
|
|
function! s:get_columns() " {{{2
|
|
" As long as vim doesn't provide a command natively,
|
|
" we have to compute the available columns.
|
|
" see :help todo.txt -> /Add argument to winwidth()/
|
|
|
|
let columns = (winwidth(0) == 0 ? 80 : winwidth(0)) - &foldcolumn
|
|
if &number
|
|
" line('w$') is the line number of the last line
|
|
let columns -= max([len(line('w$'))+1, &numberwidth])
|
|
endif
|
|
|
|
" are there any signs/is the sign column displayed?
|
|
redir => signs
|
|
silent execute 'sign place buffer='.string(bufnr("%"))
|
|
redir END
|
|
if signs =~# 'id='
|
|
let columns -= 2
|
|
endif
|
|
|
|
return columns
|
|
endf "}}}
|
|
|
|
" remove spaces at start and end of string
|
|
function! s:strip_spaces(text) "{{{2
|
|
return matchstr(a:text, '^\s*\zs\S\%(.*\S\)\?\ze\s*$')
|
|
endf "}}}
|
|
|
|
" return only those items that start with a specified keyword
|
|
function! s:filter_items(list, keyword) "{{{2
|
|
return filter(copy(a:list), 'v:val =~ ''^\V'.substitute(a:keyword, '\\', '\\\\', 'g').'''')
|
|
endf "}}}
|
|
|
|
" return all lines matching an expression, returning only the matched part
|
|
function! s:grep_buffer(expression) "{{{2
|
|
let lines = map(getline(1, '$'), 'matchstr(v:val, '''.a:expression.''')')
|
|
return filter(lines, 'v:val != ""')
|
|
endf "}}}
|
|
|
|
function! s:sort_accounts_by_depth(name1, name2) "{{{2
|
|
let depth1 = s:count_expression(a:name1, ':')
|
|
let depth2 = s:count_expression(a:name2, ':')
|
|
return depth1 == depth2 ? 0 : depth1 > depth2 ? 1 : -1
|
|
endf "}}}
|
|
|
|
function! s:count_expression(text, expression) "{{{2
|
|
return len(split(a:text, a:expression, 1))-1
|
|
endf "}}}
|