2

I wrote a function to execute different external commands according to different file types and get the corresponding formatted results:

:function! FileFormat()
    let cursorLine = line(".")
    let filetype = &filetype
    let tab_width = 4
    let text = join(getbufline(".", 1, "$"), "\n")
    let output

    if filetype == "json"
        output = system("jq .")
    elseif filetype == 'cpp' || filetype == "c"
        output = system("astyle --style=java --indent=spaces=" . tab_width . " --pad-oper -N -C --indent-labels -xw -xW -w --mode=c")
    elseif filetype == "java"
        output = system("astyle --style=java --indent=spaces=" . tab_width . " --mode=java")
    elseif filetype == 'sh' || filetype == "zsh"
        output = system("shfmt -i " . tab_width)
    elseif filetype == 'javascript' || filetype == "js" || filetype == "typescript" || filetype == "ts"
        output = system("prettier --parser typescript --print-width 160 --tab-width " . tab_width)
    elseif filetype == 'css' || filetype == "scss" || filetype == "less" || filetype == "graphql" || filetype == "markdown" || filetype == "vue" || filetype == "html"
        output = system("prettier --parser " . filetype . " --print-width 160 --tab-width " . tab_width)
    elseif filetype == 'python'
        output = system("autopep8 --max-line-length 10000 -")
    elseif filetype == 'lua'
        output = system("stylua - --indent-type Spaces --indent-width " . tab_width . " --call-parentheses None --quote-style AutoPreferDouble")
    elseif filetype == 'tex' || filetype == "plaintex"
        output = system("latexindent")
    elseif filetype == "xml"
        output = system("xmllint --encode \"UTF-8\" --format -")
    else
        echo "Formatting of " . filetype  . " files is not currently supported."
        return
    endif

    if v:shell_error
        echo output
        return
    endif

    deletebufline(".", 1, "$")
    setbufline('.', 1, split(output, '\n'))
    execute cursorLine
:endfunction

This function requires the following steps:

  1. Get the text and file type of the current buffer.

  2. According to the file type, call different external commands, transfer the text to the external tool, and receive the formatted result of the external tool

  3. If an error occurs when the external tool formats the text, output an error message

  4. Update the formatted result to the current buffer and place the cursor at the end of the original line.

vimscript doesn't have C's popen function, how do I write text out to the stdin stream of an external tool?

system() can only get the text output by the external tool to stdout, if the external tool outputs the error message to stderr, how can I get it?

To get the text of the current buffer, I need to pass getbufline(), and then join the text with "\n". Is there a way to get all the text directly? This "\n" is used as a string, not an ASCII newline, will this affect the formatting result?

6
  • I'm not 100% certain I understand the question. Does setting formatprg in an autogroup not do what you want done?
    – MDeBusk
    Jun 15, 2022 at 17:17
  • @MDeBusk How is formatprg used? Can it be mapped to a shortcut key? I would like to be able to map this functionality to the <C-f> shortcut.
    – aszswaz
    Jun 16, 2022 at 1:34
  • When you, for example, visually select a whole buffer with ggVG and hit the = key, vim re-formats the selection according to whatever rules are set up. Whatever "formatprg" is set to (usually vim's own internal tool) does the job. Here's a bit on vimwiki about using par, which is a nice little tool for stuff other than code: vim.fandom.com/wiki/Par_text_reformatter
    – MDeBusk
    Jun 16, 2022 at 3:29
  • @MDeBusk Thanks for answering for me, but this doesn't solve a problem: for example I set :autocmd BufNewFile,BufRead *.json set formatprg=jq\ ., my text is {"demo": Hello Wolrd}, use When jq formats json, a syntax error will occur, and jq will feedback error information. In this case, it is a bad thing to directly update the error information returned by jq to the buffer. It is better to output the error information returned by jq through echo. How does this need to be achieved?
    – aszswaz
    Jun 16, 2022 at 3:52
  • I'm about 99% sure that the code as written doesn't work for other reasons; let output should fail, and output = … is not a valid vimscript statement (try let output = …). In vim9script things are different, though
    – D. Ben Knoble
    Jun 16, 2022 at 16:34

3 Answers 3

4

I would suggest to use :h formatprg and gq normal command.

For example, ~/.vim/after/ftplugin/json.vim

if executable('jq')
    let &l:formatprg = "jq -e"
elseif executable('js-beautify')
    " sudo npm -g install js-beautify
    let &l:formatprg = "js-beautify -s 2 -f -"
elseif executable('python')
    let &l:formatprg = "python -m json.tool"
endif

if !empty(&l:formatprg)
    command! -buffer Fmt normal gggqG
endif

Then for other filetypes you could their own corresponding settings, e.g. for javascript in ~/.vim/after/ftplugin/javascript.vim:

setlocal expandtab shiftwidth=2

if executable('js-beautify')
    " sudo npm -g install js-beautify
    command! -buffer Fmt :silent %!js-beautify -s 2 -f -
    let &l:formatprg = "js-beautify -s 2 -f -"
endif

Now you can press gggqG to goto top line with gg, and format buffer using gqG with defined formatprg.

This apporach would have the same issue when there is an error in formatting -- it will replace buffer with an error message.

This could be improved by introducing your own gq override like:

# gq wrapper that:
# - tries its best at keeping the cursor in place
# - tries to handle formatter errors
def GqFormat(...args: list<any>): string
    if len(args) == 0
        &opfunc = matchstr(expand('<stack>'), '[^. ]*\ze[')
        return 'g@'
    endif
    if args[0] == 'v'
        normal! gvgq
    else
        normal! '[v']gq
    endif
    if v:shell_error > 0
        silent undo
        redraw
        echomsg 'formatprg "' .. &formatprg .. '" exited with status ' .. v:shell_error
    endif
    if exists("w:gqview")
        winrestview(w:gqview)
        unlet w:gqview
    endif
    return ''
