Vim editing a binary plist file

Using macOS, you may have had experiences of handling plist files. For example, ~/Library/Preferences/.GlobalPreferences.plist file holds some configurations of macOS. When you type defaults write -g ApplePressAndHoldEnabled -bool false on terminal, the following lines are added to .GlobalPreferences.plist:

<key>ApplePressAndHoldEnabled</key>
<false/>

So when you dig down the preferences or resources of macOS system, you’ll meet plist files.

vim-plist

darfink’s vim-plist plugin handles *.plist files quite well. A plist file is in one of three formats; json, binary, xml. macOS is bundled with the plutil command that can convert a plist file from one format to another. The plugin also uses plutil to handle read and write of plist files.

The plugin registers autocmd for BufReadCmd and FileReadCmd to read *.plist files, BufWriteCmd and FileWriteCmd to write *.plist files. BufRead and BufWrite events are triggered after reading the file into the buffer, but BufReadCmd and BufWriteCmd events are triggered before reading the file, and that autocmd should handle actual read and write operation of that file. These differences make handling plist files more complex.

Problems

*.strings files

Overall, the plugin is quite useful and seamless. But recently, I found some *.strings plist files under /System/Library, almost of them in binary formats. The plugin registers autocmds only for *.plist files, so there is no chance to convert them to readable formats.

At first, I’ve considered to register an autocmd for *.strings file, but I’m not sure about that .strings extension is only used for plist file, and also there can be other extensions with plist contents (for example, *.nib files are plist, too).

Saving a new file

darfink’s vim-plist checks g:plist_save_format and b:plist_save_format before writing to plist files. The buffer-local variable is set when the plugin reads the file and detect the format. The global one is set by user, and overrides buffer-local one.

I don’t want the plugin to override the content of plist files with different format, so I haven’t set g:plist_save_format. Then the problem raised. Open a new plist file, like vim test.plist, and save it after editing. Then the plugin didn’t set b:plist_save_format because it’s a new file, and I also didn’t set g:plist_save_format, so the plugin don’t know the format to use for saving.

I think this problem can be solved by patching the plugin, but its last commit is pushed in 2014, which makes me use the faster way.

Incomplete plist files (Update: 2019-06-18)

vim-plist always uses plutil -convert command when opening plist files. But plutil checks whether the given file is valid or not. This leads to a problem when we have incomplete plist files. Say a plist file is tracked by git, and when there is a merge conflict, that plist file will contain SCM conflict markers.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<<<<<<< HEAD
    <key>other_content</key>
    <integer>4</integer>
=======
    <key>content</key>
    <integer>5</integer>
>>>>>>> master
    <key>test</key>
    <integer>3</integer>
</dict>
</plist>

With vim-plist, we will never be able to edit this file because vim-plist will refuse to load it into the buffer.

Solving the problems with .vimrc

Binary plist files

I decided to check if the file is binary plist file or not, and load with functions of vim-plist plugin. The checking process is easy, because the file starts with bplist. At first, I think it’s okay to register an autocmd for BufReadCmd because calling functions of the plugin should be easy. So my first try was:

function! s:DetectBinaryPlist()
  let l:filename = expand('<afile>')
  if filereadable(l:filename)
    let l:content = readfile(l:filename, 'b', 1)
    if len(content) > 0 && content[0] =~# '^bplist'
      return 1
    endif
  endif
  return 0
endfunction
autocmd BufReadCmd *
      \ if s:DetectBinaryPlist() |
      \   call plist#Read(1) |
      \   call plist#ReadPost() |
      \ endif

Can you see the problem? This makes Vim returns an empty buffer when it reads a file that’s not a binary plist file. As I said above, BufReadCmd should handle actual read and write operation of the file. If it’s not a binary plist file, Vim won’t read anything according to this code.

So I changed BufReadCmd to BufRead. This event happens after reading the file into the buffer, so I have to empty the buffer.

function! s:ConvertBinaryPlist()
  silent! execute '%d'
  call plist#Read(1)
  call plist#ReadPost()
