COMPANY SERVICE STAFF BLOG NEWS CONTACT

STAFF BLOG

スタッフブログ

TECHNICAL

テクログ

2024.07.24

【VSCode拡張機能開発】PHPCSによるDiagnosticsとFormatter

テクログphp

1. はじめに

PHPCSをVSCodeで使用できるように拡張機能に組み込みます。PHPCSはコーディング規約違反を検出・修正するツールで、実行するためにはコマンドを叩く必要があります。しかしいちいちコマンドを叩くと面倒なため、ファイルを保存するとPHPCSが自動で実行される拡張機能を作成します。

このような拡張機能は探せばすぐ見つかりますが、自分で作成した方がちょっとした制御処理やカスタム機能を入れるときに便利です。

2. 環境

・WSL:2.2.4.0
・Ubuntu:20.04
・Node.js:v20.15.1
・VSCode:1.91.1
・PHP:8.3.8
・Composer:2.7.7

3. ひな形の作成

3.1 コマンド実行

下記コマンドを実行して、拡張機能のひな形を作成します。

npx --package yo --package generator-code -- yo code

コマンドを実行すると対話形式で質問されるので、下記のように回答します。

Q. What type of extension do you want to create?
A. New Extension (TypeScript)

Q. What’s the name of your extension?
A. phpcs-extension

Q. What’s the identifier of your extension?
A. 何も入力せずEnter

Q. What’s the description of your extension?
A. 何も入力せずEnter

Q. Initialize a git repository?
A. n ※git管理したい場合は「Y」

Q. Which bundler to use?
A. webpack

Q. Which package manager to use?
A. npm

3.2 動作確認

F5キーを押すことでデバッグモードに切り替えられます。デバッグモードに切り替えると、開発中の拡張機能が使用できるVSCodeのウィンドウが新しく作成されます。

「Ctrl + Shift + p」を押下して、「Hello World」と入力してEnterを押下します。ウィンドウの右下に「Hello World from phpcs-extension!」と書かれたダイアログが表示されたら問題なく動作しています。

3.3 activationEvents変更

package.jsonのactivationEventsを「onStartupFinished」に変更します。activationEventsは拡張機能を有効にするためのトリガーとなるイベントを登録する設定です。 onStartupFinishedはすべての拡張機能が有効になった後に、自身の拡張機能を有効にします。

{
...
    "activationEvents": [
        "onStartupFinished"
    ],
...
}

参考:https://code.visualstudio.com/api/references/activation-events#onStartupFinished

4. PHPCSインストール

下記コマンドを実行して、PHPCSのパッケージをインストールします。

composer require --dev "squizlabs/php_codesniffer=*"

動作確認のため、下記コードを記述したtest.phpという名前のファイルを作成します。

<?php

if(true){
    echo 'hello world', PHP_EOL;
}

下記、phpcsコマンドを実行します。PSR12に準拠しない書き方が検出できていれば動作に問題はありません。

php vendor/bin/phpcs test.php --standard=PSR12
----------------------------------------------------------------------
FOUND 3 ERRORS AFFECTING 2 LINES
----------------------------------------------------------------------
 3 | ERROR | [x] Expected 1 space after IF keyword; 0 found
 3 | ERROR | [x] Expected 1 space after closing parenthesis; found 0
 5 | ERROR | [x] Expected 1 newline at end of file; 0 found
----------------------------------------------------------------------
PHPCBF CAN FIX THE 3 MARKED SNIFF VIOLATIONS AUTOMATICALLY
----------------------------------------------------------------------

下記、phpcbfコマンドを実行します。phpcsコマンドで検出されたエラーのうち、「x」がついたエラーが自動で修正されていれば動作に問題はありません。

php vendor/bin/phpcbf test.php --standard=PSR12
<?php

if (true) {
    echo 'hello world', PHP_EOL;
}

5. Formatter作成

5.1 実装

src/extension.tsを下記コードに書き換えます。

import * as vscode from 'vscode';
import * as child_process from 'child_process';

export function activate(context: vscode.ExtensionContext) {
    // Formatter
    const phpDocumentFormattingEditProviderDisposable = vscode.languages.registerDocumentFormattingEditProvider(
        'php',
        new DocumentFormattingEditProvider()
    );

    // Diagnostics
    // ...

    context.subscriptions.push(phpDocumentFormattingEditProviderDisposable);
}

