User:ArielGlenn/Emacs as a PHP IDE

From mediawiki.org

Last updated: 2020-10-19

I love my emacs. I like its multiple buffers and its key bindings and its split windows and all the rest. I don't love grep -r in the mediawiki git repo.

What's to be done? There's no open source PHP IDE that lets one use emacs as the editor in some sub-window, to my knowledge. How about closed source? Nope.

I looked at docs for PHPStorm, CodeLite, Atom, Aptana, and others. No joy. Enter LSP to save the day! Drumroll please...

Emacs with LSP step by step[edit]

Emacs as a PHP IDE in terminal window, with treemacs menu and lsp-ui-imenu on right, and docs popup

The TL;DR:

  • build emacs 28-pre with native compilation and with a C json library
  • choose an LSP backend and install it
  • install the LSP-related emacs packages from elpa
  • install a few other elpa packages to round out the IDE experience
  • configure the packages and the use of the specific LSP backend
  • curse when it doesn't work until you determine that PEBCAK is to blame
  • tweak and enjoy

We'll go through each of these in detail.

What I wanted from a PHP IDE[edit]

There are really only four capabilities that I had to have:

  1. code navigation: moving to the code defining a class or method, from some other code using it
  2. docs on hover: display of the arguments and any related docs for a class or method, when the cursor is on a reference to it
  3. completion: variable, method and class name completion when coding
  4. speed: waiting for project indexing on first use is ok, but not afterwards.

No refactoring, no extra debugging features, none of that. Just the basics. Remember, my ide until now had been grep -r.

LSP, the game-changer[edit]

Language Server Protocol is a Microsoft specification for a protocol intended for use by a stand-alone server that responds to well-defined requests (where is the definition of this method? Where is this class used?) with the information requested in a specific format. Any number of clients may communicate with the server as long as they support the protocol. If your favorite editor supports it, and there's a server you like for your language, you can use it.

In other words, instead of having to build all of the capabilities we want into emacs or any other editor, a separate PHP language server can do the heavy lifting for emacs or any other editor that supports LSP from the client end.

I guess that most IDEs do not use this approach; they directly implement every feature that is desired. That's fine for IDEs with a large and demanding user base. But for just the basics, LSP is a fine choice. For more incindiary rhetoric on this topic, see the relevant HackerNews thread.

Building emacs[edit]

First things first; we need json handled by a C library: libjansson. Emacs 27 supports this, but I had only emacs 26.

We also would like native compilation, also known as gccemacs. This provides support for compiling el files into binary (elf files)! This feature is not available in Emacs 27, but may be merged into master "soon" with an eye to making it available in emacs 28. For now, it's in a separate branch. Lots more information about gcc emacs is available on the updates page for the project.

I built emacs in the following environment:

  • OS: Fedora 31, nearly two versions old
  • GCC: 9.3.1 but GCC 10 will work as well

I did NOT build with mailutils support. If you want that, adjust the below accordingly.

Steps to build for installation in /usr/local:

  • git clone https://git.savannah.gnu.org/git/emacs.git
  • git checkout -b native-comp origin/feature/native-comp
  • dnf install libgccjit libgccjit-devel jansson jansson-devel
  • dnf install webkit2gtk3 webkit2gtk3-devel libXpm-devel giflib-devel
  • install any other devel packages you'll need, or wait until configure complains about them
  • cd emacs
  • ./autogen.sh
  • ./configure --with-json --with-nativecomp --with-xwidgets --with-x-toolkit=gtk3
  • make check
  • as root, make install

If you have any packages in your .emacs.d, update them now. I used development packages where I could.

Test out your new emacs binary. The first time you run it, it will compile all of those packages. If you try to exit out before it's done, it will ask you whether you really want to interrupt those running compiles. You'll encounter this any time you install or upgrade a package. If you're curious about the output, have a look in .emacs.d/eln-cache.

You'll want to add

(setq comp-deferred-compilation t)

near the top of your init file, so that you can in fact get on with other editing while compilation goes on in the background.

Selecting an LSP backend[edit]

There are a few free as in beer PHP LSP servers, most of which are also free software. I looked at several of them: Intelephense, Serenata, Felix Becker's php language server. I did not try out the Tenkawa PHP Language Server or Psalm. Have a look at the features, how active the project is, and the license if you are a FLOSS supporter.

In the end, I went for Serenata, as it is currently maintained, open source, and has the features I needed. After setting up lsp-mode (see below), I double-checked the enabled features list in emacs for each server, and Serenata was the winner.

Installing Serenata is easy enough:

  • download the PHAR file corresponding to your version of PHP from the list of releases
  • put it in a location that doesn't trigger your OCD
  • make sure it is executable; if you don't, you will spend hours wondering why emacs cannot find it and offers to download intelephense

Installing emacs packages for LSP[edit]

