# phpls-netbeans **Repository Path**: web3d/phpls-netbeans ## Basic Information - **Project Name**: phpls-netbeans - **Description**: php lsp server based on netbeans code base and runtime - **Primary Language**: Unknown - **License**: Apache-2.0 - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-01 - **Last Updated**: 2026-09-05 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # phpls-netbeans 基于 NetBeans PHP 引擎(headless)的自研 PHP LSP Server,专为禅道(Zentao)这类 重度动态模式的 PHP 代码库设计:`@property`/`@method` 注解感知、跨文件继承解析、 链式调用方法定位、`__call` 魔法分发理解(L1 规则推断 + L2 调用点收割 + L3 运行时验证)。 ## 目录结构 ``` server/src/ 服务端源码(PhpLspServer + PhpWorkspace + Fake* SPI) server/resources/ php.editor 层文件 + SPI services 注册 server/test/ 回归用例:E2E 客户端(真实工作区)+ 声明式夹具套件(fixtures / cases.json / regress.py) server/deps.txt NetBeans jar 依赖清单 extension/ Qoder/VS Code 扩展(vscode-languageclient,stdio 拉起离散 jar) scripts/build.sh 构建(增量 ~5s):编译 → dist/phpls-server.jar(1.5M)+ dist/lib/*.jar(依赖, 按 deps.txt 哈希缓存同步);运行时 classpath = server.jar : lib/* scripts/build-split.sh 快速构建(~9s):瘦启动 jar,Class-Path 就地引用 NetBeans dist 的依赖(上游重编译即自动生效); PORTABLE=1 时改为硬链接到 dist/lib/ 的自包含发布目录 scripts/regress.sh 回归:A 套件(真实工作区 E2E 场景)+ B 套件(夹具声明式用例) scripts/runtime-classpath.sh 运行时 classpath 的唯一来源(把 Nashorn 钉在携同 FQCN 的 GraalJS jar 之前;通配符序由文件系统定,错一个赢家整套索引回滚) scripts/kate.sh Kate LSP client 拉起的包装脚本(%宏 → 工作区根 → 同一个 JVM) scripts/install-kate-config.sh 把 kate-settings.json 模板装进 Kate 的用户配置 tools/ L3 运行时学习工具(探针注入 + 日志收割) var/ 运行态数据(索引缓存、运行时分发记录) UPSTREAM.md NetBeans 版本锚点与升级流程 ``` ## 支持的 LSP 能力 | 能力 | 覆盖 | |---|---| | `textDocument/hover` | `$this->prop`(@property 含继承)、`$this->method()`(含跨文件多级继承、含同文件声明)、`CLASS::` 成员(方法 + 类常量)、类词(含内置类,全局命名空间优先)、项目全局函数(如 `zget`,声明行+docblock 首句)、同名函数/类按位形判别(`cookie(` 是函数,`Cookie::`/`new Cookie(`/`#[Cookie(` 是类)、ext/model 扩展方法(如 `feishuapi`)、全局常量(显示 define() 原行)、链式方法(含方法调用返回值推断、含 ext/model 链、含全局函数调用接收者 `load()->classs()`)、局部变量(含 `catch (Exception $e)` 内置类成员)、属性自身 docblock 里的 `$prop` 标记(按名匹配所属类字段,标 class property 而非局部变量)、魔法分发(含 L2 调用点计数与 L3 运行时验证) | | `textDocument/definition` | 字段**声明处优先**(父类 @property/属性声明行)+ 类型定义、类定义、项目全局函数声明行(调用位优先函数,见 hover 行)、全局常量定义(`TABLE_*` 跳 config/zentaopms.php)、类常量 `X::CONST` 声明行、方法声明(含继承自祖父类、含同文件声明)、`parent::` 跳父类、魔法分发跳 `__call`、普通局部变量跳**最近的绑定赋值**(`$x = …` / `foreach (… as $x)`,同块内取光标之前最后一次;类型定义留给 typeDefinition)、类体内的属性标记(`@var Type $prop`)跳属性声明行 | | `textDocument/completion` | `$this->` 列全部字段 + 继承方法;`$this->prop->` 列声明方法 + L2 收割的魔法成员;任意 `CLASS::` 列方法 + 类常量 + 静态字段;`(new X)->`、`M('table')->`、`load()->`(全局函数调用)接收者成员补全(含链中字段段);裸词前缀兜底(类名/全局函数/常量,覆盖 `new `、`extends`、`TABLE_` 输入) | | `textDocument/references` | 索引级:类/方法/常量/函数全库使用点(identifier_used 候选文件 + 词边界扫描),含声明处;**普通变量/参数走作用域裁剪**:只回同一文件内绑定它的那个块(函数体/类体各自成域),`global $x` 是与文件级共享存储的唯一跨体别名 | | `textDocument/documentSymbol` | 两级大纲:类 → 方法/字段(过滤 @method/@property 合成项),顶层函数与全局常量 | | `workspace/symbol` | 类 + 全局函数按前缀查询 | | `textDocument/signatureHelp` | 链式/`$this->`/`CLASS::`/全局函数调用的参数签名 + activeParameter | | `textDocument/rename` + `prepareRename` | 全局标识符(类/常量/函数/方法/字段名)跨文件改名,复用 references 收集链;变量与参数改名同 references 受作用域裁剪(只动绑定块内的 `$name`,字符串/注释内的提及不动);索引查无此词或命中超 200 处时拒绝(防盲改/半改,普通变量位不查索引故不受拒) | | `textDocument/implementation` | 类词的子类反查(类索引 superTypes 段全量扫描) | | `textDocument/typeDefinition` | 表达式类型解析:`$this->prop` 字段类型类、链式/静态成员按 `@return` 推断类、裸类词即定义 | | `textDocument/semanticTokens/full` | 词法级语义高亮:关键字/字符串/数字/变量 + 上下文分类的标识符(function/method/class/property);注释被 scanner 吞,留给 TextMate 层 | | `publishDiagnostics` | 解析级语法错误(didOpen/didChange 后推送,修复后自动清空) | | 文档同步 | Full 模式内存缓冲:didOpen/didChange 未保存内容即时参与全部请求(缓冲镜像挂禅道源根,跨文件继承/@property 不降级) | | **链式接收者形态** | `->` 左侧的接收者只认四种形状(`applyCallReceiver`):`$this->…`、`$localVar->…`(类型由赋值推断)、`(new X)->…`、调用表达式(`M('t')`/`D('M:t')` 模型工厂与**全局函数调用** `load()->classs('wesession')`,后者取函数声明的 `@return`);hover/definition/typeDefinition/signatureHelp 共用同一接收者解析链(`resolveReceiverHandle`);completion 因需携带已输入前缀另有一套同形模式(`CALL_FN_COMPLETION`) | | **方法调用返回值推断** | 链中方法调用段按 `@return` 推断接收者:`$this`/`self`/`static`(fluent 构建器,如 `$this->dao->select(...)->from(...)`)保持接收类;显式类名直接解析;禅道约定 `loadModel('x')` → `xModel`;无 `@return` 时回退扫描方法体的裸返回(`return $this;` 保持接收类、唯一 `return new X` 前进到 X,多个不同类返回视为不确定) | | **局部变量类型推断** | `$var = expr` 赋值扫描,窗口 = 光标所在块(方法体/函数体),块判不出来(文件作用域、类体)时**只认不属于任何函数/方法的赋值**(否则 install.php 那种函数内 `static $res = array(<14k blob>)` 会被当成文件作用域 `$res` 的值);生效绑定 = 窗口内光标之前最近一次,之前没有则取窗口内第一次;类型来源:`$this->` 链(含 loadModel 约定)、`new Class`、`M('t')`/`D('M:t')` 模型工厂、**全局函数调用取声明的 `@return`**(`$ctrl = A($ctrlName)` → `\Think\Controller`;`@return` 里的 `array`/`string`/`mixed` 等标量名一律不认,内置函数不在项目函数索引里所以 `json_decode()` 之类也不会误判)、**静态调用 `Class::method()`**(`$pagingObj = PolicyUtil::getPagingObj(...)` → `\Common\PagingVO`;`self::`/`static::` 按所在类解、`parent::` 按父类解,`@return self` 回到调用所经过的那个类;只认项目类,`DateTime::createFromFormat()` 这类内置类不参与)、`$alias` 别名跳(≤5 跳)、带类型参数兜底;接入 hover/definition/completion/typeDefinition/signatureHelp | | **PHP 内置函数库** | phpsigfiles stub(与 NetBeans 同源):3777 个内置函数 + 705 个内置类的签名/PHPDoc 首句;接入裸词 hover、definition(跳 stub 声明行)、裸前缀补全、裸调用 signatureHelp;`die`/`exit`/`echo` 等 13 个语言构造单独建表 | ## 快速使用 ```bash scripts/build.sh # 构建(需 JAVA_HOME 指向 JDK 21) scripts/regress.sh # 回归:真实工作区 E2E + 夹具套件 python3 server/test/regress.py --only 'hover-param.*' # 只跑夹具套件的子集 ``` ## Kate 接入(LSP 客户端) ```bash scripts/build.sh # 先产出 dist/phpls-server.jar + dist/lib/ scripts/install-kate-config.sh # 把 php 条目装进 Kate 的 LSP 配置 ``` 安装脚本(`--dry-run` / `--check` / `--print` / `--uninstall`)做四件事:把模板 `scripts/kate-settings.json` 里的 `__PHPLS_ROOT__` 换成本仓库的绝对路径;与本机已有的 `~/.config/kate/lspclient/settings.json` **合并**——只覆盖 `servers.php`,其它 server 条目 和顶层键原样保留(katerc 的 `[lspclient] ServerConfiguration` 若设了,就写它指定的路径); 覆盖前存 `settings.json.bak.<时间戳>`;补 `scripts/kate.sh` 的可执行位(Kate 用 `findExecutable` 解析绝对路径,缺执行位就静默不启动)。目标文件里已有坏 JSON 时直接拒绝写入 ——Kate 自己也只会把它丢进消息视图然后什么都不起。 三个必须知道的时序,缺一个就会被误判成"配置没生效": 1. **改完配置要重启 Kate**——插件只在 `readConfig()` 里读这个文件(在 GUI 里保存一次 LSP Client ▸ User Server Settings 等效)。 2. **第一次启动弹授权框**——"Do you want the LSP server to be started?",选择持久化在 katerc 的 `AllowedServerCommandLines`;命令行变了会再问一次。 3. **冷启动要等全量建索引(真实禅道工程约 30s,大工程更久)**——Kate 对 `initialize` 不设超时,慢只表现为"什么都没有"。 `scripts/kate.sh` 是 Kate 拉起的包装脚本:Kate 只能给 "程序 + argv",而服务端只认 argv[0] 作工作区根(忽略 initialize 的 rootUri),所以由它把 `%{Project:NativePath}` / `%{Document:FilePath}` 宏丢掉空值和未展开的 `%{...}`、沿最近带 root marker 的祖先定位根, 再按每个工作区独立的 `netbeans.user` 起同一个 JVM(两个窗口共用一个索引目录会互相踩缓存)。 旋钮:`PHPLS_MODE`(jar|classpath)、`PHPLS_ROOT`、`PHPLS_DEFAULT_ROOT`、`PHPLS_ROOT_MARKERS`、 `PHPLS_EXTRA_ROOTS`、`PHPLS_JAVA`、`PHPLS_DEBUG=1`。 排障顺序固定:`LSPCLIENT_DEBUG=1 kate` 让 Kate 改走条目里的 `commandDebug`(带 `--debug`, wrapper 把解析出的 root / userdir / 完整命令行打到 stderr)→ LSP Client 工具视图 `Show Messages` 看 stderr 与协议消息 → `Restart LSP Server` → 最后才怀疑服务端。 配置为什么长这样、每个键的语义与上游源码行号,见 `docs/kate-lsp-config-analysis.md`。 ## 回归夹具约定(强制) 用户反馈的每一个解析缺陷(hover/definition/references/… 判错或无提示),修完必须在 `server/test/` 留下用例,否则不算修完: 1. 场景写进 `server/test/fixtures/*.php`(已有文件优先复用,语义不相关才新建); 文件头注释记录 origins(哪些用户反馈/提交由它覆盖)。 2. 断言写进 `server/test/cases.json`:用正则 `find` + 字面 `at` 定位光标(不写死行列号, 夹具可自由增删行),`origin` 必须写明来源提交或反馈日期。 3. 尚未支持的能力用 `"xfail": "原因"` 显式挂起——记录缺口但不阻塞回归。 4. `expect` 的各键是**互斥优先级**(`absent` > `nonEmpty` > `contains`/`notContains` > …), 但 `contains` 与 `notContains` 现在**同时判定**(2026-09-02 修正:旧实现在 `contains` 命中后就 return,导致 16 条用例里的 `notContains` 守卫全部空转)。 5. `server/test/regress.py` 只索引 fixtures 目录(独立 userdir `var/nb-user-fixtures`), 秒级完成、不污染任何真实工作区索引。 编辑器接入:扩展已注册到 Qoder(`local.phpls-netbeans-0.3.0`),打开任意 `.php` 文件自动激活;开关为设置项 `phplsNetbeans.enabled`,日志在输出面板 "PHP LSP (NetBeans)"。请求处理报错时,日志会在异常信息后输出前 12 帧 + 最多 8 帧 `[ours]`(`PhpLspServer`/`PhpWorkspace`)调用链(`stackBrief`)——客户端只上报 `Range [2680, 2679)` 这类消息,而库帧(如正则引擎)会先把栈截满,没有自家帧就无法定位。 复现时加 `JAVA_TOOL_OPTIONS=-XX:MaxJavaStackTraceDepth=20000`,否则 JDK 默认只留 16 帧。 位置参数不可信任:客户端会发送超出行尾的列(past-the-end column),映射出的偏移会落在 换行符上,行切片必须先减一。 ## 文本扫描正则约定(强制) 扫描器跑在**全文件**上,而 Java 正则的重复分两类:贪心迭代节点(`[^;]*`)成本为常数栈, 而 **lazy loop(`*?`)与含交替的嵌套量化器每重复一次就递归一层**,实测 ≈1640 字符即 `StackOverflowError`(一个 base64 logo 字段就超 14k)。因此: 1. 扫描全文的正则只能有**单一字符类的贪心量化器**(`[^;]*`、`[^()]*`); 需要“跳空白直到分隔符”时,用 `\S[^;]*` 开头 + `[^;]*` 循环,绝不用 `(?:[^;\n]|\s)*?`。 2. 确实需要嵌套结构(如 `$this->m(a(b))` 的 `ARG_INNER`)时,**先量输入长度**: `PhpWorkspace.ARG_SCAN_LIMIT = 1200` + `argScannable()` 在超限时直接不扫(宁缺不崩); 调用点窗口 `PhpLspServer.CHAIN_WINDOW = 600` 必须远低于该上限。 3. 改结构绕不过去:实测把 `[^()]` 改成 `[^()]+` 或改所有格,在同样的 blob 上**挂死 >5 分钟** (指数回溯)。上限才是正解,不是“更聪明的正则”。 4. 正则改写必须取证等价:`build/probe/RegexBomb.java --corpus <文件...>` 逐匹配比对 (旧/新模式的结果与耗时),本轮 955 组真实文件×变量名比对 0 差异。 ## L3 运行时学习(可选) ```bash python3 tools/inject_probe.py inject # 给 baseDAO::__call 打日志探针 # ... 正常使用禅道一段时间,产生真实分发记录 ... python3 tools/harvest_dispatch_log.py # 聚合到 var/magic-dispatch.tsv python3 tools/inject_probe.py remove # 移除探针 ``` 收割结果在下次服务启动后自动回灌:魔法分发 hover 会显示 "✅ 运行时验证:该分发在真实执行中触发 N 次"。 ## 已知边界 - 方法名为运行时变量(`$dao->$var()`)不可静态判定(不可判定问题) - 静态工厂**作为链式接收者**尚未支持:`Foo::factory()->member()` 与 `new X()->member()`(PHP 8.4 无括号写法)仍是无提示(`CALL_FN_RECEIVER` 的回看显式排除 `::` 前缀与 `new ` 前缀,宁缺不误);先落个变量 `$x = Foo::factory()` 就能推(见上表“局部变量类型推断”行);内置函数的 `@return` 不参与链推断(只查项目函数索引),内置类的静态调用也不参与局部变量类型推断 - references/rename 为索引级:同名跨类方法含噪;变量位已按作用域裁剪,但**闭包内同名遮蔽不区分**(模型只暴露顶层函数与方法,`function ($x) {}` 落在外层块内,仍算外层变量),属性声明名(`public $x`)与 `self::$x` 仍走全局标识符扫描;字符串/注释内的变量提及不计入;不含局部变量的跨 include 全局别名推断。diagnostics 只报解析级语法错误,不含语义 hints;workspaceSymbol 只覆盖类与全局函数(不含内置库);semanticTokens 不含注释且标识符分类为词法启发式(无符号解析);局部变量推断不做流敏感分析(条件分支内的赋值仍视为生效),但 hover 与 definition 同口径:窗口内光标之前最近一次绑定胜出;内置函数优先级永远低于项目符号(同名时项目胜出;但调用位 `name(` 在 PHP 里不可能是类,故按函数解析);尚未实现 callHierarchy - 索引缓存首次构建约 2 分钟(冷启动),热启动约 4-13 秒