PHP 驱动的 IDE 插件开发:利用 LSP 协议在 Cursor/VSCode 中增强 PHP 语义识别能力

好吧,各位编程界的同仁们,把手里的泡面先放一放。今天我们不聊如何用两行代码把一个五万行的单体架构干翻,也不聊为什么 Composer 依赖安装这么慢。今天,我们要聊聊一个听起来很高大上,实际上能把你的编辑器变成“赛博朋克脑机接口”的话题——开发 PHP 语言服务器

想象一下,你的编辑器不再是一个只会给你高亮红字的复读机,它变成了一个全知全能的 PHP 大师。当你输入 $user-> 时,它不仅知道有哪些方法,还能根据 $userUser 类还是 Guest 类,瞬间给你展示只有那个类才有的方法。这就是 LSP(语言服务器协议)的力量。

有人可能会问:“PHP 不是有自动加载吗?我 require 一下不就完事了吗?”
别傻了,那是“执行时”的魔法。LSP 是“编译时”的魔法,而且是在你还没保存文件,甚至还没输入完整的时候就开始工作。这就像是你的编辑器突然拥有了预知未来的能力。

准备好了吗?让我们把 PHP 的解释器关掉,把咖啡灌满,开始搭建这个属于我们自己的 IDE 核心。

第一部分:LSP 是什么?它是代码界的“翻译官”

首先,我们得明白我们在和谁对话。LSP 是微软搞出来的一个协议,但现在是整个开发界的通用语。它就像一个中间人。

  • 编辑器(客户端): 你的 VS Code 或 Cursor。它负责显示文本、画高亮、给你弹窗。它不懂 PHP 的语义,它只懂 JSON 和文字渲染。
  • 语言服务器(服务端): 这是一个独立运行的 PHP 进程。它负责读代码、分析代码、吐出语义信息。

通信流程是这样的:

  1. 握手: 编辑器说:“嘿,服务器,你死没死?我有文档了,你能处理吗?”
  2. 初始化: 服务器说:“没死,我配置好了,我可以解析 PSR-12 标准,我可以做重构。”
  3. 生命周期: 编辑器说:“用户在输入这个类的方法。” 服务器大脑飞速运转,解析 AST(抽象语法树),查反射,然后回传:“这个类没有 fly() 方法,不过有个 swim() 方法,你要不要试试?”

我们不需要自己从头写 HTTP 服务器和 JSON-RPC 解析器。为了不重复造轮子(虽然造轮子很有趣,但今天我们是来造火箭的),我们使用 openlsp/php-language-server-core 这个库。它就像是一个精简版的 Symfony,专门为 LSP 设计。

第二部分:搭建骨架——让 PHP 服务器跑起来

好了,让我们看看第一行代码。别担心,这不像你第一次写 Laravel 那么复杂。

首先,我们需要一个 Composer 项目:

composer require openlsp/php-language-server-core php-parser

接下来,我们需要一个入口文件 server.php。这就像是一个指挥官的帐篷。

<?php

use GlueStdioIo;
use OpenLSPServerCore;
use OpenLSPServerConnection;
use OpenLSPServerTransportIoTransport;

require 'vendor/autoload.php';

// 1. 创建输入输出流
// 这里的 Io::stdio() 会从标准输入读取 JSON,向标准输出写 JSON
$stdio = Io::stdio();

// 2. 创建连接
$connection = new Connection($stdio);

// 3. 初始化服务器
// 这是一个单例模式,LSP 协议规定只有一个服务器实例
$server = Core::start($connection);

// 4. 注册能力(Capabilities)
// 告诉客户端:“兄弟,我能做这些事:同步文本文档、提供代码补全、提供诊断信息(错误提示)”
$server->onInitialize(function ($params) {
    return [
        'capabilities' => [
            'textDocumentSync' => 1, // 1 表示 Full Sync
            'completionProvider' => [
                'triggerCharacters' => ['.', '$', '>'], // 当遇到这些符号时,触发补全
            ],
            'definitionProvider' => true, // 提供跳转定义的功能
        ]
    ];
});