enddef
nnoremap <silent> gq :let w:gqview = winsaveview()<CR>:set opfunc=SID>GqFormat<CR>g@
nmap <silent> gqq gq_
xnoremap <silent> gq <ScriptCmd>GqFormat('v')<CR>

PS, it is in vim9script so might not work for you, but should be fairly easy to downgrade to vim legacy script.

2

The gq family of command reformat a section of the code. For some formatter the only option is to format the entire file.

I would suggest to use the %! filter pattern that take the entire buffer an input pass it to an external program and replace the buffer content by the output.

You could also combine it with a command to call the function more easily on your buffer.

function! FileFormat()
    let filetype = &filetype
    let tab_width = 4
    let r = ''

    if filetype == "json"
        let r = execute("%!%!jq")
    elseif filetype == 'cpp' || filetype == "c"
        let r = execute("%!astyle --style=java --indent=spaces=" . tab_width . " --pad-oper -N -C --indent-labels -xw -xW -w --mode=c")
    elseif filetype == "java"
        execute("%!astyle --style=java --indent=spaces=" . tab_width . " --mode=java")
    elseif filetype == 'sh' || filetype == "zsh"
        let r = execute("%!shfmt -i " . tab_width)
    elseif filetype == 'javascript' || filetype == "js" || filetype == "typescript" || filetype == "ts"
        let r = execute("%!prettier --parser typescript --print-width 160 --tab-width " . tab_width)
    elseif filetype == 'css' || filetype == "scss" || filetype == "less" || filetype == "graphql" || filetype == "markdown" || filetype == "vue" || filetype == "html"
        let r = execute("%!prettier --parser " . filetype . " --print-width 160 --tab-width " . tab_width)
    elseif filetype == 'python'
        let r = execute("%!autopep8 --max-line-length 10000 -")
    elseif filetype == 'lua'
        let r = execute("%!stylua - --indent-type Spaces --indent-width " . tab_width . " --call-parentheses None --quote-style AutoPreferDouble")
    elseif filetype == 'tex' || filetype == "plaintex"
        let r = execute("%!latexindent")
    elseif filetype == "xml"
        let r = execute("%!xmllint --encode \"UTF-8\" --format -")
    else
        echo "Formatting of " . filetype  . " files is not currently supported."
        return
    endif

    if v:shell_error != 0
        undo
    endif
endfunction

command! FileFormat :call FileFormat()

To integrate more smoothly to the vim file type concept you could define the command only for each specific file type in the FileFormat command in the corresponding FileType file (vimfiles/ftplugins/filetype.vim)

3
  • 1
    I'm using this method right now, but it's bad that if the text has a syntax error, the external tool will output the corresponding error message, which will also be put into the buffer. I want these error messages to be echo out.
    – aszswaz
    Jun 16, 2022 at 6:19
  • You can use shellredir option to avoid the stderr are part of the output but I don't know option to test the return value and do the replacement only if the program returns 0 Jun 16, 2022 at 6:57
  • I have adapted the the solution to use the return value of the filter and trigger an undo in case of failure. Jun 16, 2022 at 7:13
2

This feature is called a filter.

:help filter

Example:

:%! jq '.'
1
  • I think OP knows that it is possible to use :%!. However, it is clear that filetypes are important to the question and it would be awkward to run that many different commands manually.
    – r_31415
    Jul 24, 2022 at 19:42

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.