class DocumentFormattingEditProvider implements vscode.DocumentFormattingEditProvider {
    // 修正可能ステータス
    private static readonly STATUS_FIXABLE = 1;

    public provideDocumentFormattingEdits(
        document: vscode.TextDocument,
        options: vscode.FormattingOptions,
        token: vscode.CancellationToken
    ): vscode.ProviderResult<vscode.TextEdit[]> {
        // 整形範囲を取得
        const firstLine = document.lineAt(0);
        const lastLine = document.lineAt(document.lineCount - 1);
        const range = new vscode.Range(firstLine.range.start, lastLine.range.end);

        // phpcbfファイルのパスを取得
        const phpcbfFilePath = vscode.workspace.getConfiguration('phpcs-extension').get('phpcbfFilePath');
        if (typeof phpcbfFilePath !== 'string') {
            return null;
        }

        // phpcbf実行
        const res = child_process.spawnSync(phpcbfFilePath, ['-', '--standard=PSR12'], {
            input: document.getText(),
        });

        // 整形後のファイルの中身を返す
        return res.status === DocumentFormattingEditProvider.STATUS_FIXABLE
            ? [new vscode.TextEdit(range, res.stdout.toString())]
            : null;
    }
}

export function deactivate() {}

Formatterの実装にはDocumentFormattingEditProviderを使用します。DocumentFormattingEditProviderを継承したクラスを作成して、provideDocumentFormattingEditsメソッドを実装します。引数として整形しようとしているファイルの情報が渡されるので、整形後のファイルの中身を戻り値として返します。最後は、作成したクラスをregisterDocumentFormattingEditProviderで登録して完了です。

5.2 publisherとconfiguration追加

package.jsonにpublisherを追加します。ここでは「test-publisher」にします。

{
...
    "description": "",
    "publisher": "test-publisher",
    "version": "0.0.1",
...
}

次にconfigurationを追加します。これは拡張機能でsettings.jsonに記述された値を取得したい場合に必要な設定です。コード上の処理「phpcbfファイルのパスを取得」の箇所で、phpcbfファイルの絶対パスを取得しているため、その設定を追加します。

