Perl-Based Neovim Plugins, part 2: from File Path to Package Name - Hacking Thy Fearful Symmetry

Hacking Thy Fearful Symmetry

Hacker, hacker coding bright

Perl-Based Neovim Plugins, part 2: from File Path to Package Name

created: April 11, 2017

Welcome back! Quick recap: we are in the midst of an epic blog series about Neovim::RPC and how it can be used to write neovim plugins. Last time we covered how to install the beast, and this time around we'll implement a first plugin.

The endgame

For this first plugin we'll go with some very, very simple stuff: it will drive a macro generating a module's package name based on its file path. Basically, it'll figure out that the module at /home/yanick/work/Neovim-RPC/lib/Neovim/RPC/Plugin/FileToPackageName.pm should be named Neovim::RPC::Plugin::FileToPackageName. Nothing fancy, in fact could probably be done via vim-powered regular expressions, but that simplicity will give us a great starting point.

Hell is interoperability

The nice thing about blog entries is how the author usually filters out the many liters of "why aren't you working the way I want you to work, you sanity-eroding piece of blasted malappropriation?", and distill it into a few precious droplets of "isn't this easy?".

Well, this isn't your usual author. I'll get to the "isn't this easy?" part in the next section. But I want to share out a few gnashing of teeth first. For education purposes.

I use UltiSnips for my code snippets, and I love it dearly. It has a way to call vim code directly from a snippet, so I thought that hooking the plugin call into a snippet would be child's play. Something like:


snippet package "package definition" b
package `!v FileToPackage()`
endsnippet

AH! I can such a fool.

As it turns out, although a msgpack-rpc response can carry a payload, neovim doesn't seem to pass it back in any way (and, please, if I overlooked something and you know better, let me know in the comments). Well, fine, I'll have the snippet put in a __PACKAGE__ placeholder and have the plugin alter it behind the scene. But then that !v snippet inserts its output, which is always a 0, into the mix. One messy way to get around all that stuff is to do:


" in init.vim

function FileToPackage()
    call Nvimx_notify( 'load_plugin', 'FileToPackageName' )
    call Nvimx_request('file_to_package_name')
endfunction

command FileToPackage :call FileToPackage()

" ... and then in the ultisnips snippet definitions

snippet package "package definition" b
package __PACKAGE__; `!v FileToPackage()?"":""`
endsnippet

I could also just have done something like


imap @@package package __PACKAGE__;:call FileToPackage()o

but I was kinda set on showing UltiSnips who's boss. For the moment, it'll have to do, but there is definitively room for improvement.

Plugin on the vim side

In the last section, we already saw the vim-side function that will interact with the plugin.

We use Nvimx_notify and Nvimx_request, two helper functions we saw in the previous blog entry. They are just thin wrappers around rpcnotify and rpcrequest using the channel on which neovimx is listening.

function FileToPackage()
    call Nvimx_notify( 'load_plugin', 'FileToPackageName' )
    call Nvimx_request('file_to_package_name')
endfunction

