From 3c3eb024c80e398c3d7454d448bbee87e7c894ac Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sun, 15 Feb 2026 22:24:06 +0200 Subject: [PATCH] Add OCaml compilation error regexp for M-x compile support Register an `ocaml` entry in `compilation-error-regexp-alist` that handles errors, warnings, alerts, backtraces, source-code snippets, and ancillary locations. Registration happens when a neocaml major mode is activated. --- CHANGELOG.md | 1 + README.md | 3 +- neocaml.el | 71 +++++++++++++++- test/neocaml-compilation-test.el | 139 +++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 test/neocaml-compilation-test.el diff --git a/CHANGELOG.md b/CHANGELOG.md index f568c0a..1c9b30b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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+). diff --git a/README.md b/README.md index 895ea21..25b7f8c 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 diff --git a/neocaml.el b/neocaml.el index 9c866b4..fa041b1 100644 --- a/neocaml.el +++ b/neocaml.el @@ -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" @@ -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]" @@ -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 diff --git a/test/neocaml-compilation-test.el b/test/neocaml-compilation-test.el new file mode 100644 index 0000000..aa80457 --- /dev/null +++ b/test/neocaml-compilation-test.el @@ -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