Skip to content

Make flycheck-phpstan a generic checker, add Apple container support - #62

Merged
zonuexe merged 3 commits into
masterfrom
feature/flycheck-generic-checker
Jul 17, 2026
Merged

Make flycheck-phpstan a generic checker, add Apple container support#62
zonuexe merged 3 commits into
masterfrom
feature/flycheck-generic-checker

Conversation

@zonuexe

@zonuexe zonuexe commented Jul 17, 2026

Copy link
Copy Markdown
Member

Replaces the executable injection in flycheck-phpstan with a Flycheck generic checker, adds Apple container support, and fixes three bugs found on the way.

Why

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.

So the checker declared a dummy "php" executable, overwrote flycheck-phpstan-executable as a side effect of its :enabled predicate, and had phpstan-get-command-args omit the program so flycheck would prepend the injected one.

:start now builds the command from phpstan-executable with :include-executable t — the same call flymake-phpstan already makes — and reports errors through the status callback. No dummy executable, no variable injection, and no :around advice on flycheck-finish-checker-process.

Bugs fixed

All three were confirmed by execution, not by reading:

  1. phpstan-get-command-args destructively modified its inputs. It built the result with nconc, but two of the lists it links are owned by the caller. With (phpstan-executable . ("docker" "run" ...)) the variable grew by seven elements per check (6 → 13 → 20 → 27), and each M-x phpstan-generate-baseline appended another "--" to the phpstan-generate-baseline-options defcustom.

  2. The (STRING . (ARGUMENTS ...)) form dropped the command name. It returned (cdr phpstan-executable), so ("docker" "run" ...) ran run as the program. This broke flymake-phpstan and phpstan-analyze-project, and made flycheck-phpstan set its executable to "run".

  3. Flycheck could get stuck. The advice that suppressed "No files found to analyse" in a modified buffer worked by not calling the wrapped function at all, so no status was ever reported. 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. It now finishes with an empty result.

Dropping the dummy "php" also fixes a fourth: Flycheck wraps :enabled so that flycheck-find-checker-executable runs before the checker's own predicate, resolving "php" against exec-path while the injected variable was still nil. A Docker-only setup with no local php could never enable the checker.

Apple container

phpstan-executable accepts container. Apple container runs the same OCI images with the same run --rm -v HOST:/app IMAGE command line as Docker, so only the command name differs; the image still comes from phpstan-docker-image.

The existing docker handling conflated two questions, so this splits them:

  • phpstan--container-runtime-command — should phpstan.el build a run command line? Only the symbol forms say yes; (STRING . (ARGUMENTS ...)) already supplies a complete command line and must not be rewritten.
  • phpstan--container-executable-p — does PHPStan see the project through a mount point, so phpstan-normalize-path must rewrite paths to /app? Also true for ("docker" "run" ...).

Verification

Ran Flycheck for real against local PHPStan 1.12.33, for both an unmodified buffer (analyzes the original) and a modified one (editor mode via --tmp-file): errors reported correctly, no leftover temporaries, checker never stuck. container run was executed directly against the phpstan image and returns the same output as Docker. Each commit byte-compiles on its own.

Known issue, not fixed here

Docker and Apple container do not work through Flycheck, and did not before this PR either (verified against the original code). The analyzed source path is passed to the container unnormalized — the config file becomes -c /app/tests/phpstan-docker.neon correctly, but the target file stays a host path, so PHPStan reports Path /Users/... does not exist and returns zero errors. flymake-phpstan avoids this by calling phpstan-normalize-path itself.

Related: phpstan-editor-mode-available-p runs docker --version and parses d1c06ef out of Docker version 29.5.3, build d1c06ef as the PHPStan version, so editor mode is silently disabled for container users.

Both are independent of this refactor and need their own design decisions (forcing in-place temp files inside the mount; how to version-check PHPStan inside a container), so they are left for follow-ups.

Compatibility

phpstan-flycheck-auto-set-executable is obsolete and ignored (make-obsolete-variable, 0.10.0). flycheck-phpstan-executable is gone; it was an undocumented bare defvar that the package set itself, not a user-facing knob.

zonuexe added 3 commits July 17, 2026 21:33
…mand

`phpstan-get-command-args' built its result with `nconc', but two of the
lists it links are owned by the caller rather than freshly consed:

  * `executable-and-args', which for the `(STRING . (ARGUMENTS ...))'
    form of `phpstan-executable' *is* the value of that variable, and
  * `options', which `phpstan-generate-baseline' passes straight from
    the `phpstan-generate-baseline-options' defcustom.

So every call appended the PHPStan arguments onto those lists.  With
`(phpstan-executable . ("docker" "run" ...))' the variable grew by seven
elements per check, and each `M-x phpstan-generate-baseline' appended
another "--" to `phpstan-generate-baseline-options'.  Use `append', which
copies all but the last list.

The same `(STRING . (ARGUMENTS ...))' form also returned `(cdr
phpstan-executable)' as the executable and arguments, so the command name
was dropped and the first argument ran as the program: `("docker" "run"
...)' executed `run'.  This broke `flymake-phpstan' and
`phpstan-analyze-project', and made `flycheck-phpstan' set its executable
to "run".  Return the whole list instead.

The form was also gated on `phpstan-flycheck-auto-set-executable', which
made `phpstan-get-executable-and-args' return nil for a valid executable
whenever that variable was nil.  Resolving the executable has nothing to
do with how flycheck is configured, so drop the condition.
Apple container (https://github.com/apple/container) runs OCI images on
macOS with the same `run --rm -v HOST:/app IMAGE' command line as Docker,
so supporting it is a matter of picking a different command name.  The
image still comes from `phpstan-docker-image'.

Introduce two predicates rather than one, because the existing `docker'
handling conflated two questions:

  * `phpstan--container-runtime-command' answers "should phpstan.el build
    a `run' command line, and with which command?".  Only the symbol
    forms say yes; the `(STRING . (ARGUMENTS ...))' form already supplies
    a complete command line and must not be rewritten.

  * `phpstan--container-executable-p' answers "does PHPStan see the
    project through a mount point?", which decides whether
    `phpstan-normalize-path' rewrites paths to /app.  That is also true
    for `("docker" "run" ...)', which the old code special-cased by
    comparing the car against the literal "docker".
`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".
@zonuexe
zonuexe merged commit d1f90cc into master Jul 17, 2026
12 of 14 checks passed
zonuexe added a commit that referenced this pull request Jul 17, 2026
#63 and #64 were merged into their base branches rather than master:
#62 landed on master first, which left the rest of the stack behind on
feature/flycheck-generic-checker and fix/container-path-normalization.

Bring in the four commits that never reached master.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant