" Tabular:     Align columnar data using regex-designated column boundaries
" Maintainer:  Matthew Wozniski (godlygeek@gmail.com)
" Date:        Thu, 03 May 2012 20:49:32 -0400
" Version:     1.0
"
" Long Description:
" Sometimes, it's useful to line up text.  Naturally, it's nicer to have the
" computer do this for you, since aligning things by hand quickly becomes
" unpleasant.  While there are other plugins for aligning text, the ones I've
" tried are either impossibly difficult to understand and use, or too simplistic
" to handle complicated tasks.  This plugin aims to make the easy things easy
" and the hard things possible, without providing an unnecessarily obtuse
" interface.  It's still a work in progress, and criticisms are welcome.
"
" License:
" Copyright (c) 2012, Matthew J. Wozniski
" All rights reserved.
"
" Redistribution and use in source and binary forms, with or without
" modification, are permitted provided that the following conditions are met:
"     * Redistributions of source code must retain the above copyright notice,
"       this list of conditions and the following disclaimer.
"     * Redistributions in binary form must reproduce the above copyright
"       notice, this list of conditions and the following disclaimer in the
"       documentation and/or other materials provided with the distribution.
"     * The names of the contributors may not be used to endorse or promote
"       products derived from this software without specific prior written
"       permission.
"
" THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER ``AS IS'' AND ANY EXPRESS
" OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
" OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN
" NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT,
" INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
" LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
" OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
" LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
" NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
" EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

" Stupid vimscript crap                                                   {{{1
let s:savecpo = &cpo
set cpo&vim

" Private Functions                                                       {{{1

" Return the number of bytes in a string after expanding tabs to spaces.  {{{2
" This expansion is done based on the current value of 'tabstop'
if exists('*strdisplaywidth')
  " Needs vim 7.3
  let s:Strlen = function("strdisplaywidth")
else
  function! s:Strlen(string)
    " Implement the tab handling part of strdisplaywidth for vim 7.2 and
    " earlier - not much that can be done about handling doublewidth
    " characters.
    let rv = 0
    let i = 0

    for char in split(a:string, '\zs')
      if char == "\t"
        let rv += &ts - i
        let i = 0
      else
        let rv += 1
        let i = (i + 1) % &ts
      endif
    endfor

    return rv
  endfunction
endif

" Align a string within a field                                           {{{2
" These functions do not trim leading and trailing spaces.

" Right align 'string' in a field of size 'fieldwidth'
function! s:Right(string, fieldwidth)
  let spaces = a:fieldwidth - s:Strlen(a:string)
  return matchstr(a:string, '^\s*') . repeat(" ", spaces) . substitute(a:string, '^\s*', '', '')
endfunction

" Left align 'string' in a field of size 'fieldwidth'
function! s:Left(string, fieldwidth)
  let spaces = a:fieldwidth - s:Strlen(a:string)
  return a:string . repeat(" ", spaces)
endfunction

" Center align 'string' in a field of size 'fieldwidth'
function! s:Center(string, fieldwidth)
  let spaces = a:fieldwidth - s:Strlen(a:string)
  let right = spaces / 2
  let left = right + (right * 2 != spaces)
  return repeat(" ", left) . a:string . repeat(" ", right)
endfunction

" Remove spaces around a string                                           {{{2

" Remove all trailing spaces from a string.
function! s:StripTrailingSpaces(string)
  return matchstr(a:string, '^.\{-}\ze\s*$')
endfunction

" Remove all leading spaces from a string.
function! s:StripLeadingSpaces(string)
  return matchstr(a:string, '^\s*\zs.*$')
endfunction

" Find the longest common indent for a list of strings                    {{{2
" If a string is shorter than the others but contains no non-whitespace
" characters, it does not end the match.  This provides consistency with
" vim's behavior that blank lines don't have trailing spaces.
function! s:LongestCommonIndent(strings)
  if empty(a:strings)
    return ''
  endif

  let n = 0
  while 1
    let ns = join(map(copy(a:strings), 'v:val[n]'), '')
    if ns !~ '^ \+$\|^\t\+$'
      break
    endif
    let n += 1
  endwhile

  return strpart(a:strings[0], 0, n)
endfunction

" Split a string into fields and delimiters                               {{{2
" Like split(), but include the delimiters as elements
" All odd numbered elements are delimiters
" All even numbered elements are non-delimiters (including zero)
function! s:SplitDelim(string, delim)
  let rv = []
  let beg = 0

  let len = len(a:string)
  let searchoff = 0

  while 1
    let mid = match(a:string, a:delim, beg + searchoff, 1)
    if mid == -1 || mid == len
      break
    endif

    let matchstr = matchstr(a:string, a:delim, beg + searchoff, 1)
    let length = strlen(matchstr)

    if length == 0 && beg == mid
      " Zero-length match for a zero-length delimiter - advance past it
      let searchoff += 1
      continue
    endif

    if beg == mid
      let rv += [ "" ]
    else
      let rv += [ a:string[beg : mid-1] ]
    endif

    let rv += [ matchstr ]

    let beg = mid + length
    let searchoff = 0
  endwhile

  let rv += [ strpart(a:string, beg) ]

  return rv
