Files
2023-05-15 16:59:45 +02:00

493 lines
21 KiB
VimL

" ingo/plugin/cmdcomplete/dirforaction.vim: Define custom command to complete files from a specified directory.
"
" DESCRIPTION:
" In GVIM, one can define a menu item which uses browse() in combination with
" an Ex command to open a file browser dialog in a particular directory, lets
" the user select a file, and then uses that file for a predefined Ex command.
" This script provides a function to define similar custom commands for use
" without a GUI file selector, relying instead on custom command completion.
"
" EXAMPLE:
" Define a command :BrowseTemp that edits a text file from the system TEMP
" directory. >
" call ingo#plugin#cmdcomplete#dirforaction#setup(
" \ '',
" \ 'BrowseTemp',
" \ 'edit',
" \ (exists('$TEMP') ? $TEMP : '/tmp'),
" \ '*.txt',
" \ '',
" \ ''
" \)
" You can then use the new command with file completion:
" :BrowseTemp f<Tab> -> :BrowseTemp foo.txt
"
" Copyright: (C) 2009-2023 Ingo Karkat
" The VIM LICENSE applies to this script; see ':help copyright'.
"
" Maintainer: Ingo Karkat <ingo@karkat.de>
let s:save_cpo = &cpo
set cpo&vim
function! ingo#plugin#cmdcomplete#dirforaction#SetCompletionContext( CmdLine ) abort
let l:parse = ingo#cmdargs#command#Parse(a:CmdLine, '*')
if empty(l:parse)
let g:IngoLibrary_CmdCompleteDirForAction_Context = { 'bang': '', 'count': 0, 'mods': '' }
else
let [l:fullCommandUnderCursor, l:combiner, l:commandCommands, l:range, l:commandName, l:commandBang, l:commandDirectArgs, l:commandArgs] = l:parse
let g:IngoLibrary_CmdCompleteDirForAction_Context = { 'bang': l:commandBang, 'count': (l:range =~# '^\d\+$' ? str2nr(l:range) : 0), 'mods': l:commandCommands }
endif
endfunction
function! s:RemoveDirspec( filespec, dirspecs )
for l:dirspec in a:dirspecs
if strpart(a:filespec, 0, strlen(l:dirspec)) ==# l:dirspec
return strpart(a:filespec, strlen(l:dirspec))
endif
endfor
return a:filespec
endfunction
function! s:ResolveDirspecs( dirspecs, ... )
let l:dirspecs = (type(a:dirspecs) == type(function('tr')) ? call(a:dirspecs, []) : a:dirspecs)
if a:0 && type(l:dirspecs) == type([])
if len(l:dirspecs) > 1
" Iterate over all dirspecs to find the first containing a:filespec.
for l:dirspec in l:dirspecs
if ! empty(ingo#compat#glob(ingo#fs#path#Combine(l:dirspec, a:1), 0, 1))
return l:dirspec
endif
endfor
endif
return l:dirspecs[0] " This is also the fallback if a:filespec wasn't found in any of a:dirspecs.
else
return l:dirspecs
endif
endfunction
function! s:ResolveDirspecsToList( dirspecs ) abort
try
return ingo#list#Make(s:ResolveDirspecs(a:dirspecs))
catch /^Vim\%((\a\+)\)\=:E/
throw ingo#msg#MsgFromVimException() " Don't swallow Vimscript errors.
catch /^Vim\%((\a\+)\)\=:/
call ingo#msg#VimExceptionMsg() " Errors from :echoerr.
catch
call ingo#msg#ErrorMsg(v:exception)
sleep 1 " Otherwise, the error isn't visible from inside the command-line completion function.
return []
endtry
endfunction
function! s:CompleteFiles( isReturnRawFilespecs, dirspecs, Browsefilter, Wildignore, isIncludeSubdirs, isAllowOtherDirs, CompleteFunctionHook, argLead )
let l:dirspecs = s:ResolveDirspecsToList(a:dirspecs)
let l:browsefilters = (empty(a:Browsefilter) ? ['*'] : ingo#list#Make(a:Browsefilter))
let l:save_wildignore = &wildignore
let l:wildignoreValue = ingo#actions#ValueOrFunc(a:Wildignore)
if type(l:wildignoreValue) == type('')
let &wildignore = l:wildignoreValue
endif
try
let l:filespecs = []
let l:resolvedDirspecs = []
let l:sourceCnt = 0
let l:hasAbsoluteArgLead = (! empty(a:argLead) && ingo#fs#path#IsAbsolute(a:argLead))
let l:isUpwards = 0
if l:hasAbsoluteArgLead
if a:isAllowOtherDirs
" As we have an absolute arglead, we do not need (in fact: must
" not use) the provided a:dirspecs. If we replace those with a
" single empty one, the logic below will do exactly what we
" need, as it concatenates dirspec and arglead.
let l:dirspecs = ['']
else
return []
endif
elseif ! empty(a:argLead)
let l:isUpwards = ingo#fs#path#IsUpwards(a:argLead)
if l:isUpwards
if a:isAllowOtherDirs
" The upwards arglead will combine just fine with the a:dirspecs
" (which have a trailing path separator).
else
return []
endif
elseif ingo#fs#path#IsPath(a:argLead) && ! a:isIncludeSubdirs
return []
endif
endif
for l:dirspec in l:dirspecs
if a:isIncludeSubdirs || l:hasAbsoluteArgLead || l:isUpwards
" If the l:dirspec itself contains wildcards, there may be multiple
" matches.
let l:resolvedDirspecs += ingo#compat#glob(l:dirspec, 0, 1)
" If there is a browsefilter, we need to add all directories
" separately, as most of them probably have been filtered away by
" the (file-based) a:browsefilter.
if ! empty(a:Browsefilter)
let l:dirspecWildcard = l:dirspec . a:argLead . '*' . ingo#fs#path#Separator()
let l:filespecs += ingo#compat#glob(l:dirspecWildcard, 0, 1)
let l:sourceCnt += 1
endif
endif
for l:Filter in l:browsefilters
for l:filter in ingo#list#Make(ingo#actions#ValueOrFunc(l:Filter))
let l:filespecWildcard = l:dirspec . a:argLead . l:filter
let l:filespecs += ingo#compat#glob(l:filespecWildcard, 0, 1)
let l:sourceCnt += 1
endfor
endfor
endfor
if a:isIncludeSubdirs || l:hasAbsoluteArgLead || l:isUpwards
if empty(a:Browsefilter)
" glob() doesn't add a trailing path separator on directories
" unless the glob pattern has one at the end. Append the path
" separator here to be consistent with the alternative block
" above, the built-in completion, and because it makes sense to
" show the path separator, because then autocompletion of the
" directory contents can quickly be continued.
call map(l:filespecs, 'isdirectory(v:val) ? v:val . ingo#fs#path#Separator() : v:val')
endif
if ! empty(a:CompleteFunctionHook)
let l:filespecs = call(a:CompleteFunctionHook, [l:filespecs])
endif
if ! a:isReturnRawFilespecs
call map(l:filespecs, 'ingo#compat#fnameescape(s:RemoveDirspec(v:val, l:resolvedDirspecs))')
endif
else
call filter(l:filespecs, '! isdirectory(v:val)')
if ! empty(a:CompleteFunctionHook)
let l:filespecs = call(a:CompleteFunctionHook, [l:filespecs])
endif
if ! a:isReturnRawFilespecs
call map(l:filespecs, 'ingo#compat#fnameescape(fnamemodify(v:val, ":t"))')
endif
endif
if a:argLead =~# '^\.\{1,2}$' && ! a:isAllowOtherDirs
" The globbing would include "../", but this isn't allowed here.
" Remove it.
call filter(l:filespecs, '! ingo#fs#path#IsUpwards(v:val)')
endif
if l:sourceCnt > 1
call s:BuildSuffixesExpr()
return ingo#compat#uniq(sort(l:filespecs, 's:SuffixesSort')) " Maintain lower priority of 'suffixes' while sorting.
else
return l:filespecs
endif
finally
let &wildignore = l:save_wildignore
endtry
endfunction
function! s:CompleteDirectories( isReturnRawFilespecs, dirspecs, Browsefilter, Wildignore, isIncludeSubdirs, isAllowOtherDirs, CompleteFunctionHook, argLead )
if ! a:isIncludeSubdirs && ! a:isAllowOtherDirs
return [] " No completion possible; only files from a:dirspec itself.
endif
let l:filespecs = s:CompleteFiles(1, a:dirspecs, a:Browsefilter, a:Wildignore, a:isIncludeSubdirs, a:isAllowOtherDirs, a:CompleteFunctionHook, a:argLead)
call filter(l:filespecs, 'isdirectory(v:val)')
if ! a:isReturnRawFilespecs
let l:dirspecs = s:ResolveDirspecsToList(a:dirspecs)
let l:resolvedDirspecs = ingo#collections#Flatten1(map(copy(l:dirspecs), 'ingo#compat#glob(v:val, 0, 1)'))
call map(l:filespecs, 'ingo#compat#fnameescape(s:RemoveDirspec(v:val, l:resolvedDirspecs))')
endif
return l:filespecs
endfunction
function! s:BuildSuffixesExpr()
let s:suffixesExpr =
\ '\V\%(' .
\ join(
\ map(
\ split(&suffixes, '\%(\%(^\|[^\\]\)\%(\\\\\)*\\\)\@<!\%(, *\|\ze\.\)'),
\ 'ingo#escape#Unescape(v:val, ".,")'
\ ),
\ '\|'
\ ) .
\ '\)\$'
endfunction
function! s:IsSuffix( caseSigil, filespec )
execute 'return (a:filespec =~' . a:caseSigil . ' s:suffixesExpr)'
endfunction
function! s:SuffixesSort( f1, f2 )
let l:caseSigil = (ingo#os#IsWinOrDos() ? '?' : '#') " On Windows, the entire globbing is (usually) case-insensitive.
execute 'let l:isEqual = a:f1 ==' . l:caseSigil . ' a:f2'
if l:isEqual
return 0
endif
let l:isSuffix1 = s:IsSuffix(l:caseSigil, a:f1)
let l:isSuffix2 = s:IsSuffix(l:caseSigil, a:f2)
if l:isSuffix1 == l:isSuffix2
execute 'return (a:f1 >' . l:caseSigil . ' a:f2 ? 1 : -1)'
else
return (l:isSuffix1 ? 1 : -1)
endif
endfunction
function! s:Command( isBang, count, mods, Action, PostAction, isAllowOtherDirs, DefaultFilename, FilenameProcessingFunction, FilespecProcessingFunction, dirspecs, filename )
try
"****Dechomsg '****' a:isBang a:mods string(a:Action) string(a:PostAction) a:isAllowOtherDirs string(a:DefaultFilename) string(a:FilenameProcessingFunction) string(a:FilespecProcessingFunction) string(a:dirspecs) string(a:filename)
" Detach any file options or commands for assembling the filespec.
let [l:fileOptionsAndCommands, l:filename] = ingo#cmdargs#file#FilterEscapedFileOptionsAndCommands(a:filename)
"****D echomsg '****' string(l:filename) string(l:fileOptionsAndCommands)
" Set up a context object so that Funcrefs can have access to the
" information whether <bang> was given.
let g:IngoLibrary_CmdCompleteDirForAction_Context = { 'bang': a:isBang, 'count': a:count, 'mods': a:mods }
" l:filename comes from the custom command, and must be taken as is (the
" custom completion will have already escaped the completion).
" All other filespec fragments still need escaping.
if empty(l:filename)
if type(a:DefaultFilename) == 2
let l:unescapedFilename = call(a:DefaultFilename, [s:ResolveDirspecs(a:dirspecs)])
elseif a:DefaultFilename ==# '%'
let l:unescapedFilename = expand('%:t')
else
let l:unescapedFilename = a:DefaultFilename
endif
let l:filename = ingo#compat#fnameescape(l:unescapedFilename)
else
let l:unescapedFilename = ingo#escape#file#fnameunescape(l:filename)
endif
let l:isAbsoluteFilename = ingo#fs#path#IsAbsolute(l:unescapedFilename)
if (l:isAbsoluteFilename || ingo#fs#path#IsUpwards(l:filename)) && ! a:isAllowOtherDirs
" The passed (must be typed, as the completion wouldn't offer these)
" filename refers to files outside a:dirspecs, but this is not
" allowed by the client.
call ingo#err#Set(printf('Locations outside the base director%s are not allowed', len(a:dirspecs) == 1 ? 'y' : 'ies'))
return 0
endif
let l:dirspec = (l:isAbsoluteFilename ? '' : s:ResolveDirspecs(a:dirspecs, l:unescapedFilename))
if ! empty(a:FilenameProcessingFunction)
let l:processedFilename = call(a:FilenameProcessingFunction, [l:filename, l:fileOptionsAndCommands])
if empty(l:processedFilename) || empty(l:processedFilename[0])
return 1
else
let [l:filename, l:fileOptionsAndCommands] = l:processedFilename
endif
endif
if ! empty(a:FilespecProcessingFunction)
let l:processedFilespec = call(a:FilespecProcessingFunction, [l:dirspec, l:filename, l:fileOptionsAndCommands])
if empty(l:processedFilespec) || empty(join(l:processedFilespec[0:1], ''))
return 1
else
let [l:dirspec, l:filename, l:fileOptionsAndCommands] = l:processedFilespec
endif
endif
let l:expandExpr = '\%(^\|\s\zs\|"\)%\%(:\S\+\)\?\%("\|\ze\s\|$\)'
if type(a:Action) == 2
call call(a:Action, [ingo#compat#fnameescape(l:dirspec), l:filename, l:fileOptionsAndCommands])
elseif a:Action =~# l:expandExpr
" Similar to 'makeprg', the location of the inserted filespec can be
" controlled via "%".
let l:escapedFilespec = ingo#compat#fnameescape(l:dirspec) . l:filename
let l:unescapedFilespec = l:dirspec . ingo#escape#file#fnameunescape(l:filename)
let l:action = substitute(a:Action, l:expandExpr, '\=s:Expand(submatch(0), l:fileOptionsAndCommands, l:escapedFilespec, l:unescapedFilespec)', 'g')
execute l:action
else
execute a:Action l:fileOptionsAndCommands . ingo#compat#fnameescape(l:dirspec) . l:filename
endif
if ! empty(a:PostAction)
if type(a:PostAction) == 2
call call(a:PostAction, [])
else
execute a:PostAction
endif
endif
return 1
catch /^Vim\%((\a\+)\)\=:/
call ingo#err#SetVimException()
return 0
catch
call ingo#err#Set(v:exception)
return 0
finally
unlet! g:IngoLibrary_CmdCompleteDirForAction_Context
endtry
endfunction
function! s:Expand( expr, fileOptionsAndCommands, escapedFilespec, unescapedFilespec )
if a:expr ==# '%:+'
return a:fileOptionsAndCommands
elseif a:expr ==# '"%"'
return string(a:unescapedFilespec)
elseif a:expr =~# '^"%:.*"$'
return string(fnamemodify(a:unescapedFilespec, a:expr[2:-2]))
elseif a:expr ==# '%'
return a:escapedFilespec
elseif a:expr =~# '^%:.*$'
return fnamemodify(a:escapedFilespec, a:expr[1:])
else
return a:expr
endif
endfunction
let s:count = 0
function! ingo#plugin#cmdcomplete#dirforaction#setup( command, dirspecs, parameters )
"*******************************************************************************
"* PURPOSE:
" Define a custom a:command that takes an (potentially optional) single file
" argument and executes the a:parameters.action command or Funcref with it.
" The command will have a custom completion that completes files from
" a:dirspecs, with a:parameters.browsefilter applied and
" a:parameters.wildignore extensions filtered out. The custom completion will
" return the list of file (/ directory / subdir path) names found. Those
" should be interpreted relative to (and thus do not include) a:dirspecs.
"* ASSUMPTIONS / PRECONDITIONS:
" None.
"* EFFECTS / POSTCONDITIONS:
" Defines custom a:command that takes one filename argument, which will have
" filename completion from a:dirspecs. Unless a:parameters.defaultFilename is
" provided, the filename argument is mandatory.
"* INPUTS:
" a:command Name of the custom command to be defined.
" a:dirspecs Directory/ies (including trailing path separator!) from which
" files will be completed. If empty, any filespec will be
" accepted; this automatically sets a:parameters.isIncludeSubdirs
" and a:parameters.isAllowOtherDirs.
" Or Funcref to a function that takes no arguments and returns the
" dirspec(s).
"
" a:parameters.commandAttributes
" Optional :command {attr}, e.g. <buffer>, -bang, -count, -range.
" Funcrefs can access the <bang> via
" g:IngoLibrary_CmdCompleteDirForAction_Context.bang and the <count>
" via g:IngoLibrary_CmdCompleteDirForAction_Context.count
" a:parameters.action
" Ex command (e.g. 'edit', '<line1>read') to be invoked with the
" completed filespec. Default is the :drop / :Drop command.
" Or Funcref to a function that takes the dirspec, filename (both
" already escaped for use in an Ex command), and potential
" fileOptionsAndCommands (e.g. ++enc=latin1 +set\ ft=c) and performs
" the action itself. Throw an error message if needed.
" a:parameters.postAction
" Ex command to be invoked after the file has been opened via
" a:parameters.action. Default empty.
" Or Funcref to a function that takes no arguments and performs the
" post actions itself. Throw an error message if needed.
" a:parameters.browsefilter
" File wildcard (e.g. '*.txt') used for filtering the files in
" a:dirspecs. Multiple can be specified as a List. Default is empty
" string to include all (non-hidden) files. Does not apply to
" subdirectory names (but applies to the files inside the
" subdirectories).
" Or [List of] Funcref[s] to a function that takes no arguments and
" returns the browsefilter(s).
" a:parameters.wildignore
" Comma-separated list of file extensions to be ignored. This is
" similar to a:parameters.browsefilter, but with inverted semantics,
" only file extensions, and multiple possible values. Use empty string
" to disable and pass 0 (the default) to keep the current global
" 'wildignore' setting.
" Or Funcref to a function that takes no arguments and returns the
" wildignore value.
" a:parameters.isIncludeSubdirs
" Flag whether subdirectories will be included in the completion
" matches. By default, only files in a:dirspecs itself will be offered.
" a:parameters.isAllowOtherDirs
" Flag whether directories outside of a:dirspecs (using ../ or an
" absolute path) can be passed (and are offered by the completion),
" too. Disallowed by default.
" a:parameters.defaultFilename
" If specified, the command will not require the filename argument,
" and default to this filename if none is specified.
" The special value "%" will be replaced with the current buffer's
" filename.
" Or Funcref to a function that takes the [List of] dirspec[s] and
" returns the filename. Throw an error message if needed.
" This can resolve to an empty string; however, then your
" a:parameters.action has to cope with that (e.g. by putting up a
" browse dialog).
" a:parameters.overrideCompleteFunction
" If not empty, will be used as the :command -complete=customlist,...
" completion function name. This hook can be used to manipulate the
" completion list. This overriding completion function probably will
" still invoke the generated custom completion function, which is
" therefore returned from this setup function.
" a:parameters.completeFunctionHook
" Funcref that gets a List of all (still unshortened) filespecs
" generated by the default completion and can filter or augment it.
" a:parameters.FilenameProcessingFunction
" If not empty, will be passed the completed (or default) filespec and
" potential fileOptionsAndCommands, and expects a similar List of
" [filespec, fileOptionsAndCommands] in return. (Or an empty List,
" which will abort the command.)
" a:parameters.FilespecProcessingFunction
" If not empty, will be passed the (not escaped) dirspec, the
" completed (or default) filespec, and the potential
" fileOptionsAndCommands, and expects a similar List of [dirspec,
" filespec, fileOptionsAndCommands] in return. (Or an empty List,
" which will abort the command.)
"
"* RETURN VALUES:
" Name of the generated custom completion function.
"*******************************************************************************
let l:commandAttributes = get(a:parameters, 'commandAttributes', '')
let l:Action = get(a:parameters, 'action', ((exists(':Drop') == 2) ? 'Drop' : 'drop'))
let l:PostAction = get(a:parameters, 'postAction', '')
let l:Browsefilter = get(a:parameters, 'browsefilter', '')
let l:Wildignore = get(a:parameters, 'wildignore', 0)
let l:isNoDirspec = empty(a:dirspecs)
let l:isIncludeSubdirs = get(a:parameters, 'isIncludeSubdirs', l:isNoDirspec)
let l:isAllowOtherDirs = get(a:parameters, 'isAllowOtherDirs', l:isNoDirspec)
let l:DefaultFilename = get(a:parameters, 'defaultFilename', '')
let l:FilenameProcessingFunction = get(a:parameters, 'FilenameProcessingFunction', '')
let l:FilespecProcessingFunction = get(a:parameters, 'FilespecProcessingFunction', '')
let s:count += 1
let l:generatedCompleteFunctionName = 'IngoLibrary_CmdCompleteDirForAction' . s:count
let l:completeFunctionName = get(a:parameters, 'overrideCompleteFunction', l:generatedCompleteFunctionName)
let l:CompleteFunctionHook = get(a:parameters, 'completeFunctionHook', '')
let l:completeStrategy = (type(l:Action) == type('') && l:Action ==# 'chdir' ? 's:CompleteDirectories' : 's:CompleteFiles')
execute
\ printf("function! %s(ArgLead, CmdLine, CursorPos)\n", l:generatedCompleteFunctionName) .
\ "try\n" .
\ " call ingo#plugin#cmdcomplete#dirforaction#SetCompletionContext(strpart(a:CmdLine, 0, a:CursorPos))\n" .
\ printf(" return %s(0, %s, %s, %s, %d, %d, %s, a:ArgLead)\n",
\ l:completeStrategy,
\ string(a:dirspecs), string(l:Browsefilter), string(l:Wildignore), l:isIncludeSubdirs, l:isAllowOtherDirs, string(l:CompleteFunctionHook)
\ ) .
\ "finally\n" .
\ " unlet! g:IngoLibrary_CmdCompleteDirForAction_Context\n" .
\ "endtry\n" .
\ 'endfunction'
execute printf('command! -bar -nargs=%s -complete=customlist,%s %s %s if ! <SID>Command(<bang>0, <count>, ingo#compat#command#Mods(''<mods>''), %s, %s, %d, %s, %s, %s, %s, <q-args>) | echoerr ingo#err#Get() | endif',
\ (has_key(a:parameters, 'defaultFilename') ? '?' : '1'),
\ l:completeFunctionName,
\ l:commandAttributes,
\ a:command,
\ (l:commandAttributes =~# '-range=-1' && l:Action =~# '^<line[12]>,\@!' ?
\ '(<count> == -1 ? <line1> : <line2>) . ' . string(l:Action[7:]) :
\ string(l:Action)
\ ),
\ string(l:PostAction),
\ l:isAllowOtherDirs,
\ string(l:DefaultFilename),
\ string(l:FilenameProcessingFunction),
\ string(l:FilespecProcessingFunction),
\ string(a:dirspecs),
\)
return l:generatedCompleteFunctionName
endfunction
let &cpo = s:save_cpo
unlet s:save_cpo
" vim: set ts=8 sts=4 sw=4 noexpandtab ff=unix fdm=syntax :