;;; swift-mode-repl.el --- Run Apple's Swift processes in Emacs buffers -*- lexical-binding: t -*-

;; Copyright (C) 2014-2021 taku0, Chris Barrett, Bozhidar Batsov,
;;                         Arthur Evstifeev, Michael Sanders

;; Author: taku0 (http://github.com/taku0)
;;       Chris Barrett <chris.d.barrett@me.com>
;;       Bozhidar Batsov <bozhidar@batsov.com>
;;       Arthur Evstifeev <lod@pisem.net>
;;       Michael Sanders <https://github.com/msanders>

;; This file is not part of GNU Emacs.

;; 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 <http://www.gnu.org/licenses/>.

;;; Commentary:

;; Run Apple's Swift processes in Emacs buffers.

;;; Code:

(require 'comint)
(require 'json)
(require 'seq)
(require 'subr-x)
(require 'wid-edit)
(require 'compile)
(require 'rx)

;;;###autoload
(defgroup swift-mode:repl nil
  "REPL."
  :tag "Swift Mode REPL"
  :group 'swift)

(defun swift-mode:command-p (command)
  "Return non-nil if COMMAND is a string or a list of strings."
  (or (stringp command)
      (and (listp command) (seq-every-p #'stringp command))))

(defcustom swift-mode:repl-executable
  (concat (when (executable-find "xcrun") "xcrun ") "swift repl")
  "Path to the Swift CLI.  The string is split by spaces, then unquoted."
  :tag "Swift Mode REPL Executable"
  :type '(choice string (repeat string))
  :safe #'swift-mode:command-p)

(defcustom swift-mode:swift-package-executable
  (concat (when (executable-find "xcrun") "xcrun ") "swift package")
  "Path to the Swift command for package manipulation.
The string is split by spaces, then unquoted."
  :type '(choice string (repeat string))
  :safe #'swift-mode:command-p)

(defcustom swift-mode:swift-build-executable
  (concat (when (executable-find "xcrun") "xcrun ") "swift build")
  "Path to the Swift command for building.
The string is split by spaces, then unquoted."
  :type '(choice string (repeat string))
  :safe #'swift-mode:command-p)

(defcustom swift-mode:debugger-executable
  (concat (when (executable-find "xcrun") "xcrun ") "lldb")
  "Path to the debugger command.
The string is split by spaces, then unquoted."
  :type '(choice string (repeat string))
  :safe #'swift-mode:command-p)

(defcustom swift-mode:ios-deploy-executable
  "ios-deploy"
  "Path to ios-deploy command.
The string is split by spaces, then unquoted."
  :tag "Swift Mode iOS Deploy Executable"
  :type '(choice string (repeat string))
  :safe #'swift-mode:command-p)

(defcustom swift-mode:simulator-controller-executable
  (concat (when (executable-find "xcrun") "xcrun ") "simctl")
  "Path to the simulator controller command.
The string is split by spaces, then unquoted."
  :type '(choice string (repeat string))
  :safe #'swift-mode:command-p)

(defcustom swift-mode:xcodebuild-executable
  (concat (when (executable-find "xcrun") "xcrun ") "xcodebuild")
  "Path to the Xcode builder.
The string is split by spaces, then unquoted."
  :type '(choice string (repeat string))
  :safe #'swift-mode:command-p)

(defcustom swift-mode:xcode-select-executable
  "xcode-select"
  "Path to the Xcode selector.
The string is split by spaces, then unquoted."
  :type '(choice string (repeat string))
  :safe #'swift-mode:command-p)

(defcustom swift-mode:debugger-prompt-regexp "^(lldb) +\\|^[0-9]+> +"
  "Regexp to search a debugger prompt."
  :type 'string
  :safe #'stringp)

(defcustom swift-mode:repl-startup-timeout 30.0
  "Seconds to wait for the first output of a REPL process.

`swift-mode:run-repl' gives up waiting after this seconds so that a
process printing nothing does not freeze Emacs."
  :type 'number
  :safe #'numberp)

(defcustom swift-mode:ios-simulator-boot-timeout 60.0
  "Seconds to wait for an iOS simulator to boot.

`swift-mode:wait-for-ios-simulator' gives up waiting after this seconds
so that a simulator failing to boot does not freeze Emacs."
  :tag "Swift Mode iOS Simulator Boot Timeout"
  :type 'number
  :safe #'numberp)

(defcustom swift-mode:swift-testing-command-regexp
  "\\<swift \\(?:test\\|package\\|build\\)\\>"
  "Regexp to of command line of Swift Testing.

When the command of `compile' matches this regexp, its
`compilation-error-regexp-alist' is overridden."
  :type 'string
  :safe #'stringp)

(defcustom swift-mode:resolve-swift-test-file-function
  #'swift-mode:resolve-swift-test-file
  "Function to resolve Swift Testing files."
  :type 'function)

(defvar swift-mode:repl-buffer nil
  "Stores the current swift REPL buffer, or nil.")

(defvar swift-mode:repl-command-queue nil
  "List of strings to be executed on REPL.

Use `swift-mode:enqueue-repl-commands' to enqueue commands.
If an element is a cons cell, its car is used as a regexp for prompt and
cdr is used as a command.  If its car is a function, it is called to search
prompt.  It should return non-nil when a prompt is found and return nil
otherwise.")

(defvar swift-mode:ios-device-identifier nil
  "Identifier of iOS device used for building/debugging.")

(defconst swift-mode:ios-local-device-identifier
  "00000000-0000-0000-0000-000000000000"
  "Identifier of local iOS device.")

(defvar swift-mode:ios-project-scheme nil
  "Scheme to use in Xcode project for building/debugging.")

(defun swift-mode:command-list-to-string (cmd)
  "Concatenate the CMD unless it is a string.

This function quotes elements appropriately."
  (if (stringp cmd) cmd (combine-and-quote-strings cmd)))

(defun swift-mode:command-string-to-list (cmd)
  "Split the CMD unless it is a list.

This function respects quotes."
  (if (listp cmd) cmd (split-string-and-unquote cmd)))

;;;###autoload
(defun swift-mode:run-repl (cmd &optional dont-switch keep-default)
  "Run a Swift REPL process.

This function input and output via buffer `*CMD*' where CMD is replaced with
the CMD given.
If there is a process already running in `*CMD*', and DONT-SWITCH is nil,
switch to that buffer.
CMD is a string or a list, interpreted as a command line.  The default value is
`swift-mode:repl-executable'.  This function updates the buffer local variable
`swift-mode:repl-executable' with the given CMD if KEEP-DEFAULT is nil,
so it will be used as the default value for the next invocation in the current
buffer.
If KEEP-DEFAULT is non-nil, the `swift-mode:repl-executable' and the global
variable `swift-mode:repl-buffer' are not updated.  The buffer local variable
`swift-mode:repl-buffer' is always updated.
Runs the hook `swift-repl-mode-hook' \(after the `comint-mode-hook' is run).
\(Type \\[describe-mode] in the process buffer for a list of commands.)"
  (interactive
   (list (if current-prefix-arg
             (read-string
              "Run swift REPL: "
              (swift-mode:command-list-to-string swift-mode:repl-executable))
           swift-mode:repl-executable)))
  (let* ((original-buffer (current-buffer))
         (cmd-string (swift-mode:command-list-to-string cmd))
         (cmd-list (swift-mode:command-string-to-list cmd))
         (buffer-name (concat "*Swift REPL [" cmd-string "]*"))
         (buffer (get-buffer-create buffer-name))
         old-size)
    (with-current-buffer original-buffer
      (setq-local swift-mode:repl-buffer buffer)
      (unless keep-default
        (setq-local swift-mode:repl-executable cmd)
        (setq-default swift-mode:repl-buffer swift-mode:repl-buffer)))
    (with-current-buffer buffer
      (setq old-size (buffer-size))
      ;; Initializing the major mode kills buffer local variables including
      ;; `swift-mode:repl-command-queue'.  Do it only for a fresh buffer.
      (unless (derived-mode-p 'swift-repl-mode)
        (swift-repl-mode))
      (setq-local swift-mode:repl-buffer buffer))
    (unless (comint-check-proc buffer)
      (apply #'make-comint-in-buffer
             cmd-string buffer (car cmd-list) nil (cdr cmd-list))
      (with-current-buffer buffer
        ;; Test the buffer size first; the output may be already processed,
        ;; in which case `accept-process-output' has nothing to wait for and
        ;; blocks until the timeout.
        (when (= old-size (buffer-size))
          (accept-process-output (get-buffer-process buffer)
                                 swift-mode:repl-startup-timeout))))
    (unless dont-switch
      (pop-to-buffer buffer))))

;;;###autoload
(defalias 'run-swift #'swift-mode:run-repl)

;;;###autoload
(defun swift-mode:send-region (start end)
  "Send the current region to the inferior swift process.

START and END define region within current buffer"
  (interactive "r")
  (swift-mode:run-repl swift-mode:repl-executable t t)
  (comint-send-region swift-mode:repl-buffer start end)
  (comint-send-string swift-mode:repl-buffer "\n"))

;;;###autoload
(defun swift-mode:send-buffer ()
  "Send the buffer to the Swift REPL process."
  (interactive)
  (swift-mode:send-region (point-min) (point-max)))

(define-derived-mode swift-repl-mode comint-mode "Swift REPL"
  "Major mode for interacting with Swift REPL.

A REPL can be fired up with \\<swift-mode-map>\\[swift-mode:run-repl] or \
\\<swift-mode-map>\\[run-swift].

Customization: Entry to this mode runs the hooks on `comint-mode-hook' and
`swift-repl-mode-hook' (in that order).

You can send text to the REPL process from other buffers containing source.
`swift-mode:send-region' sends the current region to the REPL process,
`swift-mode:send-buffer' sends the current buffer to the REPL process.")

(defun swift-mode:call-process (executable &rest args)
  "Call EXECUTABLE synchronously in separate process.

EXECUTABLE may be a string or a list.  The string is split by spaces,
then unquoted.
ARGS are rest arguments, appended to the argument list.
Returns the exit status."
  (swift-mode:do-call-process executable nil t nil args))

(defun swift-mode:call-process-async (executable &rest args)
  "Call EXECUTABLE asynchronously in separate process.

EXECUTABLE may be a string or a list.  The string is split by spaces,
then unquoted.
ARGS are rest arguments, appended to the argument list."
  (swift-mode:do-call-process executable nil 0 nil args))

(defun swift-mode:do-call-process (executable infile destination display args)
  "Wrapper for `call-process'.

EXECUTABLE may be a string or a list.  The string is split by spaces,
then unquoted.
For INFILE, DESTINATION, DISPLAY, see `call-process'.
ARGS are rest arguments, appended to the argument list.
Returns the exit status."
  (let ((command-list
         (append (swift-mode:command-string-to-list executable) args)))
    (apply #'call-process
           (append
            (list (car command-list))
            (list infile destination display)
            (cdr command-list)))))

(defun swift-mode:call-process-to-json (executable &rest args)
  "Call EXECUTABLE synchronously in separate process.

The output is parsed as a JSON document.
EXECUTABLE may be a string or a list.  The string is split by spaces,
then unquoted.
ARGS are rest arguments, appended to the argument list."
  (let ((stderr-file (make-temp-file "swift-mode-stderr")))
    (unwind-protect
        (with-temp-buffer
          (unless (zerop
                   (swift-mode:do-call-process executable
                                               nil
                                               ;; Redirect stderr output to a
                                               ;; temporary file, as it
                                               ;; corrupts JSON.
                                               (list t stderr-file)
                                               nil
                                               args))
            (error "%s: %s"
                   "Command exited with non-zero status"
                   (with-temp-buffer
                     (insert-file-contents stderr-file)
                     (buffer-string))))
          (goto-char (point-min))
          (json-read))
      (delete-file stderr-file))))

(defun swift-mode:describe-package (project-directory)
  "Read the package definition from the manifest file Package.swift.

The manifest file is read from the PROJECT-DIRECTORY, defaults to
`default-directory'.
Return a JSON object."
  (unless project-directory (setq project-directory default-directory))
  (swift-mode:call-process-to-json
   swift-mode:swift-package-executable
   "--package-path" (expand-file-name project-directory)
   "describe"
   "--type" "json"))

(defun swift-mode:read-main-module (project-directory)
  "Read the main module description from the manifest file Package.swift.

The main module is the first non-test target.
The manifest file is read from the PROJECT-DIRECTORY, defaults to
`default-directory'."
  (let* ((description (swift-mode:describe-package project-directory))
         (modules (assoc-default 'targets description)))
    (seq-find
     (lambda (module) (not (equal "test" (assoc-default 'type module))))
     modules)))

(defun swift-mode:read-package-name (project-directory)
  "Read the main module name from the manifest file Package.swift.

The manifest file is read from the PROJECT-DIRECTORY, defaults to
`default-directory'."
  (assoc-default 'name (swift-mode:read-main-module project-directory)))

(defun swift-mode:read-c99-name (project-directory)
  "Read the C99 name from the manifest file Package.swift.

The manifest file is read from the PROJECT-DIRECTORY, defaults to
`default-directory'."
  (assoc-default 'c99name (swift-mode:read-main-module project-directory)))

(defun swift-mode:read-module-type (project-directory)
  "Read the module type from the manifest file Package.swift.

The manifest file is read from the PROJECT-DIRECTORY, defaults to
`default-directory'."
  (assoc-default 'type (swift-mode:read-main-module project-directory)))

(defun swift-mode:join-path (directory &rest components)
  "Make path string for DIRECTORY followed by COMPONENTS."
  (seq-reduce
   (lambda (directory component) (expand-file-name component directory))
   components
   directory))

(defun swift-mode:find-ancestor-or-self-directory (predicate
                                                   &optional
                                                   directory)
  "Find the nearest ancestor-or-self directory satisfying a PREDICATE.

Traverse up from DIRECTORY up to the root directory.
Return a directory satisfying the PREDICATE if exists.  Otherwise, return nil."
  (unless directory (setq directory default-directory))
  (if (funcall predicate directory)
      directory
    (let ((parent (file-name-directory (directory-file-name directory))))
      (if (or (null parent) (string-equal parent directory))
          nil
        (swift-mode:find-ancestor-or-self-directory predicate parent)))))

(defun swift-mode:swift-project-directory-p (directory)
  ;; supress warnings:
  ;;   (checkdoc) Probably "contains" should be imperative "contain"
  "Return t if the DIRECTORY contain\u0073 a file Package.swift."
  (file-exists-p (expand-file-name "Package.swift" directory)))

(defun swift-mode:find-swift-project-directory (&optional directory)
  "Find a file Package.swift in the DIRECTORY or its ancestors.

Return a directory path if found.  Return nil otherwise."
  (swift-mode:find-ancestor-or-self-directory
   'swift-mode:swift-project-directory-p directory))

(defun swift-mode:read-project-directory (default)
  "Read a project directory from the minibuffer with DEFAULT directory."
  (expand-file-name (read-directory-name "Project directory: " default nil t)))

(defun swift-mode:ensure-swift-project-directory (project-directory)
  ;; supress warnings:
  ;;   (checkdoc) Probably "contains" should be imperative "contain"
  "Check PROJECT-DIRECTORY contain\u0073 the manifest file Package.swift.

If PROJECT-DIRECTORY is nil, this function searches it from `default-directory'
or its ancestors."
  (unless project-directory
    (setq project-directory (swift-mode:find-swift-project-directory)))
  (unless project-directory
    (error "Project directory not found"))
  (unless (swift-mode:swift-project-directory-p project-directory)
    (error "Not a project directory"))
  project-directory)

(defun swift-mode:xcode-project-directory-p (directory)
  ;; supress warnings:
  ;;   (checkdoc) Probably "contains" should be imperative "contain"
  "Return t if the DIRECTORY contain\u0073 a file *.xcodeproj."
  (consp (directory-files directory nil ".*\\.xcodeproj\\'")))

(defun swift-mode:xcode-workspace-directory-p (directory)
  ;; supress warnings:
  ;;   (checkdoc) Probably "contains" should be imperative "contain"
  "Return t if the DIRECTORY contain\u0073 a file *.xcworkspace."
  (consp (directory-files directory nil ".*\\.xcworkspace\\'")))

(defun swift-mode:find-xcode-project-directory (&optional directory)
  "Find a file *.xcodeproj in the DIRECTORY or its ancestors.

Return a directory path if found.  Return nil otherwise."
  (swift-mode:find-ancestor-or-self-directory
   'swift-mode:xcode-project-directory-p directory))

(defun swift-mode:find-xcode-workspace-directory (&optional directory)
  "Find a file *.xcworkspace in the DIRECTORY or its ancestors.

Return a directory path if found.  Return nil otherwise."
  (swift-mode:find-ancestor-or-self-directory
   'swift-mode:xcode-workspace-directory-p directory))

(defun swift-mode:ensure-xcode-project-directory (project-directory)
  ;; supress warnings:
  ;;   (checkdoc) Probably "contains" should be imperative "contain"
  "Check PROJECT-DIRECTORY contain\u0073 *.xcworkspace or *.xcodeproj.

If PROJECT-DIRECTORY is nil, this function searches it from `default-directory'
or its ancestors."
  (unless project-directory
    (setq project-directory
          (or
           (swift-mode:find-xcode-workspace-directory)
           (swift-mode:find-xcode-project-directory))))
  (unless project-directory
    (error "Project directory not found"))
  (unless (or (swift-mode:xcode-project-directory-p project-directory)
              (swift-mode:xcode-workspace-directory-p project-directory))
    (error "Not a project directory"))
  project-directory)

(defun swift-mode:list-ios-simulators ()
  "List iOS simulator devices, device types, runtimes, or device pairs."
  (swift-mode:call-process-to-json
   swift-mode:simulator-controller-executable
   "list" "--json"))

(defun swift-mode:list-ios-simulator-devices ()
  "List available iOS simulator devices."
  (let* ((json (swift-mode:list-ios-simulators))
         (devices (assoc-default 'devices json))
         (flattened (apply #'seq-concatenate 'list (seq-map #'cdr devices)))
         (available-devices
          (seq-filter
           (lambda (device) (eq t (assoc-default 'isAvailable device)))
           flattened)))
    available-devices))

(defun swift-mode:read-ios-device-identifier ()
  "Read a iOS simulator device identifier from the minibuffer."
  (let* ((devices (swift-mode:list-ios-simulator-devices))
         (items (append (list (cons "Local device"
                                    swift-mode:ios-local-device-identifier))
                        (seq-map
                         (lambda (device)
                           (cons (assoc-default 'name device)
                                 (assoc-default 'udid device)))
                         devices))))
    (widget-choose "Choose a device" items)))

(defun swift-mode:read-xcode-build-settings (project-directory
                                             scheme
                                             sdk
                                             device-identifier)
  "Read Xcode build settings in PROJECT-DIRECTORY.

SCHEME is the name of the project scheme in Xcode.
SDK is the name of the SDK build against.
DEVICE-IDENTIFIER is used as the destination parameter for xcodebuild.  If
identifier is equal to `swift-mode:ios-local-device-identifier', it is not
passed as a destination to xcodebuild."
  (with-temp-buffer
    (let ((default-directory project-directory)
          (arglist `(,swift-mode:xcodebuild-executable
                     "-configuration"
                     "Debug"
                     "-sdk"
                     ,sdk
                     "-scheme"
                     ,scheme
                     "-showBuildSettings")))
      (when (and device-identifier
                 (not (equal device-identifier
                             swift-mode:ios-local-device-identifier)))
        (setq arglist
              (append
               arglist
               `("-destination"
                 ,(concat "platform=iOS Simulator,id=" device-identifier)))))
      (unless (zerop (apply #'swift-mode:call-process arglist))
        (error "%s %s" "Cannot read Xcode build settings" (buffer-string))))
    (goto-char (point-min))
    (let ((settings nil))
      (while (search-forward-regexp " *\\([_a-zA-Z0-9]+\\) *= *\\(.*\\)" nil t)
        (push (cons (match-string 1) (match-string 2)) settings))
      settings)))

(defun swift-mode:xcodebuild-list (project-directory)
  "Return the contents of `xcodebuild -list' in PROJECT-DIRECTORY as JSON."
  (let ((default-directory project-directory)
        (json-array-type 'list))
    (swift-mode:call-process-to-json
     swift-mode:xcodebuild-executable
     "-list"
     "-json")))

(defun swift-mode:read-project-scheme (project-directory)
  "Read and prompt for a project's scheme in the minibuffer.

xcodebuild is executed in PROJECT-DIRECTORY."
  (let* ((json (swift-mode:xcodebuild-list project-directory))
         (project (or (assoc-default 'project json)
                      (assoc-default 'workspace json)))
         (schemes (assoc-default 'schemes project))
         (choices (seq-map
                   (lambda (scheme) (cons scheme scheme))
                   schemes)))
    (pcase (length schemes)
      (1 (car schemes))
      (0 (error "No schemes found in %s" project-directory))
      (_ (widget-choose "Choose a scheme" choices)))))

(defun swift-mode:locate-xcode ()
  "Return the developer path in Xcode.app.

Typically, it is /Applications/Xcode.app/Contents/Developer."
  (string-trim-right
   (with-output-to-string
     (with-current-buffer
         standard-output
       (unless (zerop (swift-mode:call-process
                       swift-mode:xcode-select-executable
                       "--print-path"))
         (error "%s: %s" "Cannot locate Xcode" (buffer-string)))))))

;;;###autoload
(defun swift-mode:build-swift-module (&optional project-directory args)
  "Build a Swift module in the PROJECT-DIRECTORY.

If PROJECT-DIRECTORY is nil or omitted, it is searched from `default-directory'
or its ancestors.
An list ARGS are appended for builder command line arguments."
  (interactive
   (let* ((default-project-directory (swift-mode:find-swift-project-directory))
          (project-directory
           (if current-prefix-arg
               (swift-mode:read-project-directory default-project-directory)
             default-project-directory)))
     (list
      project-directory
      (if (string-equal (swift-mode:read-module-type project-directory)
                        "library")
          '("-Xswiftc" "-emit-library")
        '()))))
  (setq project-directory
        (swift-mode:ensure-swift-project-directory project-directory))
  (with-current-buffer (get-buffer-create "*swift-mode:compilation*")
    (fundamental-mode)
    (setq buffer-read-only nil)
    (erase-buffer)
    (let ((progress-reporter (make-progress-reporter "Building...")))
      (unless
          (zerop
           (apply #'swift-mode:call-process
                  swift-mode:swift-build-executable
                  "--package-path" (expand-file-name project-directory)
                  args))
        (compilation-mode)
        (goto-char (point-min))
        (pop-to-buffer (current-buffer))
        (error "Build error"))
      (kill-buffer)
      (progress-reporter-done progress-reporter))))

;;;###autoload
(defun swift-mode:build-ios-app (&optional project-directory
                                           device-identifier
                                           scheme)
  "Build an iOS app in the PROJECT-DIRECTORY.
Build it for iOS device DEVICE-IDENTIFIER for the given SCHEME.
If PROJECT-DIRECTORY is nil or omitted, it is searched from `default-directory'
or its ancestors.
DEVICE-IDENTIFIER is the device identifier of the iOS simulator.  If it is nil
or omitted, the value of `swift-mode:ios-device-identifier' is used.  If it is
equal to `swift-mode:ios-local-device-identifier', the app is built for
a local device instead.
SCHEME is the name of the project scheme in Xcode.  If it is nil or omitted,
the value of `swift-mode:ios-project-scheme' is used."
  (interactive
   (let* ((default-project-directory
           (or
            (swift-mode:find-xcode-workspace-directory)
            (swift-mode:find-xcode-project-directory)))
          (project-directory
           (if current-prefix-arg
               (swift-mode:read-project-directory default-project-directory)
             default-project-directory)))
     (list
      project-directory
      (if current-prefix-arg
          (swift-mode:read-ios-device-identifier)
        swift-mode:ios-device-identifier)
      (if current-prefix-arg
          (swift-mode:read-project-scheme project-directory)
        swift-mode:ios-project-scheme))))
  (setq project-directory
        (swift-mode:ensure-xcode-project-directory project-directory))
  (unless device-identifier
    (setq device-identifier
          (or
           swift-mode:ios-device-identifier
           (swift-mode:read-ios-device-identifier))))
  (setq swift-mode:ios-device-identifier device-identifier)
  (unless scheme
    (setq scheme
          (or
           swift-mode:ios-project-scheme
           (swift-mode:read-project-scheme project-directory))))
  (setq swift-mode:ios-project-scheme scheme)

  (with-current-buffer (get-buffer-create "*swift-mode:compilation*")
    (fundamental-mode)
    (setq buffer-read-only nil)
    (erase-buffer)
    (let ((progress-reporter (make-progress-reporter "Building..."))
          (xcodebuild-args `(,swift-mode:xcodebuild-executable
                             "-configuration" "Debug"
                             "-scheme" ,scheme)))
      (if (equal device-identifier swift-mode:ios-local-device-identifier)
          (setq xcodebuild-args (append xcodebuild-args '("-sdk" "iphoneos")))
        (setq xcodebuild-args
              (append xcodebuild-args
                      `("-destination"
                        ,(concat "platform=iOS Simulator,id=" device-identifier)
                        "-sdk" "iphonesimulator"))))
      (unless
          (zerop
           (let ((default-directory project-directory))
             (apply #'swift-mode:call-process xcodebuild-args)))
        (compilation-mode)
        (goto-char (point-min))
        (pop-to-buffer (current-buffer))
        (error "Build error"))
      (kill-buffer)
      (progress-reporter-done progress-reporter))))

(defun swift-mode:wait-for-prompt-then-execute-commands (string)
  "Execute the next command from the queue if the point is on a prompt.

Intended for used as a `comint-output-filter-functions'.
STRING is passed to the command."
  (let ((command (car swift-mode:repl-command-queue)))
    (when (and
           ;; The point is on an input field of comint.
           (null (field-at-pos (point)))
           ;; It is a LLDB prompt rather than that of the target executable.
           (save-excursion
             (if (and (consp command) (functionp (car command)))
                 ;; Calls custom function to search expected output
                 (funcall (car command) string)
               (forward-line 0)
               (looking-at
                (if (consp command)
                    ;; Using custom regexp
                    (car command)
                  ;; Using standard regexp
                  swift-mode:debugger-prompt-regexp)))))
      (when swift-mode:repl-command-queue
        (pop swift-mode:repl-command-queue)
        (let* ((process-mark
                (process-mark (get-buffer-process (current-buffer))))
               (pending-input (buffer-substring process-mark (point-max))))
          ;; Keep partial input typed by the user out of the command.
          (delete-region process-mark (point-max))
          (goto-char process-mark)
          (insert (if (consp command) (cdr command) command))
          (comint-send-input)
          ;; Restore the partial input typed by the user.
          (goto-char (point-max))
          (insert pending-input)))
      (unless swift-mode:repl-command-queue
        (remove-hook 'comint-output-filter-functions
                     #'swift-mode:wait-for-prompt-then-execute-commands t)))))

(defun swift-mode:enqueue-repl-commands (&rest commands)
  "Enqueue COMMANDS to be executed on REPL."
  (with-current-buffer swift-mode:repl-buffer
    (setq-local swift-mode:repl-command-queue
                (append swift-mode:repl-command-queue commands))
    (add-hook 'comint-output-filter-functions
              #'swift-mode:wait-for-prompt-then-execute-commands
              nil t)))

(defun swift-mode:debug-swift-module-library (project-directory)
  "Run debugger on a Swift library module in the PROJECT-DIRECTORY."
  (let* ((c99name (swift-mode:read-c99-name project-directory))
         (import-statement (concat "import " c99name))
         (build-debug-directory
          (swift-mode:join-path project-directory ".build" "debug")))
    (unless c99name (error "Cannot get module name"))
    (swift-mode:build-swift-module project-directory
                                   '("-Xswiftc" "-emit-library"))
    (swift-mode:run-repl
     (append
      (swift-mode:command-string-to-list swift-mode:repl-executable)
      (list
       "-I" build-debug-directory
       "-L" build-debug-directory
       (concat "-l" c99name)))
     nil t)
    (swift-mode:enqueue-repl-commands import-statement)))

(defun swift-mode:debug-swift-module-executable (project-directory)
  "Run debugger on a Swift executable module in the PROJECT-DIRECTORY."
  (let ((package-name (swift-mode:read-package-name project-directory)))
    (unless package-name (error "Cannot get module name"))
    (swift-mode:build-swift-module project-directory)
    (swift-mode:run-repl
     (append
      (swift-mode:command-string-to-list swift-mode:debugger-executable)
      (list
       (swift-mode:join-path project-directory ".build" "debug" package-name)))
     nil t)
    (swift-mode:enqueue-repl-commands
     "breakpoint set --one-shot true --file main.swift --name main"
     "run"
     "repl")))

;;;###autoload
(defun swift-mode:debug-swift-module (&optional project-directory)
  "Run debugger on a Swift module in the PROJECT-DIRECTORY.

If PROJECT-DIRECTORY is nil or omitted, it is searched from `default-directory'
or its ancestors."
  (interactive
   (let ((default-project-directory (swift-mode:find-swift-project-directory)))
     (list
      (if current-prefix-arg
          (swift-mode:read-project-directory default-project-directory)
        default-project-directory))))
  (setq project-directory
        (swift-mode:ensure-swift-project-directory project-directory))
  (if (string-equal (swift-mode:read-module-type project-directory) "library")
      (swift-mode:debug-swift-module-library project-directory)
    (swift-mode:debug-swift-module-executable project-directory)))

(defun swift-mode:find-ios-simulator-process ()
  "Return the process ID of an iOS simulator process if exists.

Return nil otherwise."
  (with-temp-buffer
    (swift-mode:call-process "ps" "-x" "-o" "pid,comm")
    (goto-char (point-min))
    (if (search-forward-regexp
         " *\\([0-9]*\\) .*/Applications/Simulator.app/Contents/MacOS/Simulator"
         nil t)
        (string-to-number (match-string 1))
      nil)))

(defun swift-mode:kill-ios-simulator ()
  "Kill an iOS simulator process if exists.

Wait until the process is terminated."
  (let ((process-identifier (swift-mode:find-ios-simulator-process)))
    (when process-identifier
      (signal-process
       process-identifier
       'SIGTERM)
      (while (swift-mode:find-ios-simulator-process)
        (sit-for 0.5)))))

(defun swift-mode:open-ios-simulator (device-identifier)
  "Open an iOS simulator asynchronously with DEVICE-IDENTIFIER."
  (swift-mode:call-process-async
   (swift-mode:join-path
    (swift-mode:locate-xcode)
    "Applications" "Simulator.app" "Contents" "MacOS" "Simulator")
   "-CurrentDeviceUDID" device-identifier))

(defun swift-mode:wait-for-ios-simulator (device-identifier)
  "Wait until an iOS simulator with DEVICE-IDENTIFIER booted.

Signal an error if it is not booted within
`swift-mode:ios-simulator-boot-timeout' seconds."
  (let ((deadline (+ (float-time) swift-mode:ios-simulator-boot-timeout)))
    (while (null (seq-find
                  (lambda (device)
                    (and (string-equal (assoc-default 'udid device)
                                       device-identifier)
                         (string-equal (assoc-default 'state device) "Booted")))
                  (swift-mode:list-ios-simulator-devices)))
      (when (< deadline (float-time))
        (error "%s: %s" "Cannot boot the iOS simulator" device-identifier))
      (sit-for 0.5))))

(defun swift-mode:install-ios-app (device-identifier codesigning-folder-path)
  "Install an iOS app to an iOS simulator with DEVICE-IDENTIFIER.

CODESIGNING-FOLDER-PATH is the path of the app."
  (with-temp-buffer
    (unless (zerop (swift-mode:call-process
                    swift-mode:simulator-controller-executable
                    "install"
                    device-identifier
                    codesigning-folder-path))
      (error "%s: %s" "Cannot install app" (buffer-string)))))

(defun swift-mode:launch-ios-app (device-identifier
                                  product-bundle-identifier
                                  &optional
                                  wait-for-debugger)
  "Launch an iOS app in DEVICE-IDENTIFIER.

PRODUCT-BUNDLE-IDENTIFIER is the product bundle identifier of the app.
If WAIT-FOR-DEBUGGER is non-nil, the new process is suspended until a debugger
attaches to it."
  (with-temp-buffer
    (unless (zerop (apply
                    #'swift-mode:call-process
                    swift-mode:simulator-controller-executable
                    (append
                     '("launch")
                     (if wait-for-debugger '("--wait-for-debugger") nil)
                     (list device-identifier product-bundle-identifier))))
      (error "%s: %s" "Cannot launch app" (buffer-string)))
    (goto-char (point-min))
    (unless (search-forward-regexp ": \\([0-9]+\\)$" nil t)
      (error "%s: %s" "Cannot get PID of app" (buffer-string)))
    (string-to-number (match-string 1))))

(defun swift-mode:search-process-stopped-message (process-identifier)
  "Find a message of process suspension in the comint output.

PROCESS-IDENTIFIER is the process ID."
  (let ((expected-output
         (concat "Process "
                 (number-to-string process-identifier)
                 " stopped")))
    (goto-char comint-last-input-end)
    (search-forward expected-output nil t)))

;;;###autoload
(defun swift-mode:debug-ios-app-on-device (project-directory
                                           scheme
                                           codesigning-folder-path)
  "Run debugger on an iOS app in the PROJECT-DIRECTORY.
Run it for the iOS local device for the given SCHEME.
CODESIGNING-FOLDER-PATH is the path of the codesigning folder in Xcode
build settings."
  (swift-mode:build-ios-app project-directory
                            swift-mode:ios-local-device-identifier
                            scheme)
  (swift-mode:run-repl
   (append
    (swift-mode:command-string-to-list swift-mode:ios-deploy-executable)
    (list "--debug" "--no-wifi" "--bundle" codesigning-folder-path))
   nil t))

;;;###autoload
(defun swift-mode:debug-ios-app-on-simulator (project-directory
                                              device-identifier
                                              scheme
                                              codesigning-folder-path
                                              product-bundle-identifier)
  "Run debugger on an iOS app in the PROJECT-DIRECTORY.
Run it for the iOS simulator DEVICE-IDENTIFIER for the given SCHEME.
DEVICE-IDENTIFIER is the device identifier of the iOS simulator.
SCHEME is the name of the project scheme in Xcode.
CODESIGNING-FOLDER-PATH is the path of the codesigning folder used in Xcode
build settings.
PRODUCT-BUNDLE-IDENTIFIER is the name of the product bundle identifier used
in Xcode build settings."
  (swift-mode:build-ios-app project-directory device-identifier scheme)
  (let* ((devices (swift-mode:list-ios-simulator-devices))
         (target-device
          (seq-find
           (lambda (device)
             (string-equal (assoc-default 'udid device) device-identifier))
           devices))
         (active-devices
          (seq-filter
           (lambda (device)
             (string-equal (assoc-default 'state device) "Booted"))
           devices))
         (target-booted
          (string-equal (assoc-default 'state target-device) "Booted"))
         (simulator-running (consp active-devices))
         progress-reporter)
    (unless target-device
      (error "%s: %s" "No such iOS simulator device" device-identifier))
    (setq progress-reporter (make-progress-reporter "Waiting for simulator..."))
    (cond
     (target-booted
      ;; The target device is already booted. Does nothing.
      t)
     (simulator-running
      (swift-mode:kill-ios-simulator)
      (swift-mode:open-ios-simulator device-identifier))
     (t (swift-mode:open-ios-simulator device-identifier)))

    (swift-mode:wait-for-ios-simulator device-identifier)

    (progress-reporter-done progress-reporter)

    (let ((progress-reporter (make-progress-reporter "Installing app...")))
      (swift-mode:install-ios-app device-identifier codesigning-folder-path)
      (progress-reporter-done progress-reporter))

    (let ((progress-reporter (make-progress-reporter "Launching app..."))
          (process-identifier
           (swift-mode:launch-ios-app
            device-identifier product-bundle-identifier t)))
      (progress-reporter-done progress-reporter)
      (swift-mode:run-repl
       (append
        (swift-mode:command-string-to-list swift-mode:debugger-executable)
        (list "--" codesigning-folder-path))
       nil t)
      (swift-mode:enqueue-repl-commands
       "platform select ios-simulator"
       (concat "platform connect " device-identifier)
       (concat "process attach --pid " (number-to-string process-identifier))
       "breakpoint set --one-shot true --name UIApplicationMain"
       "cont"
       (cons
        (lambda (_string)
          (swift-mode:search-process-stopped-message process-identifier))
        "repl")))))

;;;###autoload
(defun swift-mode:debug-ios-app (&optional project-directory
                                           device-identifier
                                           scheme)
  "Run debugger on an iOS app in the PROJECT-DIRECTORY.
Run it for the iOS simulator device DEVICE-IDENTIFIER for the given SCHEME.
If PROJECT-DIRECTORY is nil or omitted, it is searched from `default-directory'
or its ancestors.
DEVICE-IDENTIFIER is the device identifier of the iOS simulator.  If it is
nil or omitted, the value of `swift-mode:ios-device-identifier' is used.  If
it is equal to `swift-mode:ios-local-device-identifier', a local build via
`ios-deploy' is generated instead.
SCHEME is the name of the project scheme in Xcode.  If it is nil or omitted,
the value of `swift-mode:ios-project-scheme' is used."
  (interactive
   (let* ((default-project-directory
           (or
            (swift-mode:find-xcode-workspace-directory)
            (swift-mode:find-xcode-project-directory)))
          (project-directory
           (if current-prefix-arg
               (swift-mode:read-project-directory default-project-directory)
             default-project-directory)))
     (list
      project-directory
      (if current-prefix-arg
          (swift-mode:read-ios-device-identifier)
        swift-mode:ios-device-identifier)
      (if current-prefix-arg
          (swift-mode:read-project-scheme project-directory)
        swift-mode:ios-project-scheme))))
  (setq project-directory
        (swift-mode:ensure-xcode-project-directory project-directory))
  (unless device-identifier
    (setq device-identifier
          (or
           swift-mode:ios-device-identifier
           (swift-mode:read-ios-device-identifier))))
  (setq swift-mode:ios-device-identifier device-identifier)
  (unless scheme
    (setq scheme
          (or
           swift-mode:ios-project-scheme
           (swift-mode:read-project-scheme project-directory))))
  (setq swift-mode:ios-project-scheme scheme)
  (let* ((local-device-build (equal device-identifier
                                    swift-mode:ios-local-device-identifier))
         (sdk (if local-device-build "iphoneos" "iphonesimulator"))
         (build-settings
          (swift-mode:read-xcode-build-settings
           project-directory
           scheme
           sdk
           device-identifier))
         (codesigning-folder-path
          (assoc-default "CODESIGNING_FOLDER_PATH" build-settings))
         (product-bundle-identifier
          (assoc-default "PRODUCT_BUNDLE_IDENTIFIER" build-settings)))
    (unless codesigning-folder-path
      (error "Cannot get codesigning folder path"))
    (unless product-bundle-identifier
      (error "Cannot get product bundle identifier"))

    (if local-device-build
        (swift-mode:debug-ios-app-on-device project-directory
                                            scheme
                                            codesigning-folder-path)
      (swift-mode:debug-ios-app-on-simulator project-directory
                                             device-identifier
                                             scheme
                                             codesigning-folder-path
                                             product-bundle-identifier))))

(defvar swift-mode:compilation-swift-files nil
  "Hash table from Swift filenames in the project to its absolute path.")

(defun swift-mode:setup-swift-testing-buffer ()
  "Prepare *compilation* buffer for Swift Testing.

Initialize a hash table from names of swift files to its absolute path.

Adds `ansi-color-compilation-filter' to `compilation-filter-hook'.

Overrides `compilation-error-regexp-alist'."
  (setq-local swift-mode:compilation-swift-files nil)
  (when (fboundp 'ansi-color-compilation-filter)
    (add-hook 'compilation-filter-hook #'ansi-color-compilation-filter nil t))
  (setq-local compilation-error-regexp-alist '(swift-testing)))

(defvar swift-mode:original-compilation-process-setup-function nil
  "`compilation-process-setup-function' before `swift-mode:setup-swift-testing'.

It is called from `swift-mode:compilation-process-setup-function'.")

(defun swift-mode:swift-testing-compilation-process-setup-function ()
  "`compilation-process-setup-function' for Swift Testing.

Call original `compilation-process-setup-function' and prepare *compilation*
buffer.

See also `swift-mode:setup-swift-testing-buffer'."
  (when swift-mode:original-compilation-process-setup-function
    (funcall swift-mode:original-compilation-process-setup-function))
  (when (string-match-p swift-mode:swift-testing-command-regexp
                        (car compilation-arguments))
    (swift-mode:setup-swift-testing-buffer)))

(defun swift-mode:find-test-sources (project-directory target)
  "Return a list of the absolute paths of test sources.

The manifest file is searched from the PROJECT-DIRECTORY, defaults to
`default-directory', or its ancestors.

If TARGET is non-nil, return only sources of that target."
  (let* ((description (swift-mode:describe-package project-directory))
         (targets (assoc-default 'targets description))
         (test-targets (seq-filter
                        (lambda (module)
                          (and (equal "test" (assoc-default 'type module))
                               (or (null target)
                                   (equal target
                                          (assoc-default 'name module)))))
                        targets))
         (project-path (assoc-default 'path description))
         target-path)
    (seq-mapcat
     (lambda (target)
       (setq target-path
             (expand-file-name (assoc-default 'path target) project-path))
       (mapcar (lambda (source)
                 (expand-file-name source target-path))
               (assoc-default 'sources target)))
     test-targets)))

(defun swift-mode:resolve-swift-test-file (file)
  "Return a list of the full path of Swift Testing FILE in the project if any.

If FILE is an absolute path, return a list of it as is, even if it doesn't
exist.

If FILE doesn't exist in the project, return nil."
  (if (file-name-absolute-p file)
      (list file)
    (when (null swift-mode:compilation-swift-files)
      (setq-local swift-mode:compilation-swift-files
                  (make-hash-table :test 'equal))
      (dolist (path (swift-mode:find-test-sources
                     (or compilation-directory default-directory)
                     ;; TODO
                     nil))
        (puthash (file-name-nondirectory path)
                 path
                 swift-mode:compilation-swift-files)))
    (let ((path (gethash (file-name-nondirectory file)
                         swift-mode:compilation-swift-files)))
      (and path (list path)))))

(defun swift-mode:resolve-and-highlight-swift-test-file ()
  "Call `swift-mode:resolve-swift-test-file' on match and highlight if needed.

When a function is specified as the `file' component of
`compilation-error-regexp-alist-alist', `compile' does not highlight the
matching filename.  So we have to highlight it here."
  (save-match-data
    (let ((start (match-beginning 2))
          (end (match-end 2))
          (result (funcall swift-mode:resolve-swift-test-file-function
                           (match-string 2))))
      (when result
        (put-text-property
         start
         end
         'font-lock-face
         compilation-error-face))
      result)))

(defun swift-mode:setup-swift-testing ()
  "Setup `compilation-process-setup-function' for Swift Testing.

Save original `compilation-process-setup-function' and set
`compilation-process-setup-function' to
`swift-mode:swift-testing-compilation-process-setup-function'.

Also add an entry to `compilation-error-regexp-alist-alist'."
  (unless swift-mode:original-compilation-process-setup-function
    (setq swift-mode:original-compilation-process-setup-function
          (or compilation-process-setup-function #'ignore))
    (setq compilation-process-setup-function
          #'swift-mode:swift-testing-compilation-process-setup-function)
    (add-to-list 'compilation-error-regexp-alist-alist
                 `(swift-testing
                   ,(rx bol
                        (zero-or-one (seq (* not-newline) (any " \t")))
                        (group
                         (group (+ (not (any " \t\n"))) ".swift")
                         ":"
                         (group (+ digit))
                         (zero-or-one
                          ":"
                          (group (+ digit))))
                        ": ")
                   ;; filename
                   ,#'swift-mode:resolve-and-highlight-swift-test-file
                   ;; line
                   3
                   ;; column
                   4
                   ;; type
                   nil
                   ;; hyperlink
                   1))))

(provide 'swift-mode-repl)

;;; swift-mode-repl.el ends here
