Subject: [PATCH] Add 'grep-heading-mode'

From: Augusto Stoffel <[email-masked]>
Date: Wed, 07 Dec 2022 18:57:37 +0100

Tags: patch

This simulates the --heading option of certain grep-like tools.  The
output is like this:

--8<---------------cut here---------------start------------->8---
-*- mode: grep; default-directory: "~/Projects/emacs/" -*-
Grep started at Wed Dec  7 18:43:40

find [...] -exec grep --color=auto -i -nH --null -e test \{\} +

./nextstep/Makefile.in
104:	find ${ns_appdir} -exec test \! -e {} \; -ls

./nextstep/INSTALL
12:Tested on GNU/Linux, may work on other systems.

./nextstep/Makefile
104:	find ${ns_appdir} -exec test \! -e {} \; -ls

./nextstep/README
62:  10.6.8 (Snow Leopard) to the latest official release.
86:* This allows other Emacs developers to test their changes on the NS
--8<---------------cut here---------------end--------------->8---

>From 2247f006845000032fedc3dda9a073b14043a270 Mon Sep 17 00:00:00 2001
From: Augusto Stoffel <[email-masked]>
Date: Wed, 7 Dec 2022 18:44:07 +0100
Subject: [PATCH] Add 'grep-heading-mode'

New minor mode to subdivide grep output into sections, as in the
'--heading' option of certain grep-like programs.

* lisp/progmodes/grep.el (grep-heading-regexp): New user option.
(grep-heading): New face.
(grep--heading-format, grep--current-heading, grep--heading-filter):
Filter function for grep processes and supporting variables.
(grep-heading-mode): New minor mode.
---
 etc/NEWS               |  8 +++++
 lisp/progmodes/grep.el | 71 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 79 insertions(+)

diff --git a/etc/NEWS b/etc/NEWS
index 3eeef0ab4c..54c3b2447a 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -41,6 +41,14 @@ connection.
 
 * Changes in Specialized Modes and Packages in Emacs 30.1
 
+** Compile
+
+*** New minor mode 'grep-heading-mode'.
+In this mode, the grep output is split into sections, one for each
+file, instead of having file names prefixed to each line.  It is
+equivalent to the --heading option of some tools such as 'git grep'
+and 'rg.
+
 ** VC
 
 ---
diff --git a/lisp/progmodes/grep.el b/lisp/progmodes/grep.el
index 2446e86abb..132f905734 100644
--- a/lisp/progmodes/grep.el
+++ b/lisp/progmodes/grep.el
@@ -30,6 +30,7 @@
 ;;; Code:
 
 (eval-when-compile (require 'cl-lib))
+(eval-when-compile (require 'rx))
 (require 'compile)
 
 (defgroup grep nil
@@ -1407,6 +1408,76 @@ grep-file-at-point
 ;;;###autoload
 (defalias 'rzgrep #'zrgrep)
 
+;;; Headings mode
+(defcustom grep-heading-regexp
+  (rx bol
+      (or
+       (seq "Grep" (* (any "/a-zA-Z")) " "
+            (or "started" "finished" "exited" "interrupt" "killed" "terminated")
+            (* (not (any "\0\n"))))
+       (seq
+        (group-n 2
+          (group-n 1 (+? any))
+          (any "\0-:="))
+        (+ digit)
+        (any "-:="))))
+  "Regexp used to create headings from grep output lines.
+It should be anchored at beginning of line.  The first capture
+group, if present, should match the heading associated to the
+line.  The buffer range of the second capture, if present, is
+made invisible (presumably because displaying it would be
+redundant)."
+  :type 'regexp
+  :version "30.1")
+
+(defface grep-heading '((t :inherit font-lock-function-name-face))
+  "Face of headings when using `grep-heading-mode'.")
+
+(defvar grep--heading-format
+  #("\n%s\n" 1 3 (font-lock-face grep-heading outline-level 1))
+  "Format string of grep headings.
+This is passed to `format' with one argument, the text of the
+first capture group of `grep-heading-regexp'.")
+
+(defvar-local grep--current-heading nil
+  "Used by `grep--heading-filter' to keep track of the current heading.")
+
+(defun grep--heading-filter ()
+  "Filter function to add headings to output of a grep process."
+  (save-excursion
+    (let ((bound (copy-marker (pos-bol))))
+      (goto-char compilation-filter-start)
+      (forward-line 0)
+      (while (re-search-forward grep-heading-regexp bound t)
+        (let ((heading (match-string-no-properties 1))
+              (start (match-beginning 2))
+              (end (match-end 2)))
+          (when start
+            (put-text-property start end 'invisible t))
+          (when (and heading (not (equal heading grep--current-heading)))
+            (save-excursion
+              (forward-line 0)
+              (insert-before-markers (format grep--heading-format heading)))
+            (setq grep--current-heading heading)))))))
+
+;;;###autoload
+(define-minor-mode grep-heading-mode
+  "Subdivide grep output into sections, one per file."
+  :interactive 'grep-mode
+  (if (not grep-heading-mode)
+      (recompile)
+    (save-excursion
+      (save-restriction
+        (widen)
+        (let ((inhibit-read-only t)
+              (compilation-filter-start (point-min)))
+          (goto-char (point-max))
+          (grep--heading-filter))))
+    (add-hook 'compilation-filter-hook #'grep--heading-filter 80 t)
+    (setq-local outline-search-function #'outline-search-level
+                outline-level (lambda () (get-text-property
+                                          (point) 'outline-level)))))
+
 (provide 'grep)
 
 ;;; grep.el ends here
-- 
2.38.1

Some comments:

1. It's necessary to insert text into the grep buffer, which is a bit
   concerning because 'compilation-filter-start' is not a marker, just a
   number.  This could cause other filter functions to get confused.
   This is why I'm adding to 'compilation-filter-hook at a higher depth.
   Perhaps there are better approaches here.

2. One could get rid of the awkward first alternative of
   `grep-heading-regexp' if there was a way to distringuish the grep
   process output from the header and footer added by grep-mode.