1"""parsing and generation of content lines"""
2
3import re
4
5from icalendar.parser.parameter import Parameters
6from icalendar.parser.property import unescape_backslash, unescape_list_or_string
7from icalendar.parser.string import (
8 _escape_string,
9 _foldline,
10 _unescape_string,
11 validate_token,
12)
13from icalendar.parser_tools import DEFAULT_ENCODING, ICAL_TYPE, to_unicode
14
15# Equivalent to the natural ``(\r?\n)+[ \t]`` but without its quadratic cost:
16# the greedy run is re-tried at every line break of a long unfoldable block, so
17# a megabyte of bare newlines takes seconds. The leading lookbehinds pin a match
18# to the first line break of a run, making a failed attempt O(1) instead of a
19# full rescan, while matching exactly the same strings.
20UFOLD = re.compile(r"(?:(?<!\n)\r\n|(?<![\r\n])\n)(?:\r?\n)*[ \t]")
21NEWLINE = re.compile(r"\r?\n")
22
23OWS = " \t"
24# ``[ \t]*([;=])[ \t]*`` in one pass rescans a long whitespace run at every
25# position. Splitting it into two anchored passes (leading then trailing) keeps
26# the result identical but removes the quadratic blow-up.
27OWS_BEFORE_DELIMITER_RE = re.compile(r"(?<![ \t])[ \t]+([;=])")
28OWS_AFTER_DELIMITER_RE = re.compile(r"([;=])[ \t]+")
29
30
31def _strip_ows_around_delimiters(st: str, delimiters: str = ";=") -> str:
32 """Strip optional whitespace around delimiters outside of quoted sections,
33 respecting backslash escapes so that escaped delimiters are not treated as
34 separators.
35
36 This is a lenient parsing helper (used when strict=False) to support
37 iCalendar content lines that contain extra whitespace around tokens.
38 """
39 if not st:
40 return st
41
42 # Fast path for the common case in non-strict mode:
43 # no whitespace in the parameter section means there is nothing to normalize.
44 if " " not in st and "\t" not in st:
45 return st
46
47 # Fast regex-based path for simple parameter sections without quoting/escaping.
48 if delimiters == ";=" and '"' not in st and "\\" not in st:
49 st = OWS_BEFORE_DELIMITER_RE.sub(r"\1", st)
50 return OWS_AFTER_DELIMITER_RE.sub(r"\1", st).strip()
51
52 out: list[str] = []
53 pending_ws: list[str] = []
54 in_quotes = False
55 escaped = False
56 # True only if the last appended char was a raw delimiter.
57 last_was_delimiter = False
58
59 def flush_pending() -> None:
60 nonlocal pending_ws
61 if not pending_ws:
62 return
63 if not last_was_delimiter:
64 out.extend(pending_ws)
65 pending_ws.clear()
66
67 for ch in st:
68 # Handle escaped character (the backslash set escaped in previous iteration)
69 if escaped:
70 flush_pending()
71 out.append(ch)
72 escaped = False
73 last_was_delimiter = False
74 continue
75
76 # Handle backslash to escape next character
77 if ch == "\\" and not in_quotes:
78 flush_pending()
79 out.append(ch)
80 escaped = True
81 last_was_delimiter = False
82 continue
83
84 # Handle quote toggling
85 if ch == '"' and not escaped:
86 in_quotes = not in_quotes
87 flush_pending()
88 out.append(ch)
89 last_was_delimiter = False
90 continue
91
92 # Whitespace outside quotes is buffered
93 if not in_quotes and not escaped and ch in OWS:
94 pending_ws.append(ch)
95 continue
96
97 # Raw delimiter (unescaped and outside quotes)
98 if not in_quotes and not escaped and ch in delimiters:
99 pending_ws.clear()
100 while out and out[-1] in OWS:
101 out.pop()
102 out.append(ch)
103 last_was_delimiter = True
104 continue
105
106 # Regular character
107 flush_pending()
108 out.append(ch)
109 last_was_delimiter = False
110
111 if pending_ws and not last_was_delimiter:
112 out.extend(pending_ws)
113
114 return "".join(out).strip()
115
116
117class Contentline(str):
118 """A content line is basically a string that can be folded and parsed into
119 parts.
120 """
121
122 __slots__ = ("strict",)
123
124 def __new__(cls, value, strict=False, encoding=DEFAULT_ENCODING):
125 value = to_unicode(value, encoding=encoding)
126 assert "\n" not in value, (
127 "Content line can not contain unescaped new line characters."
128 )
129 self = super().__new__(cls, value)
130 self.strict = strict
131 return self
132
133 @classmethod
134 def from_parts(
135 cls,
136 name: ICAL_TYPE,
137 params: Parameters,
138 values,
139 sorted: bool = True, # noqa: A002
140 ):
141 """Turn a parts into a content line."""
142 assert isinstance(params, Parameters)
143 if hasattr(values, "to_ical"):
144 values = values.to_ical()
145 else:
146 from icalendar.prop import vText
147
148 values = vText(values).to_ical()
149 # elif isinstance(values, basestring):
150 # values = escape_char(values)
151
152 # TODO: after unicode only, remove this
153 # Convert back to unicode, after to_ical encoded it.
154 name = to_unicode(name)
155 values = to_unicode(values)
156 if params:
157 params = to_unicode(params.to_ical(sorted=sorted))
158 if params:
159 # some parameter values can be skipped during serialization
160 return cls(f"{name};{params}:{values}")
161 return cls(f"{name}:{values}")
162
163 def raw_parts(self) -> tuple[str, Parameters, str]:
164 """Split the line into ``name``, ``parameters``, and raw ``values`` parts.
165
166 This is :meth:`parts` without the unescaping: the values are returned
167 verbatim, preserving both backslash sequences and URL encoding. It is
168 used for :rfc:`7265` ``UNKNOWN`` values, whose real value type—and
169 therefore whose escaping rules—are not known.
170
171 See :meth:`parts` for the parts themselves and for examples.
172 """
173 try:
174 name_split: int | None = None
175 value_split: int | None = None
176 in_quotes: bool = False
177 escaped: bool = False
178
179 for i, ch in enumerate(self):
180 if ch == '"' and not escaped:
181 in_quotes = not in_quotes
182 elif ch == "\\" and not in_quotes:
183 escaped = True
184 continue
185 elif not in_quotes and not escaped:
186 # Find first delimiter for name
187 if ch in ":;" and name_split is None:
188 name_split = i
189 # Find value delimiter (first colon)
190 if ch == ":" and value_split is None:
191 value_split = i
192
193 escaped = False
194
195 # Validate parsing results
196 if not value_split:
197 # No colon found - value is empty, use end of string
198 value_split = len(self)
199
200 # Extract name - if no delimiter,
201 # take whole string for validate_token to reject
202 name = self[:name_split] if name_split else self
203 if not self.strict:
204 name = re.sub(r"[ \t]+", "", name.strip())
205 validate_token(name)
206
207 if not name_split or name_split + 1 == value_split:
208 # No delimiter or empty parameter section
209 raise ValueError("Invalid content line") # noqa: TRY301
210 # Parse parameters - they still need to be escaped/unescaped
211 # for proper handling of commas, semicolons, etc. in parameter values
212 raw_param_str = self[name_split + 1 : value_split]
213 if not self.strict:
214 raw_param_str = _strip_ows_around_delimiters(raw_param_str)
215 param_str = _escape_string(raw_param_str)
216 params = Parameters.from_ical(param_str, strict=self.strict)
217 params = Parameters(
218 (_unescape_string(key), unescape_list_or_string(value))
219 for key, value in iter(params.items())
220 )
221 values = self[value_split + 1 :]
222 except ValueError as exc:
223 raise ValueError(
224 f"Content line could not be parsed into parts: '{self}': {exc}"
225 ) from exc
226 return (name, params, values)
227
228 def parts(self) -> tuple[str, Parameters, str]:
229 """Split the line into ``name``, ``parameters``, and unescaped ``values`` parts.
230
231 Properly handles escaping with backslashes and double-quote sections
232 to avoid corrupting URL-encoded characters in values.
233
234 The backslash sequences in the values are unescaped, as for the values
235 of ``TEXT`` properties, while URL encoding is preserved. Use
236 :meth:`raw_parts` to get the values verbatim instead.
237
238 Examples:
239
240 With parameter:
241
242 .. code-block:: ics
243
244 DESCRIPTION;ALTREP="cid:part1.0001@example.org":The Fall'98 Wild
245
246 Without parameters:
247
248 .. code-block:: ics
249
250 DESCRIPTION:The Fall'98 Wild
251 """
252 name, params, values = self.raw_parts()
253 return (name, params, unescape_backslash(values))
254
255 def value_separator_index(self) -> int:
256 r"""Return the index of the colon that separates the value.
257
258 This is the first colon that is not inside a quoted parameter section.
259 A colon inside a quoted parameter value (for example
260 ``ALTREP="http://x"``) is skipped, and a colon that belongs to the
261 value (``TEXT`` does not escape ``:``) is not mistaken for the
262 separator. Backslash has no special meaning in the parameter grammar
263 (:rfc:`5545#section-3.1`), so it is treated as an ordinary character.
264
265 Returns:
266 An integer representing the index position of the separator,
267 or ``-1`` if there is none.
268 """
269 in_quotes = False
270 for i, ch in enumerate(self):
271 if ch == '"':
272 in_quotes = not in_quotes
273 elif ch == ":" and not in_quotes:
274 return i
275 return -1
276
277 @classmethod
278 def from_ical(cls, ical, strict=False):
279 """Unfold the content lines in an iCalendar into long content lines."""
280 ical = to_unicode(ical)
281 # a fold is carriage return followed by either a space or a tab
282 return cls(UFOLD.sub("", ical), strict=strict)
283
284 def to_ical(self):
285 """Long content lines are folded so they are less than 75 characters
286 wide.
287 """
288 return _foldline(self).encode(DEFAULT_ENCODING)
289
290
291class Contentlines(list[Contentline]):
292 """I assume that iCalendar files generally are a few kilobytes in size.
293 Then this should be efficient. for Huge files, an iterator should probably
294 be used instead.
295 """
296
297 def to_ical(self):
298 """Simply join self."""
299 return b"\r\n".join(line.to_ical() for line in self if line) + b"\r\n"
300
301 @classmethod
302 def from_ical(cls, st):
303 """Parses a string into content lines."""
304 st = to_unicode(st)
305 try:
306 # a fold is carriage return followed by either a space or a tab
307 unfolded = UFOLD.sub("", st)
308 lines = cls(Contentline(line) for line in NEWLINE.split(unfolded) if line)
309 lines.append("") # '\r\n' at the end of every content line
310 except Exception as e:
311 raise ValueError("Expected StringType with content lines") from e
312 return lines
313
314
315__all__ = ["Contentline", "Contentlines"]