/ modules / home / programs / emacs / pio-mode / pio-mode.el
pio-mode.el
   1  ;;; pio-mode.el --- PlatformIO & Emacs integration -*- lexical-binding: t; -*-
   2  
   3  ;; Copyright (C) 2022, 2026 Mumtahin Farabi.
   4  ;; Author: Mumtahin Farabi <mfarabi619@gmail.com>
   5  ;; Maintainer: Mumtahin Farabi <mfarabi619@gmail.com>
   6  ;; Created: 13 Oct 2025
   7  ;;
   8  ;; This file is not part of GNU Emacs.
   9  ;;
  10  ;; Version: 0.0.1
  11  ;; Package-Version: 0.0.1
  12  ;; Keywords: c, hardware, processes, tools
  13  ;; Package-Requires: ((platformio-core "6.1.19"))
  14  ;; URL: https://github.com/MFarabi619/MFarabi619/tree/main/modules/home/programs/emacs/pio-mode
  15  
  16  ;;; Commentary:
  17  
  18  ;; PlatformIO is a modern alternative to the Arduino CLI, and is widely adopted in the embedded systems development ecosystem.
  19  ;; This package provides a guts-out, hackable integration with the modern "pio" CLI; with the goal of being a successor to `platformio-mode'.
  20  
  21  ;;; Change Log: Initial Release
  22  
  23  ;;; Code:
  24  
  25  (require 'json)
  26  (require 'ansi-color)
  27  (require 'subr-x)
  28  (require 'seq)
  29  (require 'tabulated-list)
  30  (require 'transient nil t)
  31  
  32  (defgroup pio nil
  33    "PlatformIO integration."
  34    :group 'tools)
  35  
  36  (defcustom pio-executable "platformio"
  37    "Executable name or absolute path for the PlatformIO CLI."
  38    :type 'string
  39    :group 'pio)
  40  
  41  (defcustom pio-cache-enabled t
  42    "Enable in-memory caching for stable PlatformIO JSON commands."
  43    :type 'boolean
  44    :group 'pio)
  45  
  46  (defcustom pio-cache-ttl-account-show 600
  47    "Seconds to cache `pio account show --json-output'."
  48    :type 'integer
  49    :group 'pio)
  50  
  51  (defcustom pio-cache-ttl-org-list 600
  52    "Seconds to cache `pio org list --json-output'."
  53    :type 'integer
  54    :group 'pio)
  55  
  56  (defcustom pio-cache-ttl-team-list 600
  57    "Seconds to cache `pio team list --json-output'."
  58    :type 'integer
  59    :group 'pio)
  60  
  61  (defcustom pio-cache-ttl-boards 1800
  62    "Seconds to cache `pio boards --json-output'."
  63    :type 'integer
  64    :group 'pio)
  65  
  66  (defcustom pio-cache-ttl-device-list 30
  67    "Seconds to cache `pio device list --json-output'."
  68    :type 'integer
  69    :group 'pio)
  70  
  71  (defcustom pio-cache-ttl-remote-device-list 30
  72    "Seconds to cache `pio remote device list --json-output'."
  73    :type 'integer
  74    :group 'pio)
  75  
  76  
  77  (defconst pio-system-info-buffer-name "*PIO*")
  78  
  79  (defconst pio-device-list-buffer-name "*PIO*")
  80  
  81  (defconst pio-remote-device-list-buffer-name "*PIO*")
  82  
  83  (defconst pio-run-list-targets-buffer-name "*PIO*")
  84  
  85  (defconst pio-test-list-tests-buffer-name "*PIO*")
  86  
  87  (defconst pio-check-buffer-name "*PIO*")
  88  
  89  (defconst pio-project-config-buffer-name "*PIO*")
  90  
  91  (defconst pio-board-info-buffer-name "*PIO Board Info*")
  92  
  93  
  94  (defconst pio-account-show-buffer-name "*PIO*")
  95  
  96  (defconst pio-org-list-buffer-name "*PIO*")
  97  
  98  (defconst pio-team-list-buffer-name "*PIO*")
  99  
 100  (defvar-local pio-system-info--json-accumulator nil)
 101  
 102  (defvar-local pio-device-list--json-accumulator nil)
 103  
 104  (defvar-local pio-remote-device-list--json-accumulator nil)
 105  
 106  (defvar-local pio-check--json-accumulator nil)
 107  
 108  (defvar-local pio-project-config--json-accumulator nil)
 109  
 110  (defvar-local pio-boards--all nil)
 111  
 112  (defvar-local pio-boards--filtered nil)
 113  
 114  (defvar-local pio-boards--query "")
 115  
 116  (defvar-local pio-boards--selected-id nil)
 117  
 118  (defvar-local pio-boards--board-by-id nil)
 119  
 120  
 121  (defvar pio--json-command-cache (make-hash-table :test 'equal)
 122    "In-memory cache for JSON command results.")
 123  
 124  (defvar pio--window-configuration nil
 125    "Last non-PIO window configuration for restoring on quit.")
 126  
 127  (defvar pio-boards--window-configuration nil
 128    "Saved window configuration before opening boards fullscreen UI.")
 129  
 130  
 131  (defvar pio-command-map
 132    (let ((map (make-sparse-keymap)))
 133      (define-key map (kbd "a") #'pio-account-show)
 134      (define-key map (kbd "b") #'pio-boards)
 135      (define-key map (kbd "c") #'pio-check)
 136      (define-key map (kbd "d") #'pio-device-list)
 137      (define-key map (kbd "g") #'pio-project-config)
 138      (define-key map (kbd "k") #'pio-cache-clear)
 139      (define-key map (kbd "l") #'pio-run-list-targets)
 140      (define-key map (kbd "m") #'pio-mode)
 141      (define-key map (kbd "o") #'pio-org-list)
 142      (define-key map (kbd "r") #'pio-remote-device-list)
 143      (define-key map (kbd "s") #'pio-system-info)
 144      (define-key map (kbd "x") #'pio-test-list-tests)
 145      (define-key map (kbd "t") #'pio-team-list)
 146      map)
 147    "Prefix keymap for PlatformIO commands.")
 148  
 149  (defun pio-mode-force-refresh ()
 150    "Open the PlatformIO command dispatcher."
 151    (interactive)
 152    (pio-mode))
 153  
 154  (defun pio-account-show-force-refresh ()
 155    "Show account and bypass cache."
 156    (interactive)
 157    (pio-account-show t))
 158  
 159  (defun pio-org-list-force-refresh ()
 160    "Show org list and bypass cache."
 161    (interactive)
 162    (pio-org-list t))
 163  
 164  (defun pio-team-list-force-refresh ()
 165    "Show team list and bypass cache."
 166    (interactive)
 167    (pio-team-list t))
 168  
 169  (defun pio-boards-force-refresh ()
 170    "Show boards and bypass cache."
 171    (interactive)
 172    (pio-boards t))
 173  
 174  (when (featurep 'transient)
 175    (transient-define-prefix pio-transient ()
 176      "PlatformIO command palette."
 177      ["General"
 178       ("m" "Command menu" pio-mode)
 179       ("k" "Clear cache" pio-cache-clear)]
 180      ["Project"
 181       ("s" "System info" pio-system-info)
 182       ("g" "Project config" pio-project-config)
 183       ("d" "Device list" pio-device-list)
 184       ("r" "Remote devices" pio-remote-device-list)
 185       ("b" "Boards" pio-boards)
 186       ("B" "Boards (force)" pio-boards-force-refresh)]
 187      ["Build & Test"
 188       ("l" "Run list targets" pio-run-list-targets)
 189       ("x" "Test list" pio-test-list-tests)
 190       ("c" "Check" pio-check)]
 191      ["Cloud"
 192       ("a" "Account" pio-account-show)
 193       ("A" "Account (force)" pio-account-show-force-refresh)
 194       ("o" "Organizations" pio-org-list)
 195       ("O" "Organizations (force)" pio-org-list-force-refresh)
 196       ("t" "Teams" pio-team-list)
 197       ("T" "Teams (force)" pio-team-list-force-refresh)]))
 198  
 199  (defun pio-dispatch ()
 200    "Open the PlatformIO command dispatcher.
 201  Uses transient when available, otherwise falls back to `pio-command-map'."
 202    (interactive)
 203    (if (fboundp 'pio-transient)
 204        (pio-transient)
 205      (message "Transient is unavailable; use `C-c C-p` prefix commands.")))
 206  
 207  (defun pio--bind-common-keys (mode-map)
 208    "Bind common PlatformIO command keys into MODE-MAP."
 209    (define-key mode-map (kbd "C-c p") #'pio-dispatch)
 210    (define-key mode-map (kbd "C-c C-p") pio-command-map)
 211    (define-key mode-map (kbd "C-c a") #'pio-account-show)
 212    (define-key mode-map (kbd "C-c b") #'pio-boards)
 213    (define-key mode-map (kbd "C-c c") #'pio-check)
 214    (define-key mode-map (kbd "C-c d") #'pio-device-list)
 215    (define-key mode-map (kbd "C-c g") #'pio-project-config)
 216    (define-key mode-map (kbd "C-c k") #'pio-cache-clear)
 217    (define-key mode-map (kbd "C-c l") #'pio-run-list-targets)
 218    (define-key mode-map (kbd "C-c m") #'pio-mode)
 219    (define-key mode-map (kbd "C-c o") #'pio-org-list)
 220    (define-key mode-map (kbd "C-c r") #'pio-remote-device-list)
 221    (define-key mode-map (kbd "C-c s") #'pio-system-info)
 222    (define-key mode-map (kbd "C-c x") #'pio-test-list-tests)
 223    (define-key mode-map (kbd "C-c t") #'pio-team-list)
 224    (define-key mode-map (kbd "q") #'pio-quit-window))
 225  
 226  (defun pio-quit-window ()
 227    "Close the current PIO window cleanly."
 228    (interactive)
 229    (if pio-boards--window-configuration
 230        (progn
 231          (set-window-configuration pio-boards--window-configuration)
 232          (setq pio-boards--window-configuration nil))
 233      (if pio--window-configuration
 234          (progn
 235            (set-window-configuration pio--window-configuration)
 236            (setq pio--window-configuration nil))
 237        (if (one-window-p)
 238            (quit-window)
 239          (delete-window)))))
 240  
 241  (defun pio-boards--restore-window-configuration ()
 242    "Restore the saved pre-boards window configuration."
 243    (when pio-boards--window-configuration
 244      (set-window-configuration pio-boards--window-configuration)
 245      (setq pio-boards--window-configuration nil)))
 246  
 247  (defun pio-boards--setup-fullscreen-layout (results-buffer detail-buffer)
 248    "Show RESULTS-BUFFER and DETAIL-BUFFER in a fullscreen two-pane layout."
 249    (setq pio-boards--window-configuration (current-window-configuration))
 250    (setq pio--window-configuration nil)
 251    (delete-other-windows)
 252    (let ((results-window (selected-window))
 253          (detail-window nil))
 254      (set-window-buffer results-window results-buffer)
 255      (setq detail-window (split-window results-window nil 'right))
 256      (set-window-buffer detail-window detail-buffer)
 257      (select-window results-window)))
 258  
 259  (defun pio-boards--display-layout ()
 260    "Display boards/results in fullscreen split layout."
 261    (let ((results-buffer (get-buffer-create pio-system-info-buffer-name))
 262          (detail-buffer (pio-boards--detail-buffer)))
 263      (pio-boards--setup-fullscreen-layout results-buffer detail-buffer)))
 264  
 265  (defun pio-boards-quit-layout ()
 266    "Quit boards UI and restore the previous window layout."
 267    (interactive)
 268    (if pio-boards--window-configuration
 269        (progn
 270          (pio-boards--restore-window-configuration)
 271          (setq pio--window-configuration nil))
 272      (if (one-window-p)
 273          (quit-window)
 274        (delete-window))))
 275  
 276  (defun pio--find-visible-window ()
 277    "Return a visible window currently showing a PIO buffer, or nil."
 278    (catch 'pio-window
 279      (dolist (win (window-list nil nil))
 280        (let ((name (buffer-name (window-buffer win))))
 281          (when (and (stringp name)
 282                     (string-prefix-p "*PIO" name))
 283            (throw 'pio-window win))))
 284      nil))
 285  
 286  (defun pio--display-buffer-passive (buffer)
 287    "Show BUFFER in a PIO window without changing focus.
 288  If no PIO window is visible, create a regular window on the right." 
 289    (let ((target-window (or (get-buffer-window buffer t)
 290                             (pio--find-visible-window))))
 291      (if (window-live-p target-window)
 292          (set-window-buffer target-window buffer)
 293        (let ((origin (selected-window)))
 294          (unless pio--window-configuration
 295            (setq pio--window-configuration (current-window-configuration)))
 296          (setq target-window (split-window origin nil 'right))
 297          (set-window-buffer target-window buffer)
 298          (select-window origin)))))
 299  
 300  (defun pio--append-ansi-process-output (process output-chunk)
 301    "Append OUTPUT-CHUNK from PROCESS, applying ANSI color sequences."
 302    (when-let ((process-buffer (process-buffer process)))
 303      (with-current-buffer process-buffer
 304        (let ((inhibit-read-only t))
 305          (goto-char (point-max))
 306          (insert (ansi-color-apply output-chunk))))))
 307  
 308  (defun pio-greet ()
 309    "Return the greeting string for pio-mode."
 310    "Hellooo from pio-mode!")
 311  
 312  (defun pio-hello ()
 313    (interactive)
 314    (message "%s" (pio-greet)))
 315  
 316  (defun pio-system-info--resolve-executable ()
 317    "Return the absolute path to the PlatformIO CLI, or signal a user error."
 318    (or (executable-find pio-executable)
 319        (user-error "PlatformIO CLI not found: %s" pio-executable)))
 320  
 321  (defun pio--run-json-command-sync (&rest args)
 322    "Run `platformio' with ARGS and parse JSON output."
 323    (let ((raw-output (apply #'pio--run-command-sync args)))
 324      (condition-case err
 325          (json-parse-string raw-output :object-type 'alist :array-type 'list)
 326        (error
 327         (user-error "Failed to parse JSON from platformio %s: %s"
 328                     (string-join args " ")
 329                     (error-message-string err))))))
 330  
 331  (defun pio--run-command-sync (&rest args)
 332    "Run `platformio' with ARGS and return trimmed stdout string."
 333    (let ((platformio-cli (pio-system-info--resolve-executable)))
 334      (with-temp-buffer
 335        (let ((exit-code (apply #'process-file platformio-cli nil (current-buffer) nil args))
 336              (command-label (format "%s %s" platformio-cli (string-join args " "))))
 337          (let ((raw-output (string-trim (buffer-string))))
 338            (unless (and (integerp exit-code) (zerop exit-code))
 339              (user-error "PlatformIO command failed: %s\n%s" command-label raw-output))
 340            (when (string-empty-p raw-output)
 341              (user-error "PlatformIO command returned empty output: %s" command-label))
 342            raw-output)))))
 343  
 344  (defun pio-cache-clear ()
 345    "Clear the in-memory PlatformIO JSON cache."
 346    (interactive)
 347    (clrhash pio--json-command-cache)
 348    (message "PIO cache cleared"))
 349  
 350  (defun pio--cache-root-key ()
 351    "Return a cache root key based on current project context."
 352    (expand-file-name
 353     (or (locate-dominating-file default-directory "platformio.ini")
 354         default-directory)))
 355  
 356  (defun pio--cache-entry-fresh-p (entry ttl)
 357    "Return non-nil when ENTRY is fresh for TTL seconds."
 358    (and (consp entry)
 359         (numberp (car entry))
 360         (< (- (float-time) (car entry)) ttl)))
 361  
 362  (defun pio--run-json-command-cached (cache-id ttl force-refresh &rest args)
 363    "Run JSON command ARGS with CACHE-ID and TTL.
 364  When FORCE-REFRESH is non-nil, bypass cache." 
 365    (let* ((cache-key (list cache-id (pio--cache-root-key) args))
 366           (entry (gethash cache-key pio--json-command-cache))
 367           (use-cache (and pio-cache-enabled
 368                           (not force-refresh)
 369                           (pio--cache-entry-fresh-p entry ttl))))
 370      (if use-cache
 371          (cdr entry)
 372        (let ((value (apply #'pio--run-json-command-sync args)))
 373          (when pio-cache-enabled
 374            (puthash cache-key (cons (float-time) value) pio--json-command-cache))
 375          value))))
 376  
 377  (defun pio-boards--field (board key)
 378    "Return KEY from BOARD as a normalized display string."
 379    (pio-device-list--normalize-field
 380     (pio-device-list--alist-get-any key board)))
 381  
 382  (defun pio-boards--lookup (board key)
 383    "Return raw KEY value from BOARD alist, preserving list values."
 384    (cdr (assoc key board)))
 385  
 386  (defun pio-boards--format-size-kib (value)
 387    "Return VALUE in bytes as a human-friendly KiB string."
 388    (if (numberp value)
 389        (format "%d KiB" (/ value 1024))
 390      "n/a"))
 391  
 392  (defun pio-boards--format-cpu-hz (value)
 393    "Return CPU frequency VALUE in Hz as MHz text."
 394    (if (numberp value)
 395        (format "%.1f MHz" (/ value 1000000.0))
 396      "n/a"))
 397  
 398  (defun pio-boards--insert-detail-line (label value)
 399    "Insert one detail row with LABEL and VALUE."
 400    (insert (propertize (format "%-11s" (concat label ":")) 'face 'font-lock-variable-name-face)
 401            " "
 402            (propertize (format "%s" value) 'face 'font-lock-string-face)
 403            "\n"))
 404  
 405  (defun pio-boards--debug-tools-summary (board)
 406    "Return formatted debug tools string from BOARD."
 407    (let* ((debug (pio-boards--lookup board 'debug))
 408           (tools-cell (and (listp debug) (assoc 'tools debug)))
 409           (tools (cdr tools-cell))
 410           (names (mapcar (lambda (tool)
 411                            (if (symbolp tool)
 412                                (symbol-name tool)
 413                              (symbol-name (car tool))))
 414                          tools))
 415           (default-tool
 416            (catch 'found
 417              (dolist (tool tools nil)
 418                (when (and (listp tool)
 419                           (assoc 'default tool)
 420                           (cdr (assoc 'default tool)))
 421                  (throw 'found (symbol-name (car tool))))))))
 422      (if names
 423          (if default-tool
 424              (format "%s (default: %s)" (string-join names ", ") default-tool)
 425            (string-join names ", "))
 426        "n/a")))
 427  
 428  (defun pio-boards--board-id (board index)
 429    "Return a stable row id from BOARD and INDEX."
 430    (let ((raw-id (pio-device-list--alist-get-any 'id board)))
 431      (if (and raw-id (not (string-empty-p (format "%s" raw-id))))
 432          (format "%s" raw-id)
 433        (format "board-%d" index))))
 434  
 435  (defun pio-boards--matches-query-p (board query)
 436    "Return non-nil if BOARD matches QUERY."
 437    (if (string-empty-p query)
 438        t
 439      (let ((q (downcase query)))
 440        (or (string-match-p (regexp-quote q) (downcase (pio-boards--field board 'name)))
 441            (string-match-p (regexp-quote q) (downcase (pio-boards--field board 'vendor)))
 442            (string-match-p (regexp-quote q) (downcase (pio-boards--field board 'platform)))
 443            (string-match-p (regexp-quote q) (downcase (pio-boards--field board 'id)))))))
 444  
 445  (defun pio-boards--tabulated-entry (board index)
 446    "Convert BOARD at INDEX into one tabulated entry."
 447    (let ((id (pio-boards--board-id board index))
 448          (name (pio-boards--field board 'name))
 449          (vendor (pio-boards--field board 'vendor))
 450          (platform (pio-boards--field board 'platform)))
 451      (puthash id board pio-boards--board-by-id)
 452      (list id (vector name vendor platform))))
 453  
 454  (defun pio-boards--render-list ()
 455    "Render the boards list from local state."
 456    (setq pio-boards--board-by-id (make-hash-table :test 'equal))
 457    (let ((index 0))
 458      (setq tabulated-list-entries
 459            (mapcar (lambda (board)
 460                      (setq index (1+ index))
 461                      (pio-boards--tabulated-entry board index))
 462                    pio-boards--filtered)))
 463    (setq header-line-format
 464          (format " Boards  |  %d/%d  |  Filter: %s"
 465                  (length pio-boards--filtered)
 466                  (length pio-boards--all)
 467                  (if (string-empty-p pio-boards--query) "<none>" pio-boards--query)))
 468    (tabulated-list-print t)
 469    (goto-char (point-min))
 470    (forward-line 1)
 471    (pio-boards--update-detail-at-point))
 472  
 473  (defun pio-boards--apply-filter (query)
 474    "Apply QUERY to boards list and rerender." 
 475    (setq pio-boards--query (string-trim (or query ""))
 476          pio-boards--filtered
 477          (seq-filter (lambda (board)
 478                        (pio-boards--matches-query-p board pio-boards--query))
 479                      pio-boards--all))
 480    (pio-boards--render-list))
 481  
 482  (defun pio-boards--detail-buffer ()
 483    "Return the board detail buffer." 
 484    (get-buffer-create pio-board-info-buffer-name))
 485  
 486  (defun pio-boards--render-board-detail (board)
 487    "Render BOARD object into the detail buffer."
 488    (with-current-buffer (pio-boards--detail-buffer)
 489      (let ((inhibit-read-only t))
 490        (erase-buffer)
 491        (pio-board-info-mode)
 492        (setq-local truncate-lines t)
 493        (let ((name (pio-boards--field board 'name))
 494              (board-id (pio-boards--field board 'id))
 495              (vendor (pio-boards--field board 'vendor))
 496              (platform (pio-boards--field board 'platform))
 497              (mcu (pio-boards--field board 'mcu))
 498              (frameworks (pio-boards--lookup board 'frameworks))
 499              (url (pio-boards--field board 'url))
 500              (ram (pio-boards--lookup board 'ram))
 501              (rom (pio-boards--lookup board 'rom))
 502              (f-cpu (pio-boards--lookup board 'f_cpu)))
 503          (insert (propertize name 'face 'bold) "\n")
 504          (insert (propertize board-id 'face 'shadow) "\n\n")
 505          (insert (propertize "Overview\n" 'face 'font-lock-keyword-face))
 506          (insert "--------\n")
 507          (pio-boards--insert-detail-line "Vendor" vendor)
 508          (pio-boards--insert-detail-line "Platform" platform)
 509          (pio-boards--insert-detail-line "MCU" mcu)
 510          (pio-boards--insert-detail-line "CPU" (pio-boards--format-cpu-hz f-cpu))
 511          (pio-boards--insert-detail-line "RAM" (pio-boards--format-size-kib ram))
 512          (pio-boards--insert-detail-line "Flash" (pio-boards--format-size-kib rom))
 513          (pio-boards--insert-detail-line "Frameworks"
 514                                          (if (listp frameworks)
 515                                              (string-join (mapcar #'format frameworks) ", ")
 516                                            "n/a"))
 517          (pio-boards--insert-detail-line "Debug" (pio-boards--debug-tools-summary board))
 518          (insert "\n")
 519          (insert (propertize "URL\n" 'face 'font-lock-keyword-face))
 520          (insert "---\n")
 521          (insert-text-button url
 522                              'action (lambda (_)
 523                                        (browse-url url))
 524                              'follow-link t
 525                              'help-echo "Open board page in browser")
 526          (insert "\n\n")
 527          (insert (propertize "Raw Data\n" 'face 'font-lock-keyword-face))
 528          (insert "--------\n")
 529          (condition-case _err
 530              (let ((json-encoding-pretty-print t))
 531                (insert (json-encode board))
 532                (json-pretty-print-buffer)
 533                (goto-char (point-max)))
 534            (error
 535             (pp board (current-buffer)))))
 536        (goto-char (point-min)))))
 537  
 538  (defun pio-boards--update-detail-at-point ()
 539    "Update detail pane using the current row in results buffer."
 540    (when (derived-mode-p 'pio-boards-mode)
 541      (let ((id (tabulated-list-get-id)))
 542        (unless (equal id pio-boards--selected-id)
 543          (setq pio-boards--selected-id id)
 544          (when id
 545            (when-let ((board (gethash id pio-boards--board-by-id)))
 546              (pio-boards--render-board-detail board)))))))
 547  
 548  (defun pio-boards-filter (query)
 549    "Prompt and apply board filter QUERY." 
 550    (interactive (list (read-from-minibuffer
 551                        "Boards filter: "
 552                        pio-boards--query)))
 553    (pio-boards--apply-filter query))
 554  
 555  (defun pio-boards-clear-filter ()
 556    "Clear board list filter."
 557    (interactive)
 558    (pio-boards--apply-filter ""))
 559  
 560  (defun pio-boards-refresh (&optional force-refresh)
 561    "Refresh boards data. With FORCE-REFRESH, bypass cache."
 562    (interactive "P")
 563    (let ((boards (pio--run-json-command-cached
 564                   'boards
 565                   pio-cache-ttl-boards
 566                   force-refresh
 567                   "boards" "--json-output")))
 568      (setq pio-boards--all boards)
 569      (pio-boards--apply-filter pio-boards--query)))
 570  
 571  (defun pio-boards--post-command-hook ()
 572    "Keep board detail pane synced with current line."
 573    (pio-boards--update-detail-at-point))
 574  
 575  (define-derived-mode pio-boards-mode tabulated-list-mode "PIO-Boards"
 576    "Major mode for browsing PlatformIO boards."
 577    (setq tabulated-list-format
 578          [("Name" 34 t)
 579           ("Vendor" 22 t)
 580           ("Platform" 16 t)])
 581    (setq tabulated-list-padding 2)
 582    (setq tabulated-list-sort-key (cons "Name" nil))
 583    (setq tabulated-list-use-header-line t)
 584    (tabulated-list-init-header)
 585    (add-hook 'post-command-hook #'pio-boards--post-command-hook nil t))
 586  
 587  (define-derived-mode pio-board-info-mode special-mode "PIO-Board-Info"
 588    "Major mode for displaying board detail JSON.")
 589  
 590  (pio--bind-common-keys pio-boards-mode-map)
 591  (pio--bind-common-keys pio-board-info-mode-map)
 592  (define-key pio-boards-mode-map (kbd "g") #'pio-boards-refresh)
 593  (define-key pio-boards-mode-map (kbd "G") (lambda () (interactive) (pio-boards-refresh t)))
 594  (define-key pio-boards-mode-map (kbd "/") #'pio-boards-filter)
 595  (define-key pio-boards-mode-map (kbd "C-c /") #'pio-boards-clear-filter)
 596  (define-key pio-boards-mode-map (kbd "q") #'pio-boards-quit-layout)
 597  (define-key pio-board-info-mode-map (kbd "q") #'pio-boards-quit-layout)
 598  
 599  (defun pio-boards (&optional force-refresh)
 600    "Open boards explorer with list and board detail panes.
 601  With FORCE-REFRESH, bypass cache for boards data." 
 602    (interactive "P")
 603    (let ((results-buffer (get-buffer-create pio-system-info-buffer-name)))
 604      (with-current-buffer results-buffer
 605        (pio-boards-mode)
 606        (setq-local truncate-lines t))
 607      (pio-boards--display-layout)
 608      (with-current-buffer results-buffer
 609        (pio-boards-refresh force-refresh))))
 610  
 611  (defun pio--person-display-name (person)
 612    "Return a readable display name string from PERSON alist."
 613    (let* ((username (pio-device-list--alist-get-any 'username person))
 614           (firstname (pio-device-list--alist-get-any 'firstname person))
 615           (lastname (pio-device-list--alist-get-any 'lastname person))
 616           (full-name (string-trim (format "%s %s"
 617                                      (or firstname "")
 618                                      (or lastname "")))))
 619      (if (string-empty-p full-name)
 620          (pio-device-list--normalize-field username)
 621        full-name)))
 622  
 623  (defun pio-system-info--infer-section (field-title)
 624    "Infer a section label from FIELD-TITLE."
 625    (cond
 626     ((string-match-p "Core" field-title) "Core")
 627     ((string-match-p "Python" field-title) "Python")
 628     ((string-match-p "System" field-title) "System")
 629     ((string-match-p "Platform" field-title) "Platform")
 630     ((string-match-p "Libraries" field-title) "Libraries")
 631     ((string-match-p "Tool" field-title) "Toolchains")
 632     (t "Other")))
 633  
 634  (defun pio-system-info--format-field-title (raw-title)
 635    "Return a display title for RAW-TITLE, including a section prefix."
 636    (let ((section (pio-system-info--infer-section raw-title)))
 637      (format "%s › %s" section raw-title)))
 638  
 639  (defun pio-system-info--propertize-value (value)
 640    "Return VALUE as a pretty, colored string for display."
 641    (propertize (format "%s" value) 'face 'font-lock-string-face))
 642  
 643  (defun pio-system-info--tabulated-entry-from-json-pair (json-pair)
 644    "Convert JSON-PAIR into a `tabulated-list-entries' row."
 645    (let* ((entry-key (car json-pair))
 646           (entry-object (cdr json-pair))
 647           (field-title (alist-get 'title entry-object))
 648           (field-value (alist-get 'value entry-object))
 649           (display-title (pio-system-info--format-field-title field-title))
 650           (display-value (pio-system-info--propertize-value field-value)))
 651      (list (symbol-name entry-key)
 652            (vector display-title display-value))))
 653  
 654  (defun pio-system-info--entries-from-json (json-string)
 655    "Parse JSON-STRING and return `tabulated-list-entries'."
 656    (let ((parsed-json (json-parse-string json-string :object-type 'alist :array-type 'list)))
 657      (mapcar #'pio-system-info--tabulated-entry-from-json-pair parsed-json)))
 658  
 659  (defun pio-system-info--render-buffer (system-info-buffer)
 660    "Initialize and render SYSTEM-INFO-BUFFER for system info output."
 661    (with-current-buffer system-info-buffer
 662      (pio-system-info-mode)
 663      (setq tabulated-list-entries nil)
 664      (setq-local pio-system-info--json-accumulator nil)
 665      (tabulated-list-print)))
 666  
 667  (defun pio-system-info--append-process-output (process output-chunk)
 668    "Append OUTPUT-CHUNK from PROCESS into the current buffer accumulator."
 669    (when-let ((process-buffer (process-buffer process)))
 670      (with-current-buffer process-buffer
 671        (setq-local pio-system-info--json-accumulator
 672                    (concat (or pio-system-info--json-accumulator "") output-chunk)))))
 673  
 674  (defun pio-system-info--finalize-process (_process _event)
 675    "Finalize system info output: parse JSON and refresh the tabulated list."
 676    (with-current-buffer pio-system-info-buffer-name
 677      (let* ((json-string (or pio-system-info--json-accumulator "{}"))
 678             (tabulated-entries (pio-system-info--entries-from-json json-string)))
 679        (setq tabulated-list-entries tabulated-entries
 680              pio-system-info--json-accumulator nil)
 681        (tabulated-list-revert))))
 682  
 683  (define-derived-mode pio-system-info-mode tabulated-list-mode "PIO-System"
 684    "Major mode for displaying `pio system info --json-output' in a table."
 685    (setq tabulated-list-format
 686          [("Field" 28 t)
 687           ("Value" 0 t)])
 688    (setq tabulated-list-padding 4)
 689    (setq tabulated-list-sort-key (cons "Field" nil))
 690    (setq tabulated-list-use-header-line t)
 691    (tabulated-list-init-header))
 692  
 693  (pio--bind-common-keys pio-system-info-mode-map)
 694  
 695  (defun pio-system-info ()
 696    "Display `pio system info --json-output' in a grid."
 697    (interactive)
 698    (let* ((platformio-cli (pio-system-info--resolve-executable))
 699           (system-info-buffer (get-buffer-create pio-system-info-buffer-name)))
 700      (pio-system-info--render-buffer system-info-buffer)
 701      (pio--display-buffer-passive system-info-buffer)
 702      (make-process
 703       :name "pio-system-info"
 704       :buffer system-info-buffer
 705       :command (list platformio-cli "system" "info" "--json-output")
 706       :filter #'pio-system-info--append-process-output
 707       :sentinel #'pio-system-info--finalize-process)))
 708  
 709  (defun pio-device-list--alist-get-any (key alist)
 710    "Get KEY from ALIST, supporting symbol and string forms."
 711    (or (alist-get key alist)
 712        (alist-get (symbol-name key) alist nil nil #'equal)))
 713  
 714  (defun pio-device-list--normalize-field (value)
 715    "Return VALUE as a display-safe device field string."
 716    (let ((as-string (string-trim (format "%s" (or value "")))))
 717      (if (string-empty-p as-string) "n/a" as-string)))
 718  
 719  (defun pio-device-list--tabulated-entry-from-device (device index)
 720    "Convert DEVICE at INDEX into a `tabulated-list-entries' row."
 721    (let* ((port (pio-device-list--normalize-field
 722                  (pio-device-list--alist-get-any 'port device)))
 723           (description (pio-device-list--normalize-field
 724                         (pio-device-list--alist-get-any 'description device)))
 725           (hwid (pio-device-list--normalize-field
 726                  (pio-device-list--alist-get-any 'hwid device)))
 727           (entry-id (format "%s#%d" port index)))
 728      (list entry-id
 729            (vector port description hwid))))
 730  
 731  (defun pio-device-list--entries-from-json (json-string)
 732    "Parse JSON-STRING and return `tabulated-list-entries'."
 733    (let ((parsed-json (json-parse-string json-string :object-type 'alist :array-type 'list))
 734          (index 0))
 735      (mapcar (lambda (device)
 736                (setq index (1+ index))
 737                (pio-device-list--tabulated-entry-from-device device index))
 738              parsed-json)))
 739  
 740  (defun pio-device-list--entries-from-json-data (parsed-json)
 741    "Convert PARSED-JSON from `pio device list' into `tabulated-list-entries'."
 742    (let ((index 0))
 743      (mapcar (lambda (device)
 744                (setq index (1+ index))
 745                (pio-device-list--tabulated-entry-from-device device index))
 746              parsed-json)))
 747  
 748  (defun pio-device-list--render-buffer (device-list-buffer)
 749    "Initialize and render DEVICE-LIST-BUFFER for device list output."
 750    (with-current-buffer device-list-buffer
 751      (pio-device-list-mode)
 752      (setq tabulated-list-entries nil)
 753      (setq-local pio-device-list--json-accumulator nil)
 754      (tabulated-list-print)))
 755  
 756  (defun pio-device-list--append-process-output (process output-chunk)
 757    "Append OUTPUT-CHUNK from PROCESS into the current buffer accumulator."
 758    (when-let ((process-buffer (process-buffer process)))
 759      (with-current-buffer process-buffer
 760        (setq-local pio-device-list--json-accumulator
 761                    (concat (or pio-device-list--json-accumulator "") output-chunk)))))
 762  
 763  (defun pio-device-list--finalize-process (_process _event)
 764    "Finalize device list output: parse JSON and refresh tabulated list."
 765    (when-let ((device-list-buffer (get-buffer pio-device-list-buffer-name)))
 766      (with-current-buffer device-list-buffer
 767        (let* ((json-string (or pio-device-list--json-accumulator "[]"))
 768               (tabulated-entries (pio-device-list--entries-from-json json-string)))
 769          (setq tabulated-list-entries tabulated-entries
 770                pio-device-list--json-accumulator nil)
 771          (tabulated-list-revert)))))
 772  
 773  (define-derived-mode pio-device-list-mode tabulated-list-mode "PIO-Devices"
 774    "Major mode for displaying `pio device list --json-output' in a table."
 775    (setq tabulated-list-format
 776          [("Port" 22 t)
 777           ("Description" 34 t)
 778           ("HWID" 0 t)])
 779    (setq tabulated-list-padding 2)
 780    (setq tabulated-list-sort-key (cons "Port" nil))
 781    (setq tabulated-list-use-header-line t)
 782    (tabulated-list-init-header))
 783  
 784  (pio--bind-common-keys pio-device-list-mode-map)
 785  
 786  (defun pio-device-list (&optional force-refresh)
 787    "Display `pio device list --json-output' in a table."
 788    (interactive "P")
 789    (let* ((parsed-json (pio--run-json-command-cached
 790                         'device-list
 791                         pio-cache-ttl-device-list
 792                         force-refresh
 793                         "device" "list" "--json-output"))
 794           (device-list-buffer (get-buffer-create pio-device-list-buffer-name)))
 795      (with-current-buffer device-list-buffer
 796        (pio-device-list-mode)
 797        (setq tabulated-list-entries (pio-device-list--entries-from-json-data parsed-json))
 798        (tabulated-list-print))
 799      (pio--display-buffer-passive device-list-buffer)))
 800  
 801  (defun pio-org-list--owners-string (owners)
 802    "Return a readable owners string from OWNERS list."
 803    (if owners
 804        (string-join (mapcar #'pio--person-display-name owners) ", ")
 805      "n/a"))
 806  
 807  (defun pio-org-list--tabulated-entry-from-org (org index)
 808    "Convert ORG at INDEX into a `tabulated-list-entries' row."
 809    (let* ((orgname (pio-device-list--normalize-field
 810                     (pio-device-list--alist-get-any 'orgname org)))
 811           (displayname (pio-device-list--normalize-field
 812                         (pio-device-list--alist-get-any 'displayname org)))
 813           (email (pio-device-list--normalize-field
 814                   (pio-device-list--alist-get-any 'email org)))
 815           (owners (pio-org-list--owners-string
 816                    (pio-device-list--alist-get-any 'owners org))))
 817      (list (format "%s#%d" orgname index)
 818            (vector orgname displayname email owners))))
 819  
 820  (defun pio-org-list--entries-from-json-data (parsed-json)
 821    "Convert PARSED-JSON from `pio org list' into `tabulated-list-entries'."
 822    (let ((index 0))
 823      (mapcar (lambda (org)
 824                (setq index (1+ index))
 825                (pio-org-list--tabulated-entry-from-org org index))
 826              parsed-json)))
 827  
 828  (define-derived-mode pio-org-list-mode tabulated-list-mode "PIO-Orgs"
 829    "Major mode for displaying `pio org list --json-output' in a table."
 830    (setq tabulated-list-format
 831          [("Org" 24 t)
 832           ("Display Name" 24 t)
 833           ("Email" 30 t)
 834           ("Owners" 0 t)])
 835    (setq tabulated-list-padding 2)
 836    (setq tabulated-list-sort-key (cons "Org" nil))
 837    (setq tabulated-list-use-header-line t)
 838    (tabulated-list-init-header))
 839  
 840  (pio--bind-common-keys pio-org-list-mode-map)
 841  
 842  (defun pio-org-list (&optional force-refresh)
 843    "Display `pio org list --json-output' in a table."
 844    (interactive "P")
 845    (let* ((parsed-json (pio--run-json-command-cached
 846                         'org-list
 847                         pio-cache-ttl-org-list
 848                         force-refresh
 849                         "org" "list" "--json-output"))
 850           (org-list-buffer (get-buffer-create pio-org-list-buffer-name)))
 851      (with-current-buffer org-list-buffer
 852        (pio-org-list-mode)
 853        (setq tabulated-list-entries (pio-org-list--entries-from-json-data parsed-json))
 854        (tabulated-list-print))
 855      (pio--display-buffer-passive org-list-buffer)))
 856  
 857  (defun pio-team-list--members-string (members)
 858    "Return a readable members string from MEMBERS list."
 859    (if members
 860        (string-join (mapcar #'pio--person-display-name members) ", ")
 861      "n/a"))
 862  
 863  (defun pio-team-list--tabulated-entry-from-team (org team index)
 864    "Convert ORG TEAM at INDEX into a `tabulated-list-entries' row."
 865    (let* ((team-name (pio-device-list--normalize-field
 866                       (pio-device-list--alist-get-any 'name team)))
 867           (description (pio-device-list--normalize-field
 868                         (pio-device-list--alist-get-any 'description team)))
 869           (members (pio-team-list--members-string
 870                     (pio-device-list--alist-get-any 'members team)))
 871           (team-id (pio-device-list--normalize-field
 872                     (pio-device-list--alist-get-any 'id team))))
 873      (list (format "%s:%s#%d" org team-name index)
 874            (vector org team-name description members team-id))))
 875  
 876  (defun pio-team-list--entries-from-json-data (parsed-json)
 877    "Convert PARSED-JSON from `pio team list' into `tabulated-list-entries'."
 878    (let ((entries nil)
 879          (index 0))
 880      (dolist (org-pair parsed-json (nreverse entries))
 881        (let* ((org-key (car org-pair))
 882               (org-name (if (symbolp org-key) (symbol-name org-key) org-key))
 883               (teams (cdr org-pair)))
 884          (dolist (team teams)
 885            (setq index (1+ index))
 886            (push (pio-team-list--tabulated-entry-from-team org-name team index) entries))))))
 887  
 888  (define-derived-mode pio-team-list-mode tabulated-list-mode "PIO-Teams"
 889    "Major mode for displaying `pio team list --json-output' in a table."
 890    (setq tabulated-list-format
 891          [("Org" 24 t)
 892           ("Team" 16 t)
 893           ("Description" 28 t)
 894           ("Members" 28 t)
 895           ("ID" 0 t)])
 896    (setq tabulated-list-padding 2)
 897    (setq tabulated-list-sort-key (cons "Org" nil))
 898    (setq tabulated-list-use-header-line t)
 899    (tabulated-list-init-header))
 900  
 901  (pio--bind-common-keys pio-team-list-mode-map)
 902  
 903  (defun pio-team-list (&optional force-refresh)
 904    "Display `pio team list --json-output' in a table."
 905    (interactive "P")
 906    (let* ((parsed-json (pio--run-json-command-cached
 907                         'team-list
 908                         pio-cache-ttl-team-list
 909                         force-refresh
 910                         "team" "list" "--json-output"))
 911           (team-list-buffer (get-buffer-create pio-team-list-buffer-name)))
 912      (with-current-buffer team-list-buffer
 913        (pio-team-list-mode)
 914        (setq tabulated-list-entries (pio-team-list--entries-from-json-data parsed-json))
 915        (tabulated-list-print))
 916      (pio--display-buffer-passive team-list-buffer)))
 917  
 918  (defun pio-remote-device-list--tabulated-entry-from-device (host device index)
 919    "Convert HOST DEVICE at INDEX into a `tabulated-list-entries' row."
 920    (let* ((port (pio-device-list--normalize-field
 921                  (pio-device-list--alist-get-any 'port device)))
 922           (description (pio-device-list--normalize-field
 923                         (pio-device-list--alist-get-any 'description device)))
 924           (hwid (pio-device-list--normalize-field
 925                  (pio-device-list--alist-get-any 'hwid device)))
 926           (entry-id (format "%s:%s#%d" host port index)))
 927      (list entry-id
 928            (vector host port description hwid))))
 929  
 930  (defun pio-remote-device-list--entries-from-json (json-string)
 931    "Parse JSON-STRING and return `tabulated-list-entries'."
 932    (let ((parsed-json (json-parse-string json-string :object-type 'alist :array-type 'list))
 933          (entries nil)
 934          (index 0))
 935      (dolist (host-pair parsed-json (nreverse entries))
 936        (let* ((host-key (car host-pair))
 937               (host (pio-device-list--normalize-field
 938                      (if (symbolp host-key)
 939                          (symbol-name host-key)
 940                        host-key)))
 941               (devices (cdr host-pair)))
 942          (dolist (device devices)
 943            (setq index (1+ index))
 944            (push (pio-remote-device-list--tabulated-entry-from-device host device index)
 945                  entries))))))
 946  
 947  (defun pio-remote-device-list--entries-from-json-data (parsed-json)
 948    "Convert PARSED-JSON from `pio remote device list' into `tabulated-list-entries'."
 949    (let ((entries nil)
 950          (index 0))
 951      (dolist (host-pair parsed-json (nreverse entries))
 952        (let* ((host-key (car host-pair))
 953               (host (pio-device-list--normalize-field
 954                      (if (symbolp host-key) (symbol-name host-key) host-key)))
 955               (devices (cdr host-pair)))
 956          (dolist (device devices)
 957            (setq index (1+ index))
 958            (push (pio-remote-device-list--tabulated-entry-from-device host device index)
 959                  entries))))))
 960  
 961  (defun pio-remote-device-list--render-buffer (remote-device-list-buffer)
 962    "Initialize and render REMOTE-DEVICE-LIST-BUFFER for remote device output."
 963    (with-current-buffer remote-device-list-buffer
 964      (pio-remote-device-list-mode)
 965      (setq tabulated-list-entries nil)
 966      (setq-local pio-remote-device-list--json-accumulator nil)
 967      (tabulated-list-print)))
 968  
 969  (defun pio-remote-device-list--append-process-output (process output-chunk)
 970    "Append OUTPUT-CHUNK from PROCESS into the current buffer accumulator."
 971    (when-let ((process-buffer (process-buffer process)))
 972      (with-current-buffer process-buffer
 973        (setq-local pio-remote-device-list--json-accumulator
 974                    (concat (or pio-remote-device-list--json-accumulator "") output-chunk)))))
 975  
 976  (defun pio-remote-device-list--finalize-process (_process _event)
 977    "Finalize remote device output: parse JSON and refresh tabulated list."
 978    (when-let ((remote-device-list-buffer (get-buffer pio-remote-device-list-buffer-name)))
 979      (with-current-buffer remote-device-list-buffer
 980        (let* ((json-string (or pio-remote-device-list--json-accumulator "{}"))
 981               (tabulated-entries (pio-remote-device-list--entries-from-json json-string)))
 982          (setq tabulated-list-entries tabulated-entries
 983                pio-remote-device-list--json-accumulator nil)
 984          (tabulated-list-revert)))))
 985  
 986  (define-derived-mode pio-remote-device-list-mode tabulated-list-mode "PIO-Remote-Devices"
 987    "Major mode for displaying `pio remote device list --json-output' in a table."
 988    (setq tabulated-list-format
 989          [("Host" 18 t)
 990           ("Port" 22 t)
 991           ("Description" 34 t)
 992           ("HWID" 0 t)])
 993    (setq tabulated-list-padding 2)
 994    (setq tabulated-list-sort-key (cons "Host" nil))
 995    (setq tabulated-list-use-header-line t)
 996    (tabulated-list-init-header))
 997  
 998  (pio--bind-common-keys pio-remote-device-list-mode-map)
 999  
1000  (defun pio-remote-device-list (&optional force-refresh)
1001    "Display `pio remote device list --json-output' in a table."
1002    (interactive "P")
1003    (let* ((parsed-json (pio--run-json-command-cached
1004                         'remote-device-list
1005                         pio-cache-ttl-remote-device-list
1006                         force-refresh
1007                         "remote" "device" "list" "--json-output"))
1008           (remote-device-list-buffer (get-buffer-create pio-remote-device-list-buffer-name)))
1009      (with-current-buffer remote-device-list-buffer
1010        (pio-remote-device-list-mode)
1011        (setq tabulated-list-entries
1012              (pio-remote-device-list--entries-from-json-data parsed-json))
1013        (tabulated-list-print))
1014      (pio--display-buffer-passive remote-device-list-buffer)))
1015  
1016  (define-derived-mode pio-run-list-targets-mode special-mode "PIO-Run-Targets"
1017    "Major mode for displaying `pio run --list-targets' output.")
1018  
1019  (pio--bind-common-keys pio-run-list-targets-mode-map)
1020  
1021  (defun pio-run-list-targets--append-process-output (process output-chunk)
1022    "Append OUTPUT-CHUNK from PROCESS directly into its process buffer."
1023    (pio--append-ansi-process-output process output-chunk))
1024  
1025  (defun pio-run-list-targets--finalize-process (process event)
1026    "Finalize PROCESS for list targets output and append EVENT if needed."
1027    (when-let ((process-buffer (process-buffer process)))
1028      (with-current-buffer process-buffer
1029        (let ((inhibit-read-only t))
1030          (goto-char (point-max))
1031          (unless (and (stringp event)
1032                       (string-match-p "finished" event))
1033            (insert "\n" event))))))
1034  
1035  (defun pio-run-list-targets ()
1036    "Display raw output of `pio run --list-targets'."
1037    (interactive)
1038    (let* ((platformio-cli (pio-system-info--resolve-executable))
1039           (run-targets-buffer (get-buffer-create pio-run-list-targets-buffer-name)))
1040      (with-current-buffer run-targets-buffer
1041        (let ((inhibit-read-only t))
1042          (erase-buffer)
1043          (setq-local ansi-color-context nil)
1044          (pio-run-list-targets-mode)))
1045      (pio--display-buffer-passive run-targets-buffer)
1046      (make-process
1047       :name "pio-run-list-targets"
1048       :buffer run-targets-buffer
1049       :command (list platformio-cli "run" "--list-targets")
1050       :filter #'pio-run-list-targets--append-process-output
1051       :sentinel #'pio-run-list-targets--finalize-process)))
1052  
1053  (define-derived-mode pio-test-list-tests-mode special-mode "PIO-Test-List"
1054    "Major mode for displaying `pio test --list-tests' output.")
1055  
1056  (pio--bind-common-keys pio-test-list-tests-mode-map)
1057  
1058  (defun pio-test-list-tests--append-process-output (process output-chunk)
1059    "Append OUTPUT-CHUNK from PROCESS directly into its process buffer."
1060    (pio--append-ansi-process-output process output-chunk))
1061  
1062  (defun pio-test-list-tests--finalize-process (process event)
1063    "Finalize PROCESS for list tests output and append EVENT if needed."
1064    (when-let ((process-buffer (process-buffer process)))
1065      (with-current-buffer process-buffer
1066        (let ((inhibit-read-only t))
1067          (goto-char (point-max))
1068          (unless (and (stringp event)
1069                       (string-match-p "finished" event))
1070            (insert "\n" event))))))
1071  
1072  (defun pio-test-list-tests ()
1073    "Display raw output of `pio test --list-tests'."
1074    (interactive)
1075    (let* ((platformio-cli (pio-system-info--resolve-executable))
1076           (test-list-buffer (get-buffer-create pio-test-list-tests-buffer-name)))
1077      (with-current-buffer test-list-buffer
1078        (let ((inhibit-read-only t))
1079          (erase-buffer)
1080          (setq-local ansi-color-context nil)
1081          (pio-test-list-tests-mode)))
1082      (pio--display-buffer-passive test-list-buffer)
1083      (make-process
1084       :name "pio-test-list-tests"
1085       :buffer test-list-buffer
1086       :command (list platformio-cli "test" "--list-tests")
1087       :filter #'pio-test-list-tests--append-process-output
1088       :sentinel #'pio-test-list-tests--finalize-process)))
1089  
1090  (defun pio-check--severity-face (severity)
1091    "Return a face for SEVERITY string."
1092    (pcase (downcase (format "%s" severity))
1093      ("high" 'error)
1094      ("medium" 'warning)
1095      ("low" 'font-lock-doc-face)
1096      (_ 'default)))
1097  
1098  (defun pio-check--format-location (file line)
1099    "Return a compact location string for FILE and LINE."
1100    (format "%s:%s"
1101            (abbreviate-file-name (pio-device-list--normalize-field file))
1102            (if (numberp line) (number-to-string line) "-")))
1103  
1104  (defun pio-check--tabulated-entry-from-defect (env tool defect index)
1105    "Convert DEFECT at INDEX into a `tabulated-list-entries' row."
1106    (let* ((severity (pio-device-list--normalize-field
1107                      (pio-device-list--alist-get-any 'severity defect)))
1108           (category (pio-device-list--normalize-field
1109                      (pio-device-list--alist-get-any 'category defect)))
1110           (defect-id (pio-device-list--normalize-field
1111                       (pio-device-list--alist-get-any 'id defect)))
1112           (message (pio-device-list--normalize-field
1113                     (pio-device-list--alist-get-any 'message defect)))
1114           (file (pio-device-list--alist-get-any 'file defect))
1115           (line (pio-device-list--alist-get-any 'line defect))
1116           (location (pio-check--format-location file line)))
1117      (list (format "%s#%d" defect-id index)
1118            (vector env
1119                    tool
1120                    (propertize severity 'face (pio-check--severity-face severity))
1121                    category
1122                    defect-id
1123                    location
1124                    message))))
1125  
1126  (defun pio-check--entries-from-json (json-string)
1127    "Parse JSON-STRING and return `tabulated-list-entries'."
1128    (let ((parsed-json (json-parse-string json-string :object-type 'alist :array-type 'list))
1129          (entries nil)
1130          (index 0))
1131      (dolist (report parsed-json (nreverse entries))
1132        (let* ((env (pio-device-list--normalize-field
1133                     (pio-device-list--alist-get-any 'env report)))
1134               (tool (pio-device-list--normalize-field
1135                      (pio-device-list--alist-get-any 'tool report)))
1136               (defects (pio-device-list--alist-get-any 'defects report)))
1137          (dolist (defect defects)
1138            (setq index (1+ index))
1139            (push (pio-check--tabulated-entry-from-defect env tool defect index)
1140                  entries))))))
1141  
1142  (defun pio-check--render-buffer (check-buffer)
1143    "Initialize and render CHECK-BUFFER for pio check output."
1144    (with-current-buffer check-buffer
1145      (pio-check-mode)
1146      (setq tabulated-list-entries nil)
1147      (setq-local pio-check--json-accumulator nil)
1148      (tabulated-list-print)))
1149  
1150  (defun pio-check--append-process-output (process output-chunk)
1151    "Append OUTPUT-CHUNK from PROCESS into the current buffer accumulator."
1152    (when-let ((process-buffer (process-buffer process)))
1153      (with-current-buffer process-buffer
1154        (setq-local pio-check--json-accumulator
1155                    (concat (or pio-check--json-accumulator "") output-chunk)))))
1156  
1157  (defun pio-check--finalize-process (_process _event)
1158    "Finalize pio check output: parse JSON and refresh tabulated list."
1159    (when-let ((check-buffer (get-buffer pio-check-buffer-name)))
1160      (with-current-buffer check-buffer
1161        (let* ((json-string (or pio-check--json-accumulator "[]"))
1162               (tabulated-entries
1163                (condition-case err
1164                    (pio-check--entries-from-json json-string)
1165                  (error
1166                   (list (list "pio-check-error"
1167                               (vector "-"
1168                                       "-"
1169                                       "error"
1170                                       "-"
1171                                       "parse"
1172                                       "-"
1173                                       (format "Failed to parse pio check output: %s"
1174                                               (error-message-string err)))))))))
1175          (setq tabulated-list-entries tabulated-entries
1176                pio-check--json-accumulator nil)
1177          (tabulated-list-revert)))))
1178  
1179  (define-derived-mode pio-check-mode tabulated-list-mode "PIO-Check"
1180    "Major mode for displaying `pio check --json-output' in a table."
1181    (setq tabulated-list-format
1182          [("Env" 14 t)
1183           ("Tool" 10 t)
1184           ("Severity" 10 t)
1185           ("Category" 12 t)
1186           ("ID" 22 t)
1187           ("Location" 44 t)
1188           ("Message" 0 t)])
1189    (setq tabulated-list-padding 2)
1190    (setq tabulated-list-sort-key (cons "Severity" nil))
1191    (setq tabulated-list-use-header-line t)
1192    (tabulated-list-init-header))
1193  
1194  (pio--bind-common-keys pio-check-mode-map)
1195  
1196  (defun pio-check ()
1197    "Display `pio check --skip-packages --json-output' in a table."
1198    (interactive)
1199    (let* ((platformio-cli (pio-system-info--resolve-executable))
1200           (check-buffer (get-buffer-create pio-check-buffer-name)))
1201      (pio-check--render-buffer check-buffer)
1202      (pio--display-buffer-passive check-buffer)
1203      (make-process
1204       :name "pio-check"
1205       :buffer check-buffer
1206       :command (list platformio-cli "check" "--skip-packages" "--json-output")
1207       :filter #'pio-check--append-process-output
1208       :sentinel #'pio-check--finalize-process)))
1209  
1210  (defun pio-project-config--value-to-string (value)
1211    "Return VALUE as a readable string for project config tables."
1212    (cond
1213     ((eq value :false) "false")
1214     ((eq value t) "true")
1215     ((null value) "null")
1216     ((stringp value) (if (string-empty-p value) "" value))
1217     ((listp value) (mapconcat #'pio-project-config--value-to-string value ", "))
1218     (t (format "%s" value))))
1219  
1220  (defun pio-project-config--tabulated-entry-from-option (section option index)
1221    "Convert SECTION OPTION at INDEX into a `tabulated-list-entries' row."
1222    (let* ((option-key (car option))
1223           (option-value (cadr option))
1224           (key (pio-device-list--normalize-field option-key))
1225           (value (pio-project-config--value-to-string option-value)))
1226      (list (format "%s:%s#%d" section key index)
1227            (vector section key value))))
1228  
1229  (defun pio-project-config--entries-from-json (json-string)
1230    "Parse JSON-STRING and return `tabulated-list-entries'."
1231    (let ((parsed-json (json-parse-string json-string :object-type 'alist :array-type 'list))
1232          (entries nil)
1233          (index 0))
1234      (dolist (section-pair parsed-json (nreverse entries))
1235        (let* ((section-name (pio-device-list--normalize-field (car section-pair)))
1236               (section-options (cadr section-pair)))
1237          (dolist (option section-options)
1238            (setq index (1+ index))
1239            (push (pio-project-config--tabulated-entry-from-option section-name option index)
1240                  entries))))))
1241  
1242  (defun pio-project-config--render-buffer (project-config-buffer)
1243    "Initialize PROJECT-CONFIG-BUFFER for raw config output."
1244    (with-current-buffer project-config-buffer
1245      (pio-project-config-mode)
1246      (setq-local pio-project-config--json-accumulator nil)
1247      (let ((inhibit-read-only t))
1248        (erase-buffer)
1249        (setq-local ansi-color-context nil))))
1250  
1251  (defun pio-project-config--append-process-output (process output-chunk)
1252    "Append OUTPUT-CHUNK from PROCESS into the buffer as-is."
1253    (pio--append-ansi-process-output process output-chunk))
1254  
1255  (defun pio-project-config--finalize-process (process event)
1256    "Finalize project config output and append EVENT if needed."
1257    (when-let ((project-config-buffer (get-buffer pio-project-config-buffer-name)))
1258      (with-current-buffer project-config-buffer
1259        (setq-local pio-project-config--json-accumulator nil)
1260        (let ((inhibit-read-only t))
1261          (goto-char (point-max))
1262          (unless (and (stringp event)
1263                       (string-match-p "finished" event))
1264            (insert "\n" event))))))
1265  
1266  (define-derived-mode pio-project-config-mode special-mode "PIO-Project-Config"
1267    "Major mode for displaying raw `pio project config' output.")
1268  
1269  (pio--bind-common-keys pio-project-config-mode-map)
1270  
1271  (defun pio-project-config ()
1272    "Display raw output of `pio project config'."
1273    (interactive)
1274    (let* ((platformio-cli (pio-system-info--resolve-executable))
1275           (project-config-buffer (get-buffer-create pio-project-config-buffer-name)))
1276      (pio-project-config--render-buffer project-config-buffer)
1277      (pio--display-buffer-passive project-config-buffer)
1278      (make-process
1279       :name "pio-project-config"
1280       :buffer project-config-buffer
1281       :command (list platformio-cli "project" "config")
1282       :filter #'pio-project-config--append-process-output
1283       :sentinel #'pio-project-config--finalize-process)))
1284  
1285  (defun pio-account-show--string-value (value)
1286    "Return VALUE as a user-friendly string."
1287    (cond
1288     ((eq value :false) "false")
1289     ((null value) "-")
1290     ((stringp value) (if (string-empty-p value) "-" value))
1291     (t (format "%s" value))))
1292  
1293  (defun pio-account-show--format-expire-at (value)
1294    "Format VALUE as a friendly expiration time string."
1295    (if (numberp value)
1296        (format-time-string "%Y-%m-%d %H:%M:%S %Z" (seconds-to-time value))
1297      "-"))
1298  
1299  (defun pio-account-show--service-title (service-value)
1300    "Return a user-facing title string for SERVICE-VALUE."
1301    (if (stringp service-value)
1302        service-value
1303      (pio-account-show--string-value
1304       (pio-device-list--alist-get-any 'title service-value))))
1305  
1306  (defun pio-account-show--service-fields (package)
1307    "Extract human-readable service strings from PACKAGE."
1308    (let (services)
1309      (dolist (pair package (nreverse services))
1310        (let* ((raw-key (car pair))
1311               (key (if (symbolp raw-key) (symbol-name raw-key) raw-key))
1312               (value (cdr pair)))
1313          (when (and (stringp key)
1314                     (string-prefix-p "service." key))
1315            (push (pio-account-show--service-title value) services))))))
1316  
1317  (defun pio-account-show--insert-heading (title)
1318    "Insert TITLE as a section heading."
1319    (insert (propertize title 'face 'font-lock-keyword-face) "\n")
1320    (insert (make-string (length title) ?=) "\n\n"))
1321  
1322  (defun pio-account-show--insert-labeled-line (label value)
1323    "Insert LABEL and VALUE as a single aligned line."
1324    (insert (format "%-12s %s\n" (format "%s:" label)
1325                    (pio-account-show--string-value value))))
1326  
1327  (defun pio-account-show--insert-profile (profile)
1328    "Insert profile details from PROFILE alist."
1329    (pio-account-show--insert-heading "Profile")
1330    (pio-account-show--insert-labeled-line "Username"
1331                                           (pio-device-list--alist-get-any 'username profile))
1332    (pio-account-show--insert-labeled-line "Email"
1333                                           (pio-device-list--alist-get-any 'email profile))
1334    (pio-account-show--insert-labeled-line "First name"
1335                                           (pio-device-list--alist-get-any 'firstname profile))
1336    (pio-account-show--insert-labeled-line "Last name"
1337                                           (pio-device-list--alist-get-any 'lastname profile))
1338    (insert "\n"))
1339  
1340  (defun pio-account-show--insert-organizations (organizations)
1341    "Insert Organizations section from ORGANIZATIONS list."
1342    (pio-account-show--insert-heading "Organizations")
1343    (if organizations
1344        (dolist (org organizations)
1345          (let* ((orgname (pio-device-list--normalize-field
1346                           (pio-device-list--alist-get-any 'orgname org)))
1347                 (displayname (pio-device-list--normalize-field
1348                               (pio-device-list--alist-get-any 'displayname org)))
1349                 (email (pio-device-list--normalize-field
1350                         (pio-device-list--alist-get-any 'email org)))
1351                 (owners (pio-org-list--owners-string
1352                          (pio-device-list--alist-get-any 'owners org))))
1353            (insert (propertize orgname 'face 'bold) "\n")
1354            (insert (make-string (length orgname) ?-) "\n")
1355            (pio-account-show--insert-labeled-line "Display" displayname)
1356            (pio-account-show--insert-labeled-line "Email" email)
1357            (pio-account-show--insert-labeled-line "Owners" owners)
1358            (insert "\n")))
1359      (insert "No organizations\n\n")))
1360  
1361  (defun pio-account-show--insert-teams (teams-by-org)
1362    "Insert Teams section from TEAMS-BY-ORG alist."
1363    (pio-account-show--insert-heading "Teams")
1364    (if teams-by-org
1365        (dolist (org-pair teams-by-org)
1366          (let* ((org-key (car org-pair))
1367                 (org-name (if (symbolp org-key) (symbol-name org-key) org-key))
1368                 (teams (cdr org-pair)))
1369            (insert (propertize (format "Org: %s" org-name) 'face 'bold) "\n")
1370            (insert (make-string (+ 5 (length org-name)) ?-) "\n")
1371            (if teams
1372                (dolist (team teams)
1373                  (let ((team-name (pio-device-list--normalize-field
1374                                    (pio-device-list--alist-get-any 'name team)))
1375                        (description (pio-device-list--normalize-field
1376                                      (pio-device-list--alist-get-any 'description team)))
1377                        (members (pio-team-list--members-string
1378                                  (pio-device-list--alist-get-any 'members team))))
1379                    (insert (format "- %s\n" team-name))
1380                    (pio-account-show--insert-labeled-line "Description" description)
1381                    (pio-account-show--insert-labeled-line "Members" members)
1382                    (insert "\n")))
1383              (insert "No teams\n\n"))))
1384      (insert "No teams\n\n")))
1385  
1386  (defun pio-account-show--insert-package (package)
1387    "Insert PACKAGE details in a readable text layout."
1388    (let ((title (pio-account-show--string-value
1389                  (pio-device-list--alist-get-any 'title package)))
1390          (description (pio-device-list--alist-get-any 'description package))
1391          (path (pio-device-list--alist-get-any 'path package))
1392          (services (pio-account-show--service-fields package)))
1393      (insert (propertize title 'face 'bold) "\n")
1394      (insert (make-string (length title) ?-) "\n")
1395      (insert (pio-account-show--string-value description) "\n")
1396      (pio-account-show--insert-labeled-line "Path" path)
1397      (if services
1398          (progn
1399            (insert "Services:\n")
1400            (dolist (service services)
1401              (insert (format "- %s\n" service))))
1402        (insert "Services: -\n"))
1403      (insert "\n")))
1404  
1405  (defun pio-account-show--render-data (account-show-buffer account)
1406    "Render ACCOUNT into ACCOUNT-SHOW-BUFFER."
1407    (with-current-buffer account-show-buffer
1408      (let ((inhibit-read-only t)
1409            (profile (pio-device-list--alist-get-any 'profile account))
1410            (packages (pio-device-list--alist-get-any 'packages account))
1411            (user-id (pio-device-list--alist-get-any 'user_id account))
1412            (expire-at (pio-device-list--alist-get-any 'expire_at account)))
1413        (erase-buffer)
1414        (pio-account-show-mode)
1415        (pio-account-show--insert-profile profile)
1416        (pio-account-show--insert-heading "Packages")
1417        (if packages
1418            (dolist (package packages)
1419              (pio-account-show--insert-package package))
1420          (insert "No packages\n\n"))
1421        (pio-account-show--insert-heading "Meta")
1422        (pio-account-show--insert-labeled-line "User ID" user-id)
1423        (pio-account-show--insert-labeled-line "Expire at"
1424                                               (pio-account-show--format-expire-at expire-at))
1425        (goto-char (point-min)))))
1426  
1427  (define-derived-mode pio-account-show-mode special-mode "PIO-Account"
1428    "Major mode for displaying `pio account show --json-output' in readable text.")
1429  
1430  (pio--bind-common-keys pio-account-show-mode-map)
1431  
1432  (defun pio-account-show (&optional force-refresh)
1433    "Display `pio account show --json-output' in a readable text layout."
1434    (interactive "P")
1435    (let ((account-show-buffer (get-buffer-create pio-account-show-buffer-name)))
1436      (condition-case err
1437          (let ((account (pio--run-json-command-cached
1438                          'account-show
1439                          pio-cache-ttl-account-show
1440                          force-refresh
1441                          "account" "show" "--json-output")))
1442            (pio-account-show--render-data account-show-buffer account)
1443            (pio--display-buffer-passive account-show-buffer))
1444        (error
1445         (with-current-buffer account-show-buffer
1446           (pio-account-show-mode)
1447           (let ((inhibit-read-only t))
1448             (erase-buffer)
1449             (insert (format "Failed to load account data: %s\n" (error-message-string err)))))
1450         (pio--display-buffer-passive account-show-buffer)))))
1451  
1452  
1453  (defun pio-mode (&optional _force-refresh)
1454    "Open the PlatformIO command dispatcher."
1455    (interactive "P")
1456    (pio-dispatch))
1457  
1458  (provide 'pio-mode)
1459  ;;; pio-mode.el ends here