Skip to content

Commit 57424f3

Browse files
committed
Make flycheck-phpstan a generic checker
`flycheck-define-checker' requires the car of `:command' to be a literal string, and the program can only be overridden through the single string variable `flycheck-CHECKER-executable'. PHPStan does not fit that shape: `phpstan-executable' may expand to a whole command line such as `docker run --rm -v ...:/app IMAGE', chosen per project. The checker worked around this by declaring a dummy "php" executable and overwriting `flycheck-phpstan-executable' as a side effect of its `:enabled' predicate, then having `phpstan-get-command-args' omit the program from the arguments so flycheck would prepend the injected one. That had two consequences beyond being hard to follow: * Flycheck wraps `:enabled' so that `flycheck-find-checker-executable' runs *before* the checker's own predicate. At that point the variable was still nil, so it resolved the dummy "php" against `exec-path'. A Docker-only setup with no local php could therefore never enable the checker. * `M-x flycheck-verify-setup' and the checker's documentation reported "php" as the executable. Drive the process directly instead. `:start' builds the command from `phpstan-executable' with `:include-executable t' (the same call `flymake-phpstan' already makes) and reports errors through the status callback, so there is no dummy executable and no variable to inject. This also removes the `:around' advice on `flycheck-finish-checker-process' that suppressed "No files found to analyse" in a modified buffer. The advice worked by not calling the wrapped function at all, which means no status was ever reported -- and as `flycheck-report-buffer-checker-status' documents, a checker that never reports a finishing status leaves flycheck stuck on the current check, silently killing checking for that buffer. `flycheck-phpstan--finish' now reports an empty result instead. Note that generic checkers default `:error-filter' to `identity', while `flycheck-define-checker' installs `flycheck-sanitize-errors'. It is passed explicitly to keep the previous behaviour. `:verify' is added to replace what `flycheck-verify-command-checker' gave us for free; it now shows the real command line rather than "php".
1 parent dc68079 commit 57424f3

4 files changed

Lines changed: 190 additions & 57 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,18 @@ All notable changes of the `phpstan.el` are documented in this file using the [K
1818
### Changed
1919

2020
* `phpstan-copy-dumped-type` command now prioritizes `phpstan-hover-mode` data at point before falling back to dumped-type messages.
21+
* `flycheck-phpstan` is now a Flycheck *generic* checker instead of a command checker.
22+
* The whole command line is built from `phpstan-executable`, so the checker no longer injects an executable into flycheck's `flycheck-phpstan-executable` variable as a side effect of its `:enabled` predicate, and no longer advises `flycheck-finish-checker-process`.
23+
* `php` is no longer required in `exec-path` to enable the checker. Previously the dummy `"php"` in `:command` was resolved by Flycheck *before* the `:enabled` predicate ran, so a Docker-only setup could not enable the checker at all.
24+
* `M-x flycheck-verify-setup` now reports the real PHPStan command and configuration file instead of `"php"`.
25+
* `phpstan-flycheck-auto-set-executable` is obsolete and ignored.
2126

2227
### Fixed
2328

2429
* Fix `phpstan-get-command-args` to keep `:options` in the correct position and pass target arguments correctly when editor mode options are used.
2530
* Fix `phpstan-executable` in the `(STRING . (ARGUMENTS ...))` form dropping the command name, which made the first *argument* run as the program (`("docker" "run" ...)` executed `run`).
2631
* Fix `phpstan-get-command-args` destructively modifying its inputs with `nconc`. Each call appended the PHPStan arguments onto the caller's own list, growing `phpstan-executable` in the `(STRING . (ARGUMENTS ...))` form on every check, and appending `"--"` to `phpstan-generate-baseline-options` on every `phpstan-generate-baseline`.
32+
* Fix Flycheck getting stuck on a syntax check when PHPStan reported no files to analyse in a modified buffer. The check now finishes with an empty result instead of never reporting a status.
2733

2834
## [0.9.0]
2935

‎README.org‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,8 @@ Rule level of PHPStan analysis. Please see [[https://github.com/phpstan/phpstan
212212
- ex) ~("docker" "run" "--rm" "-v" "/path/to/project-dir/:/app" "your/docker-image")~
213213
- ~nil~ :: Auto detect ~phpstan~ executable file by composer dependencies of the project or executable command in ~PATH~ environment variable.
214214

215-
*** Custom variable ~phpstan-flycheck-auto-set-executable~
216-
Set flycheck phpstan-executable automatically when non-NIL.
215+
*** Custom variable ~phpstan-flycheck-auto-set-executable~ (obsolete)
216+
Obsolete and ignored. ~flycheck-phpstan~ now builds the whole command line from ~phpstan-executable~, so it no longer injects an executable into flycheck's ~flycheck-phpstan-executable~ variable.
217217

218218
*** Custom variable ~phpstan-memory-limit~
219219
Use phpstan memory limit option when non-NIL.

‎flycheck-phpstan.el‎

Lines changed: 174 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,22 @@
3636
;;
3737
;; (add-hook 'php-mode-hook 'my-php-mode-setup)
3838
;;
39+
;; ## For Lisp maintainers
40+
;;
41+
;; This is a generic checker (`flycheck-define-generic-checker'), not a command
42+
;; checker (`flycheck-define-checker'). A command checker takes its executable
43+
;; from the car of `:command', which must be a literal string, overridable only
44+
;; through the single string variable `flycheck-CHECKER-executable'. PHPStan
45+
;; does not fit that shape: `phpstan-executable' may expand to a whole command
46+
;; line such as `docker run --rm -v ...', and it is chosen per project. So we
47+
;; drive the process ourselves and build the command from `phpstan-executable'.
3948

4049
;;; Code:
50+
(require 'cl-lib)
4151
(require 'flycheck)
4252
(require 'phpstan)
4353

44-
;; Usually it is defined dynamically by flycheck
45-
(defvar flycheck-phpstan-executable)
4654
(defvar flycheck-phpstan--temp-buffer-name "*Flycheck PHPStan*")
47-
(defvar flycheck-phpstan--output-filter-added nil)
4855
(defconst flycheck-phpstan--nofiles-message (eval-when-compile (regexp-quote "[ERROR] No files found to analyse.")))
4956

5057
(defcustom flycheck-phpstan-ignore-metadata-list nil
@@ -59,44 +66,7 @@
5966
:safe #'stringp
6067
:group 'phpstan)
6168

62-
(defun flycheck-phpstan--suppress-no-files-error (next checker exit-status files output callback cwd)
63-
"Suppress Flycheck errors if PHPStan reports no files in a modified buffer.
64-
65-
This function is intended to be used as an :around advice for
66-
`flycheck-finish-checker-process'.
67-
68-
It prevents Flycheck from displaying an error when:
69-
- CHECKER is `phpstan',
70-
- the current buffer is modified,
71-
- and OUTPUT contains the message `flycheck-phpstan--nofiles-message'.
72-
73-
NEXT, EXIT-STATUS, FILES, OUTPUT, CALLBACK, and CWD are the original arguments
74-
passed to `flycheck-finish-checker-process'."
75-
(unless (and (eq checker 'phpstan)
76-
(buffer-modified-p)
77-
(string-match-p flycheck-phpstan--nofiles-message output))
78-
(funcall next checker exit-status files output callback cwd)))
79-
80-
(defun flycheck-phpstan--enabled-and-set-variable ()
81-
"Return path to phpstan configure file, and set buffer execute in side effect."
82-
(let ((enabled (phpstan-enabled)))
83-
(prog1 enabled
84-
(unless flycheck-phpstan--output-filter-added
85-
(advice-add 'flycheck-finish-checker-process
86-
:around #'flycheck-phpstan--suppress-no-files-error)
87-
(setq flycheck-phpstan--output-filter-added t))
88-
(when (and enabled
89-
phpstan-flycheck-auto-set-executable
90-
(null (bound-and-true-p flycheck-phpstan-executable))
91-
(or (stringp phpstan-executable)
92-
(eq 'docker phpstan-executable)
93-
(and (eq 'root (car-safe phpstan-executable))
94-
(stringp (cdr-safe phpstan-executable)))
95-
(and (stringp (car-safe phpstan-executable))
96-
(listp (cdr-safe phpstan-executable)))
97-
(null phpstan-executable)))
98-
(setq-local flycheck-phpstan-executable (car (phpstan-get-executable-and-args)))))))
99-
69+
;; Parsing PHPStan output:
10070
(defun flycheck-phpstan-parse-output (output &optional _checker _buffer)
10171
"Parse PHPStan errors from OUTPUT."
10272
(let* ((json-buffer (with-current-buffer (flycheck-phpstan--temp-buffer)
@@ -143,21 +113,171 @@ passed to `flycheck-finish-checker-process'."
143113
"Return non-NIL if ORIGINAL is non-NIL and buffer is not modified."
144114
(and original (not (buffer-modified-p))))
145115

146-
(flycheck-define-checker phpstan
116+
;; Running PHPStan:
117+
(defun flycheck-phpstan--command ()
118+
"Return the whole PHPStan command line to check the current buffer.
119+
120+
This has the side effect of saving the buffer to a temporary file, which is
121+
registered in `flycheck-temporaries' for later deletion."
122+
(phpstan-get-command-args
123+
:include-executable t
124+
:format "json"
125+
:editor (list
126+
:analyze-original #'flycheck-phpstan-analyze-original
127+
:original-file buffer-file-name
128+
:temp-file (lambda () (flycheck-save-buffer-to-temp #'flycheck-temp-file-system))
129+
:inplace (lambda () (flycheck-save-buffer-to-temp #'flycheck-temp-file-inplace)))))
130+
131+
(defun flycheck-phpstan--start (checker callback)
132+
"Start CHECKER, reporting the result to CALLBACK.
133+
134+
Return the process, which Flycheck hands back to `:interrupt'."
135+
(let (process)
136+
(condition-case err
137+
(let ((command (flycheck-phpstan--command))
138+
;; `flycheck-phpstan--nofiles-message' matches PHPStan's English
139+
;; output, so keep the checker process in the C locale. Only
140+
;; LC_MESSAGES is set, to leave the encoding of the source alone.
141+
(process-environment (cons "LC_MESSAGES=C" process-environment)))
142+
(setq process
143+
(make-process
144+
:name (format "flycheck-%s" checker)
145+
;; Do not associate a buffer, to avoid the side effects of
146+
;; attaching a process to the buffer being checked.
147+
:buffer nil
148+
:command command
149+
:noquery t
150+
:connection-type 'pipe
151+
:filter #'flycheck-phpstan--receive-output
152+
:sentinel #'flycheck-phpstan--handle-signal))
153+
(process-put process 'flycheck-phpstan-checker checker)
154+
(process-put process 'flycheck-phpstan-callback callback)
155+
(process-put process 'flycheck-phpstan-buffer (current-buffer))
156+
;; Flycheck binds `default-directory' to `:working-directory' around
157+
;; this function, so remember it for resolving relative file names.
158+
(process-put process 'flycheck-phpstan-cwd default-directory)
159+
;; Track the temporaries in the process itself, to get rid of the
160+
;; buffer-local state as soon as possible.
161+
(process-put process 'flycheck-phpstan-temporaries flycheck-temporaries)
162+
(setq flycheck-temporaries nil)
163+
process)
164+
(error
165+
(flycheck-safe-delete-temporaries)
166+
;; Deleting the process triggers the sentinel, which deletes the
167+
;; temporary files of the process anyway.
168+
(when process
169+
(delete-process process))
170+
(signal (car err) (cdr err))))))
171+
172+
(defun flycheck-phpstan--interrupt (_checker process)
173+
"Interrupt PROCESS."
174+
;; Deleting the process always triggers the sentinel, which does the cleanup.
175+
(when process
176+
(delete-process process)))
177+
178+
(defun flycheck-phpstan--receive-output (process output)
179+
"Accumulate OUTPUT of the PHPStan PROCESS for later parsing."
180+
(process-put process 'flycheck-phpstan-pending-output
181+
(cons output (process-get process 'flycheck-phpstan-pending-output))))
182+
183+
(defun flycheck-phpstan--get-output (process)
184+
"Return the complete output of the PHPStan PROCESS."
185+
(with-demoted-errors "Error while retrieving process output: %S"
186+
(apply #'concat (nreverse (process-get process 'flycheck-phpstan-pending-output)))))
187+
188+
(defun flycheck-phpstan--handle-signal (process _event)
189+
"Handle a signal from the PHPStan PROCESS.
190+
191+
_EVENT is ignored."
192+
(when (memq (process-status process) '(signal exit))
193+
(let ((files (process-get process 'flycheck-phpstan-temporaries))
194+
(buffer (process-get process 'flycheck-phpstan-buffer))
195+
(callback (process-get process 'flycheck-phpstan-callback))
196+
(cwd (process-get process 'flycheck-phpstan-cwd)))
197+
(mapc #'flycheck-safe-delete files)
198+
(when (buffer-live-p buffer)
199+
(with-current-buffer buffer
200+
(condition-case err
201+
(pcase (process-status process)
202+
(`signal
203+
(funcall callback 'interrupted))
204+
(`exit
205+
(flycheck-phpstan--finish
206+
(process-get process 'flycheck-phpstan-checker)
207+
(process-exit-status process)
208+
files
209+
(flycheck-phpstan--get-output process)
210+
callback cwd)))
211+
((debug error)
212+
(funcall callback 'errored (error-message-string err)))))))))
213+
214+
(defun flycheck-phpstan--finish (checker exit-status files output callback cwd)
215+
"Parse OUTPUT of CHECKER and report the result to CALLBACK.
216+
217+
EXIT-STATUS is the exit status of the PHPStan process. FILES is the list of
218+
temporary files given to PHPStan, used to map reported file names back onto
219+
the buffer. Relative file names are resolved against CWD.
220+
221+
CALLBACK is always invoked with a status that finishes the syntax check,
222+
because Flycheck gets stuck on the current check otherwise."
223+
(if (and (buffer-modified-p)
224+
(string-match-p flycheck-phpstan--nofiles-message output))
225+
;; PHPStan found nothing to analyse because the buffer is being edited.
226+
;; That is not a result worth showing, but the check must still finish.
227+
(funcall callback 'finished nil)
228+
(let ((errors (flycheck-phpstan-parse-output output checker (current-buffer))))
229+
(when (and (not (equal exit-status 0)) (null errors))
230+
;; Warn about a suspicious result, but keep going: `suspicious' does
231+
;; not finish the syntax check on its own.
232+
(funcall callback 'suspicious
233+
(format "Flycheck checker %S returned %S, but its output \
234+
contained no errors: %s\nTry installing a more recent version of PHPStan, and \
235+
please open a bug report if the issue persists in the latest release. Thanks!"
236+
checker exit-status output)))
237+
(funcall callback 'finished
238+
;; Fix error file names, by substituting them backwards from the
239+
;; temporaries.
240+
(mapcar (lambda (e) (flycheck-fix-error-filename e files cwd))
241+
errors)))))
242+
243+
(defun flycheck-phpstan--verify (_checker)
244+
"Verify the PHPStan setup of the current buffer."
245+
(let* ((executable-and-args (ignore-errors (phpstan-get-executable-and-args)))
246+
(program (car executable-and-args))
247+
(found (and program
248+
(if (file-name-absolute-p program)
249+
(and (file-executable-p program) program)
250+
(executable-find program))))
251+
(config-file (phpstan-get-config-file)))
252+
(list
253+
(flycheck-verification-result-new
254+
:label "executable"
255+
:message (cond (found (format "Found at %s" found))
256+
(program (format "%s not found" program))
257+
(t "Not found"))
258+
:face (if found 'success '(bold error)))
259+
(flycheck-verification-result-new
260+
:label "command"
261+
:message (if executable-and-args
262+
(mapconcat #'shell-quote-argument executable-and-args " ")
263+
"Not available")
264+
:face (if executable-and-args 'success 'warning))
265+
(flycheck-verification-result-new
266+
:label "configuration file"
267+
:message (if config-file (format "Found at %S" config-file) "Not found")
268+
:face (if config-file 'success 'warning)))))
269+
270+
(flycheck-define-generic-checker 'phpstan
147271
"PHP static analyzer based on PHPStan."
148-
:command ("php"
149-
(eval
150-
(phpstan-get-command-args
151-
:format "json"
152-
:editor (list
153-
:analyze-original #'flycheck-phpstan-analyze-original
154-
:original-file buffer-file-name
155-
:temp-file (lambda () (flycheck-save-buffer-to-temp #'flycheck-temp-file-system))
156-
:inplace (lambda () (flycheck-save-buffer-to-temp #'flycheck-temp-file-inplace))))))
272+
:start #'flycheck-phpstan--start
273+
:interrupt #'flycheck-phpstan--interrupt
274+
:verify #'flycheck-phpstan--verify
275+
;; `flycheck-define-checker' installs this filter for command checkers, but
276+
;; generic checkers default to `identity'.
277+
:error-filter #'flycheck-sanitize-errors
157278
:working-directory (lambda (_) (phpstan-get-working-dir))
158-
:enabled (lambda () (flycheck-phpstan--enabled-and-set-variable))
159-
:error-parser flycheck-phpstan-parse-output
160-
:modes (php-mode php-ts-mode phps-mode))
279+
:enabled (lambda () (phpstan-enabled))
280+
:modes '(php-mode php-ts-mode phps-mode))
161281

162282
(add-to-list 'flycheck-checkers 'phpstan t)
163283
(flycheck-add-next-checker 'php 'phpstan)

‎phpstan.el‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,15 @@
7777
:link '(url-link :tag "phpstan.el" "https://github.com/emacs-php/phpstan.el"))
7878

7979
(defcustom phpstan-flycheck-auto-set-executable t
80-
"Set flycheck phpstan-executable automatically."
80+
"Set flycheck phpstan-executable automatically.
81+
82+
This variable no longer has any effect. `flycheck-phpstan' now builds the
83+
whole command line from `phpstan-executable', so it never has to inject an
84+
executable into flycheck's own `flycheck-phpstan-executable' variable."
8185
:type 'boolean)
86+
(make-obsolete-variable 'phpstan-flycheck-auto-set-executable
87+
"the executable is always derived from `phpstan-executable'."
88+
"0.10.0")
8289

8390
(defcustom phpstan-enable-on-no-config-file t
8491
"If T, activate config from composer even when `phpstan.neon' is not found."

0 commit comments

Comments
 (0)