;;; verdict-dart.el --- Dart runner for verdict -*- lexical-binding: t -*-

;; Author: Thomas Järvstrand <https://github.com/tjarvstrand>
;; Assisted-by: Claude:claude-opus-4-7
;; Maintainer: Thomas Järvstrand <https://github.com/tjarvstrand>
;; Package-Version: 20260505.1121
;; Package-Revision: 84fff362b806
;; URL: https://github.com/tjarvstrand/verdict.el
;; Keywords: tools, languages
;; Package-Requires: ((emacs "29.1") (verdict "0.1") (dash "2.0") (f "0.20") (yaml "0.5"))
;; SPDX-License-Identifier: GPL-3.0-or-later

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; Dart and Flutter test backend for verdict.  Provides test discovery,
;; optionally via tree-sitter (recommended), JSON protocol parsing, and
;; optional debug support (by default using dape).

;;; Code:

(require 'verdict)
(require 'cl-generic)
(require 'treesit)
(require 'f)
(require 'yaml)

(defgroup verdict-dart nil
  "Dart backend for verdict."
  :group 'verdict
  :prefix "verdict-dart-")

;;; Customization

(defun verdict-dart--debug-default (context)
  "Launch a debug session using dape if available.
CONTEXT is a plist; see `verdict-dart-debug-fn'."
  (if (require 'dape nil t)
      (verdict-dart--dape-debug context)
    (error "Install dape or set `verdict-dart-debug-fn' to enable debug mode")))

(defcustom verdict-dart-debug-fn #'verdict-dart--debug-default
  "Function to launch a dart test debug session.
Called with a single argument: a plist with keys
:project, :files, :names, :name, :runner (\"dart\" or \"flutter\").
The function should start a debug session (e.g. via dape or dap-mode)
and return a kill handle: a zero-argument function that terminates
the session, or nil if no kill mechanism is available.  The handle
is invoked by `verdict-kill'.
The default uses dape if available, otherwise signals an error."
  :type 'function
  :group 'verdict-dart)

(defcustom verdict-dart-inhibit-treesit nil
  "If non-nil, use regexp-based parsing even when treesit is available.
This is mainly useful for testing the regexp fallback path."
  :type 'boolean
  :group 'verdict-dart)

(defcustom verdict-dart-flutter-packages '("flutter_test")
  "Package names whose presence in pubspec.yaml selects the Flutter runner.
When the project's pubspec depends on any of these packages, or on the
flutter SDK, `flutter test' is used instead of `dart test'."
  :type '(repeat string)
  :group 'verdict-dart)

;;; Internal State

(defvar verdict-dart--group-names (make-hash-table)
  "Map of dart group ID (integer) → full name string.")

(defvar verdict-dart--file-suite-ids (make-hash-table :test #'equal)
  "Map of file path → suite ID for the current run.")

(defvar verdict-dart--loading-tests (make-hash-table :test #'equal)
  "Map of loading-test ID → suite ID.")

(defvar verdict-dart--package-config nil
  "Cached alist of (PACKAGE-NAME . LIB-DIR) for the current run.")


;;; Dart String Literal Parsing
(defun verdict-dart--string-content (s)
  "Return the content of Dart string literal S, stripping quotes/prefixes."
  (when-let* ((match (string-match "^r?['\"]\\(['\"]['\"]\\)?" s))
              (prefix-len (match-end 0))
              (suffix-len (if (string-prefix-p "r" s) (1- prefix-len) prefix-len))
              (inner-len  (- (length s) prefix-len suffix-len)))
    (when (>= inner-len 0)
      (substring s prefix-len (+ prefix-len inner-len)))))

;;; AST Walking

(defconst verdict-dart--test-call-kinds
  '("group" "test" "testWidgets")
  "Dart test function names recognized by verdict.")

(defun verdict-dart--ensure-parser-treesit ()
  "Create a dart treesit parser for the current buffer if needed."
  (unless (treesit-ready-p 'dart t)
    (error "Dart treesit grammar not available"))
  (treesit-parser-create 'dart))

(defun verdict-dart--call-info-from-node-treesit (node)
  "Extract call info from NODE if it is a recognized test/group call.
Returns (:kind KIND :name NAME :line LINE) or nil."
  (when (and node (string= (treesit-node-type node) "expression_statement"))
    (let* ((child (treesit-node-child node 0)))
      (when (and child (string= (treesit-node-type child) "identifier"))
        (let ((kind (treesit-node-text child t)))
          (when (member kind verdict-dart--test-call-kinds)
            (let* ((selector  (treesit-node-child node 1))
                   (arg-part  (and selector
                                   (string= (treesit-node-type selector) "selector")
                                   (treesit-node-child selector 0)))
                   (arguments (and arg-part
                                   (string= (treesit-node-type arg-part) "argument_part")
                                   (treesit-node-child arg-part 0)))
                   (first-arg (and arguments
                                   (string= (treesit-node-type arguments) "arguments")
                                   (treesit-node-child arguments 1)))
                   (str-node  (when first-arg
                                (if (string= (treesit-node-type first-arg) "string_literal")
                                    first-arg
                                  (treesit-node-child first-arg 0)))))
              (when (and str-node
                         (string= (treesit-node-type str-node) "string_literal"))
                (let* ((raw  (treesit-node-text str-node t))
                       (name (verdict-dart--string-content raw))
                       (line (treesit-node-start node)))
                  (when name
                    (list :kind kind
                          :name name
                          :line (line-number-at-pos line))))))))))))

(defun verdict-dart--enclosing-calls-treesit ()
  "Return list of enclosing test/group call infos via tree-sitter.
The list is ordered outermost first."
  (verdict-dart--ensure-parser-treesit)
  (let* ((start-node (treesit-node-at (point) 'dart))
         (node       start-node)
         (results    nil))
    (while node
      (when-let* ((info (verdict-dart--call-info-from-node-treesit node)))
        (push info results))
      (setq node (treesit-node-parent node)))
    results))

(defconst verdict-dart--call-regexp
  (concat "\\_<\\("
          (mapconcat #'regexp-quote verdict-dart--test-call-kinds "\\|")
          "\\)\\_>[[:space:]]*(")
  "Regexp matching the start of a recognized test/group call.")

(defconst verdict-dart--regex-syntax-table
  (let ((tbl (make-syntax-table)))
    (modify-syntax-entry ?\" "\""    tbl)
    (modify-syntax-entry ?\' "\""    tbl)
    (modify-syntax-entry ?\\ "\\"    tbl)
    (modify-syntax-entry ?\( "()"    tbl)
    (modify-syntax-entry ?\) ")("    tbl)
    (modify-syntax-entry ?\{ "(}"    tbl)
    (modify-syntax-entry ?\} "){"    tbl)
    (modify-syntax-entry ?\[ "(]"    tbl)
    (modify-syntax-entry ?\] ")["    tbl)
    (modify-syntax-entry ?/  ". 124b" tbl)
    (modify-syntax-entry ?*  ". 23"   tbl)
    (modify-syntax-entry ?\n "> b"    tbl)
    tbl)
  "Syntax table used by `verdict-dart--enclosing-calls-regex'.
Treats both `'` and `\"` as string fences, marks C-style line and
block comments, and pairs all bracket/brace types — so the parser
works regardless of the buffer's major mode.")

(defun verdict-dart--first-string-arg-regex (open-paren)
  "Return name of the first argument if it is a string literal.
OPEN-PAREN is the position of the opening `('.  Returns nil if the
first argument is not a literal string.  Must be called inside
`verdict-dart--regex-syntax-table'."
  (save-excursion
    (goto-char (1+ open-paren))
    (skip-syntax-forward " ")
    (let ((start (point)))
      (when (eq (char-after) ?r)
        (forward-char 1))
      (let ((q (char-after)))
        (when (memq q '(?\" ?\'))
          (let ((end
                 (if (and (eq (char-after (1+ (point))) q)
                          (eq (char-after (+ (point) 2)) q))
                     ;; Triple-quoted: scan for closing triple.  Syntax
                     ;; tables can't represent triple delimiters, so do
                     ;; this by hand.
                     (save-excursion
                       (forward-char 3)
                       (when (search-forward (make-string 3 q) nil t)
                         (point)))
                   ;; Single-quoted: forward-sexp uses our syntax table.
                   (condition-case nil
                       (save-excursion (forward-sexp 1) (point))
                     (error nil)))))
            (when end
              (verdict-dart--string-content
               (buffer-substring-no-properties start end)))))))))

(defun verdict-dart--enclosing-calls-regex ()
  "Return list of enclosing test/group call infos via regex parsing.
The list is ordered outermost first.  Same data shape as the
tree-sitter implementation; intended as a fallback when the Dart
grammar is not available."
  (let ((target (point))
        results)
    (with-syntax-table verdict-dart--regex-syntax-table
      (save-excursion
        (goto-char (point-min))
        (while (re-search-forward verdict-dart--call-regexp target t)
          (let* ((kind-start (match-beginning 1))
                 (kind       (match-string-no-properties 1))
                 (paren-pos  (1- (match-end 0)))
                 (ppss       (save-excursion
                               (parse-partial-sexp (point-min) kind-start))))
            (unless (or (nth 3 ppss) (nth 4 ppss))
              (let ((call-end (save-excursion
                                (goto-char paren-pos)
                                (condition-case nil
                                    (progn (forward-sexp 1) (point))
                                  (error nil)))))
                (when (and call-end (<= kind-start target) (<= target call-end))
                  (when-let* ((name (verdict-dart--first-string-arg-regex paren-pos)))
                    (push (list :kind kind
                                :name name
                                :line (line-number-at-pos kind-start))
                          results)))))))))
    (nreverse results)))

(defun verdict-dart--enclosing-calls ()
  "Return list of enclosing test/group call infos, outermost first.
Uses tree-sitter when the Dart grammar is available; falls back to
regex parsing otherwise."
  (if (and (treesit-ready-p 'dart t) (not verdict-dart-inhibit-treesit))
      (verdict-dart--enclosing-calls-treesit)
    (verdict-dart--enclosing-calls-regex)))

;;; Test-at-Point

(defun verdict-dart--test-at-point ()
  "Return (:file FILE :name NAME) for the test at point, or nil."
  (when-let* ((calls (verdict-dart--enclosing-calls))
              (has-test (seq-find (lambda (c) (member (plist-get c :kind) '("test" "testWidgets"))) calls))
              (full-name (mapconcat (lambda (c) (plist-get c :name)) calls " ")))
    (list :file buffer-file-name :name full-name)))

;;; Module / Project Root

(defun verdict-dart--module-root ()
  "Return the module root by locating pubspec.yaml."
  (let ((dir (f-traverse-upwards
              (lambda (d) (f-exists-p (f-join d "pubspec.yaml")))
              (f-dirname buffer-file-name))))
    (unless dir
      (error "Could not find pubspec.yaml above %s" buffer-file-name))
    dir))


;;; Helpers

(defun verdict-dart--strip-parent-prefix (parent-name name)
  "Strip PARENT-NAME prefix (plus one space) from NAME if present."
  (let ((prefix (concat parent-name " ")))
    (if (string-prefix-p prefix name)
        (substring name (length prefix))
      name)))

(defun verdict-dart--innermost-group (group-ids)
  "Return the innermost known group ID from GROUP-IDS, or nil.
GROUP-IDS is a sequence (list or vector).
Skips root groups (those with empty names)."
  (seq-find (lambda (id)
              (--> (gethash id verdict-dart--group-names)
                   (not (string-empty-p it))))
            (reverse (seq-into group-ids 'list))))

(defun verdict-dart--resolve-test-id (test-id)
  "Return TEST-ID, or the suite ID if TEST-ID is a loading test."
  (or (gethash test-id verdict-dart--loading-tests) test-id))

(defun verdict-dart--url-to-file (url)
  "Convert a file:// URL to a local file path, or return nil."
  (when url
    (string-remove-prefix "file://" url)))

;;; Stack Trace Linkification

(defun verdict-dart--load-package-config (project-dir)
  "Load package_config.json from PROJECT-DIR or an ancestor.
Caches the result in `verdict-dart--package-config'."
  (let* ((dir (f-traverse-upwards
               (lambda (d) (f-exists-p (f-join d ".dart_tool" "package_config.json")))
               project-dir))
         (config-file (when dir (f-join dir ".dart_tool" "package_config.json"))))
    (setq verdict-dart--package-config
          (when config-file
            (let* ((json          (with-temp-buffer
                                    (insert-file-contents config-file)
                                    (json-parse-buffer :object-type 'plist)))
                   (packages      (plist-get json :packages))
                   (dart-tool-dir (f-join dir ".dart_tool"))
                   (result        nil))
              (seq-doseq (pkg packages)
                (let* ((name     (plist-get pkg :name))
                       (root-uri (plist-get pkg :rootUri))
                       (pkg-uri  (or (plist-get pkg :packageUri) "lib/"))
                       (abs-root (if (string-prefix-p "file://" root-uri)
                                     (string-remove-prefix "file://" root-uri)
                                   (expand-file-name root-uri dart-tool-dir)))
                       (lib-dir  (expand-file-name pkg-uri abs-root)))
                  (push (cons name lib-dir) result)))
              result)))))

(defun verdict-dart--find-sdk-root ()
  "Return the Dart SDK root directory, or nil."
  (when-let* ((dart (executable-find "dart"))
              (real (file-truename dart))
              (sdk  (expand-file-name "../.." real)))
    (when (file-directory-p (expand-file-name "lib/core" sdk))
      (file-name-as-directory sdk))))

(defun verdict-dart--resolve-stack-path (path anchor-dir)
  "Resolve a dart stack trace PATH to an absolute file path, or nil.
ANCHOR-DIR is used to locate the nearest pubspec.yaml for resolving
relative paths and package: URIs."
  (let ((project-dir (f-traverse-upwards
                      (lambda (d) (f-exists-p (f-join d "pubspec.yaml")))
                      anchor-dir)))
    (cond
     ((string-prefix-p "package:" path)
      (when project-dir
        (unless verdict-dart--package-config
          (verdict-dart--load-package-config project-dir))
        (let* ((rest     (string-remove-prefix "package:" path))
               (slash    (string-search "/" rest))
               (pkg-name (substring rest 0 slash))
               (rel-path (substring rest (1+ slash)))
               (lib-dir  (cdr (assoc pkg-name verdict-dart--package-config))))
          (when lib-dir
            (expand-file-name rel-path lib-dir)))))
     ((string-prefix-p "org-dartlang-sdk:///" path)
      (when-let* ((sdk (verdict-dart--find-sdk-root)))
        (expand-file-name (string-remove-prefix "org-dartlang-sdk:///" path) sdk)))
     ((string-prefix-p "dart:" path) nil)
     (project-dir
      (expand-file-name path project-dir)))))

(defun verdict-dart--linkify (message anchor-file)
  "Return MESSAGE with clickable link properties on stack trace references.
ANCHOR-FILE is used to locate the project root for path resolution.
Returns MESSAGE unchanged if ANCHOR-FILE is nil."
  (if (null anchor-file)
      message
    (let ((msg (copy-sequence message))
          (dir (f-dirname anchor-file))
          (pos 0))
      (while (string-match
              "^\\(package:[^ \n]+\\|org-dartlang-sdk:[^ \n]+\\|[^ \n]+\\.dart\\) \\([0-9]+\\):[0-9]+"
              msg pos)
        (let* ((path (match-string 1 msg))
               (line (string-to-number (match-string 2 msg)))
               (beg  (match-beginning 0))
               (mend (match-end 0))
               (action (let ((p path) (ln line) (d dir))
                         (lambda (_btn)
                           (if-let* ((abs (verdict-dart--resolve-stack-path p d)))
                               (progn (find-file-other-window abs)
                                      (goto-char (point-min))
                                      (forward-line (1- ln)))
                             (message "Cannot resolve %s" p))))))
          (add-text-properties
           beg mend
           (list 'button '(t) 'category 'default-button
                 'action action 'follow-link t
                 'face 'link 'mouse-face 'highlight
                 'help-echo (format "%s:%d" path line))
           msg)
          (setq pos mend)))
      msg)))

;;; JSON → Event Translation

(defun verdict-dart--handle-event (event)
  "Dispatch a parsed dart test EVENT (plist) to `verdict-event'.
EVENT uses keyword keys, vectors for arrays, and :json-false for false."
  (pcase (plist-get event :type)
    ("start" nil)

    ("suite"
     (let* ((suite    (plist-get event :suite))
            (suite-id (plist-get suite :id))
            (path     (plist-get suite :path)))
       (puthash path suite-id verdict-dart--file-suite-ids)
       (verdict-event (list :type  :group
                            :id    suite-id
                            :label (file-name-nondirectory path)
                            :file  path))))

    ("group"
     (let* ((group       (plist-get event :group))
            (parent-id   (plist-get group :parentID))
            (id          (plist-get group :id))
            (name        (plist-get group :name))
            (parent-name (and (numberp parent-id)
                              (gethash parent-id verdict-dart--group-names))))
       ;; Always store full name so children can strip it.
       (when (numberp id)
         (puthash id name verdict-dart--group-names))
       (when (and parent-id (not (string-empty-p name)))
         (let ((label (if parent-name
                          (verdict-dart--strip-parent-prefix parent-name name)
                        name)))
           (verdict-event (list :type       :group
                                :id         id
                                :parent-id  (if-let* ((pname (gethash parent-id verdict-dart--group-names))
                                                     ((not (string-empty-p pname))))
                                                parent-id
                                              (plist-get group :suiteID))
                                :name       name
                                :label      (unless (string= label name) label)
                                :test-count (plist-get group :testCount)
                                :line       (plist-get group :line)
                                :file       (verdict-dart--url-to-file (plist-get group :url))))))))

    ("testStart"
     (let* ((test      (plist-get event :test))
            (id        (plist-get test :id))
            (name      (plist-get test :name))
            (suite-id  (plist-get test :suiteID))
            (group-ids (plist-get test :groupIDs)))
       (if (seq-empty-p group-ids)
           (puthash id suite-id verdict-dart--loading-tests)
         (let* ((parent-name (gethash (seq-elt group-ids (1- (length group-ids)))
                                      verdict-dart--group-names))
                (label (if parent-name
                           (verdict-dart--strip-parent-prefix parent-name name)
                         name)))
           (verdict-event (list :type      :test-start
                                :id        id
                                :parent-id (or (verdict-dart--innermost-group group-ids) suite-id)
                                :name      name
                                :label     (unless (string= label name) label)
                                :line      (plist-get test :line)
                                :file      (verdict-dart--url-to-file (plist-get test :url))))))))

    ("print"
     (let* ((id   (verdict-dart--resolve-test-id (plist-get event :testID)))
            (file (plist-get (verdict-find-node id) :file)))
       (verdict-event (list :type     :log
                            :severity 'info
                            :id       id
                            :message  (verdict-dart--linkify (plist-get event :message) file)))))

    ("error"
     (let* ((id   (verdict-dart--resolve-test-id (plist-get event :testID)))
            (file (plist-get (verdict-find-node id) :file)))
       (verdict-event (list :type     :log
                            :severity 'error
                            :id       id
                            :message  (verdict-dart--linkify
                                       (concat (plist-get event :error) "\n" (plist-get event :stackTrace))
                                       file)))))

    ("testDone"
     (let* ((raw-id  (plist-get event :testID))
            (loading (gethash raw-id verdict-dart--loading-tests))
            (id      (or loading raw-id))
            (result  (if (eq (plist-get event :skipped) t)
                         'skipped
                       (pcase (plist-get event :result)
                         ("success" 'passed)
                         ("error"   (if loading 'error 'failed))
                         (other     (message "verdict-dart: unknown test result %S" other)
                                    'error)))))
       ;; Skip successful loading tests — suite status is aggregated from children
       (unless (and loading (eq result 'passed))
         (verdict-event (list :type   :test-done
                              :id     id
                              :result result)))))

    ("done"
     (verdict-event (list :type    :done
                          :success (plist-get event :success))))

    (_ nil)))

(defun verdict-dart--handle-line (line)
  "Parse one JSON LINE from `dart test -r json' and dispatch to `verdict-event'."
  (condition-case err
      (unless (string-empty-p line)
        (verdict-dart--handle-event
         (json-parse-string line :object-type 'plist :false-object :json-false :null-object nil)))
    (error
     (message "verdict-dart: error parsing line: %s\n%s" line (error-message-string err)))))

;;; Context and Command Functions

(defun verdict-dart--context-fn (scope)
  "Return a context plist for SCOPE, reading from the current buffer.
SCOPE is a keyword (:test-at-point, :group-at-point, :file, :module,
:project) or a cons cell (:tests . FILE-TESTS) where FILE-TESTS is
an alist of (FILE . (NAME ...)) entries.
Resets per-run parse state as a side effect."
  (setq verdict-dart--group-names    (make-hash-table)
        verdict-dart--file-suite-ids (make-hash-table :test #'equal)
        verdict-dart--loading-tests  (make-hash-table :test #'equal)
        verdict-dart--package-config nil)
  (pcase scope
    (`(:tests . ,file-tests)
     (let* ((files (mapcar #'car file-tests))
            (names (mapcan #'cdr (mapcar #'copy-sequence file-tests)))
            (buffer-file-name (car files)))
       (list :project (verdict-dart--module-root)
             :files   files
             :names   names
             :name    (car (or names files)))))
    (_
     (let* ((buf-file  (buffer-file-name))
            (test-name (pcase scope
                         (:test-at-point (plist-get (or (verdict-dart--test-at-point)
                                                       (error "No test found at point"))
                                                   :name))
                         (:group-at-point (let* ((calls (or (verdict-dart--enclosing-calls)
                                                            (error "No group or test found at point")))
                                                 (groups (seq-filter (lambda (c) (string= (plist-get c :kind) "group")) calls)))
                                           (unless groups (error "No group found at point"))
                                           (mapconcat (lambda (c) (plist-get c :name)) groups " ")))
                         (_ nil))))
       (list :project (pcase scope
                        (:project (funcall verdict-project-root-fn))
                        (_        (verdict-dart--module-root)))
             :files   (unless (memq scope '(:module :project)) (list buf-file))
             :names   (when test-name (list test-name))
             :name    (or test-name (when buf-file (file-name-nondirectory buf-file))))))))

;;; Flutter Detection

(defun verdict-dart--flutter-project-p (project-dir)
  "Return non-nil if PROJECT-DIR's pubspec.yaml depends on Flutter.
Checks for a flutter SDK dependency or a dev-dependency on any
package in `verdict-dart-flutter-packages'."
  (let* ((yaml (yaml-parse-string
                (with-temp-buffer
                  (insert-file-contents (f-join project-dir "pubspec.yaml"))
                  (buffer-string))
                :object-type 'alist))
         (deps     (alist-get 'dependencies yaml))
         (dev-deps (alist-get 'dev_dependencies yaml)))
    (or (alist-get 'sdk (alist-get 'flutter deps))
        (seq-find (lambda (pkg) (alist-get (intern pkg) dev-deps))
                  verdict-dart-flutter-packages))))

(defun verdict-dart--use-flutter-p (context)
  "Return non-nil if CONTEXT indicates Flutter should be used.
Checks the project's pubspec.yaml for Flutter dependencies."
  (verdict-dart--flutter-project-p (plist-get context :project)))

(defun verdict-dart--pcre-quote (string)
  "Return STRING with PCRE metacharacters backslash-escaped."
  ;; `]' must come first in the Emacs character alternative so it is
  ;; treated as a literal member; `\\]' is not an escape inside `[...]'.
  (replace-regexp-in-string "[][\\\\^$.|?*+(){}]" "\\\\\\&" string))

(defun verdict-dart--name-filter-args (names)
  "Return command-line args to filter by NAMES, or nil."
  (when names
    (if (= (length names) 1)
        (list "--plain-name" (car names))
      (list "--name" (concat "^(" (mapconcat #'verdict-dart--pcre-quote names "|") ")$")))))

(defun verdict-dart--command-fn (context debug)
  "Build dart/flutter test command from CONTEXT and DEBUG flag.
Returns a plist suitable for `verdict-register-backend'."
  (let* ((files  (plist-get context :files))
         (names  (plist-get context :names))
         (dir    (plist-get context :project))
         (runner (if (verdict-dart--use-flutter-p context) "flutter" "dart")))
    (if debug
        (let ((debug-context (plist-put (copy-sequence context) :runner runner)))
          (list :command   (lambda () (funcall verdict-dart-debug-fn debug-context))
                :directory dir
                :name      (plist-get context :name)
                :header    (concat "debug in " dir)))
      (let ((cmd (append (list runner "test" "-r" "json")
                         (verdict-dart--name-filter-args names)
                         files)))
        (list :command   cmd
              :directory dir
              :name      (plist-get context :name)
              :header    (concat "$ " (string-join cmd " ") "\n  in " dir))))))

;;; Backend Registration

(verdict-register-backend "\\.dart\\'"
                          #'verdict-dart--context-fn
                          #'verdict-dart--command-fn
                          #'verdict-dart--handle-line)

;;; Dape Integration

(declare-function dape "dape" (config))
(declare-function dape-quit "dape" (&optional conn))
(declare-function dape--live-connection "dape" (type &optional nowarn require-selected))

(defun verdict-dart--dape-debug (context)
  "Launch a dape debug session for a dart/flutter test.
CONTEXT is a plist with :project, :file, :names, :name, :runner.
Returns a kill handle that terminates the dape session
(see `verdict-dart-debug-fn')."
  (let* ((runner    (plist-get context :runner))
         (files     (plist-get context :files))
         (_         (when (> (length files) 1)
                      (error "Debug mode supports only a single file")))
         (file      (car files))
         (names     (plist-get context :names))
         (flutter-p (string= runner "flutter"))
         (config    `(command ,runner
                      command-args ("debug_adapter" "--test")
                      command-cwd ,(plist-get context :project)
                      :type "dart"
                      :cwd "."
                      ,@(when file (list :program file))
                      ,@(when names
                          (list :args (vector "--plain-name" (car names))))
                      ,@(when flutter-p '(:toolArgs ["-d" "all"])))))
    (dape config)
    (lambda ()
      (when-let* ((conn (dape--live-connection 'parent t)))
        (dape-quit conn)))))

(cl-defmethod dape-handle-event
  (_conn (_event (eql dart.testNotification)) body)
  "Forward Dart test notification BODY to verdict."
  (verdict-dart--handle-event body))

(cl-defmethod dape-handle-event :after
  (_conn (_event (eql terminated)) _body)
  "Stop verdict when the dape session terminates."
  (when (verdict-running-p)
    (verdict-stop)))

(provide 'verdict-dart)
;;; verdict-dart.el ends here
