;;; dune-flymake.el --- Flymake support for dune files   -*- coding: utf-8 -*-

;; Copyright 2017- Christophe Troestler
;; URL: https://github.com/ocaml/dune

;; This file is not part of GNU Emacs.

;;; Commentary:

;; This package complements the dune mode with on the fly tests to
;; pinpoint errors.

;; Permission to use, copy, modify, and distribute this software for
;; any purpose with or without fee is hereby granted, provided that
;; the above copyright notice and this permission notice appear in
;; all copies.
;;
;; THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
;; WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED
;; WARRANTIES OF MERCHANTABILITY AND FITNESS.  IN NO EVENT SHALL THE
;; AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR
;; CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
;; LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
;; NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
;; CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

(require 'flymake)
(require 'dune)

;;; Code:

(defvar dune-flymake-temporary-file-directory
  (expand-file-name "dune" temporary-file-directory)
  "Directory where to duplicate the files for flymake.")

(defvar dune-flymake-program
  (expand-file-name "dune-lint" dune-flymake-temporary-file-directory)
  "Script to use to check the dune file.")

(defvar dune-flymake--allowed-file-name-masks
  '("\\(?:\\`\\|/\\)dune\\'" dune-flymake-init
    dune-flymake-cleanup)
  "Flymake entry for dune files.  See `flymake-allowed-file-name-masks'.")

(defvar dune-flymake--err-line-patterns
  ;; Beware that the path from the root will be reported by dune
  ;; but flymake requires it to match the file name.
  '(("File \"[^\"]*\\(dune\\)\", line \\([0-9]+\\), \
characters \\([0-9]+\\)-\\([0-9]+\\): +\\([^\n]*\\)$"
     1 2 3 5))
  "Value of `flymake-err-line-patterns' for dune files.")