// 5. 注册文本文档同步
// 当你修改文件时,这里会触发。我们需要在这里解析 PHP 代码
$server->on('textDocument/didChange', function ($params) use ($server) {
    // $params 包含了文档的所有内容
    $content = $params['textDocument']['text'];

    // 这里就是魔法开始的地方!我们需要解析这段 PHP 代码
    // 简单起见,我们用 php-parser 解析
    $parser = new PhpParserParserFactory();
    $ast = $parser->create(PhpParserParserFactory::PREFER_PHP7)->parse($content);

    // 我们可以在这里做很多事,比如把 AST 存入内存,
    // 或者发送 PublishDiagnosticsNotification 告诉编辑器哪里有错。
    // 这是一个伪代码示例:
    $server->notification('textDocument/publishDiagnostics', [
        'uri' => $params['textDocument']['uri'],
        'diagnostics' => [] // 实际项目中这里会塞入 ParseError
    ]);
});

// 6. 启动!
$server->run();

跑起来试试?在终端输入 php server.php。然后打开你的 VS Code,新建一个 test.php,在里面乱敲一通。你会发现,什么都没发生。

别急,因为你还没告诉 VS Code 去连这个 PHP 进程。这就是下一部分我们要讲的内容。

第三部分:连接大脑——VS Code 和 Cursor 的配置

LSP 服务器跑在 PHP 里,VS Code 是 JS 写的。它们中间怎么传数据?这就需要 JSON。

在 VS Code 中,我们需要创建一个扩展。别慌,不是让你重写整个 IDE。我们只需要一个 package.json

{
  "name": "my-php-lsp",
  "version": "0.0.1",
  "contributes": {
    "languages": [{
      "id": "my-php",
      "aliases": ["My PHP", "php"],
      "extensions": [".php"]
    }],
    "configuration": {
      "type": "object",
      "title": "My PHP LSP",
      "properties": {
        "myPhpLsp.path": {
          "type": "string",
          "default": "php",
          "description": "Path to PHP executable"
        }
      }
    },
    "grammars": [{
      "language": "my-php",
      "scopeName": "source.php",
      "path": "./syntaxes/php.tmLanguage.json"
    }]
  },
  "activationEvents": [
    "onLanguage:my-php"
  ],
  "main": "./out/extension.js",
  "scripts": {
    "vscode:prepublish": "npm run compile",
    "compile": "tsc -p ./"
  },
  "devDependencies": {
    "@types/node": "^16.0.0",
    "typescript": "^4.5.0"
  }
}

注意看 activationEvents,这就像是门禁卡。只有当你打开 .php 文件时,VS Code 才会去启动我们那个 PHP 服务器进程。

然后我们需要写 TypeScript 代码来启动这个进程。这里有个坑,VS Code 想要一个子进程,但 php server.php 启动后,它就变成孤儿进程了。我们需要把标准输入输出重定向到 VS Code。

import * as vscode from 'vscode';
import { spawn } from 'child_process';
import * as path from 'path';

export function activate(context: vscode.ExtensionContext) {
    console.log('My PHP LSP is active!');

    // 获取配置的 PHP 路径
    const phpPath = vscode.workspace.getConfiguration('myPhpLsp').get<string>('path') || 'php';

    // 启动 PHP 语言服务器
    const serverProcess = spawn(phpPath, [path.join(context.extensionPath, 'server.php')]);

    // 这是最关键的一步!
    // VS Code 发送数据,我们通过 stdin 丢给 PHP
    // PHP 处理完,通过 stdout 把 JSON 返回给我们
    // 我们再把 JSON 解析,丢给 VS Code

    let nextMessageId = 0;
    const pendingRequests = new Map<number, { resolve: Function, reject: Function }>();

    serverProcess.stdout.on('data', (data) => {
        const messages = data.toString().split('');
        messages.forEach(msg => {
            if (!msg) return;
            const response = JSON.parse(msg);

            // LSP 响应格式: { jsonrpc: "2.0", id: 1, result: ... }
            if (response.id !== undefined) {
                const request = pendingRequests.get(response.id);
                if (request) {
                    request.resolve(response.result);
                    pendingRequests.delete(response.id);
                }
            }
        });
    });

    serverProcess.stderr.on('data', (data) => {
        console.error('LSP Server Error:', data.toString());
    });

    serverProcess.on('error', (err) => {
        console.error('Failed to start LSP server:', err);
    });

    // 封装一个发送消息的方法
    const sendRequest = (method: string, params: any): Promise<any> => {
        return new Promise((resolve, reject) => {
            const id = ++nextMessageId;
            pendingRequests.set(id, { resolve, reject });

            const request = {
                jsonrpc: "2.0",
                id: id,
                method: method,
                params: params
            };

            serverProcess.stdin.write(JSON.stringify(request) + '');
        });
    };

    // 这里是展示“语义识别”的核心
    // 当用户在输入时,触发补全
    vscode.languages.registerCompletionItemProvider('my-php', {
        provideCompletionItems(document: vscode.TextDocument, position: vscode.Position) {
            // 1. 获取当前文件内容
            const text = document.getText();

            // 2. 发送请求给 PHP 服务器
            // 我们这里模拟发送 "completion" 请求,实际协议里是 textDocument/completion
            // 注意:这只是个伪代码,为了展示交互逻辑

            return sendRequest('textDocument/completion', {
                textDocument: { uri: document.uri.toString() },
                position: position,
                context: { triggerCharacter: '.' } // 如果是 . 触发的,说明是在访问属性或方法
            }).then((items) => {
                // 3. 收到 PHP 服务器返回的补全列表,转换为 VS Code 能识别的格式
                return items.map((item: any) => {
                    const completion = new vscode.CompletionItem(item.label, vscode.CompletionItemKind.Method);
                    completion.detail = item.detail;
                    completion.documentation = item.documentation;
                    completion.insertText = item.insertText;
                    return completion;
                });
            });
        }
    });
}

