diff --git a/Cargo.lock b/Cargo.lock index 9d969dc760..61fbf4a652 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8236,6 +8236,7 @@ dependencies = [ "tree-sitter-json", "tree-sitter-kotlin-codanna", "tree-sitter-md", + "tree-sitter-php", "tree-sitter-python", "tree-sitter-regex", "tree-sitter-rust", @@ -19857,6 +19858,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-php" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-python" version = "0.25.0" diff --git a/Cargo.toml b/Cargo.toml index 6dc29cfbab..8468030c15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -771,6 +771,7 @@ tree-sitter-jsdoc = "0.23" tree-sitter-json = "0.24" tree-sitter-kotlin-codanna = "0.3.9" tree-sitter-md = { git = "https://github.com/tree-sitter-grammars/tree-sitter-markdown", rev = "9a23c1a96c0513d8fc6520972beedd419a973539" } +tree-sitter-php = "0.24.2" tree-sitter-python = "0.25" tree-sitter-regex = "0.24" tree-sitter-ruby = "0.23" diff --git a/RECAPS.md b/RECAPS.md index 23ffce01f2..8ad56eb4f0 100644 --- a/RECAPS.md +++ b/RECAPS.md @@ -6,6 +6,16 @@ Running log of completed work sessions, newest first. Each entry summarizes a co ## 2026-05-16 +### PHP language support (final of 4 bake-in PRs in this series) +- Added PHP as a built-in language: tree-sitter grammar registered, `.php` / `.phtml` files recognized, syntax highlighting + outline + indents + injections + runnables + debugger queries on, and `intelephense` driven for completion / hover / go-to-def — found on `$PATH` first, otherwise `npm install`ed into the language container dir. Brings the built-in count to 17 (assuming Swift/Kotlin/Java land first). +- Grammar: canonical `tree-sitter-php = "0.24.2"` (MIT). Same dev-dep-only `tree-sitter ^0.25` constraint as `tree-sitter-java` — resolves cleanly against workspace `tree-sitter 0.26`. Exposes `LANGUAGE_PHP` (mixed-mode PHP + inline HTML) and `LANGUAGE_PHP_ONLY` (pure PHP); registered the mixed-mode variant since `.php` files traditionally interleave HTML. +- Asset files: pulled from `zed-extensions/php` (MIT) into `crates/grammars/src/php/`. 11 query files: `brackets.scm`, `config.toml`, `debugger.scm`, `embedding.scm`, `highlights.scm`, `indents.scm`, `injections.scm`, `outline.scm`, `overrides.scm`, `runnables.scm`, `textobjects.scm`. **Zero patches needed** — like Java, the upstream queries are written against the same canonical grammar we registered. +- LSP adapter: new `crates/languages/src/php.rs` (~150 lines). Modeled directly on `typescript.rs` (single npm package version of the dual-package TS pattern). `check_if_user_installed` calls `delegate.which("intelephense")`; otherwise the adapter `npm_install_packages("intelephense", latest)` into `/node_modules/` and invokes `node /node_modules/intelephense/lib/intelephense.js --stdio`. **License note in adapter doc-comment**: intelephense is proprietary (not OSS); we don't redistribute it, `npm install` fetches it from npmjs.com at runtime. Users who prefer OSS alternatives can override via the `language_servers` setting (phpactor, phpls). +- Wired into `crates/languages/src/lib.rs::init`: new `mod php;`, `Arc::new(php::PhpLspAdapter::new(node.clone()))` (no `fs` dep needed, unlike typescript — single-package adapter doesn't do yarn-sdk detection), and a `LanguageInfo { name: "php", adapters: vec![php_lsp_adapter], manifest_name: Some("composer.json".into()), .. }` entry between `markdown-inline` and `python` (alphabetical). All four upstream-shaped edits tagged `// PaddleBoard:`. +- Verified: `cargo check -p grammars --features load-grammars` clean (9.8s, fresh `tree-sitter-php` compile); `cargo check -p paddleboard` clean (6.2s incremental); release build (17.97s) clean. Grammar load: zero query-loader errors in `PaddleBoard.log` after launch. NOT verified end-to-end: user didn't open `php-smoketest.php` during this session, so the `npm install` + intelephense spawn path wasn't exercised. Structurally identical to `typescript.rs` which is known to work; first reviewer can verify by opening any `.php` file (npm install + spawn should take ~10s). +- Preserved: every existing language adapter; the `crates/grammars/` alphabetical ordering convention; `typescript.rs`'s npm pattern unchanged (just simpler-mirrored, not refactored). +- Follow-ups: (1) live end-to-end smoke test pre-merge. (2) **OSS alternative adapter as an option** — `phpactor` is composer-distributed and a viable replacement for users avoiding intelephense's license. (3) Tailwind LSP cross-registration — the existing `tailwind_languages` list in `lib.rs::init` already includes "PHP" as a candidate language for the tailwind LSP, so once this PR lands, tailwind autocompletion in `.php` files Just Works without extra wiring. (4) Composer/PHPUnit context provider for test running. **Bake-in series complete after this PR**: Swift (#28) → Kotlin (#29) → Java (#30) → PHP. Count goes from 13 built-in languages to 17. + ### Java language support (PR 3 of 4 in the bake-in series) - Added Java as a built-in language: tree-sitter grammar registered, `.java` files recognized, syntax highlighting + outline + indents + folds + runnables on, and `jdtls` (Eclipse JDT Language Server) wired for completion / hover / go-to-def when present on `$PATH`. Brings the built-in count to 16 (assuming Swift + Kotlin land first). - Grammar: canonical `tree-sitter-java = "0.23.5"` (MIT). Initially looked like a version-conflict candidate — its dep manifest pins `tree-sitter ^0.24` — but that's a **dev-dependency only** (for its own tests). Runtime deps are `tree-sitter-language ^0.1` (provides `LanguageFn`) and `cc ^1.1` (builds parser.c), which resolve cleanly against workspace's `tree-sitter 0.26`. No alternative crate needed (unlike Kotlin where we needed the codanna fork). Rationale captured in a multi-line comment on the `native_grammars()` entry so the next reader doesn't repeat the "this won't resolve" diagnostic. diff --git a/crates/grammars/Cargo.toml b/crates/grammars/Cargo.toml index 308a0b0fbf..430460193a 100644 --- a/crates/grammars/Cargo.toml +++ b/crates/grammars/Cargo.toml @@ -32,6 +32,7 @@ tree-sitter-jsdoc = { workspace = true, optional = true } tree-sitter-json = { workspace = true, optional = true } tree-sitter-kotlin-codanna = { workspace = true, optional = true } tree-sitter-md = { workspace = true, optional = true } +tree-sitter-php = { workspace = true, optional = true } tree-sitter-python = { workspace = true, optional = true } tree-sitter-regex = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } @@ -56,6 +57,7 @@ load-grammars = [ "tree-sitter-json", "tree-sitter-kotlin-codanna", "tree-sitter-md", + "tree-sitter-php", "tree-sitter-python", "tree-sitter-regex", "tree-sitter-rust", diff --git a/crates/grammars/src/grammars.rs b/crates/grammars/src/grammars.rs index 3c7635f24f..dff2d5338a 100644 --- a/crates/grammars/src/grammars.rs +++ b/crates/grammars/src/grammars.rs @@ -45,6 +45,14 @@ pub fn native_grammars() -> Vec<(&'static str, tree_sitter::Language)> { ("kotlin", tree_sitter_kotlin_codanna::language()), ("markdown", tree_sitter_md::LANGUAGE.into()), ("markdown-inline", tree_sitter_md::INLINE_LANGUAGE.into()), + // PaddleBoard: PHP via canonical tree-sitter/tree-sitter-php (MIT). + // Like tree-sitter-java, its `tree-sitter ^0.25` dep is dev-only; + // runtime uses `tree-sitter-language ^0.1` which resolves cleanly + // against workspace `tree-sitter 0.26`. Uses `LANGUAGE_PHP` (the + // mixed-mode `.php` grammar) — there's also a `LANGUAGE_PHP_ONLY` + // for pure-PHP files but the standard `.php` registration uses + // the mixed-mode grammar that handles inline HTML. + ("php", tree_sitter_php::LANGUAGE_PHP.into()), ("python", tree_sitter_python::LANGUAGE.into()), ("regex", tree_sitter_regex::LANGUAGE.into()), ("rust", tree_sitter_rust::LANGUAGE.into()), diff --git a/crates/grammars/src/php/brackets.scm b/crates/grammars/src/php/brackets.scm new file mode 100644 index 0000000000..988602aa8d --- /dev/null +++ b/crates/grammars/src/php/brackets.scm @@ -0,0 +1,4 @@ +("{" @open "}" @close) +("(" @open ")" @close) +("[" @open "]" @close) +("\"" @open "\"" @close) diff --git a/crates/grammars/src/php/config.toml b/crates/grammars/src/php/config.toml new file mode 100644 index 0000000000..d60735e5a2 --- /dev/null +++ b/crates/grammars/src/php/config.toml @@ -0,0 +1,22 @@ +name = "PHP" +grammar = "php" +path_suffixes = ["php", "phtml"] +first_line_pattern = '^#!.*php' +line_comments = ["// ", "# "] +block_comment = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 } +documentation_comment = { start = "/**", end = "*/", prefix = "* ", tab_size = 1 } +autoclose_before = ";:.,=}])>" +brackets = [ + { start = "{", end = "}", close = true, newline = true }, + { start = "[", end = "]", close = true, newline = true }, + { start = "(", end = ")", close = true, newline = true }, + { start = "\"", end = "\"", close = true, newline = false, not_in = ["string"] }, + { start = "'", end = "'", close = true, newline = false, not_in = ["string"] }, + { start = "/*", end = " */", close = true, newline = false, not_in = ["comment", "string"] }, +] +collapsed_placeholder = "/* ... */" +scope_opt_in_language_servers = ["tailwindcss-language-server"] +prettier_parser_name = "php" +prettier_plugins = ["@prettier/plugin-php"] +completion_query_characters = ["$"] +word_characters = ["$"] diff --git a/crates/grammars/src/php/debugger.scm b/crates/grammars/src/php/debugger.scm new file mode 100644 index 0000000000..d6520afa8b --- /dev/null +++ b/crates/grammars/src/php/debugger.scm @@ -0,0 +1,28 @@ +; e.g. `$age = 25` matches `$age` +(expression_statement + (assignment_expression + left: (variable_name) @debug-variable + ) +) + +; e.g. `++$age` matches `$age` +(expression_statement + (update_expression + argument: (variable_name) @debug-variable + ) +) + +; e.g. `if ($age > 18)` matches `$age` +(binary_expression + left: (variable_name) @debug-variable +) + +; e.g. `if (18 < $age)` matches `$age` +(binary_expression + right: (variable_name) @debug-variable +) + +; e.g. `__construct(int $age)` matches `$age` +(simple_parameter + name: (variable_name) @debug-variable +) diff --git a/crates/grammars/src/php/embedding.scm b/crates/grammars/src/php/embedding.scm new file mode 100644 index 0000000000..db277775b3 --- /dev/null +++ b/crates/grammars/src/php/embedding.scm @@ -0,0 +1,36 @@ +( + (comment)* @context + . + [ + (function_definition + "function" @name + name: (_) @name + body: (_ + "{" @keep + "}" @keep) @collapse + ) + + (trait_declaration + "trait" @name + name: (_) @name) + + (method_declaration + "function" @name + name: (_) @name + body: (_ + "{" @keep + "}" @keep) @collapse + ) + + (interface_declaration + "interface" @name + name: (_) @name + ) + + (enum_declaration + "enum" @name + name: (_) @name + ) + + ] @item + ) diff --git a/crates/grammars/src/php/highlights.scm b/crates/grammars/src/php/highlights.scm new file mode 100644 index 0000000000..770be86ce1 --- /dev/null +++ b/crates/grammars/src/php/highlights.scm @@ -0,0 +1,235 @@ +(php_tag) @tag +(php_end_tag) @tag + +; Types + +(primitive_type) @type.builtin +(cast_type) @type.builtin +(named_type (name) @type) @type +(named_type (qualified_name) @type) @type + +; Named arguments (PHP 8+) + +(argument + name: (name) @variable.parameter) + +; Functions + +(array_creation_expression "array" @function.builtin) +(list_literal "list" @function.builtin) + +(method_declaration + name: (name) @function.method) + +(function_call_expression + function: [(qualified_name (name)) (name)] @function) + +(scoped_call_expression + name: (name) @function) + +(member_call_expression + name: (name) @function.method) + +(nullsafe_member_call_expression + name: (name) @function.method) + +(function_definition + name: (name) @function) + +; Member + +(property_element + (variable_name) @property) + +(member_access_expression + name: (variable_name (name)) @property) +(member_access_expression + name: (name) @property) +(nullsafe_member_access_expression + name: (variable_name (name)) @property) +(nullsafe_member_access_expression + name: (name) @property) + +; Class constant access (e.g., Class::CONSTANT) + +(class_constant_access_expression + (_) (name) @constant) + +; Special classes + +(relative_scope) @constructor + +((object_creation_expression (name) @constructor) + (#any-of? @constructor "self" "parent")) + +((binary_expression + operator: "instanceof" + right: (name) @constructor) + (#any-of? @constructor "self" "parent")) + +; Variables + +((name) @constructor + (#match? @constructor "^[A-Z]")) + +((name) @constant + (#match? @constant "^_?[A-Z][A-Z\\d_]+$")) +((name) @constant.builtin + (#match? @constant.builtin "^__[A-Z][A-Z\d_]+__$")) + +((name) @variable.builtin + (#eq? @variable.builtin "this")) + +(variable_name) @variable + +; Basic tokens +[ + (string) + (string_content) + (encapsed_string) + (heredoc) + (heredoc_body) + (nowdoc_body) +] @string +(boolean) @constant.builtin +(null) @constant.builtin +(integer) @number +(float) @number +(comment) @comment + +; Operators + +[ + "=" + "+=" + "-=" + "*=" + "/=" + "%=" + "**=" + ".=" + "??=" + "&=" + "|=" + "^=" + "<<=" + ">>=" + + "+" + "-" + "*" + "/" + "%" + "**" + "." + + "==" + "!=" + "===" + "!==" + "<" + ">" + "<=" + ">=" + "<=>" + + "&&" + "||" + "!" + + "??" + "?" + ":" + + "&" + "|" + "^" + "~" + "<<" + ">>" + + "++" + "--" + + "@" + + "$" +] @operator + +; punctuation + +[ + "(" + ")" + "[" + "]" + "{" + "}" +] @punctuation.bracket + +; "$" @punctuation.special + +; Keywords + +"abstract" @keyword +"and" @keyword +"as" @keyword +"break" @keyword +"case" @keyword +"catch" @keyword +"class" @keyword +"clone" @keyword +"const" @keyword +"continue" @keyword +"declare" @keyword +"default" @keyword +"do" @keyword +"echo" @keyword +"else" @keyword +"elseif" @keyword +"enum" @keyword +"enddeclare" @keyword +"endfor" @keyword +"endforeach" @keyword +"endif" @keyword +"endswitch" @keyword +"endwhile" @keyword +"extends" @keyword +"final" @keyword +"readonly" @keyword +"finally" @keyword +"for" @keyword +"foreach" @keyword +"fn" @keyword +"function" @keyword +"global" @keyword +"goto" @keyword +"if" @keyword +"implements" @keyword +"include_once" @keyword +"include" @keyword +"instanceof" @keyword +"insteadof" @keyword +"interface" @keyword +"match" @keyword +"namespace" @keyword +"new" @keyword +"or" @keyword +"print" @keyword +"private" @keyword +"protected" @keyword +"public" @keyword +"readonly" @keyword +"require_once" @keyword +"require" @keyword +"return" @keyword +"static" @keyword +"switch" @keyword +"throw" @keyword +"trait" @keyword +"try" @keyword +"use" @keyword +"while" @keyword +"xor" @keyword +"yield" @keyword +"yield from" @keyword diff --git a/crates/grammars/src/php/indents.scm b/crates/grammars/src/php/indents.scm new file mode 100644 index 0000000000..e975469092 --- /dev/null +++ b/crates/grammars/src/php/indents.scm @@ -0,0 +1 @@ +(_ "{" "}" @end) @indent diff --git a/crates/grammars/src/php/injections.scm b/crates/grammars/src/php/injections.scm new file mode 100644 index 0000000000..c5f63f736f --- /dev/null +++ b/crates/grammars/src/php/injections.scm @@ -0,0 +1,14 @@ +((text) @injection.content + (#set! injection.language "html") + (#set! injection.combined)) + +((comment) @injection.content + (#match? @injection.content "^/\\*\\*[^*]") + (#set! injection.language "phpdoc")) + +((comment) @injection.content + (#set! injection.language "comment")) + +((heredoc_body) (heredoc_end) @injection.language) @injection.content + +((nowdoc_body) (heredoc_end) @injection.language) @injection.content diff --git a/crates/grammars/src/php/outline.scm b/crates/grammars/src/php/outline.scm new file mode 100644 index 0000000000..5adc0b8f99 --- /dev/null +++ b/crates/grammars/src/php/outline.scm @@ -0,0 +1,52 @@ +(class_declaration + "class" @context + name: (name) @name + ) @item + +(function_definition + "function" @context + name: (_) @name + ) @item + +(method_declaration + "function" @context + name: (_) @name + ) @item + +(interface_declaration + "interface" @context + name: (_) @name + ) @item + +(enum_declaration + "enum" @context + name: (_) @name + ) @item + +(trait_declaration + "trait" @context + name: (_) @name + ) @item + +; Add support for Pest runnable +(function_call_expression + function: (_) @context + (#any-of? @context "it" "test" "describe") + arguments: (arguments + . + (argument + [ + (encapsed_string (string_content) @name) + (string (string_content) @name) + ] + ) + ) +) @item + +(comment) @annotation + +(property_declaration + (property_element + name: (variable_name) @name + ) +) @item diff --git a/crates/grammars/src/php/overrides.scm b/crates/grammars/src/php/overrides.scm new file mode 100644 index 0000000000..81b8c882d2 --- /dev/null +++ b/crates/grammars/src/php/overrides.scm @@ -0,0 +1,6 @@ +(comment) @comment.inclusive + +[ + (string) + (encapsed_string) +] @string diff --git a/crates/grammars/src/php/runnables.scm b/crates/grammars/src/php/runnables.scm new file mode 100644 index 0000000000..183cf882c1 --- /dev/null +++ b/crates/grammars/src/php/runnables.scm @@ -0,0 +1,109 @@ +; Class that follow the naming convention of PHPUnit test classes +; and that doesn't have the abstract modifier +; and have a method that follow the naming convention of PHPUnit test methods +; and the method is public +( + (class_declaration + (_)* @_modifier + (#not-any-eq? @_modifier "abstract") + . + name: (_) @_name + (#match? @_name ".*Test$") + body: (declaration_list + (method_declaration + (visibility_modifier)? @_visibility + (#eq? @_visibility "public") + name: (_) @run + (#match? @run "^test.*") + ) + ) + ) @_phpunit-test + (#set! tag phpunit-test) +) + +; Class that follow the naming convention of PHPUnit test classes +; and that doesn't have the abstract modifier +; and have a method that has the @test annotation +; and the method is public +( + (class_declaration + (_)* @_modifier + (#not-any-eq? @_modifier "abstract") + . + name: (_) @_name + (#match? @_name ".*Test$") + body: (declaration_list + ((comment) @_comment + (#match? @_comment ".*@test\\b.*") + . + (method_declaration + (visibility_modifier)? @_visibility + (#eq? @_visibility "public") + name: (_) @run + (#not-match? @run "^test.*") + )) + ) + ) @_phpunit-test + (#set! tag phpunit-test) +) + +; Class that follow the naming convention of PHPUnit test classes +; and that doesn't have the abstract modifier +; and have a method that has the #[Test] attribute +; and the method is public +( + (class_declaration + (_)* @_modifier + (#not-any-eq? @_modifier "abstract") + . + name: (_) @_name + (#match? @_name ".*Test$") + body: (declaration_list + (method_declaration + (attribute_list + (attribute_group + (attribute (name) @_attribute) + ) + ) + (#eq? @_attribute "Test") + (visibility_modifier)? @_visibility + (#eq? @_visibility "public") + name: (_) @run + (#not-match? @run "^test.*") + ) + ) + ) @_phpunit-test + (#set! tag phpunit-test) +) + +; Class that follow the naming convention of PHPUnit test classes +; and that doesn't have the abstract modifier +( + (class_declaration + (_)* @_modifier + (#not-any-eq? @_modifier "abstract") + . + name: (_) @run + (#match? @run ".*Test$") + ) @_phpunit-test + (#set! tag phpunit-test) +) + +; Add support for Pest runnable +; Function expression that has `it`, `test` or `describe` as the function name +( + (function_call_expression + function: (_) @_name + (#any-of? @_name "it" "test" "describe") + arguments: (arguments + . + (argument + [ + (encapsed_string (string_content) @run) + (string (string_content) @run) + ] + ) + ) + ) @_pest-test + (#set! tag pest-test) +) diff --git a/crates/grammars/src/php/textobjects.scm b/crates/grammars/src/php/textobjects.scm new file mode 100644 index 0000000000..d86a0c1252 --- /dev/null +++ b/crates/grammars/src/php/textobjects.scm @@ -0,0 +1,45 @@ +(function_definition + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +(method_declaration + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +(method_declaration) @function.around + +(class_declaration + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(interface_declaration + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(trait_declaration + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(enum_declaration + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(namespace_definition + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(comment)+ @comment.around diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 716d3eee77..24f155d6c6 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -29,6 +29,8 @@ mod json; // from fwcd/kotlin-language-server GitHub releases). mod kotlin; mod package_json; +// PaddleBoard: PHP language support (intelephense via node_runtime). +mod php; mod python; mod rust; // PaddleBoard: Swift language support (sourcekit-lsp on $PATH). @@ -79,6 +81,10 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime // PaddleBoard: Kotlin adapter — $PATH lookup first, then download // `server.zip` from fwcd/kotlin-language-server GitHub releases. let kotlin_lsp_adapter = Arc::new(kotlin::KotlinLspAdapter); + // PaddleBoard: PHP adapter — `intelephense` npm package. License is + // proprietary (free tier); users who want OSS can override via the + // `language_servers` setting (phpactor/phpls). + let php_lsp_adapter = Arc::new(php::PhpLspAdapter::new(node.clone())); let py_lsp_adapter = Arc::new(python::PyLspAdapter::new()); let ty_lsp_adapter = Arc::new(python::TyLspAdapter::new(fs.clone())); let python_context_provider = Arc::new(python::PythonContextProvider); @@ -183,6 +189,14 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime adapters: vec![], ..Default::default() }, + // PaddleBoard: PHP entry. `composer.json` is the manifest + // intelephense indexes against to discover package roots. + LanguageInfo { + name: "php", + adapters: vec![php_lsp_adapter], + manifest_name: Some(SharedString::new_static("composer.json").into()), + ..Default::default() + }, LanguageInfo { name: "python", adapters: vec![ diff --git a/crates/languages/src/php.rs b/crates/languages/src/php.rs new file mode 100644 index 0000000000..8d093527e4 --- /dev/null +++ b/crates/languages/src/php.rs @@ -0,0 +1,159 @@ +// PaddleBoard: PHP language adapter. +// +// Uses `intelephense` (https://intelephense.com), distributed as a +// regular npm package. The free tier is plenty for editor features +// (completion, hover, go-to-def, diagnostics); paid features (rename, +// code actions across files) require a license key the user sets via +// `lsp.intelephense.initializationOptions.licenceKey` in settings. +// +// **License note.** intelephense is proprietary, not OSS. We don't +// redistribute it — `npm_install_packages` fetches it from npmjs.com at +// runtime. Users who prefer open-source alternatives can replace it via +// the `language_servers` override in their settings (e.g. point at +// phpactor or phpls). +// +// Lifecycle mirrors `typescript.rs`: +// 1. `check_if_user_installed` looks for `intelephense` on $PATH +// (Homebrew / scoop / system npm-global installs). +// 2. Otherwise the adapter `npm install`s the latest `intelephense` +// into `/node_modules/` and invokes +// `node /node_modules/intelephense/lib/intelephense.js --stdio`. + +use anyhow::Result; +use async_trait::async_trait; +use gpui::AsyncApp; +pub use language::*; +use language::{LspAdapterDelegate, LspInstaller, Toolchain}; +use lsp::{LanguageServerBinary, LanguageServerName}; +use node_runtime::{NodeRuntime, VersionStrategy}; +use semver::Version; +use smol::fs; +use std::{ + ffi::OsString, + path::{Path, PathBuf}, +}; +use util::{ResultExt, maybe}; + +pub struct PhpLspAdapter { + node: NodeRuntime, +} + +impl PhpLspAdapter { + const PACKAGE_NAME: &'static str = "intelephense"; + const SERVER_PATH: &'static str = "node_modules/intelephense/lib/intelephense.js"; + const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("intelephense"); + + pub fn new(node: NodeRuntime) -> Self { + Self { node } + } +} + +fn intelephense_server_binary_arguments(server_path: &Path) -> Vec { + vec![server_path.into(), "--stdio".into()] +} + +impl LspInstaller for PhpLspAdapter { + type BinaryVersion = Version; + + async fn fetch_latest_server_version( + &self, + _: &dyn LspAdapterDelegate, + _: bool, + _: &mut AsyncApp, + ) -> Result { + self.node.npm_package_latest_version(Self::PACKAGE_NAME).await + } + + async fn check_if_user_installed( + &self, + delegate: &dyn LspAdapterDelegate, + _: Option, + _: &AsyncApp, + ) -> Option { + let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; + // intelephense installed via `npm install -g intelephense` ends + // up as an executable shim that already includes `--stdio` + // behavior when invoked without arguments — but to match the + // download-path invocation, we still pass `--stdio` explicitly + // so the launch shape is identical regardless of install source. + Some(LanguageServerBinary { + path, + arguments: vec!["--stdio".into()], + env: None, + }) + } + + async fn check_if_version_installed( + &self, + version: &Version, + container_dir: &PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { + let server_path = container_dir.join(Self::SERVER_PATH); + if self + .node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + container_dir, + VersionStrategy::Latest(version), + ) + .await + { + return None; + } + Some(LanguageServerBinary { + path: self.node.binary_path().await.ok()?, + env: None, + arguments: intelephense_server_binary_arguments(&server_path), + }) + } + + async fn fetch_server_binary( + &self, + latest_version: Version, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Result { + let server_path = container_dir.join(Self::SERVER_PATH); + self.node + .npm_install_packages( + &container_dir, + &[(Self::PACKAGE_NAME, &latest_version.to_string())], + ) + .await?; + Ok(LanguageServerBinary { + path: self.node.binary_path().await?, + env: None, + arguments: intelephense_server_binary_arguments(&server_path), + }) + } + + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { + maybe!(async { + let server_path = container_dir.join(Self::SERVER_PATH); + anyhow::ensure!( + fs::metadata(&server_path).await.is_ok(), + "missing intelephense binary at {server_path:?}" + ); + Ok(LanguageServerBinary { + path: self.node.binary_path().await?, + env: None, + arguments: intelephense_server_binary_arguments(&server_path), + }) + }) + .await + .log_err() + } +} + +#[async_trait(?Send)] +impl LspAdapter for PhpLspAdapter { + fn name(&self) -> LanguageServerName { + Self::SERVER_NAME + } +}