diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d3148d..830d229 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,38 +4,55 @@ on: push: paths-ignore: - '**/*.md' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.experimental }} strategy: + fail-fast: false matrix: - emacs_version: - - "26.3" - - "27.1" - - snapshot + os: [ubuntu-latest, macos-latest, windows-latest] + emacs-version: + - 27.2 + - 28.2 + - 29.4 + - 30.1 + experimental: [false] include: - - emacs_version: snapshot - allow_failure: true + - os: ubuntu-latest + emacs-version: snapshot + experimental: true + - os: macos-latest + emacs-version: snapshot + experimental: true + - os: windows-latest + emacs-version: snapshot + experimental: true + exclude: + - os: macos-latest + emacs-version: "27.2" + steps: - uses: actions/setup-python@v2 with: python-version: '3.x' architecture: 'x64' - - uses: purcell/setup-emacs@master + - uses: jcs090218/setup-emacs@master with: - version: ${{ matrix.emacs_version }} + version: ${{ matrix.emacs-version }} - - uses: conao3/setup-cask@master + - uses: emacs-eask/setup-eask@master with: version: 'snapshot' - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Run make - uses: nick-invision/retry@v2 - with: - timeout_seconds: 90 - max_attempts: 3 - command: 'cask && make' + run: 'make all' diff --git a/.gitignore b/.gitignore index e9d3537..ec6fd1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.elc /*-autoloads.el /.cask +/.eask +/dist /composer.lock /vendor diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..53ffd34 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,66 @@ +# Changelog + +All notable changes of the `phpstan.el` are documented in this file using the [Keep a Changelog](https://keepachangelog.com/) principles. + + + +## [0.9.0] + +### Added + +* Add `phpstan-copy-dumped-type` command to copy the nearest dumped type from `PHPStan\dumpType()` or `PHPStan\dumpPhpDocType()` messages +* Add support for PHPStan [Editor Mode](https://phpstan.org/user-guide/editor-mode) + +### Changed + +* Improved error handling when no JSON response is returned + +### Fixed + +* Fixed erroneous dependency from `flymake-phpstan` to Flycheck functions + +### Removed + +* Drop support for Emacs 25.3 + +## [0.8.2] + +### Fixed + +* Fix compilation errors on Emacs 30 + +## [0.8.1] + +### Added + +* Add `phpstan-enable-remote-experimental` custom variable for activate PHPStan on TRAMP. +* Add support for `php-ts-mode`. +* Add `phpstan-insert-dumptype` command +* Add `phpstan-insert-ignore` command + +### Changed + +* Refactored `phpstan-check-buffer` command for improved performance. +* Improved logic for detecting phpstan executable. + +### Removed + +* Drop support for Emacs 24.3. + +## [0.7.0] + +### Added + +* Add `phpstan-analyze-this-file` command +* Add `phpstan.dist.neon` to `phpstan-get-config-file` as PHPStan config files. +* Add `phpstan-pro` command to launch [PHPStan Pro]. +* Add `phpstan-find-baseline-file` command. +* Add `phpstan-generate-baseline-file` command. + +[PHPStan Pro]: https://phpstan.org/blog/introducing-phpstan-pro + +### Changed + +* Make flycheck analyze the original file directly instead of temporary files when there are no changes to the file. +* [Internal] Use JSON format for flycheck-phpstan. + * Support **💡 tips** diff --git a/Cask b/Cask deleted file mode 100644 index ed6cb9e..0000000 --- a/Cask +++ /dev/null @@ -1,8 +0,0 @@ -(package "phpstan" "0.6.0" "Interface to PHPStan (PHP static analyzer)") -(source "melpa" "https://melpa.org/packages/") - -(package-file "phpstan.el") -(package-file "flycheck-phpstan.el") -(package-file "flymake-phpstan.el") -(development - (depends-on "php-mode")) diff --git a/Eask b/Eask new file mode 100644 index 0000000..dfec247 --- /dev/null +++ b/Eask @@ -0,0 +1,24 @@ +;; -*- mode: eask; lexical-binding: t -*- + +(package "phpstan" + "0.9.0" + "Interface to PHPStan (PHP static analyzer)") + +(website-url "https://github.com/emacs-php/phpstan.el") +(keywords "tools" "php") + +(package-file "phpstan.el") +(files "*.el") + +(script "test" "echo \"Error: no test specified\" && exit 1") + +(source 'gnu) +(source 'melpa) + +(depends-on "emacs" "24.3") +(depends-on "compat") +(depends-on "php-mode") +(depends-on "php-runtime") +(depends-on "flycheck") + +(setq network-security-level 'low) ; see https://github.com/jcs090218/setup-emacs-windows/issues/156#issuecomment-932956432 diff --git a/Makefile b/Makefile index 40be38e..a5e34cc 100644 --- a/Makefile +++ b/Makefile @@ -1,34 +1,21 @@ EMACS ?= emacs CASK ?= cask -ELS = phpstan.el flycheck-phpstan.el flymake-phpstan.el -AUTOLOADS = phpstan-autoloads.el -ELCS = $(ELS:.el=.elc) +EASK ?= eask -.el.elc: .cask - $(EMACS) -Q -batch -L . --eval \ - "(let ((default-directory (expand-file-name \".cask\" default-directory))) \ - (require 'package) \ - (normal-top-level-add-subdirs-to-load-path))" \ - -f package-initialize -f batch-byte-compile $< +install: + $(EASK) package + $(EASK) install + $(EASK) install-deps -.cask: Cask - $(CASK) +compile: + $(EASK) compile -all: clean autoloads $(ELCS) +all: clean autoloads install compile -autoloads: $(AUTOLOADS) - -$(AUTOLOADS): $(ELCS) - $(EMACS) -Q -batch -L . --eval \ - "(progn \ - (require 'package) \ - (normal-top-level-add-subdirs-to-load-path) \ - (package-generate-autoloads \"phpstan\" default-directory))" +autoloads: + $(EASK) generate autoloads clean: - -rm -f $(ELCS) $(AUTOLOADS) - -clobber: clean - -rm -f .cask + $(EASK) clean all -.PHONY: all autoloads clean clobber +.PHONY: all autoloads clean diff --git a/README.org b/README.org index 620fe2c..bbaf1e8 100644 --- a/README.org +++ b/README.org @@ -5,9 +5,16 @@ #+END_HTML Emacs interface to [[https://github.com/phpstan/phpstan][PHPStan]], includes checker for [[http://www.flycheck.org/en/latest/][Flycheck]]. ** Support version -- Emacs 24+ +- Emacs 26+ - PHPStan latest/dev-master (NOT support 0.9 seriese) - PHP 7.1+ or Docker runtime + +#+BEGIN_QUOTE +[!TIP] +This package provides support for the [[https://phpstan.org/user-guide/editor-mode][Editor Mode]] introduced in PHPStan [[https://github.com/phpstan/phpstan/releases/tag/2.1.17][2.1.17]] and [[https://github.com/phpstan/phpstan/releases/tag/1.12.27][1.12.27]].\\ +*We strongly recommend that you always update to the latest PHPStan.* +#+END_QUOTE + ** How to install *** Install from MELPA 1. If you have not set up MELPA, see [[https://melpa.org/#/getting-started][Getting Started - MELPA]]. @@ -29,7 +36,7 @@ Emacs interface to [[https://github.com/phpstan/phpstan][PHPStan]], includes che #+END_SRC *** Using Docker (phpstan/docker-image) -Install [[https://www.docker.com/get-started][Docker]] and [[https://hub.docker.com/r/phpstan/phpstan][phpstan/phpstan image]]. +Install [[https://www.docker.com/get-started][Docker]] and [[https://github.com/phpstan/phpstan/pkgs/container/phpstan][phpstan/phpstan image]]. If you always use Docker for PHPStan, add the following into your ~.emacs~ file (~~/.emacs.d/init.el~) #+BEGIN_SRC emacs-lisp @@ -88,7 +95,31 @@ Typically, you would set the following ~.dir-locals.el~. #+END_SRC If there is a ~phpstan.neon~ file in the root directory of the project, you do not need to set both ~phpstan-working-dir~ and ~phpstan-config-file~. +** Commands +This package provides convenient commands for using PHPStan from Emacs. +*** Command ~phpstan-insert-dumptype~ +Add ~\PHPStan\dumpType(...);~ to your PHP code and analyze it to make PHPStan display the type of the expression. +#+BEGIN_SRC +(define-key php-mode-map (kbd "C-c ^") #'phpstan-insert-dumptype) +#+END_SRC + +By default, if you press ~C-u~ before invoking the command, ~\PHPStan\dumpPhpDocType()~ will be inserted. + +This feature was added in *PHPStan 1.12.7* and will dump types compatible with the ~@param~ and ~@return~ PHPDoc tags. +*** Command ~phpstan-insert-ignore~ +Insert a ~@phpstan-ignore~ tag to suppress any PHPStan errors on the current line. + +By default it inserts the tag on the previous line, but if there is already a tag at the end of the current line or on the previous line, the identifiers will be appended there. +If there is no existing tag and ~C-u~ is pressed before the command, it will be inserted at the end of the line. +*** Command ~phpstan-copy-dumped-type~ +Copy the nearest dumped type message from PHPStan's output. + +This command looks for messages like ~Dumped type: int|string|null~ reported by ~PHPStan\dumpType()~ or ~PHPStan\dumpPhpDocType()~, and copies the type string to the kill ring. + +If there are multiple dumped types in the buffer, it selects the one closest to the current line. + +If no dumped type messages are found, the command signals an error. ** API Most variables defined in this package are buffer local. If you want to set it for multiple projects, use [[https://www.gnu.org/software/emacs/manual/html_node/elisp/Default-Value.html][setq-default]]. @@ -131,6 +162,13 @@ Use phpstan memory limit option when non-NIL. - ex) ~"1G"~ - ~nil~ :: Use memory limit in php.ini +*** Custom variable ~phpstan-activate-editor-mode~ +Determines whether PHPStan Editor Mode is available. + +- ~nil~ (default) :: Dynamically checks the PHPStan version by getting the path of the installed PHPStan executable. +- ~'enabled~ :: Always use Editor Mode (this will cause an error in older versions of PHPStan) +- ~'disabled~ :: Never use Editor Mode (no support for editors provided) + *** Custom variable ~phpstan-docker-image~ Docker image URL or Docker Hub image name or NIL. Default as ~"ghcr.io/phpstan/phpstan"~. See [[https://phpstan.org/user-guide/docker][Docker - PHPStan Documentation]] and [[https://github.com/orgs/phpstan/packages/container/package/phpstan][GitHub Container Registory - Package phpstan]]. diff --git a/composer.json b/composer.json index 3dc0dd8..8787e56 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,6 @@ "description": "Emacs interface to PHPStan", "license": "GPL-3.0-or-later", "require": { - "phpstan/phpstan": "^0.12.92" + "phpstan/phpstan": "^1.9" } } diff --git a/flycheck-phpstan.el b/flycheck-phpstan.el index 35af909..5fd42b9 100644 --- a/flycheck-phpstan.el +++ b/flycheck-phpstan.el @@ -1,13 +1,13 @@ ;;; flycheck-phpstan.el --- Flycheck integration for PHPStan -*- lexical-binding: t; -*- -;; Copyright (C) 2021 Friends of Emacs-PHP development +;; Copyright (C) 2025 Friends of Emacs-PHP development ;; Author: USAMI Kenta ;; Created: 15 Mar 2018 -;; Version: 0.6.0 +;; Version: 0.9.0 ;; Keywords: tools, php ;; Homepage: https://github.com/emacs-php/phpstan.el -;; Package-Requires: ((emacs "24.3") (flycheck "26") (phpstan "0.5.0")) +;; Package-Requires: ((emacs "25.1") (flycheck "26") (phpstan "0.8.2")) ;; License: GPL-3.0-or-later ;; This program is free software; you can redistribute it and/or modify @@ -43,11 +43,48 @@ ;; Usually it is defined dynamically by flycheck (defvar flycheck-phpstan-executable) +(defvar flycheck-phpstan--temp-buffer-name "*Flycheck PHPStan*") +(defvar flycheck-phpstan--output-filter-added nil) +(defconst flycheck-phpstan--nofiles-message (eval-when-compile (regexp-quote "[ERROR] No files found to analyse."))) + +(defcustom flycheck-phpstan-ignore-metadata-list nil + "Set of metadata items to ignore in PHPStan messages for Flycheck." + :type '(set (const identifier) + (const tip)) + :group 'phpstan) + +(defcustom flycheck-phpstan-metadata-separator "\n" + "Separator of PHPStan message and metadata." + :type 'string + :safe #'stringp + :group 'phpstan) + +(defun flycheck-phpstan--suppress-no-files-error (next checker exit-status files output callback cwd) + "Suppress Flycheck errors if PHPStan reports no files in a modified buffer. + +This function is intended to be used as an :around advice for +`flycheck-finish-checker-process'. + +It prevents Flycheck from displaying an error when: +- CHECKER is `phpstan', +- the current buffer is modified, +- and OUTPUT contains the message `flycheck-phpstan--nofiles-message'. + +NEXT, EXIT-STATUS, FILES, OUTPUT, CALLBACK, and CWD are the original arguments +passed to `flycheck-finish-checker-process'." + (unless (and (eq checker 'phpstan) + (buffer-modified-p) + (string-match-p flycheck-phpstan--nofiles-message output)) + (funcall next checker exit-status files output callback cwd))) (defun flycheck-phpstan--enabled-and-set-variable () "Return path to phpstan configure file, and set buffer execute in side effect." (let ((enabled (phpstan-enabled))) (prog1 enabled + (unless flycheck-phpstan--output-filter-added + (advice-add 'flycheck-finish-checker-process + :around #'flycheck-phpstan--suppress-no-files-error) + (setq flycheck-phpstan--output-filter-added t)) (when (and enabled phpstan-flycheck-auto-set-executable (null (bound-and-true-p flycheck-phpstan-executable)) @@ -60,17 +97,67 @@ (null phpstan-executable))) (setq-local flycheck-phpstan-executable (car (phpstan-get-executable-and-args))))))) +(defun flycheck-phpstan-parse-output (output &optional _checker _buffer) + "Parse PHPStan errors from OUTPUT." + (let* ((json-buffer (with-current-buffer (flycheck-phpstan--temp-buffer) + (erase-buffer) + (insert output) + (current-buffer))) + (data (if (string-prefix-p "{" output) + (phpstan--parse-json json-buffer) + (list (flycheck-error-new-at 1 1 'warning (string-trim output))))) + (errors (phpstan--plist-to-alist (plist-get data :files)))) + (unless phpstan-disable-buffer-errors + (phpstan-update-ignorebale-errors-from-json-buffer errors)) + (phpstan-update-dumped-types errors) + (flycheck-phpstan--build-errors errors))) + +(defun flycheck-phpstan--temp-buffer () + "Return a temporary buffer for decode JSON." + (get-buffer-create flycheck-phpstan--temp-buffer-name)) + +(defun flycheck-phpstan--build-errors (errors) + "Build Flycheck errors from PHPStan ERRORS." + (cl-loop for (file . entry) in errors + append (cl-loop for messages in (plist-get entry :messages) + for text = (let* ((msg (plist-get messages :message)) + (ignorable (plist-get messages :ignorable)) + (identifier (unless (memq 'identifier flycheck-phpstan-ignore-metadata-list) + (plist-get messages :identifier))) + (tip (unless (memq 'tip flycheck-phpstan-ignore-metadata-list) + (plist-get messages :tip))) + (lines (list (when (and identifier ignorable) + (concat phpstan-identifier-prefix identifier)) + (when tip + (concat phpstan-tip-message-prefix tip)))) + (lines (cl-remove-if #'null lines))) + (if (null lines) + msg + (concat msg flycheck-phpstan-metadata-separator + (string-join lines "\n")))) + collect (flycheck-error-new-at (plist-get messages :line) + nil 'error text + :filename file)))) + +(defun flycheck-phpstan-analyze-original (original) + "Return non-NIL if ORIGINAL is non-NIL and buffer is not modified." + (and original (not (buffer-modified-p)))) + (flycheck-define-checker phpstan "PHP static analyzer based on PHPStan." - :command ("php" (eval (phpstan-get-command-args)) - (eval (phpstan-normalize-path - (flycheck-save-buffer-to-temp #'flycheck-temp-file-inplace) - (flycheck-save-buffer-to-temp #'flycheck-temp-file-system)))) + :command ("php" + (eval + (phpstan-get-command-args + :format "json" + :editor (list + :analyze-original #'flycheck-phpstan-analyze-original + :original-file buffer-file-name + :temp-file (lambda () (flycheck-save-buffer-to-temp #'flycheck-temp-file-system)) + :inplace (lambda () (flycheck-save-buffer-to-temp #'flycheck-temp-file-inplace)))))) :working-directory (lambda (_) (phpstan-get-working-dir)) :enabled (lambda () (flycheck-phpstan--enabled-and-set-variable)) - :error-patterns - ((error line-start (1+ (not (any ":"))) ":" line ":" (message) line-end)) - :modes (php-mode phps-mode)) + :error-parser flycheck-phpstan-parse-output + :modes (php-mode php-ts-mode phps-mode)) (add-to-list 'flycheck-checkers 'phpstan t) (flycheck-add-next-checker 'php 'phpstan) diff --git a/flymake-phpstan.el b/flymake-phpstan.el index 63a3979..9e89c5f 100644 --- a/flymake-phpstan.el +++ b/flymake-phpstan.el @@ -1,13 +1,13 @@ ;;; flymake-phpstan.el --- Flymake backend for PHP using PHPStan -*- lexical-binding: t; -*- -;; Copyright (C) 2021 Friends of Emacs-PHP development +;; Copyright (C) 2025 Friends of Emacs-PHP development ;; Author: USAMI Kenta ;; Created: 31 Mar 2020 -;; Version: 0.6.0 +;; Version: 0.9.0 ;; Keywords: tools, php ;; Homepage: https://github.com/emacs-php/phpstan.el -;; Package-Requires: ((emacs "26.1") (phpstan "0.5.0")) +;; Package-Requires: ((emacs "26.1") (phpstan "0.8.2")) ;; License: GPL-3.0-or-later ;; This program is free software; you can redistribute it and/or modify @@ -38,6 +38,7 @@ (require 'cl-lib) (require 'php-project) (require 'flymake) +(require 'flymake-proc) (require 'phpstan) (eval-when-compile (require 'pcase)) @@ -88,17 +89,34 @@ (kill-buffer (process-buffer proc)))) (code (user-error "PHPStan error (exit status: %s)" code))))))) +(defun flymake-phpstan-analyze-original (original) + "Return non-NIL if ORIGINAL is non-NIL and buffer is not modified." + (and original (not (buffer-modified-p)))) + +(defun flymake-phpstan--create-temp-file () + "Create temp file and return the path." + (phpstan-normalize-path + (flymake-proc-init-create-temp-buffer-copy 'flymake-proc-create-temp-inplace))) + (defun flymake-phpstan (report-fn &rest _ignored-args) "Flymake backend for PHPStan report using REPORT-FN." - (let ((command-args (phpstan-get-command-args t))) + (let ((command-args (phpstan-get-command-args :include-executable t))) (unless (car command-args) (user-error "Cannot find a phpstan executable command")) (when (process-live-p flymake-phpstan--proc) (kill-process flymake-phpstan--proc)) - (let ((source (current-buffer))) + (let* ((source (current-buffer)) + (args (phpstan-get-command-args + :include-executable t + :format "raw" + :editor (list + :analyze-original #'flymake-phpstan-analyze-original + :original-file buffer-file-name + :temp-file #'flymake-phpstan--create-temp-file + :inplace #'flymake-phpstan--create-temp-file)))) (save-restriction (widen) - (setq flymake-phpstan--proc (flymake-phpstan-make-process (php-project-get-root-dir) command-args report-fn source)) + (setq flymake-phpstan--proc (flymake-phpstan-make-process (php-project-get-root-dir) args report-fn source)) (process-send-region flymake-phpstan--proc (point-min) (point-max)) (process-send-eof flymake-phpstan--proc))))) @@ -111,7 +129,7 @@ (flymake-mode 1) (when flymake-phpstan-disable-c-mode-hooks (remove-hook 'flymake-diagnostic-functions #'flymake-cc t)) - (add-hook 'flymake-diagnostic-functions #'flymake-phpstan nil t)))) + (add-hook 'flymake-diagnostic-functions #'flymake-phpstan nil 'local)))) (provide 'flymake-phpstan) ;;; flymake-phpstan.el ends here diff --git a/phpstan.el b/phpstan.el index b05f3dd..103403d 100644 --- a/phpstan.el +++ b/phpstan.el @@ -1,13 +1,13 @@ ;;; phpstan.el --- Interface to PHPStan -*- lexical-binding: t; -*- -;; Copyright (C) 2021 Friends of Emacs-PHP development +;; Copyright (C) 2025 Friends of Emacs-PHP development ;; Author: USAMI Kenta ;; Created: 15 Mar 2018 -;; Version: 0.6.0 +;; Version: 0.9.0 ;; Keywords: tools, php ;; Homepage: https://github.com/emacs-php/phpstan.el -;; Package-Requires: ((emacs "24.3") (php-mode "1.22.3")) +;; Package-Requires: ((emacs "25.1") (compat "30") (php-mode "1.22.3") (php-runtime "0.2")) ;; License: GPL-3.0-or-later ;; This program is free software; you can redistribute it and/or modify @@ -55,12 +55,19 @@ ;;; Code: (require 'cl-lib) (require 'php-project) - +(require 'php-runtime) +(require 'seq) + +(eval-when-compile + (require 'compat nil t) + (require 'php) + (require 'json) + (declare-function 'tramp-dissect-file-name "tramp" '(name &optional nodefault)) + (declare-function 'tramp-file-name-localname "tamp" '(cl-x))) ;; Variables: - (defgroup phpstan nil - "Interaface to PHPStan" + "Interaface to PHPStan." :tag "PHPStan" :prefix "phpstan-" :group 'tools @@ -70,36 +77,109 @@ (defcustom phpstan-flycheck-auto-set-executable t "Set flycheck phpstan-executable automatically." - :type 'boolean - :group 'phpstan) + :type 'boolean) (defcustom phpstan-enable-on-no-config-file t - "If T, activate configuration from composer even when `phpstan.neon' is not found." - :type 'boolean - :group 'phpstan) + "If T, activate config from composer even when `phpstan.neon' is not found." + :type 'boolean) (defcustom phpstan-memory-limit nil "Set --memory-limit option." - :type '(choice (string :tag "Specifies the memory limit in the same format php.ini accepts.") + :type '(choice (string :tag "A memory limit number in php.ini format.") (const :tag "Not set --memory-limit option" nil)) + :link '(url-link :tag "PHP Manual" + "https://www.php.net/manual/ini.core.php#ini.memory-limit") :safe (lambda (v) (or (null v) (stringp v))) - :group 'phpstan) + :local t) (defcustom phpstan-docker-image "ghcr.io/phpstan/phpstan" "Docker image URL or Docker Hub image name or NIL." :type '(choice (string :tag "URL or image name of Docker Hub.") (const :tag "Official Docker container" "ghcr.io/phpstan/phpstan") - (const :tag "No specify Docker image")) + (const :tag "No specify Docker image" nil)) :link '(url-link :tag "PHPStan Documentation" "https://phpstan.org/user-guide/docker") :link '(url-link :tag "GitHub Container Registry" "https://github.com/orgs/phpstan/packages/container/package/phpstan") :safe (lambda (v) (or (null v) (stringp v))) - :group 'phpstan) + :local t) + +(defcustom phpstan-use-xdebug-option nil + "Set --xdebug option." + :type '(choice (const :tag "Set --xdebug option dynamically" auto) + (const :tag "Add --xdebug option" t) + (const :tag "No --xdebug option" nil)) + :safe #'symbolp + :local t) + +(defcustom phpstan-generate-baseline-options '("--generate-baseline" "--allow-empty-baseline") + "Command line options for generating PHPStan baseline." + :type '(repeat string) + :safe #'listp + :local t) + +(defcustom phpstan-baseline-file "phpstan-baseline.neon" + "File name of PHPStan baseline file." + :type 'string + :safe #'stringp + :local t) + +(defcustom phpstan-tip-message-prefix "💡 " + "Prefix of PHPStan tip message." + :type 'string + :safe #'stringp + :local t) + +(defcustom phpstan-identifier-prefix "🪪 " + "Prefix of PHPStan error identifier." + :type 'string + :safe #'stringp + :local t) + +(defcustom phpstan-enable-remote-experimental nil + "Enable PHPStan analysis remotely by TRAMP. + +When non-nil, PHPStan will be executed on a remote server for code analysis. +This feature is experimental and should be used with caution as it may +have unexpected behaviors or performance implications." + :type 'boolean + :safe #'booleanp + :local t) + +(defconst phpstan-template-dump-type "\\PHPStan\\dumpType();") +(defconst phpstan-template-dump-phpdoc-type "\\PHPStan\\dumpPhpDocType();") + +(defcustom phpstan-intert-dump-type-templates (cons phpstan-template-dump-type + phpstan-template-dump-phpdoc-type) + "Default template of PHPStan dumpType insertion." + :type '(cons string string)) + +(defcustom phpstan-disable-buffer-errors nil + "If non-NIL, don't keep errors per buffer to save memory." + :type 'boolean) + +(defcustom phpstan-not-ignorable-identifiers '("ignore.parseError") + "Lists identifiers prohibited from being added to @phpstan-ignore tags." + :type '(repeat string)) + +(defcustom phpstan-activate-editor-mode nil + "Controls how PHPStan's editor mode is activated." + :type '(choice (const :tag "Automatic (based on version)" nil) + (const :tag "Editor mode will be actively enabled, regardless of the PHPStan version." enabled) + (const :tag "Editor mode will be explicitly disabled." disabled)) + :safe (lambda (v) (memq v '(nil enabled disabled))) + :local t) + +(defvar-local phpstan--use-xdebug-option nil) + +(defvar-local phpstan--ignorable-errors '()) +(defvar-local phpstan--dumped-types '()) + +(defvar phpstan-executable-versions-alist '()) ;;;###autoload (progn - (defvar phpstan-working-dir nil + (defvar-local phpstan-working-dir nil "Path to working directory of PHPStan. *NOTICE*: This is different from the project root. @@ -112,7 +192,6 @@ STRING NIL Use (php-project-get-root-dir) as working directory.") - (make-variable-buffer-local 'phpstan-working-dir) (put 'phpstan-working-dir 'safe-local-variable #'(lambda (v) (if (consp v) (and (eq 'root (car v)) (stringp (cdr v))) @@ -120,7 +199,7 @@ NIL ;;;###autoload (progn - (defvar phpstan-config-file nil + (defvar-local phpstan-config-file nil "Path to project specific configuration file of PHPStan. STRING @@ -131,7 +210,6 @@ STRING NIL Search phpstan.neon(.dist) in (phpstan-get-working-dir).") - (make-variable-buffer-local 'phpstan-config-file) (put 'phpstan-config-file 'safe-local-variable #'(lambda (v) (if (consp v) (and (eq 'root (car v)) (stringp (cdr v))) @@ -149,7 +227,8 @@ STRING Relative path to `phpstan' configuration file from project root directory. NIL - If `phpstan-enable-on-no-config-file', search \"vendor/autoload.php\" in (phpstan-get-working-dir).") + If `phpstan-enable-on-no-config-file', search \"vendor/autoload.php\" + in (phpstan-get-working-dir).") (put 'phpstan-autoload-file 'safe-local-variable #'(lambda (v) (if (consp v) (and (eq 'root (car v)) (stringp (cdr v))) @@ -169,25 +248,24 @@ max NIL Use rule level specified in `phpstan' configuration file.") (put 'phpstan-level 'safe-local-variable - #'(lambda (v) (or (null v) - (integerp v) - (eq 'max v) - (and (stringp v) - (string= "max" v) - (string-match-p "\\`[0-9]\\'" v)))))) + (lambda (v) (or (null v) + (integerp v) + (eq 'max v) + (and (stringp v) + (or (string= "max" v) + (string-match-p "\\`[0-9]\\'" v))))))) ;;;###autoload (progn - (defvar phpstan-replace-path-prefix) - (make-variable-buffer-local 'phpstan-replace-path-prefix) + (defvar-local phpstan-replace-path-prefix nil) (put 'phpstan-replace-path-prefix 'safe-local-variable - #'(lambda (v) (or (null v) (stringp v))))) + (lambda (v) (or (null v) (stringp v))))) (defconst phpstan-docker-executable "docker") ;;;###autoload (progn - (defvar phpstan-executable nil + (defvar-local phpstan-executable nil "PHPStan excutable file. STRING @@ -204,13 +282,24 @@ STRING NIL Auto detect `phpstan' executable file.") - (make-variable-buffer-local 'phpstan-executable) (put 'phpstan-executable 'safe-local-variable #'(lambda (v) (if (consp v) (or (and (eq 'root (car v)) (stringp (cdr v))) (and (stringp (car v)) (listp (cdr v)))) (or (eq 'docker v) (null v) (stringp v)))))) +;; Utilities: +(defun phpstan--plist-to-alist (plist) + "Convert PLIST to association list." + (let (alist) + (while plist + (push (cons (substring-no-properties (symbol-name (pop plist)) 1) (pop plist)) alist)) + (nreverse alist))) + +(defsubst phpstan--current-line () + "Return the current buffer line at point. The first line is 1." + (line-number-at-pos nil t)) + ;; Functions: (defun phpstan-get-working-dir () "Return path to working directory of PHPStan." @@ -222,14 +311,15 @@ NIL (defun phpstan-enabled () "Return non-NIL if PHPStan configured or Composer detected." - (and (not (file-remote-p default-directory)) ;; Not support remote filesystem - (or (phpstan-get-config-file) - (phpstan-get-autoload-file) - (and phpstan-enable-on-no-config-file - (php-project-get-root-dir))))) + (unless (and (not phpstan-enable-remote-experimental) + (file-remote-p default-directory)) ;; Not support remote filesystem by default + (or (phpstan-get-config-file) + (phpstan-get-autoload-file) + (and phpstan-enable-on-no-config-file + (php-project-get-root-dir))))) (defun phpstan-get-config-file () - "Return path to phpstan configure file or `NIL'." + "Return path to phpstan configure file or NIL." (if phpstan-config-file (if (and (consp phpstan-config-file) (eq 'root (car phpstan-config-file))) @@ -238,8 +328,8 @@ NIL phpstan-config-file) (let ((working-directory (phpstan-get-working-dir))) (when working-directory - (cl-loop for name in '("phpstan.neon" "phpstan.neon.dist") - for dir = (locate-dominating-file working-directory name) + (cl-loop for name in '("phpstan.neon" "phpstan.neon.dist" "phpstan.dist.neon") + for dir = (locate-dominating-file working-directory name) if dir return (expand-file-name name dir)))))) @@ -252,7 +342,7 @@ NIL phpstan-autoload-file))) (defun phpstan-normalize-path (source-original &optional source) - "Return normalized source file path to pass by `SOURCE-ORIGINAL' OR `SOURCE'. + "Return normalized source file path to pass by SOURCE-ORIGINAL or SOURCE. If neither `phpstan-replace-path-prefix' nor executable docker is set, it returns the value of `SOURCE' as it is." @@ -262,7 +352,8 @@ it returns the value of `SOURCE' as it is." (cond ((eq 'docker phpstan-executable) "/app") ((and (consp phpstan-executable) - (string= "docker" (car phpstan-executable))) "/app"))))) + (string= "docker" (car phpstan-executable))) + "/app"))))) (if prefix (expand-file-name (replace-regexp-in-string (concat "\\`" (regexp-quote root-directory)) @@ -272,7 +363,7 @@ it returns the value of `SOURCE' as it is." (or source source-original)))) (defun phpstan-get-level () - "Return path to phpstan configure file or `NIL'." + "Return path to phpstan configure file or NIL." (cond ((null phpstan-level) nil) ((integerp phpstan-level) (int-to-string phpstan-level)) @@ -283,10 +374,78 @@ it returns the value of `SOURCE' as it is." "Return --memory-limit value." phpstan-memory-limit) +(defun phpstan--parse-json (buffer) + "Read JSON string from BUFFER." + (with-current-buffer buffer + (goto-char (point-min)) + ;; Ignore STDERR + (save-match-data + (when (search-forward-regexp "^{" nil t) + (backward-char 1) + (delete-region (point-min) (point)))) + (if (eval-when-compile (and (fboundp 'json-serialize) + (fboundp 'json-parse-buffer))) + (with-no-warnings + (json-parse-buffer :object-type 'plist :array-type 'list)) + (let ((json-object-type 'plist) (json-array-type 'list)) + (json-read-object))))) + +(defun phpstan--expand-file-name (name) + "Expand file name by NAME." + (let ((file (expand-file-name name))) + (if (file-remote-p name) + (tramp-file-name-localname (tramp-dissect-file-name name)) + (expand-file-name file)))) + +;;;###autoload +(defun phpstan-analyze-this-file () + "Analyze current buffer-file using PHPStan." + (interactive) + (let ((file (phpstan--expand-file-name (or buffer-file-name + (read-file-name "Choose a PHP script: "))))) + (compile (mapconcat #'shell-quote-argument + (phpstan-get-command-args :include-executable t :args (list file) :verbose 1) " ")))) + +;;;###autoload (defun phpstan-analyze-file (file) - "Analyze a PHPScript FILE using PHPStan." - (interactive (list (expand-file-name (read-file-name "Choose a PHP script: ")))) - (compile (mapconcat #'shell-quote-argument (append (phpstan-get-command-args t) (list file)) " "))) + "Analyze a PHP script FILE using PHPStan." + (interactive (list (phpstan--expand-file-name (read-file-name "Choose a PHP script: ")))) + (compile (mapconcat #'shell-quote-argument + (phpstan-get-command-args :include-executable t :args (list file) :verbose 1) " "))) + +;;;###autoload +(defun phpstan-analyze-project () + "Analyze a PHP project using PHPStan." + (interactive) + (let ((default-directory (or (php-project-get-root-dir) default-directory))) + (compile (mapconcat #'shell-quote-argument (phpstan-get-command-args :include-executable t) " ")))) + +;;;###autoload +(defun phpstan-generate-baseline () + "Generate PHPStan baseline file." + (interactive) + (let ((default-directory (or (locate-dominating-file default-directory phpstan-baseline-file) + (php-project-get-root-dir) + default-directory))) + (compile (mapconcat #'shell-quote-argument + (phpstan-get-command-args :include-executable t :options phpstan-generate-baseline-options) " ")))) + +;;;###autoload +(defun phpstan-find-baseline-file () + "Find PHPStan baseline file of current project." + (interactive) + (if-let ((path (locate-dominating-file default-directory phpstan-baseline-file))) + (find-file (expand-file-name phpstan-baseline-file path)) + (user-error "Baseline file not found. Try running M-x phpstan-generate-baseline"))) + +;;;###autoload +(defun phpstan-pro () + "Analyze current PHP project using PHPStan Pro." + (interactive) + (let ((compilation-buffer-name-function (lambda (_) "*PHPStan Pro*")) + (command (mapconcat #'shell-quote-argument + (phpstan-get-command-args :include-executable t :use-pro t) " "))) + (compile command t))) (defun phpstan-get-executable-and-args () "Return PHPStan excutable file and arguments." @@ -315,31 +474,212 @@ it returns the value of `SOURCE' as it is." (listp (cdr phpstan-executable))) (cdr phpstan-executable)) ((null phpstan-executable) - (let ((vendor-phpstan (expand-file-name "vendor/bin/phpstan" - (php-project-get-root-dir)))) + (let* ((vendor-phpstan (expand-file-name "vendor/bin/phpstan" + (php-project-get-root-dir))) + (expanded-vendor-phpstan (phpstan--expand-file-name vendor-phpstan))) (cond ((file-exists-p vendor-phpstan) (if (file-executable-p vendor-phpstan) - (list vendor-phpstan) - (list php-executable vendor-phpstan))) + (list expanded-vendor-phpstan) + (list php-executable expanded-vendor-phpstan))) ((executable-find "phpstan") (list (executable-find "phpstan"))) (t (error "PHPStan executable not found"))))))) -(defun phpstan-get-command-args (&optional include-executable) +(cl-defun phpstan-get-command-args (&key include-executable use-pro args format options config verbose editor) "Return command line argument for PHPStan." (let ((executable-and-args (phpstan-get-executable-and-args)) - (path (phpstan-normalize-path (phpstan-get-config-file))) + (config (or config (phpstan-normalize-path (phpstan-get-config-file)))) (autoload (phpstan-get-autoload-file)) (memory-limit (phpstan-get-memory-limit)) (level (phpstan-get-level))) - (append (if include-executable (list (car executable-and-args)) nil) - (cdr executable-and-args) - (list "analyze" "--error-format=raw" "--no-progress" "--no-interaction") - (and path (list "-c" path)) - (and autoload (list "-a" autoload)) - (and memory-limit (list "--memory-limit" memory-limit)) - (and level (list "-l" level)) - (list "--")))) + (nconc (if include-executable (list (car executable-and-args)) nil) + (cdr executable-and-args) + (list "analyze" + (format "--error-format=%s" (or format "raw")) + "--no-progress" "--no-interaction") + (and use-pro (list "--pro" "--no-ansi")) + (and config (list "-c" (phpstan--expand-file-name config))) + (and autoload (list "-a" autoload)) + (and memory-limit (list "--memory-limit" memory-limit)) + (and level (list "-l" level)) + (cond + ((null verbose) nil) + ((memq verbose '(1 t)) (list "-v")) + ((eq verbose 2) (list "-vv")) + ((eq verbose 3) (list "-vvv")) + ((error ":verbose option should be 1, 2, 3 or `t'"))) + (cond + (phpstan--use-xdebug-option (list phpstan--use-xdebug-option)) + ((eq phpstan-use-xdebug-option 'auto) + (setq-local phpstan--use-xdebug-option + (when (string= "1" (php-runtime-expr "extension_loaded('xdebug')")) + "--xdebug")) + (list phpstan--use-xdebug-option)) + (phpstan-use-xdebug-option (list "--xdebug"))) + options + (when editor + (let ((original-file (plist-get editor :original-file))) + (cond + ((funcall (plist-get editor :analyze-original) original-file) + (list "--" original-file)) + ((phpstan-editor-mode-available-p (car (phpstan-get-executable-and-args))) + (list "--tmp-file" (funcall (plist-get editor :temp-file)) + "--instead-of" original-file + "--" original-file)) + ((list "--" (funcall (plist-get editor :inplace))))))) + (if editor args (cons "--" args))))) + +(defun phpstan-update-ignorebale-errors-from-json-buffer (errors) + "Update `phpstan--ignorable-errors' variable by ERRORS." + (let ((identifiers + (cl-loop for (_ . entry) in errors + append (cl-loop for message in (plist-get entry :messages) + if (plist-get message :ignorable) + collect (cons (plist-get message :line) + (plist-get message :identifier)))))) + (setq phpstan--ignorable-errors + (mapcar (lambda (v) (cons (car v) (mapcar #'cdr (cdr v)))) (seq-group-by #'car identifiers))))) + +(defun phpstan-update-dumped-types (errors) + "Update `phpstan--dumped-types' variable by ERRORS." + (save-match-data + (setq phpstan--dumped-types + (cl-loop for (_ . entry) in errors + append (cl-loop for message in (plist-get entry :messages) + for msg = (plist-get message :message) + if (string-match (eval-when-compile (rx bos "Dumped type: ")) msg) + collect (cons (plist-get message :line) + (substring-no-properties msg (match-end 0)))))))) + +(defun phpstan-version (executable) + "Return the PHPStan version of EXECUTABLE." + (if-let* ((cached-entry (assoc executable phpstan-executable-versions-alist))) + (cdr cached-entry) + (let* ((version (thread-first + (mapconcat #'shell-quote-argument (list executable "--version") " ") + (shell-command-to-string) + (string-trim-right) + (split-string " ") + (last) + (car-safe)))) + (prog1 version + (push (cons executable version) phpstan-executable-versions-alist))))) + +(defun phpstan-editor-mode-available-p (executable) + "Check if the specified PHPStan EXECUTABLE supports editor mode. + +If a cached result for EXECUTABLE exists, it is returned directly. +Otherwise, this function attempts to determine support by retrieving +the PHPStan version using `phpstan --version' command." + (pcase phpstan-activate-editor-mode + ('enabled t) + ('disabled nil) + ('nil + (let* ((version (phpstan-version executable))) + (if (string-match-p (eval-when-compile (regexp-quote "-dev@")) version) + t + (pcase (elt version 0) + (?1 (version<= "1.12.27" version)) + (?2 (version<= "2.1.17" version)))))))) + +(defconst phpstan--re-ignore-tag + (eval-when-compile + (rx (* (syntax whitespace)) "//" (* (syntax whitespace)) + (group "@phpstan-ignore") + (* (syntax whitespace)) + (* (not "(")) + (group (? (+ (syntax whitespace) "(")))))) + +(cl-defun phpstan--check-existing-ignore-tag (&key in-previous) + "Check existing @phpstan-ignore PHPDoc tag. +If IN-PREVIOUS is NIL, check the previous line for the tag." + (let ((new-position (if in-previous 'previous-line 'this-line)) + (line-end (line-end-position)) + new-point append) + (save-excursion + (save-match-data + (if (re-search-forward phpstan--re-ignore-tag line-end t) + (progn + (setq new-point (match-beginning 2)) + (goto-char new-point) + (when (eq (char-syntax (char-before)) ?\ ) + (left-char) + (setq new-point (point))) + (setq append (not (eq (match-end 1) (match-beginning 2)))) + (cl-values new-position new-point append)) + (if in-previous + (cl-values nil nil nil) + (previous-logical-line) + (beginning-of-line) + (phpstan--check-existing-ignore-tag :in-previous t))))))) + +;;;###autoload +(defun phpstan-insert-ignore (position) + "Insert an @phpstan-ignore comment at the specified POSITION. + +POSITION determines where to insert the comment and can be either `this-line' or +`previous-line'. + +- If POSITION is `this-line', the comment is inserted at the end of + the current line. +- If POSITION is `previous-line', the comment is inserted on a new line above + the current line." + (interactive + (list (if current-prefix-arg 'this-line 'previous-line))) + (save-restriction + (widen) + (let ((identifiers (cl-set-difference (alist-get (phpstan--current-line) phpstan--ignorable-errors) phpstan-not-ignorable-identifiers :test #'equal)) + (padding (if (eq position 'this-line) " " "")) + new-position new-point append) + (cl-multiple-value-setq (new-position new-point append) (phpstan--check-existing-ignore-tag :in-previous nil)) + (when new-position + (setq position new-position)) + (unless (and append (null identifiers)) + (if (not new-point) + (cond + ((eq position 'this-line) (end-of-line)) + ((eq position 'previous-line) (progn + (previous-logical-line) + (end-of-line) + (newline-and-indent))) + ((error "Unexpected position: %s" position))) + (setq padding "") + (goto-char new-point)) + (insert (concat padding + (if new-position (if append ", " " ") "// @phpstan-ignore ") + (string-join identifiers ", "))))))) + +;;;###autoload +(defun phpstan-copy-dumped-type () + "Copy a dumped PHPStan type." + (interactive) + (if phpstan--dumped-types + (let ((type (if (eq 1 (length phpstan--dumped-types)) + (cdar phpstan--dumped-types) + (let ((linum (line-number-at-pos))) + (cdar (seq-sort-by (lambda (elm) (abs (- linum (car elm)))) #'< phpstan--dumped-types)))))) + (kill-new type) + (message "Copied %s" type)) + (user-error "No dumped PHPStan types"))) + +;;;###autoload +(defun phpstan-insert-dumptype (&optional expression prefix-num) + "Insert PHPStan\\dumpType() expression-statement by EXPRESSION and PREFIX-NUM." + (interactive + (list + (if (region-active-p) + (buffer-substring-no-properties (region-beginning) (region-end)) + (or (thing-at-point 'symbol t) "")) + current-prefix-arg)) + (let ((template (if prefix-num + (cdr phpstan-intert-dump-type-templates) + (car phpstan-intert-dump-type-templates)))) + (end-of-line) + (newline-and-indent) + (insert template) + (search-backward "(" (line-beginning-position) t) + (forward-char) + (insert expression))) (provide 'phpstan) ;;; phpstan.el ends here diff --git a/tests/phpstan-docker.neon b/tests/phpstan-docker.neon index 356d55c..ff593e8 100644 --- a/tests/phpstan-docker.neon +++ b/tests/phpstan-docker.neon @@ -1,2 +1,3 @@ parameters: - bootstrap: %currentWorkingDirectory%/../app/tests/bootstrap.php + bootstrapFiles: + - %currentWorkingDirectory%/../app/tests/bootstrap.php diff --git a/tests/phpstan.neon b/tests/phpstan.neon index 2b4b28c..53c205f 100644 --- a/tests/phpstan.neon +++ b/tests/phpstan.neon @@ -1,2 +1,3 @@ parameters: - bootstrap: %rootDir%/../../../tests/bootstrap.php + bootstrapFiles: + - %rootDir%/../../../tests/bootstrap.php