给 vim (neovim) 配一个 raku (perl6) language server

背景

我是属于比较粗心的那种人, 在 vim (neovim) 中编写 raku (perl6), 没有语法检查, 往往需要等运行时出错后看到错误信息再回去改动. atom 和 vscode 在中有可以检查raku 语法错误的插件. 上面两个文本编辑器使用 language server 来检查语法错误, 所以只需要将它们的 language server 用在 vim 中, 就可以实现语法检查了.

vscode 插件中的 language server 使用 javascript, 不是很熟悉如何将其在 neovim 中运行起来. 而 atom 中的 perl6 language server 是用 Raku 写的 language server. 那么就试着将这个 Raku language server 配置到 vim 下看看. vim 中有好几款支持 language server 的插件并且 neovim 最近也开始支持 language server了. 因为目前我在用 Coc, 所以最后放了在 Coc 中如何配置使用 Raku language server 的内容.

app-perl6languageserver

关于 Lanugage server

在 vscode 的文档中有关于 language server 的说明. 具体的内容在 protocol 文档中写的很详细.

language server 接收文本编辑器传送过来的 json 信息, 然后进行处理, 返回 json 格式的诊断信息. 下面的图中显示了 language server 的功能.
给 vim (neovim) 配一个 raku (perl6) language server_第1张图片

测试 language server

下载运行

从 GitHub 上 clone 了 app-perl6languageserver.
git clone https://github.com/azawawi/app-perl6langserver.git

给 vim (neovim) 配一个 raku (perl6) language server_第2张图片

文件中有一个 bin 文件夹, 打开看, 其中有一个名为 perl6-languageserver 的文件. 文件的内容是:

#!/usr/bin/env perl6

use v6;

use lib "{$*PROGRAM.parent.parent}/lib";
use App::Perl6LangServer;

my $app = App::Perl6LangServer.new;
$app.run;

这个应该是 language server 的执行文件. 其中调用了 lib 文件夹下的 App/Perl6LangServer.
可惜这个 library 中测试文件夹 t 下的 tests 不是很齐全, 只包括了包载入的测试, 不能用来做功能测试. 接下来自己弄一个例子测试一下这个 language server 的功能.

首先进入 bin 文件夹, 尝试运行 perl6-languageserver, 提示需要安装 File::Temp. 于是使用 zef 安装上. 再试, 发现还缺 JSON::Tiny. 装上再试, 接着就运行成功了. 提示信息表明这个 language server 在等待文本编辑器传输过来的代码信息了.

给 vim (neovim) 配一个 raku (perl6) language server_第3张图片

测试

我之前对 language server 的具体如何工作还不是特别了解, 测试一下刚刚启动的 language server, 熟悉一下它的输入输出.

我准备了输入:

Content-Length: 282

{ "jsonrpc": "2.0", "id" : 1, "method": "textDocument/didChange", "params": { "textDocument": { "uri": "error.raku" }, "contentChanges": [{ "text": "$a" }] } }

这里的 Raku 语法错误是使用了未定义的变量 $a.

在这个 language server 中, 文本编辑器中发生了内容改变的事件时, 文本编辑器将代码信息通过 json 传输给 language server. 然后 language server 将编辑器中的文本存在临时文件中. 接着使用 raku -c 命令去检查临时文件中代码语法正确性.

修改 language server 修改代码, 用 debug-log 函数打印出 raku -c 中输出的信息, 然后与 language server 最后输出的 error 信息做对比.

  # Invoke perl -c temp-filder
  #TODO handle windows platform
  my Str $output = qqx{$*EXECUTABLE -c $file-name 2>&1};
  
  ## 打印运行 raku -c 获得的错误信息
  debug-log($output);
  my @problems;
  if $output !~~ /^'Syntax OK'/ &&
    $output   ~~ m/\n(.+?)at\s.+?\:(\d+)/ {

    # A syntax error occurred
    my $message     = ~$/[0];
    my $line-number = +$/[1];
    @problems.push: {
      range => {
        start => {
          line      => $line-number,
          character => 0
        },
        end => {
          line      => $line-number,
          character => 0
        },
      },
      severity => 1,
      source   => 'perl6 -c',
      message  => $message
    }
  }

结果中, 错误信息正确被解析了.

给 vim (neovim) 配一个 raku (perl6) language server_第4张图片

