7

Context

I’m working on an 'omnifunc' function for Markdown editing. It’s supposed to help me link to other Markdown files while I’m typing a link – based on file content though, not file name.

Suppose I’m typing a link like [favorite editor](Vim| (where | marks my cursor). Once I hit ^X^O, my omnifunc grabs everything after the opening bracket (i.e. Vim) and basically runs :lvimgrep /\VVim/j **/*.md in order to find all Markdown files below the current directory (e.g. the project root) that contain the string Vim.

Then, I’m iterating over getloclist('.') to get the names of all matching files. The location list doesn’t contain file names though, it contains buffer numbers. However, they can easily be resolved to file names using getbufinfo().

This is how it looks like at the moment:

exec 'lvimgrep /\V' . escape(a:base, '/\') . '/j **/*.md'
let l:results = []
for l:match in getloclist('.')
    let l:fpath = getbufinfo(l:match['bufnr'])[0]['name']

Problem

l:fpath is an absolute path though. However, the link obviously should be relative to the file that contains it, which might very well be in a subdirectory of the current working directory (CWD). (It could also be outside of it, which is another can of worms entirely.)

I know that I can use fnamemodify(l:fpath, ':.') to convert the path to one relative to the CWD (if the path is inside of the CWD), but I need a path relative to the file I’m editing.

Rejected approaches and ideas

:cd to the file’s directory

I could try :lcd'ing to the file I’m editing while my omnifunc is running, but the warnings at :h current-directory makes me concerned about whether that’s a smart approach:

Changing directory fails when the current buffer is modified, the '.' flag is present in 'cpoptions' and "!" is not used in the command.

[…]

After using :cd the full path name will be used for reading and writing files. On some networked file systems this may cause problems.

It simply smells like there could be unexpected side effects of changing the CWD. Also, even if there weren’t, this still will not result in relative links going upwards, i.e. something like ../../editors.md.

Also, I know about 'autochdir'. It has all of the disadvantages mentioned in the previous paragraphs and thus doesn’t help either.

Using :grep instead

I’ve attempted to use :lgrep instead of :lvimgrep. In addition to being less cross-platform compatible, it also doesn’t improve the output format: It still contains buffer IDs, which still resolve to absolute paths.

Questions

  • Is there a builtin function that will help me convert the path to a relative one, including ../ components, and I’ve simply missed it?
  • Is there an external tool that could do the conversion, and that can be expected to be preinstalled on modern Unixes (possibly even POSIX)?
  • Do you know of any other Vim scripts that either
    • do what I’m trying to do (auto-complete a Markdown link based on a string to search for)
    • or at least contain a path conversion function like the one I’m afraid I have to write myself?
  • Would fzf be able to help me here? I’ve seen it being mentioned all over the place, but have never used it.
  • Any other helpful remarks?

Thanks in advance for your time.

2
  • 1
    The widely available GNU coreutils has a tool realpath which is used to resolve paths into fully realized absolute paths. But it has a "relative to" flag so you can do realpath --relative-to /some/path/to/foo /some/path/to/foo/bar/baz.txt and it will return bar/baz.txt (after fully realizing the absolute). It also has --relative-base=FILE that will print relative paths for paths below FILE.
    – B Layer
    Jan 21, 2021 at 19:48
  • Thanks! Using realpath might be compatible enough for my purposes. Let’s see whether someone comes up with an even better suggestion.
    – scy
    Jan 21, 2021 at 19:56

2 Answers 2

2

Nope, not a Vim answer. AFAIK, though it's the best solution available.

absolute->relative is needed a lot less frequently then relative->absolute so I expect your choices will be pretty limited. That being said, there's an option that mostly meets your requirements with the grey area being whether it's distributed widely enough for you.

I'm talking about the realpath command that's part of GNU's coreutils package.

Certainly that's a very widely used set of tools and available on many, many *nix systems and where it's not it's (usually) easy to install. Your call. I'll just give some details on how it's used.

The primary purpose of realpath is actually to go the other direction, i.e. it produces canonical absolute paths. These paths are realized by expanding all symbolic links and resolving references to /./, /../ and extra / characters. But there are two flags that will cause it to return relative paths after it's produced the canoncial representations: --relative-to and --relative-base.

Some documentation:

‘--relative-to=dir’

    Print the resolved file names relative to the specified directory. 

‘--relative-base=dir’

    Print the resolved file names as relative if the files are descendants 
    of dir. Otherwise, print the resolved file names as absolute.

Here's an example that works for both flags:

$ realpath --relative-to /some/path/to/foo /some/path/to/foo/bar/baz.txt
bar/baz.txt

And one that returns a relative path only for --relative-to:

$ realpath --relative-to=/some/path/foo/quux /some/path/bar/baz.txt
../../bar/baz.txt 

Additional examples from the docs

2
3

I'm not entirely sure this does what you want, because you didn't provide tests in the form of expected inputs/outputs, but in case you're interested in a Vim script solution, here's a starting point:

vim9script

def PathRelativeToCurfile(path: string, curfile: string): string
    var head_of_curfile: string = fnamemodify(curfile, ':h') .. '/'
    # the path leads to a file *inside* a subdirectory of the directory of the current file; we're done
    if path->stridx(head_of_curfile) == 0
        return path->substitute(head_of_curfile, '../', '')
    endif
    # the path leads to a file *outside*; let's move up in the hierarchy to find it
    return '../' .. PathRelativeToCurfile(path, curfile->fnamemodify(':h'))
enddef

echom PathRelativeToCurfile(path, curfile)

Re-written in legacy Vim script:

fu s:PathRelativeToCurfile(path, curfile)
    let head_of_curfile = fnamemodify(a:curfile, ':h') .. '/'
    " the path leads to a file *inside* a subdirectory of the directory of the current file; we're done
    if stridx(a:path, head_of_curfile) == 0
        return substitute(a:path, head_of_curfile, '../', '')
    endif
    " the path leads to a file *outside*; let's move up in the hierarchy to find it
    return '../' .. s:PathRelativeToCurfile(a:path, fnamemodify(a:curfile, ':h'))
enddef

echom PathRelativeToCurfile(path, curfile)

Any other helpful remarks?

In your code, you don't need the l: prefix, it's only necessary if you use one of these variable names:

  • count
  • errmsg
  • shell_error
  • this_session
  • version

One way to find this list is to look for backwards\_s\+compatibility at :h eval.

It's also recommended to use l: if you save a funcref inside a function-local variable; but that's not what you seem to do here.

If you're really concerned by a conflict with a v: variable, write this directive before your code:

:scriptversion 3
               ^
               or any number bigger than 3

With this, there is no ambiguity anymore. count is always equivalent to l:count; not v:count.


let l:fpath = getbufinfo(l:match['bufnr'])[0]['name']

FWIW, I would rewrite it like this:

let fpath = match.bufnr->bufname()->fnamemodify(':p')

for l:match in getloclist('.')
                          ^^^
                           ✘

getloclist() expects a number not a string:

for match in getloclist(0)
                        ^
                        ✔

Your code does not raise any error, because in Vim script legacy, a string is automatically coerced into a number. It might not hurt here, but this kind of mistake can lead to subtle bugs which are hard to fix. In Vim9 script, you would get an error at runtime:

vim9script
lh grail
def Func()
    for match in getloclist('.')
    endfor
enddef
Func()

# E1030: Using a String as a Number: "."
# E1030: Using a String as a Number: "."

You can use '.' as an alias for the current line, in an argument passed to a function; on the condition that the latter works only on the current buffer (not in arbitrary buffers). You cannot use '.' as an alias for the current "anything else" (e.g. window, tabpage, buffer, ...).

1
  • Thanks! I didn’t know about Vim9 script until now :) Unfortunately, it’s a bit too bleeding edge for me (I have some systems that still run Vim 8.0). Also, I’ve already created a small plugin based on the realpath solution by B Layer. However, I’ve added an issue to that project that points to your solution, so that once I find the time, I can get rid of the realpath dependency. Also, thanks a lot for basically linting my Vimscript code, I’m a bit rusty :)
    – scy
    Jan 28, 2021 at 10:59

Your Answer

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

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