export function deactivate() {}

现在,当你打开一个 .php 文件,打下一个点,并且 VS Code 激活了插件,它会弹出一个 PHP 进程,然后开始疯狂发送 JSON 数据。

第四部分:深入核心——如何解析 PHP 语义?

好,现在我们有了连接,有了传输通道。但最核心的问题是:PHP 服务器的大脑里到底装了什么?

PHP 的一大痛点是“动态语言”。$a->b(),你不知道 a 是什么类型,b 是什么方法。要实现高级语义识别,我们至少要解决两个问题:类定义识别上下文推断

1. 扫描类定义

我们得遍历项目目录,找到所有的类。

// 在服务器初始化时执行
$server->onInitialize(function ($params) {
    $projectRoot = dirname($params['rootUri']); // VS Code 传过来的项目根目录
    $classes = scanClasses($projectRoot); // 这是一个自定义函数

    return [
        'capabilities' => [...],
        'phpWorld' => $classes // 把扫描到的类库扔给客户端,用于后续查询
    ];
});

function scanClasses($root) {
    $classes = [];

    // 使用 RecursiveDirectoryIterator 遍历文件
    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($root)
    );

    foreach ($iterator as $file) {
        if ($file->isFile() && $file->getExtension() === 'php') {
            $content = file_get_contents($file->getPathname());

            // 这里可以用 php-parser 提取类名
            // 或者更简单的正则(不推荐生产环境,但适合快速原型)
            if (preg_match('/classs+(w+)/', $content, $matches)) {
                $className = $matches[1];
                $filePath = $file->getPathname();
                $classes[$className] = $filePath;
            }
        }
    }

    return $classes;
}

2. 基于上下文的智能补全

现在,当用户输入 $user-> 时,我们如何知道 user 的类型?

这需要检查当前光标附近的代码。我们可以在 didChange 事件中分析 AST。

假设代码是:

$var = new User();
$var->   // 光标在这里

我们要做的是:

  1. 解析 $var 声明行。
  2. 找到 new User() 中的类名。
  3. 查找 User 类的所有方法。
  4. 返回方法列表。

这听起来简单,但写起来很繁琐。好在我们有 php-parserNodeTraverser

// 假设我们已经有了文档内容
$traverser = new PhpParserNodeTraverser();
$parser = (new PhpParserParserFactory())->create(PhpParserParserFactory::PREFER_PHP7);

try {
    $ast = $parser->parse($content);

    $finder = new PhpParserNodeVisitorNameResolver(); // 自动解析命名空间
    $traverser->addVisitor($finder);
    $traverser->traverse($ast);

    // 现在我们要找变量赋值节点
    foreach ($ast as $node) {
        if ($node instanceof PhpParserNodeStmtExpression) {
            $expr = $node->expr;
            if ($expr instanceof PhpParserNodeExprNew_) {
                $classType = $finder->getClassName($expr->class);
                if ($classType) {
                    // 找到了!当前上下文中的变量应该就是这个类
                    $methods = getMethodsFromReflection($classType);
                    return $methods;
                }
            }
        }
    }
} catch (PhpParserError $e) {
    // 忽略解析错误
}

