1use crate::sections::Fence;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Target<'a> {
12 Page {
14 slug: &'a str,
15 fragment: Option<&'a str>,
16 },
17 Node {
19 id: &'a str,
20 fragment: Option<&'a str>,
21 },
22 Object {
24 scheme: &'a str,
25 kind: &'a str,
26 id: &'a str,
27 },
28 Other(&'a str),
30}
31
32pub fn classify(target: &str) -> Target<'_> {
34 if let Some(path) = target.strip_prefix('/') {
35 if path.starts_with('/') || path.is_empty() {
36 return Target::Other(target);
37 }
38 let (slug, fragment) = match path.split_once('#') {
39 Some((s, f)) => (s, Some(f)),
40 None => (path, None),
41 };
42 return Target::Page {
43 slug: slug.trim_end_matches('/'),
44 fragment,
45 };
46 }
47 if let Some(node) = target.strip_prefix("hive:") {
48 let (id, fragment) = match node.split_once('#') {
49 Some((i, f)) => (i, Some(f)),
50 None => (node, None),
51 };
52 if id.starts_with("n_")
53 && id.len() > 2
54 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
55 {
56 return Target::Node { id, fragment };
57 }
58 }
59 if let Some((scheme, rest)) = target.split_once(':') {
60 let plain = |s: &str| {
61 !s.is_empty()
62 && s.bytes()
63 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
64 };
65 if let Some((kind, id)) = rest.split_once('/') {
66 if plain(scheme) && plain(kind) && plain(id) && !rest.starts_with("//") {
67 return Target::Object { scheme, kind, id };
68 }
69 }
70 }
71 Target::Other(target)
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum Edit {
77 Target(String),
79 TextOnly,
81}
82
83pub fn targets(body: &str) -> Vec<&str> {
85 let mut out = Vec::new();
86 scan(body, |target| {
87 out.push(target);
88 None
89 });
90 out
91}
92
93pub fn rewrite(body: &str, mut f: impl FnMut(&str) -> Option<String>) -> String {
95 scan(body, |t| f(t).map(Edit::Target))
96}
97
98pub fn to_storage(body: &str, id_of: impl Fn(&str) -> Option<String>) -> (String, Vec<String>) {
102 let mut dangling = Vec::new();
103 let out = scan(body, |t| {
104 let Target::Page { slug, fragment } = classify(t) else {
105 return None;
106 };
107 let id = id_of(slug);
108 if id.is_none() {
109 dangling.push(t.to_owned());
110 }
111 id.map(|id| Edit::Target(with_fragment(&format!("hive:{id}"), fragment)))
112 });
113 (out, dangling)
114}
115
116pub fn to_export(body: &str, from_slug: &str, slug_of: impl Fn(&str) -> Option<String>) -> String {
120 scan(body, |t| match classify(t) {
121 Target::Node { id, fragment } => Some(match slug_of(id) {
122 Some(slug) => Edit::Target(with_fragment(&relative_md(from_slug, &slug), fragment)),
123 None => Edit::TextOnly,
124 }),
125 _ => None,
126 })
127}
128
129pub fn to_slugs(body: &str, slug_of: impl Fn(&str) -> Option<String>) -> String {
131 scan(body, |t| match classify(t) {
132 Target::Node { id, fragment } => {
133 slug_of(id).map(|slug| Edit::Target(with_fragment(&format!("/{slug}"), fragment)))
134 }
135 _ => None,
136 })
137}
138
139fn with_fragment(base: &str, fragment: Option<&str>) -> String {
140 match fragment {
141 Some(f) => format!("{base}#{f}"),
142 None => base.to_owned(),
143 }
144}
145
146pub fn relative_md(from: &str, to: &str) -> String {
148 let from_dirs: Vec<&str> = from.split('/').collect();
149 let from_dirs = &from_dirs[..from_dirs.len().saturating_sub(1)];
150 let to_parts: Vec<&str> = to.split('/').collect();
151 let common = from_dirs
152 .iter()
153 .zip(&to_parts)
154 .take_while(|(a, b)| a == b)
155 .count();
156 let mut out: Vec<&str> = vec![".."; from_dirs.len() - common];
157 out.extend(&to_parts[common..]);
158 format!("{}.md", out.join("/"))
159}
160
161pub fn heading_slugs(markdown: &str) -> Vec<String> {
164 headings(markdown).into_iter().map(|h| h.slug).collect()
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct Heading {
170 pub start: usize,
172 pub level: usize,
174 pub text: String,
175 pub slug: String,
177}
178
179pub fn headings(markdown: &str) -> Vec<Heading> {
181 let mut fence = Fence::default();
182 let mut seen = std::collections::BTreeMap::<String, usize>::new();
183 let mut out = Vec::new();
184 let mut pos = 0;
185 for line in markdown.split_inclusive('\n') {
186 let start = pos;
187 pos += line.len();
188 if fence.feed(line) {
189 continue;
190 }
191 let hashes = line.bytes().take_while(|b| *b == b'#').count();
192 if !(1..=6).contains(&hashes) || line.as_bytes().get(hashes) != Some(&b' ') {
193 continue;
194 }
195 let text = line[hashes + 1..].trim().to_owned();
196 let base = heading_slug(&text);
197 let n = seen.entry(base.clone()).or_insert(0);
198 let slug = if *n == 0 { base } else { format!("{base}-{n}") };
199 *n += 1;
200 out.push(Heading {
201 start,
202 level: hashes,
203 text,
204 slug,
205 });
206 }
207 out
208}
209
210pub fn section_of<'a>(markdown: &'a str, want: &str) -> Option<&'a str> {
213 let hs = headings(markdown);
214 let i = hs.iter().position(|h| h.slug == want)?;
215 let end = hs[i + 1..]
216 .iter()
217 .find(|h| h.level <= hs[i].level)
218 .map_or(markdown.len(), |h| h.start);
219 Some(&markdown[hs[i].start..end])
220}
221
222pub fn heading_slug(heading: &str) -> String {
224 heading
225 .trim()
226 .to_lowercase()
227 .chars()
228 .filter_map(|c| match c {
229 ' ' => Some('-'),
230 'a'..='z' | '0'..='9' | '-' => Some(c),
231 _ => None,
232 })
233 .collect()
234}
235
236fn scan<'a>(body: &'a str, mut f: impl FnMut(&'a str) -> Option<Edit>) -> String {
239 let mut out = String::with_capacity(body.len());
240 let mut fence = Fence::default();
241 for line in body.split_inclusive('\n') {
242 if fence.feed(line) {
243 out.push_str(line);
244 } else {
245 scan_line(line, &mut out, &mut f);
246 }
247 }
248 out
249}
250
251fn target_end(b: &[u8], start: usize) -> Option<usize> {
254 let mut depth = 0usize;
255 for (j, c) in b[start..].iter().enumerate() {
256 match c {
257 b'(' => depth += 1,
258 b')' if depth == 0 => return Some(start + j),
259 b')' => depth -= 1,
260 b' ' | b'\n' => return None,
261 _ => {}
262 }
263 }
264 None
265}
266
267fn scan_line<'a>(line: &'a str, out: &mut String, f: &mut impl FnMut(&'a str) -> Option<Edit>) {
268 let b = line.as_bytes();
269 let mut i = 0;
270 let mut copied = 0;
271 let mut opens: Vec<usize> = Vec::new();
272 while i < b.len() {
273 if b[i] == b'[' {
274 opens.push(i);
275 }
276 if b[i] == b'`' {
277 let run = b[i..].iter().take_while(|c| **c == b'`').count();
279 let ticks = &line[i..i + run];
280 match line[i + run..].find(ticks) {
281 Some(end) => i += run + end + run,
282 None => i += run,
283 }
284 continue;
285 }
286 if b[i] == b']' && b.get(i + 1) == Some(&b'(') {
287 let start = i + 2;
288 let end = target_end(b, start);
289 let open = opens.pop();
290 if let Some(end) = end {
291 match f(&line[start..end]) {
292 Some(Edit::Target(new)) => {
293 out.push_str(&line[copied..start]);
294 out.push_str(&new);
295 copied = end;
296 }
297 Some(Edit::TextOnly) => match open {
299 Some(o) if o >= copied && (o == 0 || b[o - 1] != b'!') => {
300 out.push_str(&line[copied..o]);
301 out.push_str(&line[o + 1..i]);
302 copied = end + 1;
303 }
304 _ => {}
305 },
306 None => {}
307 }
308 i = end + 1;
309 continue;
310 }
311 }
312 i += 1;
313 }
314 out.push_str(&line[copied..]);
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn classifies_pages_objects_and_others() {
323 assert_eq!(
324 classify("/design/schema#tables"),
325 Target::Page {
326 slug: "design/schema",
327 fragment: Some("tables")
328 }
329 );
330 assert_eq!(
331 classify("/index"),
332 Target::Page {
333 slug: "index",
334 fragment: None
335 }
336 );
337 assert_eq!(
338 classify("emspace:tasks/01a1"),
339 Target::Object {
340 scheme: "emspace",
341 kind: "tasks",
342 id: "01a1"
343 }
344 );
345 assert_eq!(
346 classify("hive:n_01abc#retry-budget"),
347 Target::Node {
348 id: "n_01abc",
349 fragment: Some("retry-budget")
350 }
351 );
352 assert_eq!(classify("hive:x"), Target::Other("hive:x"));
353 assert_eq!(classify("https://x.y/z"), Target::Other("https://x.y/z"));
354 assert_eq!(classify("//cdn/x"), Target::Other("//cdn/x"));
355 }
356
357 #[test]
358 fn finds_targets_outside_code_only() {
359 let body = "see [A](/a) and `[B](/b)`\n```\n[C](/c)\n```\n[D](/d#x) [E](emspace:tasks/9)\n";
360 assert_eq!(targets(body), ["/a", "/d#x", "emspace:tasks/9"]);
361 }
362
363 #[test]
364 fn rewrites_selected_targets_losslessly() {
365 let body = "[A](/a) [U](https://u) [T](emspace:tasks/9)\n";
366 let out = rewrite(body, |t| match classify(t) {
367 Target::Object { id, .. } => Some(format!("hive:task/{id}")),
368 _ => None,
369 });
370 assert_eq!(out, "[A](/a) [U](https://u) [T](hive:task/9)\n");
371 assert_eq!(rewrite(body, |_| None), body);
372 }
373
374 fn pages() -> Vec<(&'static str, &'static str)> {
376 vec![
377 ("n_aaaaaaaaaaaaaaaaaaaaaadpga", "knowledge/wiki"),
378 ("n_aaaaaaaaaaaaaaaaaaaaaadpgb", "knowledge/knowledge-items"),
379 (
380 "n_aaaaaaaaaaaaaaaaaaaaaadpgc",
381 "architecture/place-event-store",
382 ),
383 ]
384 }
385
386 #[test]
387 fn write_vectors() {
388 let id_of = |s: &str| {
389 pages()
390 .into_iter()
391 .find(|p| p.1 == s)
392 .map(|p| p.0.to_owned())
393 };
394 let cases = [
395 (
396 "[types](/knowledge/knowledge-items#types-and-bodies)",
397 "[types](hive:n_aaaaaaaaaaaaaaaaaaaaaadpgb#types-and-bodies)",
398 vec![],
399 ),
400 (
401 "[log](/architecture/place-event-store)",
402 "[log](hive:n_aaaaaaaaaaaaaaaaaaaaaadpgc)",
403 vec![],
404 ),
405 (
406 "[x](/no/such-page)",
407 "[x](/no/such-page)",
408 vec!["/no/such-page".to_owned()],
409 ),
410 (
411 "[web](https://leanctx.com/)",
412 "[web](https://leanctx.com/)",
413 vec![],
414 ),
415 ];
416 for (input, out, dangling) in cases {
417 assert_eq!(
418 to_storage(input, id_of),
419 (out.to_owned(), dangling),
420 "{input}"
421 );
422 }
423 }
424
425 #[test]
426 fn export_vectors() {
427 let slug_of = |id: &str| {
428 pages()
429 .into_iter()
430 .find(|p| p.0 == id)
431 .map(|p| p.1.to_owned())
432 };
433 let cases = [
434 (
435 "[types](hive:n_aaaaaaaaaaaaaaaaaaaaaadpgb#types-and-bodies)",
436 "[types](knowledge-items.md#types-and-bodies)",
437 ),
438 (
439 "[log](hive:n_aaaaaaaaaaaaaaaaaaaaaadpgc)",
440 "[log](../architecture/place-event-store.md)",
441 ),
442 ("[gone](hive:n_aaaaaaaaaaaaaaaaaaaaaadpgd)", "gone"),
443 ];
444 for (input, out) in cases {
445 assert_eq!(to_export(input, "knowledge/wiki", slug_of), out, "{input}");
446 }
447 assert_eq!(
448 to_slugs(
449 "[t](hive:n_aaaaaaaaaaaaaaaaaaaaaadpgb#x) ",
450 slug_of
451 ),
452 "[t](/knowledge/knowledge-items#x) "
453 );
454 assert_eq!(
455 to_export(" and [g](hive:n_zz).", "a", slug_of),
456 " and g."
457 );
458 }
459
460 #[test]
461 fn heading_slug_vectors() {
462 let md = "# Wiki\n## What It Does\n## C0 2.2: Docs!\n## `base_event` (CAS)\n## Rules And Edge Cases\n\
463 ## Two Spaces\n## Über Pages\n```\n# not a heading\n```\n## Rules And Edge Cases\n## Rules And Edge Cases\n";
464 assert_eq!(
465 heading_slugs(md),
466 [
467 "wiki",
468 "what-it-does",
469 "c0-22-docs",
470 "baseevent-cas",
471 "rules-and-edge-cases",
472 "two--spaces",
473 "ber-pages",
474 "rules-and-edge-cases-1",
475 "rules-and-edge-cases-2",
476 ]
477 );
478 }
479
480 #[test]
481 fn balanced_parentheses_stay_in_the_target() {
482 assert_eq!(
483 targets("[w](https://e.org/a_(b))\n"),
484 ["https://e.org/a_(b)"]
485 );
486 }
487}