" Vim filetype plugin file
" filetype: ledger
" Version: 0.1.0
" 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< "

" 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

" 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(0)
  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(0)/filen)
  else
    let foldtext .= repeat(' ', s:get_columns(0))
  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 lastcol = col('.') - 2
    if line =~ '^\d' "{{{2 (date / payee / description)
      let b:compl_context = 'payee'
      return -1
    elseif line =~ '^\s\+;' "{{{2 (metadata / tags)
      let b:compl_context = 'meta-tag'
      let first_possible = matchend(line, '^\s\+;')

      " find first column of text to be replaced
      let firstcol = lastcol
      while firstcol >= 0
        if firstcol <= first_possible
          " Stop before the ';' don't ever include it
          let firstcol = first_possible
          break
        elseif line[firstcol] =~ ':'
          " Stop before first ':'
          let firstcol += 1
          break
        endif

        let firstcol -= 1
      endwhile

      " strip whitespace starting from firstcol
      let end_of_whitespace = matchend(line, '^\s\+', firstcol)
      if end_of_whitespace != -1
        let firstcol = end_of_whitespace
      endif

      return firstcol
    elseif line =~ '^\s\+' "{{{2 (account)
      let b:compl_context = 'account'
      if matchend(line, '^\s\+\%(\S \S\|\S\)\+') <= lastcol
        " only allow completion when in or at end of account name
        return -1
      endif
      " the start of the first non-blank character
      " (excluding virtual-transaction-marks)
      " is the beginning of the account name
      return matchend(line, '^\s\+[\[(]\?')
    else "}}}
      return -1
    endif
  else
    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(LedgerGetAccountHierarchy(), hierarchy)
      " sort by alphabet and reverse because it will get reversed one more time
      let results = reverse(sort(results))
      if g:ledger_detailed_first
        let results = sort(results, 's:sort_accounts_by_depth')
      endif
      call add(results, a:base)
      return reverse(results)
    elseif b:compl_context == 'meta-tag' "{{{2
      unlet! b:compl_context
      let results = [a:base]
      call extend(results, sort(s:filter_items(keys(LedgerGetTags()), a:base)))
      return results
    else "}}}
      unlet! b:compl_context
      return []
    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! LedgerGetAccountHierarchy() "{{{1
  let hierarchy = {}
  let accounts = s:grep_buffer('^\s\+\zs[^[:blank:];]\%(\S \S\|\S\)\+\ze')
  for name in accounts
    " remove virtual-transaction-marks
    let name = substitute(name, '\%(^\s*[\[(]\?\|[\])]\?\s*$\)', '', 'g')
    let last = hierarchy
    for part in split(name, ':')
      let last[part] = get(last, part, {})
      let last = last[part]
    endfor
  endfor
  return hierarchy
endf "}}}

function! LedgerGetTags() "{{{1
  let alltags = {}
  let metalines = s:grep_buffer('^\s\+;\s*\zs.*$')
  for line in metalines
    " (spaces at beginning are stripped by matchstr!)
    if line[0] == ':'
      " multiple tags
      for val in split(line, ':')
        if val !~ '^\s*$'
          let name = s:strip_spaces(val)
          let alltags[name] = get(alltags, name, [])
        endif
      endfor
    elseif line =~ '^.*:.*$'
      " line with tag=value
      let name = s:strip_spaces(split(line, ':')[0])
      let val = s:strip_spaces(join(split(line, ':')[1:], ':'))
      let values = get(alltags, name, [])
      call add(values, val)
      let alltags[name] = values
    endif
  endfor
  return alltags
endf "}}}

" Helper functions {{{1

" 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(win) "{{{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()/
  " FIXME: Although this will propably never be used with debug mode enabled
  "        this should take the signs column into account (:help sign.txt)
  let columns = (winwidth(a:win) == 0 ? 80 : winwidth(a:win)) - &foldcolumn
  if &number
    " line('w$') is the line number of the last line
    let columns -= max([len(line('w$'))+1, &numberwidth])
  endif
  return columns
endfunction "}}}

" 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(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 "}}}