Vim 中用 language server 对 Raku (perl6) 脚本进行实时语法检查

之前整理了一个 Raku 脚本作为 vim/neovim 中 Raku 的 language server, 它能在文件保存后对 Raku 脚本进行语法检查.

因为之前的 language server 脚本中使用了 raku -c foobar.raku 语句对脚本进行语法检查. 上述语法检查需要文件数据, 所以必须将编辑器缓冲区的内容写入文件中, 然后才能进行语法检查. 基于上述方法, 如果需要实时进行语法检查的话, 可能的方式是建立一个临时文件, 在发生内容改变事件时缓冲区内容写入临时文件. 之后才能运行 raku -c temporary_file.raku 来对语法进行检查. 建立临时文件的方法也是目前 Atom raku 插件VS Code raku 插件所使用的策略.

在看 Raku 语法解析库 Raku-Parser 代码的时候, 发现原来 NQP 提供了解析 Raku 脚本并进行语法检查的功能. 通过NQP提供的功能, 就可以摆脱临时文件利用language server 对在 vim/neovim 中编辑的 Raku 脚本做实时的语法检查了. 在文章的最后, 我整理了一个新的脚本, 并且有相关的配置说明. 如果只想知道如何配置的话可以直接跳到最后.

NQP Raku 语法分析

NQP 中 Raku 语法分析的功能非常容易就可以在 Raku 脚本中被调用. 下面是一个例子:

use nqp;
my $*LINEPOSCACHE;

my $code = Q:to[_END_];
foor 1..10 {
}
_END_

my $compiler := nqp::getcomp('Raku') // nqp::getcomp('perl6');
my $g := nqp::findmethod(
  $compiler,'parsegrammar'
)($compiler);

my $a := nqp::findmethod(
  $compiler,'parseactions'
)($compiler);
  
try {
  $g.parse( $code, :p( 0 ), :actions( $a ) );
}

$!.say;

say "Hello I am still alive";

将上面的脚本保存, 运行脚本, 就会得到如下的错误信息:
Vim 中用 language server 对 Raku (perl6) 脚本进行实时语法检查_第1张图片

将上面脚本中的第22行改为 $!.perl.say;, 就可以看到错误信息的数据类型和内容结构, 发现原来这个错误信息里的信息可以直接拿来用而不用再做进一步文本解析. 所以只需要把错误对象中的信息提取出来就可以了.
Vim 中用 language server 对 Raku (perl6) 脚本进行实时语法检查_第2张图片

在测试了几轮不同的错误之后, 知道了不同类型的错误信息返回的数据结构略有差别. 使用下面的代码对不同错误信息进行解析, 获取错误信息发生的行、错误信息和错误的严重程度.

#!/usr/bin/env raku

use nqp;
my $*LINEPOSCACHE;
my $code = Q:to[_END_];
foor 1..10 {
}
_END_

my $compiler := nqp::getcomp('Raku') // nqp::getcomp('perl6');
my $g := nqp::findmethod(
  $compiler,'parsegrammar'
)($compiler);

my $a := nqp::findmethod(
  $compiler,'parseactions'
)($compiler);

try {
  $g.parse( $code, :p( 0 ), :actions( $a ));
}

if ($!) {
  my $line-number;
  my $severity = 1;
  my $message = "";
  # https://docs.raku.org/type-exception.html
  # https://github.com/rakudo/rakudo/blob/ca7bc91e71afe9373b57cd629215f843e8026df1/src/core.c/Exception.pm6
  given $!.WHO {
    when "X::Undeclared::Symbols" {
      if $!.unk_routines {
        $line-number = $!.unk_routines.values.min[0] - 1;
      } else {
        $line-number = $!.unk_types.values.min[0] - 1;
      }
      $message = $!.message;
    } 
    when "X::Comp::Group" {
      $line-number = $!.panic.unk_routines.values.min[0] - 1;
      $message = $!.message;
    } 
    when "X::AdHoc" {
      $line-number = 0;
      $message = $!.payload ~ "\n" ~ $!.backtrace.Str;
    }
    default {
      $line-number = $!.line - 1;
      $message = $!.message;
    }
  }
  say "line-number is:" ~ $line-number;
  say "message is :" ~ $message;
}

这样就能够获得结构化的错误信息了.
image.png

Language server

问题基本上已经解决了, 只是为了实现实时的语法检查, 还需要稍微修改之前的 language server 脚本.

编辑器返回缓冲区文本的完整内容

我们希望在 language server 可以不依赖临时文件对脚本进行语法检查. 那么编辑器给 language server 的返回信息中就需要包含脚本内容. 这个通过 language server 在初始化过程中传递给编辑器的返回值控制.

通过设定 textDocumentSync => 1 来让编辑器在文件变动后总是传回脚本全文.

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

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

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

处理文本内容改变事件

现在文本在每次变化之后, 编辑器就会将缓冲区中的文本内容传给 language server. 编辑器本文变化事件的类型信息会在编辑器传递给 language server 中被标注为 textDocument/didChange. 在 language server 中添加对应的条件执行语句块 (15 - 17 行), 调用 check-syntax 对语法进行检查.

sub process_request(%request) {
  # TODO throw an exception if a method is called before $initialized = True
  # debug-log(%request);
  given %request {
    when 'initialize' {
      my $result = initialize(%request);
      send-json-response(%request, $result);
    }
    when 'textDocument/didOpen' {
      check-syntax(%request, "open");
    }
    when 'textDocument/didSave' {
      check-syntax(%request, "save");
    }
    when 'textDocument/didChange' {
      check-syntax(%request, "change");
    }
    when 'shutdown' {
      # Client requested to shutdown...
      send-json-response(%request, Any);
    }
    when 'exit' {
      exit 0;
    }
  }
}

语法检查函数

修改语法检查函数, 在文本发生变化的时候获取编辑器传来的脚本内容 (line 6) 进行语法检查. 而在其他情况下读取文件内容进行语法检查.

sub check-syntax(%params, $type) {

  my $uri = %params;
  my $code;
  if ($type eq "change") {
    $code = %params || %params[0];
  } else {
    my $file;
    if $uri ~~ /file\:\/\/(.+)/ {
      $file = $/[0].Str;
    }
    $code = $file.IO.slurp;
  }

  my @problems = parse-error($code) || [];

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

  return;
}

执行语法检查

上述脚本中执行语法检查的函数是 parse-error. 它对字符串格式的脚本内容进行语法检查, 然后输出格式化的错误信息. 这个函数的内容由先前的 NQP 语法检查 demo 脚本修改而来.

sub parse-error($code) is export {

  my $*LINEPOSCACHE;
  my $problems;

  my $compiler := nqp::getcomp('Raku') // nqp::getcomp('perl6');
  my $g := nqp::findmethod(
    $compiler,'parsegrammar'
  )($compiler);

  #$g.HOW.trace-on($g);

  my $a := nqp::findmethod(
    $compiler,'parseactions'
  )($compiler);

  try {
    $g.parse( $code, :p( 0 ), :actions( $a ));
  }

  if ($!) {
    my $line-number;
    my $severity = 1;
    my $message = "";
    # https://docs.raku.org/type-exception.html
    # https://github.com/rakudo/rakudo/blob/ca7bc91e71afe9373b57cd629215f843e8026df1/src/core.c/Exception.pm6
    given $!.WHO {
      when "X::Undeclared::Symbols" {
        if $!.unk_routines {
          $line-number = $!.unk_routines.values.min[0] - 1;
        } else {
          $line-number = $!.unk_types.values.min[0] - 1;
        }
        $message = $!.message;
      } 
      when "X::Comp::Group" {
        $line-number = $!.panic.unk_routines.values.min[0] - 1;
        $message = $!.message;
      } 
      when "X::AdHoc" {
        $line-number = 0;
        $message = $!.payload ~ "\n" ~ $!.backtrace.Str;
      }
      default {
        $line-number = $!.line - 1;
        $message = $!.message;
      }
    }
    $problems = ({
      range => {
        start => {
          line      => $line-number,
          character => 0
        },
        end => {
          line      => $line-number,
          character => 99
        },
      },
      severity => $severity,
      source   => 'Raku',
      message  => $message
    });
  }
  return $problems;
}

使用

最后我们获得一个更新后的 raku language server 脚本, 实现实时的语法检查:

use JSON::Fast;
use nqp;

# 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() is export {
  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) {
  # TODO throw an exception if a method is called before $initialized = True
  # debug-log(%request);
  given %request {
    when 'initialize' {
      my $result = initialize(%request);
      send-json-response(%request, $result);
    }
    when 'textDocument/didOpen' {
      check-syntax(%request, "open");
    }
    when 'textDocument/didSave' {
      check-syntax(%request, "save");
    }
    when 'textDocument/didChange' {
      check-syntax(%request, "change");
    }
    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, :!pretty);
  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;
  # debug-log($request);
  print($request);
}

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

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

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

sub check-syntax(%params, $type) {

  my $uri = %params;
  my $code;
  if ($type eq "change") {
    $code = %params || %params[0];
  } else {
    my $file;
    if $uri ~~ /file\:\/\/(.+)/ {
      $file = $/[0].Str;
    }
    $code = $file.IO.slurp;
  }

  my @problems = parse-error($code) || [];


  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($code) is export {

  my $*LINEPOSCACHE;
  my $problems;

  my $compiler := nqp::getcomp('Raku') // nqp::getcomp('perl6');
  my $g := nqp::findmethod(
    $compiler,'parsegrammar'
  )($compiler);

  #$g.HOW.trace-on($g);

  my $a := nqp::findmethod(
    $compiler,'parseactions'
  )($compiler);

  try {
    $g.parse( $code, :p( 0 ), :actions( $a ));
  }

  if ($!) {
    my $line-number;
    my $severity = 1;
    my $message = "";
    # https://docs.raku.org/type-exception.html
    # https://github.com/rakudo/rakudo/blob/ca7bc91e71afe9373b57cd629215f843e8026df1/src/core.c/Exception.pm6
    given $!.WHO {
      when "X::Undeclared::Symbols" {
        if $!.unk_routines {
          $line-number = $!.unk_routines.values.min[0] - 1;
        } else {
          $line-number = $!.unk_types.values.min[0] - 1;
        }
        $message = $!.message;
      } 
      when "X::Comp::Group" {
        $line-number = $!.panic.unk_routines.values.min[0] - 1;
        $message = $!.message;
      } 
      when "X::AdHoc" {
        $line-number = 0;
        $message = $!.payload ~ "\n" ~ $!.backtrace.Str;
      }
      default {
        $line-number = $!.line - 1;
        $message = $!.message;
      }
    }
    $problems = ({
      range => {
        start => {
          line      => $line-number,
          character => 0
        },
        end => {
          line      => $line-number,
          character => 99
        },
      },
      severity => $severity,
      source   => 'Raku',
      message  => $message
    });
  }
  # say debug-log(@problems);
  return $problems;
}

用之前同样的方法使用它. (默认已经安装好了 Coc 插件)

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

然后在 vim 或者 neovim 中配置 Coc 插件:

  1. 打开配置文件 :CocConfig
  2. 添加上面的 language server 脚本:
{
    "languageserver" : {
        "raku": {
            "command": "/foo/bar/raku-lsp.raku",
            "args": ["--vim"],
            "filetypes": ["raku", "rakumod", "pl6", "p6", "pm6"]
        }
    }
}

重启 vim 或 neovim 编辑器, 打开一个 raku 文件. 随便写几行就可以看到效果了.

你可能感兴趣的:(vimperl6)