endfunction

" Replace lines from `start' to `start + len - 1' with the given strings. {{{2
" If more lines are needed to show all strings, they will be added.
" If there are too few strings to fill all lines, lines will be removed.
function! s:SetLines(start, len, strings)
  if a:start > line('$') + 1 || a:start < 1
    throw "Invalid start line!"
  endif

  if len(a:strings) > a:len
    let fensave = &fen
    let view = winsaveview()
    call append(a:start + a:len - 1, repeat([''], len(a:strings) - a:len))
    call winrestview(view)
    let &fen = fensave
  elseif len(a:strings) < a:len
    let fensave = &fen
    let view = winsaveview()
    sil exe (a:start + len(a:strings)) . ',' .  (a:start + a:len - 1) . 'd_'
    call winrestview(view)
    let &fen = fensave
  endif

  call setline(a:start, a:strings)
endfunction

" Runs the given commandstring argument as an expression.                 {{{2
" The commandstring expression is expected to reference the a:lines argument.
" If the commandstring expression returns a list the items of that list will
" replace the items in a:lines, otherwise the expression is assumed to have
" modified a:lines itself.
function! s:FilterString(lines, commandstring)
  exe 'let rv = ' . a:commandstring

  if type(rv) == type(a:lines) && rv isnot a:lines
    call filter(a:lines, 0)
    call extend(a:lines, rv)
  endif
endfunction

" Public API                                                              {{{1

if !exists("g:tabular_default_format")
  let g:tabular_default_format = "l1"
endif

let s:formatelempat = '\%([lrc]\d\+\)'

function! tabular#ElementFormatPattern()
  return s:formatelempat
endfunction

" Given a list of strings and a delimiter, split each string on every
" occurrence of the delimiter pattern, format each element according to either
" the provided format (optional) or the default format, and join them back
" together with enough space padding to guarantee that the nth delimiter of
" each string is aligned.
function! tabular#TabularizeStrings(strings, delim, ...)
  if a:0 > 1
    echoerr "TabularizeStrings accepts only 2 or 3 arguments (got ".(a:0+2).")"
    return 1
  endif

  let formatstr = (a:0 ? a:1 : g:tabular_default_format)

  if formatstr !~? s:formatelempat . '\+'
    echoerr "Tabular: Invalid format \"" . formatstr . "\" specified!"
    return 1
  endif

  let format = split(formatstr, s:formatelempat . '\zs')

  let lines = a:strings

  call map(lines, 's:SplitDelim(v:val, a:delim)')

  let first_fields = []

  " Strip spaces from non-delimiters; spaces in delimiters must have been
  " matched intentionally
  for line in lines
    if len(line) == 1 && s:do_gtabularize
      continue " Leave non-matching lines unchanged for GTabularize
    endif

    call add(first_fields, line[0])

    if len(line) >= 1
      for i in range(0, len(line)-1, 2)
        let line[i] = s:StripLeadingSpaces(s:StripTrailingSpaces(line[i]))
      endfor
    endif
  endfor

  let common_indent = s:LongestCommonIndent(first_fields)

  " Find the max length of each field
  let maxes = []
  for line in lines
    if len(line) == 1 && s:do_gtabularize
      continue " non-matching lines don't affect field widths for GTabularize
    endif

    for i in range(len(line))
      if i == len(maxes)
        let maxes += [ s:Strlen(line[i]) ]
      else
        let maxes[i] = max( [ maxes[i], s:Strlen(line[i]) ] )
      endif
    endfor
  endfor

  " Concatenate the fields, according to the format pattern.
  for idx in range(len(lines))
    let line = lines[idx]

    if len(line) == 1 && s:do_gtabularize
      let lines[idx] = line[0] " GTabularize doesn't change non-matching lines
      continue
    endif

    for i in range(len(line))
      let how = format[i % len(format)][0]
      let pad = format[i % len(format)][1:-1]

      if how =~? 'l'
        let field = s:Left(line[i], maxes[i])
      elseif how =~? 'r'
        let field = s:Right(line[i], maxes[i])
      elseif how =~? 'c'
        let field = s:Center(line[i], maxes[i])
      endif

      let line[i] = field . repeat(" ", pad)
    endfor

    let prefix = common_indent
    if len(line) == 1 && s:do_gtabularize
      " We didn't strip the indent in this case; nothing to put back.
      let prefix = ''
    endif

    let lines[idx] = s:StripTrailingSpaces(prefix . join(line, ''))
  endfor
endfunction

