Subject: [PATCH] Updated patch for Bug#74994: add support for recurrence rules

From: Richard Lawrence <[email-masked]>
Date: Wed, 12 Mar 2025 11:02:28 +0100

Tags: patch

I've been steadily working on this and I've reached another milestone
this week: I now have a working implementation of recurrence rules,
including time zone support. This is the big, complicated part of
iCalendar semantics that none of the existing code completely supports;
but it's essential, because most date-times in iCalendar are defined as
local times with a reference to a time zone, and calculating an actual
UTC offset requires applying the recurrence rules for that time zone.
Updated patch attached; the main addition here is icalendar-recur.el,
plus supporting functions in icalendar-macs.el and icalendar-utils.el.

It was a *lot* more work to get this working than I expected (mostly
because time zones are complicated...who knew? ;), but I can at least
say now that all the examples in RFC5545 work; see the tests in
icalendar-recur-tests.el.

>From 671f5e462808d725ac41544ffeba1f0d01dacfb2 Mon Sep 17 00:00:00 2001
From: Richard Lawrence <[email-masked]>
Date: Thu, 19 Dec 2024 14:30:57 +0100
Subject: [PATCH] Updated patch for Bug#74994: add support for recurrence rules

---
 lisp/calendar/icalendar-ast.el               |  400 ++
 lisp/calendar/icalendar-macs.el              | 1093 +++++
 lisp/calendar/icalendar-mode.el              |  610 +++
 lisp/calendar/icalendar-parser.el            | 4107 ++++++++++++++++++
 lisp/calendar/icalendar-recur.el             | 1968 +++++++++
 lisp/calendar/icalendar-uri-schemes.el       |  444 ++
 lisp/calendar/icalendar-utils.el             |  603 +++
 test/lisp/calendar/icalendar-parser-tests.el | 1803 ++++++++
 test/lisp/calendar/icalendar-recur-tests.el  | 2871 ++++++++++++
 9 files changed, 13899 insertions(+)
 create mode 100644 lisp/calendar/icalendar-ast.el
 create mode 100644 lisp/calendar/icalendar-macs.el
 create mode 100644 lisp/calendar/icalendar-mode.el
 create mode 100644 lisp/calendar/icalendar-parser.el
 create mode 100644 lisp/calendar/icalendar-recur.el
 create mode 100644 lisp/calendar/icalendar-uri-schemes.el
 create mode 100644 lisp/calendar/icalendar-utils.el
 create mode 100644 test/lisp/calendar/icalendar-parser-tests.el
 create mode 100644 test/lisp/calendar/icalendar-recur-tests.el

diff --git a/lisp/calendar/icalendar-ast.el b/lisp/calendar/icalendar-ast.el
new file mode 100644
index 00000000000..56b319a9eea
--- /dev/null
+++ b/lisp/calendar/icalendar-ast.el
@@ -0,0 +1,400 @@
+;;; icalendar-ast.el --- Syntax trees for iCalendar  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <[email-masked]>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines the abstract syntax tree representation for
+;; iCalendar data.
+
+
+;;; Code:
+(eval-when-compile (require 'cl-lib))
+(require 'org-element-ast)
+
+;;; Type symbols and metadata
+
+;; All nodes in the syntax treee have a type symbol as their first element.
+;; We use the following symbol properties (all prefixed with 'icalendar-')
+;; to associate type symbols with various important data about the type:
+;;
+;; is-type - t (marks this symbol as an icalendar type)
+;; is-value, is-param, is-property, or is-component - t
+;;   (specifies what sort of value this type represents)
+;; list-sep - for property and parameters types, a string (typically
+;;   "," or ";") which separates individual printed values, if the
+;;   type allows lists of values. If this is non-nil, syntax nodes of
+;;   this type should always have a list of values in their VALUE
+;;   field (even if there is only one value)
+;; matcher - a function to match this type. This function matches the
+;;   regular expression defined under the type's name; it is used to provide
+;;   syntax highlighting in `icalendar-mode'
+;; begin-rx, end-rx - for component-types, an `rx' regular expression which
+;;   matches the BEGIN and END lines that form its boundaries
+;; value-rx - an `rx' regular expression which matches individual values
+;;   of this type, with no consideration for quoting or lists of values.
+;;   (For value types, this is just a synonym for the rx definition
+;;   under the type's symbol)
+;; values-rx - for types that accept lists of values, an `rx' regular
+;;   expression which matches the whole list (including quotes, if required)
+;; full-value-rx - for property and parameter types, an `rx' regular
+;;   expression which matches a valid value expression in group 2, or
+;;   an invalid value in group 3
+;; value-reader - for value types, a function which creates syntax
+;;   nodes of this type given a string representing their value
+;; value-printer - for value types, a function to print individual
+;;   values of this type. It accepts a value and returns its string
+;;   representation.
+;; default-value - for property and parameter types, a string
+;;   representing a default value for nodes of this type. This is the
+;;   value assumed when no node of this type is present in the
+;;   relevant part of the syntax tree.
+;; substitute-value - for parameter types, a string representing a value
+;;   which will be substituted at parse times for unrecognized values.
+;;   (This is normally the same as default-value, but differs from it
+;;   in at least one case in RFC5545, thus it is stored separately.)
+;; default-type - for property types which can have values of multiple
+;;   types, this is the default type when no type for the value is
+;;   specified in the parameters. Any type of value other than this
+;;   one requires a VALUE=... parameter when the property is read or printed.
+;; other-types - for property types which can have values of multiple types,
+;;   this is a list of other types that the property can accept.
+;; child-spec - for property and component types, a plist describing the
+;;   required and optional child nodes. See `icalendar-define-property' and
+;;   `icalendar-define-component' for details.
+;; other-validator - a function to perform type-specific validation
+;;   for nodes of this type. If present, this function will be called
+;;   by `icalendar-ast-node-valid-p' during validation.
+;; type-documentation - a string documenting the type. This documentation is
+;;   printed in the help buffer when `describe-symbol' is called on TYPE.
+;; link - a hyperlink to the documentation of the type in the relevant standard
+
+(defun ical:type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is an iCalendar type symbol.
+
+This function only checks that SYMBOL has been marked as a type;
+it returns t for value types defined by `icalendar-define-type',
+but also e.g. for types defined by `icalendar-define-param' and
+`icalendar-define-property'. To check that SYMBOL names a value
+type for property or parameter values, see
+`icalendar-value-type-symbol-p' and
+`icalendar-printable-value-type-symbol-p'."
+  (and (symbolp symbol)
+       (get symbol 'ical:is-type)))
+
+(defun ical:value-type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is a type symbol representing a value
+type, i.e., a type for an iCalendar property or parameter value
+defined by `icalendar-define-type'.
+
+This means that SYMBOL must both satisfy
+`icalendar-type-symbol-p' and have the property
+`icalendar-is-value'. It does not require the type to be
+associated with a print name in `icalendar-value-types';
+for that see `icalendar-printable-value-type-symbol-p'."
+  (and (ical:type-symbol-p symbol)
+       (get symbol 'ical:is-value)))
+
+(defun ical:expects-list-of-values-p (type)
+  "Return non-nil if the syntax node type named by TYPE accepts a
+list of values. This is never t for value types or component
+types. For property and parameter types defined with
+`ical:define-param' and `ical:define-property', it is true if the
+:list-sep argument was specified in the definition."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:list-sep)))
+
+(defun ical:param-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar
+parameter."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-param)))
+
+(defun ical:property-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar
+property."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-property)))
+
+(defun ical:component-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar
+component."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-component)))
+
+;; TODO: we could define other accessors here for the other metadata
+;; properties, but at the moment I see no advantage to this; they would
+;; all just be long-winded wrappers around `get'.
+
+
+;; We define general accessors and a constructor `ical:make-ast-node'
+;; for this representation here:
+(defalias 'ical:ast-node-type #'org-element-type)
+
+(defsubst ical:ast-node-value (node)
+  "Return the value of iCalendar syntax node NODE.
+In component nodes, this is nil. Otherwise, it is a syntax node
+representing an iCalendar (property or parameter) value."
+  (org-element-property :value node))
+
+(defalias 'ical:ast-node-children #'org-element-contents)
+
+(defalias 'ical:ast-node-set-children #'org-element-set-contents)
+
+(defun ical:ast-node-p (val)
+  "Return non-nil if VAL is an iCalendar syntax node"
+  (and (listp val)
+       (ical:type-symbol-p (ical:ast-node-type val))))
+
+(defalias 'ical:ast-node-meta-get #'org-element-property)
+
+(defalias 'ical:ast-node-meta-set #'org-element-put-property)
+
+(defun ical:ast-node-first-child-of (type node)
+  "Return the first child of NODE of type TYPE, or nil if there is
+no such child."
+  (assq type (ical:ast-node-children node)))
+
+(defun ical:ast-node-children-of (type node)
+  "Return a list of all the children of NODE of type TYPE, or nil if
+there are none."
+  (seq-filter (lambda (c) (eq type (ical:ast-node-type c)))
+              (ical:ast-node-children node)))
+
+(defun ical:make-ast-node (type props &optional children)
+  "Construct an iCalendar syntax node of type TYPE with the properties
+PROPS and descendants CHILDREN.
+
+CHILDREN, if given, should be a list of syntax nodes. In
+property nodes, these should be the parameters of the property.
+In component nodes, these should be the properties or
+subcomponents of the component. It should otherwise be nil.
+
+PROPS should be a plist with any of the following keywords:
+
+:value - if given, should be a single syntax node. In value nodes, this
+  should be the Elisp value parsed from a property or parameter's value
+  string. In parameter and property nodes, this should be a value node. In
+  component nodes, it should not be present.
+:buffer - buffer from which VALUE was parsed
+:begin - position at which this node begins in BUFFER
+:end - position at which this node ends in BUFFER
+:value-begin - position at which VALUE begins in BUFFER
+:value-end - position at which VALUE ends in BUFFER
+:original-value - a string containing the original, uninterpreted value
+  of the node. This can differ from (a string represented by) VALUE
+  if e.g. a default VALUE was substituted for an unrecognized but
+  syntactically correct value.
+:original-name - a string containing the original, uninterpreted name
+  of the parameter, property or component this node represents.
+  This can differ from (a string representing) TYPE
+  if e.g. a default TYPE was substituted for an unrecognized but
+  syntactically correct one."
+  ;; automatically mark :value as a "secondary property" for org-element-ast
+  (let ((full-props (if (plist-member props :value)
+                        (plist-put props :secondary (list :value))
+                      props)))
+    (apply #'org-element-create type full-props children)))
+
+
+;;; Validation:
+
+;; Errors at the validation stage:
+;; e.g. property/param values did not match, or are of the wrong type,
+;; or required properties not present in a component
+(define-error 'ical:validation-error "Invalid iCalendar data")
+
+(defun ical:param-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a parameter type."
+  (and (ical:ast-node-p node)
+       (ical:param-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:property-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a property type."
+  (and (ical:ast-node-p node)
+       (ical:property-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:component-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a component type."
+  (and (ical:ast-node-p node)
+       (ical:component-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:ast-node-valid-value-p (node)
+  "Validate that NODE's value satisfies the requirements of its type.
+Signals an `icalendar-validation-error' if NODE's value is
+invalid, or returns NODE."
+  (let* ((type (ical:ast-node-type node))
+         (value (ical:ast-node-value node)))
+    (cond ((ical:value-type-symbol-p type)
+           (unless (cl-typep value type) ; see `ical:define-type'
+             (signal 'ical:validation-error
+                     (list (format "Invalid value for `%s' node: %s"
+                                   type value)
+                           node)))
+           node)
+          ((ical:component-node-p node)
+           ;; component types have no value, so no need to check anything
+           node)
+          ((and (or (ical:param-type-symbol-p type)
+                    (ical:property-type-symbol-p type))
+                (null (get type 'ical:value-type))
+                (stringp value))
+           ;; property and param nodes with no value type are assumed to contain
+           ;; strings which match a value regex:
+           (unless (string-match (rx-to-string (get type 'ical:value-rx)) value)
+             (signal 'ical:validation-error
+                     (list (format "Invalid string value for `%s' node: %s"
+                                   type value)
+                           node)))
+           node)
+          ;; otherwise this is a param or property node which itself
+          ;; should have one or more syntax nodes as a value, so
+          ;; recurse on value(s):
+          ((ical:expects-list-of-values-p type)
+           (unless (listp value) ;; TODO: check elements' types...?
+             (signal 'ical:validation-error
+                     (list (format "Expected list of values for `%s' node"
+                                   type)
+                           node)))
+           (mapc #'ical:ast-node-valid-value-p value)
+           node)
+          (t
+           (unless (ical:ast-node-p value)
+             (signal 'ical:validation-error
+                     (list (format "Invalid value for `%s' node: %s"
+                                   type value)
+                           node)))
+           (ical:ast-node-valid-value-p value)))))
+
+(defun ical:count-children-by-type (node)
+  "Return an alist mapping type symbols to the number of child nodes
+of that type in NODE."
+  (let ((children (ical:ast-node-children node))
+        (map nil))
+    (dolist (child children map)
+      (let* ((type (ical:ast-node-type child))
+             (n (alist-get type map)))
+        (setf (alist-get type map) (1+ (or n 0)))))))
+
+(defun ical:ast-node-valid-children-p (node)
+  "Validate that NODE's children satisfy the :child-spec associated
+with its type by `icalendar-define-component',
+`icalendar-define-property', `icalendar-define-param', or
+`icalendar-define-type'. Signals an `icalendar-validation-error'
+if NODE is invalid, or returns NODE.
+
+Note that this function does not check that the children of NODE
+are themselves valid; for that, see `ical:ast-node-valid-p'."
+  (let* ((type (ical:ast-node-type node))
+         (child-spec (get type 'ical:child-spec))
+         (child-counts (ical:count-children-by-type node)))
+
+    (when child-spec
+
+      (dolist (child-type (plist-get child-spec :one))
+        (unless (= 1 (alist-get child-type child-counts 0))
+          (signal 'ical:validation-error
+                  (list (format "iCalendar `%s' node must contain exactly one `%s'"
+                                type child-type)
+                        node))))
+
+      (dolist (child-type (plist-get child-spec :one-or-more))
+        (unless (<= 1 (alist-get child-type child-counts 0))
+          (signal 'ical:validation-error
+                  (list (format "iCalendar `%s' node must contain one or more `%s'"
+                                type child-type)
+                        node))))
+
+      (dolist (child-type (plist-get child-spec :zero-or-one))
+        (unless (<= (alist-get child-type child-counts 0)
+                    1)
+          (signal 'ical:validation-error
+                  (list (format "iCalendar `%s' node may contain at most one `%s'"
+                                type child-type)
+                        node))))
+
+      ;; check that all child nodes are allowed:
+      (unless (plist-get child-spec :allow-others)
+        (let ((allowed-types (append (plist-get child-spec :one)
+                                     (plist-get child-spec :one-or-more)
+                                     (plist-get child-spec :zero-or-one)
+                                     (plist-get child-spec :zero-or-more)))
+              (appearing-types (mapcar #'car child-counts)))
+
+          (dolist (child-type appearing-types)
+            (unless (member child-type allowed-types)
+              (signal 'ical:validation-error
+                      (list (format "`%s' may not contain `%s'"
+                                    type child-type)
+                            node)))))))
+    ;; success:
+    node))
+
+(defun ical:ast-node-valid-p (node &optional recursively)
+  "Check that NODE is a valid iCalendar syntax node.
+By default, the check will only validate NODE itself, but if
+RECURSIVELY is non-nil, it will recursively check all its
+descendants as well. Signals an `icalendar-validation-error' if
+NODE is invalid, or returns NODE."
+  (unless (ical:ast-node-p node)
+    (signal 'ical:validation-error
+            (list "Not an iCalendar syntax node"
+                  node)))
+
+  ;; (ical:ast-node-valid-meta-p node)
+  (ical:ast-node-valid-value-p node)
+  (ical:ast-node-valid-children-p node)
+
+  (let* ((type (ical:ast-node-type node))
+         (other-validator (get type 'ical:other-validator)))
+
+    (unless (ical:type-symbol-p type)
+      (signal 'ical:validation-error
+              (list (format "Node's type `%s' is not an iCalendar type symbol"
+                            type)
+                    node)))
+
+    (when (and other-validator (not (functionp other-validator)))
+      (signal 'ical:validation-error
+              (list (format "Bad validator function `%s' for type `%s'"
+                            other-validator type))))
+
+    (when other-validator
+      (funcall other-validator node)))
+
+  (let ((children (ical:ast-node-children node)))
+    (when (and recursively (not (null children)))
+      (dolist (c children)
+        (ical:ast-node-valid-p c recursively))))
+
+  ;; success:
+  node)
+
+(provide 'icalendar-ast)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-ast.el ends here
diff --git a/lisp/calendar/icalendar-macs.el b/lisp/calendar/icalendar-macs.el
new file mode 100644
index 00000000000..15b905e430f
--- /dev/null
+++ b/lisp/calendar/icalendar-macs.el
@@ -0,0 +1,1093 @@
+;;; icalendar-macs.el --- Macros for iCalendar  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <[email-masked]>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines the macros `ical:define-type', `ical:define-param',
+;; `ical:define-property' and `ical:define-component', used in
+;; icalendar-parser.el to define the particular value types, parameters,
+;; properties and components in the standard as type symbols.
+
+;; TODOs:
+;;   - in the define* macros, :default needs rethinking.
+;;     I had made this a string because otherwise you can't distinguish
+;;     an unspecified default from an explicit "FALSE" for icalendar-boolean
+;;     But this might not be true/might not matter anyway, and it's a pain
+;;     to have to read the default value where you need it. Probably
+;;     should just change these to be the value as read.
+
+
+(eval-when-compile (require 'cl-lib))
+
+(declare-function ical:ast-node-p "icalendar-ast")
+(declare-function ical:ast-node-type "icalendar-ast")
+(declare-function ical:ast-node-value "icalendar-ast")
+(declare-function ical:type-symbol-p "icalendar-ast")
+(declare-function ical:value-type-symbol-p "icalendar-ast")
+(declare-function ical:expects-list-of-values-p "icalendar-ast")
+
+;; Some utilities:
+(defun ical:protected-intern (sym-name)
+  "Call `intern' on SYM-NAME and return the result, but warn if the
+resulting symbol already has icalendar-relevant properties."
+  (let ((sym (intern sym-name)))
+    (when (or (fboundp sym)
+              (get sym 'rx-definition)
+              (get sym 'ical:is-type))
+      (warn "Symbol `%s' already has iCalendar properties" sym))
+    sym))
+
+(defun ical:format-child-spec (child-spec)
+  "Format CHILD-SPEC as a table for use in symbol documentation."
+  (concat
+   (format "%-30s%6s\n" "Type" "Number")
+   (make-string 36 ?-) "\n"
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "1"))
+    (plist-get child-spec :one))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "1+"))
+    (plist-get child-spec :one-or-more))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "0-1"))
+    (plist-get child-spec :zero-or-one))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "0+"))
+    (plist-get child-spec :zero-or-more))))
+
+
+;; Define value types:
+(cl-defmacro ical:define-type (symbolic-name print-name doc specifier matcher
+                               &key link
+                                    (reader #'identity)
+                                    (printer #'identity))
+  (declare (doc-string 2))
+  "Define an iCalendar value type named SYMBOLIC-NAME.
+
+PRINT-NAME should be the string used to represent this type in
+the value of an `icalendar-valuetypeparam' property parameter, or
+nil if this is not a type that should be specified there. DOC
+should be a documentation string for the type. SPECIFIER should
+be a type specifier in the sense of `cl-deftype'. MATCHER should
+be an RX definition body (see `rx-define'; argument lists are not
+supported).
+
+Before the type is defined with `cl-deftype', a function will be
+defined named `icalendar-match-PRINT-NAME-value'
+(or `icalendar-match-OTHER-value', if PRINT-NAME is nil, where
+OTHER is derived from SYMBOLIC-NAME by removing any prefix
+\"icalendar-\" and suffix \"value\"). This function takes a
+string argument and matches it against MATCHER. This function may
+thus occur in SPECIFIER (e.g. in a (satisfies ...) clause).
+
+See the functions `icalendar-read-value-node',
+`icalendar-parse-value-node', and `icalendar-print-value-node' to
+convert values defined with this macro to and from their text
+representation in iCalendar format.
+
+The following keyword arguments are accepted:
+
+:reader - a function to read data of this type. It will be passed
+  a string matching MATCHER and should return an Elisp data structure.
+  Its name does not need to be quoted. (default: identity)
+
+:printer - a function to convert an Elisp data structure of this
+  type to a string. Its name does not need to be quoted.
+  (default: identity)
+
+:link - a string containing an URL for further documentation of this type"
+  (let* (;; Related functions:
+         (type-dname (if print-name
+                         (downcase print-name)
+                       (string-trim
+                        (symbol-name symbolic-name)
+                        "icalendar-" "value")))
+         (matcher-name (ical:protected-intern
+                        (concat "icalendar-match-" type-dname "-value")))
+
+         ;; Documentation:
+         (header "It names a value type defined by `icalendar-define-type'.")
+         (matcher-doc (format
+"Strings representing values of this type can be matched with
+`%s'.\n" matcher-name))
+         (reader-doc (format "They can be read with `%s'\n" reader))
+         (printer-doc (format "and printed with `%s'." printer))
+         (full-doc (concat header "\n\n" doc "\n\n"
+                           matcher-doc reader-doc printer-doc "\n\n"
+"A syntax node of this type can be read with
+`icalendar-read-value-node' or parsed with `icalendar-parse-value-node',
+and printed with `icalendar-print-value-node'.")))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-value t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:value-rx (quote ,symbolic-name)
+                    'ical:value-reader (function ,reader)
+                    'ical:value-printer (function ,printer)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       (rx-define ,symbolic-name
+         ,matcher)
+
+       (defun ,matcher-name (s)
+         ,(format "Match string S against rx `%s'." symbolic-name)
+         (string-match (rx ,symbolic-name) s))
+
+       (cl-deftype ,symbolic-name () ,specifier)
+
+       ;; Store the association between the print name and the type
+       ;; symbol in ical:value-types. The check against print name
+       ;; here allows us to also define value types that aren't
+       ;; "really" types according to the standard, like
+       ;; `ical:geo-coordinates'. Only types that have a
+       ;; print-name can be specified in a VALUE parameter.
+       (when ,print-name
+         (push (cons ,print-name (quote ,symbolic-name)) ical:value-types)))))
+
+;; TODO: not sure this is needed. I've only used it once in the parser.
+(cl-defmacro ical:define-keyword-type (symbolic-name print-name doc matcher
+                                       &key link
+                                            (reader 'intern)
+                                            (printer 'symbol-name))
+  "Like `icalendar-define-type', except that string values matching MATCHER
+are assumed to be type-specific keywords that should be interned
+as symbols when read. (Thus no type specifier is necessary: it is
+always just \\='symbol.) Their printed representation is their
+symbol name."
+  `(ical:define-type ,symbolic-name ,print-name ,doc
+                     'symbol
+                     ,matcher
+                     :link ,link
+                     :reader ,reader
+                     :printer ,printer))
+
+
+;; Define parameters:
+(cl-defmacro ical:define-param (symbolic-name param-name doc value
+                                &key quoted
+                                     list-sep
+                                     default
+                                     (unrecognized default)
+                                     ((:name-face name-face)
+                                      'ical:parameter-name nondefault-name-face)
+                                     ((:value-face value-face)
+                                      'ical:parameter-value nondefault-value-face)
+                                     ((:warn-face warn-face)
+                                      'ical:warning nondefault-warn-face)
+                                     extra-faces
+                                     link)
+  (declare (doc-string 2))
+  "Define iCalendar parameter PARAM-NAME under the symbol SYMBOLIC-NAME.
+PARAM-NAME should be the parameter name as it should appear in
+iCalendar data.
+
+VALUE should either be a symbol for a value type defined with
+`icalendar-define-type', or an `rx' regular expression. If it is
+a type symbol, the regex, reader and printer functions associated
+with that type will be used when parsing and serializing values.
+If it is a regular expression, it is assumed that the values of
+this parameter are strings which match that regular expression.
+
+An `rx' regular expression named SYMBOLIC-NAME which matches the
+parameter is defined:
+  Group 1 of this regex matches PARAM-NAME
+    (or any valid parameter name, if PARAM-NAME is nil).
+  Group 2 matches VALUE, which specifies a correct value
+    for this parameter according to RFC5545.
+  Group 3, if matched, contains any parameter value which does
+    *not* match VALUE, and is incorrect according to the standard.
+
+This regex matches the entire string representing this parameter,
+from \";\" to the end of its value. Another regular expression
+named `SYMBOLIC-NAME-value' is also defined to match just the
+value part, after \";PARAM-NAME=\", with groups 2 and 3 as above.
+
+A function to match the complete parameter expression called
+`icalendar-match-PARAM-NAME-param' is defined
+(or `icalendar-match-OTHER-param-value' if PARAM-NAME is nil,
+where OTHER is derived from SYMBOLIC-NAME by removing any prefix
+`icalendar-' and suffix `param'). This function is used
+to provide syntax highlighting in `icalendar-mode'.
+
+See the functions `icalendar-read-param-value',
+`icalendar-parse-param-value', `icalendar-parse-params' and
+`icalendar-print-param-node' to convert parameters defined with
+this macro to and from their text representation in iCalendar
+format.
+
+The following keyword arguments are accepted:
+
+:default - a (string representing the) default value, if the
+  parameter is not specified on a given property.
+
+:unrecognized - a (string representing the) value which must be
+  substituted for values that are not recognized but syntactically
+  correct according to RFC5545. Unrecognized values must be in match
+  group 5 of the regex determined by VALUE. An unrecognized value will
+  be preserved in the syntax tree metadata and printed instead of this
+  value when the node is printed. Defaults to any value specified for
+  :default.
+
+:quoted - non-nil if values of this parameter must always be surrounded
+  by (double-)quotation marks when printed, according to RFC5545.
+
+:list-sep - if the parameter accepts a list of values, this should be a
+  string which separates the values (typically \",\"). If :list-sep is
+  non-nil, the value string will first be split on the separator, then
+  if :quoted is non-nil, the individual values will be unquoted, then
+  each value will be read according to VALUE and collected into a list
+  when parsing.  When printing, the inverse happens: values are quoted
+  if :quoted is non-nil, then joined with :list-sep. Passing this
+  argument marks SYMBOLIC-NAME as a type that accepts a list of values
+  for `icalendar-expects-list-of-values-p'.
+
+:name-face - a face symbol for highlighting the property name
+  (default: `icalendar-parameter-name')
+
+:value-face - a face symbol for highlighting valid property values
+  (default: `icalendar-parameter-value')
+
+:warn-face - a face symbol for highlighting invalid property values
+  (default: `icalendar-warning')
+
+:extra-faces - a list of the form accepted for HIGHLIGHT in
+  `font-lock-keywords'.  In particular,
+    ((GROUPNUM FACENAME [OVERRIDE [LAXMATCH]]) ...)
+  can be used to apply different faces to different
+  match subgroups.
+
+:link - a string containing a URL for documentation of this parameter.
+  The URL will be provided in the documentation shown by
+  `describe-symbol' for SYMBOLIC-NAME."
+  (let* (;; Related function names:
+         (param-dname (if param-name
+                          (downcase param-name)
+                        (string-trim (symbol-name symbolic-name)
+                                     "icalendar-" "param")))
+         (matcher-name (ical:protected-intern
+                        (concat "icalendar-match-" param-dname "-param")))
+
+         (type-predicate-name
+          (ical:protected-intern (concat "icalendar-" param-dname "-param-p")))
+         ;; Value regexes:
+         (qvalue-rx (if quoted `(seq ?\" ,value ?\") value))
+         (values-rx (when list-sep
+                     `(seq ,qvalue-rx (zero-or-more ,list-sep ,qvalue-rx))))
+         (full-value-rx-name (ical:protected-intern
+                               (concat (symbol-name symbolic-name) "-value")))
+         ;; Faces:
+         (has-faces (or nondefault-name-face nondefault-value-face
+                        nondefault-warn-face extra-faces))
+         ;; Documentation:
+         (header "It names a parameter type defined by `icalendar-define-param'.")
+         (val-list (if list-sep (concat "VAL1" list-sep "VAL2" list-sep "...")
+                     "VAL"))
+         (s (if list-sep "s" "")) ; to make plurals
+         (val-doc (concat "VAL" s " "
+                          "must be " (unless list-sep "a ") (when quoted "quoted ")
+                          (if (ical:value-type-symbol-p value)
+                              (format "`%s' value%s" (symbol-name value) s)
+                            (format "string%s matching rx `%s'" s value))))
+         (syntax-doc (format "Syntax: %s=%s\n%s"
+                             (or param-name "(NAME)") val-list val-doc))
+         (full-doc (concat header "\n\n" doc "\n\n" syntax-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-param t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:default-value ,default
+                    'ical:is-quoted ,quoted
+                    'ical:list-sep ,list-sep
+                    'ical:substitute-value ,unrecognized
+                    'ical:matcher (function ,matcher-name)
+                    'ical:value-type
+                    (when (ical:value-type-symbol-p (quote ,value))
+                      (quote ,value))
+                    'ical:value-rx (quote ,value)
+                    'ical:values-rx (quote ,values-rx)
+                    'ical:full-value-rx (quote ,full-value-rx-name)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Regex which matches just the value of the parameter:
+       ;; Group 2: correct values of the parameter, and
+       ;; Group 3: incorrect values up to the next parameter
+       (rx-define ,full-value-rx-name
+         (or (group-n 2 ,(or values-rx qvalue-rx))
+             (group-n 3 ical:param-value)))
+
+       ;; Regex which matches the full parameter:
+       ;; Group 1: the parameter name,
+       ;; Group 2: correct values of the parameter, and
+       ;; Group 3: incorrect values up to the next parameter
+       (rx-define ,symbolic-name
+         (seq ";"
+              ;; if the parameter name has no printed form, the best we
+              ;; can do is match ical:param-name:
+              (group-n 1 ,(or param-name 'ical:param-name))
+              "="
+              ,full-value-rx-name))
+
+       ;; CL-type to represent syntax nodes for this parameter:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s parameter" param-name)
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Matcher for the full param string, for syntax highlighting:
+       (defun ,matcher-name (limit)
+         ,(format "Matcher for %s parameter (defined by define-param)" param-name)
+         (re-search-forward (rx ,symbolic-name) limit t))
+
+       ;; Entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,name-face) t t)
+                       (2 (quote ,value-face) t t)
+                       (3 (quote ,warn-face) t t)
+                       ,@extra-faces))
+               ical:font-lock-keywords))
+
+       ;; Associate the print name with the type symbol for
+       ;; `ical:parse-params' and `ical:print-param':
+       (when ,param-name
+         (push (cons ,param-name (quote ,symbolic-name)) ical:param-types)))))
+
+
+;; Define properties:
+(cl-defmacro ical:define-property (symbolic-name property-name doc value
+                                   &key default
+                                        (unrecognized default)
+                                        (default-type
+                                         (if (ical:value-type-symbol-p value)
+                                             value
+                                           'ical:text))
+                                        other-types
+                                        list-sep
+                                        child-spec
+                                        other-validator
+                                        ((:name-face name-face)
+                                         'ical:property-name nondefault-name-face)
+                                        ((:value-face value-face)
+                                         'ical:property-value nondefault-value-face)
+                                        ((:warn-face warn-face)
+                                         'ical:warning nondefault-warn-face)
+                                        extra-faces
+                                        link)
+  (declare (doc-string 2))
+  "Define iCalendar property PROPERTY-NAME under SYMBOLIC-NAME.
+PROPERTY-NAME should be the property name as it should appear in
+iCalendar data.
+
+VALUE should either be a symbol for a value type defined with
+`icalendar-define-type', or an `rx' regular expression. If it is
+a type symbol, the regex, reader and printer functions associated
+with that type will be used when parsing and serializing the
+property's value. If it is a regular expression, it is assumed
+that the values are strings of type `icalendar-text' which match
+that regular expression.
+
+An `rx' regular expression named SYMBOLIC-NAME is defined to
+match the property:
+  Group 1 of this regex matches PROPERTY-NAME.
+  Group 2 matches VALUE.
+  Group 3, if matched, contains any property value which does
+   *not* match VALUE, and is incorrect according to the standard.
+  Group 4, if matched, contains the (unparsed) property parameters;
+   its boundaries can be used for parsing these.
+
+This regex matches the entire string representing this property,
+from the beginning of the content line to the end of its value.
+Another regular expression named `SYMBOLIC-NAME-value' is also
+defined to match just the value part, after the separating colon,
+with groups 2 and 3 as above.
+
+A function to match the complete property expression called
+`icalendar-match-PROPERTY-NAME-property' is defined. This
+function is used to provide syntax highlighting in
+`icalendar-mode'.
+
+See the functions `icalendar-read-property-value',
+`icalendar-parse-property-value', `icalendar-parse-property', and
+`icalendar-print-property-node' to convert properties defined
+with this macro to and from their text representation in
+iCalendar format.
+
+The following keyword arguments are accepted:
+
+:default - a (string representing the) default value, if
+  the property is not specified in a given component.
+
+:unrecognized - a (string representing the) value which must be
+  substituted for values that are not recognized but
+  syntactically correct according to RFC5545. Unrecognized values
+  must be in match group 5 of the regex determined by VALUE. An
+  unrecognized value will be preserved in the syntax tree
+  metadata and printed instead of this value when the node is
+  printed. Defaults to any value specified for :default.
+
+:default-type - a type symbol naming the default type of the
+  property's value. If the property's value differs from this
+  type, an `icalendar-valuetypeparam' parameter will be added to
+  the property's syntax node and printed when the node is
+  printed. Default is VALUE if VALUE is a value type symbol,
+  otherwise the type `icalendar-text'.
+
+:other-types - a list of type symbols naming value types other
+  than :default-type. These represent alternative types for the
+  property's value. If parsing the property's value under its
+  default type fails, these types will be tried in turn, and only
+  if the property's value matches none of them will an error be
+  signaled.
+
+:list-sep - if the property accepts a list of values, this should
+  be a string which separates the values (typically \",\"). If
+  :list-sep is non-nil, the value string will first be split on
+  the separator, then each value will be read according to VALUE
+  and collected into a list when parsing. When printing, the
+  inverse happens: values are printed individually and then
+  joined with :list-sep. Passing this argument marks
+  SYMBOLIC-NAME as a type that accepts a list of values for
+  `icalendar-expects-list-of-values-p'.
+
+:child-spec - a plist mapping the following keywords to lists
+of type symbols:
+  :one           - parameters that must appear exactly once
+  :one-or-more   - parameters that must appear at least once and
+                   may appear more than once
+  :zero-or-one   - parameters that must appear at most once
+  :zero-or-more  - parameters that may appear more than once
+  :allow-others  - if non-nil, other parameters besides those listed in
+                   the above are allowed to appear. (In this case, a
+                   :zero-or-more clause is redundant.)
+
+:other-validator - a function to perform any additional validation of
+  the component, beyond what `icalendar-ast-node-valid-p' checks.
+  This function should accept one argument, a syntax node. It
+  should return non-nil if the node is valid, or signal an
+  `icalendar-validation-error' if it is not. Its name does not
+  need to be quoted.
+
+:name-face - a face symbol for highlighting the property name
+  (default: `icalendar-property-name')
+
+:value-face - a face symbol for highlighting valid property values
+  (default: `icalendar-property-value')
+
+:warn-face - a face symbol for highlighting invalid property values
+  (default: `icalendar-warning')
+
+:extra-faces - a list of the form for HIGHLIGHT in `font-lock-keywords'.
+  In particular, ((GROUPNUM FACENAME [OVERRIDE [LAXMATCH]])...)
+  can be used to apply different faces to different match subgroups.
+
+:link - a string containing a URL for documentation of this property"
+  (let* (;; Value RX:
+        (full-value-rx-name
+         (ical:protected-intern
+          (concat (symbol-name symbolic-name) "-property-value")))
+        (values-rx (when list-sep
+                    `(seq ,value (zero-or-more ,list-sep ,value))))
+        ;; Related functions:
+        (property-dname (if property-name
+                            (downcase property-name)
+                          (string-trim (symbol-name symbolic-name)
+                                       "icalendar-" "-property")))
+        (matcher-name (ical:protected-intern
+                       (concat "icalendar-match-"
+                               property-dname
+                               "-property")))
+        (type-predicate-name
+         (ical:protected-intern (concat "icalendar-"
+                                        property-dname
+                                        "-property-p")))
+        ;; Faces:
+        (has-faces (or nondefault-name-face nondefault-value-face
+                       nondefault-warn-face extra-faces))
+        ;; Documentation:
+        (header "It names a property type defined by `icalendar-define-property'.")
+        (val-list (if list-sep (concat "VAL1" list-sep "VAL2" list-sep "...")
+                    "VAL"))
+        (default-doc (if default (format "The default value is: \"%s\"\n" default)
+                       ""))
+        (s (if list-sep "s" "")) ; to make plurals
+        (val-doc (concat "VAL" s " "
+                         "must be " (unless list-sep "a ")
+                         (format "value%s of one of the following types:\n" s)
+                         (string-join
+                          (cons
+                           (format "`%s' (default)" default-type)
+                           (mapcar (lambda (type) (format "`%s'" type))
+                                   other-types))
+                          "\n")
+                         default-doc))
+        (name-doc (if property-name "" "NAME must match rx `icalendar-name'"))
+        (syntax-doc (format "Syntax: %s[;PARAM...]:%s\n%s\n%s\n"
+                            (or property-name "NAME") val-list name-doc val-doc))
+        (child-doc
+         (concat
+          "The following parameters are required or allowed\n"
+          "as children in syntax nodes of this type:\n\n"
+          (ical:format-child-spec child-spec)
+          (when (plist-get child-spec :allow-others)
+            "\nOther parameters of any type are also allowed.\n")))
+        (full-doc (concat header "\n\n" doc "\n\n" syntax-doc "\n\n" child-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-property t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:default-value ,default
+                    'ical:default-type (quote ,default-type)
+                    'ical:other-types (quote ,other-types)
+                    'ical:list-sep ,list-sep
+                    'ical:substitute-value ,unrecognized
+                    'ical:value-type
+                    (when (ical:value-type-symbol-p (quote ,value))
+                      (quote ,value))
+                    'ical:value-rx (quote ,value)
+                    'ical:values-rx (quote ,values-rx)
+                    'ical:full-value-rx (quote ,full-value-rx-name)
+                    'ical:child-spec (quote ,child-spec)
+                    'ical:other-validator (function ,other-validator)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Value regex which matches:
+       ;; Group 2: correct values of the property, and
+       ;; Group 3: incorrect values up to end-of-line (for syntax warnings)
+       (rx-define ,full-value-rx-name
+         (or (group-n 2 ,(or values-rx value))
+             (group-n 3 (zero-or-more any))))
+
+       ;; Full property regex which matches:
+       ;; Group 1: the property name,
+       ;; Group 2: correct values of the property, and
+       ;; Group 3: incorrect values up to end-of-line (for syntax warnings)
+       (rx-define ,symbolic-name
+         (seq line-start
+              (group-n 1 ,(or property-name 'ical:name))
+              (group-n 4 (zero-or-more ical:other-param-safe))
+              ":"
+              ,full-value-rx-name
+              line-end))
+
+       ;; Matcher:
+       (defun ,matcher-name (limit)
+         ,(format "Matcher for `%s' property (defined by define-property)"
+                  symbolic-name)
+         (re-search-forward (rx ,symbolic-name) limit t))
+
+       ;; CL-type to represent syntax nodes for this property:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s property" property-name)
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Associate the print name with the type symbol for
+       ;; `icalendar-parse-property', `icalendar-print-property-node', etc.:
+       (when ,property-name
+         (push (cons ,property-name (quote ,symbolic-name)) ical:property-types))
+
+       ;; Generate an entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,name-face) t t)
+                       (2 (quote ,value-face) t t)
+                       (3 (quote ,warn-face) t t)
+                       ,@extra-faces))
+               ical:font-lock-keywords)))))
+
+
+;; Define components:
+(cl-defmacro ical:define-component (symbolic-name component-name doc
+                                    &key
+                                    ((:keyword-face keyword-face)
+                                     'ical:keyword nondefault-keyword-face)
+                                    ((:name-face name-face)
+                                     'ical:component-name nondefault-name-face)
+                                    child-spec
+                                    other-validator
+                                    link)
+  (declare (doc-string 2))
+  "Define iCalendar component COMPONENT-NAME under SYMBOLIC-NAME.
+COMPONENT-NAME should be the name of the component as it should
+appear in iCalendar data.
+
+Regular expressions to match the component boundaries are defined
+named `COMPONENT-NAME-begin' and `COMPONENT-NAME-end' (or
+`OTHER-begin' and `OTHER-end', where `OTHER' is derived from
+SYMBOLIC-NAME by removing any prefix `icalendar-' and suffix
+`-component' if COMPONENT-NAME is nil).
+  Group 1 of these regexes matches the \"BEGIN\" or \"END\"
+    keyword that marks a component boundary.
+  Group 2 matches the component name.
+
+A function to match the component boundaries is defined called
+`icalendar-match-COMPONENT-NAME-component' (or
+`icalendar-match-OTHER-component', with OTHER as above). This
+function is used to provide syntax highlighting in
+`icalendar-mode'.
+
+The following keyword arguments are accepted:
+
+:child-spec - a plist mapping the following keywords to lists
+of type symbols:
+  :one           - properties or components that must appear exactly once
+  :one-or-more   - properties or components that must appear at least once and
+                   may appear more than once
+  :zero-or-one   - properties or components that must appear at most once
+  :zero-or-more  - properties or components that may appear more than once
+  :allow-others  - if non-nil, other children besides those listed in the above
+                   are allowed to appear. (In this case, a :zero-or-more clause
+                   is redundant.)
+
+:other-validator - a function to perform any additional validation of
+  the component, beyond what `icalendar-ast-node-valid-p' checks.
+  This function should accept one argument, a syntax node. It
+  should return non-nil if the node is valid, or signal an
+  `icalendar-validation-error' if it is not. Its name does not
+  need to be quoted.
+
+:keyword-face - a face symbol for highlighting the BEGIN/END keyword
+  (default: `icalendar-keyword')
+
+:name-face - a face symbol for highlighting the component name
+  (default: `icalendar-component-name')
+
+:link - a string containing a URL for documentation of this component"
+  (let* (;; Regexes:
+         (name-rx (or component-name 'ical:name))
+         (component-dname (if component-name
+                              (downcase component-name)
+                            (string-trim (symbol-name symbolic-name)
+                                         "icalendar-" "-component")))
+         (begin-rx-name (ical:protected-intern
+                         (concat "icalendar-" component-dname "-begin")))
+         (end-rx-name (ical:protected-intern
+                       (concat "icalendar-" component-dname "-end")))
+         ;; Related functions:
+         (matcher-name
+          (ical:protected-intern
+           (concat "icalendar-match-" component-dname "-component")))
+         (type-predicate-name
+          (ical:protected-intern
+           (concat "icalendar-" component-dname "-component-p")))
+         ;; Faces:
+         (has-faces (or nondefault-name-face nondefault-keyword-face))
+         ;; Documentation:
+         (header "It names a component type defined by
+`icalendar-define-component'.")
+         (name-doc (if (not component-name)
+                       "\nNAME must match rx `icalendar-name'"
+                     ""))
+         (syntax-doc (format "Syntax:\nBEGIN:%s\n[contentline ...]\nEND:%1$s%s"
+                             (or component-name "NAME")
+                             name-doc))
+         (child-doc
+          (concat
+           "The following properties and components are required or "
+           "allowed\nas children in syntax nodes of this type:\n\n"
+           (ical:format-child-spec child-spec)
+           (when (plist-get child-spec :allow-others)
+             "\nOther properties and components of any type are also allowed.\n")))
+         (full-doc (concat header "\n\n" doc "\n\n" syntax-doc "\n\n" child-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-component t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:begin-rx (quote ,begin-rx-name)
+                    'ical:end-rx (quote ,end-rx-name)
+                    'ical:child-spec (quote ,child-spec)
+                    'ical:other-validator (function ,other-validator)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Regexes which match:
+       ;; Group 1: BEGIN or END, and
+       ;; Group 2: the component name
+       (rx-define ,begin-rx-name
+         (seq line-start
+              (group-n 1 "BEGIN")
+              ":"
+              (group-n 2 ,name-rx)
+              line-end))
+
+       (rx-define ,end-rx-name
+         (seq line-start
+              (group-n 1  "END")
+              ":"
+              (group-n 2 ,name-rx)
+              line-end))
+
+       (defun ,matcher-name (limit)
+         ,(format "Matcher for %s component boundaries"
+                  (or component-name "unrecognized"))
+           (re-search-forward (rx (or ,begin-rx-name ,end-rx-name)) limit t))
+
+       ;; CL-type to represent syntax nodes for this component:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s component"
+                  (or component-name "unrecognized"))
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Generate an entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,keyword-face) t t)
+                       (2 (quote ,name-face) t t)))
+               ical:font-lock-keywords))
+
+       ;; Associate the print name with the type symbol for
+       ;; `icalendar-parse-component', `icalendar-print-component' etc.:
+       (when ,component-name
+         (push (cons ,component-name (quote ,symbolic-name))
+               ical:component-types)))))
+
+
+;; Macros for destructuring and binding AST nodes
+
+(defmacro ical:with-node-children (node bindings &rest body)
+  "Bind the variables in BINDINGS to the corresponding
+child nodes in NODE, and execute BODY with these bindings.  NODE should
+be an iCalendar syntax node representing a component or property.
+
+Each binding in BINDINGS should be a list of one of the following forms:
+
+(TYPE VAR)
+  TYPE should be a type symbol for an iCalendar property or component
+  which can be a child of COMPONENT. The first child node of TYPE, if
+  any, will be bound to VAR in BODY.
+
+(TYPE KEY1 VAR1 ...)
+  For each KEY present, the corresponding VAR will be bound as follows:
+   :all - a list of all child nodes of TYPE. If this keyword is present,
+     none of the others are allowed.
+   :first - the first child node of TYPE
+   :default - the default value, if any, for TYPE
+   :value-node - the value (which is itself a node) of the node in :first
+   :value-type - the type of the node in :value-node.
+   :value - the value of the node in :value-node.
+  If TYPE expects a list of values, you should use the following keywords
+  instead of the previous three:
+   :value-nodes - the values (which are themselves nodes) of the node in :first
+   :value-types - a list of the types of the nodes in :value-nodes.
+   :values - a list of the values of the node in :value-node.
+  It is a compile-time error to use the singular keywords with a TYPE that
+  takes multiple values, or the plural keywords with a TYPE that does not."
+  (declare (indent 2))
+  ;; Static checks on the bindings prevent various annoying bugs:
+  (dolist (b bindings)
+    (let ((type (car b))
+          (kwargs (cdr b)))
+      (unless (ical:type-symbol-p type)
+        (error "Not an iCalendar type symbol: %s" type))
+      (when (and (plist-member kwargs :all)
+                 (> 2 (length kwargs)))
+        (error ":all may not be combined with other bindings"))
+      (if (ical:expects-list-of-values-p type)
+            (when (or (plist-member kwargs :value-node)
+                      (plist-member kwargs :value-type)
+                      (plist-member kwargs :value))
+              (error "Type `%s' expects a list of values" type))
+        (when (or (plist-member kwargs :value-nodes)
+                  (plist-member kwargs :value-types)
+                  (plist-member kwargs :values))
+              (error "Type `%s' does not expect a list of values" type)))))
+
+  (let ((nd (gensym "icalendar-node")))
+    `(let* ((,nd ,node)
+            ,@(mapcan
+               (lambda (tv)
+                 (let ((type (car tv))
+                       (vars (cdr tv)))
+                   (when (and (symbolp (car vars)) (null (cdr vars)))
+                     ;; the simple (TYPE VAR) case:
+                     (setq vars (list :first (car vars))))
+
+                   (let ((first-var (or (plist-get vars :first)
+                                        (gensym "first")))
+                         (default-var (or (plist-get vars :default)
+                                          (gensym "default")))
+                         (vnode-var (or (plist-get vars :value-node)
+                                        (gensym "value-node")))
+                         (vtype-var (or (plist-get vars :value-type)
+                                        (gensym "value-type")))
+                         (vval-var (or (plist-get vars :value)
+                                       (gensym "value")))
+
+                         (vnodes-var (or (plist-get vars :value-nodes)
+                                         (gensym "value-nodes")))
+                         (vtypes-var (or (plist-get vars :value-types)
+                                         (gensym "value-types")))
+                         (vvals-var (or (plist-get vars :values)
+                                        (gensym "values")))
+
+                         (all-var (or (plist-get vars :all)
+                                      (gensym "all")))
+                         ;; The corresponding vars for :all are too
+                         ;; complicated to be useful, I think, so
+                         ;; not implementing them for now
+                         )
+                     (delq nil
+                           (list
+                            (when (plist-member vars :all)
+                              `(,all-var (ical:ast-node-children-of
+                                          (quote ,type) ,nd)))
+                            (when (not (plist-member vars :all))
+                              `(,first-var (ical:ast-node-first-child-of
+                                            (quote ,type) ,nd)))
+                            (when (plist-member vars :default)
+                              `(,default-var (get (quote ,type)
+                                                  'ical:default-value)))
+                            ;; Single value:
+                            (when (or (plist-member vars :value-node)
+                                      (plist-member vars :value-type)
+                                      (plist-member vars :value))
+                              `(,vnode-var (when (ical:ast-node-p ,first-var)
+                                             (ical:ast-node-value ,first-var))))
+                            (when (plist-member vars :value-type)
+                              `(,vtype-var
+                                (when ,vnode-var
+                                  (ical:ast-node-type ,vnode-var))))
+                            (when (plist-member vars :value)
+                              `(,vval-var
+                                (when ,vnode-var
+                                  (ical:ast-node-value ,vnode-var))))
+
+                            ;; List of values:
+                            (when (or (plist-member vars :value-nodes)
+                                      (plist-member vars :value-types)
+                                      (plist-member vars :values))
+                              `(,vnodes-var
+                                (when (ical:ast-node-p ,first-var)
+                                  (ical:ast-node-value ,first-var))))
+                            (when (plist-member vars :value-types)
+                              `(,vtypes-var
+                                (when ,vnodes-var
+                                  (mapcar #'ical:ast-node-type ,vnodes-var))))
+                            (when (plist-member vars :values)
+                              `(,vvals-var
+                                (when ,vnodes-var
+                                  (mapcar #'ical:ast-node-value
+                                          ,vnodes-var)))))))))
+
+               bindings))
+       ,@body)))
+
+(defalias 'ical:with-component #'ical:with-node-children
+    "Bind the variables in BINDINGS to the corresponding
+properties in NODE, and execute BODY with these bindings.
+
+NODE should be an iCalendar syntax node representing an iCalendar
+component: `icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal',
+`icalendar-vtimezone', `icalendar-vfreebusy', `icalendar-standard',
+`icalendar-daylight'. It may also be an entire `icalendar-vcalendar'.
+
+Each binding in BINDINGS should be a list of one of the following forms:
+
+(TYPE VAR)
+  TYPE should be a type symbol for an iCalendar property or component
+  which can be a child of COMPONENT. The first child node of TYPE, if
+  any, will be bound to VAR in BODY.
+
+(TYPE KEY1 VAR1 ...)
+  For each KEY present, the corresponding VAR will be bound as follows:
+   :all - a list of all child nodes of TYPE. If this keyword is present,
+     none of the others are allowed.
+   :default - the default value, if any, for TYPE
+   :first - the first child node of TYPE
+   :value-node - the value (which is itself a node) of the node in :first
+   :value-type - the type of the node in :value-node.
+   :value - the value of the node in :value-node.
+  If TYPE expects a list of values, you should use the following keywords
+  instead of the previous three:
+   :value-nodes - the values (which are themselves nodes) of the node in :first
+   :value-types - a list of the types of the nodes in :value-nodes.
+   :values - a list of the values of the node in :value-node.
+  It is a compile-time error to use the singular keywords with a TYPE that
+  takes multiple values, or the plural keywords with a TYPE that does not.")
+
+(defmacro ical:with-node-value (node bindings &rest body)
+  "Bind the value in NODE and any of NODE's children in BINDINGS
+and execute BODY with these bindings.
+
+NODE should be an iCalendar syntax node representing a property or
+parameter. If NODE is not a syntax node, this form evalutes to nil
+without binding the variables in BINDINGS and without executing BODY.
+
+Within BODY, if NODE's value is itself a syntax node, the symbol
+`value-node' will be bound to the syntax node for NODE's value,
+`value-type' will be bound to `value-node's type, and `value' will be
+bound to `value-node's value.
+
+If NODE's value is not a syntax node, then `value' is instead bound
+directly to NODE's value, and `value-type' and `value-node' are bound to
+nil.
+
+BINDINGS are passed on to `icalendar-with-node-children' and will be
+available in BODY; see its docstring for their form."
+  (let ((vn (gensym "icalendar-node"))
+        (val (gensym "icalendar-value")))
+    `(let ((,vn ,node))
+       (when (ical:ast-node-p ,vn)
+         (let* ((,val (ical:ast-node-value ,vn))
+                (value-node (when (ical:ast-node-p ,val) ,val))
+                (value-type (when (ical:ast-node-p value-node)
+                              (ical:ast-node-type value-node)))
+                (value (if (ical:ast-node-p value-node)
+                           (ical:ast-node-value value-node)
+                         ,val)))
+           (ignore value-type) ; Silence the byte compiler when
+           (ignore value)      ; one of these goes unused
+           (ical:with-node-children ,vn ,bindings ,@body))))))
+
+(defalias 'ical:with-property #'ical:with-node-value
+    "Bind the property value in NODE and any of its parameters in BINDINGS
+and execute BODY with these bindings.
+
+NODE should be an iCalendar syntax node representing a property. If NODE
+is not a syntax node, this form evalutes to nil without binding the
+variables in BINDINGS and without executing BODY.
+
+Within BODY, if NODE's value is itself a syntax node, the symbol
+`value-node' will be bound to the syntax node for NODE's value,
+`value-type' will be bound to `value-node's type, and `value' will be
+bound to `value-node's value.
+
+If NODE's value is not a syntax node, then `value' is bound directly to
+NODE's value, and `value-type' and `value-node' are bound to nil.
+
+BINDINGS are passed on to `icalendar-with-node-children' and will be
+available in BODY; see its docstring for their form.")
+
+(defmacro ical:with-param (parameter &rest body)
+  "Bind the value in PARAMETER and execute BODY.
+
+PARAMETER should be an iCalendar syntax node representing a
+parameter. If PARAMETER is nil, this form evalutes to nil without
+executing BODY.
+
+Within BODY, if PARAMETER's value is a syntax node, the symbol
+`value-node' will be bound to that syntax node, `value-type' will be
+bound to the value node's type, and `value' will be bound to the value
+node's value.
+
+If PARAMETER's value is not a syntax node, then `value' is bound
+directly to PARAMETER's value, and `value-type' and `value-node' are
+bound to nil."
+  `(ical:with-node-value ,parameter nil ,@body))
+
+(defmacro ical:with-child-of (node type bindings &rest body)
+  "Like `icalendar-with-node-value', but applies to the parent of the node
+of interest.
+
+Find the first child node of type TYPE in NODE, bind that
+child node's value and any of its children in BINDINGS and execute BODY
+with these bindings.  If there is no such node, this form evalutes to
+nil without executing BODY.
+
+Within BODY, the symbols `value-node', `value-type', and `value' will be
+bound as in `icalendar-with-node-value'. See
+`icalendar-with-node-children' for the form of BINDINGS."
+  (let ((child (gensym "icalendar-node")))
+    `(let ((,child (ical:ast-node-first-child-of ,type ,node)))
+       (ical:with-node-value ,child ,bindings ,@body))))
+
+(defalias 'ical:with-property-of #'ical:with-child-of
+  "Like `icalendar-with-property', but applies to the parent component NODE.
+
+Find the first property node of type TYPE in NODE and execute BODY.
+
+Within BODY, the symbols `value-node', `value-type', and `value' will be
+bound to the property's value node, type and value as in
+`icalendar-with-node-value'. BINDINGS can be used to bind its
+parameters; see `icalendar-with-node-children' for the form of BINDINGS.")
+
+(defalias 'ical:with-param-of #'ical:with-child-of
+  "Like `icalendar-with-param', but applies to the parent property NODE.
+
+Find the first parameter node of type TYPE in NODE and execute BODY.
+
+Within BODY, the symbols `value-node', `value-type', and `value' will be
+bound to the parameter's value node, type and value as in
+`icalendar-with-node-value'.")
+
+
+
+(provide 'icalendar-macs)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-macs.el ends here
diff --git a/lisp/calendar/icalendar-mode.el b/lisp/calendar/icalendar-mode.el
new file mode 100644
index 00000000000..49a7fb8afda
--- /dev/null
+++ b/lisp/calendar/icalendar-mode.el
@@ -0,0 +1,610 @@
+;;; icalendar-mode.el --- Major mode for iCalendar format  -*- lexical-binding: t; -*-
+;;;
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <[email-masked]>
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines icalendar-mode, a major mode for editing
+;; iCalendar data. It defines a syntax table, faces, hooks, and
+;; commands for the mode and sets up syntax highlighting via
+;; font-lock-mode. Syntax highlighting uses the entries for
+;; font-lock-keywords already gathered in icalendar-parser.el, which
+;; see.
+
+;; When activated, icalendar-mode offers to unfold content lines if
+;; necessary, and switch to a new buffer containing the unfolded data;
+;; see `ical:maybe-switch-to-unfolded-buffer'. This is because the
+;; parsing facilities, and thus syntax highlighting, assume that
+;; content lines have already been unfolded. When a buffer is saved,
+;; icalendar-mode also offers to fold long content if necessary, as
+;; required by RFC5545; see `ical:before-save-checks'.
+
+;;; Code:
+
+(require 'icalendar-parser)
+
+;; Faces and font lock:
+(defgroup ical:faces
+  '((ical:property-name custom-face)
+    (ical:property-value custom-face)
+    (ical:parameter-name custom-face)
+    (ical:parameter-value custom-face)
+    (ical:component-name custom-face)
+    (ical:keyword custom-face)
+    (ical:binary-data custom-face)
+    (ical:date-time-types custom-face)
+    (ical:numeric-types custom-face)
+    (ical:recurrence-rule custom-face)
+    (ical:warning custom-face)
+    (ical:ignored custom-face))
+  "Faces for icalendar-mode.") ; TODO: :group
+
+(defface ical:property-name
+  '((default . (:inherit font-lock-keyword-face)))
+  "Face for iCalendar property names")
+
+(defface ical:property-value
+  '((default . (:inherit default)))
+  "Face for iCalendar property values")
+
+(defface ical:parameter-name
+  '((default . (:inherit font-lock-property-name-face)))
+  "Face for iCalendar parameter names")
+
+(defface ical:parameter-value
+  '((default . (:inherit font-lock-property-use-face)))
+  "Face for iCalendar parameter values")
+
+(defface ical:component-name
+  '((default . (:inherit font-lock-constant-face)))
+  "Face for iCalendar component names")
+
+(defface ical:keyword
+  '((default . (:inherit font-lock-keyword-face)))
+  "Face for other iCalendar keywords")
+
+(defface ical:binary-data
+  '((default . (:inherit font-lock-comment-face)))
+  "Face for iCalendar values that represent binary data")
+
+(defface ical:date-time-types
+  '((default . (:inherit font-lock-type-face)))
+  "Face for iCalendar values that represent dates, date-times,
+durations, periods, and UTC offsets")
+
+(defface ical:numeric-types
+  '((default . (:inherit ical:property-value-face)))
+  "Face for iCalendar values that represent integers, floats, and geolocations")
+
+(defface ical:recurrence-rule
+  '((default . (:inherit font-lock-type-face)))
+  "Face for iCalendar recurrence rule values")
+
+(defface ical:uri
+  '((default . (:inherit ical:property-value-face :underline t)))
+  "Face for iCalendar values that are URIs (including URLs and mail addresses)")
+
+(defface ical:warning
+  '((default . (:inherit font-lock-warning-face)))
+  "Face for iCalendar syntax errors")
+
+(defface ical:ignored
+  '((default . (:inherit font-lock-comment-face)))
+  "Face for iCalendar syntax which is parsed but ignored")
+
+;;; Font lock:
+(defconst ical:params-font-lock-keywords
+  '((ical:match-other-param
+     (1 'font-lock-comment-face t t)
+     (2 'font-lock-comment-face t t)
+     (3 'ical:warning t t))
+    (ical:match-value-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-tzid-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-sent-by-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-rsvp-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-role-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-reltype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-related-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-range-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-partstat-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-member-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-language-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-fbtype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-fmttype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-encoding-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-dir-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-delegated-to-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-delegated-from-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-cutype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-cn-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-altrep-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t)))
+  "Entries for iCalendar property parameters in `font-lock-keywords'.")
+
+(defconst ical:properties-font-lock-keywords
+  '((ical:match-request-status-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-other-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-sequence-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-last-modified-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtstamp-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-created-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-trigger-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-repeat-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-action-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-rrule-property
+     (1 'ical:property-name t t)
+     (2 'ical:recurrence-rule t t)
+     (3 'ical:warning t t))
+    (ical:match-rdate-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-exdate-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-uid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-url-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-related-to-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-recurrence-id-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-organizer-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-contact-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-attendee-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-tzurl-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-tzoffsetto-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-tzoffsetfrom-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-tzname-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-tzid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-transp-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-freebusy-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-duration-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtstart-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-due-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtend-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-completed-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-summary-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-status-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-resources-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-priority-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-percent-complete-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-location-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-geo-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-description-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-comment-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-class-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-categories-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-attach-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t)
+     (13 'ical:uri t t)
+     (14 'ical:binary-data t t))
+    (ical:match-version-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-prodid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-method-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-calscale-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t)))
+  "Entries for iCalendar properties in `font-lock-keywords'.")
+
+(defconst ical:ignored-properties-font-lock-keywords
+  `((,(rx ical:other-property) (1 'ical:ignored keep)
+                               (2 'ical:ignored keep)))
+  "Entries for iCalendar ignored properties in `font-lock-keywords'.")
+
+(defconst ical:components-font-lock-keywords
+  '((ical:match-vcalendar-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-other-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-valarm-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-daylight-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-standard-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vtimezone-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vfreebusy-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vjournal-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vtodo-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vevent-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t)))
+  "Entries for iCalendar components in `font-lock-keywords'.")
+
+(defvar ical:font-lock-keywords
+  (append ical:params-font-lock-keywords
+          ical:properties-font-lock-keywords
+          ical:components-font-lock-keywords
+          ical:ignored-properties-font-lock-keywords)
+  "Value of `font-lock-keywords' for icalendar-mode.")
+
+
+;; The major mode:
+
+;;; Mode hook
+(defvar ical:mode-hook nil
+  "Hook run when activating `icalendar-mode'.")
+
+;;; Activating the mode for .ics files
+;;; TODO: what about e.g. buffers displaying text/calendar MIME parts?
+(add-to-list 'auto-mode-alist '("\\.ics\\'" . icalendar-mode))
+
+;;; Syntax table
+(defvar ical:mode-syntax-table
+    (let ((st (make-syntax-table)))
+      ;; Characters for which the standard syntax table suffices:
+      ;; ; (punctuation): separates some property values, and property parameters
+      ;; " (string): begins and ends string values
+      ;; : (punctuation): separates property name (and parameters) from property
+      ;;                  values
+      ;; , (punctuation): separates values in a list
+      ;; CR, LF (whitespace): content line endings
+      ;; space (whitespace): when at the beginning of a line, continues the
+      ;;                     previous line
+
+      ;; Characters which need to be adjusted from the standard syntax table:
+      ;; = is punctuation, not a symbol constituent:
+      (modify-syntax-entry ?= ".   " st)
+      ;; / is punctuation, not a symbol constituent:
+      (modify-syntax-entry ?/ ".   " st)
+      st)
+    "Syntax table used in `icalendar-mode'.")
+
+;;; Commands
+
+;; TODO: is there a corresponding list by mimetype for buffers
+;; displaying message parts? Thought I saw this somewhere...
+
+(defun ical:switch-to-unfolded-buffer ()
+  "Switch to viewing the contents of the current buffer in a new
+buffer where content lines have been unfolded.
+
+`Folding' means inserting a line break and a single whitespace
+character to continue lines longer than 75 octets; `unfolding'
+means removing the extra whitespace inserted by folding. The
+iCalendar standard (RFC5545) requires folding lines when
+serializing data to iCalendar format, and unfolding before
+parsing it. In icalendar-mode, folded lines may not have proper
+syntax highlighting; this command allows you to view iCalendar
+data with proper syntax highlighting, as the parser sees it.
+
+If the current buffer is visiting a file, this function will
+offer to save the buffer first, and then reload the contents from
+the file, performing unfolding with `icalendar-unfold-undecoded-region'
+before decoding it. This is the most reliable way to unfold lines.
+
+If it is not visiting a file, it will unfold the new buffer
+with `icalendar-unfold-region'. This can in some cases have
+undesirable effects (see its docstring), so the original contents
+are preserved unchanged in the current buffer.
+
+In both cases, after switching to the new buffer, this command
+offers to kill the original buffer.
+
+It is recommended to turn off `auto-fill-mode' when viewing an
+unfolded buffer, so that filling does not interfere with syntax
+highlighting. This function offers to disable `auto-fill-mode' if
+it is enabled in the new buffer; consider using
+`visual-line-mode' instead."
+  (interactive)
+  (when (and buffer-file-name (buffer-modified-p))
+    (when (y-or-n-p (format "Save before reloading from %s?"
+                            (file-name-nondirectory buffer-file-name)))
+      (save-buffer)))
+  (let ((old-buffer (current-buffer))
+        (mmode major-mode)
+        (uf-buffer (if buffer-file-name
+                       (ical:unfolded-buffer-from-file buffer-file-name)
+                     (ical:unfolded-buffer-from-buffer (current-buffer)))))
+    (switch-to-buffer uf-buffer)
+    ;; restart original major mode, in case the new buffer is
+    ;; still in fundamental-mode: TODO: is this necessary?
+    (funcall mmode)
+    (when (y-or-n-p (format "Unfolded buffer is shown. Kill %s?"
+                            (buffer-name old-buffer)))
+      (kill-buffer old-buffer))
+    (when (and auto-fill-function
+               (y-or-n-p "Disable auto-fill-mode?"))
+      (auto-fill-mode -1))))
+
+(defun ical:maybe-switch-to-unfolded-buffer ()
+  "Check for folded lines and ask for confirmation before calling
+`icalendar-switch-to-unfolded-buffer', which see.
+
+This function is intended to be run via `icalendar-mode-hook'
+when `icalendar-mode' is activated."
+  (interactive)
+  (if (ical:contains-folded-lines-p)
+      (when (y-or-n-p "Buffer contains folded lines; unfold in new buffer?")
+        (ical:switch-to-unfolded-buffer))
+    ;; No need for unfolding, just inform the user:
+    (message "Buffer does not contain any lines to unfold")))
+
+(add-hook 'ical:mode-hook 'ical:maybe-switch-to-unfolded-buffer)
+
+(defun ical:before-save-checks ()
+  "Offer to change coding system and fold content lines in the
+current buffer when saving a buffer in `icalendar-mode'.
+
+The iCalendar standard requires CR-LF line endings, so if
+`buffer-file-coding-system' does not use a coding system which
+specifies them, this command offers to switch to a corresponding
+coding system which does.
+
+`Folding' means inserting a line break and a single whitespace
+character to continue lines longer than 75 octets. The iCalendar
+standard requires folding lines when serializing data to
+iCalendar format, so if the buffer contains unfolded lines, this
+command asks you whether you want to fold them."
+  (interactive)
+  (when (eq major-mode 'icalendar-mode)
+    (let* ((cs buffer-file-coding-system)
+           (suggested-cs (if cs (coding-system-change-eol-conversion cs 'dos)
+                           'prefer-utf-8-dos)))
+      (when (and (not (coding-system-equal cs suggested-cs))
+                 (y-or-n-p
+                  (format "Current coding system %s does not use CR-LF line endings. Change to %s for save?" cs suggested-cs)))
+        (set-buffer-file-coding-system suggested-cs))
+      (when (and (ical:contains-unfolded-lines-p)
+                 (y-or-n-p "Fold content lines before saving?"))
+        (ical:fold-region (point-min) (point-max))))))
+
+(add-hook 'before-save-hook 'ical:before-save-checks)
+
+;;; Mode definition
+(define-derived-mode icalendar-mode text-mode "iCalendar"
+  "Major mode for viewing and editing iCalendar (RFC5545) data.
+
+This mode provides syntax highlighting for iCalendar components,
+properties, values, and property parameters, and commands to deal
+with folding and unfolding iCalendar content lines.
+
+`Folding' means inserting whitespace characters to continue long
+lines; `unfolding' means removing the extra whitespace inserted
+by folding. The iCalendar standard requires folding lines when
+serializing data to iCalendar format, and unfolding before
+parsing it.
+
+Thus icalendar-mode's syntax highlighting is designed to work with
+unfolded lines. When icalendar-mode is activated, it will offer to
+unfold lines; see `icalendar-switch-to-unfolded-buffer'. It will also
+offer to fold lines when saving a buffer to a file; see
+`icalendar-before-save-checks'. That function also offers to convert the
+line endings in the file to CR-LF, as the standard requires."
+  :group 'icalendar
+  :syntax-table ical:mode-syntax-table
+  ;; TODO: Keymap?
+  ;; TODO: buffer-local variables?
+  ;; TODO: indent-line-function and indentation variables
+  ;; TODO: mode-specific menu and context menus
+  ;; TODO: eldoc integration
+  ;; TODO: completion of keywords
+  ;; TODO: hook for folding in change-major-mode-hook?
+  (progn
+    (setq font-lock-defaults '(ical:font-lock-keywords nil t))))
+
+(provide 'icalendar-mode)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-mode.el ends here
diff --git a/lisp/calendar/icalendar-parser.el b/lisp/calendar/icalendar-parser.el
new file mode 100644
index 00000000000..a47327319e1
--- /dev/null
+++ b/lisp/calendar/icalendar-parser.el
@@ -0,0 +1,4107 @@
+;;; icalendar-parser.el --- Parse iCalendar grammar  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <[email-masked]>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines regular expressions, constants and functions that
+;; implement the iCalendar grammar according to RFC5545.
+;;
+;; iCalendar data is grouped into *components*, such as events or
+;; to-do items. Each component contains one or more *content lines*,
+;; which each contain a *property* name and its *value*, and possibly
+;; also property *parameters* with additional data that affects the
+;; interpretation of the property.
+;;
+;; The macros `ical:define-type', `ical:define-param',
+;; `ical:define-property' and `ical:define-component', defined in
+;; icalendar-macs.el, each create rx-style regular expressions for one
+;; of these categories in the grammar and are used here to define the
+;; particular value types, parameters, properties and components in the
+;; standard as type symbols. These type symbols store all the metadata
+;; about the relevant types, and are used for type-based dispatch in the
+;; parser and printer functions. In the abstract syntax tree, each node
+;; contains a type symbol naming its type. A number of other regular
+;; expressions which encode basic categories of the grammar are also
+;; defined in this file.
+;;
+;; The following functions provide the high-level interface to the parser:
+;;
+;;   `icalendar-parse-component'
+;;   `icalendar-parse-property'
+;;   `icalendar-parse-params'
+;;
+;; The format of the abstract syntax tree which these functions create
+;; is documented in icalendar-ast.el. Nodes in this tree can be
+;; serialized to iCalendar format with the corresponding printer
+;; functions:
+;;
+;;   `icalendar-print-component-node'
+;;   `icalendar-print-property-node'
+;;   `icalendar-print-params'
+
+;;; Code:
+
+(eval-when-compile (require 'icalendar-macs))
+(require 'icalendar-ast)
+(eval-when-compile (require 'cl-lib))
+(require 'subr-x)
+(require 'seq)
+(require 'rx)
+(require 'calendar)
+(require 'time-date)
+(require 'simple)
+(require 'help-mode)
+
+;;; Functions for folding and unfolding
+;;
+;; According to RFC5545, iCalendar content lines longer than 75 octets
+;; should be *folded* by inserting extra line breaks and leading
+;; whitespace to continue the line. Such lines must be *unfolded*
+;; before they can be parsed.  Unfolding can only reliably happen
+;; before Emacs decodes a region of text, because decoding potentially
+;; replaces the CR-LF line endings which terminate content lines.
+;; Programs that can control when decoding happens should use the
+;; stricter `ical:unfold-undecoded-region' to unfold text; programs
+;; that must work with decoded data should use the looser
+;; `ical:unfold-region'. `ical:fold-region' will fold content lines
+;; using line breaks appropriate to the buffer's coding system.
+;;
+;; All the parsing-related code belows assumes that lines have
+;; already been unfolded if necessary.
+
+(defun ical:unfold-undecoded-region (start end &optional buffer)
+  "Unfold an undecoded region in BUFFER between START and END.
+If omitted, BUFFER defaults to the current buffer.
+
+\"Unfolding\" means removing the whitespace characters inserted to
+continue lines longer than 75 octets (see `icalendar-fold-region'
+for the folding operation). RFC5545 specifies these whitespace
+characters to be a CR-LF sequence followed by a single space or
+tab character. Unfolding can only be done reliably before a
+region is decoded, since decoding potentially replaces CR-LF line
+endings. This function searches strictly for CR-LF sequences, and
+will fail if they have already been replaced, so it should only
+be called with a region that has not yet been decoded."
+  (with-current-buffer (or buffer (current-buffer))
+    (with-restriction start end
+      (goto-char (point-min))
+      (while (re-search-forward (rx (seq "\r\n" (or " " "\t")))
+                                nil t)
+        (replace-match "" nil nil)))))
+
+(defun ical:unfold-region (start end &optional buffer)
+  "Unfold a region in BUFFER between START and END. If omitted,
+BUFFER defaults to the current buffer.
+
+\"Unfolding\" means removing the whitespace characters inserted to
+continue lines longer than 75 octets (see `icalendar-fold-region'
+for the folding operation).
+
+WARNING: Unfolding can only be done reliably before text is
+decoded, since decoding potentially replaces CR-LF line endings.
+Unfolding an already-decoded region could lead to unexpected
+results, such as displaying multibyte characters incorrectly,
+depending on the contents and the coding system used.
+
+This function attempts to do the right thing even if the region
+is already decoded. If it is still undecoded, it is better to
+call `icalendar-unfold-undecoded-region' directly instead, and
+decode it afterward."
+  ;; TODO: also make this a command so it can be run manually?
+  (with-current-buffer (or buffer (current-buffer))
+    (let ((was-multibyte enable-multibyte-characters)
+          (start-char (position-bytes start))
+          (end-char (position-bytes end)))
+      ;; we put the buffer in unibyte mode and later restore its
+      ;; previous state, so that if the buffer was already multibyte,
+      ;; any multibyte characters where line folds broke up their
+      ;; bytes can be reinterpreted:
+      (set-buffer-multibyte nil)
+      (with-restriction start-char end-char
+        (goto-char (point-min))
+        ;; since we can't be sure that line folds have a leading CR
+        ;; in already-decoded regions, do the best we can:
+        (while (re-search-forward (rx (seq (zero-or-one "\r") "\n"
+                                           (or " " "\t")))
+                                  nil t)
+          (replace-match "" nil nil)))
+      ;; restore previous state, possibly reinterpreting characters:
+      (set-buffer-multibyte was-multibyte))))
+
+(defun ical:unfolded-buffer-from-region (start end &optional buffer)
+  "Create a new buffer with the same contents as the region between
+START and END (in BUFFER, if provided) and perform line unfolding
+in the new buffer with `icalendar-unfold-region'. That function
+can in some cases have undesirable effects; see its docstring. If
+BUFFER is visiting a file, it may be better to reload its
+contents from that file and perform line unfolding before
+decoding; see `icalendar-unfolded-buffer-from-file'. Returns the
+new buffer."
+  (let* ((old-buffer (or buffer (current-buffer)))
+         (contents (with-current-buffer old-buffer
+                     (buffer-substring start end)))
+         (uf-buffer (generate-new-buffer
+                     (concat (buffer-name old-buffer)
+                             "~UNFOLDED")))) ;; TODO: again, move to modeline?
+    (with-current-buffer uf-buffer
+      (insert contents)
+      (ical:unfold-region (point-min) (point-max))
+      ;; ensure we'll use CR-LF line endings on write, even if they weren't
+      ;; in the source data. The standard also says UTF-8 is the default
+      ;; encoding, so use 'prefer-utf-8-dos when last-coding-system-used
+      ;; is nil.
+      (setq buffer-file-coding-system
+            (if last-coding-system-used
+                (coding-system-change-eol-conversion last-coding-system-used
+                                                     'dos)
+              'prefer-utf-8-dos)))
+    uf-buffer))
+
+(defun ical:unfolded-buffer-from-buffer (buffer)
+  "Create a new buffer with the same contents as BUFFER and perform
+line unfolding with `icalendar-unfold-region'. That function can in
+some cases have undesirable effects; see its docstring. If BUFFER
+is visiting a file, it may be better to reload its contents from
+that file and perform line unfolding before decoding; see
+`icalendar-unfolded-buffer-from-file'. Returns the new buffer."
+  (with-current-buffer buffer
+    (ical:unfolded-buffer-from-region (point-min) (point-max) buffer)))
+
+(defun ical:unfolded-buffer-from-file (filename &optional visit beg end)
+    "Create a new buffer with the contents of FILENAME and perform
+line unfolding with `icalendar-unfold-undecoded-region', then
+decode the buffer, setting an appropriate value for
+`buffer-file-coding-system'. Optional arguments VISIT, BEG, END
+are as in `insert-file-contents'. Returns the new buffer."
+    (unless (and (file-exists-p filename)
+                 (file-readable-p filename))
+      (error "File cannot be read: %s" filename))
+    ;; TODO: instead of messing with the buffer name, it might be more
+    ;; useful to keep track of the folding state in a variable and
+    ;; display it somewhere else in the mode line
+    (let ((uf-buffer (generate-new-buffer (concat (file-name-nondirectory filename)
+                                                  "~UNFOLDED"))))
+      (with-current-buffer uf-buffer
+        (set-buffer-multibyte nil)
+        (insert-file-contents-literally filename visit beg end t)
+        (ical:unfold-undecoded-region (point-min) (point-max))
+        (set-buffer-multibyte t)
+        (decode-coding-inserted-region (point-min) (point-max) filename)
+        ;; ensure we'll use CR-LF line endings on write, even if they weren't
+        ;; in the source data. The standard also says UTF-8 is the default
+        ;; encoding, so use 'prefer-utf-8-dos when last-coding-system-used
+        ;; is nil. FIXME: for some reason, this doesn't seem to run at all!
+        (setq buffer-file-coding-system
+              (if last-coding-system-used
+                  (coding-system-change-eol-conversion last-coding-system-used
+                                                       'dos)
+                'prefer-utf-8-dos))
+        ;; restore buffer name after renaming by set-visited-file-name:
+        (let ((bname (buffer-name)))
+          (set-visited-file-name filename t)
+          (rename-buffer bname)))
+      uf-buffer))
+
+(defun ical:fold-region (begin end &optional use-tabs)
+  "Fold all content lines in the region longer than 75 octets.
+
+\"Folding\" means inserting a line break and a single space
+character at the beginning of the new line. If USE-TABS is
+non-nil, insert a tab character instead of a single space.
+
+RFC5545 specifies that lines longer than 75 *octets* (excluding
+the line-ending CR-LF sequence) must be folded, and allows that
+some implementations might fold lines in the middle of a
+multibyte character. This function takes care not to do that in a
+buffer where `enable-multibyte-characters' is non-nil, and only
+folds between character boundaries. If the buffer is in unibyte
+mode, however, and contains undecoded multibyte data, it may fold
+lines in the middle of a multibyte character."
+  ;; TODO: also make this a command so it can be run manually?
+  (save-excursion
+    (goto-char begin)
+    (when (not (bolp))
+      (let ((inhibit-field-text-motion t))
+        (beginning-of-line)))
+    (let ((bol (point))
+          (eol (make-marker))
+          (reg-end (make-marker))
+          (line-fold
+           (concat
+            ;; if \n will be translated to \r\n on save (EOL type 1,
+            ;; "DOS"), just insert \n, otherwise the full fold sequence:
+            ;; FIXME: is buffer-file-coding-system the only relevant one here?
+            ;; What if the buffer is not visiting a file, but has come from a
+            ;; process, represents a mime part in an email, etc.?
+            (if (eq 1 (coding-system-eol-type buffer-file-coding-system))
+                "\n"
+              "\r\n")
+            ;; leading whitespace after line break:
+            (if use-tabs "\t" " "))))
+      (set-marker reg-end end)
+      (while (< bol reg-end)
+        (let ((inhibit-field-text-motion t))
+          (end-of-line))
+        (set-marker eol (point))
+        (when (< 75 (- (position-bytes (marker-position eol))
+                       (position-bytes bol)))
+          (goto-char
+           ;; the max of 75 excludes the two CR-LF
+           ;; characters we're about to add:
+           (byte-to-position (+ 75 (position-bytes bol))))
+          (insert line-fold)
+          (set-marker eol (point)))
+        (setq bol (goto-char (1+ eol)))))))
+
+(defun ical:contains-folded-lines-p ()
+  "Determine whether the current buffer contains folded content
+lines that should be unfolded for parsing and display purposes.
+If it does, return the position at the end of the first fold."
+  (save-excursion
+    (goto-char (point-min))
+    (re-search-forward (rx (seq line-start (or " " "\t")))
+                       nil t)))
+
+(defun ical:contains-unfolded-lines-p ()
+  "Determine whether the current buffer contains long content lines
+that should be folded before saving or transmitting. If it does,
+return the position at the beginning of the first line that
+requires folding."
+  (save-excursion
+    (goto-char (point-min))
+    (let ((bol (point))
+          (eol (make-marker)))
+      (catch 'unfolded-line
+        (while (< bol (point-max))
+          (let ((inhibit-field-text-motion t))
+            (end-of-line))
+          (set-marker eol (point))
+          ;; the max of 75 excludes the two CR-LF characters
+          ;; after position eol:
+          (when (< 75 (- (position-bytes (marker-position eol))
+                         (position-bytes bol)))
+            (throw 'unfolded-line bol))
+          (setq bol (goto-char (1+ eol))))
+        nil))))
+
+
+;; Parsing-related code starts here. All the parsing code assumes that
+;; content lines have already been unfolded.
+
+;;;; Error handling:
+
+;; Errors at the parsing stage:
+;; e.g. value does not match expected regex
+(define-error 'ical:parse-error "Could not parse iCalendar data")
+
+;; Errors at the printing stage:
+;; e.g. default print function doesn't know how to print value
+(define-error 'ical:print-error "Unable to print iCalendar data")
+
+;;;; Some utilities:
+(defun ical:parse-one-of (types limit)
+  "Parse a value of one of the TYPES, which should be a list of type
+symbols, from point up to LIMIT. For each type in TYPES, the
+parser function associated with that type will be called at
+point. The return value of the first successful parser function
+is returned. If none of the parser functions are able to parse a
+value, an `icalendar-parse-error' is signaled."
+  (let* ((value nil)
+         (start (point))
+         (type (car types))
+         (parser (get type 'ical:value-parser))
+         (rest (cdr types)))
+    (while (and parser (not value))
+      (condition-case nil
+          (setq value (funcall parser limit))
+        (ical:parse-error
+         ;; value of this type not found, so try again:
+         (goto-char start)
+         (setq type (car rest)
+               rest (cdr rest)
+               parser (get type 'ical:value-parser)))))
+    (unless value
+      (signal 'ical:parse-error
+              (list (format "Unable to parse any of %s between %d and %d"
+                            types start limit))))
+    value))
+
+(defun ical:read-list-with (reader string
+                            &optional value-regex separators omit-nulls trim)
+  "Read a list of values from STRING with READER.
+
+READER should be a reader function that accepts a single string argument.
+SEPARATORS, OMIT-NULLS, and TRIM are as in `split-string'.
+SEPARATORS defaults to \"[^\\][,;]\". TRIM defaults to matching a
+double quote character.
+
+VALUE-REGEX should be a regular expression if READER assumes that
+individual substrings in STRING have previously been matched
+against this regex. In this case, each value in S is placed in a
+temporary buffer and the match against VALUE-REGEX is performed
+before READER is called."
+  (let* ((wrapped-reader
+           (if (not value-regex)
+               ;; no need for temp buffer:
+               reader
+             ;; match the regex in a temp buffer before calling reader:
+             (lambda (s)
+               (with-temp-buffer
+                 (insert s)
+                 (goto-char (point-min))
+                 (unless (looking-at value-regex)
+                   (signal 'ical:parse-error
+                           (list (format "Expected list of values matching '%s'"
+                                         value-regex)
+                                 s)))
+                 (funcall reader (match-string 0))))))
+         (seps (or separators "[^\\][,;]"))
+         (trm (or trim "\""))
+         (raw-values (split-string string seps omit-nulls trm)))
+
+    (unless (functionp reader)
+      (signal 'ical:parser-error
+              (list (format "`%s' is not a reader function" reader))))
+
+    (mapcar wrapped-reader raw-values)))
+
+(defun ical:read-list-of (type string
+                          &optional separators omit-nulls trim)
+  "Read a list of values of type TYPE from STRING.
+
+TYPE should be a value type symbol. The reader function
+associated with that type will be called to read the successive
+values in STRING, and the values will be returned as a list of
+syntax nodes.
+
+SEPARATORS, OMIT-NULLS, and TRIM are as in `split-string' and
+will be passed on, if provided, to `icalendar-read-list-with'."
+  (let* ((reader (lambda (s) (ical:read-value-node type s)))
+         (val-regex (rx-to-string (get type 'ical:value-rx))))
+    (ical:read-list-with reader string val-regex
+                         separators omit-nulls trim)))
+
+(defun ical:list-of-p (list type)
+  "Returns non-nil if each value in LIST satisfies TYPE according to
+`cl-typep'"
+  (seq-every-p (lambda (val) (cl-typep val type)) list))
+
+(defun ical:default-value-printer (val)
+  "Default printer for a *single* property or parameter value.
+
+If VAL is a string, just return it unchanged.
+
+Otherwise, VAL should be a syntax node representing a value. In
+that case, return the original string value if another was
+substituted at parse time, or look up the printer function for
+the node's type and call it on the value inside the node.
+
+For properties and parameters that only allow a single value,
+this function should be a sufficient value printer. It is not
+sufficient for those that allow lists of values, or which have
+other special requirements like quoting or escaping."
+  (cond ((stringp val) val)
+        ((and (ical:ast-node-p val)
+              (get (ical:ast-node-type val) 'ical:value-printer))
+         (or (ical:ast-node-meta-get :original-value val)
+             (let* ((stored-value (ical:ast-node-value val))
+                    (type (ical:ast-node-type val))
+                    (printer (get type 'ical:value-printer)))
+               (funcall printer stored-value))))
+        ;; TODO: other cases to make things easy?
+        ;; e.g. symbols print as their names?
+        (t (signal 'ical:print-error
+                   (list (format "Don't know how to print value: %s" val)
+                         val)))))
+
+
+;;; Section 3.1: Content lines
+
+;; Regexp constants for parsing:
+
+;; In the following regexps and define-* declarations, because
+;; Emacs does not have named groups, we observe the following
+;; convention so that the regexps can be combined in sensible ways:
+;;
+;; - Groups 1 through 5 are reserved for the highest-level regexes
+;;   created by define-param, define-property and define-component and
+;;   used in the match-* functions. Group 1 always represents a 'key'
+;;   (e.g. param or property name), group 2 always represents a
+;;   correctly parsed value for that key, and group 3 (if matched) an
+;;   invalid or unknown value.
+;;
+;;   Groups 4 and 5 are reserved for other information in these
+;;   highest-level regexes, such as the parameter string between a
+;;   property name and its value, or unrecognized values allowed by
+;;   the standard and required to be treated like a default value.
+;;
+;; - Groups 6 through 10 are currently unused
+;; - Groups 11 through 20 are reserved for significant sub-expressions
+;;   of individual value expressions, e.g. the number of weeks in a
+;;   duration value. The various read-* functions rely on these groups
+;;   when converting iCalendar data to Elisp data structures.
+
+(rx-define ical:iana-token
+  (one-or-more (any alnum "-")))
+
+(rx-define ical:x-name
+  (seq "X-"
+      (zero-or-one (>= 3 (any alnum)) "-") ; Vendor ID
+      (one-or-more (any alnum "-")))) ; Name
+
+(rx-define ical:name
+  (or ical:iana-token ical:x-name))
+
+(rx-define ical:crlf
+  (seq #x12 #xa))
+
+(rx-define ical:control
+  ;; All the controls except HTAB
+  (any (#x00 . #x08) (#x0A . #x1F) #x7F))
+
+;; TODO: double check that "nonascii" class actually corresponds to
+;; the range in the standard
+(rx-define ical:safe-char
+  ;; Any character except ical:control, ?\", ?\;, ?:, ?,
+  (any #x09 #x20 #x21  (#x23 . #x2B) (#x2D . #x39) (#x3C . #x7E) nonascii))
+
+(rx-define ical:qsafe-char
+  ;; Any character except ical:control and ?"
+  (any #x09 #x20 #x21 (#x23 . #x7E) nonascii))
+
+(rx-define ical:quoted-string
+  (seq ?\" (zero-or-more ical:qsafe-char) ?\"))
+
+(rx-define ical:paramtext
+  ;; RFC5545 allows *zero* characters here, but that would mean we could
+  ;; have parameters like ;FOO=;BAR="somethingelse", and what would then
+  ;; be the value of FOO? I see no reason to allow this and it breaks
+  ;; parameter parsing so I have required at least one char here
+  (one-or-more ical:safe-char))
+
+(rx-define ical:param-name
+  (or ical:iana-token ical:x-name))
+
+(rx-define ical:param-value
+  (or ical:paramtext ical:quoted-string))
+
+(rx-define ical:value-char
+  (any #x09 #x20 (#x21 . #x7E) nonascii))
+
+(rx-define ical:value
+  (zero-or-more ical:value-char))
+
+;; some helpers for brevity, not defined in the standard:
+(rx-define ical:comma-list (item-rx)
+  (seq item-rx
+       (zero-or-more (seq ?, item-rx))))
+
+(rx-define ical:semicolon-list (item-rx)
+  (seq item-rx
+       (zero-or-more (seq ?\; item-rx))))
+
+
+;;; Section 3.3: Property Value Data Types
+
+;; Note: These definitions are here (out of order with respect to the
+;; standard) because a few of them are already required for property
+;; parameter definitions (section 3.2) below.
+
+(defconst ical:value-types nil ;; populated by define-type
+  "Alist mapping value type strings in `icalendar-valuetypeparam'
+parameters to type symbols defined with `icalendar-define-type'")
+
+(defun ical:read-value-node (type s)
+  "Read an iCalendar value of type TYPE from string S to a syntax node.
+Returns a syntax node containing the value."
+  (let ((reader (get type 'ical:value-reader)))
+    (ical:make-ast-node type (list :value (funcall reader s)))))
+
+(defun ical:parse-value-node (type limit)
+  "Parse an iCalendar value of type TYPE from point up to LIMIT.
+Returns a syntax node containing the value."
+  (let ((value-regex (rx-to-string (get type 'ical:value-rx))))
+
+    (unless (re-search-forward value-regex limit t)
+      (signal 'ical:parse-error
+              (list (format "No %s value between %d and %s"
+                            type (point) limit))))
+
+    (let ((begin (match-beginning 0))
+          (end (match-end 0))
+          (node (ical:read-value-node type (match-string 0))))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      (ical:ast-node-meta-set node :begin begin)
+      (ical:ast-node-meta-set node :end end)
+
+      node)))
+
+(defun ical:print-value-node (node)
+  "Serialize an iCalendar syntax node containing a value to a string."
+  (let* ((type (ical:ast-node-type node))
+         (value-printer (get type 'ical:value-printer)))
+    (funcall value-printer (ical:ast-node-value node))))
+
+(defun ical:printable-value-type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is a type symbol representing a printable
+iCalendar value type, i.e., a type for a property or parameter
+value defined by `icalendar-define-type' which has a print
+name (mainly for use in `icalendar-valuetypeparam' parameters).
+
+This means that SYMBOL must both satisfy
+`icalendar-value-type-symbol-p' and be associated with a print
+name in `icalendar-value-types'."
+  (and (ical:value-type-symbol-p symbol)
+       (rassq symbol ical:value-types)))
+
+(defun ical:value-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a value type."
+  (and (ical:ast-node-p node)
+       (ical:value-type-symbol-p (ical:ast-node-type node))))
+
+;;;; 3.3.1 Binary
+;; from https://www.rfc-editor.org/rfc/rfc4648#section-4:
+(rx-define ical:base64char
+  (any (?A . ?Z) (?a . ?z) (?0 . ?9) ?+ ?/))
+
+(ical:define-type ical:binary "BINARY"
+   "Type for Binary values.
+
+The parsed and printed representations are the same: a string of characters
+representing base64-encoded data."
+   '(and string (satisfies ical:match-binary-value))
+   (seq (zero-or-more (= 4 ical:base64char))
+        (zero-or-one (or (seq (= 2 ical:base64char) "==")
+                         (seq (= 3 ical:base64char) "="))))
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.1")
+
+;;;; 3.3.2 Boolean
+(defun ical:read-boolean (s)
+  "Read an `icalendar-boolean' value from a string S.
+S should be a match against rx `icalendar-boolean'."
+  (let ((upcased (upcase s)))
+    (cond ((equal upcased "TRUE") t)
+          ((equal upcased "FALSE") nil)
+          (t (signal 'ical:parse-error
+                     (list "Expected 'TRUE' or 'FALSE'" s))))))
+
+(defun ical:print-boolean (b)
+  "Serialize an `icalendar-boolean' value B to a string."
+    (if b "TRUE" "FALSE"))
+
+(ical:define-type ical:boolean "BOOLEAN"
+   "Type for Boolean values.
+
+When printed, either the string 'TRUE' or 'FALSE'.
+When read, either t or nil."
+   'boolean
+   (or "TRUE" "FALSE")
+   :reader ical:read-boolean
+   :printer ical:print-boolean
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.2")
+
+;;;; 3.3.3 Calendar User Address
+;; Defined with URI, below
+
+;; Dates and Times:
+
+;;;; 3.3.4 Date
+(cl-deftype ical:numeric-year () '(integer 0 9999))
+(cl-deftype ical:numeric-month () '(integer 1 12))
+(cl-deftype ical:numeric-monthday () '(integer 1 31))
+
+(rx-define ical:year
+  (= 4 digit))
+
+(rx-define ical:month
+  (= 2 digit))
+
+(rx-define ical:mday
+  (= 2 digit))
+
+(defun ical:read-date (s)
+  "Read an `icalendar-date' from a string S.
+S should be a match against rx `icalendar-date'."
+  (let ((year (string-to-number (substring s 0 4)))
+        (month (string-to-number (substring s 4 6)))
+        (day (string-to-number (substring s 6 8))))
+    (list month day year)))
+
+(defun ical:print-date (d)
+  "Serialize an `icalendar-date' to a string."
+  (format "%04d%02d%02d"
+          (calendar-extract-year d)
+          (calendar-extract-month d)
+          (calendar-extract-day d)))
+
+(ical:define-type ical:date "DATE"
+   "Type for Date values.
+
+When printed, a date is a string of digits in YYYYMMDD format.
+
+When read, a date is a list (MONTH DAY YEAR), with the three
+values being integers in the appropriate ranges; see `calendar.el'
+for functions that work with this representation."
+   '(and (satisfies calendar-date-is-valid-p))
+   (seq ical:year ical:month ical:mday)
+   :reader ical:read-date
+   :printer ical:print-date
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.4")
+
+;;;; 3.3.12 Time
+;; (Defined here so that ical:time RX can be used in ical:date-time)
+(cl-deftype ical:numeric-hour () '(integer 0 23))
+(cl-deftype ical:numeric-minute () '(integer 0 59))
+(cl-deftype ical:numeric-second () '(integer 0 60)) ; 60 represents a leap second
+
+(declare-function ical:make-date-time "icalendar-utils")
+
+(defun ical:read-time (s)
+  "Read an `icalendar-time' from a string S.
+S should be a match against rx `icalendar-time'."
+  (require 'icalendar-utils) ; for ical:make-date-time; avoids circular require
+  (let ((hour (string-to-number (substring s 0 2)))
+        (minute (string-to-number (substring s 2 4)))
+        (second (string-to-number (substring s 4 6)))
+        (utcoffset (if (and (length= s 7)
+                            (equal "Z" (substring s 6 7)))
+                       0
+                     ;; unknown/'floating' time zone:
+                     nil)))
+    (ical:make-date-time :second second
+                         :minute minute
+                         :hour hour
+                         :zone utcoffset)))
+
+(defun ical:print-time (time)
+  "Serialize an `icalendar-time' to a string."
+  (format "%02d%02d%02d%s"
+          (decoded-time-hour time)
+          (decoded-time-minute time)
+          (decoded-time-second time)
+          (if (eql 0 (decoded-time-zone time))
+              "Z" "")))
+
+(defun ical:-decoded-time-p (val)
+  "Return non-nil if VAL is a valid decoded *time*.
+This predicate does not check date-related values in VAL;
+for that, see `icalendar--decoded-date-time-p'."
+  ;; FIXME: this should probably be defined alongside the
+  ;; other decoded-time-* functions!
+  (and (listp val)
+       (length= val 9)
+       (cl-typep (decoded-time-second val) 'ical:numeric-second)
+       (cl-typep (decoded-time-minute val) 'ical:numeric-minute)
+       (cl-typep (decoded-time-hour val) 'ical:numeric-hour)
+       (cl-typep (decoded-time-dst val) '(member t nil -1))
+       (cl-typep (decoded-time-zone val) '(or integer null))))
+
+(ical:define-type ical:time "TIME"
+  "Type for Time values.
+
+When printed, a time is a string of six digits HHMMSS, followed
+by the letter 'Z' if it is in UTC.
+
+When read, a time is a decoded time, i.e. a list in the format
+(SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See
+`decode-time' for the specifics of the individual values. When
+read, the DAY, MONTH, YEAR, and DOW fields are nil, and these
+fields and DST are ignored when printed."
+  '(satisfies ical:-decoded-time-p)
+  (seq (= 6 digit) (zero-or-one ?Z))
+  :reader ical:read-time
+  :printer ical:print-time
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.12")
+
+;;;; 3.3.5 Date-Time
+(defun ical:-decoded-date-time-p (val)
+  (and (listp val)
+       (length= val 9)
+       (cl-typep (decoded-time-second val) 'ical:numeric-second)
+       (cl-typep (decoded-time-minute val) 'ical:numeric-minute)
+       (cl-typep (decoded-time-hour val) 'ical:numeric-hour)
+       (cl-typep (decoded-time-day val) 'ical:numeric-monthday)
+       (cl-typep (decoded-time-month val) 'ical:numeric-month)
+       (cl-typep (decoded-time-year val) 'ical:numeric-year)
+       (calendar-date-is-valid-p (list (decoded-time-month val)
+                                       (decoded-time-day val)
+                                       (decoded-time-year val)))
+       ;; FIXME: the weekday slot value should be automatically
+       ;; calculated from month, day, and year, like:
+       ;;   (calendar-day-of-week (list month day year))
+       ;; Although `ical:read-date-time' does this correctly,
+       ;; `make-decoded-time' does not. Thus we can't use
+       ;; `make-decoded-time' to construct valid `ical:date-time'
+       ;; values unless this check is turned off,
+       ;; which means it's annoying to write tests and anything
+       ;; that uses cl-typecase to dispatch on values created by
+       ;; `make-decoded-time':
+       ;; (cl-typep (decoded-time-weekday val) '(integer 0 6))
+       (cl-typep (decoded-time-dst val) '(member t nil -1))
+       (cl-typep (decoded-time-zone val) '(or integer null))))
+
+(defun ical:read-date-time (s)
+  "Read an `icalendar-date-time' from a string S.
+S should be a match against rx `icalendar-date-time'."
+  (require 'icalendar-utils) ; for ical:make-date-time; avoids circular requires
+  (let ((year (string-to-number (substring s 0 4)))
+        (month (string-to-number (substring s 4 6)))
+        (day (string-to-number (substring s 6 8)))
+        ;; "T" is index 8
+        (hour (string-to-number (substring s 9 11)))
+        (minute (string-to-number (substring s 11 13)))
+        (second (string-to-number (substring s 13 15)))
+        (utcoffset (if (and (length= s 16)
+                            (equal "Z" (substring s 15 16)))
+                       0
+                     ;; unknown/'floating' time zone:
+                     nil)))
+    (ical:make-date-time :second second
+                         :minute minute
+                         :hour hour
+                         :day day
+                         :month month
+                         :year year
+                         :zone utcoffset)))
+
+(defun ical:print-date-time (datetime)
+  "Serialize an `icalendar-date-time' to a string."
+  (format "%04d%02d%02dT%02d%02d%02d%s"
+          (decoded-time-year datetime)
+          (decoded-time-month datetime)
+          (decoded-time-day datetime)
+          (decoded-time-hour datetime)
+          (decoded-time-minute datetime)
+          (decoded-time-second datetime)
+          (if (ical:date-time-is-utc-p datetime)
+              "Z" "")))
+
+(defun ical:date-time-is-utc-p (datetime)
+  "Return non-nil if DATETIME is in UTC time"
+  (let ((offset (decoded-time-zone datetime)))
+    (and offset (= 0 offset))))
+
+(ical:define-type ical:date-time "DATE-TIME"
+   "Type for Date-Time values.
+
+When printed, a date-time is a string of digits like:
+  YYYYMMDDTHHMMSS
+where the 'T' is literal, and separates the date string from the
+time string.
+
+When read, a date-time is a decoded time, i.e. a list in the format
+(SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See
+`decode-time' for the specifics of the individual values."
+   '(satisfies ical:-decoded-date-time-p)
+  (seq ical:date ?T ical:time)
+  :reader ical:read-date-time
+  :printer ical:print-date-time
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.5")
+
+;;;; 3.3.6 Duration
+(rx-define ical:dur-second
+  (seq (group-n 19 (one-or-more digit)) ?S))
+
+(rx-define ical:dur-minute
+  (seq (group-n 18 (one-or-more digit)) ?M (zero-or-one ical:dur-second)))
+
+(rx-define ical:dur-hour
+  (seq (group-n 17 (one-or-more digit)) ?H (zero-or-one ical:dur-minute)))
+
+(rx-define ical:dur-day
+  (seq (group-n 16 (one-or-more digit)) ?D))
+
+(rx-define ical:dur-week
+  (seq (group-n 15 (one-or-more digit)) ?W))
+
+(rx-define ical:dur-time
+  (seq ?T (or ical:dur-hour ical:dur-minute ical:dur-second)))
+
+(rx-define ical:dur-date
+  (seq ical:dur-day (zero-or-one ical:dur-time)))
+
+;; TODO: This function already exists! Super: replace with iso8601-parse-duration
+(defun ical:read-dur-value (s)
+  "Read an `icalendar-dur-value' from a string S.
+S should be a match against rx `icalendar-dur-value'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((sign (if (equal (match-string 20) "-") -1 1)))
+    (if (match-string 15)
+        ;; dur-value specified in weeks, so just return an integer:
+        (* sign (string-to-number (match-string 15)))
+      ;; otherwise, make a time delta from the other units:
+      (let* ((days (match-string 16))
+             (ndays (* sign (if days (string-to-number days) 0)))
+             (hours (match-string 17))
+             (nhours (* sign (if hours (string-to-number hours) 0)))
+             (minutes (match-string 18))
+             (nminutes (* sign (if minutes (string-to-number minutes) 0)))
+             (seconds (match-string 19))
+             (nseconds (* sign (if seconds (string-to-number seconds) 0))))
+        (make-decoded-time :second nseconds :minute nminutes :hour nhours
+                           :day ndays)))))
+
+(defun ical:print-dur-value (dur)
+  "Serialize an `icalendar-dur-value' to a string"
+  (if (integerp dur)
+      ;; dur-value specified in weeks can only contain weeks:
+      (format "%sP%dW" (if (< dur 0) "-" "") (abs dur))
+    ;; otherwise, show all the time units present:
+    (let* ((days+- (or (decoded-time-day dur) 0))
+           (hours+- (or (decoded-time-hour dur) 0))
+           (minutes+- (or (decoded-time-minute dur) 0))
+           (seconds+- (or (decoded-time-second dur) 0))
+           ;; deal with the possibility of mixed positive and negative values
+           ;; in a time delta list:
+           (sum (+ seconds+-
+                   (* 60 minutes+-)
+                   (* 60 60 hours+-)
+                   (* 60 60 24 days+-)))
+           (abssum (abs sum))
+           (days (/ abssum (* 60 60 24)))
+           (sumnodays (mod abssum (* 60 60 24)))
+           (hours (/ sumnodays (* 60 60)))
+           (sumnohours (mod sumnodays (* 60 60)))
+           (minutes (/ sumnohours 60))
+           (seconds (mod sumnohours 60))
+           (sign (when (< sum 0) "-"))
+           (time-sep (unless (and (zerop hours) (zerop minutes) (zerop seconds))
+                       "T")))
+      (concat sign
+              "P"
+              (unless (zerop days) (format "%dD" days))
+              time-sep
+              (unless (zerop hours) (format "%dH" hours))
+              (unless (zerop minutes) (format "%dM" minutes))
+              (unless (zerop seconds) (format "%dS" seconds))))))
+
+(defun ical:-time-delta-p (val)
+  (and (listp val)
+       (length= val 9)
+       (let ((seconds (decoded-time-second val))
+             (minutes (decoded-time-minute val))
+             (hours (decoded-time-hour val))
+             (days (decoded-time-day val))) ; other values in list are ignored
+         (and
+          (cl-typep seconds 'integer)
+          (cl-typep minutes 'integer)
+          (cl-typep hours 'integer)
+          (cl-typep days 'integer)
+          (not (and (zerop seconds) (zerop minutes) (zerop hours)
+                    (zerop days)))))))
+
+(ical:define-type ical:dur-value "DURATION"
+  "Type for Duration values.
+
+When printed, a duration is a string containing:
+  - possibly a +/- sign
+  - the letter 'P'
+  - one or more sequences of digits followed by a letter representing a unit
+    of time: 'W' for weeks, 'D' for days, etc. Units smaller than a day are
+    separated from days by the letter 'T'. If a duration is specified in weeks,
+    other units of time are not allowed.
+
+For example, a duration of 15 days, 5 hours, and 20 seconds would be printed:
+   P15DT5H0M20S
+and a duration of 7 weeks would be printed:
+   P7W
+
+When read, a duration is either an integer, in which case it
+represents a number of weeks, or a decoded time, in which case it
+must represent a time delta in the sense of `decoded-time-add'.
+Note that, in the time delta representation, units of time longer
+than a day are not supported and will be ignored if present.
+
+This type is named `icalendar-dur-value' rather than
+`icalendar-duration' for consistency with the text of RFC5545 and
+so that its name does not collide with the symbol for the
+`DURATION' property."
+  '(or integer (satisfies ical:-time-delta-p))
+  ;; Group 15: weeks
+  ;; Group 16: days
+  ;; Group 17: hours
+  ;; Group 18: minutes
+  ;; Group 19: seconds
+  ;; Group 20: sign
+  (seq
+   (group-n 20 (zero-or-one (or ?+ ?-)))
+   ?P
+   (or ical:dur-date ical:dur-time ical:dur-week))
+  :reader ical:read-dur-value
+  :printer ical:print-dur-value
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.6")
+
+
+;;;; 3.3.7 Float
+(ical:define-type ical:float "FLOAT"
+   "Type for Float values.
+
+When printed, possibly a sign + or -, followed by a sequence of digits,
+and possibly a decimal. When read, an Elisp float value."
+   '(float * *)
+   (seq
+    (zero-or-one (or ?+ ?-))
+    (one-or-more digit)
+    (zero-or-one (seq ?. (one-or-more digit))))
+   :reader string-to-number
+   :printer number-to-string
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.7")
+
+;;;; 3.3.8 Integer
+(ical:define-type ical:integer "INTEGER"
+   "Type for Integer values.
+
+When printed, possibly a sign + or -, followed by a sequence of digits.
+When read, an Elisp integer value between -2147483648 and 2147483647."
+   '(integer -2147483648 2147483647)
+   (seq
+    (zero-or-one (or ?+ ?-))
+    (one-or-more digit))
+   :reader string-to-number
+   :printer number-to-string
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.8")
+
+;;;; 3.3.9 Period
+;; TODO: This function already exists! Super: replace with iso8601-parse-interval
+(defsubst ical:period-start (period)
+  "Return the `icalendar-date-time' which marks the start of PERIOD."
+  (car period))
+
+(defsubst ical:period-end (period)
+  "Return the `icalendar-date-time' which marks the end of PERIOD, or nil."
+  (cadr period))
+
+(defsubst ical:period-dur-value (period)
+  "Return the `icalendar-dur-value' which gives the length of PERIOD, or nil."
+  (caddr period))
+
+(defun ical:period-p (val)
+  (and (listp val)
+       (length= val 3)
+       (cl-typep (ical:period-start val) 'ical:date-time)
+       (cl-typep (ical:period-end val) '(or null ical:date-time))
+       (cl-typep (ical:period-dur-value val) '(or null ical:dur-value))))
+
+(defun ical:read-period (s)
+  "Read an `icalendar-period' from a string S.
+S should have been matched against rx `icalendar-period'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((start (ical:read-date-time (match-string 11)))
+        (end (when (match-string 12) (ical:read-date-time (match-string 12))))
+        (dur (when (match-string 13) (ical:read-dur-value (match-string 13)))))
+    (list start end dur)))
+
+(defun ical:print-period (per)
+  "Serialize an `icalendar-period' to a string"
+  (let ((start (ical:period-start per))
+        (end (ical:period-end per))
+        (dur (ical:period-dur-value per)))
+    (concat (ical:print-date-time start)
+            "/"
+            (if dur
+                (ical:print-dur-value dur)
+              (ical:print-date-time end)))))
+
+(ical:define-type ical:period "PERIOD"
+   "Type for Period values.
+
+A period of time is specified as a starting date-time together
+with either an explicit date-time as its end, or a duration which
+gives its length and implicitly marks its end.
+
+When printed, the starting date-time is separated from the end or
+duration by a / character.
+
+When read, a period is represented as a list (START END DUR),
+where START is an `icalendar-date-time', END is either an
+`icalendar-date-time' or nil, and DUR is either an
+`icalendar-dur-value' or nil. (This representation allows END to
+be computed from DUR and cached, and also distinguishes DUR and
+END, which might both be decoded times.)"
+  '(satisfies ical:period-p)
+  (seq (group-n 11 ical:date-time)
+       "/"
+       (or (group-n 12 ical:date-time)
+           (group-n 13 ical:dur-value)))
+  :reader ical:read-period
+  :printer ical:print-period
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.9")
+
+;;;; 3.3.10 Recurrence rules:
+(rx-define ical:freq
+   (or "SECONDLY" "MINUTELY" "HOURLY" "DAILY" "WEEKLY" "MONTHLY" "YEARLY"))
+
+(rx-define ical:weekday
+   (or "SU" "MO" "TU" "WE" "TH" "FR" "SA"))
+
+(rx-define ical:ordwk
+  (** 1 2 digit)) ; 1 to 53
+
+(rx-define ical:weekdaynum
+  ;; Group 19: Week num, if present
+  ;; Group 20: week day abbreviation
+   (seq (zero-or-one
+         (group-n 19 (seq (zero-or-one (or ?+ ?-))
+                          ical:ordwk)))
+        (group-n 20 ical:weekday)))
+
+(rx-define ical:weeknum
+  (seq (zero-or-one (or ?+ ?-))
+       ical:ordwk))
+
+(rx-define ical:monthdaynum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 2 digit))) ; 1 to 31
+
+(rx-define ical:monthnum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 2 digit))) ; 1 to 12
+
+(rx-define ical:yeardaynum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 3 digit))) ; 1 to 366
+
+(defconst ical:weekday-numbers
+  '(("SU" . 0)
+    ("MO" . 1)
+    ("TU" . 2)
+    ("WE" . 3)
+    ("TH" . 4)
+    ("FR" . 5)
+    ("SA" . 6))
+  "Alist mapping two-letter weekday abbreviations to numbers 0 to 6.
+Weekday abbreviations in recurrence rule parts are translated to
+and from numbers for compatibility with calendar-* and
+decoded-time-* functions.")
+
+(defun ical:read-weekdaynum (s)
+  "Read a weekday abbreviation to a number.
+If the abbreviation is preceded by an offset, read a dotted
+pair (WEEKDAY . OFFSET). Thus \"SU\" becomes 0, \"-1SU\"
+becomes (0 . -1), etc. S should have been matched against
+`icalendar-weekdaynum'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((dayno (cdr (assoc (match-string 20) ical:weekday-numbers)))
+        (weekno (match-string 19)))
+    (if weekno
+        (cons dayno (string-to-number weekno))
+      dayno)))
+
+(defun ical:print-weekdaynum (val)
+  "Serialize a number or dotted pair VAL to a string
+(as part of a BYDAY recur rule part). See `icalendar-read-weekdaynum'
+for the value format."
+  (if (consp val)
+      (let* ((dayno (car val))
+             (day (car (rassq dayno ical:weekday-numbers)))
+             (offset (cdr val)))
+        (concat (number-to-string offset) day))
+    ;; number alone just stands for a day:
+    (car (rassq val ical:weekday-numbers))))
+
+(defun ical:read-recur-rule-part (s)
+  "Read an `icalendar-recur-rule-part' from string S.
+S should have been matched against `icalendar-recur-rule-part'.
+The return value is a list (KEYWORD VALUE), where VALUE may
+itself be a list, depending on the values allowed by KEYWORD."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((keyword (intern (upcase (match-string 11))))
+        (values (match-string 12)))
+    (list keyword
+      (cl-case keyword
+        (FREQ (intern (upcase values)))
+        (UNTIL (if (length> values 8)
+                   (ical:read-date-time values)
+                 (ical:read-date values)))
+        ((COUNT INTERVAL)
+         (string-to-number values))
+        ((BYSECOND BYMINUTE BYHOUR BYMONTHDAY BYYEARDAY BYWEEKNO BYMONTH BYSETPOS)
+         (ical:read-list-with #'string-to-number values nil ","))
+        (BYDAY
+         (ical:read-list-with #'ical:read-weekdaynum values
+                              (rx ical:weekdaynum) ","))
+        (WKST (cdr (assoc values ical:weekday-numbers)))))))
+
+(defun ical:print-recur-rule-part (part)
+  "Serialize recur rule part PART to a string."
+  (let ((keyword (car part))
+        (values (cadr part))
+        values-str)
+    (cl-case keyword
+      (FREQ (setq values-str (symbol-name values)))
+      (UNTIL (setq values-str (cl-typecase values
+                                (ical:date-time (ical:print-date-time values))
+                                (ical:date (ical:print-date values)))))
+      ((COUNT INTERVAL)
+       (setq values-str (number-to-string values)))
+      ((BYSECOND BYMINUTE BYHOUR BYMONTHDAY BYYEARDAY BYWEEKNO BYMONTH BYSETPOS)
+       (setq values-str (string-join (mapcar #'number-to-string values)
+                                     ",")))
+      (BYDAY
+       (setq values-str (string-join (mapcar #'ical:print-weekdaynum values)
+                                     ",")))
+      (WKST (setq values-str (car (rassq values ical:weekday-numbers)))))
+
+    (concat (symbol-name keyword) "=" values-str)))
+
+(rx-define ical:recur-rule-part
+  ;; Group 11: keyword
+  ;; Group 12: value(s)
+  (or (seq (group-n 11 "FREQ") "=" (group-n 12 ical:freq))
+      (seq (group-n 11 "UNTIL") "=" (group-n 12 (or ical:date-time ical:date)))
+      (seq (group-n 11 "COUNT") "=" (group-n 12 (one-or-more digit)))
+      (seq (group-n 11 "INTERVAL") "=" (group-n 12 (one-or-more digit)))
+      (seq (group-n 11 "BYSECOND") "=" (group-n 12 ; 0 to 60
+                                         (ical:comma-list (** 1 2 digit))))
+      (seq (group-n 11 "BYMINUTE") "=" (group-n 12 ; 0 to 59
+                                         (ical:comma-list (** 1 2 digit))))
+      (seq (group-n 11 "BYHOUR") "=" (group-n 12 ; 0 to 23
+                                       (ical:comma-list (** 1 2 digit)))) ; 0 to 23
+      (seq (group-n 11 "BYDAY") "=" (group-n 12 ; weeknum? daynum, e.g. SU or 34SU
+                                      (ical:comma-list ical:weekdaynum)))
+      (seq (group-n 11 "BYMONTHDAY") "=" (group-n 12
+                                           (ical:comma-list ical:monthdaynum)))
+      (seq (group-n 11 "BYYEARDAY") "=" (group-n 12
+                                          (ical:comma-list ical:yeardaynum)))
+      (seq (group-n 11 "BYWEEKNO") "=" (group-n 12 (ical:comma-list ical:weeknum)))
+      (seq (group-n 11 "BYMONTH") "=" (group-n 12 (ical:comma-list ical:monthnum)))
+      (seq (group-n 11 "BYSETPOS") "=" (group-n 12
+                                         (ical:comma-list ical:yeardaynum)))
+      (seq (group-n 11 "WKST") "=" (group-n 12 ical:weekday))))
+
+(defun ical:read-recur (s)
+  "Read a recurrence rule value from string S.
+S should be a match against rx `icalendar-recur'."
+  ;; TODO: let's switch to keywords and a plist, so we can more easily
+  ;; write these clauses also in diary sexp entries without so many parens
+  (ical:read-list-with #'ical:read-recur-rule-part s (rx ical:recur-rule-part) ";"))
+
+(defun ical:print-recur (val)
+  "Serialize a recurrence rule value VAL to a string."
+  ;; RFC5545 sec. 3.3.10: "to ensure backward compatibility with
+  ;; applications that pre-date this revision of iCalendar the
+  ;; FREQ rule part MUST be the first rule part specified in a
+  ;; RECUR value."
+  (string-join
+   (cons
+    (ical:print-recur-rule-part (assq 'FREQ val))
+    (mapcar #'ical:print-recur-rule-part
+            (seq-filter (lambda (part) (not (eq 'FREQ (car part))))
+                        val)))
+   ";"))
+
+(defconst ical:-recur-value-types
+  ;; Note: "list-of" is not a cl-type specifier, just a symbol here; it is
+  ;; handled specially when checking types in ical:recur-value-p:
+  '(FREQ (member YEARLY MONTHLY WEEKLY DAILY HOURLY MINUTELY SECONDLY)
+    UNTIL (or ical:date-time ical:date)
+    COUNT (integer 1 *)
+    INTERVAL (integer 1 *)
+    BYSECOND (list-of (integer 0 60))
+    BYMINUTE (list-of (integer 0 59))
+    BYHOUR (list-of (integer 0 23))
+    BYDAY (list-of (or (integer 0 6) (satisfies ical:dayno-offset-p)))
+    BYMONTHDAY (list-of (or (integer -31 -1) (integer 1 31)))
+    BYYEARDAY (list-of (or (integer -366 -1) (integer 1 366)))
+    BYWEEKNO (list-of (or (integer -53 -1) (integer 1 53)))
+    BYMONTH (list-of (integer 1 12)) ; unlike the others, months cannot be negative
+    BYSETPOS (list-of (or (integer -366 -1) (integer 1 366)))
+    WKST (integer 0 6))
+  "Plist mapping `icalendar-recur' keywords to type specifiers")
+
+(defun ical:dayno-offset-p (val)
+  "Return non-nil if VAL is a pair (DAYNO . OFFSET), part of a
+recurrence rule BYDAY value"
+  (and (consp val)
+       (cl-typep (car val) '(integer 0 6))
+       (cl-typep (cdr val) '(or (integer -53 -1) (integer 1 53)))))
+
+(defun ical:recur-value-p (vals)
+  "Return non-nil if VALS is an iCalendar recurrence rule value."
+  (and (listp vals)
+       ;; FREQ is always required:
+       (assq 'FREQ vals)
+       ;; COUNT and UNTIL are mutually exclusive if present:
+       (not (and (assq 'COUNT vals) (assq 'UNTIL vals)))
+       ;; If BYSETPOS is present, another BYXXX clause must be too:
+       (or (not (assq 'BYSETPOS vals))
+           (assq 'BYMONTH vals)
+           (assq 'BYWEEKNO vals)
+           (assq 'BYYEARDAY vals)
+           (assq 'BYMONTHDAY vals)
+           (assq 'BYDAY vals)
+           (assq 'BYHOUR vals)
+           (assq 'BYMINUTE vals)
+           (assq 'BYSECOND vals))
+       (let ((freq (ical:recur-freq vals))
+             (byday (ical:recur-by* 'BYDAY vals))
+             (byweekno (ical:recur-by* 'BYWEEKNO vals))
+             (bymonthday (ical:recur-by* 'BYMONTHDAY vals))
+             (byyearday (ical:recur-by* 'BYYEARDAY vals)))
+         (and
+          ;; "The BYDAY rule part MUST NOT be specified with a numeric
+          ;; value when the FREQ rule part is not set to MONTHLY or
+          ;; YEARLY."
+          (or (not (consp (car byday)))
+              (memq freq '(MONTHLY YEARLY)))
+          ;; "The BYDAY rule part MUST NOT be specified with a numeric
+          ;; value with the FREQ rule part set to YEARLY when the
+          ;; BYWEEKNO rule part is specified." This also covers:
+          ;; "[The BYWEEKNO] rule part MUST NOT be used when the FREQ
+          ;; rule part is set to anything other than YEARLY."
+          (or (not byweekno)
+              (and (eq freq 'YEARLY)
+                   (not (consp (car byday)))))
+          ;; "The BYMONTHDAY rule part MUST NOT be specified when the
+          ;; FREQ rule part is set to WEEKLY."
+          (not (and bymonthday (eq freq 'WEEKLY)))
+          ;; "The BYYEARDAY rule part MUST NOT be specified when the
+          ;; FREQ rule part is set to DAILY, WEEKLY, or MONTHLY."
+          (not (and byyearday (memq freq '(DAILY WEEKLY MONTHLY))))))
+       ;; check types of all rule parts:
+       (seq-every-p
+        (lambda (kv)
+          (when (consp kv)
+            (let* ((keyword (car kv))
+                   (val (cadr kv))
+                   (type (plist-get ical:-recur-value-types keyword)))
+              (and keyword val type
+                   (if (and (consp type)
+                            (eq (car type) 'list-of))
+                       (ical:list-of-p val (cadr type))
+                     (cl-typep val type))))))
+         vals)))
+
+(ical:define-type ical:recur "RECUR"
+  "Type for Recurrence Rule values.
+
+When printed, a recurrence rule value looks like
+  KEY1=VAL1;KEY2=VAL2;...
+where the VALs may themselves be lists or have other syntactic
+structure; see RFC5545 sec. 3.3.10 for all the gory details.
+
+The KEYs and their associated value types when read are as follows.
+The first is required:
+  '(FREQ (member YEARLY MONTHLY WEEKLY DAILY HOURLY MINUTELY SECONDLY)
+These two are mutually exclusive; at most one may appear:
+    UNTIL (or icalendar-date-time icalendar-date)
+    COUNT (integer 1 *)
+All others are optional:
+    INTERVAL (integer 1 *)
+    BYSECOND (list-of (integer 0 60))
+    BYMINUTE (list-of (integer 0 59))
+    BYHOUR (list-of (integer 0 23))
+    BYDAY (list-of (or (integer 0 6) ; day of week
+                       (pair (integer 0 6)  ; (day of week . offset)
+                             (integer -53 53))) ; except 0
+    BYMONTHDAY (list-of (integer -31 31))  ; except 0
+    BYYEARDAY (list-of (integer -366 366)) ; except 0
+    BYWEEKNO (list-of (integer -53 53))    ; except 0
+    BYMONTH (list-of (integer 1 12))       ; months cannot be negative
+    BYSETPOS (list-of (integer -366 366))  ; except 0
+    WKST (integer 0 6))
+
+When read, these KEYs and their associated VALs are gathered into
+an alist.
+
+In general, the VALs consist of integers or lists of integers.
+Abbreviations for weekday names are translated into integers
+0 (=Sunday) through 6 (=Saturday), for compatibility with
+calendar.el and decoded-time-* functions.
+
+Some examples:
+
+1) Printed: FREQ=DAILY;COUNT=10;INTERVAL=2
+   Meaning: 10 occurrences that occur every other day
+   Read: ((FREQ DAILY)
+          (COUNT 10)
+          (INTERVAL 2))
+
+2) Printed: FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA
+   Meaning: Every day in January of every year until 2000/01/31 at 14:00 UTC
+   Read: ((FREQ YEARLY)
+          (UNTIL (0 0 14 31 1 2000 1 -1 0))
+          (BYMONTH (1))
+          (BYDAY (0 1 2 3 4 5 6)))
+
+3) Printed: FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2
+   Meaning: Every month on the second-to-last weekday of the month
+   Read: ((FREQ MONTHLY)
+          (BYDAY (1 2 3 4 5))
+          (BYSETPOS (-2)))
+
+Notice that singleton values are still wrapped in a list when the
+KEY accepts a list of values, but not when the KEY always has a
+single (e.g. integer) value."
+  '(satisfies ical:recur-value-p)
+  (ical:semicolon-list ical:recur-rule-part)
+  :reader ical:read-recur
+  :printer ical:print-recur
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.10")
+
+(defun ical:recur-freq (recur-value)
+  "Return the frequency in RECUR-VALUE"
+  (car (alist-get 'FREQ recur-value)))
+
+(defun ical:recur-interval-size (recur-value)
+  "Return the interval size specified in RECUR-VALUE, or the default
+of 1."
+  (or (car (alist-get 'INTERVAL recur-value)) 1))
+
+(defun ical:recur-until (recur-value)
+  "Return the UNTIL date(-time) in RECUR-VALUE"
+  (car (alist-get 'UNTIL recur-value)))
+
+(defun ical:recur-count (recur-value)
+  "Return the COUNT in RECUR-VALUE"
+  (car (alist-get 'COUNT recur-value)))
+
+(defun ical:recur-weekstart (recur-value)
+  "Return the weekday which starts the work week specified in
+RECUR-VALUE, or the default (1 = Monday)"
+  (or (car (alist-get 'WKST recur-value)) 1))
+
+(defun ical:recur-by* (byunit recur-value)
+  "Return the values in the BYUNIT clause in RECUR-VALUE.
+BYUNIT should be a symbol: \\='BYMONTH, \\='BYDAY, etc.
+See `icalendar-recur' for all the possible BYUNIT values."
+  (car (alist-get byunit recur-value)))
+
+;;;; 3.3.11 Text
+(rx-define ical:escaped-char
+   (seq ?\\ (or ?\\ ?\; ?, ?N ?n)))
+
+(rx-define ical:text-safe-char
+  (not (or ?\" ?\; ?: ?\\ ?, ical:control))) ;; TODO: is this correct?
+
+(defun ical:text-region-p (val)
+  "Return t if VAL represents a region of text."
+  (and (listp val)
+       (markerp (car val))
+       (not (null (marker-buffer (car val))))
+       (markerp (cdr val))))
+
+(defun ical:make-text-region (&optional buffer begin end)
+  "Return an object that represents the region of text in BUFFER
+between BEGIN and END. BUFFER defaults to the current buffer, and
+BEGIN and END default to point and mark in BUFFER."
+  (let ((buf (or buffer (current-buffer)))
+        (b (make-marker))
+        (e (make-marker)))
+    (with-current-buffer buf
+      (set-marker b (or begin (min (point) (mark))) buf)
+      (set-marker e (or end (max (point) (mark))))
+      (cons b e))))
+
+(defsubst ical:text-region-begin (r)
+  "Return the marker at the beginning of the text region R"
+  (car r))
+
+(defsubst ical:text-region-end (r)
+  "Return the marker at the end of the text region R"
+  (cdr r))
+
+(defun ical:unescape-text-in-region (begin end)
+ "Unescape the text between BEGIN and END, replacing
+literal '\\n' and '\\N' with newline, and removing backslashes that escape
+commas, semicolons, and backslashes."
+ (with-restriction begin end
+   (save-excursion
+    (replace-string-in-region "\\N" "\n" (point-min) (point-max))
+    (replace-string-in-region "\\n" "\n" (point-min) (point-max))
+    (replace-string-in-region "\\," "," (point-min) (point-max))
+    (replace-string-in-region "\\;" ";" (point-min) (point-max)))
+    (replace-string-in-region (concat "\\" "\\") "\\" (point-min) (point-max))))
+
+(defun ical:unescape-text-string (s)
+ "Unescape the text in string S, replacing literal '\\n' and '\\N'
+with newline, and removing backslashes that escape commas, semicolons
+and backslashes."
+  (with-temp-buffer
+    (insert s)
+    (ical:unescape-text-in-region (point-min) (point-max))
+    (buffer-string)))
+
+(defun ical:escape-text-in-region (begin end)
+  "Escape the text between BEGIN and END, replacing newlines with
+literal '\\n', and escaping commas, semicolons and backslashes with a
+backslash."
+ (with-restriction begin end
+  (save-excursion
+    ;; replace backslashes first, so the ones introduced when
+    ;; escaping other characters don't end up double-escaped:
+    (replace-string-in-region "\\" (concat "\\" "\\") (point-min) (point-max))
+    (replace-string-in-region "\n" "\\n" (point-min) (point-max))
+    (replace-string-in-region "," "\\," (point-min) (point-max))
+    (replace-string-in-region ";" "\\;" (point-min) (point-max)))))
+
+(defun ical:escape-text-string (s)
+  "Escape the text in S, replacing newlines with '\\n', and escaping
+commas, semicolons, and backslashes with a backslash."
+  (with-temp-buffer
+    (insert s)
+    (ical:escape-text-in-region (point-min) (point-max))
+    (buffer-string)))
+
+(defun ical:read-text (s)
+  "Read an `icalendar-text' value from a string S.
+S should be a match against rx `icalendar-text'."
+  (ical:unescape-text-string s))
+
+(defun ical:print-text (val)
+  "Serialize an iCalendar text value. VAL may be a string or a text
+region (see `icalendar-make-text-region'). The text will be escaped before
+printing. If VAL is a region, the text it contains will not be
+modified; it is copied before escaping."
+  (if (stringp val)
+      (ical:escape-text-string val)
+    ;; val is a region, so copy and escape its contents:
+    (let* ((beg (ical:text-region-begin val))
+           (buf (marker-buffer beg))
+           (end (ical:text-region-end val)))
+      (with-temp-buffer
+        (insert-buffer-substring buf (marker-position beg) (marker-position end))
+        (ical:escape-text-in-region (point-min) (point-max))
+        (buffer-string)))))
+
+(defun ical:text-to-string (node)
+  "Return the value of an `icalendar-text' NODE as a string.
+The returned string is *not* escaped. For that, see `icalendar-print-text'."
+  (ical:with-node-value node nil
+    (if (stringp value) value
+      ;; Otherwise the value is a text region:
+      (let* ((beg (ical:text-region-begin value))
+             (buf (marker-buffer beg))
+             (end (ical:text-region-end value)))
+        (with-current-buffer buf
+          (buffer-substring (marker-position beg) (marker-position end)))))))
+
+;; TODO: would it be useful to add a third representation, namely a
+;; function or thunk? So that e.g. Org can pre-process its own syntax
+;; and return a plain text string to use in the description?
+(ical:define-type ical:text "TEXT"
+   "Type for Text values.
+
+Text values can be represented in Elisp in two ways: as strings,
+or as buffer regions. For values which aren't expected to change,
+such as property values in a text/calendar email attachment, use
+strings. For values which are user-editable and might change
+between parsing and serializing to iCalendar format, use a
+region. In that case, a text value contains two markers BEGIN and
+END which mark the bounds of the region. See
+`icalendar-make-text-region' to create such values, and
+`icalendar-text-region-begin' and `icalendar-text-region-end' to
+access the markers.
+
+Certain characters in text values are required to be escaped by
+the iCalendar standard. These characters should NOT be
+pre-escaped when inserting them into the parse tree. Instead,
+`icalendar-print-text' takes care of escaping text values, and
+`icalendar-read-text' takes care of unescaping them, when parsing and
+printing iCalendar data."
+  '(or string (satisfies ical:text-region-p))
+  (zero-or-more (or ical:text-safe-char ?: ?\" ical:escaped-char))
+  :reader ical:read-text
+  :printer ical:print-text
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.11")
+
+;; 3.3.12 Time - Defined above
+
+;;;; 3.3.13 URI
+;; see https://www.rfc-editor.org/rfc/rfc3986#section-3
+(require 'icalendar-uri-schemes)
+(rx-define ical:uri-with-scheme
+  ;; Group 11: URI scheme; see icalendar-uri-schemes.el
+  ;; Group 12: rest of URI after ":"
+  ;; This regex mostly just scans for all characters allowed by
+  ;; RFC3986. We make an effort to parse the scheme, even though this
+  ;; is an open-ended list, because otherwise the regex is either too
+  ;; permissive or too complicated to be useful. (ical:binary, in
+  ;; particular, matches a subset of the characters allowed in a URI).
+  ;; TODO: should we parse more structure here?
+  (seq (group-n 11 ical:uri-scheme)
+       ":"
+       (group-n 12
+         (one-or-more
+          (any alnum ?- ?. ?_ ?~                   ; unreserved chars
+               ?: ?/ ?? ?# ?\[ ?\] ?@              ; gen-delims
+               ?! ?$ ?& ?' ?\( ?\) ?* ?+ ?, ?\; ?= ; sub-delims
+               ?%)))))                             ; for %-encoding
+
+(ical:define-type ical:uri "URI"
+   "Type for URI values.
+
+The parsed and printed representations are the same: a URI string."
+   '(satisfies ical:match-uri-value)
+   ical:uri-with-scheme
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.13")
+
+;;;; 3.3.3 Calendar User Address
+(ical:define-type ical:cal-address "CAL-ADDRESS"
+   "Type for Calendar User Address values.
+
+The parsed and printed representations are the same: a URI string.
+Typically, this should be a mailto: URI.
+
+RFC5545 says: '*When used to address an Internet email transport
+  address* for a calendar user, the value MUST be a mailto URI,
+  as defined by [RFC2368]'
+
+Since it is unclear whether there are Calendar User Address values
+which are not used to address email, this type does not enforce the use
+of the mailto: scheme, but be prepared for problems if you create
+values of this type with any other scheme."
+   '(and string (satisfies ical:match-cal-address-value))
+   ical:uri-with-scheme
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.3")
+
+;;;; 3.3.14 UTC Offset
+(defun ical:read-utc-offset (s)
+  "Read a UTC offset from a string.
+S should be a match against rx `icalendar-utc-offset'"
+  (let ((sign (if (equal (substring s 0 1) "-") -1 1))
+        (nhours (string-to-number (substring s 1 3)))
+        (nminutes (string-to-number (substring s 3 5)))
+        (nseconds (if (length= s 7)
+                      (string-to-number (substring s 5 7))
+                    0)))
+    (* sign (+ nseconds (* 60 nminutes) (* 60 60 nhours)))))
+
+(defun ical:print-utc-offset (utcoff)
+  "Serialize a UTC offset to a string"
+  (let* ((sign (if (< utcoff 0) "-" "+"))
+         (absoff (abs utcoff))
+         (nhours (/ absoff (* 60 60)))
+         (no-hours (mod absoff (* 60 60)))
+         (nminutes (/ no-hours 60))
+         (nseconds (mod no-hours 60)))
+    (if (zerop nseconds)
+        (format "%s%02d%02d" sign nhours nminutes)
+      (format "%s%02d%02d%02d" sign nhours nminutes nseconds))))
+
+(ical:define-type ical:utc-offset "UTC-OFFSET"
+  "Type for UTC Offset values.
+
+When printed, a sign followed by a string of digits, like +HHMM
+or -HHMMSS. When read, an integer representing the number of
+seconds offset from UTC. This representation is for compatibility
+with `decode-time' and related functions."
+  '(integer -999999 999999)
+  (seq (or ?+ ?-) ; + is not optional for positive values!
+       (= 4 digit) ; HHMM
+       (zero-or-one (= 2 digit))) ; SS
+  :reader ical:read-utc-offset
+  :printer ical:print-utc-offset
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.14")
+
+
+;;; Section 3.2: Property Parameters
+
+(defconst ical:param-types nil ;; populated by ical:define-param
+  "Alist mapping printed parameter names to type symbols")
+
+(defun ical:maybe-quote-param-value (s &optional always)
+  "Add quotes around param value string S if required. If ALWAYS is non-nil,
+add quotes to S regardless of its contents"
+  (if (or always
+          (not (string-match (rx ical:paramtext) s))
+          (< (match-end 0) (length s)))
+      (concat "\"" s "\"")
+    s))
+
+(defun ical:read-param-value (type s)
+  "Read a value for a parameter of type TYPE from a string S.
+S should have already been matched against the regex for TYPE and
+the match data should be available to this function. Returns a
+syntax node of type TYPE containing the read value.
+
+If TYPE accepts a list of values, S will be split on the list
+separator for TYPE and read individually."
+  (let* ((value-type (get type 'ical:value-type)) ; if nil, value is just a string
+         (value-regex (when (get type 'ical:value-rx)
+                         (rx-to-string (get type 'ical:value-rx))))
+         (list-sep (get type 'ical:list-sep))
+         (substitute-val (get type 'ical:substitute-value))
+         (unrecognized-val (match-string 5)) ; see :unrecognized in define-param
+         (raw-val (if unrecognized-val substitute-val s))
+         (one-val-reader (if (ical:value-type-symbol-p value-type)
+                             (lambda (s) (ical:read-value-node value-type s))
+                           #'identity)) ; value is just a string
+         ;; values may be quoted even if :quoted does not require it,
+         ;; so they need to be stripped of quotes. read-list-of does
+         ;; this by default; in the single value case, use string-trim
+         (read-val (if list-sep
+                       (ical:read-list-with one-val-reader raw-val
+                                            value-regex list-sep)
+                     (funcall one-val-reader
+                              (string-trim raw-val "\"" "\"")))))
+    (ical:make-ast-node type
+                        (list :value read-val
+                              :original-value unrecognized-val))))
+
+(defun ical:parse-param-value (type limit)
+  "Parse the value for a parameter of type TYPE from point up to LIMIT.
+TYPE should be a type symbol for an iCalendar parameter type.
+This function expects point to be at the start of the value
+string, after the parameter name and the equals sign. Returns a
+syntax node representing the parameter."
+  (let ((full-value-regex (rx-to-string (get type 'ical:full-value-rx))))
+    (unless (re-search-forward full-value-regex limit t)
+      (signal 'ical:parse-error
+              (list (format "Unable to parse `%s' value between %d and %d"
+                            type (point) limit))))
+    (when (match-string 3)
+      (signal 'ical:parse-error
+              (list (format "Invalid value for `%s' parameter" type)
+                    (match-string 3))))
+
+    (let ((value-begin (match-beginning 2))
+          (value-end (match-end 2))
+          (node (ical:read-param-value type (match-string 2))))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      ;; :begin must be set by parse-params
+      (ical:ast-node-meta-set node :value-begin value-begin)
+      (ical:ast-node-meta-set node :value-end value-end)
+      (ical:ast-node-meta-set node :end value-end)
+
+      node)))
+
+(defun ical:parse-params (limit)
+  "Parse the parameter string of the current property, up to LIMIT.
+Point should be at the \";\" at the start of the first parameter.
+Returns a list of parameters, which may be nil if none are present.
+After parsing, point is at the end of the parameter string and the
+start of the property value string."
+  (let ((params nil))
+    (rx-let ((ical:param-start (seq ";" (group-n 1 ical:param-name) "=")))
+      (while (re-search-forward (rx ical:param-start) limit t)
+        (when-let* ((begin (match-beginning 1))
+                    (param-name (match-string 1))
+                    (param-type (or (alist-get (upcase param-name)
+                                               ical:param-types
+                                               nil nil #'equal)
+                                    'ical:otherparam))
+                    (param-node (ical:parse-param-value param-type limit)))
+          (ical:ast-node-meta-set param-node :begin begin)
+          ;; store the original param name if we didn't recognize it:
+          (when (eq param-type 'ical:otherparam)
+            (ical:ast-node-meta-set param-node :original-name param-name))
+          (push param-node params))))
+    (nreverse params)))
+
+(defun ical:print-param-node (node)
+  "Serialize a parameter syntax node NODE to a string.
+NODE should be a syntax node whose type is an iCalendar
+parameter type."
+  (let* ((param-type (ical:ast-node-type node))
+         (list-sep (get param-type 'ical:list-sep))
+
+         (val/s (ical:ast-node-value node))
+         (printed (if (and list-sep (listp val/s))
+                      (mapcar #'ical:default-value-printer val/s)
+                    (ical:default-value-printer val/s)))
+         ;; add quotes to each value as needed, even if :quoted
+         ;; does not require it:
+         (must-quote (get param-type 'ical:is-quoted))
+         (quoted (if (listp printed)
+                     (mapcar
+                      (lambda (v) (ical:maybe-quote-param-value v must-quote))
+                      printed)
+                   (ical:maybe-quote-param-value printed must-quote)))
+         (val-str (or (ical:ast-node-meta-get :original-value node)
+                      (if (and list-sep (listp quoted))
+                          (string-join quoted list-sep)
+                        quoted)))
+         (param-name (car (rassq param-type ical:param-types)))
+         (name-str (or param-name
+                       ;; set by parse-params for unrecognized params:
+                       (ical:ast-node-meta-get :original-name node))))
+    (format ";%s=%s" name-str val-str)))
+
+(defun ical:print-params (param-nodes)
+  "Print the property parameter nodes in PARAM-NODES. Returns the
+printed parameter list as a string."
+  (apply #'concat
+    (mapcar #'ical:print-param-node
+            param-nodes)))
+
+;; Parameter definitions in RFC5545:
+
+(ical:define-param ical:altrepparam "ALTREP"
+  "Alternate text representation (URI)"
+  ical:uri
+  :quoted t
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.1")
+
+(ical:define-param ical:cnparam "CN"
+  "Common Name"
+  ical:param-value
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.2")
+
+(ical:define-param ical:cutypeparam "CUTYPE"
+  "Calendar User Type"
+  (or "INDIVIDUAL"
+      "GROUP"
+      "RESOURCE"
+      "ROOM"
+      "UNKNOWN"
+      (group-n 5
+        (or ical:x-name ical:iana-token)))
+  :default "INDIVIDUAL"
+  ;; "Applications MUST treat x-name and iana-token values they
+  ;; don't recognize the same way as they would the UNKNOWN
+  ;; value":
+  :unrecognized "UNKNOWN"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.3")
+
+(ical:define-param ical:delfromparam "DELEGATED-FROM"
+  "Delegators.
+
+This is a comma-separated list of quoted `icalendar-cal-address' URIs,
+typically specified on the `icalendar-attendee' property. The users in
+this list have delegated their participation to the user which is
+the value of the property."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.4")
+
+(ical:define-param ical:deltoparam "DELEGATED-TO"
+  "Delegatees.
+
+This is a comma-separated list of quoted `icalendar-cal-address' URIs,
+typically specified on the `icalendar-attendee' property. The users in
+this list have been delegated to participate by the user which is
+the value of the property."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.5")
+
+(ical:define-param ical:dirparam "DIR"
+  "Directory Entry Reference.
+
+This parameter may be specified on properties with a
+`icalendar-cal-address' value type. It is a quoted URI which specifies
+a reference to a directory entry associated with the calendar
+user which is the value of the property."
+   ical:uri
+   :quoted t
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.6")
+
+(ical:define-param ical:encodingparam "ENCODING"
+  "Inline Encoding, either \"8BIT\" (text, default) or \"BASE64\" (binary).
+
+If \"BASE64\", the property value is base64-encoded binary data.
+This parameter must be specified if the `icalendar-valuetypeparam'
+is \"BINARY\"."
+  (or "8BIT" "BASE64")
+  :default "8BIT"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.7")
+
+(rx-define ical:mimetype
+  (seq ical:mimetype-regname "/" ical:mimetype-regname))
+
+;; from https://www.rfc-editor.org/rfc/rfc4288#section-4.2:
+(rx-define ical:mimetype-regname
+  (** 1 127 (any alnum ?! ?# ?$ ?& ?. ?+ ?- ?^ ?_)))
+
+(ical:define-param ical:fmttypeparam "FMTTYPE"
+  "Format Type (Mimetype per RFC4288)
+
+Specifies the media type of the object referenced in the property value,
+for example \"text/plain\" or \"text/html\".
+Valid media types are defined in RFC4288; see
+URL `https://www.rfc-editor.org/rfc/rfc4288#section-4.2'"
+  ical:mimetype
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.8")
+
+(ical:define-param ical:fbtypeparam "FBTYPE"
+  "Free/Busy Time Type. Default is \"BUSY\".
+
+RFC5545 gives the following meanings to the values:
+
+FREE: the time interval is free for scheduling.
+BUSY: the time interval is busy because one or more events have
+  been scheduled for that interval.
+BUSY-UNAVAILABLE: the time interval is busy and that the interval
+  can not be scheduled.
+BUSY-TENTATIVE: the time interval is busy because one or more
+  events have been tentatively scheduled for that interval.
+Other values are treated like BUSY."
+  (or "FREE"
+      "BUSY-UNAVAILABLE"
+      "BUSY-TENTATIVE"
+      "BUSY"
+      ical:x-name
+      ical:iana-token)
+  :default "BUSY"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.9")
+
+;; TODO: see https://www.rfc-editor.org/rfc/rfc5646#section-2.1
+(rx-define ical:rfc5646-lang
+  (one-or-more (any alnum ?-)))
+
+(ical:define-param ical:languageparam "LANGUAGE"
+  "Language tag (per RFC5646)
+
+This parameter specifies the language of the property value as a
+language tag, for example \"en-US\" for US English or \"no\" for
+Norwegian. Valid language tags are defined in RFC5646; see
+URL `https://www.rfc-editor.org/rfc/rfc5646'"
+  ical:rfc5646-lang
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.10")
+
+(ical:define-param ical:memberparam "MEMBER"
+  "Group or List Membership.
+
+This is a comma-separated list of quoted `icalendar-cal-address'
+values. These are addresses of groups or lists of which the user
+in the property value is a member."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.11")
+
+(ical:define-param ical:partstatparam "PARTSTAT"
+  "Participation status.
+
+The value specifies the participation status of the calendar user
+in the property value. They have different interpretations
+depending on whether they occur in a VEVENT, VTODO or VJOURNAL
+component. RFC5545 gives the values the following meanings:
+
+NEEDS-ACTION (all): needs action by the user
+ACCEPTED (all): accepted by the user
+DECLINED (all): declined by the user
+TENTATIVE (VEVENT, VTODO): tentatively accepted by the user
+DELEGATED (VEVENT, VTODO): delegated by the user
+COMPLETED (VTODO): completed at the `icalendar-date-time' in the
+  VTODO's `icalendar-completed' property
+IN-PROCESS (VTODO): in the process of being completed"
+  (or "NEEDS-ACTION"
+      "ACCEPTED"
+      "DECLINED"
+      "TENTATIVE"
+      "DELEGATED"
+      "COMPLETED"
+      "IN-PROCESS"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values
+  ;; they don't recognize the same way as they would the
+  ;; NEEDS-ACTION value."
+  :default "NEEDS-ACTION"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.12")
+
+(ical:define-param ical:rangeparam "RANGE"
+  "Recurrence Identifier Range.
+
+Specifies the effective range of recurrence instances of the property's value.
+The value \"THISANDFUTURE\" is the only value compliant with RFC5545;
+legacy applications might also produce \"THISANDPRIOR\"."
+  "THISANDFUTURE"
+  :default "THISANDFUTURE"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.13")
+
+(ical:define-param ical:trigrelparam "RELATED"
+  "Alarm Trigger Relationship.
+
+This parameter may be specified on properties whose values give
+an alarm trigger as an `icalendar-duration'. If the parameter
+value is \"START\" (the default), the alarm triggers relative to
+the start of the component; similarly for \"END\"."
+  (or "START" "END")
+  :default "START"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.14")
+
+(ical:define-param ical:reltypeparam "RELTYPE"
+  "Relationship type.
+
+This parameter specifies a hierarchical relationship between the
+calendar component referenced in a `icalendar-related-to'
+property and the calendar component in which it occurs.
+\"PARENT\" means the referenced component is superior to this
+one, \"CHILD\" that the referenced component is subordinate to
+this one, and \"SIBLING\" means they are peers."
+  (or "PARENT"
+      "CHILD"
+      "SIBLING"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values they don't
+  ;; recognize the same way as they would the PARENT value."
+  :default "PARENT"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.15")
+
+(ical:define-param ical:roleparam "ROLE"
+  "Participation role.
+
+This parameter specifies the participation role of the calendar
+user in the property value. RFC5545 gives the parameter values
+the following meanings:
+CHAIR: chair of the calendar entity
+REQ-PARTICIPANT (default): user's participation is required
+OPT-PARTICIPANT: user's participation is optional
+NON-PARTICIPANT: user is copied for information purposes only"
+  (or "CHAIR"
+      "REQ-PARTICIPANT"
+      "OPT-PARTICIPANT"
+      "NON-PARTICIPANT"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values
+  ;; they don't recognize the same way as they would the
+  ;; REQ-PARTICIPANT value."
+  :default "REQ-PARTICIPANT"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.16")
+
+(ical:define-param ical:rsvpparam "RSVP"
+  "RSVP expectation.
+
+This parameter is an `icalendar-boolean' which specifies whether
+the calendar user in the property value is expected to reply to
+the Organizer of a VEVENT or VTODO."
+  ical:boolean
+  :default "FALSE"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.17")
+
+(ical:define-param ical:sentbyparam "SENT-BY"
+  "Sent by.
+
+This parameter specifies a calendar user that is acting on behalf
+of the user in the property value."
+  ;; "The parameter value MUST be a mailto URI as defined in [RFC2368]"
+  ;; Weirdly, this is the only place in the standard I've seen "mailto:"
+  ;; be *required* for a cal-address. We ignore this requirement for
+  ;; now, because coding around the exception is not worth it: it
+  ;; requires some hackery to work around the fact that two different
+  ;; types, the looser and the more stringent cal-address, would need to
+  ;; have the same print name.
+  ical:cal-address
+  :quoted t
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.18")
+
+(ical:define-param ical:tzidparam "TZID"
+  "Time Zone identifier.
+
+This parameter identifies the VTIMEZONE component in the calendar
+which should be used to interpret the time value given in the
+property. The value of this parameter must be equal to the value
+of the TZID property in that VTIMEZONE component; there must be
+exactly one such component for every unique value of this
+parameter in the calendar."
+  ;; TODO: "This parameter MUST be specified on the "DTSTART","DTEND",
+  ;; "DUE", "EXDATE", and "RDATE" properties when either a DATE-TIME
+  ;; or TIME value type is specified and when the value is neither a
+  ;; UTC or a "floating" time."
+  ;; TODO: "The "TZID" property parameter MUST NOT be applied to DATE
+  ;; properties and DATE-TIME or TIME properties whose time values are
+  ;; specified in UTC."
+  (seq (zero-or-one "/") ical:paramtext)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.19")
+
+(defun ical:read-value-type (s)
+  "Read a value type from string S.
+S should contain the printed representation of a value type in a \"VALUE=...\"
+property parameter. If S represents a known type in `icalendar-value-types',
+it is read as the associated type symbol. Otherwise S is returned unchanged."
+  (let ((type-assoc (assoc s ical:value-types)))
+    (if type-assoc
+        (cdr type-assoc)
+      s)))
+
+(defun ical:print-value-type (type)
+  "Print a value type TYPE.
+TYPE should be an iCalendar type symbol naming a known value type
+defined with `icalendar-define-type', or a string naming an
+unknown type. If it is a symbol, return the associated printed
+representation for the type from `icalendar-value-types'.
+Otherwise return TYPE."
+  (if (symbolp type)
+      (car (rassq type ical:value-types))
+    type))
+
+(ical:define-type ical:printed-value-type nil
+  "Type to represent values of the `icalendar-valuetypeparam' parameter.
+
+When read, if the type named by the parameter is a known value
+type in `icalendar-value-types', it is represented as a type
+symbol for that value type. If it is an unknown value type, it is
+represented as a string. When printed, a string is returned
+unchanged; a type symbol is printed as the associated name in
+`icalendar-value-types'.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing of the `icalendar-valuetypeparam' parameter."
+  '(or string (satisfies ical:printable-value-type-symbol-p))
+  (or "BINARY"
+      "BOOLEAN"
+      "CAL-ADDRESS"
+      "DATE-TIME"
+      "DATE"
+      "DURATION"
+      "FLOAT"
+      "INTEGER"
+      "PERIOD"
+      "RECUR"
+      "TEXT"
+      "TIME"
+      "URI"
+      "UTC-OFFSET"
+      ;; Note: "Applications MUST preserve the value data for x-name
+      ;; and iana-token values that they don't recognize without
+      ;; attempting to interpret or parse the value data." So in this
+      ;; case we don't specify :default or :unrecognized in the
+      ;; parameter definition, and we don't put the value in group 5;
+      ;; the reader will just preserve whatever string matches here.
+      ical:x-name
+      ical:iana-token)
+  :reader ical:read-value-type
+  :printer ical:print-value-type)
+
+(ical:define-param ical:valuetypeparam "VALUE"
+  "Property value data type.
+
+This parameter is used to specify the value type of the
+containing property's value, if it is not of the default value
+type."
+  ical:printed-value-type
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.20")
+
+(ical:define-param ical:otherparam nil ; don't add to ical:param-types
+  "Parameter with an unknown name.
+
+This is not a parameter type defined by RFC5545; it represents
+parameters with an unknown name (matching rx `icalendar-param-name')
+whose values must be parsed and preserved but not further
+interpreted."
+  ical:param-value)
+
+(rx-define ical:other-param-safe
+  ;; we use this rx to skip params when matching properties and
+  ;; their values. Thus we *don't* capture the param names and param values
+  ;; in numbered groups here, which would clobber the groups of the enclosing
+  ;; expression.
+  (seq ";"
+       (or ical:iana-token ical:x-name)
+       "="
+       (ical:comma-list ical:param-value)))
+
+
+;;; Properties:
+
+(defconst ical:property-types nil ;; populated by ical:define-property
+  "Alist mapping printed property names to type symbols")
+
+(defun ical:read-property-value (type s &optional params)
+  "Read a value for the property type TYPE from a string S.
+
+TYPE should be a type symbol for an iCalendar property type
+defined with `icalendar-define-property'. The property value is
+assumed to be of TYPE's default value type, unless an
+`icalendar-valuetypeparam' parameter appears in PARAMS, in which
+case a value of that type will be read. S should have already
+been matched against TYPE's value regex and the match data should
+be available to this function. Returns a property syntax node of
+type TYPE containing the read value and the list of PARAMS.
+
+If TYPE accepts lists of values, they will be split from S on the
+list separator and read separately."
+  (let* ((value-type (or (ical:value-type-from-params params)
+                         (get type 'ical:default-type)))
+         (list-sep (get type 'ical:list-sep))
+         (unrecognized-val (match-string 5))
+         (raw-val (if unrecognized-val
+                      (get type 'ical:substitute-value)
+                    s))
+         (value (if list-sep
+                    (ical:read-list-of value-type raw-val list-sep)
+                  (ical:read-value-node value-type raw-val))))
+    (ical:make-ast-node type
+                        (list :value value
+                              :original-value unrecognized-val)
+                        params)))
+
+(defun ical:parse-property-value (type limit &optional params)
+  "Parse a value for the property type TYPE from point up to LIMIT.
+This function expects point to be at the start of the value
+expression, after \"PROPERTY-NAME[PARAM...]:\". Returns a syntax
+node of type TYPE containing the parsed value and the list of
+PARAMS."
+  (let ((full-value-regex (rx-to-string (get type 'ical:full-value-rx))))
+
+    (unless (re-search-forward full-value-regex limit t)
+      (signal 'ical:parse-error
+              (list (format "Unable to parse `%s' property value between %d and %d"
+                            type (point) limit))))
+
+    (when (match-string 3)
+      (signal 'ical:parse-error
+              (list (format "Invalid value for `%s' property" type)
+                    (match-string 3))))
+
+    (let* ((value-begin (match-beginning 2))
+           (value-end (match-end 2))
+           (end value-end)
+           (node (ical:read-property-value type (match-string 2) params)))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      ;; 'begin must be set by parse-property
+      (ical:ast-node-meta-set node :value-begin value-begin)
+      (ical:ast-node-meta-set node :value-end value-end)
+      (ical:ast-node-meta-set node :end end)
+
+      node)))
+
+(defun ical:print-property-node (node)
+  "Serialize a property syntax node NODE to a string."
+  (ical:maybe-add-value-param node)
+  (let* ((type (ical:ast-node-type node))
+         (list-sep (get type 'ical:list-sep))
+         (property-name (car (rassq type ical:property-types)))
+         (params (ical:ast-node-children node))
+         (value (ical:ast-node-value node))
+         (value-str
+          (or (ical:ast-node-meta-get :original-value node)
+              (if list-sep
+                  (string-join (mapcar #'ical:default-value-printer value)
+                               list-sep)
+                (ical:default-value-printer value))))
+         (name-str (or property-name
+                       (ical:ast-node-meta-get :original-name node))))
+
+    (unless (and (stringp name-str)
+                 (length> name-str 0))
+      (signal 'ical:print-error
+              (list (format "Unknown property name for type `%s'" type)
+                    type node)))
+
+    (concat name-str
+            (ical:print-params params)
+            ":"
+            value-str
+            ;; TODO: make line ending sensitive to coding system?
+            "\r\n")))
+
+(defun ical:maybe-add-value-param (property-node)
+  "If the type of PROPERTY-NODE's value is not the same as its
+default-type, check that its parameter list contains an
+`icalendar-valuetypeparam' specifying that type as the type for
+the value. If not, add such a parameter to PROPERTY-NODE's list
+of parameters. Returns the possibly-modified PROPERTY-NODE.
+
+If the parameter list already contains a value type parameter for
+a type other than the property value's type, an
+`icalendar-validation-error' is signaled.
+
+If PROPERTY's value is a list, the type of the first element will
+be assumed to be the type for all the values in the list. If the
+list is empty, no change will be made to PROPERTY's parameters."
+  (catch 'no-value-type
+    (let* ((property-type (ical:ast-node-type property-node))
+           (value/s (ical:ast-node-value property-node))
+           (value (if (and (ical:expects-list-of-values-p property-type)
+                           (listp value/s))
+                      (car value/s)
+                    value/s))
+           (value-type (cond ((stringp value) 'ical:text)
+                             ((ical:ast-node-p value)
+                              (ical:ast-node-type value))
+                             ;; if we can't determine a type from the value, bail:
+                             (t (throw 'no-value-type property-node))))
+           (params (ical:ast-node-children property-node))
+           (expected-type (ical:value-type-from-params params)))
+
+      (when (not (eq value-type (get property-type 'ical:default-type)))
+        (if expected-type
+            (when (not (eq value-type expected-type))
+              (signal 'ical:validation-error
+                      (list (format (concat "Mismatching VALUE parameter. "
+                                            "VALUE specifies %s but "
+                                            "property value has type %s")
+                                    expected-type value-type))))
+          ;; the value isn't of the default type, but we didn't find a
+          ;; VALUE parameter, so add one now:
+          (let* ((valuetype-param
+                  (ical:make-ast-node 'ical:valuetypeparam
+                                      (list :value (ical:make-ast-node
+                                                    'ical:printed-value-type
+                                                    (list :value value-type)))))
+                 (new-params (cons valuetype-param
+                                   (ical:ast-node-children property-node))))
+            (ical:ast-node-set-children property-node new-params))))
+
+      ;; Return the modified property node:
+      property-node)))
+
+(defun ical:value-type-from-params (params)
+  "If there is an `icalendar-valuetypeparam' in PARAMS, return the
+type symbol associated with the value type it specifies."
+  (catch 'found
+    (dolist (param params)
+      (when (ical:value-param-p param)
+        (let ((type (ical:ast-node-value
+                     (ical:ast-node-value param))))
+          (throw 'found type))))))
+
+(defun ical:parse-property (limit)
+  "Parse the current property, up to LIMIT. Point should be at the
+beginning of a property line; LIMIT should be the position at the
+end of the line.
+
+Returns a syntax node for the property. After parsing, point is
+at the beginning of the next content line."
+  (rx-let ((ical:property-start (seq line-start
+                                     (group-n 1 ical:name))))
+    (let ((line-begin nil)
+          (line-end nil)
+          (property-name nil)
+          (params nil))
+
+      ;; Property name
+      (unless (re-search-forward (rx ical:property-start) limit t)
+        (signal 'ical:parse-error
+                (list (format (concat "Malformed property at line %d, position %d:"
+                                      "could not match property name")
+                              (line-number-at-pos (point))
+                              (line-beginning-position)))))
+
+      (setq property-name (match-string 1))
+      (setq line-begin (line-beginning-position))
+      (setq line-end (line-end-position))
+
+      ;; Parameters
+      (when (looking-at ";")
+        (setq params (ical:parse-params line-end)))
+
+      (unless (looking-at ":")
+        (signal 'ical:parse-error
+                (list (format (concat "Malformed property at line %d, position %d:"
+                                      "missing colon before value")
+                              (line-number-at-pos (point))
+                              (point)))))
+      (forward-char)
+
+      ;; Value
+      (let* ((known-type (alist-get (upcase property-name)
+                                    ical:property-types
+                                    nil nil #'equal))
+             (property-type (or known-type 'ical:other-property))
+             (node (ical:parse-property-value property-type limit params)))
+
+        ;; sanity check, since e.g. invalid base64 data might not
+        ;; match all the way to the end of the line, as test
+        ;; rfc5545-sec3.1.3/2 initially revealed
+        (unless (eql (point) (line-end-position))
+          (signal 'ical:parse-error
+                  (list (format "Property value did not consume line %d: %s"
+                                (line-number-at-pos (point))
+                                (ical:default-value-printer
+                                 (ical:ast-node-value node))))))
+
+        ;; Set point up for the next property parser:
+        (while (not (bolp))
+          (forward-char))
+
+        ;; value, children are set in ical:read-property-value,
+        ;; value-begin, value-end, end in ical:parse-property-value.
+        ;; begin and original-name are only available here:
+        (ical:ast-node-meta-set node :begin line-begin)
+        (when (eq property-type 'ical:other-property)
+          (ical:ast-node-meta-set node :original-name property-name))
+
+        ;; Return the syntax node
+        node))))
+
+
+;;;; Section 3.7: Calendar Properties
+(ical:define-property ical:calscale "CALSCALE"
+  "Calendar scale.
+
+This property specifies the time scale of an
+`icalendar-vcalendar' object. The only scale defined by RFC5545
+is \"GREGORIAN\", which is the default."
+  ;; only allowed value:
+  "GREGORIAN"
+  :default "GREGORIAN"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.1")
+
+(ical:define-property ical:method "METHOD"
+  "Method for a scheduling request.
+
+When an `icalendar-vcalendar' is sent in a MIME message, this property
+specifies the semantics of the request in the message: e.g. it is
+a request to publish the calendar object, or a reply to an
+invitation. This property and the MIME message's \"method\"
+parameter value must be the same.
+
+RFC5545 does not define any methods, but RFC5546 does; see
+URL `https://www.rfc-editor.org/rfc/rfc5546.html#section-3.2'"
+  ;; TODO: implement methods in RFC5546?
+  ical:iana-token
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.2")
+
+(ical:define-property ical:prodid "PRODID"
+  "Product Identifier.
+
+This property identifies the program that created an
+`icalendar-vcalendar' object. It must be specified exactly once
+in a calendar object. Its value should be a globally unique
+identifier for the program, though RFC5545 does not specify any
+particular way of creating such an identifier."
+  ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.3")
+
+(ical:define-property ical:version "VERSION"
+  "Version (2.0 corresponds to RFC5545).
+
+This property specifies the version number of the iCalendar
+specification to which an `icalendar-vcalendar' object conforms,
+and must be specified exactly once in a calendar object. It is
+either the string \"2.0\" or a string like MIN;MAX specifying
+minimum and maximum versions of future revisions of the
+specification."
+  (or "2.0"
+      ;; minver ";" maxver
+      (seq ical:iana-token ?\; ical:iana-token))
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.4")
+
+
+;;;; Section 3.8:
+;;;;; Section 3.8.1: Descriptive Component Properties
+
+(ical:define-property ical:attach "ATTACH"
+  "Attachment.
+
+This property specifies a file attached to an iCalendar
+component, either via a URI, or as encoded binary data. In
+`icalendar-valarm' components, it is used to specify the
+notification sent by the alarm."
+  ;; Groups 11, 12 are used in ical:uri
+  (or (group-n 13 ical:uri)
+      (group-n 14 ical:binary))
+  :default-type ical:uri
+  :other-types (ical:binary)
+  :child-spec (:zero-or-one (ical:fmttypeparam
+                             ical:valuetypeparam
+                             ical:encodingparam)
+               :zero-or-more (ical:otherparam))
+  :other-validator ical:attach-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.1")
+
+(defun ical:attach-validator (node)
+  "Additional validator for an `icalendar-attach' NODE.
+Checks that NODE has a correct `icalendar-encodingparam' and
+`icalendar-valuetypeparam' if its value is an `icalendar-binary'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+ATTACH nodes; it is not normally necessary to call it directly."
+  (let* ((value-node (ical:ast-node-value node))
+         (value-type (ical:ast-node-type value-node))
+         (valtypeparam (ical:ast-node-first-child-of 'ical:valuetypeparam node))
+         (encodingparam (ical:ast-node-first-child-of 'ical:encodingparam node)))
+
+    (when (eq value-type 'ical:binary)
+      (unless (and (ical:ast-node-p valtypeparam)
+                   (eq 'ical:binary
+                       (ical:ast-node-value ; unwrap inner printed-value-type
+                        (ical:ast-node-value valtypeparam))))
+        (signal 'ical:validation-error
+                (list (concat "`icalendar-binary' attachment requires "
+                              "'VALUE=BINARY' parameter")
+                      node)))
+      (unless (and (ical:ast-node-p encodingparam)
+                   (equal "BASE64" (ical:ast-node-value encodingparam)))
+        (signal 'ical:validation-error
+                (list (concat "`icalendar-binary' attachment requires "
+                              "'ENCODING=BASE64' parameter")
+                      node))))
+    ;; success:
+    node))
+
+(ical:define-property ical:categories "CATEGORIES"
+  "Categories.
+
+This property lists categories or subtypes of an iCalendar
+component for e.g. searching or filtering. The categories can be
+any `icalendar-text' value."
+  ical:text
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.2")
+
+(ical:define-property ical:class "CLASS"
+  "(Access) Classification.
+
+This property specifies the scope of access that the calendar
+owner intends for a given component, e.g. public or private."
+  (or "PUBLIC"
+      "PRIVATE"
+      "CONFIDENTIAL"
+      (group-n 5
+        (or ical:iana-token
+            ical:x-name)))
+  ;; "If not specified in a component that allows this property, the
+  ;; default value is PUBLIC. Applications MUST treat x-name and
+  ;; iana-token values they don't recognize the same way as they would
+  ;; the PRIVATE value."
+  :default "PUBLIC"
+  :unrecognized "PRIVATE"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.3")
+
+(ical:define-property ical:comment "COMMENT"
+  "Comment to calendar user.
+
+This property can be specified multiple times in calendar components,
+and can contain any `icalendar-text' value."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.4")
+
+(ical:define-property ical:description "DESCRIPTION"
+  "Description.
+
+This property should be a longer, more complete description of
+the calendar component than is contained in the
+`icalendar-summary' property. In a `icalendar-vjournal'
+component, it is used to capture a journal entry, and may be
+specified multiple times. Otherwise it may only be specified
+once. In an `icalendar-valarm' component, it contains the
+notification text for a DISPLAY or EMAIL alarm."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.5")
+
+(defun ical:read-geo-coordinates (s)
+  "Read an `icalendar-geo-coordinates' value from string S"
+  (let ((vals (mapcar #'string-to-number (string-split s ";"))))
+    (cons (car vals) (cadr vals))))
+
+(defun ical:print-geo-coordinates (val)
+  "Serialize an `icalendar-geo-coordinates' value to a string"
+  (concat (number-to-string (car val)) ";" (number-to-string (cdr val))))
+
+(defun ical:geo-coordinates-p (val)
+  "Return non-nil if VAL is an `icalendar-geo-coordinates' value"
+  (and (floatp (car val)) (floatp (cdr val))))
+
+(ical:define-type ical:geo-coordinates nil ; don't add to ical:value-types
+  "Type for global positions.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing the `icalendar-geo' property. When printed, it
+is represented as a pair of `icalendar-float' values separated by
+a semicolon, like LATITUDE;LONGITUDE. When read, it is a dotted
+pair of Elisp floats (LATITUDE . LONGITUDE)."
+  '(satisfies ical:geo-coordinates-p)
+  (seq ical:float ";" ical:float)
+  :reader ical:read-geo-coordinates
+  :printer ical:print-geo-coordinates)
+
+(ical:define-property ical:geo "GEO"
+  "Global position of a component as a pair LATITUDE;LONGITUDE.
+
+Both values are floats representing a number of degrees. The
+latitude value is north of the equator if positive, and south of
+the equator if negative. The longitude value is east of the prime
+meridian if positive, and west of it if negative."
+  ical:geo-coordinates
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.6")
+
+(ical:define-property ical:location "LOCATION"
+  "Location.
+
+This property describes the intended location or venue of a
+component, e.g. a particular room or building, with an
+`icalendar-text' value. RFC5545 suggests using the
+`icalendar-altrep' parameter on this property to provide more
+structured location information."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.7")
+
+;; TODO: type for percentages?
+(ical:define-property ical:percent-complete "PERCENT-COMPLETE"
+  "Percent Complete.
+
+This property describes progress toward the completion of an
+`icalendar-vtodo' component. It can appear at most once in such a
+component. If this TODO is assigned to multiple people, the value
+represents the completion state for each person individually. The
+value should be between 0 and 100 (though this is not currently
+enforced here)."
+  ical:integer
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.8")
+
+;; TODO: type for priority values?
+(ical:define-property ical:priority "PRIORITY"
+  "Priority.
+
+This property describes the priority of a component. 0 means an
+undefined priority. Other values range from 1 (highest priority)
+to 9 (lowest priority). See RFC5545 for suggestions on how to
+represent other priority schemes with this property."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.9")
+
+(ical:define-property ical:resources "RESOURCES"
+  "Resources for an activity.
+
+This property is a list of `icalendar-text' values that describe
+any resources required or foreseen for the activity represented
+by a component, e.g. a projector and screen for a meeting."
+  ical:text
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.10")
+
+(ical:define-type ical:status-keyword nil
+  "Keyword value of a STATUS property.
+
+This is not a real type defined by RFC5545; it is defined here to
+facilitate parsing that property."
+  '(and string (satisfies ical:match-status-keyword-value))
+  ;; Note that this type does NOT allow arbitrary text:
+  (or "TENTATIVE"
+      "CONFIRMED"
+      "CANCELLED"
+      "NEEDS-ACTION"
+      "COMPLETED"
+      "IN-PROCESS"
+      "DRAFT"
+      "FINAL"))
+
+(ical:define-property ical:status "STATUS"
+  "Overall status or confirmation.
+
+This property is a keyword used by an Organizer to inform
+Attendees about the status of a component, e.g. whether an
+`icalendar-vevent' has been cancelled, whether an
+`icalendar-vtodo' has been completed, or whether an
+`icalendar-vjournal' is still in draft form. It can be specified
+at most once on these components."
+  ical:status-keyword
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.11")
+
+(ical:define-property ical:summary "SUMMARY"
+  "Short summary.
+
+This property provides a short, one-line description of a
+component for display purposes. In an EMAIL `icalendar-valarm',
+it is used as the subject of the email. A longer description of
+the component can be provided in the `icalendar-description'
+property."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.12")
+
+;;;;; Section 3.8.2: Date and Time Component Properties
+
+(ical:define-property ical:completed "COMPLETED"
+  "Time completed.
+
+This property is a timestamp that records the date and time when
+an `icalendar-vtodo' was actually completed. The value must be an
+`icalendar-date-time' with a UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.1")
+
+(ical:define-property ical:dtend "DTEND"
+  "End time of an event or free/busy block.
+
+This property's value specifies when an `icalendar-vevent' or
+`icalendar-freebusy' ends. Its value must be of the same type as
+the value of the component's corresponding `icalendar-dtstart'
+property. The value is a non-inclusive bound, i.e., the value of
+this property must be the first time or date *after* the end of
+the event or free/busy block."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.2")
+
+(ical:define-property ical:due "DUE"
+  "Due date.
+
+This property specifies the date (and possibly time) by which an
+`icalendar-todo' item is expected to be completed, i.e., its
+deadline. If the component also has an `icalendar-dtstart'
+property, the two properties must have the same value type, and
+the value of the DTSTART property must be earlier than the value
+of this property."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.3")
+
+(ical:define-property ical:dtstart "DTSTART"
+  "Start time of a component.
+
+This property's value specifies when a component starts. In an
+`icalendar-vevent', it specifies the start of the event. In an
+`icalendar-vfreebusy', it specifies the start of the free/busy
+block. In `icalendar-standard' and `icalendar-daylight'
+sub-components, it defines the start time of a time zone
+specification.
+
+It is required in any component with an `icalendar-rrule'
+property, and in any `icalendar-vevent' component contained in a
+calendar that does not have a `icalendar-method' property.
+
+Its value must be of the same type as the value of the
+component's corresponding `icalendar-dtend' property. In an
+`icalendar-vtodo' component, it must also be of the same type as
+the value of an `icalendar-due' property (if present)."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.4")
+
+(ical:define-property ical:duration "DURATION"
+  "Duration.
+
+This property specifies a duration of time for a component.
+In an `icalendar-vevent', it can be used to implicitly specify
+the end of the event, instead of an explicit `icalendar-dtend'.
+In an `icalendar-vtodo', it can likewise be used to implicitly specify
+the due date, instead of an explicit `icalendar-due'.
+In an `icalendar-valarm', it used to specify the delay period
+before the alarm repeats.
+
+If a related `icalendar-dtstart' property has an `icalendar-date'
+value, then the duration must be given as a number of weeks or days."
+  ical:dur-value
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.5")
+
+(ical:define-property ical:freebusy "FREEBUSY"
+  "Free/Busy Times.
+
+This property specifies a list of periods of free or busy time in
+an `icalendar-vfreebusy' component. Whether it specifies free or
+busy times is determined by its `icalendar-fbtype' parameter. The
+times in each period must be in UTC format."
+  ical:period
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:fbtypeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.6")
+
+(ical:define-property ical:transp "TRANSP"
+  "Time Transparency for free/busy searches.
+
+Note that this property only allows two values: \"TRANSPARENT\"
+or \"OPAQUE\". An OPAQUE value means that the component consumes
+time on a calendar. TRANSPARENT means it does not, and thus is
+invisible to free/busy time searches."
+  ;; Note that this does NOT allow arbitrary text:
+  (or "TRANSPARENT"
+      "OPAQUE")
+  :default "OPAQUE"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.7")
+
+;;;;; Section 3.8.3: Time Zone Component Properties
+
+(ical:define-property ical:tzid "TZID"
+  "Time Zone Identifier.
+
+This property specifies the unique identifier for a timezone in
+an `icalendar-vtimezone' component, and is a required property of
+that component. This is an identifier that `icalendar-tzidparam'
+parameters in other components may then refer to."
+  (seq (zero-or-one "/") ical:text)
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.1")
+
+(ical:define-property ical:tzname "TZNAME"
+  "Time Zone Name.
+
+This property specifies a customary name for a time zone in
+`icalendar-daylight' and `icalendar-standard' sub-components."
+  ical:text
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.2")
+
+(ical:define-property ical:tzoffsetfrom "TZOFFSETFROM"
+  "Time Zone Offset (prior to observance).
+
+This property specifies the time zone offset that is in use
+*prior to* this time zone observance. It is used to calculate the
+absolute time at which the observance takes place. It is a
+required property of an `icalendar-vtimezone' component. Positive
+numbers indicate time east of the prime meridian (ahead of UTC).
+Negative numbers indicate time west of the prime meridian (behind
+UTC)."
+  ical:utc-offset
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.3")
+
+(ical:define-property ical:tzoffsetto "TZOFFSETTO"
+  "Time Zone Offset (in this observance).
+
+This property specifies the time zone offset that is in use *in*
+this time zone observance. It is used to calculate the absolute
+time at which a new observance takes place. It is a required
+property of `icalendar-standard' and `icalendar-daylight'
+components. Positive numbers indicate time east of the prime
+meridian (ahead of UTC). Negative numbers indicate time west of
+the prime meridian (behind UTC)."
+  ical:utc-offset
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.4")
+
+(ical:define-property ical:tzurl "TZURL"
+  "Time Zone URL.
+
+This property specifies a URL where updated versions of an
+`icalendar-vtimezone' component are published."
+  ical:uri
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.5")
+
+;;;;; Section 3.8.4: Relationship Component Properties
+
+(ical:define-property ical:attendee "ATTENDEE"
+  "Attendee.
+
+This property specfies a participant in a `icalendar-vevent',
+`icalendar-vtodo', or `icalendar-valarm'. It is required when the
+containing component represents event, task, or notification for
+a *group* of people, but not for components that simply represent
+these items in a single user's calendar (in that case, it should
+not be specified). The property can be specified multiple times,
+once for each participant in the event or task. In an
+EMAIL-category VALARM component, this property specifies the
+address of the user(s) who should receive the notification email.
+
+The parameters `icalendar-roleparam', `icalendar-partstatparam',
+`icalendar-rsvpparam', `icalendar-delfromparam', and
+`icalendar-deltoparam' are especially relevant for further
+specifying the roles of each participant in the containing
+component."
+  ical:cal-address
+  :child-spec (:zero-or-one (ical:cutypeparam
+                             ical:memberparam
+                             ical:roleparam
+                             ical:partstatparam
+                             ical:rsvpparam
+                             ical:deltoparam
+                             ical:delfromparam
+                             ical:sentbyparam
+                             ical:cnparam
+                             ical:dirparam
+                             ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.1")
+
+(ical:define-property ical:contact "CONTACT"
+  "Contact.
+
+This property provides textual contact information relevant to an
+`icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal', or
+`icalendar-vfreebusy'."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.2")
+
+(ical:define-property ical:organizer "ORGANIZER"
+  "Organizer.
+
+This property specifies the organizer of a group-scheduled
+`icalendar-vevent', `icalendar-vtodo', or `icalendar-vjournal'.
+It is required in those components if they represent a calendar
+entity with multiple participants. In an `icalendar-vfreebusy'
+component, it used to specify the user requesting free or busy
+time, or the user who published the calendar that the free/busy
+information comes from."
+  ical:cal-address
+  :child-spec (:zero-or-one (ical:cnparam
+                             ical:dirparam
+                             ical:sentbyparam
+                             ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.3")
+
+(ical:define-property ical:recurrence-id "RECURRENCE-ID"
+  "Recurrence ID.
+
+This property is used together with the `icalendar-uid' and
+`icalendar-sequence' properties to identify a specific instance
+of a recurring `icalendar-vevent', `icalendar-vtodo', or
+`icalendar-vjournal' component. The property value is the
+original value of the `icalendar-dtstart' property of the
+recurrence instance. Its value must have the same type as that
+property's value, and both must specify times in the same way
+(either local or UTC)."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam
+                             ical:tzidparam
+                             ical:rangeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.4")
+
+(ical:define-property ical:related-to "RELATED-TO"
+  "Related To (component UID).
+
+This property specifies the `icalendar-uid' value of a different,
+related calendar component. It can be specified on an
+`icalendar-vevent', `icalendar-vtodo', or `icalendar-vjournal'
+component. An `icalendar-reltypeparam' can be used to specify the
+relationship type."
+  ical:text
+  :child-spec (:zero-or-one (ical:reltypeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.5")
+
+(ical:define-property ical:url "URL"
+  "Uniform Resource Locator.
+
+This property specifies the URL associated with an
+`icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal', or
+`icalendar-vfreebusy' component."
+  ical:uri
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.6")
+
+;; TODO: UID should probably be its own type
+(ical:define-property ical:uid "UID"
+  "Unique Identifier.
+
+This property specifies a globally unique identifier for the
+containing component, and is required in an `icalendar-vevent',
+`icalendar-vtodo', `icalendar-vjournal', or `icalendar-vfreebusy'
+component.
+
+RFC5545 requires that the program generating the UID guarantee
+that it be unique, and recommends generating it in a format which
+includes a timestamp on the left hand side of an '@' character,
+and the domain name or IP address of the host on the right-hand
+side."
+  ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.7")
+
+;;;;; Section 3.8.5: Recurrence Component Properties
+
+(ical:define-property ical:exdate "EXDATE"
+  "Exception Date-Times.
+
+This property defines a list of exceptions to a recurrence rule
+in an `icalendar-vevent', `icalendar-todo', `icalendar-vjournal',
+`icalendar-standard', or `icalendar-daylight' component. Together
+with the `icalendar-dtstart', `icalendar-rrule', and
+`icalendar-rdate' properties, it defines the recurrence set of
+the component."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.1")
+
+(ical:define-property ical:rdate "RDATE"
+  "Recurrence Date-Times.
+
+This property defines a list of date-times or dates on which an
+`icalendar-vevent', `icalendar-todo', `icalendar-vjournal',
+`icalendar-standard', or `icalendar-daylight' component recurs.
+Together with the `icalendar-dtstart', `icalendar-rrule', and
+`icalendar-exdate' properties, it defines the recurrence set of
+the component."
+  (or ical:period
+      ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date ical:period)
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.2")
+
+(ical:define-property ical:rrule "RRULE"
+  "Recurrence Rule.
+
+This property defines a rule or repeating pattern for the dates
+and times on which an `icalendar-vevent', `icalendar-todo',
+`icalendar-vjournal', `icalendar-standard', or
+`icalendar-daylight' component recurs. Together with the
+`icalendar-dtstart', `icalendar-rdate', and `icalendar-exdate'
+properties, it defines the recurrence set of the component."
+  ical:recur
+  ;; TODO: faces for subexpressions?
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.3")
+
+;;;;; Section 3.8.6: Alarm Component Properties
+
+(ical:define-property ical:action "ACTION"
+  "Action (when alarm triggered).
+
+This property defines the action to be taken when the containing
+`icalendar-valarm' component is triggered. It is a required
+property in an alarm component."
+  (or "AUDIO"
+      "DISPLAY"
+      "EMAIL"
+      (group-n 5
+        (or ical:iana-token
+            ical:x-name)))
+  :default-type ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.1")
+
+(ical:define-property ical:repeat "REPEAT"
+  "Repeat Count (after initial trigger).
+
+This property specifies the number of times an `icalendar-valarm'
+should repeat after it is initially triggered. This property,
+along with the `icalendar-duration' property, is required if the
+alarm triggers more than once."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.2")
+
+(ical:define-property ical:trigger "TRIGGER"
+  "Trigger.
+
+This property specifies when an `icalendar-valarm' should
+trigger. If the value is an `icalendar-dur-value', it represents
+a time of that duration relative to the start or end of a related
+`icalendar-vevent' or `icalendar-vtodo'. Whether the trigger
+applies to the start time or end time of the related component
+can be specified with the `icalendar-trigrelparam' parameter. A
+positive duration value triggers after the start or end of the
+related component; a negative duration value triggers before.
+
+If the value is an `icalendar-date-time', it must be in UTC
+format, and it triggers at the specified time."
+  (or ical:dur-value
+      ical:date-time)
+  :default-type ical:dur-value
+  :other-types (ical:date-time)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:trigrelparam)
+               :zero-or-more (ical:otherparam))
+  :other-validator ical:trigger-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.3")
+
+(defun ical:trigger-validator (node)
+  "Additional validator for an `icalendar-trigger' NODE.
+Checks that NODE has valid parameters depending on the type of its value.
+
+This function is called by `icalendar-ast-node-valid-p' for
+TRIGGER nodes; it is not normally necessary to call it directly."
+  (let* ((params (ical:ast-node-children node))
+         (value-node (ical:ast-node-value node))
+         (value-type (and value-node (ical:ast-node-type value-node))))
+    (when (eq value-type 'ical:date-time)
+      (let ((expl-type (ical:value-type-from-params params))
+            (dt-value (ical:ast-node-value value-node)))
+        (unless (eq expl-type 'ical:date-time)
+          (signal 'ical:validation-error
+                  (list (concat "Explicit `icalendar-valuetypeparam' required in "
+                                "`icalendar-trigger' with non-duration value")
+                        node)))
+        (when (ical:ast-node-first-child-of 'ical:trigrelparam node)
+          (signal 'ical:validation-error
+                  (list (concat "`icalendar-trigrelparam' not allowed in "
+                                "`icalendar-trigger' with non-duration value"))))
+        (unless (ical:date-time-is-utc-p dt-value)
+          (signal 'ical:validation-error
+                  (list (concat "`icalendar-date-time' value of "
+                                "`icalendar-trigger' must be in UTC time")
+                        node)))))
+    ;; success:
+    node))
+
+;;;;; Section 3.8.7: Change Management Component Properties
+
+(ical:define-property ical:created "CREATED"
+  "Date-Time Created.
+
+This property specifies the date and time when the calendar user
+initially created an `icalendar-vevent', `icalendar-vtodo', or
+`icalendar-vjournal' in the calendar database. The value must be
+in UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.1")
+
+(ical:define-property ical:dtstamp "DTSTAMP"
+  "Timestamp (of last revision or instance creation).
+
+In an `icalendar-vevent', `icalendar-vtodo',
+`icalendar-vjournal', or `icalendar-vfreebusy', this property
+specifies the date and time when the calendar user last revised
+the component's data in the calendar database. (In this case, it
+is equivalent to the `icalendar-last-modified' property.)
+
+If this property is specified on an `icalendar-vcalendar' object
+which contains an `icalendar-method' property, it specifies the
+date and time when that instance of the calendar object was
+created. In this case, it differs from the `icalendar-creation'
+and `icalendar-last-modified' properties: whereas those specify
+the time the underlying data was created and last modified in the
+calendar database, this property specifies when the calendar
+object *representing* that data was created.
+
+The value must be in UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.2")
+
+(ical:define-property ical:last-modified "LAST-MODIFIED"
+  "Last Modified timestamp.
+
+This property specifies when the data in an `icalendar-vevent',
+`icalendar-vtodo', `icalendar-vjournal', or `icalendar-vtimezone'
+was last modified in the calendar database."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.3")
+
+(ical:define-property ical:sequence "SEQUENCE"
+  "Revision Sequence Number.
+
+This property specifies the number of the current revision in a
+sequence of revisions in an `icalendar-vevent',
+`icalendar-vtodo', or `icalendar-vjournal' component. It starts
+at 0 and should be incremented monotonically every time the
+Organizer makes a significant revision to the calendar data that
+component represents."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.4")
+
+;;;;; Section 3.8.8: Miscellaneous Component Properties
+;; IANA and X- properties should be parsed and printed but can be ignored:
+(ical:define-property ical:other-property nil ; don't add to ical:property-types
+  "IANA or X-name property.
+
+This property type corresponds to the IANA Properties and
+Non-Standard Properties defined in RFC5545; it represents
+properties with an unknown name (matching rx
+`icalendar-iana-token' or `icalendar-x-name') whose values must
+be parsed and preserved but not further interpreted. Its value
+may be set to any type with the `icalendar-valuetypeparam'
+parameter."
+  ical:value
+  :default-type ical:text
+  ;; "The default value type is TEXT. The value type can be set to any
+  ;; value type." TODO: should we specify :other-types? Without it, a
+  ;; VALUE param will be required to parse anything other than text,
+  ;; but that seems reasonable.
+  :child-spec (:allow-others t)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8")
+
+(defun ical:read-req-status-info (s)
+  "Read a request status value from S.
+S should have been previously matched against `icalendar-request-status-info'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((code (match-string 11))
+        (desc (match-string 12))
+        (exdata (match-string 13)))
+    (list code (ical:read-text desc) (when exdata (ical:read-text exdata)))))
+
+(defun ical:print-req-status-info (rsi)
+  "Serialize request status info value RSI to a string."
+  (let ((code (car rsi))
+        (desc (cadr rsi))
+        (exdata (caddr rsi)))
+    (if exdata
+        (format "%s;%s;%s" code (ical:print-text desc) (ical:print-text exdata))
+      (format "%s;%s" code (ical:print-text desc)))))
+
+(defun ical:req-status-info-p (val)
+  "Return non-nil if VAL is an `icalendar-request-status-info' value."
+  (and (listp val)
+       (length= val 3)
+       (stringp (car val))
+       (stringp (cadr val))
+       (cl-typep (caddr val) '(or string null))))
+
+(ical:define-type ical:req-status-info nil
+  "Type for REQUEST-STATUS property values.
+
+When read, a list (CODE DESCRIPTION EXCEPTION). CODE is a hierarchical
+numerical code, represented as a string, with the following meanings:
+  1.xx Preliminary success
+  2.xx Successful
+  3.xx Client Error
+  4.xx Scheduling Error
+DESCRIPTION is a longer description of the request status, also a string.
+EXCEPTION (which may be nil) is textual data describing an error.
+
+When printed, the three elements are separated by semicolons, like
+  CODE;DESCRIPTION;EXCEPTION
+or
+  CODE;DESCRIPTION
+if EXCEPTION is nil.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing the `icalendar-request-status' property."
+  '(satisfies ical:req-status-info-p)
+  (seq
+   ;; statcode: hierarchical status code
+   (group-n 11
+     (seq (one-or-more digit)
+          (** 1 2 (seq ?. (one-or-more digit)))))
+   ?\;
+   ;; statdesc: status description
+   (group-n 12 ical:text)
+   ;; exdata: exception data
+   (zero-or-one (seq ?\; (group-n 13 ical:text))))
+  :reader ical:read-req-status-info
+  :printer ical:print-req-status-info
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8.3")
+
+(ical:define-property ical:request-status "REQUEST-STATUS"
+  "Request status"
+  ical:req-status-info
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8.3")
+
+
+;;; Section 3.6: Calendar Components
+
+(defconst ical:component-types nil ;; populated by ical:define-component
+  "Alist mapping printed component names to type symbols")
+
+(defun ical:parse-component (limit)
+  "Parse an iCalendar component from point up to LIMIT.
+Point should be at the start of the component, i.e., at the start
+of a line that looks like \"BEGIN:[COMPONENT-NAME]\". After parsing,
+point is at the beginning of the next line following the component
+(or end of the buffer). Returns a syntax node representing the component."
+  (let ((begin-pos nil)
+        (body-begin-pos nil)
+        (end-pos nil)
+        (body-end-pos nil)
+        (begin-regex (rx line-start "BEGIN:" (group-n 2 ical:name) line-end)))
+
+    (unless (re-search-forward begin-regex limit t)
+      (signal 'ical:parse-error
+              (list (format "Not at start of a component at line %d, position %d"
+                            (line-number-at-pos (point))
+                            (point)))))
+
+    (setq begin-pos (match-beginning 0)
+          body-begin-pos (1+ (match-end 0))) ; start of next line
+
+    (let* ((component-name (match-string 2))
+           (known-type (alist-get (upcase component-name)
+                                  ical:component-types
+                                  nil nil #'equal))
+           (component-type (or known-type 'ical:other-component))
+           (children nil))
+
+      ;; Find end of component:
+      (save-excursion
+        (if (re-search-forward
+             (rx-to-string `(seq line-start "END:" ,component-name line-end))
+             limit t)
+            (setq end-pos (match-end 0)
+                  body-end-pos (1- (match-beginning 0))) ; end of prev. line
+          (signal 'ical:parse-error
+                  (list (format (concat "Matching END: of component %s not found "
+                                        "between %d and %d")
+                                component-name begin-pos limit)))))
+
+      (while (not (bolp)) (forward-char))
+
+      ;; Parse the properties and subcomponents of this component:
+      (while (<= (point) body-end-pos)
+        (push (ical:parse-property-or-component end-pos)
+              children))
+
+      ;; Set point up for the next parser:
+      (goto-char end-pos)
+      (while (and (< (point) (point-max)) (not (bolp)))
+        (forward-char))
+
+      ;; Return the syntax node for the component:
+      (ical:make-ast-node component-type
+                          (list
+                           :original-name
+                           (when (eq component-type 'ical:other-component)
+                             component-name)
+                           :buffer (current-buffer)
+                           :begin begin-pos
+                           :end end-pos
+                           :value-begin body-begin-pos
+                           :value-end body-end-pos)
+                          (nreverse children)))))
+
+(defun ical:parse-property-or-component (limit)
+  "Parse a component or a property at point.
+Point should be at the beginning of a line which begins a
+component or contains a property."
+  (cond ((looking-at (rx line-start "BEGIN:" ical:name line-end))
+         (ical:parse-component limit))
+        ((looking-at (rx line-start ical:name))
+         (ical:parse-property (line-end-position)))
+        (t (signal 'ical:parse-error
+                   (list (format (concat "Not at start of property or component "
+                                         "at line %d, position %d")
+                                 (line-number-at-pos (point))
+                                 (point)))))))
+
+(defun ical:print-component-node (node)
+  "Serialize a component syntax node NODE to a string."
+  (let* ((type (ical:ast-node-type node))
+         (name (or (ical:ast-node-meta-get :original-name node)
+                   (car (rassq type ical:component-types))))
+         (children (ical:ast-node-children node)))
+
+    (unless name
+      (signal 'ical:print-error
+              (list (format "Unknown component name for type `%s'" type)
+                    type node)))
+
+    (concat
+     ;; TODO: should line ending be sensitive to buffer coding system?
+     (format "BEGIN:%s\r\n" name)
+     (apply #'concat
+            (mapcar #'ical:print-property-or-component children))
+     (format "END:%s\r\n" name))))
+
+(defun ical:print-property-or-component (node)
+  "Serialize a property or component node NODE to a string."
+  (let ((type (ical:ast-node-type node)))
+    (cond ((get type 'ical:is-property)
+           (ical:print-property-node node))
+          ((get type 'ical:is-component)
+           (ical:print-component-node node))
+          (t (signal 'ical:print-error
+                     (list (format "Not a component or property node")
+                           node))))))
+
+(ical:define-component ical:vevent "VEVENT"
+  "Represents an event.
+
+This component contains properties which describe an event, such
+as its start and end time (`icalendar-dtstart' and
+`icalendar-dtend') and a summary (`icalendar-summary') and
+description (`icalendar-description'). It may also contain
+`icalendar-valarm' components as subcomponents which describe
+reminder notifications related to the event. Event components can
+only be direct children of an `icalendar-vcalendar'; they cannot
+be subcomponents of any other component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:dtstart
+                             ;; TODO: dtstart required if METHOD not present
+                             ;; in parent calendar
+                             ical:class
+                             ical:created
+                             ical:description
+                             ical:dtend
+                             ical:duration
+                             ical:geo
+                             ical:last-modified
+                             ical:location
+                             ical:organizer
+                             ical:priority
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:transp
+                             ical:url
+                             ical:recurid
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:exdate
+                              ical:rstatus
+                              ical:related-to
+                              ical:resources
+                              ical:rdate
+                              ical:other-property
+                              ical:valarm))
+  :other-validator ical:vevent-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1")
+
+(defun ical:rrule-validator (node)
+  "When component NODE has an `icalendar-rrule', validate that its
+`icalendar-dtstart', `icalendar-rdate', and `icalendar-exdate' properties
+satisfy the requirements imposed by this rule."
+  (let* ((rrule (ical:ast-node-first-child-of 'ical:rrule node))
+         (recval (when rrule (ical:ast-node-value rrule)))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node))
+         (start (when dtstart (ical:ast-node-value dtstart)))
+         (rdates (ical:ast-node-children-of 'ical:rdate node))
+         (included (when rdates (mapcan #'ical:ast-node-value rdates)))
+         (exdates (ical:ast-node-children-of 'ical:exdate node))
+         (excluded (when exdates (mapcan #'ical:ast-node-value exdates))))
+    (when rrule
+      (unless dtstart
+        (signal 'ical:validation-error
+                (list (concat "An `icalendar-rrule' requires an "
+                              "`icalendar-dtstart' property")
+                      node)))
+      (when included
+        (unless (ical:list-of-p (ical:ast-node-type start) included)
+          (signal 'ical:validation-error
+                 (list (concat "`icalendar-rdate' values must agree with type "
+                              "of `icalendar-dtstart' property")
+                       node)))
+        ;; ""RDATE" in this usage [i.e., in STANDARD and DAYLIGHT
+        ;; subcomponents] MUST be specified as a date with local time
+        ;; value, relative to the UTC offset specified in the
+        ;; "TZOFFSETFROM" property.
+        (when (and (memq (ical:ast-node-type node) '(ical:standard ical:daylight))
+                   (seq-some #'decoded-time-zone
+                             (mapcar #'ical:ast-node-value included)))
+          (signal 'ical:validation-error
+                  (list (concat "`icalendar-rdate' values must be in "
+                                "local time in `icalendar-standard' and "
+                                "`icalendar-daylight' components")
+                        node))))
+
+      (when excluded
+        (unless (ical:list-of-p (ical:ast-node-type start) excluded)
+          (signal 'ical:validation-error
+                 (list (concat "`icalendar-exdate' values must agree with type "
+                              "of `icalendar-dtstart' property")
+                       node))))
+      (let* ((freq (car (alist-get 'FREQ recval)))
+             (until (car (alist-get 'UNTIL recval))))
+        (when (eq 'ical:date (ical:ast-node-type start))
+          (when (or (memq freq '(HOURLY MINUTELY SECONDLY))
+                    (assq 'BYSECOND recval)
+                    (assq 'BYMINUTE recval)
+                    (assq 'BYHOUR recval))
+            (signal 'ical:validation-error
+                    (list (concat "`icalendar-rrule' must not contain time-based "
+                                  "rules when `icalendar-dtstart' is a plain date")
+                          node))))
+        (when until
+          (unless (eq (ical:ast-node-type start)
+                      (ical:ast-node-type until))
+            (signal 'ical:validation-error
+                    (list (concat "`icalendar-rrule' UNTIL clause must agree with "
+                                  "type of `icalendar-dtstart' property")
+                          node)))
+          (when (eq 'ical:date-time (ical:ast-node-type until))
+            (let ((until-zone
+                   (decoded-time-zone (ical:ast-node-value until)))
+                  (start-zone
+                   (decoded-time-zone (ical:ast-node-value start))))
+              ;; "If the "DTSTART" property is specified as a date
+              ;; with local time, then the UNTIL rule part MUST also
+              ;; be specified as a date with local time":
+              (when (and (null start-zone) (not (null until-zone)))
+                (signal 'ical:validation-error
+                        (list (concat "`icalendar-rrule' UNTIL clause must be in "
+                                      "local time if `icalendar-dtstart' is")
+                              node)))
+              ;; "If the "DTSTART" property is specified as a date
+              ;; with UTC time or a date with local time and time zone
+              ;; reference, then the UNTIL rule part MUST be specified
+              ;; as a date with UTC time":
+              (when (and (integerp start-zone)
+                         (not (ical:date-time-is-utc-p until)))
+                (signal 'ical:validation-error
+                        (list (concat "`icalendar-rrule' UNTIL clause must be in "
+                                      "UTC time if `icalendar-dtstart' has a "
+                                      "defined time zone")
+                              node)))))
+          (when (memq (ical:ast-node-type node) '(ical:standard ical:daylight))
+            ;; "In the case of the "STANDARD" and "DAYLIGHT"
+            ;; sub-components the UNTIL rule part MUST always be
+            ;; specified as a date with UTC time":
+            (unless (ical:date-time-is-utc-p until)
+              (signal 'ical:validation-error
+                      (list (concat "`icalendar-rrule' UNTIL clause must be in "
+                                      "UTC time in `icalendar-standard' and "
+                                      "`icalendar-daylight' components")
+                            node)))))
+
+        ;; "DTSTART in this usage [i.e., in STANDARD and DAYLIGHT
+        ;; subcomponents] MUST be specified as a date with a local
+        ;; time value."
+        (when (memq (ical:ast-node-type node) '(ical:standard ical:daylight))
+          (unless (eq 'ical:date-time (ical:ast-node-type start))
+            (signal 'ical:validation-error
+                    (list (concat "`icalendar-dtstart' must be an "
+                                  "`icalendar-date-time' in `icalendar-standard' "
+                                  "and `icalendar-daylight' components"))))
+
+          (when (decoded-time-zone (ical:ast-node-value start))
+            (signal 'ical:validation-error
+                    (list (concat "`icalendar-dtstart' must be in "
+                                  "local time in `icalendar-standard' and "
+                                  "`icalendar-daylight' components")
+                          node))))))
+
+    ;; Success:
+    node))
+
+(defun ical:vevent-validator (node)
+  "Additional validator for an `icalendar-vevent' NODE.
+Checks that NODE has does not have both `icalendar-duration' and
+`icalendar-dtend' properties, and
+calls `icalendar-rrule-validator'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VEVENT nodes; it is not normally necessary to call it directly."
+  (let* ((duration (ical:ast-node-first-child-of 'ical:duration node))
+         (dur-value (when duration (ical:ast-node-value
+                                     (ical:ast-node-value duration))))
+         (dtend (ical:ast-node-first-child-of 'ical:dtend node))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node)))
+    (when (and dtend duration)
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-dtend' and `icalendar-duration' "
+                            "properties must not appear in the same "
+                            "`icalendar-vevent'")
+                    node)))
+    ;; don't allow time-based durations with dates
+    ;; TODO: check that the standard disallows this...?
+    (when (and dtstart duration
+               (eq 'ical:date (ical:ast-node-type dtstart))
+               (or (not (integerp dur-value))
+                   (decoded-time-hour dur-value)
+                   (decoded-time-minute dur-value)
+                   (decoded-time-second dur-value)))
+      (signal 'ical:validation-error
+              (list (concat "Event with `icalendar-date' as DTSTART "
+                            " cannot have time units in DURATION")
+                    node)))
+
+  (ical:rrule-validator node)
+  ;; success:
+  node))
+
+(ical:define-component ical:vtodo "VTODO"
+  "Represents a To-Do item or task.
+
+This component contains properties which describe a to-do item or
+task, such as its due date (`icalendar-due') and a summary
+(`icalendar-summary') and description (`icalendar-description').
+It may also contain `icalendar-valarm' components as
+subcomponents which describe reminder notifications related to
+the task. To-do components can only be direct children of an
+`icalendar-vcalendar'; they cannot be subcomponents of any other
+component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:class
+                             ical:completed
+                             ical:created
+                             ical:description
+                             ical:dtstart
+                             ical:due
+                             ical:duration
+                             ical:geo
+                             ical:last-modified
+                             ical:location
+                             ical:organizer
+                             ical:percent-complete
+                             ical:priority
+                             ical:recurrence-id
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:url
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:exdate
+                              ical:request-status
+                              ical:related-to
+                              ical:resources
+                              ical:rdate
+                              ical:other-property
+                              ical:valarm))
+  :other-validator ical:vtodo-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.2")
+
+(defun ical:vtodo-validator (node)
+  "Additional validator for an `icalendar-vtodo' NODE.
+Checks that NODE has conformant `icalendar-due',
+`icalendar-duration', and `icalendar-dtstart' properties, and calls
+`icalendar-rrule-validator'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VTODO nodes; it is not normally necessary to call it directly."
+  (let* ((due (ical:ast-node-first-child-of 'ical:due node))
+         (duration (ical:ast-node-first-child-of 'ical:duration node))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node)))
+    (when (and due duration)
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-due' and `icalendar-duration' properties "
+                            "must not appear in the same `icalendar-vtodo'")
+                    node)))
+    (when (and duration (not dtstart))
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-duration' requires `icalendar-dtstart' "
+                            "property in the same `icalendar-vtodo'")
+                    node))))
+  (ical:rrule-validator node)
+  ;; success:
+  node)
+
+(ical:define-component ical:vjournal "VJOURNAL"
+  "Represents a journal entry.
+
+This component contains properties which describe a journal
+entry, which might be any longer-form data (e.g., meeting notes,
+a diary entry, or information needed to complete a task). It can
+be associated with an `icalendar-vevent' or `icalendar-vtodo' via
+the `icalendar-related-to' property. A journal entry does not
+take up time in a calendar, and plays no role in searches for
+free or busy time. Journal components can only be direct children
+of `icalendar-vcalendar'; they cannot be subcomponents of any
+other component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:class
+                             ical:created
+                             ical:dtstart
+                             ical:last-modified
+                             ical:organizer
+                             ical:recurid
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:url
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:description
+                              ical:exdate
+                              ical:related-to
+                              ical:rdate
+                              ical:rstatus
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.3")
+
+(ical:define-component ical:vfreebusy "VFREEBUSY"
+  "Represents a published set of free/busy time blocks, or a request
+or response for such blocks.
+
+The free/busy information is represented by the
+`icalendar-freebusy' property (which may be given more than once)
+and the related `icalendar-fbtype' parameter. Note that
+recurrence properties (`icalendar-rrule', `icalendar-rdate', and
+`icalendar-exdate') are NOT permitted in this component.
+
+When used to publish blocks of free/busy time in a user's
+schedule, the `icalendar-organizer' property specifies the user.
+
+When used to request free/busy time in a user's schedule, or to
+respond to such a request, the `icalendar-attendee' property
+specifies the user whose time is being requested, and the
+`icalendar-organizer' property specifies the user making the
+request.
+
+Free/busy components can only be direct children
+of `icalendar-vcalendar'; they cannot be subcomponents of any
+other component, and cannot contain subcomponents."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:contact
+                             ical:dtstart
+                             ical:dtend
+                             ical:organizer
+                             ical:url)
+               :zero-or-more (ical:attendee
+                              ical:comment
+                              ical:freebusy
+                              ical:rstatus
+                              ical:other-property))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.4")
+
+;; TODO: RFC7808 defines additional properties that are relevant here:
+;; https://www.rfc-editor.org/rfc/rfc7808.html#section-7
+(ical:define-component ical:vtimezone "VTIMEZONE"
+  "Represents a time zone.
+
+A time zone is identified by an `icalendar-tzid' property, which
+is required in this component. Times in other calendar components
+can be specified in local time in this time zone with the
+`icalendar-tzidparam' parameter. An `icalendar-vcalendar' object
+must contain exactly one `icalendar-vtimezone' component for each
+unique timezone identifier used in the calendar.
+
+Besides the time zone identifier, a time zone component must
+contain at least one `icalendar-standard' or `icalendar-daylight'
+subcomponent, which describe the observance of standard or
+daylight time in the time zone, including the dates of the
+observance and the relevant offsets from UTC time."
+  :child-spec (:one (ical:tzid)
+               :zero-or-one (ical:last-modified
+                             ical:tzurl)
+               :zero-or-more (ical:standard
+                              ical:daylight
+                              ical:other-property))
+  :other-validator ical:vtimezone-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(defun ical:vtimezone-validator (node)
+  "Additional validator for an `icalendar-vtimezone' NODE.
+Checks that NODE has at least one `icalendar-standard' or
+`icalendar-daylight' child.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VTIMEZONE nodes; it is not normally necessary to call it directly."
+  (let ((child-counts (ical:count-children-by-type node)))
+    (when (and (= 0 (alist-get 'ical:standard child-counts 0))
+               (= 0 (alist-get 'ical:daylight child-counts 0)))
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-timezone' must have at least one "
+                            "`icalendar-standard' or `icalendar-daylight' child")
+                    node))))
+  ;; success:
+  node)
+
+(ical:define-component ical:standard "STANDARD"
+  "Represents a Standard Time observance in a time zone.
+
+The observance has a start time, specified by an
+`icalendar-dtstart' property, which is required in this component
+and must be in *local* time format. The observance may have a
+recurring onset (e.g. each year on a particular day or date)
+described by the `icalendar-rrule' and `icalendar-rdate'
+properties. An end date for the observance, if there is one, must
+be specified in the UNTIL clause of the `icalendar-rrule' in UTC
+time.
+
+The offset from UTC time when the observance begins is specified
+in the `icalendar-tzoffsetfrom' property, which is required. The
+offset from UTC time while the observance is in effect is
+specified by the `icalendar-tzoffsetto' property, which is also
+required. A common identifier for the time zone observance can be
+specified in the `icalendar-tzname' property. Other explanatory
+comments can be provided in `icalendar-comment'.
+
+This component must be a direct child of an `icalendar-vtimezone'
+component and cannot contain other subcomponents."
+  :child-spec (:one (ical:dtstart
+                     ical:tzoffsetto
+                     ical:tzoffsetfrom)
+               :zero-or-one (ical:rrule)
+               :zero-or-more (ical:comment
+                              ical:rdate
+                              ical:tzname
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(ical:define-component ical:daylight "DAYLIGHT"
+  "Represents a Daylight Savings Time observance in a time zone.
+
+The observance has a start time, specified by an
+`icalendar-dtstart' property, which is required in this component
+and must be in *local* time format. The observance may have a
+recurring onset (e.g. each year on a particular day or date)
+described by the `icalendar-rrule' and `icalendar-rdate'
+properties. An end date for the observance, if there is one, must
+be specified in the UNTIL clause of the `icalendar-rrule' in UTC
+time.
+
+The offset from UTC time when the observance begins is specified
+in the `icalendar-tzoffsetfrom' property, which is required. The
+offset from UTC time while the observance is in effect is
+specified by the `icalendar-tzoffsetto' property, which is also
+required. A common identifier for the time zone observance can be
+specified in the `icalendar-tzname' property. Other
+explanatory comments can be provided in `icalendar-comment'.
+
+This component must be a direct child of an `icalendar-vtimezone'
+component and cannot contain other subcomponents."
+  :child-spec (:one (ical:dtstart
+                     ical:tzoffsetto
+                     ical:tzoffsetfrom)
+               :zero-or-one (ical:rrule)
+               :zero-or-more (ical:comment
+                              ical:rdate
+                              ical:tzname
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(ical:define-component ical:valarm "VALARM"
+  "Represents an alarm.
+
+An alarm is a notification or reminder for an event or task. The
+type of notification is determined by this component's
+`icalendar-action' property: it may be an AUDIO, DISPLAY, or
+EMAIL notification.
+If it is an audio alarm, it can include an
+`icalendar-attach' property specifying the audio to be rendered.
+If it is a DISPLAY alarm, it must include an `icalendar-description'
+property containing the text to be displayed.
+If it is an EMAIL alarm, it must include both an
+`icalendar-summary' and an `icalendar-description', which specify
+the subject and body of the email, and one or more
+`icalendar-attendee' properties, which specify the recipients.
+
+The required `icalendar-trigger' property specifies when the
+alarm triggers. If the alarm repeats, then `icalendar-duration'
+and `icalendar-repeat' properties are also both required.
+
+This component must occur as a direct child of an
+`icalendar-vevent' or `icalendar-vtodo' component, and cannot
+contain any subcomponents."
+  :child-spec (:one (ical:action ical:trigger)
+               :zero-or-one (ical:duration ical:repeat)
+               :zero-or-more (ical:summary
+                              ical:description
+                              ical:attendee
+                              ical:attach
+                              ical:other-property))
+  :other-validator ical:valarm-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.6")
+
+(defun ical:valarm-validator (node)
+  "Additional validator function for `icalendar-valarm' components.
+Checks that NODE has the right properties corresponding to its
+`icalendar-action' type, e.g., that an EMAIL alarm has a
+subject (`icalendar-summary') and recipients (`icalendar-attendee').
+
+This function is called by `icalendar-ast-node-valid-p' for
+VALARM nodes; it is not normally necessary to call it directly."
+  (let* ((action (ical:ast-node-first-child-of 'ical:action node))
+         (duration (ical:ast-node-first-child-of 'ical:duration node))
+         (repeat (ical:ast-node-first-child-of 'ical:repeat node))
+         (child-counts (ical:count-children-by-type node)))
+
+    (when (and duration (not repeat))
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-valarm' node with `icalendar-duration' "
+                            "must also have `icalendar-repeat' property")
+                    node)))
+
+    (when (and repeat (not duration))
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-valarm' node with `icalendar-repeat' "
+                            "must also have `icalendar-duration' property")
+                    node)))
+
+    (let ((action-str (upcase (ical:text-to-string
+                               (ical:ast-node-value action)))))
+      (cond ((equal "AUDIO" action-str)
+             (unless (<= (alist-get 'ical:attach child-counts 0) 1)
+               (signal 'ical:validation-error
+                       (list (concat "AUDIO `icalendar-valarm' may not have "
+                                     "more than one `icalendar-attach'")
+                             node)))
+             node)
+
+            ((equal "DISPLAY" action-str)
+             (unless (= 1 (alist-get 'ical:description child-counts 0))
+               (signal 'ical:validation-error
+                       (list (concat "DISPLAY `icalendar-valarm' must have "
+                                     "exactly one `icalendar-description'")
+                             node)))
+             node)
+
+            ((equal "EMAIL" action-str)
+             (unless (= 1 (alist-get 'ical:summary child-counts 0))
+               (signal 'ical:validation-error
+                       (list (concat "EMAIL `icalendar-valarm' must have "
+                                     "exactly one `icalendar-summary'")
+                             node)))
+             (unless (= 1 (alist-get 'ical:description child-counts 0))
+               (signal 'ical:validation-error
+                       (list (concat "EMAIL `icalendar-valarm' must have "
+                                     "exactly one `icalendar-description'")
+                             node)))
+             (unless (<= 1 (alist-get 'ical:attendee child-counts 0))
+               (signal 'ical:validation-error
+                       (list (concat "EMAIL `icalendar-valarm' must have "
+                                     "at least one `icalendar-attendee'")
+                             node)))
+             node)
+
+            (t
+             ;; "Applications MUST ignore alarms with x-name and iana-token
+             ;; values they don't recognize." So this is not a validation-error:
+             (warn (format "Unknown ACTION value in VALARM: %s" action-str))
+             node)))))
+
+(ical:define-component ical:other-component nil
+  "Component type for unrecognized component names.
+
+This component type corresponds to the IANA and X-name components
+allowed by RFC5545 sec. 3.6; it represents components with an
+unknown name (matching rx `icalendar-iana-token' or
+`icalendar-x-name') which must be parsed and preserved but not
+further interpreted."
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6")
+
+;; Technically VCALENDAR is not a "component", but for the
+;; purposes of parsing and syntax highlighting, it looks just like
+;; one, so we define it as such here.
+;; TODO: if this becomes a problem, modify `ical:component-node-p'
+;; to return nil for VCALENDAR components
+(ical:define-component ical:vcalendar "VCALENDAR"
+  "Calendar Object.
+
+This is the top-level data structure defined by RFC5545. A
+VCALENDAR must contain the calendar properties `icalendar-prodid'
+and `icalendar-version', and may contain the calendar properties
+`icalendar-method' and `icalendar-calscale'.
+
+It must also contain at least one VEVENT, VTODO, VJOURNAL,
+VFREEBUSY, or other component, and for every unique
+`icalendar-tzidparam' value appearing in a property within these
+components, the calendar object must contain an
+`icalendar-vtimezone' defining a timezone with that TZID."
+
+  :child-spec (:one (ical:prodid ical:version)
+               :zero-or-one (ical:calscale ical:method)
+               :zero-or-more (ical:other-property
+                              ical:vevent
+                              ical:vtodo
+                              ical:vjournal
+                              ical:vfreebusy
+                              ical:vtimezone
+                              ical:other-component))
+  :other-validator ical:vcalendar-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.4")
+
+(defun ical:all-tzidparams-in (node)
+  "Recursively search NODE for `icalendar-tzidparam' nodes and
+return a list of their values"
+  (cond ((ical:tzid-param-p node)
+         (list (ical:ast-node-value node)))
+        ((ical:param-node-p node)
+         nil)
+        (t ;; TODO: could prune search here when properties don't allow tzidparam
+         (seq-uniq (mapcan #'ical:all-tzidparams-in
+                           (ical:ast-node-children node))))))
+
+(defun ical:vcalendar-validator (node)
+  "Additional validator for `icalendar-vcalendar' NODE. Checks that
+NODE has at least one component child and that all of the
+`ical-tzidparam' values appearing in subcomponents have a
+corresponding `icalendar-vtimezone' definition.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VCALENDAR nodes; it is not normally necessary to call it directly."
+  (let* ((children (ical:ast-node-children node))
+         (comp-children (seq-filter #'ical:component-node-p children))
+         (tz-children (seq-filter #'ical:vtimezone-component-p children))
+         (defined-tzs
+          (mapcar
+           (lambda (tz)
+             ;; ensure timezone component has a TZID property and
+             ;; extract its string value:
+             (when (ical:ast-node-valid-p tz)
+               (ical:with-component tz ((ical:tzid :value-node tzid-text))
+                 (ical:text-to-string tzid-text))))
+           tz-children))
+         (appearing-tzids (ical:all-tzidparams-in node)))
+    (unless comp-children
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-vcalendar' must contain "
+                            "at least one component")
+                    node)))
+
+    (let ((seen nil))
+      (dolist (tzid appearing-tzids)
+        (unless (member tzid seen)
+          (unless (member tzid defined-tzs)
+            (signal 'ical:validation-error
+                    (list (format "No VTIMEZONE with TZID '%s' in calendar" tzid)
+                          node))))
+        (push tzid seen)))
+
+    ;; success:
+    node))
+
+(declare-function icr:tz-set-zones-in "icalendar-recur")
+
+;; `icalendar-parse-component' is sufficient to parse all the syntax in
+;; a calendar, but a calendar-level parsing function is needed to add
+;; support for time zones. This function ensures that every
+;; `icalendar-tzidparam' in the calendar has a corresponding
+;; `icalendar-vtimezone' component, and modifies the zone information of
+;; the parsed date-time according to the offset in that timezone.
+(defun ical:parse-calendar (limit)
+  "Parse an `icalendar-vcalendar' object from point up to LIMIT.
+Point should be at the start of the calendar object, i.e., at the start
+of a line that looks like \"BEGIN:VCALENDAR\". After parsing, point is
+at the beginning of the next line following the calendar (or end of the
+buffer). Returns a syntax node representing the calendar."
+  (require 'icalendar-recur) ; for icr:tz-set-zones-in; avoids circular require
+  (unless (looking-at-p "^BEGIN:VCALENDAR")
+    (signal 'ical:parse-error
+            (list (format "Not at start of VCALENDAR at line %d, position %d"
+                          (line-number-at-pos (point))
+                          (point)))))
+  (let ((cal-node (ical:parse-component limit)))
+    (when (ical:ast-node-valid-p cal-node t)
+      (ical:with-component cal-node
+        ((ical:vtimezone :all tzs))
+        ;; After parsing and validating the whole calendar, set the zone
+        ;; and dst slots in all date-times which are relative to a time
+        ;; zone defined in the calendar: (TODO: if this proves too slow
+        ;; in general, we should instead do it lazily when individual
+        ;; components are queried somehow)
+        (dolist (comp (ical:ast-node-children cal-node))
+          (unless (ical:vtimezone-component-p comp)
+            (icr:tz-set-zones-in tzs comp)))))
+    cal-node))
+
+;; TODO: should we do anything to *create* VTIMEZONE nodes when they're
+;; required but don't exist?
+(defalias 'ical:print-calendar-node #'ical:print-component-node)
+
+
+;;; Documentation for all of the above via `describe-symbol':
+(defun ical:documented-symbol-p (sym)
+  "iCalendar symbol predicate for `describe-symbol-backends'"
+  (or (get sym 'icalendar-type-documentation)
+      ;; grammatical categories defined with rx-define, but with no
+      ;; other special icalendar docs:
+      (and (get sym 'rx-definition)
+           (length> (symbol-name sym) 10)
+           (equal "icalendar-" (substring (symbol-name sym) 0 10)))))
+
+(defun ical:documentation (sym buf frame)
+  "iCalendar documentation backend for `describe-symbol-backends'"
+  (ignore buf frame) ; Silence the byte compiler
+  (with-help-window (help-buffer)
+    (with-current-buffer standard-output
+      (let* ((type-doc (get sym 'icalendar-type-documentation))
+             (link (get sym 'icalendar-link))
+             (rx-def (get sym 'rx-definition))
+             (rx-doc (when rx-def
+                       (with-output-to-string
+                         (pp rx-def))))
+             (value-rx-def (get sym 'ical:value-rx))
+             (value-rx-doc (when value-rx-def
+                             (with-output-to-string
+                               (pp value-rx-def))))
+             (values-rx-def (get sym 'ical:values-rx))
+             (values-rx-doc (when values-rx-def
+                             (with-output-to-string
+                               (pp values-rx-def))))
+
+             (full-doc
+              (concat
+               (when type-doc
+                 (format "`%s' is an iCalendar type:\n\n%s\n\n"
+                         sym type-doc))
+               (when link
+                 (format "For further information see\nURL `%s'\n\n" link))
+               ;; FIXME: this is probably better done in rx.el!
+               ;; TODO: could also generalize this to recursively
+               ;; search rx-def for any symbol that starts with "icalendar-"...
+               (when rx-def
+                 (format "`%s' is an iCalendar grammar category.
+Its `rx' definition is:\n\n%s%s%s"
+                         sym
+                         rx-doc
+                         (if value-rx-def
+                             (format "\nIndividual values must match:\n%s"
+                                      value-rx-doc)
+                           "")
+                         (if values-rx-def
+                             (format "\nLists of values must match:\n%s"
+                                      values-rx-doc)
+                           "")))
+               "\n")))
+
+        (insert full-doc)
+        full-doc))))
+
+
+(defconst ical:describe-symbol-backend
+  '(nil icalendar-documented-symbol-p icalendar-documentation)
+  "Entry for icalendar documentation in `describe-symbol-backends'")
+
+(push ical:describe-symbol-backend describe-symbol-backends)
+
+;; Unloading:
+(defun ical:parser-unload-function ()
+  "Unload function for `icalendar-parser'."
+  (mapatoms
+   (lambda (sym)
+     (when (string-match "^icalendar-" (symbol-name sym))
+       (unintern sym obarray))))
+
+  (setq describe-symbol-backends
+        (remq ical:describe-symbol-backend describe-symbol-backends))
+  ;; Proceed with normal unloading:
+  nil)
+
+(provide 'icalendar-parser)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-") ("icr:" . "icalendar-recur-"))
+;; End:
+;;; icalendar-parser.el ends here
diff --git a/lisp/calendar/icalendar-recur.el b/lisp/calendar/icalendar-recur.el
new file mode 100644
index 00000000000..608b43df363
--- /dev/null
+++ b/lisp/calendar/icalendar-recur.el
@@ -0,0 +1,1968 @@
+;;; icalendar-recur.el --- Support for iCalendar recurrences and time zones -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <[email-masked]>
+;; Created: December 2024
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This is a sub-library for working with recurrence rules and time
+;; zones, as defined by RFC5545 (see especially Secs. 3.3.10 and
+;; 3.8.5.3, which are required reading before you make any changes to
+;; the code below) and related standards (especially RFC8984 Sec. 4.3,
+;; also strongly recommended reading). Recurrence rules and time zones
+;; are mutually dependent: to calculate the date and time of future
+;; instances of a recurring event, you must be able to apply time zone
+;; rules; and to apply time zone rules, you must be able to calculate
+;; the date and time of recurring events, namely the shifts between
+;; observances of standard and daylight savings time. For example, an
+;; event that occurs "on the last Friday of every month at 11AM" in a
+;; given time zone should recur at 11AM daylight savings time in July,
+;; but 11AM standard time in January, for a typical time zone that
+;; shifts from standard to DST and back once each year. These shifts
+;; occur at, say, "the last Sunday in March at 2AM" and "the first
+;; Sunday in November at 2AM". So to calculate an absolute time for a
+;; given instance of the original event, you first have to calculate the
+;; nearest instance of the shift between standand and daylight savings
+;; time, which itself involves applying a recurrence rule of the same
+;; form.
+;;
+;; This mutual dependence between recurrence rules and time zones is not
+;; a *vicious* circle, because the shifts between time zone observances
+;; have fixed offsets from UTC time which are made explicit in iCalendar
+;; data. But it does make things complicated. RFC5545 focuses on making
+;; recurrence rules expressive enough to cover existing practices,
+;; including time zone observance shifts, rather than on being easy to
+;; implement.
+;;
+;; So be forewarned: here be dragons. The code here was difficult to get
+;; working, in part because this mutual dependence means it is difficult
+;; to implement anything less than the whole system, in part because
+;; recurrence rules are very flexible in order to cover as many
+;; practical uses as possible, in part because time zone practices are
+;; themselves complicated, and in part because there are a *lot* of edge
+;; cases to worry about. Much of it is tedious and repetitive but
+;; doesn't lend itself to further simplification or abstraction. If you
+;; need to make changes, make them slowly, and use the tests in
+;; test/lisp/calendar/icalendar-recur-tests.el to make sure they don't
+;; break anything.
+;;
+;; Notation: `date/time' with a slash in symbol names means "`date' or
+;; `date-time'", i.e., is a way of indicating that a function can
+;; accept either type of value, and `dt' is typically used for an
+;; argument of either type. `date-time' should always refer to *just*
+;; date-time values, not plain (calendar-style) dates.
+
+(require 'icalendar-ast)
+(require 'icalendar-parser)
+(require 'icalendar-utils)
+(require 'cl-lib)
+(require 'calendar)
+(require 'simple)
+(require 'seq)
+(eval-when-compile '(require 'icalendar-macs))
+
+;; FIXME: this function, or something similar, should probably be in
+;; calendar.el. It is the inverse of `calendar-day-number',
+;; extracted from `calendar-goto-day-of-year'.
+(defun ical:calendar-date-from-yearday-number (year dayno)
+  "Return the date of the DAYNO-th day in YEAR. DAYNO must be an
+integer between -366 and 366."
+  (calendar-gregorian-from-absolute
+   (+ (if (< dayno 0)
+          (+ 1 dayno (if (calendar-leap-year-p year) 366 365))
+        dayno)
+      (calendar-absolute-from-gregorian (list 12 31 (1- year))))))
+
+
+;; Recurrence Intervals
+;;
+;; Two important ideas in the following:
+;;
+;; 1) Because recurrence sets are potentially infinite, we always
+;; calculate recurrences within certain upper and lower bounds. These
+;; bounds might be determined by a user interface (e.g. the week or
+;; month displayed in a calendar) or might be derived from the logic of
+;; the recurrence rule itself. In the former case, where the bounds can
+;; be arbitrary, it's called a 'window' here (as in "window of
+;; time"). In the latter case, it's called an 'interval' here (after the
+;; "INTERVAL=..." clause in recurrence rules).
+;;
+;; Unlike a window, an interval must be synced up with the recurrence
+;; rule: its bounds must fall at successive integer multiples of the
+;; product of the recurrence rule's FREQ and INTERVAL values, relative
+;; to a starting date/time. For example, a recurrence rule with a
+;; MONTHLY frequency and INTERVAL=3 will have an interval that is three
+;; months long. If its start date is, e.g., in November, then the first
+;; interval runs from November to February, the next from February to
+;; May, and so on. Because intervals depend only on the starting
+;; date/time, the frequency, and the interval length, it is relatively
+;; straightforward to compute the bounds of the interval surrounding an
+;; arbitrary point in time (without enumerating them successively from
+;; the start time); see `icalendar-recur-find-interval', which calls
+;; this arbitrary point in time the 'target'.
+;;
+;; 2) An interval is the smallest unit of time for which we compute
+;; values of the recurrence set. This is because the "BYSETPOS=..."
+;; clause in a recurrence rule operates on the sequence of recurrences
+;; in a single interval. Since it selects recurrences by their index in
+;; this sequence, the sequence must have a determinate length and known
+;; bounds. The function `icalendar-recur-recurrences-in-interval' is the
+;; main function to compute recurrences in a given interval.
+;;
+;; The way to compute the recurrences in an arbitrary *window* is thus
+;; to find the interval bounds which are closest to the window's lower
+;; and upper bound, and then compute the recurrences for all the
+;; intervals in between, i.e., that "cover" the window. This is what the
+;; function `icalendar-recur-recurrences-in-window' does.
+;;
+;; Note that the recurrence set for a recurrence rule with a COUNT
+;; clause cannot be computed for an arbitrary interval (or window);
+;; instead, the set must be enumerated from the beginning, so that the
+;; enumeration can stop after a fixed number of recurrences. This is
+;; what the function `icalendar-recur-recurrences-to-count' does. But
+;; also in this case, recurrences are generated for one interval at a
+;; time, because a BYSETPOS clause might apply.
+;;
+;; An interval is represented as a list (LOW HIGH NEXT-LOW) of decoded
+;; times. The length of time between LOW and HIGH corresponds to the
+;; FREQ rule part: they are one year apart for a 'YEARLY rule, a month
+;; apart for a 'MONTHLY rule, etc. NEXT-LOW is the upper bound of the
+;; interval: it is equal to LOW in the subsequent interval. When the
+;; INTERVAL rule part is equal to 1 (the default), HIGH and NEXT-LOW are
+;; the same, but if it is > 1, NEXT-LOW is equal to LOW + INTERVAL *
+;; FREQ.  For example, in a 'MONTHLY rule where INTERVAL=3, which means
+;; "every three months", LOW and HIGH bound the first month, while HIGH
+;; and NEXT-LOW bound the following two months.
+;;
+;; The times between LOW and HIGH are candidates for recurrences.  LOW
+;; is an inclusive lower bound, and HIGH is an exclusive upper bound:
+;; LOW <= R < HIGH for each recurrence R in the interval. The times
+;; between HIGH and NEXT-LOW are not candidates for recurrences.
+;;
+;; The following functions deal with constructing intervals, given a
+;; target, a start date/time, and intervalsize, and optionally a time
+;; zone.  The main entry point is `icalendar-recur-find-interval'.
+
+;; Look, dragons already:
+(defun icr:find-absolute-interval (target dtstart intervalsize freqs
+                                   &optional vtimezone)
+  "Find a recurrence interval based on a fixed number of seconds.
+
+INTERVALSIZE should be the total size of the interval in seconds. FREQS
+should be the number of seconds between the lower bound of the interval
+and the upper bound for candidate recurrences; it is the number of
+seconds in the unit of time in a recurrence rule's FREQ part.  The
+returned interval looks like (LOW LOW+FREQS LOW+INTERVALSIZE).  See
+`icalendar-recur-find-interval' for other arguments' meanings."
+  ;; We assume here that the interval needs to be calculated using
+  ;; absolute times for SECONDLY, MINUTELY, and HOURLY rules.
+  ;; There are two reasons for this:
+  ;;
+  ;; 1) Time zone shifts. If we don't use absolute times, and instead
+  ;;    find interval boundaries using local clock times with e.g.
+  ;;    `ical:date/time-add' (as we do with time units of a day or
+  ;;    greater below), we have to adjust for clock time changes.  Using
+  ;;    absolute times is simpler.
+  ;; 2) More problematically, using local clock times, at least in its
+  ;;    most straightforward implementation, has pathological results
+  ;;    when `intervalsize' is relatively prime with 60 (for a SECONDLY
+  ;;    rule, similarly for the others): intervals generated by
+  ;;    successive enumeration from one target value will not in general
+  ;;    align with intervals generated from a different, but nearby,
+  ;;    target value.  (So going this route seems to mean giving up on
+  ;;    the idea that intervals can be calculated just from `target',
+  ;;    `dtstart' and `intervalsize', and instead always enumerating
+  ;;    them from the beginning.)
+  ;;
+  ;; In effect, we are deciding that a rule like "every 3 hours" always
+  ;; means every 3 * 60 * 60 = 10800 seconds after `dtstart', and not
+  ;; "every 10800 seconds, except when there's a time zone observance
+  ;; change".  People who want the latter have another option: use a
+  ;; DAILY rule and specify the (local) times for the hours they want in
+  ;; the BYHOUR clause, etc. (People who want it for a number of hours,
+  ;; e.g. 7, which does not divide 24, unfortunately do *not* have this
+  ;; option, but anyone who wants that but does not want to understand
+  ;; "7 hours" as a fixed number of seconds has a pathology that I
+  ;; cannot cure here.)
+  ;;
+  ;; RFC5545 does not seem to pronounce one way or the other on whether
+  ;; this decision is correct: there are no examples of SECONDLY rules
+  ;; to go on, and the few examples for MINUTELY and HOURLY rules only
+  ;; use "nice" values in the INTERVAL clause (real-life examples
+  ;; probably(?)  will too).  Our assumption has some possibly
+  ;; unintuitive consequences for `intervalsize' values that are not
+  ;; "nice" (basically, whenever intervalsize and either 60 or 24 are
+  ;; relatively prime), and for how interval boundaries behave at the
+  ;; shifts between time zone observances (since local clock times in
+  ;; the interval bounds will shift from what they would have been
+  ;; before the observance change -- arguably correct but possibly
+  ;; surprising, depending on the case). But the alternative seems
+  ;; worse, so until countervailing evidence emerges, this approach
+  ;; seems reasonable.
+  (let* ((given-start-zone (decoded-time-zone dtstart))
+         (start-w/zone (cond (given-start-zone dtstart)
+                             ((ical:vtimezone-component-p vtimezone)
+                              (ical:date-time-variant dtstart :tz vtimezone))
+                             (t
+                              ;; "Floating" time should be interpreted in user's
+                              ;; current time zone; see RFC5545 Sec 3.3.5
+                              (ical:date-time-variant
+                               dtstart :zone (car (current-time-zone))))))
+         (start-abs (ignore-errors
+                      (time-convert (encode-time start-w/zone) 'integer)))
+         (given-target-zone (decoded-time-zone target))
+         (target-w/zone (cond (given-target-zone target)
+                              (vtimezone
+                               (ical:date-time-variant target :tz vtimezone))
+                              (t
+                               (ical:date-time-variant
+                                target :zone (car (current-time-zone))))))
+         (target-abs (ignore-errors
+                         (time-convert (encode-time target-w/zone) 'integer)))
+         low-abs low high next-low)
+
+    (unless (zerop (mod intervalsize freqs))
+      ;; Bad things will happen if intervalsize is not an integer
+      ;; multiple of freqs
+      (error "FREQS=%d does not divide INTERVALSIZE=%d" freqs intervalsize))
+    (unless (and start-abs target-abs)
+      (when (not start-abs)
+        (error "Could not determine an offset for DTSTART=%s" dtstart))
+      (when (not target-abs)
+        (error "Could not determine an offset for TARGET=%s" target)))
+
+    ;; Find the lower bound below target that is the closest integer
+    ;; multiple of intervalsize seconds from dtstart
+    (setq low-abs (- target-abs
+                     (mod (- target-abs start-abs) intervalsize)))
+
+    (if vtimezone
+        (setq low (icr:tz-decode-time low-abs vtimezone)
+              high (icr:tz-decode-time (+ low-abs freqs) vtimezone)
+              next-low (icr:tz-decode-time (+ low-abs intervalsize) vtimezone))
+      ;; best we can do is decode into target's zone:
+      (let ((offset (decoded-time-zone target-w/zone)))
+        (setq low (icr:tz-decode-time low-abs offset)
+              high (icr:tz-decode-time (+ low-abs freqs) offset)
+              next-low (icr:tz-decode-time (+ low-abs intervalsize) offset))))
+
+    (unless (and given-start-zone given-target-zone)
+      ;; but if we started with floating times, we should return floating times:
+      (setf (decoded-time-zone low) nil)
+      (setf (decoded-time-dst low) -1)
+      (setf (decoded-time-zone high) nil)
+      (setf (decoded-time-dst high) -1)
+      (setf (decoded-time-zone next-low) nil)
+      (setf (decoded-time-dst next-low) -1))
+
+    (list low high next-low)))
+
+(defun icr:find-secondly-interval (target dtstart intervalsize &optional vtimezone)
+  "Find a SECONDLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (icr:find-absolute-interval
+   target
+   dtstart
+   intervalsize
+   1
+   vtimezone))
+
+(defun icr:find-minutely-interval (target dtstart intervalsize &optional vtimezone)
+  "Find a MINUTELY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (icr:find-absolute-interval
+   target
+   ;; A MINUTELY interval always runs from the beginning of a minute to
+   ;; the beginning of the next minute:
+   (ical:date-time-variant dtstart :second 0 :tz 'preserve)
+   (* 60 intervalsize)
+   60
+   vtimezone))
+
+(defun icr:find-hourly-interval (target dtstart intervalsize &optional vtimezone)
+  "Find an HOURLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (icr:find-absolute-interval
+   target
+   ;; An HOURLY interval always runs from the beginning of an hour to
+   ;; the beginning of the next hour:
+   (ical:date-time-variant dtstart :minute 0 :second 0 :tz 'preserve)
+   (* 60 60 intervalsize)
+   (* 60 60)
+   vtimezone))
+
+(defun icr:find-daily-interval (target dtstart intervalsize &optional vtimezone)
+  "Find a DAILY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((start-absdate (calendar-absolute-from-gregorian
+                         (ical:date/time-to-date dtstart)))
+         (target-absdate (calendar-absolute-from-gregorian
+                          (ical:date/time-to-date target)))
+         ;; low-absdate is the closest absolute date below target that
+         ;; is an integer multiple of intervalsize days from dtstart
+         (low-absdate (- target-absdate
+                         (mod (- target-absdate start-absdate) intervalsize)))
+         (high-absdate (1+ low-absdate))
+         (next-low-absdate (+ low-absdate intervalsize)))
+
+    (let* ((low-dt (ical:date-to-date-time
+                     (calendar-gregorian-from-absolute low-absdate)))
+           (high-dt (ical:date-to-date-time
+                      (calendar-gregorian-from-absolute high-absdate)))
+           (next-low-dt (ical:date-to-date-time
+                          (calendar-gregorian-from-absolute next-low-absdate))))
+
+      (when vtimezone
+        (icr:tz-set-zone low-dt vtimezone)
+        (icr:tz-set-zone high-dt vtimezone)
+        (icr:tz-set-zone next-low-dt vtimezone))
+
+      ;; Return the bounds:
+      (list low-dt high-dt next-low-dt))))
+
+(defun icr:find-weekly-interval (target dtstart intervalsize
+                                 &optional weekstart vtimezone)
+  "Find a WEEKLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((target-date (ical:date/time-to-date target))
+         (start-date (ical:date/time-to-date dtstart))
+         ;; the absolute dates of the week start before target and
+         ;; dtstart; these are always a whole number of weeks apart:
+         (target-week-abs (calendar-nth-named-absday
+                           -1
+                           (or weekstart 1)
+                           (calendar-extract-month target-date)
+                           (calendar-extract-year target-date)
+                           (calendar-extract-day target-date)))
+         (start-abs (calendar-nth-named-absday
+                     -1
+                     (or weekstart 1)
+                     (calendar-extract-month start-date)
+                     (calendar-extract-year start-date)
+                     (calendar-extract-day start-date)))
+         (intsize-days (* 7 intervalsize))
+         ;; the absolute date of the week start before target which is
+         ;; an integer multiple of intervalsize weeks from dtstart:
+         (low-abs (- target-week-abs
+                  (mod (- target-week-abs start-abs) intsize-days)))
+         ;; then use this to find the interval bounds:
+         (low (ical:date-to-date-time
+               (calendar-gregorian-from-absolute low-abs)))
+         (high (ical:date-to-date-time
+               (calendar-gregorian-from-absolute (+ 7 low-abs))))
+         (next-low (ical:date-to-date-time
+                    (calendar-gregorian-from-absolute (+ intsize-days low-abs)))))
+
+    (when vtimezone
+      (icr:tz-set-zone low vtimezone)
+      (icr:tz-set-zone high vtimezone)
+      (icr:tz-set-zone next-low vtimezone))
+
+    ;; Return the bounds:
+    (list low high next-low)))
+
+(defun icr:find-monthly-interval (target dtstart intervalsize &optional vtimezone)
+  "Find a MONTHLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((start-month (ical:date/time-month dtstart))
+         (start-year (ical:date/time-year dtstart))
+         ;; we calculate in "absolute months", i.e., number of months
+         ;; since the beginning of the Gregorian calendar, to make
+         ;; finding the lower bound easier:
+         (start-abs-months (+ (* 12 (1- start-year)) (1- start-month)))
+         (target-month (ical:date/time-month target))
+         (target-year (ical:date/time-year target))
+         (target-abs-months (+ (* 12 (1- target-year)) (1- target-month)))
+         ;; number of "absolute months" between start of dtstart's month
+         ;; and start of target's month:
+         (nmonths (- target-abs-months start-abs-months))
+         ;; the number of months after dtstart that is the closest integer
+         ;; multiple of intervalsize months before target:
+         (lmonths (- nmonths (mod nmonths intervalsize)))
+         ;; convert these "absolute months" back to Gregorian month and year:
+         (mod-month (mod (+ start-month lmonths) 12))
+         (low-month (if (zerop mod-month) 12 mod-month))
+         (low-year (+ (/ lmonths 12) start-year
+                      ;; iff we cross a year boundary moving forward in
+                      ;; time from start-month to target-month, we need
+                      ;; to add one to the year:
+                      (if (<= start-month target-month) 0 1)))
+         ;; and now we can use these to calculate the interval bounds:
+         (low (ical:make-date-time :year low-year :month low-month :day 1
+                                   :hour 0 :minute 0 :second 0 :tz vtimezone))
+         (high (ical:date/time-add low :month 1 vtimezone))
+         (next-low (ical:date/time-add low :month intervalsize vtimezone)))
+
+    ;; Return the bounds:
+    (list low high next-low)))
+
+(defun icr:find-yearly-interval (target dtstart intervalsize &optional vtimezone)
+  "Find a YEARLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((start-year (ical:date/time-year dtstart))
+         (target-year (ical:date/time-year target))
+         ;; The year before target that is the closest integer multiple
+         ;; of intervalsize years after dtstart:
+         (low-year (- target-year
+                      (mod (- target-year start-year) intervalsize)))
+         (low (ical:make-date-time :year low-year :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0 :tz vtimezone))
+         (high (ical:make-date-time :year (1+ low-year) :month 1 :day 1
+                                    :hour 0 :minute 0 :second 0 :tz vtimezone))
+         (next-low (ical:make-date-time :year (+ low-year intervalsize)
+                                        :month 1 :day 1 :hour 0 :minute 0 :second 0
+                                        :tz vtimezone)))
+
+    ;; Return the bounds:
+    (list low high next-low)))
+
+(defun icr:find-interval (target dtstart recur-value &optional vtimezone)
+  "Return the recurrence interval around TARGET.
+
+TARGET and DTSTART should be `icalendar-date' or `icalendar-date-time'
+values. RECUR-VALUE should be an `icalendar-recur'.
+
+The returned value is a list (LOW HIGH NEXT-LOW) which
+represents the lower and upper bounds of a recurrence interval around
+TARGET. For some N, LOW is equal to START + N*INTERVALSIZE units, HIGH
+is equal to START + (N+1)*INTERVALSIZE units, and LOW <= TARGET < HIGH.
+START here is a time derived from DTSTART depending on RECUR-VALUE's
+FREQ part: the first day of the year for a \\='YEARLY rule, first day
+of the month for a \\='MONTHLY rule, etc.
+
+RECUR-VALUE's interval determines INTERVALSIZE, and its frequency
+determines the units: a month for \\='MONTHLY, etc.
+
+If VTIMEZONE is provided, it is used to set time zone information in the
+returned interval bounds. Otherwise, the bounds contain no time zone
+information and represent floating local times."
+  (let ((freq (ical:recur-freq recur-value))
+        (intsize (ical:recur-interval-size recur-value))
+        (weekstart (ical:recur-weekstart recur-value)))
+    (cl-case freq
+      (SECONDLY (icr:find-secondly-interval target dtstart intsize vtimezone))
+      (MINUTELY (icr:find-minutely-interval target dtstart intsize vtimezone))
+      (HOURLY (icr:find-hourly-interval target dtstart intsize vtimezone))
+      (DAILY (icr:find-daily-interval target dtstart intsize vtimezone))
+      (WEEKLY (icr:find-weekly-interval target dtstart intsize
+                                        weekstart vtimezone))
+      (MONTHLY (icr:find-monthly-interval target dtstart intsize vtimezone))
+      (YEARLY (icr:find-yearly-interval target dtstart intsize vtimezone)))))
+
+(defun icr:nth-interval (n dtstart recur-value &optional vtimezone)
+  "Return the Nth recurrence interval after DTSTART.
+
+The returned value is a list (LOW HIGH NEXT-LOW) which represent the Nth
+recurrence interval after DTSTART.  LOW is equal to START +
+N*INTERVALSIZE units, HIGH is equal to START + (N+1)*INTERVALSIZE units,
+and LOW <= TARGET < HIGH.  START here is a time derived from DTSTART
+depending on RECUR-VALUE's FREQ part: the first day of the year for a
+\\='YEARLY rule, first day of the month for a \\='MONTHLY rule, etc.
+
+RECUR-VALUE's interval determines INTERVALSIZE, and its frequency
+determines the units: a month for \\='MONTHLY, etc.
+
+N should be a non-negative integer. Interval 0 is the interval
+containing DTSTART.  DTSTART should be an `icalendar-date' or
+`icalendar-date-time' value.  RECUR-VALUE should be an
+`icalendar-recur'.
+
+If VTIMEZONE is provided, it is used to set time zone information in the
+returned interval bounds. Otherwise, the bounds contain no time zone
+information and represent floating local times."
+  (when (< n 0) (error "Recurrence interval undefined for negative N"))
+  (let* ((start-dt (if (cl-typep dtstart 'ical:date)
+                       (ical:date-to-date-time dtstart :tz vtimezone)
+                     dtstart))
+         (freq (ical:recur-freq recur-value))
+         (intervalsize (ical:recur-interval-size recur-value))
+         (unit (cl-case freq
+                 (YEARLY :year)
+                 (MONTHLY :month)
+                 (WEEKLY :week)
+                 (DAILY :day)
+                 (HOURLY :hour)
+                 (MINUTELY :minute)
+                 (SECONDLY :second)))
+         (target (ical:date/time-add start-dt unit (* n intervalsize) vtimezone)))
+    (icr:find-interval target dtstart recur-value vtimezone)))
+
+(defun icr:next-interval (interval recur-value &optional vtimezone)
+  "Given a recurrence INTERVAL (LOW HIGH NEXT), return the next interval
+(NEXT HIGHER HIGHER-NEXT), where HIGHER and HIGHER-NEXT are determined
+by the frequency and interval sizes of RECUR-VALUE."
+  (let* ((new-low (caddr interval))
+         (freq (ical:recur-freq recur-value))
+         (unit (cl-case freq
+                 (YEARLY :year)
+                 (MONTHLY :month)
+                 (WEEKLY :week)
+                 (DAILY :day)
+                 (HOURLY :hour)
+                 (MINUTELY :minute)
+                 (SECONDLY :second)))
+         (intervalsize (ical:recur-interval-size recur-value))
+         (new-high (ical:date/time-add new-low unit 1 vtimezone))
+         (new-next (ical:date/time-add new-low unit intervalsize vtimezone)))
+
+    (when vtimezone
+      (icr:tz-set-zone new-low vtimezone)
+      ;; (icr:tz-set-zone new-high vtimezone)
+      ;; (icr:tz-set-zone new-next vtimezone)
+      )
+
+    (list new-low new-high new-next)))
+
+(defun icr:previous-interval (interval recur-value dtstart &optional vtimezone)
+  "Given a recurrence INTERVAL, return the previous interval.
+
+For an interval (LOW HIGH NEXT-LOW), the previous interval is
+(PREV-LOW PREV-HIGH LOW), where PREV-LOW and PREV-HIGH are determined by
+the frequency and interval sizes of RECUR-VALUE (see
+`icalendar-recur-find-interval').  If the resulting period of time
+between PREV-LOW and PREV-HIGH occurs entirely before DTSTART, then the
+interval does not exist; in this case nil is returned."
+  (let* ((upper (car interval))
+         (freq (ical:recur-freq recur-value))
+         (unit (cl-case freq
+                 (YEARLY :year)
+                 (MONTHLY :month)
+                 (WEEKLY :week)
+                 (DAILY :day)
+                 (HOURLY :hour)
+                 (MINUTELY :minute)
+                 (SECONDLY :second)))
+         (intervalsize (ical:recur-interval-size recur-value))
+         (new-low (ical:date/time-add upper unit (* -1 intervalsize) vtimezone))
+         (new-high (ical:date/time-add new-low unit 1 vtimezone)))
+
+    (when vtimezone
+      ;; (icr:tz-set-zone new-low vtimezone)
+      ;; (icr:tz-set-zone new-high vtimezone)
+      (icr:tz-set-zone upper vtimezone))
+
+    (unless (ical:date-time< new-high dtstart)
+      (list new-low new-high upper))))
+
+
+
+;; Refining intervals into subintervals
+;;
+;; For a given interval, the various BY*=... clauses in a recurrence
+;; rule specify the recurrences in that interval.
+;;
+;; RFC5545 unfortunately has an overly-complicated conceptual model for
+;; how recurrences are to be calculated which is based on "expanding" or
+;; "limiting" the recurrence set for each successive clause. This model
+;; is difficult to think about and implement, and the text of the
+;; standard is ambiguous. I did not succeed in producing a working
+;; implementation based on the description in the standard, and the
+;; existing implementations don't seem to agree on how it's to be
+;; implemented anyway.
+;;
+;; Fortunately, RFC8984 (JSCalendar) is a forthcoming standard which
+;; attempts to resolve the ambiguities while being semantically
+;; backward-compatible with RFC5545. It provides a much cleaner
+;; conceptual model: the recurrence set is generated by starting with a
+;; list of candidates, which consist of every second in (what is here
+;; called) an interval, and then filtering out any candidates which do
+;; not match the rule's clauses. The most straightforward implementation
+;; of this model, however, is unusably slow in typical cases. Consider
+;; for example the case of calculating the onset of daylight savings
+;; time in a given year: the interval is a year long, so it consists of
+;; over 31 million seconds. Although it's easy to generate Lisp
+;; timestamps for each of those seconds, filtering them through the
+;; various BY* clauses means decoding each of those timestamps, which
+;; means doing a fairly expensive computation over 31 million times, and
+;; then throwing away the result in all but one case. When I implemented
+;; this model, I was not patient enough to sit through the calculations
+;; for even MONTHLY rules (which on my laptop took minutes).
+;;
+;; So instead of implementing RFC8984's model directly, the strategy
+;; here is to do something equivalent but much more efficient: rather
+;; than thinking of an interval as consisting of a set of successive
+;; seconds, we think of it as described by its bounds; and for each BY*
+;; clause, we *refine* the interval into subintervals by computing the
+;; bounds of each subinterval corresponding to the value(s) in that
+;; clause. For example, in a YEARLY rule, the initial interval is one
+;; year long, say all of 2025. If it has a "BYMONTH=4,10" clause, then
+;; we refine this interval into two subintervals, each one month long:
+;; one for April 2025 and one for October 2025.  This is much more
+;; efficient in the typical case, because the number of bounds which
+;; describe the final set of subintervals is usually *much* smaller than
+;; the number of seconds in the original interval.
+;;
+;; The following functions are responsible for computing these
+;; refinements. The main entry point here is
+;; `icalendar-recur-refine-from-clauses', which takes care of
+;; successively refining the interval both by the explicit values in the
+;; rule's clauses and by the implicit values in DTSTART. (There, too,
+;; RFC8984 is helpful: it gives a much more explicit description of how
+;; the information in DTSTART interacts with the BY* clauses to further
+;; refine the subintervals.)
+
+(defun icr:refine-byyearday (interval yeardays &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching YEARDAYS.
+
+YEARDAYS should be a list of values from a recurrence rule's
+BYYEARDAY=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-ydays (sort yeardays
+                             :lessp (lambda (a b)
+                                      (let ((pos-a (if (< 0 a) a (+ 366 a)))
+                                            (pos-b (if (< 0 b) b (+ 366 b))))
+                                        (< pos-a pos-b)))))
+         (interval-start (car interval))
+         (start-year (decoded-time-year interval-start))
+         (interval-end (cadr interval))
+         (end-year (decoded-time-year interval-end))
+         (subintervals nil))
+    (while (<= start-year end-year)
+      ;; For each year in the interval...
+      (dolist (n sorted-ydays)
+        ;; ...the subinterval is one day long on the nth yearday
+        (let* ((nthday (ical:calendar-date-from-yearday-number start-year n))
+               (low (ical:make-date-time :year start-year
+                                         :month (calendar-extract-month nthday)
+                                         :day (calendar-extract-day nthday)
+                                         :hour 0 :minute 0 :second 0
+                                         :tz vtimezone))
+               (high (ical:date/time-add low :day 1 vtimezone)))
+          ;; "Clip" the subinterval bounds if they fall outside the
+          ;; interval.  Careful! This clipping can lead to high <= low,
+          ;; so need to check it is still the case that low < high
+          ;; before pushing the subinterval
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date-time<= interval-start low)
+                     (ical:date-time< low high)
+                     (ical:date-time<= high interval-end))
+            (push (list low high) subintervals))))
+
+      (setq start-year (1+ start-year)))
+    (nreverse subintervals)))
+
+(defun icr:refine-byweekno (interval weeknos &optional weekstart vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching WEEKNOS.
+
+WEEKNOS should be a list of values from a recurrence rule's
+BYWEEKNO=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-weeknos (sort weeknos
+                               :lessp (lambda (a b)
+                                        (let ((pos-a (if (< 0 a) a (+ 53 a)))
+                                              (pos-b (if (< 0 b) b (+ 53 b))))
+                                          (< pos-a pos-b)))))
+         (interval-start (car interval))
+         (start-year (decoded-time-year interval-start))
+         (interval-end (cadr interval))
+         (end-year (decoded-time-year interval-end))
+         (subintervals nil))
+    (while (<= start-year end-year)
+      ;; For each year in the interval...
+      (dolist (wn sorted-weeknos)
+        ;; ...the subinterval is one week long in the wn-th week
+        (let* ((nth-wstart (ical:start-of-weekno wn start-year weekstart))
+               (low (ical:make-date-time :year (calendar-extract-year nth-wstart)
+                                         :month (calendar-extract-month nth-wstart)
+                                         :day (calendar-extract-day nth-wstart)
+                                         :hour 0 :minute 0 :second 0
+                                         :tz vtimezone))
+               (high (ical:date/time-add low :day 7 vtimezone)))
+          ;; "Clip" the subinterval bounds if they fall outside the
+          ;; interval, as above. This can happen often here because week
+          ;; boundaries generally do not align with year boundaries.
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date-time<= interval-start low)
+                     (ical:date-time< low high)
+                     (ical:date-time<= high interval-end))
+              (push (list low high) subintervals))))
+      (setq start-year (1+ start-year)))
+    (nreverse subintervals)))
+
+(defun icr:refine-bymonth (interval months &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching MONTHS.
+
+MONTHS should be a list of values from a recurrence rule's
+BYMONTH=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-months (sort months))
+         (interval-start (car interval))
+         (start-year (decoded-time-year interval-start))
+         (interval-end (cadr interval))
+         (end-year (decoded-time-year interval-end))
+         (subintervals nil))
+    (while (<= start-year end-year)
+      ;; For each year in the interval...
+      (dolist (m sorted-months)
+        ;; ...the subinterval is from the first day of the given month
+        ;; to the first day of the next
+        (let* ((low (ical:make-date-time :year start-year :month m :day 1
+                                         :hour 0 :minute 0 :second 0
+                                         :tz vtimezone))
+               (high (ical:date/time-add low :month 1 vtimezone)))
+
+          ;; Clip the subinterval bounds, as above
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date/time<= interval-start low)
+                     (ical:date/time< low high)
+                     (ical:date/time<= high interval-end))
+            (push (list low high) subintervals))))
+      (setq start-year (1+ start-year)))
+
+    (nreverse subintervals)))
+
+(defun icr:refine-bymonthday (interval monthdays &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching MONTHDAYS.
+
+MONTHDAYS should be a list of values from a recurrence rule's
+BYMONTHDAY=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-mdays (sort monthdays
+                             :lessp (lambda (a b)
+                                      (let ((pos-a (if (< 0 a) a (+ 31 a)))
+                                            (pos-b (if (< 0 b) b (+ 31 b))))
+                                        (< pos-a pos-b)))))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         (subintervals nil))
+    (while (ical:date-time<= interval-start interval-end)
+      ;; For each month in the interval...
+      (dolist (m sorted-mdays)
+        ;; ...the subinterval is one day long on the given monthday
+        (let* ((month (ical:date/time-month interval-start))
+               (year (ical:date/time-year interval-start))
+               (monthday (if (< 0 m) m
+                           (+ m 1 (calendar-last-day-of-month month year))))
+               (low (ical:date-time-variant interval-start :day monthday
+                                            :hour 0 :minute 0 :second 0
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :day 1 vtimezone)))
+
+          (ignore-errors ; ignore invalid dates, e.g. 2025-02-29
+            ;; Clip subinterval, as above
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when (and (ical:date/time<= interval-start low)
+                       (ical:date/time< low high)
+                       (ical:date/time<= high interval-end))
+              (push (list low high) subintervals)))))
+      (setq interval-start
+            (ical:date/time-add interval-start :month 1 vtimezone)))
+    (nreverse subintervals)))
+
+(defun icr:refine-byday (interval weekdays &optional in-month vtimezone)
+  "Refine INTERVAL to days matching the given WEEKDAYS.
+
+WEEKDAYS should be a list of values from a recurrence rule's
+BYDAY=... clause; see `icalendar-recur' for the possible values.
+
+If WEEKDAYS contains pairs (DOW . OFFSET), then IN-MONTH indicates
+whether OFFSET is relative to the month of the start of the interval. If
+it is nil, OFFSET will be relative to the year, rather than the month."
+  (let* ((sorted-weekdays (sort (seq-filter #'natnump weekdays)))
+         (with-offsets (sort (seq-filter #'consp weekdays)
+                             :lessp (lambda (w1 w2) (and (< (car w1) (car w2))))))
+         (interval-start (car interval))
+         (start-abs (calendar-absolute-from-gregorian
+                     (ical:date-time-to-date interval-start)))
+         (interval-end (cadr interval))
+         (end-abs (calendar-absolute-from-gregorian
+                   (ical:date-time-to-date interval-end)))
+         (subintervals nil))
+
+    ;; For days where an offset was given, the subinterval is a single
+    ;; weekday relative to the month or year of interval-start:
+    (dolist (wo with-offsets)
+      (let* ((dow (car wo))
+             (offset (cdr wo))
+             (low-date
+              (ical:nth-weekday-in offset dow
+                                   (ical:date/time-year interval-start)
+                                   (when in-month
+                                     (ical:date/time-month interval-start))))
+             (low (ical:date-to-date-time low-date :tz vtimezone))
+             (high (ical:date/time-add low :day 1 vtimezone)))
+        (when (ical:date/time< low interval-start)
+          (setq low interval-start))
+        (when (ical:date/time< interval-end high)
+          (setq high interval-end))
+        (when vtimezone
+          (icr:tz-set-zone low vtimezone)
+          (icr:tz-set-zone high vtimezone))
+        (when (and (ical:date/time<= interval-start low)
+                   (ical:date/time<= high interval-end)
+                   (ical:date/time< low high))
+          (push (list low high) subintervals))))
+
+    ;; When no offset was given, for each day in the interval...
+    (while (and (<= start-abs end-abs)
+                sorted-weekdays)
+      ;; ...the subinterval is one day long on matching weekdays.
+      (let* ((gdate (calendar-gregorian-from-absolute start-abs)))
+        (when (memq (calendar-day-of-week gdate) sorted-weekdays)
+          (let* ((low (ical:date-to-date-time gdate))
+                 (high (ical:date/time-add low :day 1 vtimezone)))
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when vtimezone
+              (icr:tz-set-zone low vtimezone)
+              (icr:tz-set-zone high vtimezone))
+            (when (and (ical:date/time<= interval-start low)
+                       (ical:date/time<= high interval-end)
+                       (ical:date/time< low high))
+              (push (list low high) subintervals)))))
+      (setq start-abs (1+ start-abs)))
+
+    ;; Finally, sort and return all subintervals:
+    (sort subintervals
+          :lessp (lambda (int1 int2)
+                   (ical:date-time< (car int1) (car int2)))
+          :in-place t)))
+
+(defun icr:refine-byhour (interval hours &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching HOURS.
+
+HOURS should be a list of values from a recurrence rule's
+BYHOUR=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-hours (sort hours))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         (subintervals nil))
+    (while (ical:date-time<= interval-start interval-end)
+      ;; For each day in the interval...
+      (dolist (h sorted-hours)
+        ;; ...the subinterval is one hour long in the given hour
+        (let* ((low (ical:date-time-variant interval-start
+                                            :hour h :minute 0 :second 0
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :hour 1 vtimezone)))
+          (ignore-errors ; do not generate subintervals for nonexisting times
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when (and (ical:date/time<= interval-start low)
+                       (ical:date/time< low high)
+                       (ical:date/time<= high interval-end))
+              (push (list low high) subintervals)))))
+      (setq interval-start (ical:date/time-add interval-start :day 1 vtimezone)))
+    (nreverse subintervals)))
+
+(defun icr:refine-byminute (interval minutes &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching MINUTES.
+
+MINUTES should be a list of values from a recurrence rule's
+BYMINUTE=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-minutes (sort minutes))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         ;; we use absolute times (in seconds) for the loop variables in
+         ;; case the interval crosses the boundary between two observances:
+         (low-ts (time-convert (encode-time interval-start) 'integer))
+         (end-ts (time-convert (encode-time interval-end) 'integer))
+         (subintervals nil))
+    (while (<= low-ts end-ts)
+      ;; For each hour in the interval...
+      (dolist (m sorted-minutes)
+        ;; ...the subinterval is one minute long in the given minute
+        (let* ((low (ical:date-time-variant interval-start :minute m :second 0
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :minute 1 vtimezone)))
+          (ignore-errors ; do not generate subintervals for nonexisting times
+            ;; Clip the subinterval, as above
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when (and (ical:date/time<= interval-start low)
+                       (ical:date/time< low high)
+                       (ical:date/time<= high interval-end))
+              (push (list low high) subintervals)))))
+      (setq low-ts (+ low-ts (* 60 60))
+            interval-start (if vtimezone (icr:tz-decode-time low-ts vtimezone)
+                             (ical:date/time-add interval-start :hour 1))))
+    (nreverse subintervals)))
+
+(defun icr:refine-bysecond (interval seconds &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching SECONDS.
+
+SECONDS should be a list of values from a recurrence rule's
+BYSECOND=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-seconds (sort seconds))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         ;; we use absolute times (in seconds) for the loop variables in
+         ;; case the interval crosses the boundary between two observances:
+         (low-ts (time-convert (encode-time interval-start) 'integer))
+         (end-ts (time-convert (encode-time interval-end) 'integer))
+         (subintervals nil))
+    (while (<= low-ts end-ts)
+      ;; For each minute in the interval...
+      (dolist (s sorted-seconds)
+        ;; ...the subinterval is one second long: the given second
+        (let* ((low (ical:date-time-variant interval-start :second s
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :second 1 vtimezone)))
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date/time<= interval-start low)
+                     (ical:date/time< low high)
+                     (ical:date/time<= high interval-end))
+            (push (list low high) subintervals))))
+      (setq low-ts (+ low-ts 60)
+            interval-start (if vtimezone
+                               (icr:tz-decode-time low-ts vtimezone)
+                             (ical:date/time-add interval-start :minute 1))))
+    (nreverse subintervals)))
+
+;; TODO: should this just become a generic function, with the above
+;; refine-by* functions becoming its methods?
+(defun icr:refine-by (unit interval values
+                      &optional byday-inmonth weekstart vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching VALUES for UNIT."
+  (cl-case unit
+    (BYYEARDAY (icr:refine-byyearday interval values vtimezone))
+    (BYWEEKNO (icr:refine-byweekno interval values weekstart vtimezone))
+    (BYMONTH (icr:refine-bymonth interval values vtimezone))
+    (BYMONTHDAY (icr:refine-bymonthday interval values vtimezone))
+    (BYDAY (icr:refine-byday interval values byday-inmonth vtimezone))
+    (BYHOUR (icr:refine-byhour interval values vtimezone))
+    (BYMINUTE (icr:refine-byminute interval values vtimezone))
+    (BYSECOND (icr:refine-bysecond interval values vtimezone))))
+
+(defun icr:make-bysetpos-filter (setpos)
+  "Return a filter on values for the indices in SETPOS.
+
+SETPOS should be a list of positive or negative integers between -366
+and 366, indicating a fixed index in a set of recurrences for *one
+interval* of a recurrence set, as found in the BYSETPOS=...  clause of
+an `icalendar-recur'. For example, in a YEARLY recurrence rule with an
+INTERVAL of 1, the SETPOS represent indices in the recurrence instances
+generated for a single year.
+
+The returned value is a closure which can be called on the list of
+recurrences for one interval to filter it by index."
+  (lambda (dts)
+    (let* ((len (length dts))
+           (keep-indices (mapcar
+                          (lambda (pos)
+                            ;; sequence indices are 0-based, POS's are 1-based:
+                            (if (< pos 0)
+                                (+ pos len)
+                              (1- pos)))
+                          setpos)))
+      (delq nil
+        (seq-map-indexed
+         (lambda (dt index)
+           (when (memq index keep-indices)
+                 dt))
+         dts)))))
+
+(defun icr:refine-from-clauses (interval recur-value dtstart
+                                &optional vtimezone)
+  "Resolve INTERVAL into subintervals based on the clauses in RECUR-VALUE.
+
+The resulting list of subintervals represents all times in INTERVAL
+which match the BY* clauses of RECUR-VALUE except BYSETPOS, as well as
+the constraints implicit in DTSTART. (For example, if there is no
+BYMINUTE clause, subintervals will have the same minute value as
+DTSTART.)
+
+If specified, VTIMEZONES should be a list of `icalendar-vtimezone'
+components and TZID should be the `icalendar-tzid' property value of one
+of those timezones. In this case, TZID states the time zone of DTSTART,
+and the offsets effective in that time zone on the dates and times of
+recurrences will be local to that time zone."
+  (let ((freq (ical:recur-freq recur-value))
+        (weekstart (ical:recur-weekstart recur-value))
+        (subintervals (list interval)))
+
+    (dolist (byunit (list 'BYMONTH 'BYWEEKNO
+                          'BYYEARDAY 'BYMONTHDAY 'BYDAY
+                          'BYHOUR 'BYMINUTE 'BYSECOND))
+      (let ((values (ical:recur-by* byunit recur-value))
+            (in-month nil))
+        ;; When there is no explicit BY* clause, use the value implicit
+        ;; in DTSTART. (These conditions are adapted from RFC8984:
+        ;;   https://www.rfc-editor.org/rfc/rfc8984.html#section-4.3.3.1-4.3.1
+        ;; Basically, the conditions are somewhat complicated because
+        ;; the meanings of various BY* clauses are not independent and
+        ;; so we have to be careful about the information we take to be
+        ;; implicit in DTSTART, especially with MONTHLY and YEARLY
+        ;; rules. For example, we *do* want to take the weekday of
+        ;; DTSTART as an implicit constraint if a BYWEEKNO clause is
+        ;; present, but not if an explicit BYDAY or BYMONTHDAY clause is
+        ;; also present, since they might contain conflicting
+        ;; constraints.)
+        (when (and (eq byunit 'BYSECOND)
+                   (not (eq freq 'SECONDLY))
+                   (not values))
+          (setq values (list (ical:date/time-second dtstart))))
+        (when (and (eq byunit 'BYMINUTE)
+                   (not (memq freq '(SECONDLY MINUTELY)))
+                   (not values))
+          (setq values (list (ical:date/time-minute dtstart))))
+        (when (and (eq byunit 'BYHOUR)
+                   (not (memq freq '(SECONDLY MINUTELY HOURLY)))
+                   (not values))
+          (setq values (list (ical:date/time-hour dtstart))))
+        (when (and (eq byunit 'BYDAY)
+                   (eq freq 'WEEKLY)
+                   (not values))
+          (setq values (list (ical:date/time-weekday dtstart))))
+        (when (and (eq byunit 'BYMONTHDAY)
+                   (eq freq 'MONTHLY)
+                   (not (ical:recur-by* 'BYDAY recur-value))
+                   (not values))
+          (setq values (list (ical:date/time-monthday dtstart))))
+        (when (and (eq freq 'YEARLY)
+                   (not (ical:recur-by* 'BYYEARDAY recur-value)))
+          (when (and (eq byunit 'BYMONTH)
+                     (not values)
+                     (not (ical:recur-by* 'BYWEEKNO recur-value))
+                     (or (ical:recur-by* 'BYMONTHDAY recur-value)
+                         (not (ical:recur-by* 'BYDAY recur-value))))
+            (setq values (list (ical:date/time-month dtstart))))
+          (when (and (eq byunit 'BYMONTHDAY)
+                     (not values)
+                     (not (ical:recur-by* 'BYWEEKNO recur-value))
+                     (not (ical:recur-by* 'BYDAY recur-value)))
+            (setq values (list (ical:date/time-monthday dtstart))))
+          (when (and (eq byunit 'BYDAY)
+                     (not values)
+                     (ical:recur-by* 'BYWEEKNO recur-value)
+                     (not (ical:recur-by* 'BYMONTHDAY recur-value)))
+            (setq values (list (ical:date/time-weekday dtstart)))))
+
+        ;; Handle offsets in a BYDAY clause:
+        ;; "If present, this [offset] indicates the nth occurrence of a
+        ;; specific day within the MONTHLY or YEARLY "RRULE".  For
+        ;; example, within a MONTHLY rule, +1MO (or simply 1MO)
+        ;; represents the first Monday within the month, whereas -1MO
+        ;; represents the last Monday of the month.  The numeric value
+        ;; in a BYDAY rule part with the FREQ rule part set to YEARLY
+        ;; corresponds to an offset within the month when the BYMONTH
+        ;; rule part is present"
+        (when (and (eq byunit 'BYDAY)
+                   (or (eq freq 'MONTHLY)
+                       (and (eq freq 'YEARLY)
+                            (ical:recur-by* 'BYMONTH recur-value))))
+          (setq in-month t))
+
+        ;; On each iteration of the loop, we refine the subintervals
+        ;; with these explicit or implicit values:
+        (when values
+          (setq subintervals
+                (delq nil
+                      (mapcan (lambda (in)
+                                (icr:refine-by byunit in values in-month
+                                               weekstart vtimezone))
+                              subintervals))))))
+
+    ;; Finally return the refined subintervals after we've looked at all
+    ;; clauses:
+    subintervals))
+
+;; Once we have refined an interval into a final set of subintervals, we
+;; need to convert those subintervals into a set of recurrences. For a
+;; recurrence set where DTSTART and the recurrences are date-times, the
+;; recurrence set (in this interval) consists of every date-time
+;; corresponding to each second of any subinterval. When DTSTART and the
+;; recurrences are plain dates, the recurrence set consists of each
+;; distinct date in any subinterval.
+(defun icr:subintervals-to-date-times (subintervals &optional vtimezone)
+  "Transform SUBINTERVALS into a list of `icalendar-date-time' recurrences.
+
+The returned list of recurrences contains one date-time value for each
+second of each subinterval."
+  (let (recurrences)
+    (dolist (int subintervals)
+      (let* ((start (car int))
+             (dt start)
+             ;; use absolute times for the loop in case the subinterval
+             ;; crosses the boundary between two observances
+             ;; TODO: what if we only have floating times?
+             (end (time-convert (encode-time (cadr int)) 'integer))
+             (tick (time-convert (encode-time start) 'integer)))
+        (while (time-less-p tick end)
+          (push dt recurrences)
+          (setq tick (1+ tick)
+                dt (if vtimezone (icr:tz-decode-time tick vtimezone)
+                     (ical:date/time-add dt :second 1))))))
+    (nreverse recurrences)))
+
+(defun icr:subintervals-to-dates (subintervals)
+  "Transform SUBINTERVALS into a list of `icalendar-date' recurrences.
+
+The returned list of recurrences contains one date value for each
+day of each subinterval."
+  (let (recurrences)
+    (dolist (int subintervals)
+      (let* ((start (car int))
+             (start-abs (calendar-absolute-from-gregorian
+                         (ical:date-time-to-date start)))
+             (end (cadr int))
+             (end-abs (calendar-absolute-from-gregorian
+                       (ical:date-time-to-date end)))
+             ;; number-sequence needs an *inclusive* upper bound:
+             (bound (if (< start-abs end-abs) (1- end-abs)
+                      ;; otherwise start-abs == end-abs, so
+                      ;; make sure we don't exclude that day:
+                      end-abs)))
+        (setq recurrences
+              (append recurrences
+                      (mapcar #'calendar-gregorian-from-absolute
+                              (number-sequence start-abs bound))))))
+    recurrences))
+
+(defun icr:subintervals-to-recurrences (subintervals dtstart &optional vtimezone)
+  "Transform SUBINTERVALS into a list of recurrences.
+
+The returned list of recurrences contains all distinct values in each
+subinterval of the same type as DTSTART."
+  (if (cl-typep dtstart 'ical:date)
+      (icr:subintervals-to-dates subintervals)
+    (icr:subintervals-to-date-times subintervals vtimezone)))
+
+
+;; Calculating recurrences in a given interval or window
+;;
+;; We can now put all of the above together to compute the set of
+;; recurrences in a given interval (`icr:recurrences-in-interval'), and
+;; thereby in a given window (`icr:recurences-in-window'); or, if the
+;; rule describing the set has a COUNT clause, we can enumerate the
+;; recurrences in each interval starting from the beginning of the set
+;; (`icr:recurrences-to-count').
+(defun icr:recurrences-in-interval (interval component &optional vtimezone nmax)
+  "Return a list of the recurrences of COMPONENT in INTERVAL.
+
+INTERVAL should be a list (LOW HIGH NEXT) of date-times which bound a
+single recurrence interval, as returned e.g. by
+`icalendar-recur-find-interval'. (To find the recurrences in an
+arbitrary window of time, rather than between interval boundaries, see
+`icalendar-recur-recurrences-in-window'.)
+
+COMPONENT should be an iCalendar component node representing a recurring
+event: it should contain at least an `icalendar-dtstart' and either an
+`icalendar-rrule' or `icalendar-rdate' property.
+
+If specified, VTIMEZONE should be an `icalendar-vtimezone' component.
+In this case, the dates and times of recurrences will be computed with
+UTC offsets local to that time zone.
+
+If specified, NMAX should be a positive integer containing a maximum
+number of recurrences to return from this interval. In this case, if the
+interval contains more than NMAX recurrences, only the first NMAX
+recurrences will be returned; otherwise all recurrences in the interval
+are returned. (The NMAX argument mainly exists to support recurrence
+rules with a COUNT clause; see `icalendar-recur-recurrences-to-count'.)
+
+The computed recurrences for INTERVAL are cached in COMPONENT and
+retrieved on subsequent calls with the same arguments."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:tzoffsetfrom :value offset-from)
+       (ical:rrule :value recur-value)
+       (ical:rdate :values rdates)
+       (ical:exdate :values exdates))
+    (unless (or recur-value rdates)
+      (error "No recurrence data in component: %s" component))
+    (when (memq (ical:ast-node-type component) '(ical:standard ical:daylight))
+      ;; in time zone observances, set the zone field in dtstart
+      ;; from the TZOFFSETFROM property:
+      (setq dtstart
+            (ical:date-time-variant dtstart
+                                    :zone offset-from
+                                    :dst (not (ical:daylight-component-p
+                                               component)))))
+    (cl-labels ((get-interval (apply-partially #'icr:-set-get-interval component))
+                (put-interval (apply-partially #'icr:-set-put-interval component)))
+      (let ((cached (get-interval interval)))
+        (cond ((eq cached :none) nil)
+              (cached cached)
+              (t
+               (let* (;; Start by generating all the recurrences matching the
+                      ;; BY* clauses except for BYSETPOS:
+                      (subs (icr:refine-from-clauses interval recur-value dtstart
+                                                     vtimezone))
+                      (sub-recs (icr:subintervals-to-recurrences subs dtstart
+                                                                 vtimezone))
+                      ;; Apply any BYSETPOS clause to this set:
+                      (keep-indices (ical:recur-by* 'BYSETPOS recur-value))
+                      (pos-recs
+                       (if keep-indices
+                           (funcall (icr:make-bysetpos-filter keep-indices)
+                                    sub-recs)
+                         sub-recs))
+                      ;; Remove any recurrences before DTSTART or after UNTIL
+                      ;; (both of which are inclusive bounds):
+                      (until (ical:recur-until recur-value))
+                      (until-recs
+                       (seq-filter
+                        (lambda (rec) (and (ical:date/time<= dtstart rec)
+                                           (or (not until)
+                                               (ical:date/time<= rec until))))
+                        pos-recs))
+                      ;; Include any values in the interval from the
+                      ;; RDATE property:
+                      (low (car interval))
+                      (high (cadr interval))
+                      (interval-rdates
+                       (seq-filter
+                        (lambda (rec) (and (ical:date/time<= low rec)
+                                           (ical:date/time< high rec)))
+                        rdates))
+                      (included-recs (append until-recs interval-rdates))
+                      ;; Exclude any values from the EXDATE property; this
+                      ;; gives us the complete set of recurrences in this interval:
+                      (all-recs
+                       (if exdates
+                           (seq-filter
+                            (lambda (rec) (not (member rec exdates)))
+                            included-recs)
+                         included-recs))
+                      ;; Limit to the first NMAX recurrences if requested.
+                      ;; `icr:recurrences-to-count' provides NMAX so as not to
+                      ;; store more recurrences in the final interval than the
+                      ;; COUNT clause allows:
+                      (nmax-recs
+                       (if nmax (seq-take all-recs nmax)
+                         all-recs)))
+                 ;; Store and return the computed recurrences:
+                 (put-interval interval (or nmax-recs :none))
+                 nmax-recs)))))))
+
+(defun icr:recurrences-in-window (lower upper component &optional vtimezone)
+  "Return the recurrences of COMPONENT in the window between LOWER and UPPER.
+
+LOWER and UPPER may be arbitrary `icalendar-date' or
+`icalendar-date-time' values. COMPONENT should be an iCalendar component
+node representing a recurring event: it should contain at least an
+`icalendar-dtstart' and either an `icalendar-rrule' or `icalendar-rdate'
+property.
+
+If specified, VTIMEZONE should be an `icalendar-vtimezone' component.
+In this case, the dates and times of recurrences will be computed with
+UTC offsets local to that time zone."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:tzoffsetfrom :value offset-from)
+       (ical:rrule :value recur-value)
+       (ical:rdate :values rdates))
+    (unless (or recur-value rdates)
+      (error "No recurrence data in component: %s" component))
+    (when (memq (ical:ast-node-type component) '(ical:standard ical:daylight))
+      ;; in time zone observances, set the zone field in dtstart
+      ;; from the TZOFFSETFROM property:
+      (setq dtstart
+            (ical:date-time-variant dtstart
+                                    :zone offset-from
+                                    :dst (not (ical:daylight-component-p
+                                               component)))))
+
+    (let* (;; don't look for nonexistent intervals:
+           (low-start (if (ical:date/time< lower dtstart) dtstart lower))
+           (until (ical:recur-until recur-value))
+           (high-end (if (and until (ical:date/time< until upper)) until upper))
+           (curr-interval (icr:find-interval low-start dtstart recur-value
+                                             vtimezone))
+           (high-interval (icr:find-interval high-end dtstart recur-value
+                                             vtimezone))
+           (high-intbound (cadr high-interval))
+           (recurrences nil))
+
+      (while (ical:date-time< (car curr-interval) high-intbound)
+        (setq recurrences
+              (append
+               (icr:recurrences-in-interval curr-interval component vtimezone)
+               recurrences))
+        (setq curr-interval (icr:next-interval curr-interval recur-value
+                                               vtimezone)))
+
+      ;; exclude any recurrences inside the first and last intervals but
+      ;; outside the window before returning:
+      (seq-filter
+       (lambda (dt)
+         (and (ical:date/time<= lower dt)
+              (ical:date/time< dt upper)))
+       recurrences))))
+
+(defun icr:recurrences-to-count (component &optional vtimezone)
+  "Return all the recurrences in COMPONENT up to COUNT in its recurrence rule.
+
+COMPONENT should be an iCalendar component node representing a recurring
+event: it should contain at least an `icalendar-dtstart' and an
+`icalendar-rrule', which must contain a COUNT=... clause.
+
+Warning: this function finds *all* the recurrences in COMPONENT's
+recurrence set. If the value of COUNT is large, this can be slow.
+
+If specified, VTIMEZONE should be an `icalendar-vtimezone' component.
+In this case, the dates and times of recurrences will be computed with
+UTC offsets local to that time zone."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:tzoffsetfrom :value offset-from)
+       (ical:rrule :value recur-value)
+       (ical:rdate :values rdates))
+    (when (memq (ical:ast-node-type component) '(ical:standard ical:daylight))
+      ;; in time zone observances, set the zone field in dtstart
+      ;; from the TZOFFSETFROM property:
+      (setq dtstart
+            (ical:date-time-variant dtstart
+                                    :zone offset-from
+                                    :dst (not (ical:daylight-component-p
+                                               component)))))
+    (unless (or recur-value rdates)
+      (error "No recurrence data in component: %s" component))
+    (unless (ical:recur-count recur-value)
+      (error "Recurrence rule has no COUNT clause"))
+    (let ((count (ical:recur-count recur-value))
+          (int (icr:nth-interval 0 dtstart recur-value vtimezone))
+          recs)
+      (while (length< recs count)
+        (setq recs
+              (append recs (icr:recurrences-in-interval int component vtimezone
+                                                        (- count (length recs)))))
+        (setq int (icr:next-interval int recur-value vtimezone)))
+      recs)))
+
+
+;; Recurrence set representation
+;;
+;; We represent a recurrence set as a map from intervals to the
+;; recurrences in that interval. The primary purpose of this
+;; representation is to memoize the computation of recurrences, since
+;; the computation is relatively expensive and the results are needed
+;; repeatedly, particularly for time zone observances. The map is stored
+;; in the `:recurrence-set' property of the iCalendar component which
+;; represents the recurring event.
+;;
+;; The macro `icalendar-recur-with-recurrence-set' makes it easy to work
+;; with these maps. Given a component representing a recurring event, it
+;; binds the values for all the recurrence-related properties in the
+;; component, as well as two functions to store and retrieve recurrences
+;; by interval.
+;;
+;; Internally, we use a hash table for the map, since the set can grow
+;; quite large. We use the start date-times of intervals as the keys,
+;; since these uniquely identify intervals within a given component; we
+;; ignore the weekday, zone and dst fields in the keys, mostly to avoid
+;; cache misses during time zone observance lookups, which must generate
+;; intervals with different zone values.
+;;
+;; In order to avoid repeating the computation of recurrences, we store
+;; the keyword `:none' as the value when there are no recurrences in a
+;; given interval. This distinguishes the value from nil, so that,
+;; whereas (gethash some-key the-map) => nil means "We haven't computed
+;; recurrences yet for this interval", (gethash some-key the-map) =>
+;; :none means "We've computed that there are no recurrences in this
+;; interval", and can skip the computation of recurrences. See
+;; `icalendar-recur-recurrences-in-interval', which performs the check.
+
+(defun icr:-make-set ()
+  (make-hash-table :test #'equal))
+
+(defsubst icr:-key-from-interval (interval)
+  (take 6 (car interval))) ; (secs mins hours day month year)
+
+(defun icr:-set-get-interval (component interval)
+  (let ((set (ical:ast-node-meta-get :recurrence-set component))
+        (key (icr:-key-from-interval interval)))
+    (when (hash-table-p set)
+      (gethash key set))))
+
+(defun icr:-set-put-interval (component interval recurrences)
+  (let ((set (or (ical:ast-node-meta-get :recurrence-set component)
+                 (icr:-make-set)))
+        (key (icr:-key-from-interval interval)))
+    (setf (gethash key set) recurrences)
+    (ical:ast-node-meta-set component :recurrence-set set)))
+
+(defun icr:-set-complete-p (component)
+  (let* ((set (ical:ast-node-meta-get :recurrence-set component))
+         (recur-value (ical:with-property-of component 'ical:recur nil value))
+         (count (ical:recur-count recur-value))
+         (n 0)
+         (until (ical:recur-until recur-value))
+         (has-until nil))
+    (when (hash-table-p set)
+      (dolist (recs (hash-table-values set))
+        (cl-incf n (length recs))
+        ;; TODO: This isn't right; the set is only complete if it has
+        ;; recurrences for *all* intervals *up to* UNTIL. May not be
+        ;; worth computing this.
+        (when (and (listp recs) (member until recs))
+          (setq has-until t)))
+      (cond (count (= count n))
+            (until has-until)
+            (t nil)))))
+
+;; TODO: this needs more thought.
+;; The byte compiler doesn't like all these implicit bindings.
+;; Instead, with-component should offer the option to bind
+;; get-interval and put-interval; or these
+;; TODO: get/put-interval might need better names, and should be bound with
+;; cl-labels instead of let, so that they can be put directly in function position
+(defmacro icr:with-recurrence-set (component &rest body)
+  "Execute BODY with bindings for recurrence set properties in COMPONENT.
+
+This macro facilitates memoized computations of the values in
+COMPONENT's recurrence set.
+
+Within BODY, the following symbols are bound as follows:
+ dtstart - the value in COMPONENT's `icalendar-dtstart' property.
+   In `icalendar-standard' and `icalendar-daylight' components,
+   this value includes the UTC offset in the `icalendar-tzoffsetfrom' property.
+ recur-value - the value in COMPONENT's `icalendar-rrule' property.
+ rdates - the values in COMPONENT's `icalendar-rdate' property.
+ exdates - the values in COMPONENT's `icalendar-exdate' property.
+ get-interval - a function which, given an interval, returns the
+   recurrences in that interval cached in COMPONENT's recurrence set.
+   It should be called like:
+   (funcall get-interval INTERVAL)
+ put-interval - a function which, given an interval and a list of recurrences
+   in that interval, caches those recurrences in COMPONENT's recurrence set.
+   It should be called like:
+   (funcall put-interval INTERVAL RECURRENCES)
+"
+  (let ((comp (gensym "icalendar-component"))
+        (offset (gensym "tzoffset"))
+        (recset (gensym "recurrence-set")))
+  `(let ((,comp ,component))
+     (ical:with-component ,comp
+       ((ical:dtstart :value dtstart :value-type dtstart-type)
+        (ical:tzoffsetfrom :value ,offset)
+        (ical:rrule :value recur-value)
+        (ical:rdate :values rdates)
+        (ical:exdate :values exdates))
+       (when (memq (ical:ast-node-type ,comp) '(ical:standard ical:daylight))
+         ;; in time zone observances, set the zone field in dtstart
+         ;; from the TZOFFSETFROM property:
+         (setq dtstart (ical:date-time-variant dtstart :zone ,offset
+                                               :dst (not (ical:daylight-component-p ,comp)))))
+       (if (or recur-value rdates)
+         (let ((,recset (ical:ast-node-meta-get :recurrence-set ,comp)))
+           (unless ,recset
+             (setq ,recset (icr:-make-set))
+             (ical:ast-node-meta-set ,comp :recurrence-set ,recset))
+           (let ((get-interval (apply-partially #'icr:-set-get-interval ,comp))
+                 (put-interval (apply-partially #'icr:-set-put-interval ,comp)))
+             ;; TODO: further functions to test for membership, query by
+             ;; date, etc.?
+             ,@body))
+         ;; TODO: what's the most sensible thing when there's no RRULE or RDATE?
+         ;; Should we still execute body?
+         (error "No recurrence data in this component"))))))
+
+
+;; Timezones:
+
+(define-error 'ical:tz-nonexistent-time "Date-time does not exist")
+
+(define-error 'ical:tz-no-observance "No observance found for date-time")
+
+;; In RFC5545 Section 3.3.10, we read: "If the computed local start time
+;; of a recurrence instance does not exist ... the time of the
+;; recurrence instance is interpreted in the same manner as an explicit
+;; DATE-TIME value describing that date and time, as specified in
+;; Section 3.3.5." which in turn says:
+;; "If, based on the definition of the referenced time zone, the local
+;; time described occurs more than once (when changing from daylight to
+;; standard time), the DATE-TIME value refers to the first occurrence of
+;; the referenced time.  Thus, TZID=America/New_York:20071104T013000
+;; indicates November 4, 2007 at 1:30 A.M. EDT (UTC-04:00).  If the
+;; local time described does not occur (when changing from standard to
+;; daylight time), the DATE-TIME value is interpreted using the UTC
+;; offset before the gap in local times. Thus,
+;; TZID=America/New_York:20070311T023000 indicates March 11, 2007 at
+;; 3:30 A.M. EDT (UTC-04:00), one hour after 1:30 A.M. EST (UTC-05:00)."
+
+;; TODO: verify that these functions are correct for time zones other
+;; than US Eastern.
+(defun icr:nonexistent-date-time-p (dt obs-onset observance)
+  "Return non-nil if DT does not exist in a given OBSERVANCE.
+
+Some local date-times do not exist in a given time zone.  When switching
+from standard to daylight savings time, the local clock time jumps over
+a certain range of times. This function tests whether DT is one of those
+non-existent local times.
+
+DT and OBS-ONSET should be `icalendar-date-time' values; OBS-ONSET
+should be the (local) time immediately at the onset of the
+OBSERVANCE. OBSERVANCE should be an `icalendar-standard' or
+`icalendar-daylight' component.
+
+If this function returns t, then per RFC5545 Sec. 3.3.5, DT must be
+interpreted using the UTC offset in effect prior to the onset of
+OBSERVANCE. For example, at the switch from Standard to Daylight Savings
+time in US Eastern, the nonexistent time 2:30AM (Standard) must be
+re-interpreted as 3:30AM DST."
+  (when (ical:daylight-component-p observance)
+    (ical:with-component observance
+        ((ical:tzoffsetfrom :value offset-from)
+         (ical:tzoffsetto :value offset-to))
+      (and (= (decoded-time-year dt) (decoded-time-year obs-onset))
+           (= (decoded-time-month dt) (decoded-time-month obs-onset))
+           (= (decoded-time-day dt) (decoded-time-day obs-onset))
+           (let* ((onset-secs (+ (decoded-time-second obs-onset)
+                                 (* 60 (decoded-time-minute obs-onset))
+                                 (* 60 60 (decoded-time-hour obs-onset))))
+                  (dt-secs (+ (decoded-time-second dt)
+                              (* 60 (decoded-time-minute dt))
+                              (* 60 60 (decoded-time-hour dt))))
+                  (jumped (abs (- offset-from offset-to)))
+                  (after-jumped (+ onset-secs jumped)))
+             (and
+              (<= onset-secs dt-secs)
+              (< dt-secs after-jumped)))))))
+
+(defun icr:date-time-occurs-twice-p (dt obs-onset observance)
+  "Return non-nil if DT represents a local clock time that occurs twice in
+a given observance.
+
+Some local date-times occur twice in a given time zone.  When switching
+from daylight savings to standard time time, the local clock time is
+typically set back, so that a certain range of clock times occurs twice,
+once in daylight savings time and once in standard time. This function
+tests whether DT is one of those local times which occur twice.
+
+DT and OBS-ONSET should be `icalendar-date-time' values; OBS-ONSET
+should be the (local) time immediately at the relevant onset of the
+OBSERVANCE. OBSERVANCE should be an `icalendar-standard' or
+`icalendar-daylight' component.
+
+If this function returns t, then per RFC5545 Sec. 3.3.5, DT must be
+interpreted as the first occurrence of this clock time, i.e., in
+daylight savings time, prior to OBS-ONSET."
+  (when (ical:standard-component-p observance)
+    (ical:with-component observance
+        ((ical:tzoffsetfrom :value offset-from)
+         (ical:tzoffsetto :value offset-to))
+      (and (= (decoded-time-year dt) (decoded-time-year obs-onset))
+           (= (decoded-time-month dt) (decoded-time-month obs-onset))
+           (= (decoded-time-day dt) (decoded-time-day obs-onset))
+           (let* ((onset-secs (+ (decoded-time-second obs-onset)
+                                 (* 60 (decoded-time-minute obs-onset))
+                                 (* 60 60 (decoded-time-hour obs-onset))))
+                  (dt-secs (+ (decoded-time-second dt)
+                              (* 60 (decoded-time-minute dt))
+                              (* 60 60 (decoded-time-hour dt))))
+                  (repeated (abs (- offset-from offset-to)))
+                  (start-repeateds (- onset-secs repeated)))
+             (and
+              (<= start-repeateds dt-secs)
+              (< dt-secs onset-secs)))))))
+
+(defun icr:tz--get-updated-in (dt obs-onset observance)
+  "Determine how to update DT's zone and dst slots from OBSERVANCE.
+
+DT should be an `icalendar-date-time', OBSERVANCE an
+`icalendar-standard' or `icalendar-daylight', and OBS-ONSET the nearest
+onset of OBSERVANCE before DT. Returns an `icalendar-date-time' that can
+be used to update DT.
+
+In most cases, the return value will contain a zone offset equal to
+OBSERVANCE's `icalendar-tzoffsetto' value.
+
+However, when DT falls within a range of nonexistent times after
+OBS-ONSET, or a range of local times that occur twice (see
+`icalendar-recur-nonexistent-date-time-p' and
+`icalendar-recur-date-time-occurs-twice-p'), it needs to be interpreted
+with the UTC offset in effect prior to the OBS-ONSET of OBSERVANCE (see
+RFC5545 Sec. 3.3.5).  So e.g. at the switch from Standard to Daylight in
+US Eastern, 2:30AM EST (a nonexistent time) becomes 3:30AM EDT, and at
+the switch from Daylight to Standard, 1:30AM (which occurs twice)
+becomes 1:30AM EDT, the first occurence."
+  (ical:with-component observance
+      ((ical:tzoffsetfrom :value offset-from)
+       (ical:tzoffsetto :value offset-to))
+    (let* ((is-daylight (ical:daylight-component-p observance))
+           (to-dt (ical:date-time-variant dt :dst is-daylight :zone offset-to))
+           (from-dt (ical:date-time-variant dt :dst (not is-daylight)
+                                            :zone offset-from))
+          updated)
+      (cond ((icr:nonexistent-date-time-p to-dt obs-onset observance)
+             ;; In this case, RFC5545 requires that we take the same
+             ;; point in absolute time as from-dt, but re-decode it into
+             ;; to-dt's zone:
+             (setq updated (decode-time (encode-time from-dt) offset-to))
+             (setf (decoded-time-dst updated) is-daylight))
+            ((icr:date-time-occurs-twice-p to-dt obs-onset observance)
+             ;; In this case, RFC5545 requires that we interpret dt as
+             ;; from-dt, since that is the first occurrence of the clock
+             ;; time in the zone:
+             (setq updated from-dt))
+            (t
+             ;; Otherwise we interpret dt as to-dt, i.e., with the
+             ;; offset effective within the observance:
+             (setq updated to-dt)))
+      updated)))
+
+(defun icr:tz-for (tzid vtimezones)
+  "Return the `icalendar-vtimezone' for the TZID.
+
+VTIMEZONES should be a list of `icalendar-vtimezone' components.  TZID
+should be a time zone identifier, as found e.g. in an
+`icalendar-tzidparam' parameter. The first time zone in VTIMEZONES whose
+`icalendar-tzid' value matches this parameter's value is returned."
+  (catch 'found
+    (dolist (tz vtimezones)
+      (ical:with-component tz
+          ((ical:tzid :value tzidval))
+        (when (equal tzidval tzid)
+          (throw 'found tz))))))
+
+;; DRAGONS DRAGONS DRAGONS
+(defun icr:tz-observance-on (dt vtimezone &optional update nonexisting)
+  "Return the time zone observance in effect on DT in VTIMEZONE.
+
+If there is such an observance, the returned value is a list (OBSERVANCE
+ONSET). OBSERVANCE is an `icalendar-standard' or `icalendar-daylight'
+component node. ONSET is the recurrence of OBSERVANCE (an
+`icalendar-date-time') which occurs closest in time, but before, DT.
+
+If there is no such observance in VTIMEZONE, the returned value is nil.
+
+VTIMEZONE should be an `icalendar-vtimezone' component node.
+
+DT may be an an `icalendar-date-time' or a Lisp timestamp. If it is a
+date-time, it represents a local time assumed to be in VTIMEZONE. Any
+existing offset in DT is ignored, and DT is compared with the local
+clock time at the start of each observance in VTIMEZONE to determine the
+correct observance and onset. (This is so that the correct observance
+can be found for clock times generated during recurrence rule
+calculations.)
+
+If UPDATE is non-nil, the observance found will be used to update the
+offset value in DT (as a side effect) before returning the observance
+and onset.
+
+If UPDATE is non-nil, NONEXISTING specifies how to handle clock times
+that do not exist in the observance (see
+`icalendar-recur-tz-nonexistent-date-time-p').  The keyword `:error'
+means to signal an \\='icalendar-tz-nonexistent-time error, without
+modifying any of the fields in DT.  Otherwise, the default is to
+interpret DT using the offset from UTC before the onset of the found
+observance, and then reset the clock time in DT to the corresponding
+existing time after the onset of the observance.  For example, the
+nonexisting time 2:30AM in Standard time on the day of the switch to
+Daylight time in the US Eastern time zone will be reset to 3:30AM
+Eastern Daylight time.
+
+If DT is a Lisp timestamp, it represents an absolute time and
+comparisons with the onsets in VTIMEZONE are performed with absolute
+times. UPDATE and NONEXISTING have no meaning in this case and are
+ignored."
+  (ical:with-component vtimezone
+    ((ical:standard :all stds)
+     (ical:daylight :all dls))
+    (let (given-abs-time     ;; = `dt', if given a Lisp timestamp
+          given-clock-time   ;; = `dt', if given a decoded time
+          nearest-observance ;; the observance we're looking for
+          nearest-onset      ;; latest onset of this observance before `dt'
+          updated)           ;; stores how `dt's fields should be updated
+                             ;; in line with this observance, if requested
+
+      (if (cl-typep dt 'ical:date-time)
+          ;; We were passed a date-time with local clock time, not an
+          ;; absolute time; in this case, we must make local clock time
+          ;; comparisons with the observance onset start and recurrences
+          ;; (in order to determine the correct offset for it within the
+          ;; zone)
+          (setq given-clock-time dt
+                given-abs-time nil)
+        ;; We were passed an absolute time, not a date-time; in this
+        ;; case, we can make comparisons in absolute time with
+        ;; observance onset start and recurrences (in order to determine
+        ;; the correct offset for decoding it)
+        (setq given-abs-time dt
+              given-clock-time nil))
+
+      (dolist (obs (append stds dls))
+        (ical:with-component obs
+          ((ical:dtstart :value start)
+           (ical:rrule :value recur-value)
+           (ical:rdate :values rdates)
+           (ical:tzoffsetfrom :value offset-from))
+          ;; DTSTART of the observance must be given as local time, and is
+          ;; combined with TZOFFSETFROM to define the effective onset
+          ;; for the observance in absolute time.
+          (let* ((is-daylight (ical:daylight-component-p obs))
+                 (effective-start
+                  (ical:date-time-variant start :zone offset-from
+                                          :dst (not is-daylight)))
+                 (observance-might-apply
+                  (if given-clock-time
+                      (ical:date-time-locally<= effective-start given-clock-time)
+                    (ical:time<= (encode-time effective-start) given-abs-time))))
+
+            (when observance-might-apply
+              ;; Initialize our return values on the first iteration
+              ;; where an observance potentially applies:
+              (unless nearest-onset
+                (setq nearest-onset effective-start
+                      nearest-observance obs)
+                (when (and update given-clock-time)
+                  (setq updated
+                        (icr:tz--get-updated-in given-clock-time
+                                                effective-start obs))))
+
+              ;; We first check whether any RDATEs in the observance are
+              ;; the relevant onset:
+              (dolist (rd rdates)
+                (let* ((effective-rd
+                        (ical:date-time-variant rd :zone offset-from
+                                                :dst (not is-daylight)))
+                       (onset-applies
+                        (if given-clock-time
+                            (ical:date-time-locally<= effective-rd
+                                                      given-clock-time)
+                          (ical:time<= (encode-time effective-rd)
+                                       given-abs-time))))
+
+                  (when (and onset-applies nearest-onset
+                             (ical:date-time< nearest-onset effective-rd))
+                    (setq nearest-onset effective-rd
+                          nearest-observance obs)
+
+                    (when (and update given-clock-time)
+                      (setq updated
+                            (icr:tz--get-updated-in given-clock-time
+                                                    effective-rd obs))))))
+
+              ;; If the observance has a recurrence value, it's the
+              ;; relevant observance if it:
+              ;; (1) has a recurrence which starts before dt
+              ;; (2) that recurrence is the nearest in the zone
+              ;;     which starts before dt
+              ;; Note that we intentionally do *not* pass `vtimezone'
+              ;; through here to find-interval, recurrences-in-interval,
+              ;; etc. so as not to cause infinite recursion. Instead we
+              ;; directly pass `offset-from' (the offset from UTC at the
+              ;; start of each observance onset), which
+              ;; `icr:tz-set-zone' knows to handle specially without
+              ;; calling this function.
+              (when recur-value
+                (let* ((target (or given-clock-time
+                                   (decode-time given-abs-time offset-from)))
+                       (int (icr:find-interval
+                             target effective-start recur-value offset-from))
+                       (int-recs (icr:recurrences-in-interval
+                                  int obs offset-from))
+                       ;; The closest observance onset before `dt' might
+                       ;; actually be in the previous interval, e.g.
+                       ;; if `dt' is in January after an annual change to
+                       ;; Standard Time in November. So check that as well.
+                       (prev-int (icr:previous-interval int recur-value
+                                                        effective-start
+                                                        offset-from))
+                       (prev-recs (when prev-int
+                                    (icr:recurrences-in-interval
+                                     prev-int obs offset-from)))
+                       (recs (append prev-recs int-recs))
+                       (keep-recs<=given
+                        (if given-clock-time
+                            (lambda (rec)
+                              (ical:date-time-locally<= rec given-clock-time))
+                          (lambda (rec)
+                            (ical:time<= (encode-time rec) given-abs-time))))
+                       (srecs (sort (seq-filter ; (1)
+                                     keep-recs<=given
+                                     recs)
+                                    :lessp #'ical:date-time<
+                                    :in-place t :reverse t))
+                       (latest-rec (car srecs)))
+
+                  (when (and latest-rec
+                             (ical:date-time< nearest-onset latest-rec)) ; (2)
+                    (setf (decoded-time-dst latest-rec)
+                          ;; if obs is a DAYLIGHT observance, latest-rec
+                          ;; represents the last moment of standard time, and
+                          ;; vice versa
+                          (not is-daylight))
+                    (setq nearest-onset latest-rec
+                          nearest-observance obs)
+                    (when (and update given-clock-time)
+                      (setq updated
+                            (icr:tz--get-updated-in given-clock-time
+                                                    latest-rec obs))))))))))
+
+      ;; We've now found the nearest observance, if there was one.
+      ;; Update `dt' as a side effect if requested.  This saves
+      ;; repeating a lot of the above in a separate function.
+      (when (and update given-clock-time nearest-observance updated)
+        ;; signal an error when `dt' does not exist if requested, so the
+        ;; nonexistence can be handled further up the stack:
+        (when (and (eq :error nonexisting)
+                   (not (ical:date-time-locally-simultaneous-p dt updated)))
+          (signal 'ical:tz-nonexistent-time
+                  (list (format "%d-%02d-%02d %02d:%02d:%02d does not exist in %s"
+                                (decoded-time-year dt)
+                                (decoded-time-month dt)
+                                (decoded-time-day dt)
+                                (decoded-time-hour dt)
+                                (decoded-time-minute dt)
+                                (decoded-time-second dt)
+                                (or
+                                 (ical:with-property-of nearest-observance
+                                                        'ical:tzname nil value)
+                                 "time zone observance"))
+                        dt nearest-observance)))
+        ;; otherwise we copy `updated' over to `dt', which resets the
+        ;; clock time in `dt' if it did not exist:
+        (setf (decoded-time-zone dt) (decoded-time-zone updated))
+        (setf (decoded-time-dst dt) (decoded-time-dst updated))
+        (setf (decoded-time-second dt) (decoded-time-second updated))
+        (setf (decoded-time-minute dt) (decoded-time-minute updated))
+        (setf (decoded-time-hour dt) (decoded-time-hour updated))
+        (setf (decoded-time-day dt) (decoded-time-day updated))
+        (setf (decoded-time-month dt) (decoded-time-month updated))
+        (setf (decoded-time-year dt) (decoded-time-year updated))
+        (setf (decoded-time-weekday dt)
+              (calendar-day-of-week (ical:date-time-to-date updated))))
+
+      ;; Return the observance and onset if found, nil if not:
+      (when nearest-observance
+        (list nearest-observance nearest-onset)))))
+
+(defun icr:tz-offset-in (observance)
+  "Return the offset (in seconds) from UTC in effect during OBSERVANCE.
+
+OBSERVANCE should be an `icalendar-standard' or `icalendar-daylight'
+subcomponent of a particular `icalendar-vtimezone'. The returned value
+is the value of its `icalendar-tzoffsetto' property."
+  (ical:with-property-of observance 'ical:tzoffsetto nil value))
+
+(defun icr:tz-decode-time (ts vtimezone)
+  "Decode Lisp timestamp TS with the appropriate offset in VTIMEZONE.
+
+VTIMEZONE should be an `icalendar-vtimezone' component node. The correct
+observance for TS will be looked up in VTIMEZONE, TS will be decoded
+with the UTC offset of that observance, and its dst slot will be set
+based on whether the observance is an `icalendar-standard' or
+`icalendar-daylight' component.  If VTIMEZONE does not have an
+observance that applies to TS, it is decoded into UTC time.
+
+VTIMEZONE may also be an `icalendar-utc-offset'. In this case TS is
+decoded directly into this UTC offset, and its dst slot is set to -1."
+  (let* ((observance (when (ical:vtimezone-component-p vtimezone)
+                       (car (icr:tz-observance-on ts vtimezone))))
+         (offset (cond (observance (icr:tz-offset-in observance))
+                       ((cl-typep vtimezone 'ical:utc-offset)
+                        vtimezone)
+                       (t 0))))
+
+    (ical:date-time-variant ; ensures weekday gets set, too
+     (decode-time ts offset)
+     :zone offset
+     :dst (if observance (ical:daylight-component-p observance)
+            -1))))
+
+(defun icr:tz-set-zone (dt vtimezone &optional nonexisting)
+  "Set the time zone offset and dst flag in DT based on VTIMEZONE.
+
+DT should be an `icalendar-date-time' and VTIMEZONE should be an
+`icalendar-vtimezone'. VTIMEZONE can also be an `icalendar-utc-offset',
+in which case this value is directly set in DT's zone field (without
+changing its dst flag). The updated DT is returned.
+
+This function generally sets only the zone and dst slots of DT, without
+changing the other slots; its main purpose is to adjust date-times
+generated from other date-times during recurrence rule calculations,
+where a different time zone observance may be in effect in the original
+date-time. It cannot be used to re-decode a fixed point in time into a
+different time zone; for that, see `icalendar-recur-tz-decode-time'.
+
+If given, NONEXISTING is a keyword that specifies what to do if DT
+represents a clock time that does not exist according to the relevant
+observance in VTIMEZONE. The value :error means to signal an
+\\='icalendar-tz-nonexistent-time error, and nil means to reset the
+clock time in DT to an existing one; see
+`icalendar-recur-tz-observance-on'."
+  (if (cl-typep vtimezone 'ical:utc-offset)
+      ;; This is where the recurrence rule/time zone mutual dependence
+      ;; bottoms out; don't remove this conditional!
+      (setf (decoded-time-zone dt) vtimezone)
+
+    ;; Otherwise, if there's already zone information in dt, trust it
+    ;; without looking up the observance.  This is partly a performance
+    ;; optimization (because the lookup is expensive) and partly about
+    ;; avoiding problems: looking up the observance uses the clock time
+    ;; in dt without considering the zone information, and doing this
+    ;; when dt has already been adjusted to contain valid zone
+    ;; information can invalidate that information.
+    ;;
+    ;; It's reliable to skip the lookup when dt already contains zone
+    ;; information only because `icalendar-make-date-time',
+    ;; `icalendar-date/time-add', and in particular
+    ;; `icalendar-date-time-variant' are careful to remove the UTC
+    ;; offset and DST information in the date-times they construct,
+    ;; unless provided with enough information to fill those slots.
+    (unless (and (cl-typep dt 'ical:date-time)
+                 (decoded-time-zone dt)
+                 (booleanp (decoded-time-dst dt)))
+      ;; This updates the relevant slots in dt as a side effect:
+      ;; TODO: if no observance is found, is it ever sensible to signal an error,
+      ;; instead of just leaving the zone slot unset?
+      (icr:tz-observance-on dt vtimezone t nonexisting)))
+    dt)
+
+(defun icr:tz-set-zones-in (vtimezones node)
+  "Recursively set time zone offset and dst flags in times in NODE.
+
+VTIMEZONES should be a list of the `icalendar-vtimezone' components in
+the calendar containing NODE. NODE can be any iCalendar syntax node. If
+NODE is a property node with an `icalendar-tzidparam' parameter and an
+`icalendar-date-time' value, the appropriate time zone observance for
+its value is looked up in VTIMEZONES, and used to the set the zone and
+dst slots in its value. Otherwise, the function is called recursively for "
+  (cond
+   ((ical:property-node-p node)
+    (ical:with-property node
+      ((ical:tzidparam :value tzid))
+      (when (and tzid (cl-typep value 'ical:date-time))
+        (let* ((tz (icr:tz-for tzid vtimezones))
+               (updated (icr:tz-set-zone value tz)))
+          (ical:ast-node-meta-set node :value updated)))))
+   ((ical:component-node-p node)
+    (mapc (apply-partially #'icr:tz-set-zones-in vtimezones)
+          (ical:ast-node-children node)))
+   (t nil)))
+
+
+(provide 'icalendar-recur)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-") ("icr:" . "icalendar-recur-"))
+;; End:
+;;; icalendar-recur.el ends here
diff --git a/lisp/calendar/icalendar-uri-schemes.el b/lisp/calendar/icalendar-uri-schemes.el
new file mode 100644
index 00000000000..c94a36c13d8
--- /dev/null
+++ b/lisp/calendar/icalendar-uri-schemes.el
@@ -0,0 +1,444 @@
+;;; icalendar-uri-schemes.el --- URI schemes in iCalendar -*- lexical-binding:t -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <[email-masked]>
+;; Maintainer: [email-masked]
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines one (large) regular expression, ical:uri-scheme,
+;; to match URI schemes registered with IANA.
+;;
+;; The schemes are listed at:
+;;   https://www.iana.org/assignments/uri-schemes/uri-schemes.txt
+;; Note the licensing terms for this list, available at:
+;;   https://www.iana.org/help/licensing-terms
+;; which as of 2024-10-24 says:
+;;
+;;   IANA and IETF desire to (a) dedicate any applicable copyright
+;;   rights that they may own in the Protocol Registries to the public
+;;   domain, and (b) license any copyright or related rights for which
+;;   they are a licensee (with a right to sublicense) to the broadest
+;;   extent that they are permitted to do so. Accordingly, both IANA
+;;   and IETF affirm that any applicable rights that they may have in
+;;   the Protocol Registries are subject to the Creative Commons CC0
+;;   1.0 dedication found at
+;;   https://creativecommons.org/publicdomain/zero/1.0/legalcode
+;;
+;; This file is current as of 2024-10-24.
+
+;;; Code:
+(require 'rx)
+
+(rx-define ical:uri-scheme (or
+"aaa"
+"aaas"
+"about"
+"acap"
+"acct"
+"acd"
+"acr"
+"adiumxtra"
+"adt"
+"afp"
+"afs"
+"aim"
+"amss"
+"android"
+"appdata"
+"apt"
+"ar"
+"ark"
+"at"
+"attachment"
+"aw"
+"barion"
+"bb"
+"beshare"
+"bitcoin"
+"bitcoincash"
+"blob"
+"bluetooth"
+"bolo"
+"brid"
+"browserext"
+"cabal"
+"calculator"
+"callto"
+"cap"
+"cast"
+"casts"
+"chrome"
+"chrome-extension"
+"cid"
+"coap"
+"coap+tcp"
+"coap+ws"
+"coaps"
+"coaps+tcp"
+"coaps+ws"
+"com-eventbrite-attendee"
+"content"
+"content-type"
+"crid"
+"cstr"
+"cvs"
+"dab"
+"dat"
+"data"
+"dav"
+"dhttp"
+"diaspora"
+"dict"
+"did"
+"dis"
+"dlna-playcontainer"
+"dlna-playsingle"
+"dns"
+"dntp"
+"doi"
+"dpp"
+"drm"
+"drop"
+"dtmi"
+"dtn"
+"dvb"
+"dvx"
+"dweb"
+"ed2k"
+"eid"
+"elsi"
+"embedded"
+"ens"
+"ethereum"
+"example"
+"facetime"
+"fax"
+"feed"
+"feedready"
+"fido"
+"file"
+"filesystem"
+"finger"
+"first-run-pen-experience"
+"fish"
+"fm"
+"ftp"
+"fuchsia-pkg"
+"geo"
+"gg"
+"git"
+"gitoid"
+"gizmoproject"
+"go"
+"gopher"
+"graph"
+"grd"
+"gtalk"
+"h323"
+"ham"
+"hcap"
+"hcp"
+"hs20"
+"http"
+"https"
+"hxxp"
+"hxxps"
+"hydrazone"
+"hyper"
+"iax"
+"icap"
+"icon"
+"im"
+"imap"
+"info"
+"iotdisco"
+"ipfs"
+"ipn"
+"ipns"
+"ipp"
+"ipps"
+"irc"
+"irc6"
+"ircs"
+"iris"
+"iris.beep"
+"iris.lwz"
+"iris.xpc"
+"iris.xpcs"
+"isostore"
+"itms"
+"jabber"
+"jar"
+"jms"
+"keyparc"
+"lastfm"
+"lbry"
+"ldap"
+"ldaps"
+"leaptofrogans"
+"lid"
+"lorawan"
+"lpa"
+"lvlt"
+"machineProvisioningProgressReporter"
+"magnet"
+"mailserver"
+"mailto"
+"maps"
+"market"
+"matrix"
+"message"
+"microsoft.windows.camera"
+"microsoft.windows.camera.multipicker"
+"microsoft.windows.camera.picker"
+"mid"
+"mms"
+"modem"
+"mongodb"
+"moz"
+"ms-access"
+"ms-appinstaller"
+"ms-browser-extension"
+"ms-calculator"
+"ms-drive-to"
+"ms-enrollment"
+"ms-excel"
+"ms-eyecontrolspeech"
+"ms-gamebarservices"
+"ms-gamingoverlay"
+"ms-getoffice"
+"ms-help"
+"ms-infopath"
+"ms-inputapp"
+"ms-launchremotedesktop"
+"ms-lockscreencomponent-config"
+"ms-media-stream-id"
+"ms-meetnow"
+"ms-mixedrealitycapture"
+"ms-mobileplans"
+"ms-newsandinterests"
+"ms-officeapp"
+"ms-people"
+"ms-personacard"
+"ms-project"
+"ms-powerpoint"
+"ms-publisher"
+"ms-recall"
+"ms-remotedesktop"
+"ms-remotedesktop-launch"
+"ms-restoretabcompanion"
+"ms-screenclip"
+"ms-screensketch"
+"ms-search"
+"ms-search-repair"
+"ms-secondary-screen-controller"
+"ms-secondary-screen-setup"
+"ms-settings"
+"ms-settings-airplanemode"
+"ms-settings-bluetooth"
+"ms-settings-camera"
+"ms-settings-cellular"
+"ms-settings-cloudstorage"
+"ms-settings-connectabledevices"
+"ms-settings-displays-topology"
+"ms-settings-emailandaccounts"
+"ms-settings-language"
+"ms-settings-location"
+"ms-settings-lock"
+"ms-settings-nfctransactions"
+"ms-settings-notifications"
+"ms-settings-power"
+"ms-settings-privacy"
+"ms-settings-proximity"
+"ms-settings-screenrotation"
+"ms-settings-wifi"
+"ms-settings-workplace"
+"ms-spd"
+"ms-stickers"
+"ms-sttoverlay"
+"ms-transit-to"
+"ms-useractivityset"
+"ms-virtualtouchpad"
+"ms-visio"
+"ms-walk-to"
+"ms-whiteboard"
+"ms-whiteboard-cmd"
+"ms-word"
+"msnim"
+"msrp"
+"msrps"
+"mss"
+"mt"
+"mtqp"
+"mumble"
+"mupdate"
+"mvn"
+"mvrp"
+"mvrps"
+"news"
+"nfs"
+"ni"
+"nih"
+"nntp"
+"notes"
+"num"
+"ocf"
+"oid"
+"onenote"
+"onenote-cmd"
+"opaquelocktoken"
+"openid"
+"openpgp4fpr"
+"otpauth"
+"p1"
+"pack"
+"palm"
+"paparazzi"
+"payment"
+"payto"
+"pkcs11"
+"platform"
+"pop"
+"pres"
+"prospero"
+"proxy"
+"pwid"
+"psyc"
+"pttp"
+"qb"
+"query"
+"quic-transport"
+"redis"
+"rediss"
+"reload"
+"res"
+"resource"
+"rmi"
+"rsync"
+"rtmfp"
+"rtmp"
+"rtsp"
+"rtsps"
+"rtspu"
+"sarif"
+"secondlife"
+"secret-token"
+"service"
+"session"
+"sftp"
+"sgn"
+"shc"
+"shttp"
+"sieve"
+"simpleledger"
+"simplex"
+"sip"
+"sips"
+"skype"
+"smb"
+"smp"
+"sms"
+"smtp"
+"snews"
+"snmp"
+"soap.beep"
+"soap.beeps"
+"soldat"
+"spiffe"
+"spotify"
+"ssb"
+"ssh"
+"starknet"
+"steam"
+"stun"
+"stuns"
+"submit"
+"svn"
+"swh"
+"swid"
+"swidpath"
+"tag"
+"taler"
+"teamspeak"
+"tel"
+"teliaeid"
+"telnet"
+"tftp"
+"things"
+"thismessage"
+"thzp"
+"tip"
+"tn3270"
+"tool"
+"turn"
+"turns"
+"tv"
+"udp"
+"unreal"
+"upt"
+"urn"
+"ut2004"
+"uuid-in-package"
+"v-event"
+"vemmi"
+"ventrilo"
+"ves"
+"videotex"
+"vnc"
+"view-source"
+"vscode"
+"vscode-insiders"
+"vsls"
+"w3"
+"wais"
+"web3"
+"wcr"
+"webcal"
+"web+ap"
+"wifi"
+"wpid"
+"ws"
+"wss"
+"wtai"
+"wyciwyg"
+"xcon"
+"xcon-userid"
+"xfire"
+"xmlrpc.beep"
+"xmlrpc.beeps"
+"xmpp"
+"xftp"
+"xrcp"
+"xri"
+"ymsgr"
+"z39.50"
+"z39.50r"
+"z39.50s"
+))
+
+(provide 'icalendar-uri-schemes)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-uri-schemes.el ends here
diff --git a/lisp/calendar/icalendar-utils.el b/lisp/calendar/icalendar-utils.el
new file mode 100644
index 00000000000..193e5d32f75
--- /dev/null
+++ b/lisp/calendar/icalendar-utils.el
@@ -0,0 +1,603 @@
+;;; icalendar-utils.el --- iCalendar utility functions  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <[email-masked]>
+;; Created: January 2025
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+(require 'cl-lib)
+(require 'icalendar-macs)
+(require 'icalendar-parser)
+
+;; Accessors for commonly used properties
+
+(defun ical:component-dtstart (component)
+  "Return the value of the `icalendar-dtstart' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:dtstart nil value))
+
+(defun ical:component-dtend (component)
+  "Return the value of the `icalendar-dtend' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:dtend nil value))
+
+(defun ical:component-rdate (component)
+  "Return the value of the `icalendar-rdate' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:rdate nil value))
+
+(defun ical:component-summary (component)
+  "Return the value of the `icalendar-summary' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:summary nil value))
+
+(defun ical:component-description (component)
+  "Return the value of the `icalendar-description' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:description nil value))
+
+(defun ical:component-uid (component)
+  "Return the value of the `icalendar-uid' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:uid nil value))
+
+(defun ical:component-url (component)
+  "Return the value of the `icalendar-url' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:url nil value))
+
+;; Date/time
+;; TODO: could turn all the 'date/time' functions into methods dispatched by type
+(defun ical:date-time-to-date (dt)
+  "Convert an `icalendar-date-time' value DT to an `icalendar-date'"
+  (list (decoded-time-month dt)
+        (decoded-time-day dt)
+        (decoded-time-year dt)))
+
+(cl-defun ical:date-to-date-time (dt &key (hour 0) (minute 0) (second 0) (tz nil))
+  "Convert an `icalendar-date' value DT to an `icalendar-date-time'.
+
+The following keyword arguments are accepted:
+  :hour, :minute, :second - integers representing a local clock time on date DT
+  :tz - an `icalendar-vtimezone' in which to interpret this clock time
+
+If these arguments are all unspecified, the hour, minute, and second
+slots of the returned date-time will be zero, and it will contain no
+time zone information. See `icalendar-make-date-time' for more on these
+arguments."
+  (ical:make-date-time
+   :year (calendar-extract-year dt)
+   :month (calendar-extract-month dt)
+   :day (calendar-extract-day dt)
+   :hour hour
+   :minute minute
+   :second second
+   :tz tz))
+
+(defun ical:date/time-to-date (dt)
+  "Extract a Gregorian date from DT.  An `icalendar-date'
+value is returned unchanged; an `icalendar-date-time' value is
+converted to an `icalendar-date'."
+  (if (cl-typep dt 'ical:date)
+      dt
+    (ical:date-time-to-date dt)))
+
+;; Type-aware accessors for date/time slots that work for both
+;; ical:date and ical:date-time:
+;; NOTE: cl-typecase ONLY works here if dt is a valid decoded-time with all slots!
+;; May need to adjust this if it's necessary to work with incomplete decoded-times
+(defun ical:date/time-year (dt)
+  (cl-typecase dt
+    (ical:date (calendar-extract-year dt))
+    (ical:date-time (decoded-time-year dt))))
+
+(defun ical:date/time-month (dt)
+  (cl-typecase dt
+    (ical:date (calendar-extract-month dt))
+    (ical:date-time (decoded-time-month dt))))
+
+(defun ical:date/time-monthday (dt)
+  (cl-typecase dt
+    (ical:date (calendar-extract-day dt))
+    (ical:date-time (decoded-time-day dt))))
+
+(defun ical:date/time-weekno (dt &optional weekstart)
+  ;; TODO: Add support for weekstart.
+  ;; calendar-iso-from-absolute doesn't support this yet.
+  (when (and weekstart (not (= weekstart 1)))
+    (error "Support for WEEKSTART other than 1 (=Monday) not implemented yet"))
+  (let* ((gdate (ical:date/time-to-date dt))
+         (isodate (calendar-iso-from-absolute
+                   (calendar-absolute-from-gregorian gdate)))
+         (weekno (car isodate)))
+    weekno))
+
+(defun ical:date/time-weekday (dt)
+  (cl-typecase dt
+    (ical:date (calendar-day-of-week dt))
+    (ical:date-time
+     (or (decoded-time-weekday dt)
+         ;; compensate for possibly-nil weekday slot if the date-time
+         ;; has been constructed by `make-decoded-time'; cf. comment
+         ;; in `icalendar--decoded-date-time-p':
+         (calendar-day-of-week (ical:date-time-to-date dt))))))
+
+(defun ical:date/time-hour (dt)
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-hour dt)))
+
+(defun ical:date/time-minute (dt)
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-minute dt)))
+
+(defun ical:date/time-second (dt)
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-second dt)))
+
+(defun ical:date/time-zone (dt)
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-zone dt)))
+
+;;; Date/time comparisons and arithmetic:
+(defun ical:date< (dt1 dt2)
+  "Return non-nil if date DT1 is strictly earlier than date DT2.
+DT1 and DT2 must both be `icalendar-date' values of the form (MONTH DAY YEAR)."
+  (< (calendar-absolute-from-gregorian dt1)
+     (calendar-absolute-from-gregorian dt2)))
+
+(defun ical:date<= (dt1 dt2)
+  "Return non-nil if date DT1 is earlier than or the same date as DT2.
+DT1 and DT2 must both be `icalendar-date' values of the form (MONTH DAY YEAR)."
+  (or (calendar-date-equal dt1 dt2) (ical:date< dt1 dt2)))
+
+(defun ical:date-time-locally-earlier (dt1 dt2 &optional or-equal)
+  "Return non-nil if date-time DT1 is locally earlier than DT2.
+
+Unlike `icalendar-date-time<', this function assumes both times are
+local to some time zone and does not consider their zone information.
+
+If OR-EQUAL is non-nil, this function acts like `<=' rather than `<':
+it will return non-nil if DT1 and DT2 are locally the same time."
+  (let ((year1 (decoded-time-year dt1))
+        (year2 (decoded-time-year dt2))
+        (month1 (decoded-time-month dt1))
+        (month2 (decoded-time-month dt2))
+        (day1 (decoded-time-day dt1))
+        (day2 (decoded-time-day dt2))
+        (hour1 (decoded-time-hour dt1))
+        (hour2 (decoded-time-hour dt2))
+        (minute1 (decoded-time-minute dt1))
+        (minute2 (decoded-time-minute dt2))
+        (second1 (decoded-time-second dt1))
+        (second2 (decoded-time-second dt2)))
+    (or (< year1 year2)
+        (and (= year1 year2)
+             (or (< month1 month2)
+                 (and (= month1 month2)
+                      (or (< day1 day2)
+                          (and (= day1 day2)
+                               (or (< hour1 hour2)
+                                   (and (= hour1 hour2)
+                                        (or (< minute1 minute2)
+                                            (and (= minute1 minute2)
+                                                 (if or-equal
+                                                     (<= second1 second2)
+                                                   (< second1 second2))))))))))))))
+
+(defun ical:date-time-locally< (dt1 dt2)
+  "Return non-nil if date-time DT1 is locally strictly earlier than DT2.
+
+Unlike `icalendar-date-time<', this function assumes both times are
+local to some time zone and does not consider their zone information."
+  (ical:date-time-locally-earlier dt1 dt2 nil))
+
+(defun ical:date-time-locally<= (dt1 dt2)
+  "Return non-nil if date-time DT1 is locally earlier than, or equal to, DT2.
+
+Unlike `icalendar-date-time<=', this function assumes both times are
+local to some time zone and does not consider their zone information."
+  (ical:date-time-locally-earlier dt1 dt2 t))
+
+(defun ical:date-time< (dt1 dt2)
+  "Return non-nil if date-time DT1 is strictly earlier than DT2.
+
+DT1 and DT2 must both be decoded times, and either both or neither
+should have time zone information.
+
+If one has a time zone offset and the other does not, the offset
+returned from `current-time-zone' is used as the missing offset; if
+`current-time-zone' cannot provide this information, an error is
+signaled."
+  (let ((zone1 (decoded-time-zone dt1))
+        (zone2 (decoded-time-zone dt2)))
+    (cond ((and (integerp zone1) (integerp zone2))
+           (time-less-p (encode-time dt1) (encode-time dt2)))
+          ((and (null zone1) (null zone2))
+           (ical:date-time-locally< dt1 dt2))
+          (t
+           ;; Cf. RFC5545 Sec. 3.3.5:
+           ;; "The recipient of an iCalendar object with a property value
+           ;; consisting of a local time, without any relative time zone
+           ;; information, SHOULD interpret the value as being fixed to whatever
+           ;; time zone the "ATTENDEE" is in at any given moment.  This means
+           ;; that two "Attendees", in different time zones, receiving the same
+           ;; event definition as a floating time, may be participating in the
+           ;; event at different actual times.  Floating time SHOULD only be
+           ;; used where that is the reasonable behavior."
+           ;; I'm interpreting this to mean that if we get here, where
+           ;; one date-time has zone information and the other doesn't,
+           ;; we should use the offset from (current-time-zone).
+           (let* ((user-tz (current-time-zone))
+                  (user-offset (car user-tz))
+                  (dt1z (ical:date-time-variant dt1 :zone (or zone1 user-offset)))
+                  (dt2z (ical:date-time-variant dt2 :zone (or zone2 user-offset))))
+             (if user-offset
+                 (time-less-p (encode-time dt1z) (encode-time dt2z))
+               (error "Too little zone information for comparison: %s %s"
+                      dt1 dt2)))))))
+
+;; Two different notions of equality are relevant to decoded times:
+;; strict equality (`icalendar-date-time=') of all slots, or
+;; simultaneity (`icalendar-date-time-simultaneous-p').
+;; Most tests probably want the strict notion, because it distinguishes
+;; between simultaneous events decoded into different time zones,
+;; whereas most user-facing functions (e.g. sorting events by date and time)
+;; probably want simultaneity.
+(defun ical:date-time= (dt1 dt2)
+  "Return non-nil if DT1 and DT2 are decoded-times with identical slot values.
+
+Note that this function returns nil if DT1 and DT2 represent times in
+different time zones, even if they are simultaneous. For the latter, see
+`icalendar-date-time-simultaneous-p'."
+  (equal dt1 dt2))
+
+(defun ical:date-time-locally-simultaneous-p (dt1 dt2)
+  "Return non-nil if DT1 and DT2 are locally simultaneous date-times.
+Note that this function ignores zone information in dt1 and dt2. It
+returns non-nil if DT1 and DT2 represent the same clock time in
+different time zones, even if they encode to different absolute times."
+  (and (eq (decoded-time-year dt1)   (decoded-time-year dt2))
+       (eq (decoded-time-month dt1)  (decoded-time-month dt2))
+       (eq (decoded-time-day dt1)    (decoded-time-day dt2))
+       (eq (decoded-time-hour dt1)   (decoded-time-hour dt2))
+       (eq (decoded-time-minute dt1) (decoded-time-minute dt2))
+       (eq (decoded-time-second dt1) (decoded-time-second dt2))))
+
+(defun ical:date-time-simultaneous-p (dt1 dt2)
+  "Return non-nil if DT1 and DT2 are simultaneous date-times.
+
+This function returns non-nil if DT1 and DT2 encode to the same Lisp
+timestamp. Thus they can count as simultaneous even if they represent
+times in different timezones. If both date-times lack an offset from
+UTC, they are treated as simultaneous if they encode to the same
+timestamp in UTC.
+
+If only one date-time has an offset, they are treated as
+non-simultaneous if they represent different clock times according to
+`icalendar-date-time-locally-simultaneous-p'.  Otherwise an error is
+signaled."
+  (let ((zone1 (decoded-time-zone dt1))
+        (zone2 (decoded-time-zone dt2)))
+    (cond ((and (integerp zone1) (integerp zone2))
+           (time-equal-p (encode-time dt1) (encode-time dt2)))
+          ((and (null zone1) (null zone2))
+           (time-equal-p (encode-time (ical:date-time-variant dt1 :zone 0))
+                         (encode-time (ical:date-time-variant dt2 :zone 0))))
+          (t
+           ;; Best effort:
+           ;; TODO: I'm not convinced this is the right thing to do yet.
+           ;; Might want to be stricter here and fix the problem of comparing
+           ;; times with and without zone information elsewhere.
+           (if (ical:date-time-locally-simultaneous-p dt1 dt2)
+               (error "Missing zone information: %s %s" dt1 dt2)
+             nil)))))
+
+(defun ical:date-time<= (dt1 dt2)
+  "Return non-nil if DT1 is earlier than, or simultaneous with, DT2.
+DT1 and DT2 must both be decoded times, and either both or neither must have
+time zone information."
+  (or (ical:date-time< dt1 dt2)
+      (ical:date-time-simultaneous-p dt1 dt2)))
+
+;; TODO: do we still need the constraint that both be of the same type, given
+;; the accessors above? Couldn't we compare a date with a date-time on the basis
+;; of the date fields alone, or assume a plain date has a time of 00:00:00?
+(defun ical:date/time< (dt1 dt2)
+  "Return non-nil if the date or date-time DT1 is strictly earlier than DT2.
+DT1 and DT2 must be either calendar.el dates or decoded-time
+values, and both must be of the same type."
+  (cl-typecase dt1
+    (ical:date
+     (if (cl-typep dt2 'ical:date)
+         (ical:date< dt1 dt2)
+       (error "Dates must be of the same type: %s, %s" dt1 dt2)))
+
+    (ical:date-time
+     (if (cl-typep dt2 'ical:date-time)
+         (ical:date-time< dt1 dt2)
+       (error "Dates must be of the same type: %s, %s" dt1 dt2)))))
+
+
+(defun ical:date/time<= (dt1 dt2)
+  "Return non-nil if the date or date-time DT1 is earlier or simultaneous to DT2.
+DT1 and DT2 must be either calendar.el dates or decoded-time
+values, and both must be of the same type."
+  (cl-typecase dt1
+    (ical:date
+     (if (cl-typep dt2 'ical:date)
+         (ical:date<= dt1 dt2)
+       (error "Dates must be of the same type: %s, %s" dt1 dt2)))
+
+    (ical:date-time
+     (if (cl-typep dt2 'ical:date-time)
+         (ical:date-time<= dt1 dt2)
+       (error "Dates must be of the same type: %s, %s" dt1 dt2)))))
+
+(defun ical:date-add (date unit n)
+  "Add N UNITs to DATE.
+
+UNIT should be `:year', `:month', `:week', or `:day'; time units will be
+ignored. N may be a positive or negative integer."
+  (if (memq unit '(:hour :minute :second))
+      date
+    (let* ((dt (ical:make-date-time :year (calendar-extract-year date)
+                                    :month (calendar-extract-month date)
+                                    :day (calendar-extract-day date)))
+           (delta (if (eq unit :week)
+                      (make-decoded-time :day (* 7 n))
+                    (make-decoded-time unit n)))
+           (new-dt (decoded-time-add dt delta)))
+      (ical:date-time-to-date new-dt))))
+
+(declare-function icalendar-recur-tz-decode-time "icalendar-recur")
+
+(defun ical:date-time-add (dt delta &optional vtimezone)
+  "Like `decoded-time-add', but also updates weekday and time zone slots.
+
+DT and DELTA should be `icalendar-date-time' values (decoded times), as
+in `decoded-time-add'.  VTIMEZONE, if given, should be an
+`icalendar-vtimezone'. The resulting date-time will be given the offset
+determined by VTIMEZONE at the local time determined by adding DELTA to
+DT.
+
+This function assumes that time units in DELTA larger than an hour
+should not affect the local clock time in the result, even when crossing
+an observance boundary in VTIMEZONE. This means that e.g. if DT is at
+9AM daylight savings time on the day before the transition to standard
+time, then the result of adding a DELTA of two days will be at 9AM
+standard time, even though this is not exactly 48 hours later. Adding a
+DELTA of 48 hours, on the other hand, will result in a time exactly 48
+hours later, but at a different local time."
+  (require 'icalendar-recur) ; for icr:tz-decode-time; avoids circular requires
+  (if (not vtimezone)
+      ;; the simple case: we have no time zone info, so just use
+      ;; `decoded-time-add':
+      (let ((sum (decoded-time-add dt delta)))
+        (ical:date-time-variant sum))
+    ;; `decoded-time-add' does not take time zone shifts into account,
+    ;; so we need to do the adjustment ourselves. We first add the units
+    ;; larger than an hour using `decoded-time-add', holding the clock
+    ;; time fixed, as described in the docstring. Then we add the time
+    ;; units as a fixed number of seconds and re-decode the resulting
+    ;; absolute time into the time zone.
+    (let* ((cal-delta (make-decoded-time :year (or (decoded-time-year delta) 0)
+                                         :month (or (decoded-time-month delta) 0)
+                                         :day (or (decoded-time-day delta) 0)))
+           (cal-sum (decoded-time-add dt cal-delta))
+           (dt-w/zone (ical:date-time-variant cal-sum
+                                              :tz vtimezone))
+           (secs-delta (+ (or (decoded-time-second delta) 0)
+                          (* 60 (or (decoded-time-minute delta) 0))
+                          (* 60 60 (or (decoded-time-hour delta) 0))))
+           (sum-ts (time-add (encode-time dt-w/zone) secs-delta)))
+      (icalendar-recur-tz-decode-time sum-ts vtimezone))))
+
+(defun ical:date/time-add (dt unit n &optional vtimezone)
+  "Add N UNITs to DT.
+
+DT should be an `icalendar-date' or `icalendar-date-time'. UNIT should
+be `:year', `:month', `:week', `:day', `:hour', `:minute', or `:second';
+time units will be ignored if DT is an `icalendar-date'. N may be a
+positive or negative integer."
+  (cl-typecase dt
+    (ical:date-time
+     (let ((delta (if (eq unit :week) (make-decoded-time :day (* 7 n))
+                    (make-decoded-time unit n))))
+       (ical:date-time-add dt delta vtimezone)))
+    (ical:date (ical:date-add dt unit n))))
+
+(cl-defun ical:make-date-time (&key second minute hour day month year
+                                    (dst -1 given-dst) zone tz)
+  "Make an `icalendar-date-time' from the given keyword arguments.
+
+This function is like `make-decoded-time', except that it automatically
+sets the weekday slot set based on the date arguments, and it accepts an
+additional keyword argument: `:tz'. If provided, its value should be an
+`icalendar-vtimezone', and the `:zone' and `:dst' arguments should not
+be provided.  In this case, the zone and dst slots in the returned
+date-time will be adjusted to the correct values in the given time zone
+for the local time represented by the remaining arguments."
+  (when (and tz (or zone given-dst))
+    (error "Possibly conflicting time zone data in args"))
+  (apply #'ical:date-time-variant (make-decoded-time)
+         `(:second ,second :minute ,minute :hour ,hour
+           :day ,day :month ,month :year ,year
+           ;; Don't pass these keywords unless they were given explicitly.
+           ;; TODO: is there a cleaner way to write this?
+           ,@(when tz (list :tz tz))
+           ,@(when given-dst (list :dst dst))
+           ,@(when zone (list :zone zone)))))
+
+(declare-function icalendar-recur-tz-set-zone "icalendar-recur")
+
+(cl-defun ical:date-time-variant (dt &key second minute hour
+                                          day month year
+                                          (dst -1 given-dst)
+                                          (zone nil given-zone)
+                                          tz)
+  "Return a variant of DT with slots modified as in the given arguments.
+
+DT should be an `icalendar-date-time'; the keyword arguments have the
+same meanings as in `make-decoded-time'.  The returned variant will have
+slot values as specified by the arguments or copied from DT, except that
+the weekday slot will be updated if necessary, and the zone and dst
+fields will not be set unless given explicitly (because varying the date
+and clock time generally invalidates the time zone information in DT).
+
+One additional keyword argument is accepted: `:tz'. If provided, its
+value should be an `icalendar-vtimezone', an `icalendar-utc-offset', or
+the symbol \\='preserve.  If it is a time zone component, the zone and
+dst slots in the returned variant will be adjusted to the correct
+values in the given time zone for the local time represented by the
+variant. If it is a UTC offset, the variant's zone slot will contain
+this value, but its dst slot will not be adjusted.  If it is the symbol
+\\='preserve, then both the zone and dst fields are copied from DT into
+the variant."
+  (require 'icalendar-recur) ; for icr:tz-set-zone; avoids circular requires
+  (let ((variant
+         (make-decoded-time :second (or second (decoded-time-second dt))
+                            :minute (or minute (decoded-time-minute dt))
+                            :hour (or hour (decoded-time-hour dt))
+                            :day (or day (decoded-time-day dt))
+                            :month (or month (decoded-time-month dt))
+                            :year (or year (decoded-time-year dt))
+                            ;; For zone and dst slots, trust the value
+                            ;; if explicitly specified or explicitly
+                            ;; requested to preserve, but not otherwise
+                            :dst (cond (given-dst dst)
+                                       ((eq 'preserve tz) (decoded-time-dst dt))
+                                       (t -1))
+                            :zone (cond (given-zone zone)
+                                        ((eq 'preserve tz) (decoded-time-zone dt))
+                                        (t nil)))))
+    ;; update weekday slot when possible, since it depends on the date
+    ;; slots, which might have changed. (It's not always possible,
+    ;; because pure time values are also represented as decoded-times,
+    ;; with empty date slots.)
+    (unless (or (null (decoded-time-year variant))
+                (null (decoded-time-month variant))
+                (null (decoded-time-day variant)))
+      (setf (decoded-time-weekday variant)
+            (calendar-day-of-week (ical:date-time-to-date variant))))
+    ;; if given a time zone or UTC offset, update zone and dst slots,
+    ;; which also might have changed:
+    (when (and tz (not (eq 'preserve tz)))
+      (icalendar-recur-tz-set-zone variant tz))
+    variant))
+
+;; TODO: surely this exists already?
+(defun ical:time<= (a b)
+  "Compare two lisp timestamps A and B: is A <= B?"
+  (or (time-equal-p a b)
+      (time-less-p a b)))
+
+(defun ical:number-of-weeks (year &optional weekstart)
+  "Return the number of weeks in (Gregorian) YEAR.
+
+RFC5545 defines week 1 as the first week to include at least four days
+in the year. Weeks are assumed to start on Monday (= 1) unless WEEKSTART
+is specified, in which case it should be an integer between 0 (= Sunday)
+and 6 (= Saturday)."
+  ;; There are 53 weeks in a year if Jan 1 is the fourth day after
+  ;; WEEKSTART, e.g. if the week starts on Monday and Jan 1 is a
+  ;; Thursday, or in a leap year if Jan 1 is the third day after WEEKSTART
+  (let* ((jan1wd (calendar-day-of-week (list 1 1 year)))
+         (delta (mod (- jan1wd (or weekstart 1)) 7)))
+    (if (or (= 4 delta)
+            (and (= 3 delta) (calendar-leap-year-p year)))
+        53
+      52)))
+
+(defun ical:start-of-weekno (weekno year &optional weekstart)
+  "Return the start of the WEEKNOth week in the (Gregorian) YEAR.
+
+RFC5545 defines week 1 as the first week to include at least four days
+in the year. Weeks are assumed to start on Monday (= 1) unless WEEKSTART
+is specified, in which case it should be an integer between 0 (= Sunday)
+and 6 (= Saturday). The returned value is an `icalendar-date'.
+
+If WEEKNO is negative, it refers to the WEEKNOth week before the end of
+the year: -1 is the last week of the year, -2 second to last, etc."
+  (calendar-gregorian-from-absolute
+   (+
+    (* 7 (if (< 0 weekno)
+             (1- weekno)
+           (+ 1 weekno (ical:number-of-weeks year weekstart))))
+    (calendar-dayname-on-or-before
+     (or weekstart 1)
+     ;; Three days after Jan 1. gives us the nearest occurrence;
+     ;; see `calendar-dayname-on-or-before'
+     (+ 3 (calendar-absolute-from-gregorian (list 1 1 year)))))))
+
+(defun ical:nth-weekday-in (n weekday year &optional month)
+  "Return the Nth WEEKDAY in YEAR or MONTH.
+
+If MONTH is specified, it refers to MONTH in YEAR, and N acts as an
+index for WEEKDAYs within the month. Otherwise, N acts as an index for
+WEEKDAYs within the entire YEAR.
+
+N should be an integer. If N<0, it counts from the end of the month or
+year: if N=-1, it refers to the last WEEKDAY in the month or year, if
+N=-2 the second to last, and so on."
+  (if month
+      (calendar-nth-named-day n weekday month year)
+    (let* ((jan1 (calendar-absolute-from-gregorian (list 1 1 year)))
+           (dec31 (calendar-absolute-from-gregorian (list 12 31 year))))
+      ;; Adapted from `calendar-nth-named-absday'.
+      ;; TODO: we could generalize that function to make month an optional
+      ;; argument, but that would mean changing its interface.
+      (calendar-gregorian-from-absolute
+       (if (> n 0)
+           (+ (* 7 (1- n))
+              (calendar-dayname-on-or-before
+               weekday
+               (+ 6 jan1)))
+         (+ (* 7 (1+ n))
+            (calendar-dayname-on-or-before
+             weekday
+             dec31)))))))
+
+;; Parsing, printing and testing utilities
+;; TODO: should this live in icalendar-parser instead?
+(defun ical:parse-from-string (type s)
+  "Parse string S to an iCalendar syntax node of type TYPE."
+  (with-temp-buffer
+    (insert s)
+    (goto-char (point-min))
+    ;; TODO: unfold?
+    (cond ((ical:component-type-symbol-p type)
+           (ical:parse-component (point-max)))
+          ((ical:property-type-symbol-p type)
+           (ical:parse-property (point-max)))
+          ((ical:param-type-symbol-p type)
+           (insert ";")
+           (backward-char)
+           (ical:parse-params (point-max)))
+          ((ical:value-type-symbol-p type)
+           (ical:parse-value-node type (point-max)))
+          (t
+           (error "Don't know how to parse type %s" type)))))
+
+(provide 'icalendar-utils)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-utils.el ends here
diff --git a/test/lisp/calendar/icalendar-parser-tests.el b/test/lisp/calendar/icalendar-parser-tests.el
new file mode 100644
index 00000000000..e05ae5e90a3
--- /dev/null
+++ b/test/lisp/calendar/icalendar-parser-tests.el
@@ -0,0 +1,1803 @@
+;;; tests/icalendar-parser.el --- Tests for icalendar-parser  -*- lexical-binding: t; -*-
+
+(eval-when-compile (require 'cl-lib))
+(require 'ert)
+(require 'icalendar-parser)
+
+(cl-defmacro ict:parse/print-test (string &key expected parser type printer source)
+  "Create a test which parses STRING, prints the resulting parse
+tree, and compares the printed version with STRING (or with
+EXPECTED, if given). If they are the same, the test passes.
+PARSER and PRINTER should be the parser and printer functions
+appropriate to STRING. TYPE, if given, should be the type of
+object PARSER is expected to parse; it will be passed as PARSER's
+first argument. SOURCE should be a symbol; it is used to name the
+test."
+  (let ((parser-form
+         (if type
+             `(funcall (function ,parser) (quote ,type) (point-max))
+           `(funcall (function ,parser) (point-max)))))
+    `(ert-deftest ,(intern (concat "ict:parse/print-" (symbol-name source))) ()
+       ,(format "Parse and reprint example from `%s'; pass if they match" source)
+       (let* ((parse-buf (get-buffer-create "*iCalendar Parse Test*"))
+              (print-buf (get-buffer-create "*iCalendar Print Test*"))
+              (unparsed ,string)
+              (expected (or ,expected unparsed))
+              (printed nil))
+         (set-buffer parse-buf)
+         (erase-buffer)
+         (insert unparsed)
+         (goto-char (point-min))
+         (let ((parsed ,parser-form))
+           (should (icalendar-ast-node-valid-p parsed))
+           (set-buffer print-buf)
+           (erase-buffer)
+           (insert (funcall (function ,printer) parsed))
+           ;; TODO: this may need adjusting if printers become coding-system aware
+           (decode-coding-region (point-min) (point-max) 'utf-8-dos)
+           (setq printed (buffer-substring-no-properties (point-min) (point-max)))
+           (should (equal expected printed)))))))
+
+(ict:parse/print-test
+"ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.1/1)
+
+(ict:parse/print-test
+"RDATE;VALUE=DATE:19970304,19970504,19970704,19970904\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.1/2)
+
+(ict:parse/print-test
+"ATTACH:http://example.com/public/quarterly-report.doc\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.3/1)
+
+(ict:parse/print-test
+;; Corrected. The original contains invalid base64 data; it was
+;; missing the final "=", as noted in errata ID 5602.
+;; The decoded string should read:
+;; The quick brown fox jumps over the lazy dog.
+"ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4=\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.3/2)
+
+(ict:parse/print-test
+"DESCRIPTION;ALTREP=\"cid:[email-masked]\":The Fall'98 Wild Wizards Conference - - Las Vegas\\, NV\\, USA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2/1)
+
+(ict:parse/print-test
+"DESCRIPTION;ALTREP=\"CID:[email-masked]\": Project XYZ Review Meeting will include the following agenda items: (a) Market Overview\\, (b) Finances\\, (c) Project Management\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.1/1)
+
+(ict:parse/print-test
+"ORGANIZER;CN=\"John Smith\":mailto:[email-masked]\n"
+;; CN param value does not require quotes, so they're missing when
+;; re-printed:
+:expected "ORGANIZER;CN=John Smith:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.2/1)
+
+(ict:parse/print-test
+"ATTENDEE;CUTYPE=GROUP:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.3/1)
+
+(ict:parse/print-test
+"ATTENDEE;DELEGATED-FROM=\"mailto:[email-masked]\":mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.4/1)
+
+(ict:parse/print-test
+"ATTENDEE;DELEGATED-TO=\"mailto:[email-masked]\",\"mailto:[email-masked]\":mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.5/1)
+
+(ict:parse/print-test
+"ORGANIZER;DIR=\"ldap://example.com:6666/o=ABC%20Industries,c=US???(cn=Jim%20Dolittle)\":mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.6/1)
+
+(ict:parse/print-test
+"ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2ljaW5nIGVsaXQsIHNlZCBkbyBlaXVzbW9kIHRlbXBvciBpbmNpZGlkdW50IHV0IGxhYm9yZSBldCBkb2xvcmUgbWFnbmEgYWxpcXVhLiBVdCBlbmltIGFkIG1pbmltIHZlbmlhbSwgcXVpcyBub3N0cnVkIGV4ZXJjaXRhdGlvbiB1bGxhbWNvIGxhYm9yaXMgbmlzaSB1dCBhbGlxdWlwIGV4IGVhIGNvbW1vZG8gY29uc2VxdWF0LiBEdWlzIGF1dGUgaXJ1cmUgZG9sb3IgaW4gcmVwcmVoZW5kZXJpdCBpbiB2b2x1cHRhdGUgdmVsaXQgZXNzZSBjaWxsdW0gZG9sb3JlIGV1IGZ1Z2lhdCBudWxsYSBwYXJpYXR1ci4gRXhjZXB0ZXVyIHNpbnQgb2NjYWVjYXQgY3VwaWRhdGF0IG5vbiBwcm9pZGVudCwgc3VudCBpbiBjdWxwYSBxdWkgb2ZmaWNpYSBkZXNlcnVudCBtb2xsaXQgYW5pbSBpZCBlc3QgbGFib3J1bS4=\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.7/1)
+
+(ict:parse/print-test
+"ATTACH;FMTTYPE=application/msword:ftp://example.com/pub/docs/agenda.doc\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.8/1)
+
+(ict:parse/print-test
+"FREEBUSY;FBTYPE=BUSY:19980415T133000Z/19980415T170000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.9/1)
+
+(ict:parse/print-test
+"SUMMARY;LANGUAGE=en-US:Company Holiday Party\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/1)
+
+(ict:parse/print-test
+"LOCATION;LANGUAGE=en:Germany\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/2)
+
+(ict:parse/print-test
+"LOCATION;LANGUAGE=no:Tyskland\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/3)
+
+(ict:parse/print-test
+"ATTENDEE;MEMBER=\"mailto:[email-masked]\":mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.11/1)
+
+(ict:parse/print-test
+"ATTENDEE;MEMBER=\"mailto:[email-masked]\",\"mailto:[email-masked]\":mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.11/2)
+
+(ict:parse/print-test
+"ATTENDEE;PARTSTAT=DECLINED:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.12/1)
+
+(ict:parse/print-test
+"RECURRENCE-ID;RANGE=THISANDFUTURE:19980401T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.13/1)
+
+(ict:parse/print-test
+"TRIGGER;RELATED=END:PT5M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.14/1)
+
+(ict:parse/print-test
+"RELATED-TO;RELTYPE=SIBLING:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.15/1)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=CHAIR:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.16/1)
+
+(ict:parse/print-test
+"ATTENDEE;RSVP=TRUE:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.17/1)
+
+(ict:parse/print-test
+"ORGANIZER;SENT-BY=\"mailto:[email-masked]\":mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.18/1)
+
+(ict:parse/print-test
+"DTSTART;TZID=America/New_York:19980119T020000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.19/1)
+
+(ict:parse/print-test
+"DTEND;TZID=America/New_York:19980119T030000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.19/2)
+
+(ict:parse/print-test
+"ATTACH;FMTTYPE=image/vnd.microsoft.icon;ENCODING=BASE64;VALUE=BINARY:AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgIAAAICAgADAwMAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMwAAAAAAABNEMQAAAAAAAkQgAAAAAAJEREQgAAACECQ0QgEgAAQxQzM0E0AABERCRCREQAADRDJEJEQwAAAhA0QwEQAAAAAEREAAAAAAAAREQAAAAAAAAkQgAAAAAAAAMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.1/1)
+
+(ict:parse/print-test
+"TRUE"
+:type icalendar-boolean
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.2/1)
+
+(ict:parse/print-test
+"mailto:[email-masked]"
+:type icalendar-cal-address
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.3/1)
+
+(ict:parse/print-test
+"19970714"
+:type icalendar-date
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.4/1)
+
+(ict:parse/print-test
+;; 'Floating' time:
+"19980118T230000"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/1)
+
+(ict:parse/print-test
+;; UTC time:
+"19980119T070000Z"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/2)
+
+(ict:parse/print-test
+;; Leap second (seconds = 60)
+"19970630T235960Z"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/3)
+
+(ict:parse/print-test
+;; Local time:
+"DTSTART:19970714T133000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/4)
+
+(ict:parse/print-test
+;; UTC time:
+"DTSTART:19970714T173000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/5)
+
+(ict:parse/print-test
+;; Local time with TZ identifier:
+"DTSTART;TZID=America/New_York:19970714T133000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/6)
+
+(ict:parse/print-test
+"P15DT5H0M20S"
+:expected "P15DT5H20S"
+:type icalendar-dur-value
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.6/1)
+
+(ict:parse/print-test
+"P7W"
+:type icalendar-dur-value
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.6/2)
+
+(ict:parse/print-test
+"1000000.0000001"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/1)
+
+(ict:parse/print-test
+"1.333"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/2)
+
+(ict:parse/print-test
+"-3.14"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/3)
+
+(ict:parse/print-test
+"1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/1)
+
+(ict:parse/print-test
+"-1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/2)
+
+(ict:parse/print-test
+"+1234567890"
+;; "+" sign isn't required, so it's not re-printed:
+:expected "1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/3)
+
+(ict:parse/print-test
+"432109876"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/4)
+
+(ict:parse/print-test
+"19970101T180000Z/19970102T070000Z"
+:type icalendar-period
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.9/1)
+
+(ict:parse/print-test
+"19970101T180000Z/PT5H30M"
+:type icalendar-period
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.9/2)
+
+(ict:parse/print-test
+"FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/1)
+
+(ict:parse/print-test
+"FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/2)
+
+(ict:parse/print-test
+"FREQ=DAILY;COUNT=10;INTERVAL=2"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/3)
+
+(ict:parse/print-test
+"Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared."
+:type icalendar-text
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.11/1)
+
+(ict:parse/print-test
+;; Local time:
+"230000"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/1)
+
+(ict:parse/print-test
+;; UTC time:
+"070000Z"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/2)
+
+(ict:parse/print-test
+;; Local time:
+"083000"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/3)
+
+(ict:parse/print-test
+;; UTC time:
+"133000Z"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/4)
+
+(ict:parse/print-test
+;; Local time with TZ identifier:
+"SOMETIMEPROP;TZID=America/New_York;VALUE=TIME:083000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.12/5)
+
+(ict:parse/print-test
+"http://example.com/my-report.txt"
+:type icalendar-uri
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.13/1)
+
+(ict:parse/print-test
+"-0500"
+:type icalendar-utc-offset
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.14/1)
+
+(ict:parse/print-test
+"+0100"
+:type icalendar-utc-offset
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc55453.3.14/1)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:[email-masked]
+DTSTAMP:19970610T172345Z
+DTSTART:19970714T170000Z
+DTEND:19970715T040000Z
+SUMMARY:Bastille Day Party
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec3.4/1)
+
+(ict:parse/print-test
+"DTSTART:19960415T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.5/1)
+
+(ict:parse/print-test
+"BEGIN:VEVENT
+UID:[email-masked]
+DTSTAMP:19970901T130000Z
+DTSTART:19970903T163000Z
+DTEND:19970903T190000Z
+SUMMARY:Annual Employee Review
+CLASS:PRIVATE
+CATEGORIES:BUSINESS,HUMAN RESOURCES
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/1)
+
+(ict:parse/print-test
+"BEGIN:VEVENT
+UID:[email-masked]
+DTSTAMP:19970901T130000Z
+DTSTART:19970401T163000Z
+DTEND:19970402T010000Z
+SUMMARY:Laurel is in sensitivity awareness class.
+CLASS:PUBLIC
+CATEGORIES:BUSINESS,HUMAN RESOURCES
+TRANSP:TRANSPARENT
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/2)
+
+(ict:parse/print-test
+"BEGIN:VEVENT
+UID:[email-masked]
+DTSTAMP:19970901T130000Z
+DTSTART;VALUE=DATE:19971102
+SUMMARY:Our Blissful Anniversary
+TRANSP:TRANSPARENT
+CLASS:CONFIDENTIAL
+CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION
+RRULE:FREQ=YEARLY
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/3)
+
+(ict:parse/print-test
+"BEGIN:VEVENT
+UID:[email-masked]
+DTSTAMP:20070423T123432Z
+DTSTART;VALUE=DATE:20070628
+DTEND;VALUE=DATE:20070709
+SUMMARY:Festival International de Jazz de Montreal
+TRANSP:TRANSPARENT
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/4)
+
+(ict:parse/print-test
+"BEGIN:VTODO
+UID:[email-masked]
+DTSTAMP:20070313T123432Z
+DUE;VALUE=DATE:20070501
+SUMMARY:Submit Quebec Income Tax Return for 2006
+CLASS:CONFIDENTIAL
+CATEGORIES:FAMILY,FINANCE
+STATUS:NEEDS-ACTION
+END:VTODO
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.2/1)
+
+(ict:parse/print-test
+"BEGIN:VTODO
+UID:[email-masked]
+DTSTAMP:20070514T103211Z
+DTSTART:20070514T110000Z
+DUE:20070709T130000Z
+COMPLETED:20070707T100000Z
+SUMMARY:Submit Revised Internet-Draft
+PRIORITY:1
+STATUS:NEEDS-ACTION
+END:VTODO
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.2/2)
+
+(ict:parse/print-test
+"BEGIN:VJOURNAL
+UID:[email-masked]
+DTSTAMP:19970901T130000Z
+DTSTART;VALUE=DATE:19970317
+SUMMARY:Staff meeting minutes
+DESCRIPTION:1. Staff meeting: Participants include Joe\\,Lisa\\, and Bob. Aurora project plans were reviewed. There is currently no budget reserves for this project. Lisa will escalate to management. Next meeting on Tuesday.\\n 2. Telephone Conference: ABC Corp. sales representative called to discuss new printer. Promised to get us a demo by Friday.\\n3. Henry Miller (Handsoff Insurance): Car was totaled by tree. Is looking into a loaner car. 555-2323 (tel).
+END:VJOURNAL
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.3/1)
+
+(ict:parse/print-test
+"BEGIN:VFREEBUSY
+UID:[email-masked]
+ORGANIZER:mailto:[email-masked]
+ATTENDEE:mailto:[email-masked]
+DTSTART:19971015T050000Z
+DTEND:19971016T050000Z
+DTSTAMP:19970901T083000Z
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/1)
+
+(ict:parse/print-test
+"BEGIN:VFREEBUSY
+UID:[email-masked]
+ORGANIZER:mailto:[email-masked]
+ATTENDEE:mailto:[email-masked]
+DTSTAMP:19970901T100000Z
+FREEBUSY:19971015T050000Z/PT8H30M,19971015T160000Z/PT5H30M,19971015T223000Z/PT6H30M
+URL:http://example.com/pub/busy/jpublic-01.ifb
+COMMENT:This iCalendar file contains busy time information for the next three months.
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/2)
+
+(ict:parse/print-test
+;; Corrected. Original has invalid value in ORGANIZER
+"BEGIN:VFREEBUSY
+UID:[email-masked]
+DTSTAMP:19970901T120000Z
+ORGANIZER:mailto:[email-masked]
+DTSTART:19980313T141711Z
+DTEND:19980410T141711Z
+FREEBUSY:19980314T233000Z/19980315T003000Z
+FREEBUSY:19980316T153000Z/19980316T163000Z
+FREEBUSY:19980318T030000Z/19980318T040000Z
+URL:http://www.example.com/calendar/busytime/jsmith.ifb
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/3)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:DAYLIGHT
+DTSTART:19670430T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20061029T060000Z
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19740106T020000
+RDATE:19750223T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19760425T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19860427T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=20060402T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/1)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:STANDARD
+DTSTART:20071104T020000
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/2)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+TZURL:http://zones.example.com/tz/America-New_York.ics
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/3)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:Fictitious
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/4)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:Fictitious
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19990424T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=4
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/5)
+
+(ict:parse/print-test
+"BEGIN:VALARM
+TRIGGER;VALUE=DATE-TIME:19970317T133000Z
+REPEAT:4
+DURATION:PT15M
+ACTION:AUDIO
+ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/1)
+
+(ict:parse/print-test
+"BEGIN:VALARM
+TRIGGER:-PT30M
+REPEAT:2
+DURATION:PT15M
+ACTION:DISPLAY
+DESCRIPTION:Breakfast meeting with executive\\nteam at 8:30 AM EST.
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/2)
+
+(ict:parse/print-test
+"BEGIN:VALARM
+TRIGGER;RELATED=END:-P2D
+ACTION:EMAIL
+ATTENDEE:mailto:[email-masked]
+SUMMARY:*** REMINDER: SEND AGENDA FOR WEEKLY STAFF MEETING ***
+DESCRIPTION:A draft agenda needs to be sent out to the attendees to the weekly managers meeting (MGR-LIST). Attached is a pointer the document template for the agenda file.
+ATTACH;FMTTYPE=application/msword:http://example.com/templates/agenda.doc
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/3)
+
+(ict:parse/print-test
+"CALSCALE:GREGORIAN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.1/1)
+
+(ict:parse/print-test
+"METHOD:REQUEST\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.2/1)
+
+(ict:parse/print-test
+"PRODID:-//ABC Corporation//NONSGML My Product//EN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.3/1)
+
+(ict:parse/print-test
+"VERSION:2.0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7./1)
+
+(ict:parse/print-test
+"ATTACH:CID:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.1/1)
+
+(ict:parse/print-test
+"ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/reports/r-960812.ps\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.1/2)
+
+(ict:parse/print-test
+"CATEGORIES:APPOINTMENT,EDUCATION\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.2/1)
+
+(ict:parse/print-test
+"CATEGORIES:MEETING\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.2/2)
+
+(ict:parse/print-test
+"CLASS:PUBLIC\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.3/1)
+
+(ict:parse/print-test
+"COMMENT:The meeting really needs to include both ourselves and the customer. We can't hold this meeting without them. As a matter of fact\\, the venue for the meeting ought to be at their site. - - John\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.4/1)
+
+(ict:parse/print-test
+"DESCRIPTION:Meeting to provide technical review for \"Phoenix\" design.\\nHappy Face Conference Room. Phoenix design team MUST attend this meeting.\\nRSVP to team leader.\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.5/1)
+
+(ict:parse/print-test
+"GEO:37.386013;-122.082932\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.6/1)
+
+(ict:parse/print-test
+"LOCATION:Conference Room - F123\\, Bldg. 002\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.7/1)
+
+(ict:parse/print-test
+"LOCATION;ALTREP=\"http://xyzcorp.com/conf-rooms/f123.vcf\":Conference Room - F123\\, Bldg. 002\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.7/2)
+
+(ict:parse/print-test
+"PERCENT-COMPLETE:39\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.8/1)
+
+(ict:parse/print-test
+"PRIORITY:1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/1)
+
+(ict:parse/print-test
+"PRIORITY:2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/2)
+
+(ict:parse/print-test
+"PRIORITY:0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/3)
+
+(ict:parse/print-test
+"RESOURCES:EASEL,PROJECTOR,VCR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.10/1)
+
+(ict:parse/print-test
+"RESOURCES;LANGUAGE=fr:Nettoyeur haute pression\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.10/2)
+
+(ict:parse/print-test
+"STATUS:TENTATIVE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/1)
+
+(ict:parse/print-test
+"STATUS:NEEDS-ACTION\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/2)
+
+(ict:parse/print-test
+"STATUS:DRAFT\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/3)
+
+(ict:parse/print-test
+"SUMMARY:Department Party\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.12/1)
+
+(ict:parse/print-test
+"COMPLETED:19960401T150000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.1/1)
+
+(ict:parse/print-test
+"DTEND:19960401T150000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.2/1)
+
+(ict:parse/print-test
+"DTEND;VALUE=DATE:19980704\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.2/2)
+
+(ict:parse/print-test
+"DUE:19980430T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.3/1)
+
+(ict:parse/print-test
+"DTSTART:19980118T073000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.4/1)
+
+(ict:parse/print-test
+"DURATION:PT1H0M0S\n"
+;; 0M and 0S are not re-printed because they don't contribute to the duration:
+:expected "DURATION:PT1H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.5/1)
+
+(ict:parse/print-test
+"DURATION:PT15M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.5/2)
+
+(ict:parse/print-test
+"FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:19970308T160000Z/PT8H30M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/1)
+
+(ict:parse/print-test
+"FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/2)
+
+(ict:parse/print-test
+"FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H,19970308T230000Z/19970309T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/3)
+
+(ict:parse/print-test
+"TRANSP:TRANSPARENT\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.7/1)
+
+(ict:parse/print-test
+"TRANSP:OPAQUE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.7/2)
+
+(ict:parse/print-test
+"TZID:America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/1)
+
+(ict:parse/print-test
+"TZID:America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/2)
+
+(ict:parse/print-test
+"TZID:/example.org/America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/3)
+
+(ict:parse/print-test
+"TZNAME:EST\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.2/1)
+
+(ict:parse/print-test
+"TZNAME;LANGUAGE=fr-CA:HNE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.2/2)
+
+(ict:parse/print-test
+"TZOFFSETFROM:-0500\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.3/1)
+
+(ict:parse/print-test
+"TZOFFSETFROM:+1345\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.3/2)
+
+(ict:parse/print-test
+"TZOFFSETTO:-0400\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.4/1)
+
+(ict:parse/print-test
+"TZOFFSETTO:+1245\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.4/2)
+
+(ict:parse/print-test
+"TZURL:http://timezones.example.org/tz/America-Los_Angeles.ics\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.5/1)
+
+(ict:parse/print-test
+"ATTENDEE;MEMBER=\"mailto:[email-masked]\":mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/1)
+
+(ict:parse/print-test
+"ATTENDEE;DELEGATED-FROM=\"mailto:[email-masked]\":mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/2)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Henry Cabot:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/3)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM=\"mailto:[email-masked]\";PARTSTAT=ACCEPTED;CN=Jane Doe:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/4)
+
+(ict:parse/print-test
+"ATTENDEE;CN=John Smith;DIR=\"ldap://example.com:6666/o=ABC%20Industries,c=US???(cn=Jim%20Dolittle)\":mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/5)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;DELEGATED-FROM=\"mailto:[email-masked]\";CN=Henry Cabot:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/6)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=NON-PARTICIPANT;PARTSTAT=DELEGATED;DELEGATED-TO=\"mailto:[email-masked]\";CN=The Big Cheese:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/7)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Jane Doe:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/8)
+
+(ict:parse/print-test
+;; Corrected. Original lacks quotes around SENT-BY address.
+"ATTENDEE;SENT-BY=\"mailto:[email-masked]\";CN=John Smith:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/9)
+
+(ict:parse/print-test
+"CONTACT:Jim Dolittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/1)
+
+(ict:parse/print-test
+;; Corrected. Original contained unallowed backslash in ldap: URI
+"CONTACT;ALTREP=\"ldap://example.com:6666/o=ABC%20Industries,c=US???(cn=Jim%20Dolittle)\":Jim Dolittle\\, ABC Industries\\,+1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/2)
+
+(ict:parse/print-test
+"CONTACT;ALTREP=\"CID:[email-masked]\":Jim Dolittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/3)
+
+(ict:parse/print-test
+"CONTACT;ALTREP=\"http://example.com/pdi/jdoe.vcf\":Jim Dolittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/4)
+
+(ict:parse/print-test
+"ORGANIZER;CN=John Smith:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/1)
+
+(ict:parse/print-test
+"ORGANIZER;CN=JohnSmith;DIR=\"ldap://example.com:6666/o=DC%20Associates,c=US???(cn=John%20Smith)\":mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/2)
+
+(ict:parse/print-test
+"ORGANIZER;SENT-BY=\"mailto:[email-masked]\":mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/3)
+
+(ict:parse/print-test
+"RECURRENCE-ID;VALUE=DATE:19960401\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.4/1)
+
+(ict:parse/print-test
+"RECURRENCE-ID;RANGE=THISANDFUTURE:19960120T120000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.4/2)
+
+(ict:parse/print-test
+"RELATED-TO:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.5/1)
+
+(ict:parse/print-test
+"RELATED-TO:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.5/2)
+
+(ict:parse/print-test
+"URL:http://example.com/pub/calendars/jsmith/mytime.ics\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.6/1)
+
+(ict:parse/print-test
+"UID:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.7/1)
+
+(ict:parse/print-test
+"EXDATE:19960402T010000Z,19960403T010000Z,19960404T010000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.1/1)
+
+(ict:parse/print-test
+"RDATE:19970714T123000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/1)
+
+(ict:parse/print-test
+"RDATE;TZID=America/New_York:19970714T083000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/2)
+
+(ict:parse/print-test
+"RDATE;VALUE=PERIOD:19960403T020000Z/19960403T040000Z,19960404T010000Z/PT3H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/3)
+
+(ict:parse/print-test
+"RDATE;VALUE=DATE:19970101,19970120,19970217,19970421,19970526,19970704,19970901,19971014,19971128,19971129,19971225\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/4)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;COUNT=10\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/1)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;UNTIL=19971224T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/2)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;INTERVAL=2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/3)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;INTERVAL=10;COUNT=5\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/4)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/5)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/6)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;COUNT=10\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/7)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/8)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/9)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/10)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;COUNT=10;WKST=SU;BYDAY=TU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/11)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/12)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/13)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=10;BYDAY=1FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/14)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;UNTIL=19971224T000000Z;BYDAY=1FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/15)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/16)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=-2MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/17)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYMONTHDAY=-3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/18)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/19)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/20)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/21)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/22)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;COUNT=10;BYMONTH=6,7\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/23)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/24)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/25)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;BYDAY=20MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/26)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/27)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/28)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/29)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/30)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/31)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/32)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/33)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/34)
+
+(ict:parse/print-test
+"RRULE:FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T170000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/35)
+
+(ict:parse/print-test
+"RRULE:FREQ=MINUTELY;INTERVAL=15;COUNT=6\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/36)
+
+(ict:parse/print-test
+"RRULE:FREQ=MINUTELY;INTERVAL=90;COUNT=4\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/37)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/38)
+
+(ict:parse/print-test
+"RRULE:FREQ=MINUTELY;INTERVAL=20;BYHOUR=9,10,11,12,13,14,15,16\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/39)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/40)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/41)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/42)
+
+(ict:parse/print-test
+"ACTION:AUDIO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.1/1)
+
+(ict:parse/print-test
+"ACTION:DISPLAY\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.1/2)
+
+(ict:parse/print-test
+"REPEAT:4\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.2/1)
+
+(ict:parse/print-test
+"TRIGGER:-PT15M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/1)
+
+(ict:parse/print-test
+"TRIGGER;RELATED=END:PT5M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/2)
+
+(ict:parse/print-test
+"TRIGGER;VALUE=DATE-TIME:19980101T050000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/3)
+
+(ict:parse/print-test
+"CREATED:19960329T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.1/1)
+
+(ict:parse/print-test
+"DTSTAMP:19971210T080000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.2/1)
+
+(ict:parse/print-test
+"LAST-MODIFIED:19960817T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.3/1)
+
+(ict:parse/print-test
+"SEQUENCE:0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.4/1)
+
+(ict:parse/print-test
+"SEQUENCE:2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.4/2)
+
+(ict:parse/print-test
+"DRESSCODE:CASUAL\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.1/1)
+
+(ict:parse/print-test
+"NON-SMOKING;VALUE=BOOLEAN:TRUE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.1/2)
+
+(ict:parse/print-test
+"X-ABC-MMSUBJ;VALUE=URI;FMTTYPE=audio/basic:http://www.example.org/mysubj.au\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.2/1)
+
+(ict:parse/print-test
+"REQUEST-STATUS:2.0;Success\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/1)
+
+(ict:parse/print-test
+"REQUEST-STATUS:3.1;Invalid property value;DTSTART:96-Apr-01\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/2)
+
+(ict:parse/print-test
+"REQUEST-STATUS:2.8; Success\\, repeating event ignored. Scheduled as a single event.;RRULE:FREQ=WEEKLY\\;INTERVAL=2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/3)
+
+(ict:parse/print-test
+"REQUEST-STATUS:4.1;Event conflict.  Date-time is busy.\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/4)
+
+(ict:parse/print-test
+"REQUEST-STATUS:3.7;Invalid calendar user;ATTENDEE:mailto:[email-masked]\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/5)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:19960704T120000Z
+UID:[email-masked]
+ORGANIZER:mailto:[email-masked]
+DTSTART:19960918T143000Z
+DTEND:19960920T220000Z
+STATUS:CONFIRMED
+CATEGORIES:CONFERENCE
+SUMMARY:Networld+Interop Conference
+DESCRIPTION:Networld+Interop Conference and Exhibit\\nAtlanta World Congress Center\\nAtlanta\\, Georgia
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/1)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+PRODID:-//RDU Software//NONSGML HandCal//EN
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:America/New_York
+BEGIN:STANDARD
+DTSTART:19981025T020000
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19990404T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTAMP:19980309T231000Z
+UID:guid-1.example.com
+ORGANIZER:mailto:[email-masked]
+ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:mailto:[email-masked]
+DESCRIPTION:Project XYZ Review Meeting
+CATEGORIES:MEETING
+CLASS:PUBLIC
+CREATED:19980309T130000Z
+SUMMARY:XYZ Project Review
+DTSTART;TZID=America/New_York:19980312T083000
+DTEND;TZID=America/New_York:19980312T093000
+LOCATION:1CP Conference Room 4350
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/2)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+METHOD:xyz
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VEVENT
+DTSTAMP:19970324T120000Z
+SEQUENCE:0
+UID:[email-masked]
+ORGANIZER:mailto:[email-masked]
+ATTENDEE;RSVP=TRUE:mailto:[email-masked]
+DTSTART:19970324T123000Z
+DTEND:19970324T210000Z
+CATEGORIES:MEETING,PROJECT
+CLASS:PUBLIC
+SUMMARY:Calendaring Interoperability Planning Meeting
+DESCRIPTION:Discuss how we can test c&s interoperability\\nusing iCalendar and other IETF standards.
+LOCATION:LDB Lobby
+ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/conf/bkgrnd.ps
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/3)
+
+(ict:parse/print-test
+;; Corrected. The TRIGGER property originally did not specify
+;; VALUE=DATE-TIME, which is required since it is not the default type.
+;; See https://www.rfc-editor.org/errata/eid2039
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VTODO
+DTSTAMP:19980130T134500Z
+SEQUENCE:2
+UID:[email-masked]
+ORGANIZER:mailto:[email-masked]
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:[email-masked]
+DUE:19980415T000000
+STATUS:NEEDS-ACTION
+SUMMARY:Submit Income Taxes
+BEGIN:VALARM
+ACTION:AUDIO
+TRIGGER;VALUE=DATE-TIME:19980403T120000Z
+ATTACH;FMTTYPE=audio/basic:http://example.com/pub/audio-files/ssbanner.aud
+REPEAT:4
+DURATION:PT1H
+END:VALARM
+END:VTODO
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/4)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VJOURNAL
+DTSTAMP:19970324T120000Z
+UID:[email-masked]
+ORGANIZER:mailto:[email-masked]
+STATUS:DRAFT
+CLASS:PUBLIC
+CATEGORIES:Project Report,XYZ,Weekly Meeting
+DESCRIPTION:Project xyz Review Meeting Minutes\\nAgenda\\n1. Review of project version 1.0 requirements.\\n2.Definitionof project processes.\\n3. Review of project schedule.\\nParticipants: John Smith\\, Jane Doe\\, Jim Dandy\\n-It was decided that the requirements need to be signed off byproduct marketing.\\n-P roject processes were accepted.\\n-Project schedule needs to account for scheduled holidaysand employee vacation time. Check with HR for specificdates.\\n-New schedule will be distributed by Friday.\\n-Next weeks meeting is cancelled. No meeting until 3/23.
+END:VJOURNAL
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/5)
+
+(ict:parse/print-test
+;; Corrected. Original text in the standard is missing UID and DTSTAMP.
+;; See https://www.rfc-editor.org/errata/eid4149
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//RDU Software//NONSGML HandCal//EN
+BEGIN:VFREEBUSY
+UID:[email-masked]
+DTSTAMP:19970901T120000Z
+ORGANIZER:mailto:[email-masked]
+DTSTART:19980313T141711Z
+DTEND:19980410T141711Z
+FREEBUSY:19980314T233000Z/19980315T003000Z
+FREEBUSY:19980316T153000Z/19980316T163000Z
+FREEBUSY:19980318T030000Z/19980318T040000Z
+URL:http://www.example.com/calendar/busytime/jsmith.ifb
+END:VFREEBUSY
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/6)
+
+
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ict:" . "icalendar-test-"))
+;; End:
+;;; tests/icalendar-parser.el ends here
diff --git a/test/lisp/calendar/icalendar-recur-tests.el b/test/lisp/calendar/icalendar-recur-tests.el
new file mode 100644
index 00000000000..7f73f227a26
--- /dev/null
+++ b/test/lisp/calendar/icalendar-recur-tests.el
@@ -0,0 +1,2871 @@
+;;; icalendar-recur-tests.el --- Tests for icalendar-recur  -*- lexical-binding: t; -*-
+
+(require 'cl-lib)
+(require 'ert)
+(require 'icalendar-recur)
+(require 'icalendar-utils)
+(require 'icalendar-parser)
+
+;; Some constants for tests that use time zones:
+(defconst ict:tz-eastern
+  (ical:parse-from-string 'ical:vtimezone
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:DAYLIGHT
+DTSTART:19670430T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20061029T060000Z
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19740106T020000
+RDATE:19750223T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19760425T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19860427T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=20060402T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+END:VTIMEZONE
+")
+"`icalendar-vtimezone' representing America/New_York (Eastern) time.")
+
+(defconst ict:est-latest
+  (ical:with-component ict:tz-eastern
+      ((ical:standard :all stds))
+    (seq-find (lambda (obs)
+                (ical:date-time=
+                 (ical:make-date-time :year 2007 :month 11 :day 4
+                                      :hour 2 :minute 0 :second 0)
+                 (ical:with-property-of obs 'ical:dtstart nil value)))
+              stds))
+  "The observance of Eastern Standard Time which began 2007-11-04")
+
+(defconst ict:edt-latest
+  (ical:with-component ict:tz-eastern
+      ((ical:daylight :all dls))
+    (seq-find (lambda (obs)
+                (ical:date-time=
+                 (ical:make-date-time :year 2007 :month 3 :day 11
+                                      :hour 2 :minute 0 :second 0)
+                 (ical:with-property-of obs 'ical:dtstart nil value)))
+              dls))
+  "The observance of Eastern Daylight Time which began 2007-03-11")
+
+(defconst ict:est -18000  ;; = -0500
+  "UTC offset for Eastern Standard Time")
+
+(defconst ict:edt -14400 ;; = -0400
+  "UTC offset for Eastern Daylight Time")
+
+
+;; Tests for basic functions:
+
+(ert-deftest ict:recur-bysetpos-filter ()
+  "Test that `icr:make-bysetpos-filter' filters correctly by position"
+  (let* ((t1 (list 1 1 2024))
+         (t2 (list 2 1 2024))
+         (t3 (list 12 30 2024))
+         (dts (list t1 t2 t3))
+         (filter (icr:make-bysetpos-filter (list 1 -1)))
+         (filtered (funcall filter dts)))
+    (should (member t1 filtered))
+    (should (member t3 filtered))
+    (should-not (member t2 filtered))))
+
+(ert-deftest ict:recur-yearday-number ()
+  "Test that `icr:calendar-date-from-yearday-number' finds correct dates"
+  (let* ((year 2025)
+         (daynos (list '(1 . (1 1 2025))
+                       '(8 . (1 8 2025))
+                       '(-1 . (12 31 2025))
+                       '(363 . (12 29 2025)))))
+    (dolist (d daynos)
+      (let ((dayno (car d))
+            (date (cdr d)))
+        (should
+         (equal date (ical:calendar-date-from-yearday-number year dayno)))))))
+
+(ert-deftest ict:date-time-add ()
+  "Does `ical:date-time-add' correctly handle time zone transitions?"
+  ;; A sum that does not use a time zone at all:
+  (let* ((dt (ical:make-date-time :year 2007 :month 1 :day 1
+                                  :hour 12 :minute 0 :second 0))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 3)))
+    (should (equal expected (ical:date-time-add dt delta))))
+
+  ;; A sum that does not cross an observance boundary:
+  (let* ((dt (ical:make-date-time :year 2007 :month 2 :day 1
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 3 :tz 'preserve)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the Std->DST boundary and should preserve clock time:
+  (let* ((dt (ical:make-date-time :year 2007 :month 3 :day 10
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 12 :zone ict:edt :dst t)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the Std->DST boundary and should be exactly 48 hours later:
+  (let* ((dt (ical:make-date-time :year 2007 :month 3 :day 10
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :hour 48))
+         (expected (ical:date-time-variant dt :day 12 :hour 13
+                                           :zone ict:edt :dst t)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the DST->Std boundary and should preserve clock time:
+  (let* ((dt (ical:make-date-time :year 2007 :month 11 :day 3
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 5 :zone ict:est :dst nil)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the DST->Std boundary and should be exactly 48 hours later:
+  (let* ((dt (ical:make-date-time :year 2007 :month 11 :day 3
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (delta (make-decoded-time :hour 48))
+         (expected (ical:date-time-variant dt :day 5 :hour 11
+                                           :zone ict:est :dst nil)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that lands exactly on the Std->DST boundary and should result
+  ;; in a clock time one hour later:
+  (let* ((dt (ical:make-date-time :year 2007 :month 3 :day 10
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :hour 24))
+         (expected (ical:date-time-variant dt :day 11 :hour 3
+                                           :zone ict:edt :dst t)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that lands exactly on the DST->Std boundary and should result
+  ;; in a clock time one hour earlier:
+  (let* ((dt (ical:make-date-time :year 2007 :month 11 :day 3
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (delta (make-decoded-time :hour 24))
+         (expected (ical:date-time-variant dt :day 4 :hour 1
+                                           :zone ict:est :dst nil)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern)))))
+
+(ert-deftest ict:recur-nonexistent-date-time-p ()
+  "Does `icr:nonexistent-date-time-p' correctly identify nonexistent times?"
+  (let*  ((dst-onset (ical:make-date-time :year 2025 :month 3 :day 9
+                                          :hour 2 :minute 0 :second 0
+                                          :zone ict:est :dst nil))
+          ;; 2:30 AM falls into the gap when shifting from 2AM EST to 3AM EDT:
+          (nonexistent1 (ical:make-date-time :year 2025 :month 3 :day 9
+                                             :hour 2 :minute 30 :second 0
+                                             :zone ict:est :dst nil))
+          (nonexistent2 (ical:date-time-variant nonexistent1
+                                                :zone ict:edt :dst t))
+          (std-onset (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 2 :minute 0 :second 0
+                                          :zone ict:edt :dst t))
+          ;; 1:30AM around the shift back to EST exists twice (once in
+          ;; EDT, once in EST) and should not be nonexistent:
+          (existent1 (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 1 :minute 30 :second 0
+                                          :zone ict:edt :dst t))
+          (existent2 (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 1 :minute 30 :second 0
+                                          :zone ict:est :dst nil)))
+    (should (icr:nonexistent-date-time-p nonexistent1 dst-onset ict:edt-latest))
+    (should (icr:nonexistent-date-time-p nonexistent2 dst-onset ict:edt-latest))
+    (should-not
+     (icr:nonexistent-date-time-p existent1 std-onset ict:est-latest))
+    (should-not
+     (icr:nonexistent-date-time-p existent2 std-onset ict:est-latest))))
+
+(ert-deftest ict:recur-date-time-occurs-twice-p ()
+  "Does `icr:date-time-occurs-twice-p' correctly identify times that occur twice?"
+  (let*  ((std-onset (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 2 :minute 0 :second 0
+                                          :zone ict:edt :dst t))
+          ;; 1:00, 1:30 AM occur twice when shifting from 2AM EDT to 1AM EST:
+          (twice1 (ical:make-date-time :year 2025 :month 11 :day 2
+                                       :hour 1 :minute 0 :second 0))
+          (twice2 (ical:make-date-time :year 2025 :month 11 :day 2
+                                       :hour 1 :minute 30 :second 0))
+          ;; 12:59 AM, 2AM should not occur twice:
+          (once1 (ical:make-date-time :year 2025 :month 11 :day 2
+                                      :hour 0 :minute 59 :second 0
+                                      :zone ict:edt :dst t))
+          (once2 (ical:make-date-time :year 2025 :month 11 :day 2
+                                      :hour 2 :minute 0 :second 0
+                                      :zone ict:est :dst nil)))
+    (should (icr:date-time-occurs-twice-p twice1 std-onset ict:est-latest))
+    (should (icr:date-time-occurs-twice-p twice2 std-onset ict:est-latest))
+    (should-not
+     (icr:date-time-occurs-twice-p once1 std-onset ict:est-latest))
+    (should-not
+     (icr:date-time-occurs-twice-p once2 std-onset ict:est-latest))))
+
+(ert-deftest ict:recur-find-secondly-interval ()
+  "Does `icr:find-secondly-interval' find correct intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       :hour 0 :minute 0 :second 0
+                                       ;; Use UTC for the tests with no
+                                       ;; time zone, so that the results
+                                       ;; don't depend on system's local time
+                                       :zone 0))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst nil)))
+
+    ;; Year numbers are monotonically increasing in the following test cases,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :second 5 :zone 0))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 0 :tz 'preserve)
+             (ical:date-time-variant target :second 1 :tz 'preserve)
+             (ical:date-time-variant target :second 10 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart 10))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :second 10 :zone 0))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 10 :tz 'preserve)
+             (ical:date-time-variant target :second 11 :tz 'preserve)
+             (ical:date-time-variant target :second 20 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart 10))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2028 :month 2 :second 20
+                                           :zone ict:est :dst nil))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 20 :tz 'preserve)
+             (ical:date-time-variant target :second 21 :tz 'preserve)
+             (ical:date-time-variant target :second 30 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz 10
+                                          ict:tz-eastern))))
+
+    ;; With timezone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :second 30
+                                           :zone ict:edt :dst t))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 30 :tz 'preserve)
+             (ical:date-time-variant target :second 31 :tz 'preserve)
+             (ical:date-time-variant target :second 40 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz 10 ict:tz-eastern))))
+
+    ;; With timezone, around a target that falls into the gap in local
+    ;; times and thus does not exist as a local time. In this case, what
+    ;; is supposed to happen is that the clock time value in the [observance]
+    ;; recurrences "is interpreted using the UTC offset before the gap
+    ;; in local times."  So we should get the same absolute times back,
+    ;; but re-decoded into the new observance, i.e., one hour later.
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2030 :month 3 :day 10
+                                           :hour 2 :minute 30 :second 0
+                                           :zone ict:est :dst nil))
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 3 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 3 :second 1
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target
+                                     :hour 3 :second 10
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz 10 ict:tz-eastern))))
+
+    ;; With timezone, with a "pathological" interval size of 59 seconds.
+    ;; There should be no problem with this case, because the interval
+    ;; bounds calculation is done in absolute time, but it's annoying to
+    ;; calculate the expected interval by hand:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2031 :month 4 :day 15
+                                           :hour 12 :minute 0 :second 0
+                                           :zone ict:edt :dst t))
+           (intsize 59)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 11 :minute 59 :second 16
+                                     :tz 'preserve)
+             (ical:date-time-variant target :hour 11 :minute 59 :second 17
+                                     :tz 'preserve)
+             (ical:date-time-variant target :hour 12 :minute 0 :second 15
+                                     :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-minutely-interval ()
+  "Does `icr:find-minutely-interval' find correct intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       :hour 0 :minute 0
+                                       ;; make sure intervals are
+                                       ;; bounded on whole minutes:
+                                       :second 23))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst nil)))
+
+    ;; Year numbers are monotonically increasing in the following test cases,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just a fixed offset, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :minute 5))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 0 :second 0)
+             (ical:date-time-variant target :minute 1 :second 0)
+             (ical:date-time-variant target :minute 10 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart intsize))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :minute 10))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 10 :second 0)
+             (ical:date-time-variant target :minute 11 :second 0)
+             (ical:date-time-variant target :minute 20 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart intsize))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2028 :month 2 :minute 20
+                                           :zone ict:est :dst nil))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 20 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :minute 21 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :minute 30 :second 0
+                                     :zone ict:est :dst nil))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))
+
+    ;; With timezone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :minute 30
+                                           :zone ict:edt :dst t))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 30 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :minute 31 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :minute 40 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))
+
+    ;; With timezone, around a target that falls into the gap in local
+    ;; times and thus does not exist as a local time. In this case, what
+    ;; is supposed to happen is that the clock time value in the [observance]
+    ;; recurrences "is interpreted using the UTC offset before the gap
+    ;; in local times."  So we should get the same absolute times back,
+    ;; but re-decoded into the new observance, i.e., one hour later.
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2030 :month 3 :day 10
+                                           :hour 2 :minute 30 :second 0
+                                           :zone ict:est :dst nil))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 3 :minute 30 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 3 :minute 31 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target
+                                     :hour 3 :minute 40 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-hourly-interval ()
+  "Does `icr:find-hourly-interval' find correct intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       :hour 0
+                                       ;; make sure intervals are bounded on
+                                       ;; whole hours:
+                                       :minute 11 :second 23))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst nil)))
+
+    ;; Year numbers are monotonically increasing in the following test cases,
+    ;; to make it easy to tell which of them fails.
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :hour 5))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 1 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 10 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart intsize))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :hour 10))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 10 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 11 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 20 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart intsize))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2028 :month 2 :hour 10
+                                           :zone ict:est :dst nil))
+           (intsize 2)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 10 :minute 0 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :hour 11 :minute 0 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :hour 12 :minute 0 :second 0
+                                     :zone ict:est :dst nil))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart/tz intsize
+                                        ict:tz-eastern))))
+
+    ;; With time zone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift. Note that
+    ;; because of our decision to calculate with absolute times in
+    ;; SECONDLY/MINUTELY/HOURLY rules (see `icr:find-secondly-recurrence-rule')
+    ;; the interval clock times shift an hour here:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :hour 12
+                                           :zone ict:edt :dst t))
+           (intsize 2)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 11 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 12 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 13 :minute 0 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart/tz intsize
+                                        ict:tz-eastern))))
+
+    ;; With timezone, around a target that falls into the gap in local
+    ;; times and thus does not exist as a local time. In this case, what
+    ;; is supposed to happen is that the clock time value in the [observance]
+    ;; recurrences "is interpreted using the UTC offset before the gap
+    ;; in local times."  So we should get the same absolute times back,
+    ;; but re-decoded into the new observance, i.e., one hour later.
+    (let* ((target (ical:make-date-time :year 2030 :month 3 :day 10
+                                        :hour 2 :minute 30 :second 30
+                                        :zone ict:est :dst nil))
+           (intsize 2)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 3 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 4 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 5 :minute 0 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart/tz intsize
+                                        ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-daily-interval-w/date ()
+  "Does `icr:find-daily-interval' find correct date intervals?"
+  (let* ((dtstart (list 1 8 2025)))
+    ;; Since all the results should be the same after the initial
+    ;; calculation of the absolute dates DTSTART and TARGET, we just
+    ;; test one simple case here and test with date-times more
+    ;; thoroughly below.
+
+    ;; A target that doesn't fall on an interval boundary:
+    (let* ((target (list 1 9 2026))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:make-date-time :year 2026 :month 1 :day 7
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 8
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 14
+                                  :hour 0 :minute 0 :second 0))))
+      (should (equal expected-int
+                     (icr:find-daily-interval target dtstart intsize))))))
+
+(ert-deftest ict:recur-find-daily-interval-w/date-time ()
+  "Does `icr:find-daily-interval' find correct date-time intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 8 ; a Wednesday
+                                       ;; make sure intervals are bounded on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst nil)))
+
+    ;; Year numbers are monotonically increasing in the following test cases,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart
+                                              :year 2026 :month 1 :day 9))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 7 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 8 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 14
+                                     :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart intsize))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :month 1 :day 6))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 6 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 7 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 13 :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart intsize))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz :year 2028 :month 2 :day 2
+                                           :zone ict:est :dst nil))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 2 :hour 0 :minute 0 :second 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 3 :hour 0 :minute 0 :second 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 9 :hour 0 :minute 0 :second 0
+                                     :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart/tz intsize ict:tz-eastern))))
+
+    ;; With time zone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift.
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :day 28
+                                           :zone ict:edt :dst t))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 23 :hour 0 :minute 0 :second 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 24 :hour 0 :minute 0 :second 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 30 :hour 0 :minute 0 :second 0
+                                     :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart/tz intsize
+                                       ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-weekly-interval-w/date ()
+  "Does `icr:find-weekly-interval' find correct date intervals?"
+  (let* ((dtstart '(1 8 2025)))
+    ;; Since all the results should be the same after the initial
+    ;; calculation of the absolute dates DTSTART and TARGET, we just
+    ;; test one simple case here and test with date-times more
+    ;; thoroughly below.
+
+    ;; A target that doesn't fall on an interval boundary:
+    (let* ((target '(1 9 2026))
+           (intsize 2)
+           (expected-int-mon
+            (list
+             (ical:make-date-time :year 2026 :month 1 :day 5
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 12
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 19
+                                  :hour 0 :minute 0 :second 0))))
+      (should (equal expected-int-mon
+                     (icr:find-weekly-interval target dtstart intsize))))))
+
+(ert-deftest ict:recur-find-weekly-interval-w/date-time ()
+  "Does `icr:find-weekly-interval' find correct date-time intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 8 ; a Wednesday
+                                       ;; make sure intervals are bounded on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23)))
+
+    ;; Year numbers are monotonically increasing in the following test cases,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :month 1 :day 9))
+           (intsize 2)
+           (weds 3)
+           ;; expected interval for Monday (default) week start:
+           (expected-int-mon
+            (list
+             (ical:date-time-variant target :day 5 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 12 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 19 :hour 0 :minute 0 :second 0)))
+           ;; expected interval for Wednesday week start:
+           (expected-int-wed
+            (list
+             (ical:date-time-variant target :day 7 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 14 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 21 :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int-mon
+              (icr:find-weekly-interval target dtstart intsize)))
+      (should
+       (equal expected-int-wed
+              (icr:find-weekly-interval target dtstart intsize weds))))
+
+    ;; Around a target that does fall on an interval boundary, Monday week start:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :month 1 :day 4))
+           (intsize 3)
+           ;; expected interval for Monday (default) week start:
+           (expected-int-mon
+            (list
+             (ical:date-time-variant target :year 2026 :month 12 :day 21
+                                     :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :year 2026 :month 12 :day 28
+                                     :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 11
+                                     :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int-mon
+              (icr:find-weekly-interval target dtstart intsize))))
+
+    ;; Around a target that does fall on an interval boundary, Sunday week start:
+    (let* ((target (ical:date-time-variant dtstart :year 2028 :month 1 :day 2))
+           (intsize 3)
+           (sun 0)
+           ;; expected interval for Sunday week start:
+           (expected-int-sun
+            (list
+             (ical:date-time-variant target :day 2 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 9 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 23 :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int-sun
+              (icr:find-weekly-interval target dtstart intsize sun))))))
+
+(ert-deftest ict:recur-find-monthly-interval ()
+  "Does `icr:find-monthly-interval' find correct intervals?"
+  ;; Year numbers are monotonically increasing in the following test cases,
+  ;; to make it easy to tell which of them fails.
+
+  ;; One test with dates, to make sure that works:
+  (let* ((dtstart '(1 8 2025))
+         (target '(10 9 2025))
+           (intsize 5)
+           (expected-int
+            (list
+             (ical:make-date-time :year 2025 :month 6 :day 1
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2025 :month 7 :day 1
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2025 :month 11 :day 1
+                                  :hour 0 :minute 0 :second 0))))
+      (should (equal expected-int
+                     (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that doesn't fall on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       ;; make sure intervals are bounded on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23))
+         (target (ical:date-time-variant dtstart :year 2026 :month 3 :day 9))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :day 1 :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :month 4 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+    (should
+     (equal expected-int
+            (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that does fall on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       ;; make sure intervals are bounded on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23))
+         (target (ical:date-time-variant dtstart :year 2027 :month 5 :day 1))
+         (intsize 7)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2027 :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2027 :month 6 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2027 :month 12 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+    (should
+     (equal expected-int
+            (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that does not fall on an interval boundary, where
+  ;; start month > target month
+  (let* ((dtstart (ical:make-date-time :year 2028 :month 11 :day 11
+                                       :hour 11 :minute 11 :second 11))
+         (target (ical:date-time-variant dtstart
+                                         :year 2029 :month 4 :day 15))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2029 :month 3 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2029 :month 4 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2029 :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that falls on an interval boundary, where
+  ;; start month > target month
+  (let* ((dtstart (ical:make-date-time :year 2029 :month 11 :day 11
+                                       :hour 11 :minute 11 :second 11 ))
+         (target (ical:date-time-variant dtstart
+                                         :year 2030 :month 5 :day 1))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2030 :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2030 :month 6 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2030 :month 7 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that falls on an interval boundary, where
+  ;; start month = target month
+  (let* ((dtstart (ical:make-date-time :year 2031 :month 11 :day 11
+                                       :hour 11 :minute 11 :second 11 ))
+         (target (ical:date-time-variant dtstart :year 2032 :month 11 :day 11))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2032 :month 11 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2032 :month 12 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2033 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-monthly-interval target dtstart intsize)))))
+
+(ert-deftest ict:recur-find-yearly-interval ()
+  "Does `icr:find-yearly-interval' find correct date intervals?"
+  ;; Year numbers are monotonically increasing in the following test cases,
+  ;; to make it easy to tell which of them fails.
+
+  ;; One test with dates, to make sure that works:
+  (let* ((dtstart '(1 8 2025))
+         (target '(10 9 2025))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2025 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2026 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2027 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize))))
+
+  ;; A target not on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2026 :month 3 :day 1
+                                       :hour 1 :minute 2 :second 3))
+         (target (ical:make-date-time :year 2026 :month 7 :day 28
+                                      :hour 11 :minute 58 :second 0))
+         (intsize 3)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2026 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2027 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2029 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize))))
+
+  ;; A target on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2027 :month 3 :day 1
+                                       :hour 1 :minute 2 :second 3))
+         (target (ical:make-date-time :year 2028 :month 1 :day 1
+                                      :hour 0 :minute 0 :second 0))
+         (intsize 4)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2027 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2028 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2031 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize))))
+
+  ;; A target earlier than dtstart but in the same year;
+  ;; it's important that this works when looking up recurrences of
+  ;; time zone observance onsets
+  (let* ((dtstart (ical:make-date-time :year 2029 :month 5 :day 28
+                                       :hour 1 :minute 2 :second 3))
+         (target (ical:make-date-time :year 2029 :month 2 :day 14
+                                      :hour 11 :minute 58 :second 0))
+         (intsize 1)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2029 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2030 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2030 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize)))))
+
+;; Subintervals:
+
+(ert-deftest ict:recur-refine-byyearday ()
+  "Does `icr:refine-byyearday' correctly refine by yeardays?"
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :year 1))
+         (interval (list low high high))
+         (yeardays (list 2 -7))
+         (sub1 (list (ical:date-time-variant low :day 2)
+                     (ical:date-time-variant low :day 3)))
+         (sub2 (list (ical:date-time-variant low :month 12 :day 25)
+                     (ical:date-time-variant low :month 12 :day 26)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byyearday interval yeardays)))))
+
+(ert-deftest ict:recur-refine-bymonth ()
+  "Does `icr:refine-bymonth' correctly refine by months?"
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :year 1))
+         (interval (list low high high))
+         (months (list 9 2))
+         (sub1 (list (ical:date-time-variant low :month 2 :day 1)
+                     (ical:date-time-variant low :month 3 :day 1)))
+         (sub2 (list (ical:date-time-variant low :month 9 :day 1)
+                     (ical:date-time-variant low :month 10 :day 1)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bymonth interval months)))))
+
+(ert-deftest ict:recur-refine-bymonthday ()
+  "Does `icr:refine-bymonthday' correctly refine by days of the month?"
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :month 1))
+         (interval (list low high high))
+         (monthdays (list -1 2 29))
+         ;; N.B. we should get no subinterval for Feb. 29, 2025
+         (sub1 (list (ical:date-time-variant low :day 2)
+                     (ical:date-time-variant low :day 3)))
+         (sub2 (list (ical:date-time-variant low :day 28)
+                     (ical:date-time-variant low :month 3 :day 1)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bymonthday interval monthdays)))))
+
+(ert-deftest ict:recur-refine-byday ()
+  "Does `icr:refine-byday' correctly refine by days of the week?"
+  ;; The simple case: just day names
+  (let* ((low (ical:make-date-time :year 2025 :month 3 :day 3 ; a Monday
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :day 7))
+         (interval (list low high high))
+         (days (list 0 6)) ; just the weekend, please!
+         (sub1 (list (ical:date-time-variant low :day 8)
+                     (ical:date-time-variant low :day 9)))
+         (sub2 (list (ical:date-time-variant low :day 9)
+                     (ical:date-time-variant low :day 10)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byday interval days))))
+
+  ;; Day names with offsets within the month
+  (let* ((low (ical:make-date-time :year 2025 :month 3 :day 1 ; a Saturday
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :month 1))
+         (interval (list low high high))
+         (days (list '(1 . 2) '(1 . -1)))  ; second and last Monday
+         (sub1 (list (ical:date-time-variant low :day 10)
+                     (ical:date-time-variant low :day 11)))
+         (sub2 (list (ical:date-time-variant low :day 31)
+                     (ical:date-time-variant low :month 4 :day 1)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byday interval days t))))
+
+  ;; Day names with offsets within the year
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :year 1))
+         (interval (list low high high))
+         (days (list '(5 . 1) '(5 . -1)))  ; first and last Friday
+         (sub1 (list (ical:date-time-variant low :day 3)
+                     (ical:date-time-variant low :day 4)))
+         (sub2 (list (ical:date-time-variant low :month 12 :day 26)
+                     (ical:date-time-variant low :month 12 :day 27)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byday interval days nil)))))
+
+(ert-deftest ict:recur-refine-byhour ()
+  "Does `icr:refine-byhour' correctly refine by hours?"
+  ;; No time zone, just clock times:
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :day 1))
+         (interval (list low high high))
+         (hours (list 2 19))
+         (sub1 (list (ical:date-time-variant low :hour 2)
+                     (ical:date-time-variant low :hour 3)))
+         (sub2 (list (ical:date-time-variant low :hour 19)
+                     (ical:date-time-variant low :hour 20)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byhour interval hours))))
+
+  ;; With time zone, but without crossing an observance boundary:
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 0 :minute 0 :second 0
+                                   :zone ict:est :dst nil))
+         (high (ical:date/time-add low :day 1 ict:tz-eastern))
+         (interval (list low high high))
+         (hours (list 2 19))
+         (sub1 (list (ical:date-time-variant low :hour 2 :tz 'preserve)
+                     (ical:date-time-variant low :hour 3 :tz 'preserve)))
+         (sub2 (list (ical:date-time-variant low :hour 19 :tz 'preserve)
+                     (ical:date-time-variant low :hour 20 :tz 'preserve)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byhour interval hours ict:tz-eastern)))))
+
+(ert-deftest ict:recur-refine-byminute ()
+  "Does `icr:refine-byminute' correctly refine by minutes?"
+  ;; No time zone, just clock times:
+  (let* ((low (ical:make-date-time :year 2025 :month 5 :day 1
+                                   :hour 13 :minute 0 :second 0))
+         (high (ical:date/time-add low :hour 1))
+         (interval (list low high high))
+         (minutes (list 7 59))
+         (sub1 (list (ical:date-time-variant low :minute 7)
+                     (ical:date-time-variant low :minute 8)))
+         (sub2 (list (ical:date-time-variant low :minute 59)
+                     (ical:date-time-variant low :hour 14 :minute 0)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byminute interval minutes))))
+
+  ;; With time zone, but without crossing an observance boundary:
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 13 :minute 0 :second 0
+                                   :zone ict:est :dst nil))
+         (high (ical:date/time-add low :hour 1 ict:tz-eastern))
+         (interval (list low high high))
+         (minutes (list 7 59))
+         (sub1 (list (ical:date-time-variant low :minute 7 :tz 'preserve)
+                     (ical:date-time-variant low :minute 8 :tz 'preserve)))
+         (sub2 (list (ical:date-time-variant low :minute 59 :tz 'preserve)
+                     (ical:date-time-variant low :hour 14 :minute 0
+                                             :tz 'preserve)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byminute interval minutes ict:tz-eastern)))))
+
+(ert-deftest ict:recur-refine-bysecond ()
+  "Does `icr:refine-bysecond' correctly refine by seconds?"
+  ;; No time zone, just clock times:
+  (let* ((low (ical:make-date-time :year 2025 :month 5 :day 1
+                                   :hour 13 :minute 59 :second 0))
+         (high (ical:date/time-add low :minute 1))
+         (interval (list low high high))
+         (seconds (list 24 59))
+         (sub1 (list (ical:date-time-variant low :second 24)
+                     (ical:date-time-variant low :second 25)))
+         (sub2 (list (ical:date-time-variant low :second 59)
+                     (ical:date-time-variant low :hour 14 :minute 0 :second 0)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bysecond interval seconds))))
+
+  ;; With time zone, but without crossing an observance boundary:
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 13 :minute 19 :second 0
+                                   :zone ict:est :dst nil))
+         (high (ical:date/time-add low :minute 1 ict:tz-eastern))
+         (interval (list low high high))
+         (seconds (list 24 59))
+         (sub1 (list (ical:date-time-variant low :second 24 :tz 'preserve)
+                     (ical:date-time-variant low :second 25 :tz 'preserve)))
+         (sub2 (list (ical:date-time-variant low :second 59 :tz 'preserve)
+                     (ical:date-time-variant low :minute 20 :second 0
+                                             :tz 'preserve)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bysecond interval seconds ict:tz-eastern)))))
+
+(ert-deftest ict:recur-subintervals-to-dates ()
+  "Does `icr:subintervals-to-dates' correctly generate recurrences?"
+  ;; Two subintervals, the first three days long, the second less than a single day
+  (let* ((low1 (ical:make-date-time :year 2025 :month 5 :day 1
+                                    :hour 13 :minute 59 :second 0))
+         (high1 (ical:date/time-add low1 :day 3))
+         (sub1 (list low1 high1))
+         (low2 (ical:make-date-time :year 2025 :month 5 :day 31
+                                    :hour 14 :minute 0 :second 0))
+         (high2 (ical:date/time-add low2 :hour 3)) ; later but on the same day
+         (sub2 (list low2 high2))
+         (low-date1 (ical:date-time-to-date low1))
+         (low-date2 (ical:date-time-to-date low2))
+         (expected-recs (list low-date1
+                              (ical:date/time-add low-date1 :day 1)
+                              (ical:date/time-add low-date1 :day 2)
+                              low-date2)))
+    (should (equal expected-recs
+                   (icr:subintervals-to-dates (list sub1 sub2))))))
+
+(ert-deftest ict:recur-subintervals-to-date-times ()
+  "Does `icr:subintervals-to-date-times' correctly generate recurrences?"
+  ;; Two subintervals, each one second long, no time zone
+  (let* ((low1 (ical:make-date-time :year 2025 :month 5 :day 1
+                                    :hour 13 :minute 59 :second 0))
+         (high1 (ical:date/time-add low1 :second 1))
+         (sub1 (list low1 high1))
+         (low2 (ical:make-date-time :year 2025 :month 5 :day 2
+                                    :hour 14 :minute 0 :second 0))
+         (high2 (ical:date/time-add low2 :second 1))
+         (sub2 (list low2 high2))
+         (expected-recs (list low1 low2)))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1 sub2)))))
+
+  ;; A subinterval five seconds long, with time zone
+  (let* ((low1 (ical:make-date-time :year 2025 :month 6 :day 1
+                                    :hour 13 :minute 59 :second 0
+                                    :zone ict:edt :dst t))
+         (high1 (ical:date/time-add low1 :second 5 ict:tz-eastern))
+         (sub1 (list low1 high1))
+         (expected-recs
+          (list low1
+                (ical:date/time-add low1 :second 1 ict:tz-eastern)
+                (ical:date/time-add low1 :second 2 ict:tz-eastern)
+                (ical:date/time-add low1 :second 3 ict:tz-eastern)
+                (ical:date/time-add low1 :second 4 ict:tz-eastern))))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1) ict:tz-eastern))))
+
+  ;; A subinterval five seconds long, with time zone, which crosses an
+  ;; observance boundary where the final three seconds occur after
+  ;; clocks are set forward an hour; these seconds should therefore be in EDT:
+  (let* ((low1 (ical:make-date-time :year 2025 :month 3 :day 9
+                                    :hour 1 :minute 59 :second 58
+                                    :zone ict:est :dst nil))
+         (high1 (ical:make-date-time :year 2025 :month 3 :day 9
+                                     :hour 3 :minute 0 :second 3
+                                     :zone ict:edt :dst t))
+         (sub1 (list low1 high1))
+         (expected-recs
+          (list low1
+                (ical:date-time-variant low1 :second 59 :tz 'preserve)
+                (ical:date-time-variant high1 :second 0 :tz 'preserve)
+                (ical:date-time-variant high1 :second 1 :tz 'preserve)
+                (ical:date-time-variant high1 :second 2 :tz 'preserve))))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1) ict:tz-eastern))))
+
+  ;; A subinterval five seconds long, with time zone, which crosses an
+  ;; observance boundary where the final three seconds occur after
+  ;; clocks are set back an hour; these seconds should therefore be in
+  ;; EST:
+  (let* ((low1 (ical:make-date-time :year 2024 :month 11 :day 3
+                                    :hour 1 :minute 59 :second 58
+                                    :zone ict:edt :dst t))
+         (high1 (ical:make-date-time :year 2024 :month 11 :day 3
+                                     :hour 1 :minute 0 :second 2
+                                     :zone ict:est :dst nil))
+         (sub1 (list low1 high1))
+         (expected-recs
+          (list low1
+                (ical:date-time-variant low1 :second 59 :tz 'preserve)
+                (ical:date-time-variant high1 :second 0 :tz 'preserve)
+                (ical:date-time-variant high1 :second 1 :tz 'preserve))))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1) ict:tz-eastern)))))
+
+;; Tests for time zone functions:
+
+(ert-deftest ict:recur-tz-observance-on/nonexistent ()
+  "Does `icr:tz-observance-on' correctly interpret nonexistent times?"
+  (let* ((onset-start (ical:make-date-time :year 2030 :month 3 :day 10
+                                           :hour 2 :minute 0 :second 0
+                                           :zone ict:est :dst nil))
+         (start-shifted (ical:date-time-variant onset-start :hour 3
+                                                :zone ict:edt :dst t))
+         ;; 2:30AM falls into the gap when the clock jumps from 2AM to 3AM:
+         (nonexistent (ical:date-time-variant onset-start :minute 30
+                                              :zone ict:est :dst nil))
+         (nonexistent-shifted (ical:date-time-variant nonexistent :hour 3
+                                                      :zone ict:edt :dst t)))
+    (icr:tz-observance-on onset-start ict:tz-eastern t) ;; updates the time to EDT
+    (icr:tz-observance-on nonexistent ict:tz-eastern t) ;; updates the time to EDT
+    (should (equal onset-start start-shifted))
+    (should (equal nonexistent nonexistent-shifted))))
+
+(ert-deftest ict:recur-tz-observance-on/occurs-twice ()
+  "Does `icr:tz-observance-on' correctly interpret times that occur twice?"
+  (let* ((onset-start (ical:make-date-time :year 2025 :month 11 :day 2
+                                           :hour 2 :minute 0 :second 0
+                                           :zone ict:edt :dst t))
+         ;; 1:30AM occurs twice when the clock is set back from 2AM to 1AM:
+         (no-zone (ical:date-time-variant onset-start :hour 1 :minute 30))
+         (first (ical:date-time-variant onset-start :hour 1 :minute 30
+                                        :zone ict:edt :dst t))
+         (second (ical:date-time-variant first :zone ict:est :dst nil))
+         (first+1h (ical:date/time-add first :hour 1 ict:tz-eastern)))
+    (icr:tz-observance-on no-zone ict:tz-eastern t) ;; sets zone
+    (should (equal first no-zone))
+    (should (equal second first+1h))))
+
+(ert-deftest ict:recur-tz-observance-on ()
+  "Does `icr:tz-observance-on' correctly find observances?"
+
+  ;; A date before the start of all observances in the timezone.
+  ;; In this case, there is no matching observance, so we should get nil.
+  (let* ((dt (ical:make-date-time :year 1900 :month 1 :day 1
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (ts (encode-time dt)))
+    (should (null (icr:tz-observance-on dt ict:tz-eastern)))
+    (should (null (icr:tz-observance-on ts ict:tz-eastern))))
+
+  ;; A date matching the start of one of the STANDARD observances:
+  (let* ((dt (ical:make-date-time :year 1967 :month 10 :day 29
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching the start of a DAYLIGHT observance:
+  (let* ((dt (ical:make-date-time :year 1967 :month 4 :day 30
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching an RDATE of a DAYLIGHT observance:
+  (let* ((dt (ical:make-date-time :year 1975 :month 2 :day 23
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching the end of a STANDARD observance:
+  (let* ((ut (ical:make-date-time :year 2006 :month 10 :day 29
+                                  :hour 6 :minute 0 :second 0
+                                  :zone 0 :dst nil)) ; UNTIL is in UTC
+         (dt (ical:make-date-time :year 2006 :month 10 :day 29
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (ical:date-time-simultaneous-p ut dt))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching the end of a DAYLIGHT observance:
+  (let* ((ut (ical:make-date-time :year 2006 :month 4 :day 2
+                                  :hour 7 :minute 0 :second 0
+                                  :zone 0 :dst nil)) ; UNTIL is in UTC
+         (dt (ical:make-date-time :year 2006 :month 4 :day 2
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (ical:date-time-simultaneous-p ut dt))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching an onset in the middle of a DAYLIGHT observance
+  ;; which has ended:
+  (let* ((dt (ical:make-date-time :year 1980 :month 4 :day 27
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (end (ical:make-date-time :year 1986 :month 4 :day 27
+                                   :hour 7 :minute 0 :second 0
+                                   :zone 0)) ; UNTIL is in UTC
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal end (ical:recur-until
+                        (ical:with-property-of obs 'ical:rrule nil value))))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching an onset of the DAYLIGHT observance which is
+  ;; ongoing:
+  (let* ((dt (ical:make-date-time :year 2025 :month 3 :day 9
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date in the middle of the DAYLIGHT observance which is ongoing:
+  (let* ((start (ical:make-date-time :year 2025 :month 3 :day 9
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:est :dst nil))
+         (dt (ical:make-date-time :year 2025 :month 5 :day 28
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date in the middle of the STANDARD observance which is ongoing:
+  (let* ((start (ical:make-date-time :year 2025 :month 11 :day 2
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2026 :month 1 :day 28
+                                  :hour 12 :minute 30 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; The following two tests were useful in detecting a broken optimization:
+  (let* ((start (ical:make-date-time :year 2006 :month 10 :day 29
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2006 :month 11 :day 1
+                                  :hour 12 :minute 30 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  (let* ((start (ical:make-date-time :year 2007 :month 11 :day 4
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2008 :month 2 :day 1
+                                  :hour 12 :minute 30 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+
+  ;; A date in the middle of the STANDARD observance which is ongoing;
+  ;; test that the update flag correctly sets the zone information:
+  (let* ((start (ical:make-date-time :year 2025 :month 11 :day 2
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2026 :month 1 :day 28
+                                  :hour 12 :minute 30 :second 0
+                                  ;; no zone information
+                                  ))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern t))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))))
+
+
+;; Tests for recurrence rule interpretation:
+(cl-defmacro ict:rrule-test (recur-string doc
+                             &key dtstart
+                             (low dtstart)
+                             high
+                             tz
+                             rdates
+                             exdates
+                             members
+                             nonmembers
+                             size
+                             source)
+
+  "Create a test which parses RECUR-STRING to an `icalendar-recur',
+creates an event with a recurrence set from this value, and checks
+various properties of the recurrence set.
+
+DTSTART should be an `icalendar-date' or `icalendar-date-time'
+  value appropriate to the RECUR-STRING. The value will be
+  bound to the symbol `dtstart'; this symbol can thus be used inside
+  the expressions for MEMBERS and NONMEMBERS.
+LOW and HIGH should be the bounds of the window in which to compute
+  recurrences. LOW defaults to DTSTART.
+TZ, if present, should be an `icalendar-vtimezone'.
+  Date-times in the recurrence set will be calculated relative to this
+  time zone.
+RDATES, if present, should be a list of additional
+  `icalendar-date' or `icalendar-date-time' values to be added to
+  the recurrence set *in addition to* those generated by the
+  recurrence rule (see `icalendar-rdate').
+EXDATES, if present, should be a list of `icalendar-date' or
+  `icalendar-date-time' values to be excluded from the recurrence
+  set, *even if* they are in RDATES or generated by the
+  recurrence rule (see `icalendar-exdate').
+MEMBERS, if present, should be a list of values that are expected
+  to be present in the recurrence set.
+NONMEMBERS, if present, should be a list of values that are expected
+  to be excluded from the recurrence set.
+SIZE, if present, should be a positive integer representing the
+  expected size of the recurrence set. Defaults to the value of the
+  COUNT clause in the recurrence rule, if any.
+SOURCE should be a symbol; it is used to name the test."
+  `(ert-deftest ,(intern (concat "ict:rrule-test-" (symbol-name source))) ()
+     ,(format "Parse and evaluate recur-value example from `%s':\n%s"
+              source doc)
+     (let* ((parsed (ical:parse-from-string 'ical:recur ,recur-string))
+            (recvalue (ical:ast-node-value parsed))
+            (until (ical:recur-until recvalue))
+            (count (ical:recur-count recvalue))
+            (dtstart ,dtstart)
+            (recset-size (or ,size count))
+            (vevent ;; TODO: an ical:make-vevent (etc.) macro would be nice...
+             (ical:make-ast-node 'icalendar-vevent nil
+               (list
+                (ical:make-ast-node
+                 'ical:dtstart
+                 (list :value (ical:make-ast-node
+                               (if (cl-typep dtstart 'ical:date) 'ical:date 'ical:date-time)
+                               (list :value dtstart))))
+                (ical:make-ast-node
+                 'ical:rrule
+                 (list :value parsed))
+                (ical:make-ast-node
+                 'ical:rdate
+                 (list :value
+                       (mapcar
+                        (lambda (d)
+                          (ical:make-ast-node
+                           (if (cl-typep d 'ical:date) 'ical:date 'ical:date-time)
+                           (list :value d)))
+                        ,rdates)))
+                (ical:make-ast-node
+                 'ical:exdate
+                 (list :value
+                       (mapcar
+                        (lambda (d)
+                          (ical:make-ast-node
+                           (if (cl-typep d 'ical:date) 'ical:date 'ical:date-time)
+                           (list :value d)))
+                        ,exdates))))))
+            ;; default for HIGH: UNTIL or DTSTART+3*INTERVAL
+            (win-high
+             (or ,high
+                 until
+                 (cadr
+                  (icr:nth-interval 2 ,dtstart recvalue))))
+            (recs
+             (if count
+                 (icr:recurrences-to-count vevent ,tz)
+               (icr:recurrences-in-window ,low win-high vevent ,tz))))
+       (should (ical:ast-node-valid-p parsed))
+       (when ,members
+         (dolist (dt ,members)
+           (should (member dt recs))))
+       (when ,nonmembers
+         (dolist (dt ,nonmembers)
+           (should-not (member dt recs))))
+       (when recset-size
+         (should (length= recs recset-size))))))
+
+(ict:rrule-test
+ "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1"
+ "Last non-weekend day of the month"
+ :dtstart '(3 31 2025)
+ :high '(6 1 2025)
+ :members '((3 31 2025) (4 30 2025) (5 30 2025))
+ :nonmembers '((5 31 2025)) ;; 5/31/2025 is a Saturday
+ :source rfc5545-sec3.3.10/1)
+
+(ict:rrule-test
+ "FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30"
+ "Every Sunday in January at 8:30AM and 9:30AM, every other year"
+ :dtstart (ical:read-date-time "20250105T083000")
+ :high (ical:read-date-time "20271231T000000")
+ :members
+ (let ((jan3-27 (ical:make-date-time :year 2027 :month 1 :day 3
+                                     :hour 8 :minute 30 :second 0)))
+   (list dtstart
+         ;; 2025: Jan 5, 12, 19, 26
+         (ical:date-time-variant dtstart :hour 9)
+         (ical:date-time-variant dtstart :day 12)
+         (ical:date-time-variant dtstart :day 12 :hour 9)
+         (ical:date-time-variant dtstart :day 19)
+         (ical:date-time-variant dtstart :day 19 :hour 9)
+         (ical:date-time-variant dtstart :day 19)
+         (ical:date-time-variant dtstart :day 19 :hour 9)
+         (ical:date-time-variant dtstart :day 26)
+         (ical:date-time-variant dtstart :day 26 :hour 9)
+         ;; 2027: Jan 3, 10, 17, 24, 31
+         (ical:date-time-variant jan3-27 :hour 9)
+         (ical:date-time-variant jan3-27 :day 10)
+         (ical:date-time-variant jan3-27 :day 10 :hour 9)
+         (ical:date-time-variant jan3-27 :day 17)
+         (ical:date-time-variant jan3-27 :day 17 :hour 9)
+         (ical:date-time-variant jan3-27 :day 24)
+         (ical:date-time-variant jan3-27 :day 24 :hour 9)
+         (ical:date-time-variant jan3-27 :day 31)
+         (ical:date-time-variant jan3-27 :day 31 :hour 9)))
+ :nonmembers
+ (list
+  (ical:make-date-time :year 2026 :month 1 :day 4
+                       :hour 8 :minute 30 :second 0)
+  (ical:make-date-time :year 2026 :month 1 :day 4
+                       :hour 9 :minute 30 :second 0))
+ :source rfc5545-sec3.3.10/2)
+
+(ict:rrule-test
+ "FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1"
+ "Every year on the last day in February"
+ :dtstart '(2 29 2024)
+ :high '(3 1 2028)
+ :members '((2 28 2025) (2 28 2026) (2 28 2027) (2 29 2028))
+ :nonmembers '((2 28 2028))
+ :source leap-day/1)
+
+(ict:rrule-test
+ "FREQ=YEARLY;INTERVAL=4;BYMONTH=2;BYMONTHDAY=29"
+ "Every four years on February 29"
+ :dtstart '(2 29 2024)
+ :high '(3 1 2028)
+ :members '((2 29 2028))
+ :nonmembers '((2 28 2028))
+ :source leap-day/2)
+
+(ict:rrule-test
+"FREQ=DAILY;COUNT=10"
+"Daily for 10 occurrences"
+:dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                              :hour 9 :minute 0 :second 0)
+:members
+;; (1997 9:00 AM EDT) September 2-11
+(mapcar
+ (lambda (day) (ical:date-time-variant dtstart :day day))
+ (number-sequence 2 11))
+:source rfc5545-sec3.3.10/3)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY"
+ "Every year on a specific date, e.g. an anniversary"
+ :dtstart '(11 11 2024)
+ :high '(10 1 2030)
+ :members '((11 11 2024)
+            (11 11 2025)
+            (11 11 2026)
+            (11 11 2027)
+            (11 11 2028)
+            (11 11 2029))
+ :nonmembers '((11 11 2030))
+ :source rfc5545-sec3.6.1/3)
+
+;; Time zone tests
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T070000Z"
+ "Every year on the last Sunday of April (through 1973-04-29) at 2AM.
+(Onset of US Eastern Daylight Time.)"
+ :tz ict:tz-eastern
+ ;; DTSTART and all the times below are at *3*AM EDT, because 2AM EST
+ ;; (the onset of the observance) does not exist as a local time:
+ :dtstart (ical:make-date-time :year 1967 :month 4 :day 30
+                               :hour 3 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:date-time-variant dtstart :year 1973 :month 4 :day 30
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  (ical:date-time-variant dtstart :year 1968 :day 28 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1969 :day 27 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1970 :day 26 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1971 :day 25 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1972 :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1973 :day 29 :tz 'preserve))
+ :source rfc5545-sec3.6.5/1)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU"
+ "Every year on the first Sunday of November at 2AM.
+(Onset of Eastern Standard Time)."
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 2007 :month 11 :day 4
+                               :hour 2 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:date-time-variant dtstart :year 2010 :month 11 :day 8
+                               :zone ict:est :dst nil)
+ :members
+ ;; all the times below are at *1*AM EST, because 2AM EDT (the onset of
+ ;; the observance) is when clocks get set back:
+ (list (ical:date-time-variant dtstart
+                               :year 2008 :month 11 :day 2
+                               :zone ict:est :dst nil)
+       (ical:date-time-variant dtstart
+                               :year 2009 :month 11 :day 1
+                               :zone ict:est :dst nil)
+       (ical:date-time-variant dtstart
+                               :year 2010 :month 11 :day 7
+                               :zone ict:est :dst nil))
+ :source rfc5545-sec3.6.5/3.1)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;INTERVAL=3;BYDAY=1SU"
+ "Every three months on the first Sunday of the month."
+ :dtstart '(1 5 2025)
+ :high '(1 1 2026)
+ :members (list '(4 6 2025)
+                '(7 6 2025)
+                '(10 5 2025))
+ :nonmembers (list '(1 12 2025) ;; second Sun.
+                   '(2 2 2025) ;; first Sun. in Feb.
+                   '(4 5 2025)) ;; Sat.
+ :source monthly/interval)
+
+(ict:rrule-test
+ "RRULE:FREQ=DAILY;COUNT=10\n"
+ "Daily for 10 occurrences"
+ :dtstart (ical:read-date-time "19970902T090000")
+ :members
+ (mapcar
+  (lambda (day) (ical:date-time-variant dtstart :day day))
+  (number-sequence 2 11))
+ :nonmembers (list (ical:date-time-variant dtstart :day 12))
+ :high (ical:read-date-time "19970912T090000")
+ :source rfc5545-sec3.8.5.3/1)
+
+(ict:rrule-test
+ "RRULE:FREQ=DAILY;UNTIL=19971224T000000Z\n"
+ "Daily at 9AM until December 24, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+   ;; EDT:
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (number-sequence 2 30)) ;; Sept. 2--30
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :month 10 :day day
+                                         :tz 'preserve))
+   (number-sequence 1 25)) ;; Oct. 1--25
+  ;; EST:
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :month 10 :day day :zone ict:est :dst nil))
+   (number-sequence 26 31))) ;; Oct. 26--31
+ :source rfc5545-sec3.8.5.3/2)
+
+(ict:rrule-test
+ "RRULE:FREQ=DAILY;INTERVAL=2\n"
+ "Every other day - forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 12 :day 4
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (append
+  ;; (1997 9:00 AM EDT) September 2,4,6,8...24,26,28,30;
+  ;;                    October 2,4,6...20,22,24
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :day (* 2 n) :tz 'preserve))
+   (number-sequence 1 15))
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 10 :day (* 2 n) :tz 'preserve))
+   (number-sequence 1 12))
+  ;; (1997 9:00 AM EST) October 26,28,30;
+  ;;                    November 1,3,5,7...25,27,29;
+  ;;                    December 1,3,...
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 10 :day (* 2 n)
+                             :zone ict:est :dst nil))
+   (number-sequence 13 15))
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 11 :day (1- (* 2 n))
+                             :zone ict:est :dst nil))
+   (number-sequence 1 15))
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 12 :day (1- (* 2 n))
+                             :zone ict:est :dst nil))
+   (number-sequence 1 2)))
+
+ :nonmembers
+ (list
+  ;; e.g.
+  (ical:make-date-time :year 1997 :month 10 :day 27
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/3)
+
+(ict:rrule-test
+ "RRULE:FREQ=DAILY;INTERVAL=10;COUNT=5\n"
+ "Every ten days for five recurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members ;; (1997 9:00 AM EDT) September 2,12,22; October 2,12
+ (list
+  (ical:make-date-time :year 1997 :month 9 :day 2
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 9 :day 12
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 9 :day 22
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 10 :day 2
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 10 :day 12
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t))
+ :source rfc5545-sec3.8.5.3/4)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA\n"
+ "Every day in January, for three years (weekdays explicit)"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1998 :month 1 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :high (ical:make-date-time :year 2000 :month 2 :day 1
+                            :hour 9 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ ;; (1998 9:00 AM EST)January 1-31
+ ;; (1999 9:00 AM EST)January 1-31
+ ;; (2000 9:00 AM EST)January 1-31
+ (append
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 1999 :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 2000 :day day :tz 'preserve))
+   (number-sequence 1 31)))
+ :source rfc5545-sec3.8.5.3/5)
+
+(ict:rrule-test
+ "RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1\n"
+ "Every day in January, for three years (weekdays implicit)"
+ ;; TODO: as things are currently implemented, this way of expressing
+ ;; the rule is quite expensive, since we end up computing intervals and
+ ;; recurrences for every day of the year, even though the only relevant
+ ;; days are in January and there are no recurrences on the other days.
+ ;; We could try to optimize e.g. icr:refine-from-clauses to deal with such
+ ;; cases.
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1998 :month 1 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :high (ical:make-date-time :year 2000 :month 2 :day 1
+                            :hour 9 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ ;; (1998 9:00 AM EST)January 1-31
+ ;; (1999 9:00 AM EST)January 1-31
+ ;; (2000 9:00 AM EST)January 1-31
+ (append
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 1999 :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 2000 :day day :tz 'preserve))
+   (number-sequence 1 31)))
+ :source rfc5545-sec3.8.5.3/6)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;COUNT=10\n"
+ "Weekly for ten occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+  ;; (1997 9:00 AM EDT) September 2,9,16,23,30;October 7,14,21
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (list 2 9 16 23 30))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :month 10 :day day :tz 'preserve))
+   (list 7 14 21))
+  ;; (1997 9:00 AM EST) October 28;November 4
+  (list
+   (ical:make-date-time :year 1997 :month 10 :day 28
+                        :hour 9 :minute 0 :second 0
+                        :zone ict:est :dst nil)
+   (ical:make-date-time :year 1997 :month 11 :day 4
+                        :hour 9 :minute 0 :second 0
+                        :zone ict:est :dst nil)))
+ :source rfc5545-sec3.8.5.3/7)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z\n"
+ "Every week until December 24, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (let ((oct97 (ical:date-time-variant dtstart :month 10
+                                      :zone ict:edt :dst t))
+       (nov97 (ical:date-time-variant dtstart :month 11
+                                      :zone ict:est :dst nil))
+       (dec97 (ical:date-time-variant dtstart :month 12
+                                      :zone ict:est :dst nil)))
+   (append
+    ;; (1997 9:00 AM EDT) September 2,9,16,23,30;
+    ;;                    October 7,14,21
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant dtstart :day day :tz 'preserve))
+     (list 2 9 16 23 30))
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant oct97 :day day :tz 'preserve))
+     (list 7 14 21))
+    ;; (1997 9:00 AM EST) October 28;
+    ;;                    November 4,11,18,25;
+    ;;                    December 2,9,16,23
+    (list (ical:date-time-variant oct97 :day 28 :zone ict:est :dst nil))
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant nov97 :day day :tz 'preserve))
+     (list 4 11 18 25))
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant dec97 :day day :tz 'preserve))
+     (list 2 9 16 23))))
+ :source rfc5545-sec3.8.5.3/8)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU\n"
+ "Every other week - forever; Weekstart on Sunday"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 3 :day 1
+                            :hour 9 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 2,16,30;
+  ;;                        October 14
+  dtstart
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 14 :tz 'preserve)
+  ;;    (1997 9:00 AM EST) October 28;
+  ;;                       November 11,25;
+  ;;                       December 9,23
+  (ical:date-time-variant dtstart :month 10 :day 28 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 11 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 25 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 23 :zone ict:est :dst nil)
+  ;;    (1998 9:00 AM EST) January 6,20;
+  ;;                       February 3, 17
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 20
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 3
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 17
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/9)
+
+(ict:rrule-test
+"RRULE:FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH\n"
+"Weekly on Tuesday and Thursday for five weeks, using UNTIL"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 10 :day 8
+                            :hour 0 :minute 0 :second 0 :zone 0)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30;
+  ;;                        October 2
+  dtstart
+  (ical:date-time-variant dtstart :day 4 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 18 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 23 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 25 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/10)
+
+(ict:rrule-test
+"RRULE:FREQ=WEEKLY;COUNT=10;WKST=SU;BYDAY=TU,TH\n"
+"Weekly on Tuesday and Thursday for five weeks, using COUNT"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 10 :day 8
+                            :hour 0 :minute 0 :second 0 :zone 0)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30;
+  ;;                        October 2
+  dtstart
+  (ical:date-time-variant dtstart :day 4 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 18 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 23 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 25 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/11)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR\n"
+ "Every other week on Monday, Wednesday, and Friday until December 24,
+1997, starting on Monday, September 1, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  dtstart
+  ;; ==> (1997 9:00 AM EDT) September 1,3,5,15,17,19,29;
+  ;;                        October 1,3,13,15,17
+  (ical:date-time-variant dtstart :day 3 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 5 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 17 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 19 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 29 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 1 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 3 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 13 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 17 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) October 27,29,31;
+  ;;                        November 10,12,14,24,26,28;
+  ;;                        December 8,10,12,22
+  (ical:date-time-variant dtstart :month 10 :day 27 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 10 :day 29 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 10 :day 31 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 10 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 12 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 14 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 24 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 26 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 28 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 8  :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 10 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 12 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 22 :zone ict:est :dst nil))
+ :nonmembers
+ (list
+  ;; These match the rule, but are just past the UNTIL date:
+  (ical:date-time-variant dtstart :month 12 :day 24 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 26 :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/12)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH\n"
+ "Every other week on Tuesday and Thursday, for 8 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ ;; ==> (1997 9:00 AM EDT) September 2,4,16,18,30;
+ ;;                        October 2,14,16
+ (list
+   dtstart
+   (ical:date-time-variant dtstart :day 4 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 18 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+   (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve)
+   (ical:date-time-variant dtstart :month 10 :day 14 :tz 'preserve)
+   (ical:date-time-variant dtstart :month 10 :day 16 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/13)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;COUNT=10;BYDAY=1FR\n"
+ "Monthly on the first Friday for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 5;October 3
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 3 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 7;December 5
+  (ical:date-time-variant dtstart :month 11 :day 7 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 5 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 2;February 6;March 6;April 3
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 4 :day 3
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EDT) May 1;June 5
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 1 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 6 :day 5 :tz 'preserve))
+  :source rfc5545-sec3.8.5.3/14)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;UNTIL=19971224T000000Z;BYDAY=1FR\n"
+ "Monthly on the first Friday until December 24, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 5; October 3
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 3 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 7;December 5
+  (ical:date-time-variant dtstart :month 11 :day 7 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 5 :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/15)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU\n"
+ "Every other month on the first and last Sunday of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 7
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 7,28
+  dtstart
+  (ical:date-time-variant dtstart :day 28 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 2,30
+  (ical:date-time-variant dtstart :month 11 :day 2 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 30 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 4,25;March 1,29
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 4
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 25
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 29
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EDT) May 3,31
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 3 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 31 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/16)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=-2MO\n"
+ "Monthly on the second-to-last Monday of the month for 6 months"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 22
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 22;October 20
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 20 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 17;December 22
+  (ical:date-time-variant dtstart :month 11 :day 17
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 22
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 19;February 16
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 19
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 16
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/17)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;BYMONTHDAY=-3\n"
+ "Monthly on the third-to-the-last day of the month, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 28
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 3 :day 1
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 28
+  dtstart
+  ;;     (1997 9:00 AM EST) October 29;November 28;December 29
+  (ical:date-time-variant dtstart :month 10 :day 29
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 28
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 29
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 29;February 26
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 29
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 26
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/18)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15\n"
+ "Monthly on the 2nd and 15th of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 2,15;October 2,15
+  dtstart
+  (ical:date-time-variant dtstart :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 15 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 2,15;December 2,15
+  (ical:date-time-variant dtstart :month 11 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 15
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 15
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 2,15
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 15
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/19)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1\n"
+ "Monthly on the first and last day of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 30
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 30;October 1
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 1 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) October 31;November 1,30;December 1,31
+  (ical:date-time-variant dtstart :month 10 :day 31
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 30
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 31
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 1,31;February 1
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 31
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 1
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/20)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15\n"
+ "Every 18 months on the 10th thru 15th of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 10
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+  (list
+   ;; ==> (1997 9:00 AM EDT) September 10,11,12,13,14,15
+   dtstart
+   (ical:date-time-variant dtstart :day 11 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 12 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 13 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 14 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 15 :tz 'preserve))
+
+  ;;     (1999 9:00 AM EST) March 10,11,12,13
+  (let ((mar99 (ical:make-date-time :year 1999 :month 3 :day 10
+                                    :hour 9 :minute 0 :second 0
+                                    :zone ict:est :dst nil)))
+    (list
+     mar99
+     (ical:date-time-variant mar99 :day 11 :tz 'preserve)
+     (ical:date-time-variant mar99 :day 12 :tz 'preserve)
+     (ical:date-time-variant mar99 :day 13 :tz 'preserve))))
+ :nonmembers
+ (list
+  ;; These match the rule but are excluded by the COUNT clause:
+  (ical:make-date-time :year 1999 :month 3 :day 14
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:est :dst nil)
+  (ical:make-date-time :year 1999 :month 3 :day 15
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/21)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU\n"
+ "Every Tuesday, every other month"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 4 :day 1
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 2,9,16,23,30
+  dtstart
+  (ical:date-time-variant dtstart :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 23 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 4,11,18,25
+  (ical:date-time-variant dtstart :month 11 :day 4
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 11
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 11
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 25
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 6,13,20,27;March 3,10,17,24,31
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 20
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 27
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 3
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 10
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 17
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 24
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 31
+                          :zone ict:est :dst nil))
+ :nonmembers
+ ;; e.g. Tuesdays in December 1997:
+ (list
+  (ical:date-time-variant dtstart :month 12 :day 2 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 9 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 16 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 23 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 30 :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/22)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;COUNT=10;BYMONTH=6,7\n"
+ "Yearly in June and July for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 6 :day 10
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ ;; Note: Since none of the BYDAY, BYMONTHDAY, or BYYEARDAY
+ ;; clauses are specified, the month day is gotten from "DTSTART"
+ :members
+ ;; ==> (1997 9:00 AM EDT) June 10;July 10
+ ;;     (1998 9:00 AM EDT) June 10;July 10
+ ;;     (1999 9:00 AM EDT) June 10;July 10
+ ;;     (2000 9:00 AM EDT) June 10;July 10
+ ;;     (2001 9:00 AM EDT) June 10;July 10
+ (mapcan
+  (lambda (y)
+    (list
+     (ical:date-time-variant dtstart :year y :month 6 :tz 'preserve)
+     (ical:date-time-variant dtstart :year y :month 7 :tz 'preserve)))
+  (number-sequence 1997 2001))
+ :source rfc5545-sec3.8.5.3/23)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3\n"
+ "Every other year on January, February, and March for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 3 :day 10
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :members
+ ;; ==> (1997 9:00 AM EST) March 10
+ ;;     (1999 9:00 AM EST) January 10;February 10;March 10
+ ;;     (2001 9:00 AM EST) January 10;February 10;March 10
+ ;;     (2003 9:00 AM EST) January 10;February 10;March 10
+ (cons
+  dtstart
+  (mapcan
+   (lambda (y)
+     (list
+      (ical:date-time-variant dtstart :year y :month 1 :tz 'preserve)
+      (ical:date-time-variant dtstart :year y :month 2 :tz 'preserve)
+      (ical:date-time-variant dtstart :year y :month 3 :tz 'preserve)))
+   (list 1999 2001 2003)))
+ :source rfc5545-sec3.8.5.3/24)
+
+(ict:rrule-test
+"RRULE:FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200\n"
+"Every third year on the 1st, 100th, and 200th day for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 1 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EST) January 1
+  dtstart
+  ;;     (1997 9:00 AM EDT) April 10;July 19
+  (ical:date-time-variant dtstart :month 4 :day 10 :zone ict:edt :dst t)
+  (ical:date-time-variant dtstart :month 7 :day 19 :zone ict:edt :dst t)
+  ;;     (2000 9:00 AM EST) January 1
+  (ical:date-time-variant dtstart :year 2000 :tz 'preserve)
+  ;;     (2000 9:00 AM EDT) April 9;July 18
+  (ical:date-time-variant dtstart :year 2000 :month 4 :day 9 :zone ict:edt :dst t)
+  (ical:date-time-variant dtstart :year 2000 :month 7 :day 18 :zone ict:edt :dst t)
+  ;;     (2003 9:00 AM EST) January 1
+  (ical:date-time-variant dtstart :year 2003 :tz 'preserve)
+  ;;     (2003 9:00 AM EDT) April 10;July 19
+  (ical:date-time-variant dtstart :year 2003 :month 4 :day 10 :zone ict:edt :dst t)
+  (ical:date-time-variant dtstart :year 2003 :month 7 :day 19 :zone ict:edt :dst t)
+  ;;     (2006 9:00 AM EST) January 1
+  (ical:date-time-variant dtstart :year 2006 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/25)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;BYDAY=20MO\n"
+ "Every 20th Monday of the year, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 5 :day 19
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) May 19
+  ;;     (1998 9:00 AM EDT) May 18
+  ;;     (1999 9:00 AM EDT) May 17
+  ;;     ...
+  dtstart
+  (ical:date-time-variant dtstart :year 1998 :day 18 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1999 :day 17 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/26)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO\n"
+ "Every year on Monday in Week 20 (where the week starts Monday), forever"
+ :tz ict:tz-eastern
+ :dtstart
+ (ical:make-date-time :year 1997 :month 5 :day 12
+                      :hour 9 :minute 0 :second 0
+                      :zone ict:edt :dst t)
+ :members
+ (list
+  (ical:date-time-variant dtstart :year 1998 :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1999 :day 17 :tz 'preserve))
+ :nonmembers
+ (list
+  (ical:date-time-variant dtstart :year 1998 :day 12 :tz 'preserve) ; a Tuesday
+  (ical:date-time-variant dtstart :year 1998 :day 18 :tz 'preserve)) ; wrong weekno
+ :source rfc5545-sec3.8.5.3/27)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=TH\n"
+ "Every Thursday in March, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 3 :day 13
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :members
+ (append
+  ;; ==> (1997 9:00 AM EST) March 13,20,27
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :day d :tz 'preserve))
+   (list 13 20 27))
+  ;;     (1998 9:00 AM EST) March 5,12,19,26
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :day d :tz 'preserve))
+   (list 5 12 19 26))
+  ;;     (1999 9:00 AM EST) March 4,11,18,25
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :day d :tz 'preserve))
+   (list 4 11 18 25)))
+ :source rfc5545-sec3.8.5.3/28)
+
+(ict:rrule-test
+"RRULE:FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8\n"
+"Every Thursday, but only during June, July, and August, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 6 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+  ;; ==> (1997 9:00 AM EDT) June 5,12,19,26;July 3,10,17,24,31;
+  ;;                        August 7,14,21,28
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :day d :tz 'preserve))
+   (list 5 12 19 26))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :month 7 :day d :tz 'preserve))
+   (list 3 10 17 24 31))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :month 8 :day d :tz 'preserve))
+   (list 7 14 21 28))
+  ;;     (1998 9:00 AM EDT) June 4,11,18,25;July 2,9,16,23,30;
+  ;;                        August 6,13,20,27
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :day d :tz 'preserve))
+   (list 4 11 18 25))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :month 7 :day d :tz 'preserve))
+   (list 2 9 16 23 30))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :month 8 :day d :tz 'preserve))
+   (list 6 13 20 27))
+  ;;     (1999 9:00 AM EDT) June 3,10,17,24;July 1,8,15,22,29;
+  ;;                        August 5,12,19,26
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :day d :tz 'preserve))
+   (list 3 10 17 24))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :month 7 :day d :tz 'preserve))
+   (list 1 8 15 22 29))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :month 8 :day d :tz 'preserve))
+   (list 5 12 19 26)))
+ :source rfc5545-sec3.8.5.3/29)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13\n"
+ "Every Friday the 13th, forever, *excluding* DTSTART "
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 2000 :month 10 :day 14
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :exdates (list dtstart)
+ :members
+ (list
+  ;; ==> (1998 9:00 AM EST) February 13;March 13;November 13
+  ;;     (1999 9:00 AM EDT) August 13
+  ;;     (2000 9:00 AM EDT) October 13
+  ;;     ...
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 11 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1999 :month 8 :day 13 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 2000 :month 10 :day 13 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/30)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13\n"
+ "The first Saturday that follows the first Sunday of the month, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 13
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 6 :day 14
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 13;October 11
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 11 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 8;December 13
+  (ical:date-time-variant dtstart :month 11 :day 8 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 13 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 10;February 7;March 7
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 10
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 7
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 7
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EDT) April 11;May 9;June 13...
+  (ical:date-time-variant dtstart :year 1998 :month 4 :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 6 :day 13 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/31)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8\n"
+ "Every 4 years, the first Tuesday after a Monday in November, forever
+(U.S. Presidential Election day)"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1996 :month 11 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+
+ :members
+ (list
+  ;; ==> (1996 9:00 AM EST) November 5
+  dtstart
+  ;;     (2000 9:00 AM EST) November 7
+  (ical:date-time-variant dtstart :year 2000 :day 7 :tz 'preserve)
+  ;;     (2004 9:00 AM EST) November 2
+  (ical:date-time-variant dtstart :year 2004 :day 2 :tz 'preserve))
+  :source rfc5545-sec3.8.5.3/32)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3\n"
+ "The third instance into the month of one of Tuesday, Wednesday, or
+Thursday, for the next 3 months"
+ ;; TODO: Yikes, why is this so slow??
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 4
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 4;October 7
+  ;;     (1997 9:00 AM EST) November 6
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 7 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 11 :day 6 :zone ict:est :dst nil))
+:source rfc5545-sec3.8.5.3/33)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2\n"
+ "The second-to-last weekday of the month"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 29
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 4 :day 1
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 29
+  dtstart
+  ;;     (1997 9:00 AM EST) October 30;November 27;December 30
+  (ical:date-time-variant dtstart :month 10 :day 30 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 27 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 30 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 29;February 26;March 30
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 29
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 26
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 30
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/34)
+
+(ict:rrule-test
+ ;; corrected, see Errata ID 3883: https://www.rfc-editor.org/errata/eid3883
+ "RRULE:FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T210000Z\n"
+ "Every 3 hours from 9:00 AM to 5:00 PM on a specific day"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members
+ (list
+  ;; ==> (September 2, 1997 EDT) 09:00,12:00,15:00
+  dtstart
+  (ical:date-time-variant dtstart :hour 12 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 15 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/35)
+
+(ict:rrule-test
+ "RRULE:FREQ=MINUTELY;INTERVAL=15;COUNT=6\n"
+ "Every 15 minutes for 6 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members
+ (list
+  ;; ==> (September 2, 1997 EDT) 09:00,09:15,09:30,09:45,10:00,10:15
+  dtstart
+  (ical:date-time-variant dtstart :minute 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :minute 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :minute 45 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 10 :minute 0 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 10 :minute 15 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/36)
+
+(ict:rrule-test
+ "RRULE:FREQ=MINUTELY;INTERVAL=90;COUNT=4\n"
+ "Every hour and a half for 4 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (September 2, 1997 EDT) 09:00,10:30;12:00;13:30
+  dtstart
+  (ical:date-time-variant dtstart :hour 10 :minute 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 12 :minute 0 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 13 :minute 30 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/37)
+
+(ict:rrule-test
+ "RRULE:FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40\n"
+ "Every 20 minutes from 9:00 AM to 4:40 PM every day"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 9 :day 4
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :members
+ (append
+  ;; ==> (September 2, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ... 16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :minute 0 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 20 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 40 :tz 'preserve)))
+   (number-sequence 9 16))
+  ;;     (September 3, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ...16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 0 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 20 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 40 :tz 'preserve)))
+   (number-sequence 9 16)))
+ :source rfc5545-sec3.8.5.3/38)
+
+(ict:rrule-test
+ "RRULE:FREQ=MINUTELY;INTERVAL=20;BYHOUR=9,10,11,12,13,14,15,16\n"
+ "Every 20 minutes from 9:00 AM to 4:40 PM every day
+(Alternative rule for the previous example)"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 9 :day 4
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :members
+ (append
+  ;; ==> (September 2, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ... 16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :minute 0 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 20 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 40 :tz 'preserve)))
+   (number-sequence 9 16))
+  ;;     (September 3, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ...16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 0 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 20 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 40 :tz 'preserve)))
+   (number-sequence 9 16)))
+:source rfc5545-sec3.8.5.3/39)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO\n"
+ "An example where the days generated makes a difference because of WKST:
+every other week on Tuesday and Sunday, week start Monday, for four recurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 8 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 EDT) August 5,10,19,24
+  dtstart
+  (ical:date-time-variant dtstart :day 10 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 19 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 24 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/40)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU\n"
+ "An example where the days generated makes a difference because of WKST:
+every other week on Tuesday and Sunday, week start Sunday, for four recurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 8 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 EDT) August 5,17,19,31
+  dtstart
+  (ical:date-time-variant dtstart :day 17 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 19 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 31 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/41)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5\n"
+ "An example where an invalid date (i.e., February 30) is ignored."
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 2007 :month 1 :day 15
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :high (ical:make-date-time :year 2007 :month 4 :day 1
+                               :hour 0 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (2007 EST) January 15,30
+  ;;     (2007 EST) February 15
+  ;;     (2007 EDT) March 15,30
+  dtstart
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 2 :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 3 :day 15 :zone ict:edt :dst t)
+  (ical:date-time-variant dtstart :month 3 :day 30 :zone ict:edt :dst t))
+ :nonmembers
+ (list
+  (ical:date-time-variant dtstart :month 2 :day 28 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 2 :day 30 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/42)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ict:" . "icalendar-test-") ("icr:" . "icalendar-recur-") ("ical:" . "icalendar-"))
+;; End:
+;;; tests/icalendar-recur.el ends here
-- 
2.39.5