" 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 and 'cleared' marks) " is the beginning of the account name let b:compl_context = 'account' return matchend(line, '^\s\+[*!]\?\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 let [state, rest] = matchlist(line[0], '^\s\+\([*!]\?\)\s*\(.*\)$')[1:2] if rest =~ '\t\| ' let [account, amount] = matchlist(rest, '^\(.\{-}\)\%(\t\| \)\s*\(.\{-}\)\s*$')[1:2] else let amount = '' let account = matchstr(rest, '^\s*\zs.\{-}\ze\s*$') endif call add(postings, {'account': account, 'amount': amount, 'state': state}) 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 "}}}