{
...
    "main": "./dist/extension.js",
    "contributes": {
        "configuration": {
            "type": "object",
            "title": "phpcs-extension",
            "properties": {
                "phpcs-extension.phpcbfFilePath": {
                    "type": "string",
                    "markdownDescription": "phpcbfファイルの絶対パス"
                }
            }
        }
    },
    "scripts": {
...
}

元々あったコマンドの設定は不要なので削除しました。

5.3 debugディレクトリとsettings.json作成

デバッグ用のファイル置き場となるディレクトリを作成します。ワークスペース配下にdebugディレクトリを作成して、その中に.vscode/settings.jsonを作成します。

{
    "[php]": {
        "editor.defaultFormatter": "test-publisher.phpcs-extension",
        "editor.formatOnSave": true,
    },
    "phpcs-extension.phpcbfFilePath": "phpcbfファイルの絶対パスに差し替える"
}

phpcs-extension.phpcbfFilePathの値は自分の環境に合わせて変更してください。phpcbfファイルはvendor/bin配下にあります。

5.4 動作確認

F5キーを押下してデバッグモードに切り替えます。新しく作成されたVSCodeのウィンドウで、先ほど作成したdebugディレクトリに移動します。

次に、テスト用のPHPファイルを作成します。ファイル名はtest.phpで、ファイルの中身は下記の通りです。

<?php

if(true){
    echo 'Hello World', PHP_EOL;
}

ファイルを保存したときにtest.phpの中身が下記のようになれば、Formatterの機能が正常に動作したことになります。

<?php

if (true) {
    echo 'Hello World', PHP_EOL;
}

6. Diagnostics作成

6.1 実装

src/extension.tsを下記コードのように追記・修正します。

// ...

export function activate(context: vscode.ExtensionContext) {
    // Formatter
    const phpDocumentFormattingEditProviderDisposable = vscode.languages.registerDocumentFormattingEditProvider(
        'php',
        new DocumentFormattingEditProvider()
    );

    // Diagnostics
    const onDidSaveTextDocument = new OnDidSaveTextDocument();
    const onDidSaveTextDocumentDisposable = vscode.workspace.onDidSaveTextDocument(
        onDidSaveTextDocument.onDidSaveTextDocument
    );

    context.subscriptions.push(phpDocumentFormattingEditProviderDisposable, onDidSaveTextDocumentDisposable);
}

// ...

class OnDidSaveTextDocument {
    private readonly diagnosticCollection: vscode.DiagnosticCollection;

    public constructor() {
        this.diagnosticCollection = vscode.languages.createDiagnosticCollection('php');
        this.onDidSaveTextDocument = this.onDidSaveTextDocument.bind(this);
    }

    public onDidSaveTextDocument(document: vscode.TextDocument): void {
        // Diagnosticsクリア
        this.diagnosticCollection.clear();

        // phpcsが無効化されていたら何もしない
        const enablePhpcs = vscode.workspace.getConfiguration('phpcs-extension').get('enablePhpcs');
        if (typeof enablePhpcs !== 'boolean' || !enablePhpcs) {
            return;
        }

        // PHPファイルでなければ何もしない
        if (document.languageId !== 'php') {
            return;
        }

        // phpcsファイルのパスを取得
        const phpcsFilePath = vscode.workspace.getConfiguration('phpcs-extension').get('phpcsFilePath');
        if (typeof phpcsFilePath !== 'string') {
            return;
        }

        // phpcs実行
        const res = child_process.spawnSync(phpcsFilePath, [document.fileName, '--report=json']);
        const json = JSON.parse(res.stdout.toString());

        // Diagnostics作成
        const diagnostics: vscode.Diagnostic[] = [];
        for (const { message, source, type, line, column } of json['files'][document.fileName]['messages']) {
            const range = new vscode.Range(line - 1, column - 1, line - 1, column - 1);
            const msg = `${message}\n${source}`;
            const severity = type === 'ERROR' ? vscode.DiagnosticSeverity.Error : vscode.DiagnosticSeverity.Warning;
            diagnostics.push(new vscode.Diagnostic(range, msg, severity));
        }

        this.diagnosticCollection.set(document.uri, diagnostics);
    }
}

// ...

6.2 settings.jsonに設定を追加

下記のようにsettings.jsonにConfigurationを追加します。追加する位置は、contributes > configuration > properties の配下です。

{
...
                "phpcs-extension.phpcsFilePath": {
                    "type": "string",
                    "markdownDescription": "phpcsファイルの絶対パス"
                },
                "phpcs-extension.enablePhpcs": {
                    "type": "boolean",
                    "enum": [
                        true,
                        false
                    ],
                    "markdownDescription": "コーディングルール違反検出の有効・無効"
                }
...
}

6.3 Configuration追加

debug/.vscode/settings.jsonに下記の設定を追加します。

{
...
    "phpcs-extension.phpcsFilePath": "phpcsファイルの絶対パスに差し替える",
    "phpcs-extension.enablePhpcs": true
}

phpcbfと同様、 phpcs-extension.phpcsFilePathの値は自分の環境に合わせて変更してください。phpcsファイルもvendor/bin配下にあります。

6.3 動作確認

F5キーを押下してデバッグモードに切り替えます。新しく作成されたVSCodeのウィンドウは、先ほど作成したdebugディレクトリに移動しておきます。

先ほど作成したtest.phpを下記のコードに差し替えます。

<?php

class Hoge
{
    public function snake_case(): void
    {
        return;
    }
}

ファイルを保存したときに「function」の箇所に赤い波線が表示されていれば、Diagnoticsの機能が正常に動作したことになります。PSR準拠の場合、メソッド名はキャメルケースで宣言する必要がありますが、「snake_case」はスネークケースのためエラーとして扱われています。

7. 拡張機能をインストール可能なファイルとして出力

7.1 README.mdファイル変更

README.mdファイルに拡張機能の説明を記述します。このファイルの中身を変更しないと次の手順でエラーが出る可能性があります。

7.2 vsixファイルの作成

拡張機能をインストール可能なvsixファイルとして出力します。下記コマンドをして、phpcs-extension-0.0.1.vsixファイルを作成します。

npx vsce package

7.3 vsixファイルから拡張機能のインストール

phpcs-extension-0.0.1.vsixファイルを右クリックして表示される「拡張機能のVSIXのインストール」を選択することで、拡張機能をインストールできます。

8. おわりに

VSCode拡張機能を開発する際の参考になれば幸いです。

この記事を書いた人

ノコ

入社年2021年

出身地岩手

業務内容Web開発

特技・趣味ゲーム、競プロ、数学

テクログに関する記事一覧

TOP