Deno から Rust を FFI で実行する
最近ふと「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 に出力してもらう必要があります.
[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
を実装してみます.
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 が必要です.
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
のための設定をします.
{
"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
からの相対パスで指定しましょう.
const dylibPathRel = "../../awesome-func/target/release/libawesome_func.so";
const dylibPath = new URL(import.meta.resolve(dylibPath)).pathname;
次に .so
を読み込みます.
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
が必要です.)
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 の例の通りに実装すれば,完了です.
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