" Apply 0 or more filters, in sequence, to selected text in the buffer    {{{2
" The lines to be filtered are determined as follows:
"   If the function is called with a range containing multiple lines, then
"     those lines will be used as the range.
"   If the function is called with no range or with a range of 1 line, then
"     if GTabularize mode is being used,
"       the range will not be adjusted
"     if "includepat" is not specified,
"       that 1 line will be filtered,
"     if "includepat" is specified and that line does not match it,
"       no lines will be filtered
"     if "includepat" is specified and that line does match it,
"       all contiguous lines above and below the specified line matching the
"       pattern will be filtered.
"
" The remaining arguments must each be a filter to apply to the text.
" Each filter must either be a String evaluating to a function to be called.
function! tabular#PipeRange(includepat, ...) range
  exe a:firstline . ',' . a:lastline
      \ . 'call tabular#PipeRangeWithOptions(a:includepat, a:000, {})'
endfunction

" Extended version of tabular#PipeRange, which
" 1) Takes the list of filters as an explicit list rather than as varargs
" 2) Supports passing a dictionary of options to control the routine.
"    Currently, the only supported option is 'mode', which determines whether
"    to behave as :Tabularize or as :GTabularize
" This allows me to add new features here without breaking API compatibility
" in the future.
function! tabular#PipeRangeWithOptions(includepat, filterlist, options) range
  let top = a:firstline
  let bot = a:lastline

  let s:do_gtabularize = (get(a:options, 'mode', '') ==# 'GTabularize')

  if !s:do_gtabularize
    " In the default mode, apply range extension logic
    if a:includepat != '' && top == bot
      if top < 0 || top > line('$') || getline(top) !~ a:includepat
        return
      endif
      while top > 1 && getline(top-1) =~ a:includepat
        let top -= 1
      endwhile
      while bot < line('$') && getline(bot+1) =~ a:includepat
        let bot += 1
      endwhile
    endif
  endif

  let lines = map(range(top, bot), 'getline(v:val)')

  for filter in a:filterlist
    if type(filter) != type("")
      echoerr "PipeRange: Bad filter: " . string(filter)
    endif

    call s:FilterString(lines, filter)

    unlet filter
  endfor

  call s:SetLines(top, bot - top + 1, lines)
endfunction

" Part of the public interface so interested pipelines can query this and
" adjust their behavior appropriately.
function! tabular#DoGTabularize()
  return s:do_gtabularize
endfunction

function! s:SplitDelimTest(string, delim, expected)
  let result = s:SplitDelim(a:string, a:delim)

  if result !=# a:expected
    echomsg 'Test failed!'
    echomsg '  string=' . string(a:string) . '  delim=' . string(a:delim)
    echomsg '  Returned=' . string(result)
    echomsg '  Expected=' . string(a:expected)
  endif
endfunction

function! tabular#SplitDelimUnitTest()
  let assignment = '[|&+*/%<>=!~-]\@<!\([<>!=]=\|=\~\)\@![|&+*/%<>=!~-]*='
  let two_spaces = '  '
  let ternary_operator = '^.\{-}\zs?\|:'
  let cpp_io = '<<\|>>'
  let pascal_assign = ':='
  let trailing_c_comments = '\/\*\|\*\/\|\/\/'

  call s:SplitDelimTest('a+=b',    assignment, ['a', '+=', 'b'])
  call s:SplitDelimTest('a-=b',    assignment, ['a', '-=', 'b'])
  call s:SplitDelimTest('a!=b',    assignment, ['a!=b'])
  call s:SplitDelimTest('a==b',    assignment, ['a==b'])
  call s:SplitDelimTest('a&=b',    assignment, ['a', '&=', 'b'])
  call s:SplitDelimTest('a|=b',    assignment, ['a', '|=', 'b'])
  call s:SplitDelimTest('a=b=c',   assignment, ['a', '=', 'b', '=', 'c'])

  call s:SplitDelimTest('a  b  c', two_spaces, ['a', '  ', 'b', '  ', 'c'])
  call s:SplitDelimTest('a b   c', two_spaces, ['a b', '  ', ' c'])
  call s:SplitDelimTest('ab    c', two_spaces, ['ab', '  ', '', '  ', 'c'])

  call s:SplitDelimTest('a?b:c',   ternary_operator, ['a', '?', 'b', ':', 'c'])

  call s:SplitDelimTest('a<<b<<c', cpp_io, ['a', '<<', 'b', '<<', 'c'])

  call s:SplitDelimTest('a:=b=c',  pascal_assign, ['a', ':=', 'b=c'])

  call s:SplitDelimTest('x//foo',  trailing_c_comments, ['x', '//', 'foo'])
  call s:SplitDelimTest('x/*foo*/',trailing_c_comments, ['x', '/*', 'foo', '*/', ''])

  call s:SplitDelimTest('#ab#cd#ef', '[^#]*', ['#', 'ab', '#', 'cd', '#', 'ef', ''])
  call s:SplitDelimTest('#ab#cd#ef', '#\zs',  ['#', '', 'ab#', '', 'cd#', '', 'ef'])
endfunction

" Stupid vimscript crap, part 2                                           {{{1
let &cpo = s:savecpo
unlet s:savecpo

" vim:set sw=2 sts=2 fdm=marker:
