🐜

Deno から Rust を FFI で実行する

2024/01/14に公開

最近ふと「Rust で書いた関数を Denops から FFI で呼んでみよう」と思いつきました.
cargo build が必要でビルドの工程が複雑になるので,プラグインとしてはあまり良くないのですが,自分で使う分には問題ないかなと思って試していました.

が,いくつか躓いたところがあったのでメモも兼ねて残して置きます.

主に FFI に関する問題なので,Denops を使わない人にも参考になるかなとは思います.

ディレクトリ構造

Denops のディレクトリ構造 を参考にして,このような構造にしてみます.

~/denops-ffi-test
  |
  +-- deno.json
  |
  +-- denops/denops-ffi-test/
  |     |
  |     +-- main.ts
  |
  +-- awesome-func/
        |
        +-- Cargo.toml
        |
        +-- src/
              |
              +-- lib.rs

なお coc-deno の仕様上,deno.json が repository root に位置するようにしています.

Rust 側の実装

Deno の FFI のドキュメントCargo のドキュメント を参考にして,実装していきます.

本当は deno_bindgen を使いたかったのですが,#[deno_bindgen] attribute が頻繁に panic するので諦めました.

Cargo.toml

Deno 側では C dynamic library を使うことになるので,それを Cargo に出力してもらう必要があります.

Cargo.toml
[package]
name = "awesome-func"
version = "0.1.0"
edition = "2021"

[dependencies]

[lib]
name = "awesome_func"
crate-type = ["cdylib"]

これで,cargo build [--release] すると Linux なら

awesome-func/target/{debug,release}/libawesome_func.so

というファイルが作られます.これを Deno.dlopen() で読み込みます.

src/lib.rs

通常の FFI と同じく,#[no_mangle] attribute を関数に付ければ良いです.
使える型は Deno FFI のドキュメント にある

  • 数値型 {i,u}{8,16,32,64,size}, f32, f64
  • ポインター *mut c_void, *mut u8, Option<extern "C" fn()>
  • 構造体

ですが,構造体は Box に包んで *mut c_void として渡すのが一番楽な気がします.
使うのは tuple を返すときくらいですね.

文字列についての公式の情報が見つからず苦戦しましたが,Rust 側は普通に C string だと思って実装すれば大丈夫です.

ここでは説明のために,name を受け取って "こんにちは,{name}!" を返すための構造体 Greet を実装してみます.

lib.rs
use std::ffi::c_char;
use std::ffi::CString;

#[derive(Debug)]
struct Greet(CString);

impl Greet {
    /// # Safety
    /// `name` must not contain `\0`.
    unsafe fn new(name: &[u8]) -> Box<Self> {
        let mut greet = "こんにちは,".as_bytes().to_vec();
        greet.extend_from_slice(name);
        greet.push(b'!');
        let greet = CString::from_vec_unchecked(greet);
        Box::new(Self(greet))
    }

    fn as_ptr(&self) -> *const c_char {
        self.0.as_ptr()
    }
}

これを Deno で扱えるように,wrapper が必要です.

lib.rs
use std::ffi::c_void;

/// # Safety
/// `txt[0..len]` must not contain `\0`.
#[no_mangle]
pub unsafe extern "C" fn greet_with_name(txt: *mut u8, len: usize) -> *mut c_void {
    let name = std::slice::from_raw_parts(txt as *const u8, len);
    let greet = Greet::new(name);
    Box::into_raw(greet) as _
}

#[no_mangle]
pub extern "C" fn greet_drop(greet: *mut c_void) {
    // SAFETY: `greet` is a return value from [`greet_new()`].
    unsafe {
        let greet = Box::from_raw(greet as *mut Greet);
        drop(greet);
    }
}

#[no_mangle]
pub extern "C" fn greet_as_str(greet: *mut c_void) -> *mut c_void {
    // SAFETY: `greet` is a return value from [`greet_new()`].
    unsafe {
        let greet = &*(greet as *const Greet);
        greet.as_ptr() as _
    }
}

