Skip to repository content406 lines · 14.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:00:00.678Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
markdown.rs
1use std::fmt::{Display, Formatter};
2
3/// Generates a URL-friendly slug from heading text (e.g. "Hello World" → "hello-world").
4pub fn generate_heading_slug(text: &str) -> String {
5 text.trim()
6 .chars()
7 .filter_map(|c| {
8 if c.is_alphanumeric() || c == '-' || c == '_' {
9 Some(c.to_lowercase().next().unwrap_or(c))
10 } else if c == ' ' {
11 Some('-')
12 } else {
13 None
14 }
15 })
16 .collect()
17}
18
19/// Returns true if the URL starts with a URI scheme (RFC 3986 §3.1).
20fn has_uri_scheme(url: &str) -> bool {
21 let mut chars = url.chars();
22 match chars.next() {
23 Some(c) if c.is_ascii_alphabetic() => {}
24 _ => return false,
25 }
26 for c in chars {
27 if c == ':' {
28 return true;
29 }
30 if !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
31 return false;
32 }
33 }
34 false
35}
36
37/// Splits a relative URL into its path and `#fragment` parts.
38/// Absolute URLs are returned as-is with no fragment.
39pub fn split_local_url_fragment(url: &str) -> (&str, Option<&str>) {
40 if has_uri_scheme(url) {
41 return (url, None);
42 }
43 match url.find('#') {
44 Some(pos) => {
45 let path = &url[..pos];
46 let fragment = &url[pos + 1..];
47 (
48 path,
49 if fragment.is_empty() {
50 None
51 } else {
52 Some(fragment)
53 },
54 )
55 }
56 None => (url, None),
57 }
58}
59
60pub fn source_position_from_fragment(fragment: &str) -> Option<(u32, u32)> {
61 let fragment = fragment.strip_prefix('L').unwrap_or(fragment);
62 let (line, column) = match fragment.split_once([',', ':']) {
63 Some((line, column)) => (line, Some(column)),
64 None => (
65 fragment.split_once('-').map_or(fragment, |(line, _)| line),
66 None,
67 ),
68 };
69 let line = line.parse::<u32>().ok()?.checked_sub(1)?;
70 let column = column
71 .and_then(|column| column.parse::<u32>().ok())
72 .and_then(|column| column.checked_sub(1))
73 .unwrap_or(0);
74 Some((line, column))
75}
76
77/// Indicates that the wrapped `String` is markdown text.
78#[derive(Debug, Clone)]
79pub struct MarkdownString(pub String);
80
81impl Display for MarkdownString {
82 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
83 write!(f, "{}", self.0)
84 }
85}
86
87/// Escapes markdown special characters in markdown text blocks. Markdown code blocks follow
88/// different rules and `MarkdownInlineCode` or `MarkdownCodeBlock` should be used in that case.
89///
90/// Also escapes the following markdown extensions:
91///
92/// * `^` for superscripts
93/// * `$` for inline math
94/// * `~` for strikethrough
95///
96/// Escape of some characters is unnecessary, because while they are involved in markdown syntax,
97/// the other characters involved are escaped:
98///
99/// * `!`, `]`, `(`, and `)` are used in link syntax, but `[` is escaped so these are parsed as
100/// plaintext.
101///
102/// * `;` is used in HTML entity syntax, but `&` is escaped, so they are parsed as plaintext.
103///
104/// TODO: There is one escape this doesn't do currently. Period after numbers at the start of the
105/// line (`[0-9]*\.`) should also be escaped to avoid it being interpreted as a list item.
106pub struct MarkdownEscaped<'a>(pub &'a str);
107
108/// Implements `Display` to format markdown inline code (wrapped in backticks), handling code that
109/// contains backticks and spaces. All whitespace is treated as a single space character. For text
110/// that does not contain whitespace other than ' ', this escaping roundtrips through
111/// pulldown-cmark.
112///
113/// When used in tables, `|` should be escaped like `\|` in the text provided to this function.
114pub struct MarkdownInlineCode<'a>(pub &'a str);
115
116/// Implements `Display` to format markdown code blocks, wrapped in 3 or more backticks as needed.
117pub struct MarkdownCodeBlock<'a> {
118 pub tag: &'a str,
119 pub text: &'a str,
120}
121
122impl Display for MarkdownEscaped<'_> {
123 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
124 let mut start_of_unescaped = None;
125 for (ix, c) in self.0.char_indices() {
126 match c {
127 // Always escaped.
128 '\\' | '`' | '*' | '_' | '[' | '^' | '$' | '~' | '&' |
129 // TODO: these only need to be escaped when they are the first non-whitespace
130 // character of the line of a block. There should probably be both an `escape_block`
131 // which does this and an `escape_inline` method which does not escape these.
132 '#' | '+' | '=' | '-' => {
133 match start_of_unescaped {
134 None => {}
135 Some(start_of_unescaped) => {
136 write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
137 }
138 }
139 write!(formatter, "\\")?;
140 // Can include this char in the "unescaped" text since a
141 // backslash was just emitted.
142 start_of_unescaped = Some(ix);
143 }
144 // Escaped since `<` is used in opening HTML tags. `<` is used since Markdown
145 // supports HTML entities, and this allows the text to be used directly in HTML.
146 '<' => {
147 match start_of_unescaped {
148 None => {}
149 Some(start_of_unescaped) => {
150 write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
151 }
152 }
153 write!(formatter, "<")?;
154 start_of_unescaped = None;
155 }
156 // Escaped since `>` is used for blockquotes. `>` is used since Markdown supports
157 // HTML entities, and this allows the text to be used directly in HTML.
158 '>' => {
159 match start_of_unescaped {
160 None => {}
161 Some(start_of_unescaped) => {
162 write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
163 }
164 }
165 write!(formatter, ">")?;
166 start_of_unescaped = None;
167 }
168 _ => {
169 if start_of_unescaped.is_none() {
170 start_of_unescaped = Some(ix);
171 }
172 }
173 }
174 }
175 if let Some(start_of_unescaped) = start_of_unescaped {
176 write!(formatter, "{}", &self.0[start_of_unescaped..])?;
177 }
178 Ok(())
179 }
180}
181
182impl Display for MarkdownInlineCode<'_> {
183 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
184 // Apache License 2.0, same as this crate.
185 //
186 // Copied from `pulldown-cmark-to-cmark-20.0.0` with modifications:
187 //
188 // * Handling of all whitespace. pulldown-cmark-to-cmark is anticipating
189 // `Code` events parsed by pulldown-cmark.
190 //
191 // https://github.com/Byron/pulldown-cmark-to-cmark/blob/3c850de2d3d1d79f19ca5f375e1089a653cf3ff7/src/lib.rs#L290
192
193 let mut all_whitespace = true;
194 let text = self
195 .0
196 .chars()
197 .map(|c| {
198 if c.is_whitespace() {
199 ' '
200 } else {
201 all_whitespace = false;
202 c
203 }
204 })
205 .collect::<String>();
206
207 // When inline code has leading and trailing ' ' characters, additional space is needed
208 // to escape it, unless all characters are space.
209 if all_whitespace {
210 write!(formatter, "`{text}`")
211 } else {
212 // More backticks are needed to delimit the inline code than the maximum number of
213 // backticks in a consecutive run.
214 let backticks = "`".repeat(count_max_consecutive_chars(&text, '`') + 1);
215 let space = match text.as_bytes() {
216 &[b'`', ..] | &[.., b'`'] => " ", // Space needed to separate backtick.
217 &[b' ', .., b' '] => " ", // Space needed to escape inner space.
218 _ => "", // No space needed.
219 };
220 write!(formatter, "{backticks}{space}{text}{space}{backticks}")
221 }
222 }
223}
224
225impl Display for MarkdownCodeBlock<'_> {
226 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
227 let tag = self.tag;
228 let text = self.text;
229 let backticks = "`".repeat(3.max(count_max_consecutive_chars(text, '`') + 1));
230 write!(formatter, "{backticks}{tag}\n{text}\n{backticks}\n")
231 }
232}
233
234// Copied from `pulldown-cmark-to-cmark-20.0.0` with changed names.
235// https://github.com/Byron/pulldown-cmark-to-cmark/blob/3c850de2d3d1d79f19ca5f375e1089a653cf3ff7/src/lib.rs#L1063
236// Apache License 2.0, same as this code.
237fn count_max_consecutive_chars(text: &str, search: char) -> usize {
238 let mut in_search_chars = false;
239 let mut max_count = 0;
240 let mut cur_count = 0;
241
242 for ch in text.chars() {
243 if ch == search {
244 cur_count += 1;
245 in_search_chars = true;
246 } else if in_search_chars {
247 max_count = max_count.max(cur_count);
248 cur_count = 0;
249 in_search_chars = false;
250 }
251 }
252 max_count.max(cur_count)
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn test_markdown_escaped() {
261 let input = r#"
262 # Heading
263
264 Another heading
265 ===
266
267 Another heading variant
268 ---
269
270 Paragraph with [link](https://example.com) and `code`, *emphasis*, and ~strikethrough~.
271
272 ```
273 code block
274 ```
275
276 List with varying leaders:
277 - Item 1
278 * Item 2
279 + Item 3
280
281 Some math: $`\sqrt{3x-1}+(1+x)^2`$
282
283 HTML entity:
284 "#;
285
286 let expected = r#"
287 \# Heading
288
289 Another heading
290 \=\=\=
291
292 Another heading variant
293 \-\-\-
294
295 Paragraph with \[link](https://example.com) and \`code\`, \*emphasis\*, and \~strikethrough\~.
296
297 \`\`\`
298 code block
299 \`\`\`
300
301 List with varying leaders:
302 \- Item 1
303 \* Item 2
304 \+ Item 3
305
306 Some math: \$\`\\sqrt{3x\-1}\+(1\+x)\^2\`\$
307
308 HTML entity: \
309 "#;
310
311 assert_eq!(MarkdownEscaped(input).to_string(), expected);
312 }
313
314 #[test]
315 fn test_markdown_inline_code() {
316 assert_eq!(MarkdownInlineCode(" ").to_string(), "` `");
317 assert_eq!(MarkdownInlineCode("text").to_string(), "`text`");
318 assert_eq!(MarkdownInlineCode("text ").to_string(), "`text `");
319 assert_eq!(MarkdownInlineCode(" text ").to_string(), "` text `");
320 assert_eq!(MarkdownInlineCode("`").to_string(), "`` ` ``");
321 assert_eq!(MarkdownInlineCode("``").to_string(), "``` `` ```");
322 assert_eq!(MarkdownInlineCode("`text`").to_string(), "`` `text` ``");
323 assert_eq!(
324 MarkdownInlineCode("some `text` no leading or trailing backticks").to_string(),
325 "``some `text` no leading or trailing backticks``"
326 );
327 }
328
329 #[test]
330 fn test_count_max_consecutive_chars() {
331 assert_eq!(
332 count_max_consecutive_chars("``a```b``", '`'),
333 3,
334 "the highest seen consecutive segment of backticks counts"
335 );
336 assert_eq!(
337 count_max_consecutive_chars("```a``b`", '`'),
338 3,
339 "it can't be downgraded later"
340 );
341 }
342
343 #[test]
344 fn test_split_local_url_fragment() {
345 assert_eq!(split_local_url_fragment("#heading"), ("", Some("heading")));
346 assert_eq!(
347 split_local_url_fragment("./file.md#heading"),
348 ("./file.md", Some("heading"))
349 );
350 assert_eq!(split_local_url_fragment("./file.md"), ("./file.md", None));
351 assert_eq!(
352 split_local_url_fragment("https://example.com#frag"),
353 ("https://example.com#frag", None)
354 );
355 assert_eq!(
356 split_local_url_fragment("mailto:user@example.com"),
357 ("mailto:user@example.com", None)
358 );
359 assert_eq!(split_local_url_fragment("#"), ("", None));
360 assert_eq!(
361 split_local_url_fragment("../other.md#section"),
362 ("../other.md", Some("section"))
363 );
364 assert_eq!(
365 split_local_url_fragment("123:not-a-scheme#frag"),
366 ("123:not-a-scheme", Some("frag"))
367 );
368 }
369
370 #[test]
371 fn test_source_position_from_fragment() {
372 assert_eq!(source_position_from_fragment("9,16"), Some((8, 15)));
373 assert_eq!(source_position_from_fragment("33,33"), Some((32, 32)));
374 assert_eq!(source_position_from_fragment("L42"), Some((41, 0)));
375 assert_eq!(source_position_from_fragment("L42:7"), Some((41, 6)));
376 assert_eq!(source_position_from_fragment("5"), Some((4, 0)));
377 assert_eq!(source_position_from_fragment("L10-L20"), Some((9, 0)));
378 assert_eq!(source_position_from_fragment("heading"), None);
379 assert_eq!(source_position_from_fragment("0,0"), None);
380 }
381
382 #[test]
383 fn test_generate_heading_slug() {
384 assert_eq!(generate_heading_slug("Hello World"), "hello-world");
385 assert_eq!(generate_heading_slug("Hello World"), "hello--world");
386 assert_eq!(generate_heading_slug("Hello-World"), "hello-world");
387 assert_eq!(
388 generate_heading_slug("Some **bold** text"),
389 "some-bold-text"
390 );
391 assert_eq!(generate_heading_slug("Let's try with Ü"), "lets-try-with-ü");
392 assert_eq!(
393 generate_heading_slug("heading with 123 numbers"),
394 "heading-with-123-numbers"
395 );
396 assert_eq!(
397 generate_heading_slug("What about (parens)?"),
398 "what-about-parens"
399 );
400 assert_eq!(
401 generate_heading_slug(" leading spaces "),
402 "leading-spaces"
403 );
404 }
405}
406