Emacs as a PHP IDE in its own window, with treemacs menu and lsp-ui-imenu on right, and docs popup

You may decide to use some other combination of things, but you will want at a minimum:

  • php-mode (duh!)
  • lsp-mode
  • lsp-ui-mode (highly recommended)
  • company (for lsp-mode)
  • flycheck (for lsp-mode)
  • treemacs and lsp-treemacs (for lsp-mode)
  • which-key (for lsp-mode)
  • xref (for lsp-mode and lsp-ui-mode)
  • yasnippet (for lsp-mode)

I use treemacs for code navigation of the project as well as for display of trees via lsp. You may choose some other means. I also have phpunit installed.

Note that in very case where I could, I installed the latest development package.

Package configuration[edit]

I use "use-package" for everything, with a mix of cargo-cult copy-pasting and rolling my own.

PHP mode configuration[edit]

MediaWiki PHP coding style is its own thing, so setting up for appropriate tabs is a must. Here is the stanza I use for that, stolen shamelessly from Stack Overflow. Note that there's a whole section in the MW coding conventions manual for that, but I haven't looked at that at all.

;; php-mode setup

(defun my-php-mode-hook ()
  (setq indent-tabs-mode t)
  (let ((my-tab-width 4))
    (setq tab-width my-tab-width)
    (setq c-basic-indent my-tab-width)
    (set (make-local-variable 'tab-stop-list)
         (number-sequence my-tab-width 200 my-tab-width))))

And here's the stanza for php-mode itself.

(use-package php-mode
  :ensure t
  :hook
  (php-mode-hook . my-php-mode-hook)
  :mode
  ("\\.php\\'"  . php-mode)
  ("\\.inc\\'" . php-mode))

which-key, flycheck, phpunit[edit]

(use-package flycheck
  :ensure t
  :init
  (global-flycheck-mode))

(use-package which-key
  :ensure t
  :config
  (which-key-mode))

(use-package phpunit
  :ensure t
  :after php-mode)

The Good Stuff (lsp-mode and friends)[edit]

You should set up a file with emacs local variables for the mediawiki-core repo. These settings will determine the tree that is indexed, and where the index is written. The file will be called ".dir-locals.el" and you should put it at the top level of your mediawiki-core repo.

Adjust these settings for your own directory layout.

Wherever you decide serenata's sqlite database and related files should live, make sure you have created all of the directories in the path.

;; serenata settings for mediawiki-core project

; where to stash index of files in project, what dir to index
((nil . (
	      (lsp-serenata-index-database-uri . "file:///home/ariel/.emacs.d/serenata-cache/mwcore-index.sqlite")
	      (lsp-serenata-uris . ["file:///home/ariel/src/wmf/mediawiki/core/core"])
	      )
	   )
 )

Unfortunately, to get this to work properly, at least of Serenata 5.4.0 and lsp-mode 20201011.1353, you must edit clients/lsp-php.el in the lsp-mode package you retrieved from elpa. It currently sends initialization parameters with the string "config" and it should send the string "configuration" as documented in the [Serenata config docs]. So you'll need to change

   `( :config ( :uris ,lsp-serenata-uris

to

   `( :configuration ( :uris ,lsp-serenata-uris

If you forget to do this, indexing files will be written to /tmp and you will be very sad at some point. (Upstream issue opened, if there's some better fix I should hear about it soon.)

On to package configuration. The key bindings chosen are an odd mix of crap which I will surely change later.

Note the setting for lsp-serenata-server-path which you should change to the directory where you put the PHAR you downloaded, or if you are not using Serenata, follow the instructions on the emacs LSP-mode page for your back end.

Note also the hoops one needs to jump through in order to get C-c l as the prefix for lsp-mode. If you prefer, you can make this global and avoid having both an :init and a :config entry, see a bug report on this for more.

Note the C-c l i key binding; the intent of this is that once you have loaded up your first php file in a buffer, you can use this to start up treemacs and lsp-ui-imenu with the treemacs nodes automatically expanded. If you don't want this, just leave it out.

And finally, take note of the hack-local-variables entry. This says "start lsp only after local variables (i.e. the ones you defined in the .dir-locals.el file) have been loaded." Make sure you don't accidentally launch lsp from some other part of your emacs init file, at least for php files.

(use-package company
  :ensure t
  :after php-mode
  :config
  (setq company-idle-delay 0.3)
  (push 'company-files company-backends)
  (global-set-key (kbd "C-<tab>") 'company-complete))

(use-package lsp-mode
  :ensure t
  :after php-mode
  :init
  (setq lsp-keymap-prefix "C-c l")
  :config
  (define-key lsp-mode-map (kbd "C-c l") lsp-command-map)
  (setq lsp-prefer-flymake nil
	lsp-serenata-server-path (substitute-in-file-name "$HOME/bin/php-7.3-serenata-distribution.phar")
	lsp-serenata-file-extensions ["php" "inc"])
  :hook
  (hack-local-variables . (lambda () (when (derived-mode-p 'php-mode) (lsp))))
  (lsp-mode . lsp-enable-which-key-integration)
  :bind
  (:map lsp-mode-map
	;; we always want these in php + lsp mode, so at least
	;; have one key sequence to do them both
        ("C-c l i" . (lambda ()
		       (interactive)
		       (lsp-ui-imenu)
		       ;; go back to the pane with the php file
		       (other-window 1)
		       ;; now invoke treemacs from that pane
		       ;; so that expansion of the node will work
		       (treemacs)
		       ;; expand the top node always
		       (lambda()
			 (setq pos (treemacs-project->position project)
			 (treemacs--expand-root-node pos))
			 )
		       )
	 ))
  :commands
  lsp)

(use-package lsp-ui
  :ensure t
  :requires flycheck
  :after lsp-mode
  :config
  (setq
   lsp-ui-doc-enable t
   lsp-ui-doc-use-childframe t
   lsp-ui-doc-position 'top
   lsp-ui-doc-include-signature t

   lsp-ui-flycheck-enable t
   lsp-ui-flycheck-list-position 'right
   lsp-ui-flycheck-live-reporting t

   lsp-ui-imenu-enable t

   lsp-ui-peek-enable t
   lsp-ui-peek-list-width 60
   lsp-ui-peek-peek-height 25

   lsp-ui-sideline-enable t
   lsp-ui-sideline-update-mode 'line
   lsp-ui-sideline-show-code-actions t
   lsp-ui-sideline-show-hover nil)

  :bind
  (:map lsp-ui-mode-map
	("C-c C-j" . lsp-ui-peek-find-definitions)
	("C-c i"   . lsp-ui-peek-find-implementation)
	("C-c m"   . lsp-ui-imenu))

  :hook
  (lsp-mode . lsp-ui-mode))


(use-package treemacs
  :ensure t
  :commands treemacs
  :bind
  (:map global-map
        ("M-0"       . treemacs-select-window)
        ("C-x t 1"   . treemacs-delete-other-windows)
        ("C-x t t"   . treemacs)
        ("C-x t B"   . treemacs-bookmark)
        ("C-x t C-t" . treemacs-find-file)
        ("C-x t M-t" . treemacs-find-tag))
  :after lsp-mode)

(use-package lsp-treemacs
  :ensure t
  :after (lsp-mode treemacs)
  :commands lsp-treemacs-errors-list
  :bind (:map lsp-mode-map
         ("C-c 9" . lsp-treemacs-errors-list)))

Cursing and stuff[edit]

Aborts, Memory: When I first started this up, I didn't have treemacs in there. Serenata spent some time indexing the entire mediawiki core repo, and then proceeded to one of the vendor subdirs with PHPStorm stubs. There, it died. If it does that to you too, you should NOT agree to a restart when asked, but should exit out of emacs, restart it, and Serenata will take up where it left off. If you restart, it will start from the beginning, dying once again somewhere in the vendor dir. You can increase the memory_limit in your php.ini if this gets too annoying.

When I added treemacs and created a project (coincidentally the mediawiki core repo), Serenata indexed the entire thing again, dying once again in the vendor subdir, so I went through the same don't-restart-it-or-you'll-be-sorry song and dance. Now everything seeeeeems to be indexed properly.

Symlinks to project: If you have a symlink somewhere in your path to the mediawiki core repo, the files will all be indexed with the expanded (not symlink) name, but if you load a file into the buffer in emacs via the symlink path, that path will not be expanded when time comes for Serenata to look up the file in the index. The workaround for this is to alias cd to "cd -P".

Debugging: For debugging, you can add the setting (setq lsp-print-io t) near the top of your emacs init file. This will generate a buffer called lsp log: serenata:nnnn where nnnn is some port number. It will show all messages sent to and from the lsp server.

You can also check what initially calls lsp, in case you are not getting per-project initialization and suspect that your hack-local-variables hook is not happening. Use M-x debug-entry on lsp itself, before loading your first php file into a buffer. The output will show you whether you've got lsp starting from somewhere else in your config.

Don't forget to look at the Messages buffer and the lsp log buffer in addition.

Use, tweak and enjoy[edit]

My intended use is to start emacs, pull up some MediaWiki php file, and then immediately invoke treemacs and lsp-ui-imenu by typing C-c l i. After this, one can use treemacs or M-. etc. or basic emacs commands to get to other files.

  • I need to see how xref is used, what lsp-sideline features I might want, etc.
  • And the big question: emacs in the terminal, always my default until now, or emacs as a separate window?? Do I want pretty treemacs icons?

Comments welcome[edit]

I'm just getting started with this. Today as I clean up this page somewhat, I will literally have used this setup for a grand total of three days. Comments and suggestions are more than welcome! You know where the talk page is :-)