On-demand plugin loading! Since it's in the function, we only load the plugin when it's first used (subsequent calls will amount to be no-ops, so it's fine)

function FileToPackage()
    call Nvimx_notify( 'load_plugin', 'FileToPackageName' )
    call Nvimx_request('file_to_package_name')
endfunction

We use a rpcrequest, as opposed to a rpcnotify to pause the ui while we replace that line.

function FileToPackage()
    call Nvimx_notify( 'load_plugin', 'FileToPackageName' )
    call Nvimx_request('file_to_package_name')
endfunction

Plugin on the Perl side

Here comes the plat de résistance. First, let's implement it in a straight-forward, naive way.

We start with the basic declaration. use Neovim::RPC::Plugin automatically sets the current class to extends Neovim::RPC::Plugin.

package Neovim::RPC::Plugin::FileToPackageName;

use 5.20.0;
use warnings;

use Neovim::RPC::Plugin;

use experimental 'signatures';

Then we implement the function that actually do the work of converting the path to a package name. Woohoo, regex fun!

sub file_to_package_name($path) {
  $path
    =~ s#^(.*/)?lib/##r
    =~ s#^/##r
    =~ s#/#::#rg
    =~ s#\.p[ml]$##r;
}

And then we hook up the plugin to listen to file_to_package_name requests coming from neovim.

sub BUILD($self,@) {
  $self->rpc->subscribe( file_to_package_name => sub($event) {
    my $filename;

    $self->shall_get_filename
         ->then(sub($f){ $filename = $f })
         ->then(sub{ $self->api->vim_get_current_line })
         ->then(sub($line){ 
            $line =~ s/__PACKAGE__/
                file_to_package_name($filename)
            /e;
            $self->api->vim_set_current_line($line);
         })
         ->finally(sub{ $event->resp('ok') });
    }
}

When we do get a request, we query back neovim to know which file path is associated with the current buffer...

sub BUILD($self,@) {
  $self->rpc->subscribe( file_to_package_name => sub($event) {
    my $filename;

    $self->shall_get_filename
         ->then(sub($f){ $filename = $f })
         ->then(sub{ $self->api->vim_get_current_line })
         ->then(sub($line){ 
            $line =~ s/__PACKAGE__/
                file_to_package_name($filename)
            /e;
            $self->api->vim_set_current_line($line);
         })
         ->finally(sub{ $event->resp('ok') });
    }
}

sub shall_get_filename ($self) {
  $self->api->vim_call_function( 
    fname => 'expand',
    args  => [ '%:p' ] 
  );
}

...then we get the current line, fill in the __PACKAGE__ placeholder and send it back...

sub BUILD($self,@) {
  $self->rpc->subscribe( file_to_package_name => sub($event) {
    my $filename;

    $self->shall_get_filename
         ->then(sub($f){ $filename = $f })
         ->then(sub{ $self->api->vim_get_current_line })
         ->then(sub($line){ 
            $line =~ s/__PACKAGE__/
                file_to_package_name($filename)
            /e;
            $self->api->vim_set_current_line($line);
         })
         ->finally(sub{ $event->resp('ok') });
    }
}

...and finally reply to the original request, so that the UI gets unblocked.

(we use finally instead of then so that it'll get unblocked, even if something throws an exception in the previous promises)

sub BUILD($self,@) {
  $self->rpc->subscribe( file_to_package_name => sub($event) {
    my $filename;

    $self->shall_get_filename
         ->then(sub($f){ $filename = $f })
         ->then(sub{ $self->api->vim_get_current_line })
         ->then(sub($line){ 
            $line =~ s/__PACKAGE__/
                file_to_package_name($filename)
            /e;
            $self->api->vim_set_current_line($line);
         })
         ->finally(sub{ $event->resp('ok') });
    }
}

Doesn't look too bad, right? But wait! There is a few optimizations I've slipped in Neovim::RPC::Plugin to make it even easier to work with.

Let's begin where we left off.

sub BUILD($self,@) {
  $self->rpc->subscribe( file_to_package_name => sub($event) {
    my $filename;

    $self->shall_get_filename
         ->then(sub($f){ $filename = $f })
         ->then(sub{ $self->api->vim_get_current_line })
         ->then(sub($line){ 
            $line =~ s/__PACKAGE__/
                file_to_package_name($filename)
            /e;
            $self->api->vim_set_current_line($line);
         })
         ->finally(sub{ $event->resp('ok') });
    }
}

Instead of the awkward BUILD construct, there is a DSL keyword, subscribe, that registers the subscription against the class.

subscribe file_to_package_name => sub($self, $event) {
   my $filename;

   $self->shall_get_filename
        ->then(sub($f){ $filename = $f })
        ->then(sub{ $self->api->vim_get_current_line })
        ->then(sub($line){ 
           $line =~ s/__PACKAGE__/
               file_to_package_name($filename)
           /e;
           $self->api->vim_set_current_line($line);
        })
        ->finally(sub{ $event->resp('ok') });
};

Since an rpcrequest always requires a response, there is another DSL keyword, rpcrequest(), that wraps subsequent coderefs so that the request's response is automatically sent when they are all done.

subscribe file_to_package_name => rpcrequest sub($self, $event) {
   my $filename;

   $self->shall_get_filename
        ->then(sub($f){ $filename = $f })
        ->then(sub{ $self->api->vim_get_current_line })
        ->then(sub($line){ 
           $line =~ s/__PACKAGE__/
               file_to_package_name($filename)
           /e;
           $self->api->vim_set_current_line($line);
        })
};

Writing those chains of then is not exactly a hardship, but it's a little noisy. subscribe knows to turn a list of coderefs into such a promise chain, so we can unclutter the code.

Not that it improves much on things here, mind you.

subscribe file_to_package_name => rpcrequest
    sub($self,@) {
        $self->shall_get_filename
    },
    sub($self, $filename) {
        $self->api->vim_get_current_line
            ->then(sub($line) { ( $filename, $line ) })
    },
    sub($self,$filename,$line){ 
        $line =~ s/__PACKAGE__/
            file_to_package_name($filename)
        /e;
        $self->api->vim_set_current_line($line);
    });

By the by, notice how fetching the file path and current line aren't related? We can break the linear chain and use collect_props.

Now, that looks better.

subscribe file_to_package_name => rpcrequest
    sub($self,@) {
        collect_props(
          filename => $self->shall_get_filename,
          line     => $self->api->vim_get_current_line,
        );
    },
    sub($self, $props) {
        $self->api->vim_set_current_line(
            $props->{line} =~ s/__PACKAGE__/
                file_to_package_name($props->{filename})
            /er
        );
    });

And this is it. In its final form, the plugin looks like


package Neovim::RPC::Plugin::FileToPackageName;

use 5.20.0;
use warnings;

use Neovim::RPC::Plugin;

use experimental 'signatures';

sub file_to_package_name {
    shift
    =~ s#^(.*/)?lib/##r
    =~ s#^/##r
    =~ s#/#::#rg
    =~ s#\.p[ml]$##r;
}

sub shall_get_filename ($self) {
    $self->api->vim_call_function(
        fname => 'expand', args => [ '%:p' ] 
    );
}

subscribe file_to_package_name => rpcrequest 
    sub($self,@) {
        collect_props(
            filename => $self->shall_get_filename,
            line     => $self->api->vim_get_current_line,
        )
    },
    sub ($self,$props) {
        $self->api->vim_set_current_line(
            $props->{line} =~ s/__PACKAGE__/
                file_to_package_name($props->{filename})
            /er
        )
    };

1;

And that's it...

Well...

...almost. That collect_props function? It's not currently part of the Promises module, but there is a pull request for it. In the meantime, this is what it looks like:


sub collect_props {
    use Promises qw/ deferred /;
    my %promises = @_;

    my $all_done  = deferred();

    my $results   = {};
    my $remaining = scalar keys %promises;

    my $are_we_there_yet = sub {
        return if --$remaining;

        return if $all_done->is_rejected;

        $all_done->resolve($results);
    };

    while( my( $key, $promise ) = each %promises ) {
        unless( ref $promise eq 'Promises::Promise' 
             or ref $promise eq 'Promises::Deferred' ) {
            my $p = deferred();
            $p->resolve($promise);
            $promise = $p;
        }

        $promise->then(sub{ $results->{$key} = shift })
            ->then( $are_we_there_yet, sub { $all_done->reject(@_) } );
    }

    return $all_done->promise;
}

To be continued

Next time, we'll ratchet things up and attack slightly more meaty plugins. ...in fact, I'm beginning to think that this advertised trilogy might grow a few more installments...

comments powered by Disqus

About the author

Yanick Champoux
Perl necrohacker , ACP writer, orchid lover. Slightly bonker all around.