Subject: 28.0.50; Fix and extend format-spec

From: "Basil L. Contovounesios" <[email-masked]>
Date: Mon, 08 Jun 2020 00:50:22 +0100

Tags: patch

I attach a patch which fixes and extends some of the changes made to
format-spec's behaviour in Emacs 27.  Some of the proposed changes were
already alluded to in the discussion of bug#41571.

---

Before Emacs 27, format-spec was implemented in terms of 'format',
allowing e.g.:

  (format-spec "%.2s" '((?s . "שָׁלוֹם"))) ; => "שָׁל"

In Emacs 27, a precision modifier ".N" is still supported (doesn't
signal an error), but is a no-op:

  (format-spec "%.2s" '((?s . "שָׁלוֹם"))) ; => "שָׁלוֹם"

This is arguably a regression (since format-spec's docstring
historically said it supported "'format'-like specs" without explicitly
listing the precision modifier as one of them).  I wouldn't be surprised
if someone misses this feature.

Instead, Emacs 27 added the notion of "padding or truncating" (as a
single operation) to a desired length:

  (format-spec "%>2s" '((?s . "שָׁלוֹם"))) ; => "שָ"

There are two problems with this:
0. Unlike 'format', it truncates to length rather than width.
1. It is impossible to specify separate padding and truncation, e.g.:

  (format "%3.2s" "שָׁלוֹם") ; => " שָׁל"

The attached patch brings back 'format'-like truncation based on string
width, and separate from padding:

  (format-spec "%>2s" '((?s . "שָׁלוֹם")))  ; => "שָׁל"
  (format-spec "%3.2s" '((?s . "שָׁלוֹם"))) ; => " שָׁל"

  (let ((f "%3.2s")
        (s "שָׁלוֹם"))
    (equal (format-spec f `((?s . ,s)))
           (format f s))) ; => t

---

Emacs 27 also added an optional third argument for ignoring the case
when the caller does not provide a particular replacement, e.g.:

  (format-spec "%s" ())   ; => (error "Invalid format character: ‘%s’")
  (format-spec "%s" () t) ; => "%s"

The problem with a non-nil third argument is that it also
unconditionally leaves '%%' verbatim in the output:

  (format-spec "%%%s" () t) ; => "%%%s"

I'm sure this has its uses, but I find it a surprising default since the
replacement of '%%' is always known.  The function battery-format in
lisp/battery.el is an example of where the usual replacement of '%%' is
desirable, even when some replacements are not provided.

The attached patch therefore adds two new special values to the optional
third argument:

  (format-spec "%%%s" () 'ignore) ; => "%%s"
  (format-spec "%%%s" () 'delete) ; => "%"

Together with the Emacs 27 behaviour, I think these should cover most
bases.

WDYT?

Thanks,

-- 
Basil

>From 824d1df77e95651904b4f8a97cd4be9db255e098 Mon Sep 17 00:00:00 2001
From: "Basil L. Contovounesios" <[email-masked]>
Date: Fri, 29 May 2020 19:56:14 +0100
Subject: [PATCH] Fix and extend format-spec

* lisp/format-spec.el: Use lexical-binding.  Remove dependence on
subr-x.el.
(format-spec-make): Clarify docstring.
(format-spec--parse-modifiers): Rename to...
(format-spec--parse-flags): ...this and simplify.  In particular,
don't bother parsing :space-pad which is redundant and unused.
(format-spec--pad): Remove, replacing with...
(format-spec--do-flags): ...this new helper function which performs
more of format-spec's supported text manipulation.
(format-spec): Autoload.  Allow optional argument to take on special
values 'ignore' and 'delete' for more control over what happens when
a replacement for a format specification isn't provided.  Add proper
support for a precision modifier similar to that of 'format'.

* lisp/battery.el (battery-format): Rewrite in terms of format-spec.
(battery-echo-area-format, battery-mode-line-format): Mention
support of format-spec syntax in docstrings.

* doc/lispref/strings.texi (Custom Format Strings):
* etc/NEWS: Document and announce these changes.

* lisp/dired-aux.el (dired-do-compress-to):
* lisp/erc/erc-match.el (erc-log-matches):
* lisp/erc/erc.el (erc-update-mode-line-buffer):
* lisp/gnus/gnus-sieve.el (gnus-sieve-update):
* lisp/gnus/gssapi.el (open-gssapi-stream):
* lisp/gnus/mail-source.el (mail-source-fetch-file)
(mail-source-fetch-directory, mail-source-fetch-pop)
(mail-source-fetch-imap):
* lisp/gnus/message.el (message-insert-formatted-citation-line):
* lisp/image-dired.el:
* lisp/net/eww.el:
* lisp/net/imap.el (imap-kerberos4-open, imap-gssapi-open)
(imap-shell-open):
* lisp/net/network-stream.el (network-stream-open-shell):
* lisp/obsolete/tls.el (open-tls-stream):
* lisp/textmodes/tex-mode.el:
Remove extraneous loads and autoloads of format-spec now that it is
autoloaded and simplify its uses where possible.

* test/lisp/format-spec-tests.el (test-format-spec): Rename to...
(format-spec) ...this, extending test cases.
(test-format-unknown): Rename to...
(format-spec-unknown): ...this, extending test cases.
(test-format-modifiers): Rename to...
(format-spec-flags): ...this.
(format-spec-make, format-spec-parse-flags, format-spec-do-flags)
(format-spec-do-flags-truncate, format-spec-do-flags-pad)
(format-spec-do-flags-chop, format-spec-do-flags-case): New tests.
---
 doc/lispref/strings.texi       |  35 +++++--
 etc/NEWS                       |  17 ++++
 lisp/battery.el                |  18 ++--
 lisp/dired-aux.el              |  15 ++-
 lisp/erc/erc-match.el          |  19 ++--
 lisp/erc/erc.el                |  21 ++--
 lisp/format-spec.el            | 181 ++++++++++++++++++---------------
 lisp/gnus/gnus-sieve.el        |  10 +-
 lisp/gnus/gssapi.el            |  11 +-
 lisp/gnus/mail-source.el       |  30 +++---
 lisp/gnus/message.el           | 137 +++++++++++--------------
 lisp/image-dired.el            |   1 -
 lisp/net/eww.el                |   1 -
 lisp/net/imap.el               |  30 ++----
 lisp/net/network-stream.el     |  13 +--
 lisp/obsolete/tls.el           |  16 +--
 lisp/textmodes/tex-mode.el     |   3 -
 test/lisp/format-spec-tests.el | 135 ++++++++++++++++++++++--
 18 files changed, 405 insertions(+), 288 deletions(-)

diff --git a/doc/lispref/strings.texi b/doc/lispref/strings.texi
index 4a7bda57c4..2ef88b9025 100644
--- a/doc/lispref/strings.texi
+++ b/doc/lispref/strings.texi
@@ -1152,7 +1152,7 @@ Custom Format Strings
 similar function to @code{format}, except it operates on format
 control strings that use arbitrary specification characters.
 
-@defun format-spec template spec-alist &optional only-present
+@defun format-spec template spec-alist &optional ignore-missing
 This function returns a string produced from the format string
 @var{template} according to conversions specified in @var{spec-alist},
 which is an alist (@pxref{Association Lists}) of the form
@@ -1185,12 +1185,15 @@ Custom Format Strings
 the order of associations in @var{spec-alist}.
 @end itemize
 
-The optional argument @var{only-present} indicates how to handle
+The optional argument @var{ignore-missing} indicates how to handle
 specification characters in @var{template} that are not found in
 @var{spec-alist}.  If it is @code{nil} or omitted, the function
-signals an error.  Otherwise, those format specifications and any
-occurrences of @samp{%%} in @var{template} are left verbatim in the
-output, including their text properties, if any.
+signals an error; if it is @code{ignore}, those format specifications
+are left verbatim in the output, including their text properties, if
+any; if it is @code{delete}, those format specifications are removed
+from the output; any other non-@code{nil} value is handled like
+@code{ignore}, but any occurrences of @samp{%%} are also left verbatim
+in the output.
 @end defun
 
 The syntax of format specifications accepted by @code{format-spec} is
@@ -1238,7 +1241,7 @@ Custom Format Strings
 
 @item <
 This flag causes the substitution to be truncated on the left to the
-given width, if specified.
+given width and precision, if specified.
 
 @item >
 This flag causes the substitution to be truncated on the right to the
@@ -1257,9 +1260,12 @@ Custom Format Strings
 lower case) is undefined.
 
 As is the case with @code{format}, a format specification can include
-a width, which is a decimal number that appears after any flags.  If a
-substitution contains fewer characters than its specified width, it is
-padded on the left:
+a width, which is a decimal number that appears after any flags, and a
+precision, which is a decimal-point @samp{.} followed by a decimal
+number that appears after any flags and width.
+
+If a substitution contains fewer characters than its specified width,
+it is padded on the left:
 
 @example
 @group
@@ -1269,6 +1275,17 @@ Custom Format Strings
 @end group
 @end example
 
+If a substitution contains more characters than its specified
+precision, it is truncated on the right:
+
+@example
+@group
+(format-spec "%.2a is truncated on the right"
+             '((?a . "alpha")))
+     @result{} "al is truncated on the right"
+@end group
+@end example
+
 Here is a more complicated example that combines several
 aforementioned features:
 
diff --git a/etc/NEWS b/etc/NEWS
index 6e94d4a91b..add842a7ec 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -451,6 +451,16 @@ https://www.w3.org/TR/xml/#charsets).  Now it rejects such strings.
 
 ** The metamail.el library is now marked obsolete.
 
+** Battery
+
+---
+*** A richer syntax can be used to format battery status information.
+The user options 'battery-mode-line-format' and
+'battery-echo-area-format' now support the full formatting syntax of
+the function 'format-spec' documented under '(elisp) Custom Format
+Strings'.  The new syntax includes specifiers for padding and
+truncation, amongst other things.
+
 
 * New Modes and Packages in Emacs 28.1
 
@@ -568,6 +578,13 @@ for encoding and decoding without having to bind
 It controls, whether 'process-file' returns a string when a remote
 process is interrupted by a signal.
 
++++
+** The behavior of 'format-spec' is now closer to that of 'format'.
+In order for the two functions to behave more consistently,
+'format-spec' now pads and truncates based on string width rather than
+length, and also supports format specifications that include a
+truncating precision field, such as '%.2a'.
+
 
 * Changes in Emacs 28.1 on Non-Free Operating Systems
 
diff --git a/lisp/battery.el b/lisp/battery.el
index b8855a8ce3..3872819650 100644
--- a/lisp/battery.el
+++ b/lisp/battery.el
@@ -121,7 +121,10 @@ battery-echo-area-format
 %p Battery load percentage
 %m Remaining time (to charge or discharge) in minutes
 %h Remaining time (to charge or discharge) in hours
-%t Remaining time (to charge or discharge) in the form `h:min'"
+%t Remaining time (to charge or discharge) in the form `h:min'
+
+The full `format-spec' formatting syntax is supported."
+  :link '(info-link "(elisp) Custom Format Strings")
   :type '(choice string (const nil)))
 
 (defvar battery-mode-line-string nil
@@ -153,7 +156,10 @@ battery-mode-line-format
 %p Battery load percentage
 %m Remaining time (to charge or discharge) in minutes
 %h Remaining time (to charge or discharge) in hours
-%t Remaining time (to charge or discharge) in the form `h:min'"
+%t Remaining time (to charge or discharge) in the form `h:min'
+
+The full `format-spec' formatting syntax is supported."
+  :link '(info-link "(elisp) Custom Format Strings")
   :type '(choice string (const nil)))
 
 (defcustom battery-update-interval 60
@@ -823,13 +829,7 @@ battery-pmset
 
 (defun battery-format (format alist)
   "Substitute %-sequences in FORMAT."
-  (replace-regexp-in-string
-   "%."
-   (lambda (str)
-     (let ((char (aref str 1)))
-       (if (eq char ?%) "%"
-	 (or (cdr (assoc char alist)) ""))))
-   format t t))
+  (format-spec format alist 'delete))
 
 (defun battery-search-for-one-match-in-files (files regexp match-num)
   "Search REGEXP in the content of the files listed in FILES.
diff --git a/lisp/dired-aux.el b/lisp/dired-aux.el
index 24ebfa4b0d..97d4893239 100644
--- a/lisp/dired-aux.el
+++ b/lisp/dired-aux.el
@@ -1064,8 +1064,6 @@ dired-compress-files-alist
 Within CMD, %i denotes the input file(s), and %o denotes the
 output file. %i path(s) are relative, while %o is absolute.")
 
-(declare-function format-spec "format-spec.el" (format specification))
-
 ;;;###autoload
 (defun dired-do-compress-to ()
   "Compress selected files and directories to an archive.
@@ -1073,7 +1071,6 @@ dired-do-compress-to
 Choose the archiving command based on the archive file-name extension
 and `dired-compress-files-alist'."
   (interactive)
-  (require 'format-spec)
   (let* ((in-files (dired-get-marked-files nil nil nil nil t))
          (out-file (expand-file-name (read-file-name "Compress to: ")))
          (rule (cl-find-if
@@ -1093,12 +1090,12 @@ dired-do-compress-to
            (when (zerop
                   (dired-shell-command
                    (format-spec (cdr rule)
-                                `((?\o . ,(shell-quote-argument out-file))
-                                  (?\i . ,(mapconcat
-                                           (lambda (file-desc)
-                                             (shell-quote-argument (file-name-nondirectory
-                                                                    file-desc)))
-                                           in-files " "))))))
+                                `((?o . ,(shell-quote-argument out-file))
+                                  (?i . ,(mapconcat
+                                          (lambda (in-file)
+                                            (shell-quote-argument
+                                             (file-name-nondirectory in-file)))
+                                          in-files " "))))))
              (message (ngettext "Compressed %d file to %s"
 			        "Compressed %d files to %s"
 			        (length in-files))
diff --git a/lisp/erc/erc-match.el b/lisp/erc/erc-match.el
index 3107ff2ccd..0e98f2bc61 100644
--- a/lisp/erc/erc-match.el
+++ b/lisp/erc/erc-match.el
@@ -555,16 +555,15 @@ erc-log-matches
 	       (and (eq erc-log-matches-flag 'away)
 		    (erc-away-time)))
 	   match-buffer-name)
-      (let ((line (format-spec erc-log-match-format
-		   (format-spec-make
-		    ?n nick
-		    ?t (format-time-string
-			(or (and (boundp 'erc-timestamp-format)
-				 erc-timestamp-format)
-			    "[%Y-%m-%d %H:%M] "))
-		    ?c (or (erc-default-target) "")
-		    ?m message
-		    ?u nickuserhost))))
+      (let ((line (format-spec
+                   erc-log-match-format
+                   `((?n . ,nick)
+                     (?t . ,(format-time-string
+                             (or (bound-and-true-p erc-timestamp-format)
+                                 "[%Y-%m-%d %H:%M] ")))
+                     (?c . ,(or (erc-default-target) ""))
+                     (?m . ,message)
+                     (?u . ,nickuserhost)))))
 	(with-current-buffer (erc-log-matches-make-buffer match-buffer-name)
 	  (let ((inhibit-read-only t))
 	    (goto-char (point-max))
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index cfde84e19a..3880778794 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -6391,17 +6391,16 @@ tabbar--local-hlf
 (defun erc-update-mode-line-buffer (buffer)
   "Update the mode line in a single ERC buffer BUFFER."
   (with-current-buffer buffer
-    (let ((spec (format-spec-make
-                 ?a (erc-format-away-status)
-                 ?l (erc-format-lag-time)
-                 ?m (erc-format-channel-modes)
-                 ?n (or (erc-current-nick) "")
-                 ?N (erc-format-network)
-                 ?o (or (erc-controls-strip erc-channel-topic) "")
-                 ?p (erc-port-to-string erc-session-port)
-                 ?s (erc-format-target-and/or-server)
-                 ?S (erc-format-target-and/or-network)
-                 ?t (erc-format-target)))
+    (let ((spec `((?a . ,(erc-format-away-status))
+                  (?l . ,(erc-format-lag-time))
+                  (?m . ,(erc-format-channel-modes))
+                  (?n . ,(or (erc-current-nick) ""))
+                  (?N . ,(erc-format-network))
+                  (?o . ,(or (erc-controls-strip erc-channel-topic) ""))
+                  (?p . ,(erc-port-to-string erc-session-port))
+                  (?s . ,(erc-format-target-and/or-server))
+                  (?S . ,(erc-format-target-and/or-network))
+                  (?t . ,(erc-format-target))))
           (process-status (cond ((and (erc-server-process-alive)
                                       (not erc-server-connected))
                                  ":connecting")
diff --git a/lisp/format-spec.el b/lisp/format-spec.el
index 9278bd74c4..6af79a4416 100644
--- a/lisp/format-spec.el
+++ b/lisp/format-spec.el
@@ -1,4 +1,4 @@
-;;; format-spec.el --- functions for formatting arbitrary formatting strings
+;;; format-spec.el --- format arbitrary formatting strings -*- lexical-binding: t -*-
 
 ;; Copyright (C) 1999-2020 Free Software Foundation, Inc.
 
@@ -24,10 +24,8 @@
 
 ;;; Code:
 
-(eval-when-compile
-  (require 'subr-x))
-
-(defun format-spec (format specification &optional only-present)
+;;;###autoload
+(defun format-spec (format specification &optional ignore-missing)
   "Return a string based on FORMAT and SPECIFICATION.
 FORMAT is a string containing `format'-like specs like \"su - %u %k\".
 SPECIFICATION is an alist mapping format specification characters
@@ -39,22 +37,22 @@ format-spec
                \\=`((?u . ,(user-login-name))
                  (?l . \"ls\")))
 
-Each %-spec may contain optional flag and width modifiers, as
-follows:
+Each %-spec may contain optional flag, width, and precision
+modifiers, as follows:
 
-  %<flags><width>character
+  %<flags><width><precision>character
 
 The following flags are allowed:
 
 * 0: Pad to the width, if given, with zeros instead of spaces.
 * -: Pad to the width, if given, on the right instead of the left.
-* <: Truncate to the width, if given, on the left.
-* >: Truncate to the width, if given, on the right.
+* <: Truncate to the width and precision, if given, on the left.
+* >: Truncate to the width and precision, if given, on the right.
 * ^: Convert to upper case.
 * _: Convert to lower case.
 
-The width modifier behaves like the corresponding one in `format'
-when applied to %s.
+The width and truncation modifiers behave like the corresponding
+ones in `format' when applied to %s.
 
 For example, \"%<010b\" means \"substitute into the output the
 value associated with ?b in SPECIFICATION, either padding it with
@@ -64,89 +62,108 @@ format-spec
 Any text properties of FORMAT are copied to the result, with any
 text properties of a %-spec itself copied to its substitution.
 
-ONLY-PRESENT indicates how to handle %-spec characters not
+IGNORE-MISSING indicates how to handle %-spec characters not
 present in SPECIFICATION.  If it is nil or omitted, emit an
-error; otherwise leave those %-specs and any occurrences of
-\"%%\" in FORMAT verbatim in the result, including their text
-properties, if any."
+error; if it is the symbol `ignore', leave those %-specs verbatim
+in the result, including their text properties, if any; if it is
+the symbol `delete', remove those %-specs from the result;
+otherwise do the same as for the symbol `ignore', but also leave
+any occurrences of \"%%\" in FORMAT verbatim in the result."
   (with-temp-buffer
     (insert format)
     (goto-char (point-min))
     (while (search-forward "%" nil t)
       (cond
-        ;; Quoted percent sign.
-        ((eq (char-after) ?%)
-         (unless only-present
-	   (delete-char 1)))
-        ;; Valid format spec.
-        ((looking-at "\\([-0 _^<>]*\\)\\([0-9.]*\\)\\([a-zA-Z]\\)")
-	 (let* ((modifiers (match-string 1))
-                (num (match-string 2))
-	        (spec (string-to-char (match-string 3)))
-	        (val (assq spec specification)))
-	   (if (not val)
-               (unless only-present
-	         (error "Invalid format character: `%%%c'" spec))
-	     (setq val (cdr val)
-                   modifiers (format-spec--parse-modifiers modifiers))
-	     ;; Pad result to desired length.
-	     (let ((text (format "%s" val)))
-               (when num
-                 (setq num (string-to-number num))
-                 (setq text (format-spec--pad text num modifiers))
-                 (when (> (length text) num)
-                   (cond
-                    ((memq :chop-left modifiers)
-                     (setq text (substring text (- (length text) num))))
-                    ((memq :chop-right modifiers)
-                     (setq text (substring text 0 num))))))
-               (when (memq :uppercase modifiers)
-                 (setq text (upcase text)))
-               (when (memq :lowercase modifiers)
-                 (setq text (downcase text)))
-	       ;; Insert first, to preserve text properties.
-	       (insert-and-inherit text)
-	       ;; Delete the specifier body.
-               (delete-region (+ (match-beginning 0) (length text))
-                              (+ (match-end 0) (length text)))
-               ;; Delete the percent sign.
-               (delete-region (1- (match-beginning 0)) (match-beginning 0))))))
-        ;; Signal an error on bogus format strings.
-        (t
-          (unless only-present
-	    (error "Invalid format string")))))
+       ;; Quoted percent sign.
+       ((= (following-char) ?%)
+        (when (memq ignore-missing '(nil ignore delete))
+          (delete-char 1)))
+       ;; Valid format spec.
+       ((looking-at (rx (? (group (+ (in " 0<>^_-"))))
+                        (? (group (+ digit)))
+                        (? (group ?. (+ digit)))
+                        (group alpha)))
+        (let* ((beg (point))
+               (end (match-end 0))
+               (flags (match-string 1))
+               (width (match-string 2))
+               (trunc (match-string 3))
+               (char (string-to-char (match-string 4)))
+               (text (assq char specification)))
+          (cond (text
+                 ;; Handle flags.
+                 (setq text (format-spec--do-flags
+                             (format "%s" (cdr text))
+                             (format-spec--parse-flags flags)
+                             (and width (string-to-number width))
+                             (and trunc (car (read-from-string trunc 1)))))
+                 ;; Insert first, to preserve text properties.
+                 (insert-and-inherit text)
+                 ;; Delete the specifier body.
+                 (delete-region (point) (+ end (length text)))
+                 ;; Delete the percent sign.
+                 (delete-region (1- beg) beg))
+                ((eq ignore-missing 'delete)
+                 ;; Delete the whole format spec.
+                 (delete-region (1- beg) end))
+                ((not ignore-missing)
+                 (error "Invalid format character: `%%%c'" char)))))
+       ;; Signal an error on bogus format strings.
+       ((not ignore-missing)
+        (error "Invalid format string"))))
     (buffer-string)))
 
-(defun format-spec--pad (text total-length modifiers)
-  (if (> (length text) total-length)
-      ;; The text is longer than the specified length; do nothing.
-      text
-    (let ((padding (make-string (- total-length (length text))
-                                (if (memq :zero-pad modifiers)
-                                    ?0
-                                  ?\s))))
-      (if (memq :right-pad modifiers)
-          (concat text padding)
-        (concat padding text)))))
+(defun format-spec--do-flags (str flags width trunc)
+  "Return STR formatted according to FLAGS, WIDTH, and TRUNC.
+FLAGS is a list of keywords as returned by
+`format-spec--parse-flags'.  WIDTH and TRUNC are either nil or
+string widths corresponding to `format-spec' modifiers."
+  (let (diff str-width)
+    ;; Truncate original string first, like `format' does.
+    (when trunc
+      (setq str-width (string-width str))
+      (when (> (setq diff (- str-width trunc)) 0)
+        (setq str (if (memq :chop-left flags)
+                      (truncate-string-to-width str str-width diff)
+                    (format (format "%%.%ds" trunc) str))
+              ;; We know the new width so save it for later.
+              str-width trunc)))
+    ;; Pad or chop to width.
+    (when width
+      (setq str-width (or str-width (string-width str))
+            diff (- width str-width))
+      (cond ((zerop diff))
+            ((> diff 0)
+             (let ((pad (make-string diff (if (memq :pad-zero flags) ?0 ?\s))))
+               (setq str (if (memq :pad-right flags)
+                             (concat str pad)
+                           (concat pad str)))))
+            ((memq :chop-left flags)
+             (setq str (truncate-string-to-width str str-width (- diff))))
+            ((memq :chop-right flags)
+             (setq str (format (format "%%.%ds" width) str))))))
+  ;; Fiddle case.
+  (cond ((memq :upcase flags)
+         (upcase str))
+        ((memq :downcase flags)
+         (downcase str))
+        (str)))
 
-(defun format-spec--parse-modifiers (modifiers)
+(defun format-spec--parse-flags (flags)
+  "Convert sequence of FLAGS to list of human-readable keywords."
   (mapcan (lambda (char)
-            (when-let ((modifier
-                        (pcase char
-                          (?0 :zero-pad)
-                          (?\s :space-pad)
-                          (?^ :uppercase)
-                          (?_ :lowercase)
-                          (?- :right-pad)
-                          (?< :chop-left)
-                          (?> :chop-right))))
-              (list modifier)))
-          modifiers))
+            (pcase char
+              (?0 (list :pad-zero))
+              (?- (list :pad-right))
+              (?< (list :chop-left))
+              (?> (list :chop-right))
+              (?^ (list :upcase))
+              (?_ (list :downcase))))
+          flags))
 
 (defun format-spec-make (&rest pairs)
   "Return an alist suitable for use in `format-spec' based on PAIRS.
-PAIRS is a list where every other element is a character and a value,
-starting with a character."
+PAIRS is a property list with characters as keys."
   (let (alist)
     (while pairs
       (unless (cdr pairs)
diff --git a/lisp/gnus/gnus-sieve.el b/lisp/gnus/gnus-sieve.el
index 278e3a5d6f..5d8f9b55de 100644
--- a/lisp/gnus/gnus-sieve.el
+++ b/lisp/gnus/gnus-sieve.el
@@ -29,8 +29,6 @@
 
 (require 'gnus)
 (require 'gnus-sum)
-(require 'format-spec)
-(autoload 'sieve-mode "sieve-mode")
 (eval-when-compile
   (require 'sieve))
 
@@ -88,10 +86,10 @@ gnus-sieve-update
   (save-buffer)
   (shell-command
    (format-spec gnus-sieve-update-shell-command
-		(format-spec-make ?f gnus-sieve-file
-				  ?s (or (cadr (gnus-server-get-method
-						nil gnus-sieve-select-method))
-					 "")))))
+                `((?f . ,gnus-sieve-file)
+                  (?s . ,(or (cadr (gnus-server-get-method
+                                    nil gnus-sieve-select-method))
+                             ""))))))
 
 ;;;###autoload
 (defun gnus-sieve-generate ()
diff --git a/lisp/gnus/gssapi.el b/lisp/gnus/gssapi.el
index 218a1542e3..485d58ad94 100644
--- a/lisp/gnus/gssapi.el
+++ b/lisp/gnus/gssapi.el
@@ -25,8 +25,6 @@
 
 ;;; Code:
 
-(require 'format-spec)
-
 (defcustom gssapi-program (list
 			   (concat "gsasl %s %p "
 				   "--mechanism GSSAPI "
@@ -53,12 +51,9 @@ open-gssapi-stream
 	       (coding-system-for-write 'binary)
 	       (process (start-process
 			 name buffer shell-file-name shell-command-switch
-			 (format-spec
-			  cmd
-			  (format-spec-make
-			   ?s server
-			   ?p (number-to-string port)
-			   ?l user))))
+                         (format-spec cmd `((?s . ,server)
+                                            (?p . ,(number-to-string port))
+                                            (?l . ,user)))))
 	       response)
 	  (when process
 	    (while (and (memq (process-status process) '(open run))
diff --git a/lisp/gnus/mail-source.el b/lisp/gnus/mail-source.el
index acf35a376a..43180726c4 100644
--- a/lisp/gnus/mail-source.el
+++ b/lisp/gnus/mail-source.el
@@ -24,7 +24,6 @@
 
 ;;; Code:
 
-(require 'format-spec)
 (eval-when-compile
   (require 'cl-lib)
   (require 'imap))
@@ -769,14 +768,14 @@ mail-source-fetch-file
   "Fetcher for single-file sources."
   (mail-source-bind (file source)
     (mail-source-run-script
-     prescript (format-spec-make ?t mail-source-crash-box)
+     prescript `((?t . ,mail-source-crash-box))
      prescript-delay)
     (let ((mail-source-string (format "file:%s" path)))
       (if (mail-source-movemail path mail-source-crash-box)
 	  (prog1
 	      (mail-source-callback callback path)
 	    (mail-source-run-script
-	     postscript (format-spec-make ?t mail-source-crash-box))
+             postscript `((?t . ,mail-source-crash-box)))
 	    (mail-source-delete-crash-box))
 	0))))
 
@@ -784,7 +783,7 @@ mail-source-fetch-directory
   "Fetcher for directory sources."
   (mail-source-bind (directory source)
     (mail-source-run-script
-     prescript (format-spec-make ?t path) prescript-delay)
+     prescript `((?t . ,path)) prescript-delay)
     (let ((found 0)
 	  (mail-source-string (format "directory:%s" path)))
       (dolist (file (directory-files
@@ -793,7 +792,7 @@ mail-source-fetch-directory
 		   (funcall predicate file)
 		   (mail-source-movemail file mail-source-crash-box))
 	  (cl-incf found (mail-source-callback callback file))
-	  (mail-source-run-script postscript (format-spec-make ?t path))
+          (mail-source-run-script postscript `((?t . ,path)))
 	  (mail-source-delete-crash-box)))
       found)))
 
@@ -803,8 +802,8 @@ mail-source-fetch-pop
     ;; fixme: deal with stream type in format specs
     (mail-source-run-script
      prescript
-     (format-spec-make ?p password ?t mail-source-crash-box
-		       ?s server ?P port ?u user)
+     `((?p . ,password) (?t . ,mail-source-crash-box)
+       (?s . ,server) (?P . ,port) (?u . ,user))
      prescript-delay)
     (let ((from (format "%s:%s:%s" server user port))
 	  (mail-source-string (format "pop:%s@%s" user server))
@@ -825,8 +824,8 @@ mail-source-fetch-pop
 	      (mail-source-fetch-with-program
 	       (format-spec
 		program
-		(format-spec-make ?p password ?t mail-source-crash-box
-				  ?s server ?P port ?u user))))
+                `((?p . ,password) (?t . ,mail-source-crash-box)
+                  (?s . ,server) (?P . ,port) (?u . ,user)))))
 	     (function
 	      (funcall function mail-source-crash-box))
 	     ;; The default is to use pop3.el.
@@ -863,8 +862,8 @@ mail-source-fetch-pop
 		  (setq mail-source-new-mail-available nil))
 	      (mail-source-run-script
 	       postscript
-	       (format-spec-make ?p password ?t mail-source-crash-box
-				 ?s server ?P port ?u user))
+               `((?p . ,password) (?t . ,mail-source-crash-box)
+                 (?s . ,server) (?P . ,port) (?u . ,user)))
 	      (mail-source-delete-crash-box)))
 	;; We nix out the password in case the error
 	;; was because of a wrong password being given.
@@ -1077,8 +1076,9 @@ mail-source-fetch-imap
   "Fetcher for imap sources."
   (mail-source-bind (imap source)
     (mail-source-run-script
-     prescript (format-spec-make ?p password ?t mail-source-crash-box
-				 ?s server ?P port ?u user)
+     prescript
+     `((?p . ,password) (?t . ,mail-source-crash-box)
+       (?s . ,server) (?P . ,port) (?u . ,user))
      prescript-delay)
     (let ((from (format "%s:%s:%s" server user port))
 	  (found 0)
@@ -1143,8 +1143,8 @@ mail-source-fetch-imap
       (kill-buffer buf)
       (mail-source-run-script
        postscript
-       (format-spec-make ?p password ?t mail-source-crash-box
-			 ?s server ?P port ?u user))
+       `((?p . ,password) (?t . ,mail-source-crash-box)
+         (?s . ,server) (?P . ,port) (?u . ,user)))
       found)))
 
 (provide 'mail-source)
diff --git a/lisp/gnus/message.el b/lisp/gnus/message.el
index 5a6827af76..fb560f0eab 100644
--- a/lisp/gnus/message.el
+++ b/lisp/gnus/message.el
@@ -42,13 +42,12 @@
 (require 'mail-parse)
 (require 'mml)
 (require 'rfc822)
-(require 'format-spec)
 (require 'dired)
 (require 'mm-util)
 (require 'rfc2047)
 (require 'puny)
-(require 'rmc)			; read-multiple-choice
-(eval-when-compile (require 'subr-x))	; when-let*
+(require 'rmc)                          ; read-multiple-choice
+(eval-when-compile (require 'subr-x))
 
 (autoload 'mailclient-send-it "mailclient")
 
@@ -440,8 +439,8 @@ message-signature-separator
 
 (defcustom message-elide-ellipsis "\n[...]\n\n"
   "The string which is inserted for elided text.
-This is a format-spec string, and you can use %l to say how many
-lines were removed, and %c to say how many characters were
+This is a `format-spec' string, and you can use %l to say how
+many lines were removed, and %c to say how many characters were
 removed."
   :type 'string
   :link '(custom-manual "(message)Various Commands")
@@ -3977,7 +3976,6 @@ message-cite-original
   "Cite function in the standard Message manner."
   (message-cite-original-1 nil))
 
-(autoload 'format-spec "format-spec")
 (autoload 'gnus-date-get-time "gnus-util")
 
 (defun message-insert-formatted-citation-line (&optional from date tz)
@@ -4002,20 +4000,18 @@ message-insert-formatted-citation-line
   (when (or message-reply-headers (and from date))
     (unless from
       (setq from (mail-header-from message-reply-headers)))
-    (let* ((data (condition-case ()
-		     (funcall (if (boundp 'gnus-extract-address-components)
-				  gnus-extract-address-components
-				'mail-extract-address-components)
-			      from)
-		   (error nil)))
+    (let* ((data (ignore-errors
+                   (funcall (or (bound-and-true-p
+                                 gnus-extract-address-components)
+                                #'mail-extract-address-components)
+                            from)))
 	   (name (car data))
 	   (fname name)
 	   (lname name)
-	   (net (car (cdr data)))
-	   (name-or-net (or (car data)
-			    (car (cdr data)) from))
+           (net (cadr data))
+           (name-or-net (or name net from))
 	   (time
-	    (when (string-match "%[^fnNFL]" message-citation-line-format)
+            (when (string-match-p "%[^FLNfn]" message-citation-line-format)
 	      (cond ((numberp (car-safe date)) date) ;; backward compatibility
 		    (date (gnus-date-get-time date))
 		    (t
@@ -4024,68 +4020,53 @@ message-insert-formatted-citation-line
 	   (tz (or tz
 		   (when (stringp date)
 		     (nth 8 (parse-time-string date)))))
-	   (flist
-	    (let ((i ?A) lst)
-	      (when (stringp name)
-		;; Guess first name and last name:
-		(let* ((names (delq
-			       nil
-			       (mapcar
-				(lambda (x)
-				  (if (string-match "\\`\\(\\w\\|[-.]\\)+\\'"
-						    x)
-				      x
-				    nil))
-				(split-string name "[ \t]+"))))
-		       (count (length names)))
-		  (cond ((= count 1)
-			 (setq fname (car names)
-			       lname ""))
-			((or (= count 2) (= count 3))
-			 (setq fname (car names)
-			       lname (mapconcat 'identity (cdr names) " ")))
-			((> count 3)
-			 (setq fname (mapconcat 'identity
-						(butlast names (- count 2))
-						" ")
-			       lname (mapconcat 'identity
-						(nthcdr 2 names)
-						" "))))
-                  (when (string-match "\\(.*\\),\\'" fname)
-                    (let ((newlname (match-string 1 fname)))
-                      (setq fname lname lname newlname)))))
-	      ;; The following letters are not used in `format-time-string':
-	      (push ?E lst) (push "<E>" lst)
-	      (push ?F lst) (push (or fname name-or-net) lst)
-	      ;; We might want to use "" instead of "<X>" later.
-	      (push ?J lst) (push "<J>" lst)
-	      (push ?K lst) (push "<K>" lst)
-	      (push ?L lst) (push lname lst)
-	      (push ?N lst) (push name-or-net lst)
-	      (push ?O lst) (push "<O>" lst)
-	      (push ?P lst) (push "<P>" lst)
-	      (push ?Q lst) (push "<Q>" lst)
-	      (push ?f lst) (push from lst)
-	      (push ?i lst) (push "<i>" lst)
-	      (push ?n lst) (push net lst)
-	      (push ?o lst) (push "<o>" lst)
-	      (push ?q lst) (push "<q>" lst)
-	      (push ?t lst) (push "<t>" lst)
-	      (push ?v lst) (push "<v>" lst)
-	      ;; Delegate the rest to `format-time-string':
-	      (while (<= i ?z)
-		(when (and (not (memq i lst))
-			   ;; Skip (Z,a)
-			   (or (<= i ?Z)
-			       (>= i ?a)))
-		  (push i lst)
-		  (push (condition-case nil
-			    (format-time-string (format "%%%c" i) time tz)
-			  (error (format ">%c<" i)))
-			lst))
-		(setq i (1+ i)))
-	      (reverse lst)))
-	   (spec (apply 'format-spec-make flist)))
+           spec)
+      (when (stringp name)
+        ;; Guess first name and last name:
+        (let* ((names (seq-filter
+                       (lambda (s)
+                         (string-match-p (rx bos (+ (in word ?. ?-)) eos) s))
+                       (split-string name "[ \t]+")))
+               (count (length names)))
+          (cond ((= count 1)
+                 (setq fname (car names)
+                       lname ""))
+                ((or (= count 2) (= count 3))
+                 (setq fname (car names)
+                       lname (string-join (cdr names) " ")))
+                ((> count 3)
+                 (setq fname (string-join (butlast names (- count 2))
+                                          " ")
+                       lname (string-join (nthcdr 2 names) " "))))
+          (when (string-match "\\(.*\\),\\'" fname)
+            (let ((newlname (match-string 1 fname)))
+              (setq fname lname lname newlname)))))
+      ;; The following letters are not used in `format-time-string':
+      (push (cons ?E "<E>") spec)
+      (push (cons ?F (or fname name-or-net)) spec)
+      ;; We might want to use "" instead of "<X>" later.
+      (push (cons ?J "<J>") spec)
+      (push (cons ?K "<K>") spec)
+      (push (cons ?L lname) spec)
+      (push (cons ?N name-or-net) spec)
+      (push (cons ?O "<O>") spec)
+      (push (cons ?P "<P>") spec)
+      (push (cons ?Q "<Q>") spec)
+      (push (cons ?f from) spec)
+      (push (cons ?i "<i>") spec)
+      (push (cons ?n net) spec)
+      (push (cons ?o "<o>") spec)
+      (push (cons ?q "<q>") spec)
+      (push (cons ?t "<t>") spec)
+      (push (cons ?v "<v>") spec)
+      ;; Delegate the rest to `format-time-string':
+      (dolist (c (nconc (number-sequence ?A ?Z)
+                        (number-sequence ?a ?z)))
+        (unless (assq c spec)
+          (push (cons c (condition-case nil
+                            (format-time-string (format "%%%c" c) time tz)
+                          (error (format ">%c<" c))))
+                spec)))
       (insert (format-spec message-citation-line-format spec)))
     (newline)))
 
diff --git a/lisp/image-dired.el b/lisp/image-dired.el
index 1cc38ba714..6f297672ca 100644
--- a/lisp/image-dired.el
+++ b/lisp/image-dired.el
@@ -149,7 +149,6 @@
 ;;; Code:
 
 (require 'dired)
-(require 'format-spec)
 (require 'image-mode)
 (require 'widget)
 
diff --git a/lisp/net/eww.el b/lisp/net/eww.el
index 2a70560ca7..cf31d37f07 100644
--- a/lisp/net/eww.el
+++ b/lisp/net/eww.el
@@ -25,7 +25,6 @@
 ;;; Code:
 
 (require 'cl-lib)
-(require 'format-spec)
 (require 'shr)
 (require 'url)
 (require 'url-queue)
diff --git a/lisp/net/imap.el b/lisp/net/imap.el
index aa10f0291f..a492dc8c79 100644
--- a/lisp/net/imap.el
+++ b/lisp/net/imap.el
@@ -136,7 +136,6 @@
 ;;; Code:
 
 (eval-when-compile (require 'cl-lib))
-(require 'format-spec)
 (require 'utf7)
 (require 'rfc2104)
 ;; Hmm... digest-md5 is not part of Emacs.
@@ -517,12 +516,9 @@ imap-kerberos4-open
 	     (process-connection-type imap-process-connection-type)
 	     (process (start-process
 		       name buffer shell-file-name shell-command-switch
-		       (format-spec
-			cmd
-			(format-spec-make
-			 ?s server
-			 ?p (number-to-string port)
-			 ?l imap-default-user))))
+                       (format-spec cmd `((?s . ,server)
+                                          (?p . ,(number-to-string port))
+                                          (?l . ,imap-default-user)))))
 	     response)
 	(when process
 	  (with-current-buffer buffer
@@ -583,12 +579,9 @@ imap-gssapi-open
 	     (process-connection-type imap-process-connection-type)
 	     (process (start-process
 		       name buffer shell-file-name shell-command-switch
-		       (format-spec
-			cmd
-			(format-spec-make
-			 ?s server
-			 ?p (number-to-string port)
-			 ?l imap-default-user))))
+                       (format-spec cmd `((?s . ,server)
+                                          (?p . ,(number-to-string port))
+                                          (?l . ,imap-default-user)))))
 	     response)
 	(when process
 	  (with-current-buffer buffer
@@ -701,13 +694,10 @@ imap-shell-open
              (process-connection-type imap-process-connection-type)
 	     (process (start-process
 		       name buffer shell-file-name shell-command-switch
-		       (format-spec
-			cmd
-			(format-spec-make
-			 ?s server
-			 ?g imap-shell-host
-			 ?p (number-to-string port)
-			 ?l imap-default-user)))))
+                       (format-spec cmd `((?s . ,server)
+                                          (?g . ,imap-shell-host)
+                                          (?p . ,(number-to-string port))
+                                          (?l . ,imap-default-user))))))
 	(when process
 	  (while (and (memq (process-status process) '(open run))
 		      (set-buffer buffer) ;; XXX "blue moon" nntp.el bug
diff --git a/lisp/net/network-stream.el b/lisp/net/network-stream.el
index 1d5cf382a8..1c371f5987 100644
--- a/lisp/net/network-stream.el
+++ b/lisp/net/network-stream.el
@@ -170,8 +170,8 @@ open-network-stream
 :nowait, if non-nil, says the connection should be made
 asynchronously, if possible.
 
-:shell-command is a format-spec string that can be used if :type
-is `shell'.  It has two specs, %s for host and %p for port
+:shell-command is a `format-spec' string that can be used if
+:type is `shell'.  It has two specs, %s for host and %p for port
 number.  Example: \"ssh gateway nc %s %p\".
 
 :tls-parameters is a list that should be supplied if you're
@@ -453,11 +453,7 @@ network-stream-open-tls
                   (network-stream-command stream capability-command eo-capa)
                   'tls)))))))
 
-(declare-function format-spec "format-spec" (format spec))
-(declare-function format-spec-make "format-spec" (&rest pairs))
-
 (defun network-stream-open-shell (name buffer host service parameters)
-  (require 'format-spec)
   (let* ((capability-command (plist-get parameters :capability-command))
 	 (eoc 		     (plist-get parameters :end-of-command))
 	 (start (with-current-buffer buffer (point)))
@@ -467,9 +463,8 @@ network-stream-open-shell
 				  shell-command-switch
 				  (format-spec
 				   (plist-get parameters :shell-command)
-				   (format-spec-make
-				    ?s host
-				    ?p service))))))
+                                   `((?s . ,host)
+                                     (?p . ,service)))))))
     (when coding (if (consp coding)
                        (set-process-coding-system stream
                                                   (car coding)
diff --git a/lisp/obsolete/tls.el b/lisp/obsolete/tls.el
index cd091c0108..d1b215cbfb 100644
--- a/lisp/obsolete/tls.el
+++ b/lisp/obsolete/tls.el
@@ -47,9 +47,6 @@
 
 (require 'gnutls)
 
-(autoload 'format-spec "format-spec")
-(autoload 'format-spec-make "format-spec")
-
 (defgroup tls nil
   "Transport Layer Security (TLS) parameters."
   :group 'comm)
@@ -224,14 +221,11 @@ open-tls-stream
       (while (and (not done) (setq cmd (pop cmds)))
 	(let ((process-connection-type tls-process-connection-type)
 	      (formatted-cmd
-	       (format-spec
-		cmd
-		(format-spec-make
-                 ?t (car (gnutls-trustfiles))
-		 ?h host
-		 ?p (if (integerp port)
-			(int-to-string port)
-		      port)))))
+               (format-spec cmd `((?t . ,(car (gnutls-trustfiles)))
+                                  (?h . ,host)
+                                  (?p . ,(if (integerp port)
+                                             (number-to-string port)
+                                           port))))))
 	  (message "Opening TLS connection with `%s'..." formatted-cmd)
 	  (setq process (start-process
 			 name buffer shell-file-name shell-command-switch
diff --git a/lisp/textmodes/tex-mode.el b/lisp/textmodes/tex-mode.el
index 1b302e34a7..e3d5759579 100644
--- a/lisp/textmodes/tex-mode.el
+++ b/lisp/textmodes/tex-mode.el
@@ -2295,9 +2295,6 @@ tex-uptodate-p
 	     (setq uptodate nil)))))
      uptodate)))
 
-
-(autoload 'format-spec "format-spec")
-
 (defvar tex-executable-cache nil)
 (defun tex-executable-exists-p (name)
   "Like `executable-find' but with a cache."
diff --git a/test/lisp/format-spec-tests.el b/test/lisp/format-spec-tests.el
index 23ee88c526..11882217af 100644
--- a/test/lisp/format-spec-tests.el
+++ b/test/lisp/format-spec-tests.el
@@ -22,22 +22,145 @@
 (require 'ert)
 (require 'format-spec)
 
-(ert-deftest test-format-spec ()
+(ert-deftest format-spec-make ()
+  "Test `format-spec-make'."
+  (should-not (format-spec-make))
+  (should-error (format-spec-make ?b))
+  (should (equal (format-spec-make ?b "b") '((?b . "b"))))
+  (should-error (format-spec-make ?b "b" ?a))
+  (should (equal (format-spec-make ?b "b" ?a 'a)
+                 '((?b . "b")
+                   (?a . a)))))
+
+(ert-deftest format-spec-parse-flags ()
+  "Test `format-spec--parse-flags'."
+  (should-not (format-spec--parse-flags nil))
+  (should-not (format-spec--parse-flags ""))
+  (should (equal (format-spec--parse-flags "-") '(:pad-right)))
+  (should (equal (format-spec--parse-flags " 0") '(:pad-zero)))
+  (should (equal (format-spec--parse-flags " -x0y< >^_z ")
+                 '(:pad-right :pad-zero :chop-left :chop-right
+                              :upcase :downcase))))
+
+(ert-deftest format-spec-do-flags ()
+  "Test `format-spec--do-flags'."
+  (should (equal (format-spec--do-flags "" () nil nil) ""))
+  (dolist (flag '(:pad-zero :pad-right :upcase :downcase
+                            :chop-left :chop-right))
+    (should (equal (format-spec--do-flags "" (list flag) nil nil) "")))
+  (should (equal (format-spec--do-flags "FOOBAR" '(:downcase :chop-right) 5 2)
+                 "   fo"))
+  (should (equal (format-spec--do-flags
+                  "foobar" '(:pad-zero :pad-right :upcase :chop-left) 5 2)
+                 "AR000")))
+
+(ert-deftest format-spec-do-flags-truncate ()
+  "Test `format-spec--do-flags' truncation."
+  (let (flags)
+    (should (equal (format-spec--do-flags "" flags nil 0) ""))
+    (should (equal (format-spec--do-flags "" flags nil 1) ""))
+    (should (equal (format-spec--do-flags "a" flags nil 0) ""))
+    (should (equal (format-spec--do-flags "a" flags nil 1) "a"))
+    (should (equal (format-spec--do-flags "a" flags nil 2) "a"))
+    (should (equal (format-spec--do-flags "asd" flags nil 0) ""))
+    (should (equal (format-spec--do-flags "asd" flags nil 1) "a")))
+  (let ((flags '(:chop-left)))
+    (should (equal (format-spec--do-flags "" flags nil 0) ""))
+    (should (equal (format-spec--do-flags "" flags nil 1) ""))
+    (should (equal (format-spec--do-flags "a" flags nil 0) ""))
+    (should (equal (format-spec--do-flags "a" flags nil 1) "a"))
+    (should (equal (format-spec--do-flags "a" flags nil 2) "a"))
+    (should (equal (format-spec--do-flags "asd" flags nil 0) ""))
+    (should (equal (format-spec--do-flags "asd" flags nil 1) "d"))))
+
+(ert-deftest format-spec-do-flags-pad ()
+  "Test `format-spec--do-flags' padding."
+  (let (flags)
+    (should (equal (format-spec--do-flags "" flags 0 nil) ""))
+    (should (equal (format-spec--do-flags "" flags 1 nil) " "))
+    (should (equal (format-spec--do-flags "a" flags 0 nil) "a"))
+    (should (equal (format-spec--do-flags "a" flags 1 nil) "a"))
+    (should (equal (format-spec--do-flags "a" flags 2 nil) " a")))
+  (let ((flags '(:pad-zero)))
+    (should (equal (format-spec--do-flags "" flags 0 nil) ""))
+    (should (equal (format-spec--do-flags "" flags 1 nil) "0"))
+    (should (equal (format-spec--do-flags "a" flags 0 nil) "a"))
+    (should (equal (format-spec--do-flags "a" flags 1 nil) "a"))
+    (should (equal (format-spec--do-flags "a" flags 2 nil) "0a")))
+  (let ((flags '(:pad-right)))
+    (should (equal (format-spec--do-flags "" flags 0 nil) ""))
+    (should (equal (format-spec--do-flags "" flags 1 nil) " "))
+    (should (equal (format-spec--do-flags "a" flags 0 nil) "a"))
+    (should (equal (format-spec--do-flags "a" flags 1 nil) "a"))
+    (should (equal (format-spec--do-flags "a" flags 2 nil) "a ")))
+  (let ((flags '(:pad-right :pad-zero)))
+    (should (equal (format-spec--do-flags "" flags 0 nil) ""))
+    (should (equal (format-spec--do-flags "" flags 1 nil) "0"))
+    (should (equal (format-spec--do-flags "a" flags 0 nil) "a"))
+    (should (equal (format-spec--do-flags "a" flags 1 nil) "a"))
+    (should (equal (format-spec--do-flags "a" flags 2 nil) "a0"))))
+
+(ert-deftest format-spec-do-flags-chop ()
+  "Test `format-spec--do-flags' chopping."
+  (let ((flags '(:chop-left)))
+    (should (equal (format-spec--do-flags "a" flags 0 nil) ""))
+    (should (equal (format-spec--do-flags "a" flags 1 nil) "a"))
+    (should (equal (format-spec--do-flags "asd" flags 0 nil) ""))
+    (should (equal (format-spec--do-flags "asd" flags 1 nil) "d")))
+  (let ((flags '(:chop-right)))
+    (should (equal (format-spec--do-flags "a" flags 0 nil) ""))
+    (should (equal (format-spec--do-flags "a" flags 1 nil) "a"))
+    (should (equal (format-spec--do-flags "asd" flags 0 nil) ""))
+    (should (equal (format-spec--do-flags "asd" flags 1 nil) "a"))))
+
+(ert-deftest format-spec-do-flags-case ()
+  "Test `format-spec--do-flags' case fiddling."
+  (dolist (flag '(:pad-zero :pad-right :chop-left :chop-right))
+    (let ((flags (list flag)))
+      (should (equal (format-spec--do-flags "a" flags nil nil) "a"))
+      (should (equal (format-spec--do-flags "A" flags nil nil) "A")))
+    (let ((flags (list flag :downcase)))
+      (should (equal (format-spec--do-flags "a" flags nil nil) "a"))
+      (should (equal (format-spec--do-flags "A" flags nil nil) "a")))
+    (let ((flags (list flag :upcase)))
+      (should (equal (format-spec--do-flags "a" flags nil nil) "A"))
+      (should (equal (format-spec--do-flags "A" flags nil nil) "A")))))
+
+(ert-deftest format-spec ()
+  (should (equal (format-spec "" ()) ""))
+  (should (equal (format-spec "a" ()) "a"))
+  (should (equal (format-spec "b" '((?b . "bar"))) "b"))
+  (should (equal (format-spec "%%%b%%b%b%%" '((?b . "bar"))) "%bar%bbar%"))
   (should (equal (format-spec "foo %b zot" `((?b . "bar")))
                  "foo bar zot"))
   (should (equal (format-spec "foo %-10b zot" '((?b . "bar")))
                  "foo bar        zot"))
   (should (equal (format-spec "foo %10b zot" '((?b . "bar")))
-                 "foo        bar zot")))
+                 "foo        bar zot"))
+  (should (equal-including-properties
+           (format-spec (propertize "a" 'a 'b) '((?a . "foo")))
+           #("a" 0 1 (a b))))
+  (let ((fmt (concat (propertize "%a" 'a 'b)
+                     (propertize "%%" 'c 'd)
+                     "%b"
+                     (propertize "%b" 'e 'f))))
+    (should (equal-including-properties
+             (format-spec fmt '((?b . "asd") (?a . "fgh")))
+             #("fgh%asdasd" 0 3 (a b) 3 4 (c d) 7 10 (e f))))))
 
-(ert-deftest test-format-unknown ()
+(ert-deftest format-spec-unknown ()
   (should-error (format-spec "foo %b %z zot" '((?b . "bar"))))
+  (should-error (format-spec "foo %b %%%z zot" '((?b . "bar"))))
   (should (equal (format-spec "foo %b %z zot" '((?b . "bar")) t)
                  "foo bar %z zot"))
-  (should (equal (format-spec "foo %b %z %% zot" '((?b . "bar")) t)
-                 "foo bar %z %% zot")))
+  (should (equal (format-spec "foo %4b %%%4z %%4 zot" '((?b . "bar")) t)
+                 "foo  bar %%%4z %%4 zot"))
+  (should (equal (format-spec "foo %4b %%%4z %%4 zot" '((?b . "bar")) 'ignore)
+                 "foo  bar %%4z %4 zot"))
+  (should (equal (format-spec "foo %4b %%%4z %%4 zot" '((?b . "bar")) 'delete)
+                 "foo  bar % %4 zot")))
 
-(ert-deftest test-format-modifiers ()
+(ert-deftest format-spec-flags ()
   (should (equal (format-spec "foo %10b zot" '((?b . "bar")))
                  "foo        bar zot"))
   (should (equal (format-spec "foo % 10b zot" '((?b . "bar")))
-- 
2.26.2