2

I've found myself in a bit of an interesting situation.

I'm writing a plugin in which I need to do a tiny bit of line parsing.

Given a file:

æøå

It's easy to verify that col('.') returns 1, 3, and 5 respectively. This is fine, I can work with this. Other unicode characters (or I guess anything expanding outside ascii) returns similar results: each character has an expanded length, col('.') is relative to the total string length.

In normal vimscript, with this specific use case in mind, I could:

fun! pndtest#Invoke()
    let line = getline('.')
    echoerr line[col('.') - 1]
endfun

(note: the autoload prefix is an arbitrary one I picked because I had a testbench ready. They have no practical meaning and are largely random)

call pndtest#Invoke() on one of the unicode characters, and get trash out because it's only half the character in this case. That's actually fine - in fact, this approach is what I used in vim9 and failed to use (this is my actual implementation by slightly butchered for the sake of an MCVE):

var position: number = col('.') - 1
var line = getline('.')
var i = position
var output = ""
while i >= 0
    var character = line[i]
    # in reality, there's other parsing here, but this is a simplification
    output = character .. output
    i -= len(character) # This did nothing, by the way. :')
endwhile

At the end of which, output should contain all the characters, unicode and otherwise, prior to the current position in the line.

This works fine in Vimscript, but not in vim9. Here's the exact same case as earlier:

vim9script

def pnd2test#Invoke()
    var line = getline('.')
    echoerr line[col('.') - 1]
enddef

Given the same file as presented at the start of this post, it doesn't echoerr trash - it echoerrs the actual unicode character.

While this is great because it means Vim is starting to do some unicode handling, this means col() is essentially useless because the strings are suddenly not indexable in the same way as they used to be. It works fine on the first column, but getline('.')[1] in the previously listed file is ø, not trash triggered by a partial character. You might see where this is going.

This means that col('.') would have to return 2 on ø for the code to work, not 3. The å would have to be 3, not 5. Unfortunately for me, this stacks. The more unicode characters in the line, the more the error propagates, and the worse the offset is, rendering my approach essentially useless.

And again, this is purely a vim9 thing - vimscript itself treats the characters appropriately relative to col('.'), presenting different but arguably more managable unicode-related problems (they can largely be solved by subtracting or adding the length of the individual character)

Just for the sake of visualizing the problem: (note: the spaces used in the vimscript example represent the second byte of the character, NOT literal spaces)

VimScript:
String index: 01234
file        : æ ø å
Col index   : 1 3 5  # This is fine: subtract 1, the data is still continuous between the indices

Vim9:
String index: 012 
File        : æøå
Col index   : 135    # And this is the problem: complete mismatch with the string indices

Of course, while writing this question, I had a sudden realization: what about match()? Again assuming the file first in the question and:

vim9script

def pnd2test#Invoke()
    echoerr match(getline('.'), 'å')
enddef

the result is 4. This means match() also results in legacy indices. Here's an expanded example:

File:

æøå this is a unicode line

Code:

vim9script

def pnd2test#Invoke()
    echoerr getline('.')[match(getline('.'), 'å')]
enddef

Returns t, not å. Just for the record, this isn't a problem caused by getline(). As observed earlier, getline('.')[2] does give the right results. This also results in t instead of å:

def pnd2test#Invoke()
    var x: string = 'æøå t'
    echoerr x[match(x, 'å')]
enddef

This in turn kinda expands the problem: several of the vimscript functions return indices that are incompatible with vimscript strings. This of course is my question: when there are several functions returning incompatible indices, how do I in vim9 do any operations on strings containing unicode when using these types of functions?

3
  • Is charcol('.') what you're looking for? It works similarly to col('.') but returns the index of the Unicode character, not the byte position.
    – filbranden
    Apr 16 at 3:46
  • 1
    In part, but not entirely. Fortunately, the docs include byteidx() and charidx() (that I of course managed to miss when reading col() docs), which should be enough to let be convert between systems and access strings properly. charcol should fix the col issue, and byteidx and charidx should be able to work around compatibility with other functions.
    – Zoe
    Apr 16 at 8:36
  • 1
    Feel free to post a self answer with what solved the issue in full for you! Thanks!
    – filbranden
    Apr 16 at 11:47
1

filbranden nudged me in the right direction in a comment.

charcol('.') works around pure access based on the current cursor position, meaning line[charcol('.') - 1] returns the correct character for the current column, at least for two-byte characters. I'm still not entirely sure how far the unicode strings in vim9 go, so it might be that it'll fail to retrieve characters consisting of more bytes. Not sure how charcol() works with those, but for my purpose, it's good enough.

That alone doesn't help with match() or other functions, though. However, the nudge in the comment also helped me find two other functions that are extremely useful: charidx() and byteidx(). match() itself still takes the start position in bytes and not characters, which means any limits relative to the cursor position should still use col().

byteidx() is not as useful for cursor-related access (we already have col(), so taking the output from charcol() and using it for byteidx() is just col() with extra steps), but it can be used to convert a char position into a byte position. This is extremely useful if there's a char index that's calculated in some other way. Again, this is largely useful for the start parameter of match(), or similar arguments for other functions that take a position in bytes rather than in characters, and you're sitting there with a character position.

With charidx() though, we can convert the output of match() into a character position that can be used to re-index a string. Taking the example getline('.')[match(getline('.'), 'å')] from the question:

getline('.')[charidx(getline('.'), match(getline('.'), 'å'))]

(Of course, I don't recommend three calls to getline() when one is enough, but for the sake of testing and demonstration, this works fine)

Assuming usage on the file æøå t, the code returns å instead of t, in spite of match() returning the wrong position.

Just for the sake of including an example of it, assuming you have a character position, this is how it's converted back to a byte position (test file: æøåæøå)

vim9script

def pndtest#Invoke()
    echoerr charidx(getline('.'),
        match(getline('.'), 'å', byteidx(getline('.'), charcol('.')))
    )
enddef

Of course, byteidx(getline('.'), charcol('.')) is completely redundant (because it's col('.') with extra steps), but it demonstrates how positions can be converted from chars, into bytes, and finally back into chars for reuse in a string. The number returned by charidx could here be used to index a string, regardless of whether that string is the result of getline('.') or not.

This isn't elegant though, but it does the trick at converting between the two index types. In an optimal world, we wouldn't need to convert between the two for string compatibility, but here we are.

TL;DR: use charidx() to convert to the format used by vim9 strings, and byteidx() to convert from the format used by vim9 strings. charcol() can be used to get the current cursor position in a way that's directly usable by strings without conversion first.


And finally, a tiny list of alternatives to broken functions to avoid charidx() and byteidx() where conversion isn't needed, such as for accessing strings directly in vim9:

  • col() -> charcol()
  • len(str) -> len(split(str, '\zs')) (split strings by characters, return the length of the split (meaning the character count) instead of the byte count - not performance tested)

It's also worth noting that a few of the functions here have applications in regular vimscript as well, especially if you need to operate on characters instead of bytes. One notable difference is that there's different conversions and systems for accessing strings by characters in regular vimscript compared to vim9. However, that is outside the scope of this question.

1
  • 1
    Thanks for posting such a detailed answer!!!
    – filbranden
    Apr 21 at 5:51

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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