endfunction
autocmd BufRead *
      \ if getline(1) =~# '^bplist' |
      \   call s:ConvertBinaryPlist() |
      \ endif

The getline(1) reads the first line of the buffer, and we can call it because it’s after reading the file. It’s working quite well, so at that time, I wanted to bring the writing functionality also.

function! s:ConvertBinaryPlist()
  silent! execute '%d'
  call plist#Read(1)
  call plist#ReadPost()

  autocmd! BufWriteCmd,FileWriteCmd <buffer>
  autocmd BufWriteCmd,FileWriteCmd <buffer>
        \ call plist#Write()
endfunction

Note that the autocmd! line means deleting every other BufWriteCmd and FileWriteCmd autocmds for that buffer, and the second line registers BufWriteCmd and FileWriteCmd for that buffer.

But when I saved after editing a *.strings file, I saw this error message:

<stdin>: Property List error: Unable to convert string to correct encoding / JSON error: JSON text did not start with array or object and option to allow fragments not set.

After poking around, I found that the fileencoding is set to latin1. The original file is binary and we just replaced the contents of the buffer, the fileencoding was not properly set. So I just set it to UTF-8.

function! s:ConvertBinaryPlist()
  silent! execute '%d'
  call plist#Read(1)
  call plist#ReadPost()
  set fileencoding=utf-8

  autocmd! BufWriteCmd,FileWriteCmd <buffer>
  autocmd BufWriteCmd,FileWriteCmd <buffer>
        \ call plist#Write()
endfunction

Saving a new file

It’s simple:

autocmd BufNewFile *.plist
      \ if !get(b:, 'plist_original_format') |
      \   let b:plist_original_format = 'xml' |
      \ endif

We don’t write binary file by our own hands, so a new plist file would be in xml format.

Disable autocmds of vim-plist (Update: 2019-06-18)

I decided to remove default autocmds of vim-plist. The plist files I open or edit are either in XML format or binary format. When the file is in binary format, it’s handled by above vimrc. When the file is in XML format, it doesn’t need to be converted as I’ll save them in the same format.

let g:loaded_plist = 1
let g:plist_display_format = 'xml'
let g:plist_save_format = ''
let g:plist_json_filetype = 'json'

When g:loaded_plist exists, vim-plist will do nothing. In plugin/plist.vim, it registers autocmds and set default values to global variables. So here we set global variables of vim-plist. The default value of g:plist_json_filetype is 'javascript', but I set it to 'json' as Vim can handle that filetype.

Fix for editing a new file (Update: 2021-09-10)

As we disabled all autocmds of vim-plist, above “Saving a new file” section also doesn’t work. Instead of setting the b:plist_original_format variable, setting filetype to xml would be enough.

autocmd BufNewFile *.plist set filetype=xml

Also, there was an update to vim-plist, supporting plistutil and removing the g:plist_json_filetype variable.

Apply changes of vim-plist#5 (Update: 2022-04-09)

In this pull request, plist#BufReadCmd(), plist#FileReadCmd(), plist#BufWriteCmd(), and plist#FileWriteCmd() are added instead of plist#Read() and plist#Write() functions. So we apply that changes to the patch.

Conclusion

So this is the complete part of my .vimrc for binary plist files:

function! s:ConvertBinaryPlist()
  silent! execute '%d'
  call plist#BufReadCmd()
  set fileencoding=utf-8

  augroup BinaryPlistWrite
    autocmd! BufWriteCmd,FileWriteCmd <buffer>
    autocmd BufWriteCmd <buffer> call plist#BufWriteCmd()
    autocmd FileWriteCmd <buffer> call plist#FileWriteCmd()
  augroup END
endfunction
augroup BinaryPlistRead
  autocmd!
  autocmd BufRead *
        \ if getline(1) =~# '^bplist' |
        \   call s:ConvertBinaryPlist() |
        \ endif
  autocmd BufNewFile *.plist set filetype=xml
augroup END
" Disable default autocmds
let g:loaded_plist = 1
let g:plist_display_format = 'xml'
let g:plist_save_format = ''

It’s also available on GitHub, you can visit my dotfiles repository!