把输出结果 format 一下

{
  "method": "textDocument/publishDiagnostics",
  "params": {
    "uri": "error.raku",
    "diagnostics": [
      {
        "source": "perl6 -c",
        "severity": 1,
        "message": "Variable '$a' is not declared\n",
        "range": {
          "end": {
            "character": 0,
            "line": 1
          },
          "start": {
            "line": 1,
            "character": 0
          }
        }
      }
    ]
  },
  "jsonrpc": "2.0"
}

准备了另外一个输入. 这时的错误是将for错拼写成foor但是程序却没有给出错误信息.

Content-Length: 282

{ "jsonrpc": "2.0", "id" : 1, "method": "textDocument/didChange", "params": { "textDocument": { "uri": "error.raku" }, "contentChanges": [{ "text": "foor 1..10 " }] } }

可能是由于这个插件年久失修, 已经不能正确解析错误信息了.
给 vim (neovim) 配一个 raku (perl6) language server_第5张图片

重构 language server

原本的 language server 的功能不止是语法检查, 还有文档的获取. 目前文档获取的功能没有完成, 这个项目也好久没有维护了. 我目前只想使用代码语法检查, 这个部分存在一些问题还需要调试. 为了方便调试, 将相关的代码提取出来重新拼成一个简单的脚本, 以后有空再看看文档查询功能.

给 vim (neovim) 配一个 raku (perl6) language server_第6张图片

重构了之后的脚本如下. 重构后的 language server 不再生成临时文件, 再进行 raku -c 代码检查. 而是只在编辑器打开文件, 或者保存文件后对代码文件进行检查. 所以错误信息在保存后才会显示. 我使用了 grammar 去解析 raku -c 输出的错误信息.

#!/usr/bin/env raku

use JSON::Tiny;

# No standard input/output buffering to prevent unwanted hangs/failures/waits
$*OUT.out-buffer = False;
$*ERR.out-buffer = False;

debug-log(": Starting raku-langserver... Reading/writing stdin/stdout.");

start-listen();


sub start-listen() {
  my %request;

  loop {
    my $content-length = get_content_length();

    if $content-length == 0 {
      next;
    }

    # debug-log("length is: " ~ $content-length);

    %request = read_request($content-length);

    unless %request {
      next;
    }

    # debug-log(%request);
    process_request(%request);

  }
}


sub get_content_length {

  my $content-length = 0;
  for $*IN.lines -> $line {

    # we're done here
    last if $line eq '';

    # Parse HTTP-style header
    my ($name, $value) = $line.split(': ');
    if $name eq 'Content-Length' {
      $content-length += $value;
    }
  }

  # If no Content-Length in the header
  return $content-length;
}

sub read_request($content-length) {
  my $json    = $*IN.read($content-length).decode;
  my %request = from-json($json);

  return %request;
}

sub process_request(%request) {
  given %request {
    when 'initialize' {
      my $result = initialize(%request);
      send-json-response(%request, $result);
    }
    when 'textDocument/didOpen' {
      check-syntax(%request);
    }
    when 'textDocument/didSave' {
      check-syntax(%request);
    }
    when 'shutdown' {
      # Client requested to shutdown...
      send-json-response(%request, Any);
    }
    when 'exit' {
      exit 0;
    }
  }
}

sub debug-log($text) is export {
  $*ERR.say($text);
}

sub send-json-response($id, $result) {
  my %response = %(
    jsonrpc => "2.0",
    id       => $id,
    result   => $result,
  );
  my $json-response = to-json(%response);
  my $content-length = $json-response.chars;
  my $response = "Content-Length: $content-length\r\n\r\n" ~ $json-response;
  print($response);
}


sub send-json-request($method, %params) {
  my %request = %(
    jsonrpc  => "2.0",
    'method' => $method,
    params   => %params,
  );
  my $json-request = to-json(%request);
  my $content-length = $json-request.chars;
  my $request = "Content-Length: $content-length\r\n\r\n" ~ $json-request;
  print($request);
}

sub initialize(%params) {
  %(
    capabilities => {
      # TextDocumentSyncKind.Full
      # Documents are synced by always sending the full content of the document.
      textDocumentSync => 2,

      # Provide outline view support (not)
      documentSymbolProvider => False,

      # Provide hover support (not)
      hoverProvider => False
    }
  )
}

