Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Changes

- Register OCaml compilation error regexp for `M-x compile` support (errors, warnings, alerts, backtraces).
- Add `treesit-thing-settings` for sexp, sentence, text, and comment navigation (Emacs 30+).
- Add sentence navigation (`M-a`/`M-e`) for moving between top-level definitions.
- `transpose-sexps` now works with tree-sitter awareness (Emacs 30+).
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ alternatives.
| .ml/.mli toggle | Yes | Yes | Yes |
| LSP (Eglot) integration | Yes | Manual setup | Manual setup |
| Debugger (ocamldebug) | No | Yes | Yes |
| Compilation commands | No | Yes | Yes |
| Compilation commands | Error regexp only | Yes | Yes |
| Menhir / opam support | No | No | Yes |
| Code templates / skeletons | No | Yes | Yes |

Expand Down Expand Up @@ -282,6 +282,7 @@ You can customize the OCaml REPL integration with the following variables:
- Toggling between implementation and interface via `ff-find-other-file` (`C-c C-a`)
- OCaml toplevel (REPL) integration (`neocaml-repl`)
- Easy installation of `ocaml` and `ocaml-interface` tree-sitter grammars via `M-x neocaml-install-grammars`
- Compilation error regexp for `M-x compile` (errors, warnings, alerts, backtraces)
- Eglot integration (with [ocaml-eglot](https://github.com/tarides/ocaml-eglot) support)
- Prettify-symbols for common OCaml operators

Expand Down
71 changes: 69 additions & 2 deletions neocaml.el
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,71 @@ Configures sexp, sentence, text, and comment navigation."
(text ,(regexp-opt '("comment" "string" "quoted_string" "character")))
(comment "comment"))))

;;;; Compilation support

(defconst neocaml--compilation-error-regexp
(eval-when-compile
(rx bol
;; 0 or 7 leading spaces. 7 spaces = ancillary location (info level).
;; Requiring exactly 7 avoids false matches on Python tracebacks.
(? (group-n 9 " "))
(group-n 1
(or "File "
;; Exception backtraces (OCaml >= 4.11 includes function names)
(seq (or "Raised at" "Re-raised at"
"Raised by primitive operation at"
"Called from")
(* nonl)
" file "))
(group-n 2 (? "\""))
(group-n 3 (+ (not (in "\t\n \",<>"))))
(backref 2)
(? " (inlined)")
", line" (? "s") " "
(group-n 4 (+ (in "0-9")))
(? "-" (group-n 5 (+ (in "0-9"))))
(? ", character" (? "s") " "
(group-n 6 (+ (in "0-9")))
(? "-" (group-n 7 (+ (in "0-9")))))
(? ":"))
;; Skip source-code snippets and match Warning/Alert on next line
(? "\n"
(* (in "\t "))
(* (or (seq (+ (in "0-9"))
" | "
(* nonl))
(+ "^"))
"\n"
(* (in "\t ")))
(group-n 8 (or "Warning" "Alert")
(* (not (in ":\n")))
":"))))
"Regexp matching OCaml compiler error, warning, and backtrace messages.")

(defun neocaml--compilation-end-column ()
"Return the end-column from an OCaml compilation message.
OCaml uses exclusive end-columns but Emacs expects inclusive ones."
(when (match-beginning 7)
(+ (string-to-number (match-string 7))
(if (>= emacs-major-version 28) -1 0))))

(defvar compilation-error-regexp-alist)
(defvar compilation-error-regexp-alist-alist)

(defun neocaml--setup-compilation ()
"Register OCaml compilation error regexp with compile.el."
(require 'compile)
(setq compilation-error-regexp-alist-alist
(assq-delete-all 'ocaml compilation-error-regexp-alist-alist))
(push `(ocaml
,neocaml--compilation-error-regexp
3 (4 . 5) (6 . neocaml--compilation-end-column) (8 . 9) 1
(8 font-lock-function-name-face))
compilation-error-regexp-alist-alist)
(setq compilation-error-regexp-alist
(delq 'ocaml compilation-error-regexp-alist))
(push 'ocaml compilation-error-regexp-alist))

;;;; Utility commands

(defconst neocaml-report-bug-url "https://github.com/bbatsov/neocaml/issues/new"
Expand Down Expand Up @@ -803,7 +868,8 @@ Shared setup used by both `neocaml-mode' and `neocaml-interface-mode'."
\\{neocaml-mode-map}"
:syntax-table neocaml-mode-syntax-table
(setq-local treesit-simple-imenu-settings neocaml--imenu-settings)
(neocaml--setup-mode 'ocaml))
(neocaml--setup-mode 'ocaml)
(neocaml--setup-compilation))

;;;###autoload
(define-derived-mode neocaml-interface-mode prog-mode "OCaml[Interface]"
Expand All @@ -812,7 +878,8 @@ Shared setup used by both `neocaml-mode' and `neocaml-interface-mode'."
\\{neocaml-interface-mode-map}"
:syntax-table neocaml-mode-syntax-table
(setq-local treesit-simple-imenu-settings neocaml--interface-imenu-settings)
(neocaml--setup-mode 'ocaml-interface))
(neocaml--setup-mode 'ocaml-interface)
(neocaml--setup-compilation))

;;;###autoload
(progn
Expand Down
139 changes: 139 additions & 0 deletions test/neocaml-compilation-test.el
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
;;; neocaml-compilation-test.el --- Tests for compilation error regexp -*- lexical-binding: t; -*-

;; Copyright © 2025-2026 Bozhidar Batsov

;;; Commentary:

;; Tests for `neocaml--compilation-error-regexp' covering all OCaml compiler
;; output formats: errors, warnings, alerts, backtraces, and ancillary locations.

;;; Code:

(require 'buttercup)
(require 'neocaml)

(defun neocaml-test--match-compilation (output)
"Match OUTPUT against `neocaml--compilation-error-regexp' in a temp buffer.
Returns a plist (:file FILE :line LINE :end-line END-LINE
:col COL :end-col END-COL :severity SEV) where SEV is
2 (error), 1 (warning), or 0 (info), matching compilation convention.
Returns nil if the regexp does not match."
(with-temp-buffer
(insert output)
(goto-char (point-min))
(when (re-search-forward neocaml--compilation-error-regexp nil t)
(let* ((file (match-string 3))
(line (string-to-number (match-string 4)))
(end-line (when (match-string 5)
(string-to-number (match-string 5))))
(col (when (match-string 6)
(string-to-number (match-string 6))))
(end-col (when (match-string 7)
(string-to-number (match-string 7))))
;; Severity mirrors compilation-error-regexp-alist convention:
;; group 8 matched = warning (1), group 9 matched = info (0),
;; otherwise error (2).
;; N.B. We use :severity instead of :type to avoid a conflict
;; with buttercup's oclosure :type tag on Emacs 29.
(severity (cond ((match-string 8) 1)
((match-string 9) 0)
(t 2))))
(list :file file :line line :end-line end-line
:col col :end-col end-col :severity severity)))))

(describe "compilation regexp"
(it "matches a simple error"
(let ((info (neocaml-test--match-compilation
"File \"foo.ml\", line 4, characters 6-7:\nError: Unbound value x\n")))
(expect info :not :to-be nil)
(expect (plist-get info :file) :to-equal "foo.ml")
(expect (plist-get info :severity) :to-equal 2)
(expect (plist-get info :line) :to-equal 4)
(expect (plist-get info :col) :to-equal 6)))

(it "matches a warning"
(let ((info (neocaml-test--match-compilation
"File \"foo.ml\", line 3, characters 6-7:\nWarning 26 [unused-var]: unused variable x.\n")))
(expect info :not :to-be nil)
(expect (plist-get info :file) :to-equal "foo.ml")
(expect (plist-get info :severity) :to-equal 1)
(expect (plist-get info :line) :to-equal 3)))

(it "matches a warning (old format without mnemonic)"
(let ((info (neocaml-test--match-compilation
"File \"foo.ml\", line 3, characters 6-7:\nWarning 26: unused variable x.\n")))
(expect info :not :to-be nil)
(expect (plist-get info :severity) :to-equal 1)))

(it "matches an alert"
(let ((info (neocaml-test--match-compilation
"File \"foo.ml\", line 5, characters 9-10:\nAlert deprecated: use new_fn instead.\n")))
(expect info :not :to-be nil)
(expect (plist-get info :file) :to-equal "foo.ml")
(expect (plist-get info :severity) :to-equal 1)))

(it "matches a multi-line span"
(let ((info (neocaml-test--match-compilation
"File \"foo.ml\", lines 5-7, characters 10-20:\nError: Syntax error\n")))
(expect info :not :to-be nil)
(expect (plist-get info :file) :to-equal "foo.ml")
(expect (plist-get info :line) :to-equal 5)
(expect (plist-get info :end-line) :to-equal 7)))

(it "matches error with source-code snippet"
(let ((info (neocaml-test--match-compilation
"File \"foo.ml\", line 2, characters 0-5:\n2 | let x\n ^^^^^\nError: Unbound value x\n")))
(expect info :not :to-be nil)
(expect (plist-get info :file) :to-equal "foo.ml")
(expect (plist-get info :severity) :to-equal 2)
(expect (plist-get info :line) :to-equal 2)
(expect (plist-get info :col) :to-equal 0)))

(it "matches warning with source-code snippet"
(let ((info (neocaml-test--match-compilation
"File \"foo.ml\", line 2, characters 0-5:\n2 | let x\n ^^^^^\nWarning 26 [unused-var]: unused variable x.\n")))
(expect info :not :to-be nil)
(expect (plist-get info :severity) :to-equal 1)))

(it "matches a backtrace with Raised at"
(let ((info (neocaml-test--match-compilation
"Raised at Foo.f in file \"foo.ml\", line 5, characters 4-22\n")))
(expect info :not :to-be nil)
(expect (plist-get info :file) :to-equal "foo.ml")
(expect (plist-get info :severity) :to-equal 2)
(expect (plist-get info :line) :to-equal 5)
(expect (plist-get info :col) :to-equal 4)))

(it "matches a backtrace with Called from"
(let ((info (neocaml-test--match-compilation
"Called from Foo.g in file \"foo.ml\", line 9, characters 2-5\n")))
(expect info :not :to-be nil)
(expect (plist-get info :file) :to-equal "foo.ml")
(expect (plist-get info :severity) :to-equal 2)
(expect (plist-get info :line) :to-equal 9)))

(it "matches an ancillary location (7-space indent) as info"
(let ((info (neocaml-test--match-compilation
" File \"foo.ml\", line 10, characters 2-41:\n")))
(expect info :not :to-be nil)
(expect (plist-get info :file) :to-equal "foo.ml")
(expect (plist-get info :severity) :to-equal 0)
(expect (plist-get info :line) :to-equal 10)))

(it "matches a file location with no characters"
(let ((info (neocaml-test--match-compilation
"File \"foo.ml\", line 1:\nError: Syntax error\n")))
(expect info :not :to-be nil)
(expect (plist-get info :file) :to-equal "foo.ml")
(expect (plist-get info :line) :to-equal 1)
(expect (plist-get info :col) :to-be nil)))

(it "computes end-column correctly"
(with-temp-buffer
(insert "File \"foo.ml\", line 4, characters 6-20:\nError: type error\n")
(goto-char (point-min))
(re-search-forward neocaml--compilation-error-regexp)
(expect (neocaml--compilation-end-column) :to-equal
(+ 20 (if (>= emacs-major-version 28) -1 0))))))

;;; neocaml-compilation-test.el ends here