Deno 側の実装

deno.json

残念ながら Denops で import map を使うのは難しいみたいなので,依存パッケージはソースコード内にベタ書きしてしまいます.

ここでは deno task check のための設定をします.

deno.json
{
  "tasks": {
    "check": "deno fmt --check && deno lint && deno check --unstable **/*.ts"
  },
  "lint": {
    "rules": {
      "tags": [
        "recommended"
      ]
    }
  },
  "exclude": [
    "awesome-func/**/*"
  ]
}

FFI は unstable な機能であるため,deno check にも --unstable オプションが必要です.
また,Rust のコードを検査から除外するために,exclude を設定しています.

FFI の実装

Denops のエントリーポイントは denops/denops-ffi-test/main.ts になるわけですが,まずはこちらに FFI の実装をしていきます.
なお unstable な機能を用いるので,coc-deno を使っている場合は deno.unstable = true にしておく必要があります.

.so ファイルの位置は,import.meta を使って main.ts からの相対パスで指定しましょう.

main.ts
const dylibPathRel = "../../awesome-func/target/release/libawesome_func.so";
const dylibPath = new URL(import.meta.resolve(dylibPath)).pathname;

次に .so を読み込みます.

main.ts
const dylib = Deno.dlopen(
  dylibPath,
  {
    "greet_with_name": { parameters: ["buffer", "usize"], result: "pointer" },
    "greet_drop": { parameters: ["pointer"], result: "void" },
    "greet_as_str": { parameters: ["pointer"], result: "pointer" },
  } as const,
);

greet_as_str() の返り値は "buffer" でも良いのですが,いずれにせよ Deno.PointerValue が返るようなので気分的に "pointer" にしています.

さて,自動で greet_drop() を呼んでもらうために,最近 Deno にも追加された using 宣言を使ってみます.(Deno >= 1.38 が必要です.)

main.ts
class Greet {
  readonly greet: Deno.PointerValue;

  constructor(name: string) {
    const buf = new TextEncoder().encode(name);
    this.greet = dylib.symbols.greet_with_name(buf.buffer, buf.byteLength);
  }

  [Symbol.dispose]() {
    dylib.symbols.greet_drop(this.greet);
  }

  toString() {
    const s = dylib.symbols.greet_as_str(this.greet);
    if (s !== null) {
      const dataView = new Deno.UnsafePointerView(s);
      const cStr = dataView.getCString();
      return cStr;
    } else {
      return "null str";
    }
  }
}

const getGreeting = (name: string) => {
  using gr = new Greet(name);
  return gr.toString();
};

これによって,Greet のインスタンスがスコープアウトしたときに自動で drop() してくれます.

Denops に乗せる

あとは Denops の例の通りに実装すれば,完了です.

main.ts
import { Denops } from "https://deno.land/x/denops_std@v5.2.0/mod.ts";
import * as helper from "https://deno.land/x/denops_std@v5.2.0/helper/mod.ts";

export async function main(denops: Denops): Promise<void> {
  denops.dispatcher = {
    async greet(text: unknown): Promise<unknown> {
      if (typeof text === "string") {
        const greeting = getGreeting(text);
        return await Promise.resolve(greeting);
      } else {
        return await Promise.resolve(`Get non text`);
      }
    },
  };

  // :Greet 私 #=> こんにちは,私!
  await helper.execute(
    denops,
    `command! -nargs=1 Greet echomsg denops#request('${denops.name}', 'greet', [<q-args>])`,
  );
}

Vim の runtimepath に追加して,

set runtimepath^=~/denops-ffi-test

コマンドモードで :Greet 私<CR> と入力すれば,こんにちは,私! という文字列が表示されるはずです.

おわりに

案外苦労しましたが,無事 Denops から FFI することができました.
まだ Deno の FFI が unstable なので今後どう変わるかは分かりませんが,夢が広がりますね.

Discussion