sub check-syntax(%params) {

  my $uri = %params;

  # debug-log($uri);

  if $uri ~~ /file\:\/\/(.+)/ {
    $uri = $/[0].Str;
    # debug-log($uri);
  }

  my Str $output = qqx{$*EXECUTABLE -c $uri 2>&1};

  # debug-log($output);

  my @problems = parse-error($output);

  my %parameters = %(
    uri         => $uri,
    diagnostics => @problems
  );
  send-json-request('textDocument/publishDiagnostics', %parameters);

  return;
}

grammar ErrorMessage {
  token TOP { + }
  token Error {  ||  ||  ||  }

  rule Undeclared-name {  Undeclared s?\:\r?\n\s+ used at lines? \.?  .* }
  rule Missing-generics{   <-[\:]>+\: \s* "------>" ? }
  rule Missing-libs {  Could not find  in\:<-[\:]>+\: }
  token Warning { "Potential difficulties:" \n  <-[\:]>+\: \s* "------>" ? }

  token Error-type { \N* }
  token Undeclared-type { routine || name }
  token Name { <-[\'\s]>+ }
  token Linenum { \d+ }
  token Message { .* }
  token ErrorInit { '[31m===[0mSORRY![31m===[0m' \N+ }
}

class ErrorMessage-actions {
  method TOP ($/) {
    make $.map( -> $e {
      my $line-number;
      my $message;
      my $severity = 1;

      given $e {
        when $e {
          $line-number = $e.Int;
          $message = qq[Could not find Library $e];
        }
        when $e {
          $line-number = $e.Int;
          $message = qq[$e\n{$e.trim.subst(/\x1b\[\d+m/, '', :g)}];
        }
        when $e {
          $line-number = $e.Int;
          $message = qq[Undelcared $e $e. {$e.trim.subst(/\x1b\[\d+m/, '', :g)}];
        }
        when $e {
          $line-number = $e.Int;
          $message = qq[{$e.trim}\n{$e.trim.subst(/\x1b\[\d+m/, '', :g)}];
          $severity = 3;
        }
      }

      my Bool $vim = True;
      $line-number-- if $vim;

      ({
        range => {
          start => {
            line      => $line-number,
            character => 0
          },
          end => {
            line      => $line-number + 1,
            character => 0
          },
        },
        severity => $severity,
        source   => 'raku -c',
        message  => $message
      })
    })      
  }
}

sub parse-error($output) {

  my @problems;
  unless $output ~~ /^'Syntax OK'/ {
    @problems = ErrorMessage.parse($output, actions => ErrorMessage-actions).made 
  }
  return @problems;
}

将上面的代码保存在 raku-lsp.raku 中, 在终端中运行上面的脚本就开始监听错误输入了:

image.png

准备一个包含有错误的代码的文件 error.raku :

foo 1..10 {
    say $_;
}

对代码的错误进行检查, 获得的错误信息如下.

给 vim (neovim) 配一个 raku (perl6) language server_第7张图片

然后运行 language server. 输入 json 信息, 使用 language server 对上面的 error.raku 进行语法检查:

Content-Length: 190

{ "jsonrpc": "2.0", "id": 1, "method": "textDocument/didChange", "params": { "textDocument": { "uri": "error.raku" }, "contentChanges": [] } }

返回的结果正确地解析了错误.

给 vim (neovim) 配一个 raku (perl6) language server_第8张图片

配置 vim (neovim)

最后时在 vim 中使用上面调试好的 language server.

在 neovim 中安装 Coc 插件 https://github.com/neoclide/c...

将上述脚本保存在一个文件中 /foo/bar/raku-lsp.raku, 并且给文件添加执行权限 chmod +x /foo/bar/raku-lsp.raku.

然后在 vim 中配置 Coc 插件:

  1. 打开配置文件 :CocConfig
  2. 添加上面的 language server 脚本:

    {
        "languageserver" : {
            "raku": {
                "command": "/foo/bar/raku-lsp.raku",
                "args": ["--vim"],
                "filetypes": ["raku", "rakumod", "pl6", "p6", "pm6"]
            }
        }
    }

打开一个文件, 可以看到上面的 language server 就起作用了:

给 vim (neovim) 配一个 raku (perl6) language server_第9张图片

你可能感兴趣的:(perl6)