+
+

更新履歴

+
    +
  1. + : 公開 +
  2. +
+
+
+
+ NOTE +
+
+

+ この記事は Vim 駅伝 #136 の記事です。 +

+
+
+
+

やりたいこと

+

+ Neovim で空の PHP ファイルを開いたとき、そのファイルが置かれているディレクトリの構造に基づいて、自動的に namespace 宣言を挿入したい。具体的には、トップレベルの名前空間が MyNamespace であり、ファイル src/Foo/Bar/Baz.php を開いたときに、そのファイルが空であるなら、次のようなテンプレートが自動的に挿入されてほしい。 +

+
+
<?php
+
+namespace MyNamespace\Foo\Bar;
+
+
+
+

バージョン情報

+
+
$ nvim --version
+NVIM v0.9.2
+Build type: Release
+LuaJIT 2.1.1693350652
+
+

+ 今回は Lua で処理を記述したため、Vim では動作しない。以下の説明でも Neovim に絞って述べる。また、パス区切りがスラッシュである前提で記述したため、Windows には対応していない。 +

+
+
+

ftplugin を用意する

+

+ Neovim には特定のファイルタイプに対して特別な処理をおこなうための ftplugin と呼ばれる仕組みがある。Neovim の設定を置くディレクトリ (例えば ~/.config/nvim) の配下に ftplugin/<FILE_TYPE>.vim または ftplugin/<FILE_TYPE>.lua というファイルを配置すると、その <FILE_TYPE> が読み込まれたときにそのファイルが自動的に実行される。 +

+

+ 今回は、Neovim がデフォルトで用意している PHP 用 ftplugin が動作したあとに追加の処理をおこないたいので、after/ftplugin/php.{vim,lua} というファイルを配置する。名前から察せられるとおり、after/ftplugin 以下のファイルは ftplugin 以下のファイルよりもあとに実行される。 +

+

+ この記事では Lua で処理を記述するため、拡張子には .lua を用いる。これ以降載せるコードは、すべて after/ftplugin/php.lua の中に記述している。 +

+
+
+

二重読み込みを防ぐ

+

+ ファイルタイプは読み込んだあとに変更されることもあるので、ftplugin は複数回実行されうる。二重読み込みを防ぐために、did_ftplugin_<FILE_TYPE>_after というバッファローカル変数を定義しておくのが慣習となっている。 +

+
+
if vim.b.did_ftplugin_php_after then
+   return
+end
+
+-- ここに実際の処理を書く
+
+vim.b.did_ftplugin_php_after = true
+
+
+
+

実装する

+

+ では実装していこう。今回私は次のようなロジックとした。以降、「今 Neovim で開いた PHP ファイル」のことを「対象ファイル」と呼ぶことにする。 +

+
    +
  1. + 対象ファイルが空でなければ何もしない +
  2. +
  3. + 対象ファイルが置かれたディレクトリを上に辿って、composer.json を見つける +
  4. +
  5. + composer.jsonautoload.psr-4 を見て、トップレベルの名前空間とディレクトリを特定する +
  6. +
  7. + 対象ファイルが置かれたディレクトリが、トップレベルのディレクトリを基準としてどのようにネストしているか調べる +
  8. +
  9. + オートロードの設定と照らし合わせて、対象ファイルが属すべき名前空間を特定する +
  10. +
  11. + PHP の開始タグとともに namespace 宣言を挿入する +
  12. +
+

+ 実装を簡単にするため、Composer を用いない場合や PSR 4 以外のオートロード規則を使う場合には対応しない。少々長くなるが、以下にスクリプト全文を載せる。 +