(defun dune-flymake-create-lint-script ()
  "Create the lint script if it does not exist.
This is needed as long as https://github.com/ocaml/dune/issues/241
is not fixed."
  (unless (file-exists-p dune-flymake-program)
    (let ((dir (file-name-directory dune-program))
          (pgm "#!/usr/bin/env ocaml
;;
#load \"unix.cma\";;
#load \"str.cma\";;

open Printf

let filename = Sys.argv.(1)
let root = try Some(Sys.argv.(2)) with _ -> None

let read_all fh =
  let buf = Buffer.create 1024 in
  let b = Bytes.create 1024 in
  let len = ref 0 in
  while len := input fh b 0 1024; !len > 0 do
    Buffer.add_subbytes buf b 0 !len
  done;
  Buffer.contents buf

let errors =
  let root = match root with
    | None | Some \"\" -> \"\"
    | Some r -> \"--root=\" ^ Filename.quote r in
  let cmd = sprintf \"dune external-lib-deps %s %s\" root
              (Filename.quote (Filename.basename filename)) in
  let env = Unix.environment() in
  let (_,_,fh) as p = Unix.open_process_full cmd env in
  let out = read_all fh in
  match Unix.close_process_full p with
  | Unix.WEXITED (0|1) ->
     (* dune will normally exit with 1 as it will not be able to
        perform the requested action. *)
     out
  | Unix.WEXITED 127 -> printf \"dune not found in path.\\n\"; exit 1
  | Unix.WEXITED n -> printf \"dune exited with status %d.\\n\" n; exit 1
  | Unix.WSIGNALED n -> printf \"dune was killed by signal %d.\\n\" n;
                        exit 1
  | Unix.WSTOPPED n -> printf \"dune was stopped by signal %d\\n.\" n;
                       exit 1


let () =
  let re = \"\\\\(:?\\\\)[\\r\\n]+\\\\([a-zA-Z]+\\\\)\" in
  let errors = Str.global_substitute (Str.regexp re)
                 (fun s -> let colon = Str.matched_group 1 s = \":\" in
                           let f = Str.matched_group 2 s in
                           if f = \"File\" then \"\\n File\"
                           else if colon then \": \" ^ f
                           else \", \" ^ f)
                 errors in
  print_string errors"))
      (make-directory dir t)
      (append-to-file pgm nil dune-program)
      (set-file-modes dune-program #o777)
      )))

(defun dune-flymake--temp-name (absolute-path)
  "Return the full path of the copy of ABSOLUTE-PATH in the temp dir.
The temporary directory is given by `dune-flymake-temporary-file-directory'."
  (let ((slash-pos (string-match "/" absolute-path)))
    (file-truename (expand-file-name (substring absolute-path (1+ slash-pos))
                                     dune-flymake-temporary-file-directory))))

(defun dune-flymake--opam-files (dir)
  "Return all opam files in the directory DIR."
  (let ((files nil))
    (dolist (f (directory-files-and-attributes dir t ".*\\.opam\\'"))
      (when (null (cadr f))
        (push (car f) files)))
    files))

(defun dune-flymake--root (filename)
  "Return the Dune root for FILENAME.
Create the temporary copy the necessary context files for dune."
  ;; FIXME: the root depends on dune-project.  If none is found,
  ;; assume the commands are issued from the dir where opam files are found.
  (let* ((dir (locate-dominating-file (file-name-directory filename)
                                      #'dune-flymake--opam-files)))
    (when dir
      (setq dir (expand-file-name dir)); In case it is ~/...
      (make-directory (dune-flymake--temp-name dir) t)
      (dolist (f (dune-flymake--opam-files dir))
        (copy-file f (dune-flymake--temp-name f) t)))
    dir))

(defalias 'dune-flymake--safe-delete-file
  (if (fboundp 'flymake-proc--safe-delete-file)
      'flymake-proc--safe-delete-file
    'flymake-safe-delete-file))

(defun dune-flymake--delete-opam-files (dir)
  "Delete all opam files in the directory DIR."
  (dolist (f (dune-flymake--opam-files dir))
    (dune-flymake--safe-delete-file f)))

(defvaralias 'dune-flymake--temp-source-file-name
  (if (boundp 'flymake-proc--temp-source-file-name)
      'flymake-proc--temp-source-file-name
    'flymake-temp-source-file-name))

(defun dune-flymake-cleanup ()
  "Attempt to delete temp dir created by `dune-flymake-create-temp'.
Do not fail on error."
  (let ((dir (file-name-directory dune-flymake--temp-source-file-name))
        (temp-dir (concat (directory-file-name
                           dune-flymake-temporary-file-directory) "/")))
    (flymake-log 3 "Clean up %s" dune-flymake--temp-source-file-name)
    (dune-flymake--safe-delete-file dune-flymake--temp-source-file-name)
    (condition-case nil
        (delete-directory (expand-file-name "_build" dir) t)
      (error nil))
    ;; Also delete parent dirs if empty or only contain opam files
    (while (and (not (string-equal dir temp-dir))
                (> (length dir) 0))
      (condition-case nil
          (progn
            (dune-flymake--delete-opam-files dir)
            (delete-directory dir)
            (setq dir (file-name-directory (directory-file-name dir))))
        (error ; then top the loop
         (setq dir ""))))))

(defalias 'dune-flymake--create-temp-buffer-copy
  (if (fboundp 'flymake-proc-init-create-temp-buffer-copy)
      'flymake-proc-init-create-temp-buffer-copy
    'flymake-init-create-temp-buffer-copy))

(defun dune-flymake-init ()
  "Set up dune-flymake."
  (dune-flymake-create-lint-script)
  (let ((fname (dune-flymake--create-temp-buffer-copy
                'dune-flymake-create-temp))
        (root (or (dune-flymake--root buffer-file-name) "")))
    (list dune-program (list fname root))))

(defun dune-flymake-dune-mode-hook ()
  "Hook to add to `dune-mode-hook' to enable lint tests."
  (push dune-flymake--allowed-file-name-masks
        (if (boundp 'flymake-proc-allowed-file-name-masks)
            flymake-proc-allowed-file-name-masks
          flymake-allowed-file-name-masks))
  (set (make-local-variable (if (boundp 'flymake-proc-err-line-patterns)
                                 'flymake-proc-err-line-patterns
                               'flymake-err-line-patterns))
        dune-flymake--err-line-patterns))

(provide 'dune-flymake)

;;; dune-flymake.el ends here