第五部分:重构与定义跳转——不仅仅是补全

LSP 的真正威力在于“导航”。比如你看到一个方法调用 processData($data),你想看看 processData 到底在哪里定义的。

这就需要 textDocument/definition 方法。

客户端发送:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "textDocument/definition",
  "params": {
    "textDocument": { "uri": "file:///path/to/project/User.php" },
    "position": { "line": 10, "character": 5 }
  }
}

服务端逻辑:

  1. 解析光标下的内容,判断它是一个函数调用(FunctionCall)。
  2. 提取函数名(比如 processData)。
  3. 在项目目录中搜索包含这个函数定义的文件。
    • 如果是在 Trait 里?那就麻烦点,得递归查找。
    • 如果是在命名空间下?那就加上命名空间前缀。
  4. 构造 Location 对象返回。
$server->on('textDocument/definition', function ($params) use ($server) {
    $uri = $params['textDocument']['uri'];
    $line = $params['position']['line'];
    $char = $params['position']['character'];

    // 获取文档内容
    $document = $server->getDocument($uri); // 假设有一个文档管理器
    $content = $document->getText();

    // 简单的字符提取
    $lines = explode("n", $content);
    $lineContent = $lines[$line];

    // 获取单词(这里简化了逻辑,实际需要判断光标在哪个 token 上)
    // 假设光标在函数名末尾
    $funcName = trim(substr($lineContent, 0, $char));

    // 搜索定义
    $definition = findDefinition($funcName);

    return $definition ? [$definition] : [];
});

第六部分:实时诊断——报错也要有智能

传统的 PHP 错误是“语法错误”或者“运行时错误”。LSP 的“语义识别”能帮你在写代码时就发现错误。

比如:

$users = User::all();
foreach ($users as $user) {
    $user->profile()->delete(); // 这里会有警告吗?如果 $user 是 null 呢?
}

我们需要在每次保存文件或修改文件时,分析代码的逻辑流。

这就涉及到 PHPStanPsalm 的集成。你完全可以让你的 PHP 服务器作为一个包装器,去调用这些静态分析工具。

$server->on('textDocument/didSave', function ($params) use ($server) {
    $uri = $params['textDocument']['uri'];

    // 1. 获取当前文档内容
    $content = $server->getDocument($uri)->getText();

    // 2. 写入临时文件
    $tempFile = tempnam(sys_get_temp_dir(), 'lsp_analysis_');
    file_put_contents($tempFile, $content);

    // 3. 执行 PHPStan(这里假设你已经安装了 phpstan)
    // 这是一个非常耗时的操作,所以通常会异步做,或者只做增量分析
    $output = shell_exec("phpstan analyse $tempFile --error-format=json 2>&1");

    // 4. 解析输出并转换为 VS Code 的 Diagnostic 格式
    $errors = json_decode($output, true);
    $diagnostics = [];

    foreach ($errors['files'] as $fileError) {
        foreach ($fileError['messages'] as $msg) {
            $diagnostics[] = [
                'severity' => $msg['type'] === 'error' ? 1 : 2, // 1=Error, 2=Warning
                'range' => [
                    'start' => ['line' => $msg['line_number'], 'character' => 0],
                    'end' => ['line' => $msg['line_number'], 'character'  => 999]
                ],
                'message' => $msg['message'],
                'source' => 'MyLSP'
            ];
        }
    }

    // 5. 发送回客户端
    $server->notification('textDocument/publishDiagnostics', [
        'uri' => $uri,
        'diagnostics' => $diagnostics
    ]);

    // 清理
    unlink($tempFile);
});

第七部分:性能优化——别让服务器“跑路”了

开发阶段这代码跑起来很爽,但你可能没注意到,每次你按回车,PHP 服务器进程都会占用大量 CPU 和内存来解析你的代码。而且,如果项目有几千个文件,RecursiveIterator 可能会遍历个半天,导致客户端连不上服务器,或者出现 Read timed out

这时候,我们需要缓存