+
+
if vim.b.did_ftplugin_php_after then
+   return
+end
+
+-- base_dir を起点としてディレクトリを上向きに辿っていき、composer.json を探す
+-- :help vim.fs.find()
+local function find_composer_json(base_dir)
+   return vim.fs.find('composer.json', {
+      path = base_dir,
+      upward = true,
+      -- ホームディレクトリまで到達したら探索を打ち切る
+      stop = vim.loop.os_homedir(),
+      type = 'file',
+   })[1]
+end
+
+-- JSON ファイルを読み込み、デコードして返す
+-- :help readblob()
+-- :help vim.json.decode
+-- :help luaref-pcall()
+local function load_json(file_path)
+   -- readblob() は Vim script では Blob オブジェクトを返すが、Lua から呼ぶと string に変換される
+   local ok_read, content = pcall(vim.fn.readblob, file_path)
+   if not ok_read then
+      return nil
+   end
+   local ok_decode, obj = pcall(vim.json.decode, content)
+   if not ok_decode then
+      return nil
+   end
+   return obj
+end
+
+-- 対象ファイルの置かれたディレクトリを基に namespace 宣言を生成する
+-- :help nvim_buf_get_name()
+-- :help vim.fs.dirname()
+local function generate_namespace_declaration()
+   -- composer.json を探し、トップレベルの名前空間とディレクトリを特定する
+   local current_dir = vim.fs.dirname(vim.api.nvim_buf_get_name(0))
+   local path_to_composer_json = find_composer_json(current_dir)
+   if not path_to_composer_json then
+      return nil -- failed to locate composer.json
+   end
+   local composer_json = load_json(path_to_composer_json)
+   if not composer_json then
+      return nil -- failed to load composer.json
+   end
+   -- autoload.psr-4 を探し、型が期待される型と一致するかどうか調べる
+   local psr4 = vim.tbl_get(composer_json, 'autoload', 'psr-4')
+   if not psr4 then
+      return nil -- autoload.psr-4 section is absent
+   end
+   if vim.tbl_count(psr4) ~= 1 then
+      return nil -- psr-4 section is ambiguous
+   end
+   local psr4_namespace, psr4_dir
+   for k, v in pairs(psr4) do
+      psr4_namespace = k
+      psr4_dir = v
+   end
+   if type(psr4_dir) == 'table' then
+      if #psr4_dir == 1 then
+         psr4_dir = psr4_dir[1]
+      else
+         return nil -- psr-4 section is ambiguous
+      end
+   end
+   if type(psr4_namespace) ~= 'string' or type(psr4_dir) ~= 'string' then
+      return nil -- psr-4 section is invalid
+   end
+   -- 末尾のスラッシュとバックスラッシュを取り除いておく
+   if psr4_namespace:sub(-1, -1) == '\\' then
+      psr4_namespace = psr4_namespace:sub(0, -2)
+   end
+   if psr4_dir:sub(-1, -1) == '/' then
+      psr4_dir = psr4_dir:sub(0, -2)
+   end
+
+   -- 対象ファイルが置かれたディレクトリとトップレベルのディレクトリを比較し、その差分を名前空間とする
+   local namespace_root_dir = vim.fs.dirname(path_to_composer_json) .. '/' .. psr4_dir
+   if not vim.startswith(current_dir, namespace_root_dir) then
+      return nil
+   end
+   local current_path_suffix = current_dir:sub(#namespace_root_dir + 1)
+   local namespace = psr4_namespace .. current_path_suffix:gsub('/', '\\')
+   return ("namespace %s;"):format(namespace)
+end
+
+local function generate_template()
+   local lines = {
+      '<?php',
+      '',
+      'declare(strict_types=1);',
+      '',
+   }
+   local namespace_decl = generate_namespace_declaration()
+   if namespace_decl then
+      lines[#lines + 1] = namespace_decl
+      lines[#lines + 1] = ''
+   end
+   lines[#lines + 1] = ''
+   return lines
+end
+
+if vim.fn.line('$') == 1 and vim.fn.getline(1) == '' then
+   -- 対象ファイルが空なら、テンプレートを挿入してカーソルを末尾に移動させる
+   -- :help setline()
+   -- :help cursor()
+   vim.fn.setline(1, generate_template())
+   vim.fn.cursor('$', 0)
+end
+
+vim.b.did_ftplugin_php_after = true
+
+
+
+

おわりに

+

+ 簡易的な実装だが、多くのケースではうまく動いているようだ。最大の問題は PSR 4 に準拠しないフレームワークを用いているとまったく役に立たないことで、今まさに職場で困っている。こちらはいずれ改良したい。 +

+
+