aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--etc/NEWS6
-rw-r--r--lisp/mail/ietf-drums-date.el274
-rw-r--r--lisp/mail/ietf-drums.el6
-rw-r--r--test/lisp/mail/ietf-drums-date-tests.el190
4 files changed, 475 insertions, 1 deletions
diff --git a/etc/NEWS b/etc/NEWS
index 902d89e62d8..8deb6999789 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -1158,6 +1158,12 @@ functions.
1158 1158
1159* Lisp Changes in Emacs 29.1 1159* Lisp Changes in Emacs 29.1
1160 1160
1161---
1162** New function 'ietf-drums-parse-date-string'.
1163This function parses RFC5322 (and RFC822) date strings, and should be
1164used instead of 'parse-time-string' when parsing data that's standards
1165compliant.
1166
1161+++ 1167+++
1162** New macro 'setopt'. 1168** New macro 'setopt'.
1163This is like 'setq', but uses 'customize-set-variable' to set the 1169This is like 'setq', but uses 'customize-set-variable' to set the
diff --git a/lisp/mail/ietf-drums-date.el b/lisp/mail/ietf-drums-date.el
new file mode 100644
index 00000000000..6f64ae73377
--- /dev/null
+++ b/lisp/mail/ietf-drums-date.el
@@ -0,0 +1,274 @@
1;;; ietf-drums-date.el --- parse time/date for ietf-drums.el -*- lexical-binding: t -*-
2
3;; Copyright (C) 2022 Free Software Foundation, Inc.
4
5;; Author: Bob Rogers <rogers@rgrjr.com>
6;; Keywords: mail, util
7
8;; This file is part of GNU Emacs.
9
10;; GNU Emacs is free software: you can redistribute it and/or modify
11;; it under the terms of the GNU General Public License as published by
12;; the Free Software Foundation, either version 3 of the License, or
13;; (at your option) any later version.
14
15;; GNU Emacs is distributed in the hope that it will be useful,
16;; but WITHOUT ANY WARRANTY; without even the implied warranty of
17;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18;; GNU General Public License for more details.
19
20;; You should have received a copy of the GNU General Public License
21;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
22
23;;; Commentary:
24
25;; 'ietf-drums-parse-date-string' parses a time and/or date in a
26;; string and returns a list of values, just like `decode-time', where
27;; unspecified elements in the string are returned as nil (except
28;; unspecified DST is returned as -1). `encode-time' may be applied
29;; on these values to obtain an internal time value.
30
31;; Historically, `parse-time-string' was used for this purpose, but it
32;; was gradually but imperfectly extended to handle other date
33;; formats. 'ietf-drums-parse-date-string' is compatible in that it
34;; uses the same return value format and parses the same email date
35;; formats by default, but can be made stricter if desired.
36
37;;; Code:
38
39(require 'cl-lib)
40(require 'parse-time)
41
42(define-error 'date-parse-error "Date/time parse error" 'error)
43
44(defconst ietf-drums-date--slot-names
45 '(second minute hour day month year weekday dst zone)
46 "Names of return value slots, for better error messages
47See the decoded-time defstruct.")
48
49(defconst ietf-drums-date--slot-ranges
50 '((0 60) (0 59) (0 23) (1 31) (1 12) (1 9999))
51 "Numeric slot ranges, for bounds checking.
52Note that RFC5322 explicitly requires that seconds go up to 60,
53to allow for leap seconds (see Mills, D., 'Network Time
54Protocol', STD 12, RFC 1119, September 1989).")
55
56(defsubst ietf-drums-date--ignore-char-p (char)
57 ;; Ignore whitespace and commas.
58 (memq char '(?\s ?\t ?\r ?\n ?,)))
59
60(defun ietf-drums-date--tokenize-string (string &optional comment-eof)
61 "Turn STRING into tokens, separated only by whitespace and commas.
62Multiple commas are ignored. Pure digit sequences are turned
63into integers. If COMMENT-EOF is true, then a comment as
64defined by RFC5322 (strictly, the CFWS production that also
65accepts comments) is treated as an end-of-file, and no further
66tokens are recognized, otherwise we strip out all comments and
67treat them as whitespace (per RFC822)."
68 (let ((index 0)
69 (end (length string))
70 (list ()))
71 (cl-flet ((skip-ignored ()
72 ;; Skip ignored characters at index (the scan
73 ;; position). Skip RFC822 comments in matched parens,
74 ;; but do not complain about unterminated comments.
75 (let ((char nil)
76 (nest 0))
77 (while (and (< index end)
78 (setq char (aref string index))
79 (or (> nest 0)
80 (ietf-drums-date--ignore-char-p char)
81 (and (not comment-eof) (eql char ?\())))
82 (cl-incf index)
83 ;; FWS bookkeeping.
84 (cond ((and (eq char ?\\)
85 (< (1+ index) end))
86 ;; Move to the next char but don't check
87 ;; it to see if it might be a paren.
88 (cl-incf index))
89 ((eq char ?\() (cl-incf nest))
90 ((eq char ?\)) (cl-decf nest)))))))
91 (skip-ignored) ;; Skip leading whitespace.
92 (while (and (< index end)
93 (not (and comment-eof
94 (eq (aref string index) ?\())))
95 (let* ((start index)
96 (char (aref string index))
97 (all-digits (<= ?0 char ?9)))
98 ;; char is valid; look for more valid characters.
99 (when (and (eq char ?\\)
100 (< (1+ index) end))
101 ;; Escaped character, which might be a "(". If so, we are
102 ;; correct to include it in the token, even though the
103 ;; caller is sure to barf. If not, we violate RFC2?822 by
104 ;; not removing the backslash, but no characters in valid
105 ;; RFC2?822 dates need escaping anyway, so it shouldn't
106 ;; matter that this is not done strictly correctly. --
107 ;; rgr, 24-Dec-21.
108 (cl-incf index))
109 (while (and (< (cl-incf index) end)
110 (setq char (aref string index))
111 (not (or (ietf-drums-date--ignore-char-p char)
112 (eq char ?\())))
113 (unless (<= ?0 char ?9)
114 (setq all-digits nil))
115 (when (and (eq char ?\\)
116 (< (1+ index) end))
117 ;; Escaped character, see above.
118 (cl-incf index)))
119 (push (if all-digits
120 (cl-parse-integer string :start start :end index)
121 (substring string start index))
122 list)
123 (skip-ignored)))
124 (nreverse list))))
125
126(defun ietf-drums-parse-date-string (time-string &optional error no-822)
127 "Parse an RFC5322 or RFC822 date, passed as TIME-STRING.
128The optional ERROR parameter causes syntax errors to be flagged
129by signalling an instance of the date-parse-error condition. The
130optional NO-822 parameter disables the more lax RFC822 syntax,
131which is permitted by default.
132
133The result is a list of (SEC MIN HOUR DAY MON YEAR DOW DST TZ),
134which can be accessed as a decoded-time defstruct (q.v.),
135e.g. `decoded-time-year' to extract the year, and turned into an
136Emacs timestamp by `encode-time'.
137
138The strict syntax for RFC5322 is as follows:
139
140 [ day-of-week \",\" ] day FWS month-name FWS year FWS time [CFWS]
141
142where the \"time\" production is:
143
144 2DIGIT \":\" 2DIGIT [ \":\" 2DIGIT ] FWS ( \"+\" / \"-\" ) 4DIGIT
145
146and FWS is \"folding white space,\" and CFWS is \"comments and/or
147folding white space\", where comments are included in nesting
148parentheses and are equivalent to white space. RFC822 also
149accepts comments in random places (all of which is handled by
150ietf-drums-date--tokenize-string) and two-digit years. For
151two-digit years, 50 and up are interpreted as 1950 through 1999
152and 00 through 49 as 200 through 2049.
153
154We are somewhat more lax in what we accept (specifically, the
155hours don't have to be two digits, and the TZ and the comma after
156the DOW are optional), but we do insist that the items that are
157present do appear in this order. Unspecified/unrecognized
158elements in the string are returned as nil (except unspecified
159DST is returned as -1)."
160 (let ((tokens (ietf-drums-date--tokenize-string (downcase time-string)
161 no-822))
162 (time (list nil nil nil nil nil nil nil -1 nil)))
163 (cl-labels ((set-matched-slot (slot index token)
164 ;; Assign a slot value from match data if index is
165 ;; non-nil, else from token, signalling an error if
166 ;; enabled and it's out of range.
167 (let ((value (if index
168 (cl-parse-integer (match-string index token))
169 token)))
170 (when error
171 (let ((range (nth slot ietf-drums-date--slot-ranges)))
172 (when (and range
173 (not (<= (car range) value (cadr range))))
174 (signal 'date-parse-error
175 (list "Slot out of range"
176 (nth slot ietf-drums-date--slot-names)
177 token (car range) (cadr range))))))
178 (setf (nth slot time) value)))
179 (set-numeric (slot token)
180 ;; Only assign the slot if the token is a number.
181 (cond ((natnump token)
182 (set-matched-slot slot nil token))
183 (error
184 (signal 'date-parse-error
185 (list "Not a number"
186 (nth slot ietf-drums-date--slot-names)
187 token))))))
188 ;; Check for weekday.
189 (let ((dow (assoc (car tokens) parse-time-weekdays)))
190 (when dow
191 ;; Day of the week.
192 (set-matched-slot 6 nil (cdr dow))
193 (pop tokens)))
194 ;; Day.
195 (set-numeric 3 (pop tokens))
196 ;; Alphabetic month.
197 (let* ((month (pop tokens))
198 (match (assoc month parse-time-months)))
199 (cond (match
200 (set-matched-slot 4 nil (cdr match)))
201 (error
202 (signal 'date-parse-error
203 (list "Expected an alphabetic month" month)))
204 (t
205 (push month tokens))))
206 ;; Year.
207 (let ((year (pop tokens)))
208 ;; Check the year for the right number of digits.
209 (cond ((not (natnump year))
210 (when error
211 (signal 'date-parse-error
212 (list "Expected a year" year)))
213 (push year tokens))
214 ((>= year 1000)
215 (set-numeric 5 year))
216 ((or no-822
217 (>= year 100))
218 (when error
219 (signal 'date-parse-error
220 (list "Four-digit years are required" year)))
221 (push year tokens))
222 ((>= year 50)
223 ;; second half of the 20th century.
224 (set-numeric 5 (+ 1900 year)))
225 (t
226 ;; first half of the 21st century.
227 (set-numeric 5 (+ 2000 year)))))
228 ;; Time.
229 (let ((time (pop tokens)))
230 (cond ((or (null time) (natnump time))
231 (when error
232 (signal 'date-parse-error
233 (list "Expected a time" time)))
234 (push time tokens))
235 ((string-match
236 "^\\([0-9][0-9]?\\):\\([0-9][0-9]\\):\\([0-9][0-9]\\)$"
237 time)
238 (set-matched-slot 2 1 time)
239 (set-matched-slot 1 2 time)
240 (set-matched-slot 0 3 time))
241 ((string-match "^\\([0-9][0-9]?\\):\\([0-9][0-9]\\)$" time)
242 ;; Time without seconds.
243 (set-matched-slot 2 1 time)
244 (set-matched-slot 1 2 time)
245 (set-matched-slot 0 nil 0))
246 (error
247 (signal 'date-parse-error
248 (list "Expected a time" time)))))
249 ;; Timezone.
250 (let* ((zone (pop tokens))
251 (match (assoc zone parse-time-zoneinfo)))
252 (cond (match
253 (set-matched-slot 8 nil (cadr match))
254 (set-matched-slot 7 nil (caddr match)))
255 ((and (stringp zone)
256 (string-match "^[-+][0-9][0-9][0-9][0-9]$" zone))
257 ;; Numeric time zone.
258 (set-matched-slot
259 8 nil
260 (* 60
261 (+ (cl-parse-integer zone :start 3 :end 5)
262 (* 60 (cl-parse-integer zone :start 1 :end 3)))
263 (if (= (aref zone 0) ?-) -1 1))))
264 ((and zone error)
265 (signal 'date-parse-error
266 (list "Expected a timezone" zone)))))
267 (when (and tokens error)
268 (signal 'date-parse-error
269 (list "Extra token(s)" (car tokens)))))
270 time))
271
272(provide 'ietf-drums-date)
273
274;;; ietf-drums-date.el ends here
diff --git a/lisp/mail/ietf-drums.el b/lisp/mail/ietf-drums.el
index 85aa27235fc..d1ad671b160 100644
--- a/lisp/mail/ietf-drums.el
+++ b/lisp/mail/ietf-drums.el
@@ -294,9 +294,13 @@ a list of address strings."
294 (replace-match " " t t)) 294 (replace-match " " t t))
295 (goto-char (point-min))) 295 (goto-char (point-min)))
296 296
297(declare-function ietf-drums-parse-date-string "ietf-drums-date"
298 (time-string &optional error? no-822?))
299
297(defun ietf-drums-parse-date (string) 300(defun ietf-drums-parse-date (string)
298 "Return an Emacs time spec from STRING." 301 "Return an Emacs time spec from STRING."
299 (encode-time (parse-time-string string))) 302 (require 'ietf-drums-date)
303 (encode-time (ietf-drums-parse-date-string string)))
300 304
301(defun ietf-drums-narrow-to-header () 305(defun ietf-drums-narrow-to-header ()
302 "Narrow to the header section in the current buffer." 306 "Narrow to the header section in the current buffer."
diff --git a/test/lisp/mail/ietf-drums-date-tests.el b/test/lisp/mail/ietf-drums-date-tests.el
new file mode 100644
index 00000000000..5b798077ff9
--- /dev/null
+++ b/test/lisp/mail/ietf-drums-date-tests.el
@@ -0,0 +1,190 @@
1;;; ietf-drums-date-tests.el --- Test suite for ietf-drums-date.el -*- lexical-binding:t -*-
2
3;; Copyright (C) 2022 Free Software Foundation, Inc.
4
5;; Author: Bob Rogers <rogers@rgrjr.com>
6
7;; This file is part of GNU Emacs.
8
9;; GNU Emacs is free software: you can redistribute it and/or modify
10;; it under the terms of the GNU General Public License as published by
11;; the Free Software Foundation, either version 3 of the License, or
12;; (at your option) any later version.
13
14;; GNU Emacs is distributed in the hope that it will be useful,
15;; but WITHOUT ANY WARRANTY; without even the implied warranty of
16;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17;; GNU General Public License for more details.
18
19;; You should have received a copy of the GNU General Public License
20;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
21
22;;; Commentary:
23
24;;; Code:
25
26(require 'ert)
27(require 'ietf-drums)
28(require 'ietf-drums-date)
29
30(ert-deftest ietf-drums-date-tests ()
31 "Test basic ietf-drums-parse-date-string functionality."
32
33 ;; Test tokenization.
34 (should (equal (ietf-drums-date--tokenize-string " ") '()))
35 (should (equal (ietf-drums-date--tokenize-string " a b") '("a" "b")))
36 (should (equal (ietf-drums-date--tokenize-string "a bbc dde")
37 '("a" "bbc" "dde")))
38 (should (equal (ietf-drums-date--tokenize-string " , a 27 b,, c 14:32 ")
39 '("a" 27 "b" "c" "14:32")))
40 ;; Some folding whitespace tests.
41 (should (equal (ietf-drums-date--tokenize-string " a b (end) c" t)
42 '("a" "b")))
43 (should (equal (ietf-drums-date--tokenize-string "(quux)a (foo (bar)) b(baz)")
44 '("a" "b")))
45 (should (equal (ietf-drums-date--tokenize-string "a b\\cde")
46 ;; Strictly incorrect, but strictly unnecessary syntax.
47 '("a" "b\\cde")))
48 (should (equal (ietf-drums-date--tokenize-string "a b\\ de")
49 '("a" "b\\ de")))
50 (should (equal (ietf-drums-date--tokenize-string "a \\de \\(f")
51 '("a" "\\de" "\\(f")))
52
53 ;; Start with some compatible RFC822 dates.
54 (dolist (case '(("Mon, 22 Feb 2016 19:35:42 +0100"
55 (42 35 19 22 2 2016 1 -1 3600)
56 (22219 21758))
57 ("22 Feb 2016 19:35:42 +0100"
58 (42 35 19 22 2 2016 nil -1 3600)
59 (22219 21758))
60 ("Mon, 22 February 2016 19:35:42 +0100"
61 (42 35 19 22 2 2016 1 -1 3600)
62 (22219 21758))
63 ("Mon, 22 feb 2016 19:35:42 +0100"
64 (42 35 19 22 2 2016 1 -1 3600)
65 (22219 21758))
66 ("Monday, 22 february 2016 19:35:42 +0100"
67 (42 35 19 22 2 2016 1 -1 3600)
68 (22219 21758))
69 ("Monday, 22 february 2016 19:35:42 PST"
70 (42 35 19 22 2 2016 1 nil -28800)
71 (22219 54158))
72 ("Friday, 21 Sep 2018 13:47:58 PDT"
73 (58 47 13 21 9 2018 5 t -25200)
74 (23461 22782))
75 ("Friday, 21 Sep 2018 13:47:58 EDT"
76 (58 47 13 21 9 2018 5 t -14400)
77 (23461 11982))))
78 (let* ((input (car case))
79 (parsed (cadr case))
80 (encoded (caddr case)))
81 ;; The input should parse the same without RFC822.
82 (should (equal (ietf-drums-parse-date-string input) parsed))
83 (should (equal (ietf-drums-parse-date-string input nil t) parsed))
84 ;; Check the encoded date (the official output, though the
85 ;; decoded-time is easier to debug).
86 (should (equal (ietf-drums-parse-date input) encoded))))
87
88 ;; Test a few without timezones.
89 (dolist (case '(("Mon, 22 Feb 2016 19:35:42"
90 (42 35 19 22 2 2016 1 -1 nil))
91 ("Friday, 21 Sep 2018 13:47:58"
92 (58 47 13 21 9 2018 5 -1 nil))))
93 (let* ((input (car case))
94 (parsed (cadr case)))
95 ;; The input should parse the same without RFC822.
96 (should (equal (ietf-drums-parse-date-string input) parsed))
97 (should (equal (ietf-drums-parse-date-string input nil t) parsed))
98 ;; We can't check the encoded date here because it will differ
99 ;; depending on the TZ of the test environment.
100 ))
101
102 ;; Two-digit years are not allowed by the "modern" format.
103 (should (equal (ietf-drums-parse-date-string "22 Feb 16 19:35:42 +0100")
104 '(42 35 19 22 2 2016 nil -1 3600)))
105 (should (equal (ietf-drums-parse-date-string "22 Feb 16 19:35:42 +0100" nil t)
106 '(nil nil nil 22 2 nil nil -1 nil)))
107 (should (equal (should-error (ietf-drums-parse-date-string
108 "22 Feb 16 19:35:42 +0100" t t))
109 '(date-parse-error "Four-digit years are required" 16)))
110 (should (equal (ietf-drums-parse-date-string "22 Feb 96 19:35:42 +0100")
111 '(42 35 19 22 2 1996 nil -1 3600)))
112 (should (equal (ietf-drums-parse-date-string "22 Feb 96 19:35:42 +0100" nil t)
113 '(nil nil nil 22 2 nil nil -1 nil)))
114 (should (equal (should-error (ietf-drums-parse-date-string
115 "22 Feb 96 19:35:42 +0100" t t))
116 '(date-parse-error "Four-digit years are required" 96)))
117
118 ;; Try some dates with comments.
119 (should (equal (ietf-drums-parse-date-string
120 "22 Feb (today) 16 19:35:42 +0100")
121 '(42 35 19 22 2 2016 nil -1 3600)))
122 (should (equal (ietf-drums-parse-date-string
123 "22 Feb (today) 16 19:35:42 +0100" nil t)
124 '(nil nil nil 22 2 nil nil -1 nil)))
125 (should (equal (should-error (ietf-drums-parse-date-string
126 "22 Feb (today) 16 19:35:42 +0100" t t))
127 '(date-parse-error "Expected a year" nil)))
128 (should (equal (ietf-drums-parse-date-string
129 "22 Feb 96 (long ago) 19:35:42 +0100")
130 '(42 35 19 22 2 1996 nil -1 3600)))
131 (should (equal (ietf-drums-parse-date-string
132 "Friday, 21 Sep(comment \\) with \\( parens)18 19:35:42")
133 '(42 35 19 21 9 2018 5 -1 nil)))
134 (should (equal (ietf-drums-parse-date-string
135 "Friday, 21 Sep 18 19:35:42 (unterminated comment")
136 '(42 35 19 21 9 2018 5 -1 nil)))
137
138 ;; Test some RFC822 error cases
139 (dolist (test '(("33 1 2022" ("Slot out of range" day 33 1 31))
140 ("0 1 2022" ("Slot out of range" day 0 1 31))
141 ("1 1 2020 2021" ("Expected an alphabetic month" 1))
142 ("1 Jan 2020 2021" ("Expected a time" 2021))
143 ("1 Jan 2020 20:21 2000" ("Expected a timezone" 2000))
144 ("1 Jan 2020 20:21 +0200 33" ("Extra token(s)" 33))))
145 (should (equal (should-error (ietf-drums-parse-date-string (car test) t))
146 (cons 'date-parse-error (cadr test)))))
147
148 (dolist (test '(("22 Feb 196" nil ;; bad year
149 ("Four-digit years are required" 196))
150 ("22 Feb 16 19:35:24" t ;; two-digit year
151 ("Four-digit years are required" 16))
152 ("22 Feb 96 19:35:42" t ;; two-digit year
153 ("Four-digit years are required" 96))
154 ("2 Feb 2021 1996" nil
155 ("Expected a time" 1996))
156 ("22 Fub 1996" nil
157 ("Expected an alphabetic month" "fub"))
158 ("1 Jan 2020 30" nil
159 ("Expected a time" 30))
160 ("1 Jan 2020 16:47 15:15" nil
161 ("Expected a timezone" "15:15"))
162 ("1 Jan 2020 16:47 +0800 -0800" t
163 ("Extra token(s)" "-0800"))
164 ;; Range tests
165 ("32 Dec 2021" nil
166 ("Slot out of range" day 32 1 31))
167 ("0 Dec 2021" nil
168 ("Slot out of range" day 0 1 31))
169 ("3 13 2021" nil
170 ("Expected an alphabetic month" 13))
171 ("3 Dec 0000" t
172 ("Four-digit years are required" 0))
173 ("3 Dec 20021" nil
174 ("Slot out of range" year 20021 1 9999))
175 ("1 Jan 2020 24:21:14" nil
176 ("Slot out of range" hour "24:21:14" 0 23))
177 ("1 Jan 2020 14:60:21" nil
178 ("Slot out of range" minute "14:60:21" 0 59))
179 ("1 Jan 2020 14:21:61" nil
180 ("Slot out of range" second "14:21:61" 0 60))))
181 (should (equal (should-error
182 (ietf-drums-parse-date-string (car test) t (cadr test)))
183 (cons 'date-parse-error (caddr test)))))
184 (should (equal (ietf-drums-parse-date-string
185 "1 Jan 2020 14:21:60") ;; a leap second!
186 '(60 21 14 1 1 2020 nil -1 nil))))
187
188(provide 'ietf-drums-date-tests)
189
190;;; ietf-drums-date-tests.el ends here