因为 PHP 服务器是常驻进程,我们可以把扫描到的类列表、方法列表存到数组里,或者存到 Redis 里。当文件改变时,我们只更新缓存,而不是重新扫描整个项目。

另外,延迟加载 也是关键。不要在初始化时就解析所有文件,只解析当前打开的文件,以及被当前文件引用的文件。

还有一个坑是 Composer Autoload。我们的服务器运行在 CLI 环境下,它需要能自动加载项目的类。所以,我们必须在服务器启动时加载 vendor/autoload.php,或者至少加载项目的 composer.json 生成一个 Loader。

// 在初始化时
require __DIR__ . '/vendor/autoload.php';
// 或者更聪明的做法,扫描 vendor/composer/autoload_psr4.php

第八部分:Cursor 的特别之处

Cursor 是基于 VS Code 改造的,它对 LSP 的支持非常好,而且它内置了对 Anthropic Claude 的集成。

如果你的 PHP LSP 服务器足够智能,你甚至可以让 Cursor 利用它的 AI 能力。

例如:

  1. 你在 LSP 服务器里注入一个自定义功能:textDocument/aiRefactor
  2. 当你选中一段代码,按下一个特殊的快捷键(比如 Ctrl+Shift+R)。
  3. 客户端(Cursor)把这个请求转发给 LSP 服务器。
  4. LSP 服务器解析代码,计算 AST,生成重构建议。
  5. Cursor 展示这些建议,甚至直接帮你应用。

这就把“静态分析”和“生成式 AI”完美结合了。

第九部分:避坑指南与常见错误

在开发 PHP LSP 的过程中,你会遇到各种奇葩问题:

  1. JSON 传输损坏:
    PHP 处理字符串很随意,有时候 这个字符(LSP 协议用它作为消息分隔符)会被 PHP 的 echo 吞掉或者处理错。

    • 解决方法: 始终使用 JSON_UNESCAPED_UNICODEJSON_UNESCAPED_SLASHES,并且在写入 socket 之前,确保字符串是干净的。
  2. 反射循环引用:
    如果你有 A 类依赖 B,B 类依赖 C,C 类依赖 A,用 get_class_methodsReflectionClass 可能会导致栈溢出或无限循环。

    • 解决方法: 手写一个简单的引用计数器或集合来跟踪已经访问过的类。
  3. VS Code 的白屏:
    如果你发送的 JSON 格式不对,或者服务器返回了错误的响应结构,VS Code 的扩展开发主机可能会直接崩掉,根本看不出是哪里错了。

    • 解决方法:package.json 里配置 "scripts": { "lint": "eslint src" },然后在终端实时监控错误日志。
  4. 字符编码:
    VS Code 发过来的是 UTF-8,但有时文件是 GBK 编码,或者 Windows 的换行符是 rn,而 LSP 协议通常约定用 n

    • 解决方法: 在解析文本前,统一处理换行符。

第十部分:未来的展望

现在的 PHP LSP 还有很多不足。它很难像 Rust 那样做到毫秒级的响应。PHP 是解释型语言,解析 AST 和反射总是比编译型语言慢。

但是,随着 PHP 8.2, 8.3 的引入,类型系统越来越完善,PHPStan 等工具越来越智能,开发一个高性能 PHP LSP 服务器变得越来越可行。

想象一下,未来的 IDE:

  • 预测式开发: 在你敲下第一个字母时,它已经根据你的历史习惯预测出了你要写什么方法。
  • 上下文感知重构: 修改一个 Trait,能实时看到它对整个项目所有引用处的影响,并给出警告。
  • 混合语言支持: 你的 PHP 代码调用 JavaScript 函数,LSP 服务器能同时理解这两种语言的结构。

结语

开发一个 PHP LSP 插件,本质上是在为 PHP 语言“续命”。它把 PHP 从一个脚本语言提升到了一种“有结构、可预测”的开发范式。

虽然这过程充满了调试 JSON、解析 AST 和处理文件路径的痛苦,但当你第一次看到编辑器完美地补全了你还没敲完的方法,当你点击一个函数名,光标优雅地跳转到定义处,那种成就感是无与伦比的。

这就是技术的乐趣。别再忍受黑底白字的枯燥了,去写个 LSP 服务器吧,让你的编辑器动起来!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注