1use std::collections::HashMap;
6use std::fmt;
7use std::sync::LazyLock;
8
9use omgbase_format::hash::{hex, sha256};
10use omgbase_format::{AttrValue, BlockKind};
11use omgbase_properties::DocBlock;
12use regex::Regex;
13use serde_json::{Map as JsonMap, Value as Json};
14
15use crate::mask::mask_code_bytes;
16
17pub(crate) const JS_WS: &str = r"\t\n\x0B\x0C\r \x{A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}";
21
22pub(crate) const JS_LINE_TERMINATORS: [char; 4] = ['\n', '\r', '\u{2028}', '\u{2029}'];
24
25static LINK: LazyLock<Regex> = LazyLock::new(|| {
27 Regex::new(&format!(
28 r#"\[([^\]]*)\]\(([^){JS_WS}]+)(?:[{JS_WS}]+"[^"]*")?\)"#
29 ))
30 .expect("valid")
31});
32static WIKILINK: LazyLock<Regex> =
34 LazyLock::new(|| Regex::new(r"\[\[([^\]]+)\]\]").expect("valid"));
35static ANCHOR: LazyLock<Regex> =
37 LazyLock::new(|| Regex::new(r"\^([a-zA-Z0-9_-]+)").expect("valid"));
38static BRACKETED: LazyLock<Regex> = LazyLock::new(|| {
41 Regex::new(r"[\[(]([A-Za-z][A-Za-z0-9_]*)::[ \t]*([^\]\n)]*?)[ \t]*[\])]").expect("valid")
42});
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46pub enum NodeKind {
47 Link,
48 Wikilink,
49 Task,
50 Anchor,
51 InlineField,
52 Section,
53}
54
55impl NodeKind {
56 #[must_use]
58 pub const fn as_str(&self) -> &'static str {
59 match self {
60 NodeKind::Link => "md:link",
61 NodeKind::Wikilink => "md:wikilink",
62 NodeKind::Task => "md:task",
63 NodeKind::Anchor => "md:anchor",
64 NodeKind::InlineField => "md:inline_field",
65 NodeKind::Section => "md:section",
66 }
67 }
68
69 #[must_use]
71 pub fn parse(s: &str) -> Option<Self> {
72 match s {
73 "md:link" => Some(NodeKind::Link),
74 "md:wikilink" => Some(NodeKind::Wikilink),
75 "md:task" => Some(NodeKind::Task),
76 "md:anchor" => Some(NodeKind::Anchor),
77 "md:inline_field" => Some(NodeKind::InlineField),
78 "md:section" => Some(NodeKind::Section),
79 _ => None,
80 }
81 }
82}
83
84impl fmt::Display for NodeKind {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 f.write_str(self.as_str())
87 }
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct ProjectedNode {
93 pub kind: NodeKind,
94 pub block_id: String,
96 pub name: Option<String>,
97 pub value: Option<String>,
98 pub span: Option<(usize, usize)>,
101 pub attrs: JsonMap<String, Json>,
103}
104
105impl ProjectedNode {
106 fn new(kind: NodeKind, block_id: &str) -> Self {
107 Self {
108 kind,
109 block_id: block_id.to_owned(),
110 name: None,
111 value: None,
112 span: None,
113 attrs: JsonMap::new(),
114 }
115 }
116
117 #[must_use]
121 pub fn section(
122 heading_block: &str,
123 text: &str,
124 level: i64,
125 first_ordinal: i64,
126 last_ordinal: i64,
127 ) -> Self {
128 let mut attrs = JsonMap::new();
129 attrs.insert("level".to_owned(), Json::from(level));
130 attrs.insert("first_ordinal".to_owned(), Json::from(first_ordinal));
131 attrs.insert("last_ordinal".to_owned(), Json::from(last_ordinal));
132 Self {
133 kind: NodeKind::Section,
134 block_id: heading_block.to_owned(),
135 name: Some(text.to_owned()),
136 value: None,
137 span: None,
138 attrs,
139 }
140 }
141}
142
143pub(crate) fn line_field(line: &str) -> Option<(&str, &str, usize, usize)> {
149 let lead = line.len() - line.trim_start_matches([' ', '\t']).len();
150 let rest = &line[lead..];
151 let mut key_end = 0;
152 for (i, c) in rest.char_indices() {
153 let ok = if i == 0 {
154 c.is_ascii_alphabetic()
155 } else {
156 c.is_ascii_alphanumeric() || c == '_'
157 };
158 if !ok {
159 break;
160 }
161 key_end = i + c.len_utf8();
162 }
163 if key_end == 0 {
164 return None;
165 }
166 let key = &rest[..key_end];
167 let after = rest[key_end..].strip_prefix("::")?;
168 let value = after
169 .trim_start_matches([' ', '\t'])
170 .trim_end_matches([' ', '\t']);
171 let end = line.len() - line.trim_end_matches([' ', '\t']).len();
172 Some((key, value, lead, line.len() - end))
173}
174
175fn scan_block(b: &DocBlock<'_>, out: &mut Vec<ProjectedNode>) {
176 let id = b.block_id;
177 let scan = if b.kind == BlockKind::CodeFence {
179 String::new()
180 } else {
181 mask_code_bytes(b.raw)
182 };
183 for m in LINK.captures_iter(&scan) {
184 let whole = m.get(0).expect("match");
185 let mut n = ProjectedNode::new(NodeKind::Link, id);
186 n.name = Some(m[1].to_owned());
187 n.value = Some(m[2].to_owned());
188 n.span = Some((whole.start(), whole.end()));
189 out.push(n);
190 }
191 for m in WIKILINK.captures_iter(&scan) {
192 let whole = m.get(0).expect("match");
193 let mut n = ProjectedNode::new(NodeKind::Wikilink, id);
194 n.value = Some(m[1].to_owned());
195 n.span = Some((whole.start(), whole.end()));
196 out.push(n);
197 }
198 if b.kind == BlockKind::Task {
199 let checked = matches!(b.attrs.get("checked"), Some(AttrValue::Bool(true)));
200 let mut n = ProjectedNode::new(NodeKind::Task, id);
201 n.value = Some(b.text.to_owned());
202 n.span = Some((0, b.raw.len()));
203 n.attrs.insert("checked".to_owned(), Json::Bool(checked));
204 out.push(n);
205 }
206 for m in ANCHOR.captures_iter(&scan) {
207 let whole = m.get(0).expect("match");
208 let mut n = ProjectedNode::new(NodeKind::Anchor, id);
209 n.name = Some(m[1].to_owned());
210 n.span = Some((whole.start(), whole.end()));
211 out.push(n);
212 }
213 for m in BRACKETED.captures_iter(&scan) {
214 let whole = m.get(0).expect("match");
215 let mut n = ProjectedNode::new(NodeKind::InlineField, id);
216 n.name = Some(m[1].to_owned());
217 n.value = Some(m[2].to_owned());
218 n.span = Some((whole.start(), whole.end()));
219 out.push(n);
220 }
221 let mut line_start = 0;
222 for line in scan.split(JS_LINE_TERMINATORS) {
223 if let Some((key, value, start, end)) = line_field(line) {
224 let mut n = ProjectedNode::new(NodeKind::InlineField, id);
225 n.name = Some(key.to_owned());
226 n.value = Some(value.to_owned());
227 n.span = Some((line_start + start, line_start + end));
228 out.push(n);
229 }
230 let next = line_start + line.len();
233 line_start = match scan[next..].chars().next() {
234 Some(t) => next + t.len_utf8(),
235 None => next,
236 };
237 }
238 for child in &b.children {
239 scan_block(child, out);
240 }
241}
242
243#[must_use]
249pub fn project_nodes(blocks: &[DocBlock<'_>]) -> Vec<ProjectedNode> {
250 let mut out = Vec::new();
251 for b in blocks {
252 scan_block(b, &mut out);
253 }
254 out
255}
256
257#[must_use]
260pub fn node_id(doc_id: &str, block_id: &str, kind: &str, ordinal: u32) -> String {
261 let digest = sha256(format!("{doc_id}|{block_id}|{kind}|{ordinal}").as_bytes());
262 format!("n_{}", &hex(&digest)[..12])
263}
264
265#[derive(Clone, Debug, PartialEq, Eq)]
267pub struct NodeRow<'a> {
268 pub node_id: String,
269 pub ordinal: u32,
271 pub node: &'a ProjectedNode,
272}
273
274#[must_use]
277pub fn node_rows<'a>(doc_id: &str, nodes: &'a [ProjectedNode]) -> Vec<NodeRow<'a>> {
278 let mut counters: HashMap<(NodeKind, &str), u32> = HashMap::new();
279 nodes
280 .iter()
281 .map(|n| {
282 let ordinal = counters.entry((n.kind, n.block_id.as_str())).or_insert(0);
283 let this = *ordinal;
284 *ordinal += 1;
285 NodeRow {
286 node_id: node_id(doc_id, &n.block_id, n.kind.as_str(), this),
287 ordinal: this,
288 node: n,
289 }
290 })
291 .collect()
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use omgbase_format::parse_markdown;
298
299 fn nodes_of(source: &str) -> Vec<ProjectedNode> {
300 let tree = parse_markdown(source);
301 let ids: Vec<String> = (0..DocBlock::count(&tree.children))
302 .map(|i| format!("b_{i}"))
303 .collect();
304 let blocks = DocBlock::from_blocks(&tree.children, &ids);
305 project_nodes(&blocks)
306 }
307
308 type View<'a> = (
310 NodeKind,
311 &'a str,
312 Option<&'a str>,
313 Option<&'a str>,
314 Option<(usize, usize)>,
315 );
316
317 fn view(nodes: &[ProjectedNode]) -> Vec<View<'_>> {
318 nodes
319 .iter()
320 .map(|n| {
321 (
322 n.kind,
323 n.block_id.as_str(),
324 n.name.as_deref(),
325 n.value.as_deref(),
326 n.span,
327 )
328 })
329 .collect()
330 }
331
332 #[test]
333 fn node_id_is_the_sha256_prefix() {
334 let id = node_id("d_0", "b_1", "md:link", 0);
335 assert_eq!(id.len(), 14);
336 assert_eq!(
337 id,
338 format!("n_{}", &hex(&sha256(b"d_0|b_1|md:link|0"))[..12])
339 );
340 assert_ne!(id, node_id("d_0", "b_1", "md:link", 1));
341 }
342
343 #[test]
344 fn links_with_titles_images_and_spans() {
345 let n = nodes_of("See [t](x.md \"title\") and \n");
346 assert_eq!(
347 view(&n),
348 vec![
349 (
350 NodeKind::Link,
351 "b_0",
352 Some("t"),
353 Some("x.md"),
354 Some((4, 21))
355 ),
356 (
357 NodeKind::Link,
358 "b_0",
359 Some("alt"),
360 Some("img.png"),
361 Some((27, 41))
362 ),
363 ]
364 );
365 assert!(
366 nodes_of("[t](a b)\n").is_empty(),
367 "no space in a destination"
368 );
369 assert!(nodes_of("[t]()\n").is_empty());
370 let n = nodes_of("[](x)\n");
371 assert_eq!(n[0].name.as_deref(), Some(""));
372 }
373
374 #[test]
375 fn spans_are_bytes_into_raw() {
376 let n = nodes_of("héllo [t](x) `é` [[w]]\n");
377 let raw = "héllo [t](x) `é` [[w]]";
378 assert_eq!(
379 n[0].span,
380 Some((raw.find("[t]").unwrap(), raw.find("[t]").unwrap() + 6))
381 );
382 assert_eq!(n[1].kind, NodeKind::Wikilink);
383 assert_eq!(n[1].span, Some((raw.find("[[").unwrap(), raw.len())));
384 let n = nodes_of("- [x] dö it\n");
385 let task = n.iter().find(|n| n.kind == NodeKind::Task).unwrap();
386 assert_eq!(task.span, Some((0, "- [x] dö it".len())));
387 }
388
389 #[test]
390 fn wikilinks_keep_aliases_and_fragments() {
391 let n = nodes_of("[[note|Alias]] [[a#H]] [[b^r]]\n");
392 let values: Vec<&str> = n
393 .iter()
394 .filter(|n| n.kind == NodeKind::Wikilink)
395 .map(|n| n.value.as_deref().unwrap())
396 .collect();
397 assert_eq!(values, ["note|Alias", "a#H", "b^r"]);
398 let anchors: Vec<&str> = n
400 .iter()
401 .filter(|n| n.kind == NodeKind::Anchor)
402 .map(|n| n.name.as_deref().unwrap())
403 .collect();
404 assert_eq!(anchors, ["r"]);
405 }
406
407 #[test]
408 fn tasks_project_once_per_nesting_level_of_the_task_only() {
409 let n = nodes_of("- [ ] open\n- [x] done\n");
410 let tasks: Vec<_> = n
411 .iter()
412 .filter(|n| n.kind == NodeKind::Task)
413 .map(|n| {
414 (
415 n.block_id.as_str(),
416 n.value.as_deref().unwrap(),
417 n.attrs["checked"].as_bool().unwrap(),
418 )
419 })
420 .collect();
421 assert_eq!(tasks, [("b_1", "open", false), ("b_2", "done", true)]);
422 assert_eq!(n[0].kind, NodeKind::Task, "the list itself is not a task");
423 }
424
425 #[test]
426 fn anchors() {
427 let n = nodes_of("Para ^ref-1 and ^x_y and ^ (none)\n");
428 assert_eq!(
429 view(&n),
430 vec![
431 (NodeKind::Anchor, "b_0", Some("ref-1"), None, Some((5, 11))),
432 (NodeKind::Anchor, "b_0", Some("x_y"), None, Some((16, 20))),
433 ]
434 );
435 }
436
437 #[test]
438 fn inline_fields_bracketed_then_line_form_with_spans() {
439 let n = nodes_of("key:: two words \nSee [k2:: v] (k3::\tw )\n");
440 let fields: Vec<_> = n
441 .iter()
442 .filter(|n| n.kind == NodeKind::InlineField)
443 .map(|n| {
444 (
445 n.name.as_deref().unwrap(),
446 n.value.as_deref().unwrap(),
447 n.span.unwrap(),
448 )
449 })
450 .collect();
451 let raw = "key:: two words \nSee [k2:: v] (k3::\tw )";
452 assert_eq!(
453 fields,
454 [
455 (
456 "k2",
457 "v",
458 (raw.find("[k2").unwrap(), raw.find("[k2").unwrap() + 8)
459 ),
460 ("k3", "w", (raw.find("(k3").unwrap(), raw.len())),
461 ("key", "two words", (0, 15)),
462 ]
463 );
464 assert_eq!(line_field(" \tkey:: v \t"), Some(("key", "v", 3, 11)));
466 assert_eq!(line_field("k::"), Some(("k", "", 0, 3)));
467 assert_eq!(line_field("- k:: v"), None);
468 assert_eq!(line_field("k: v"), None);
469 let n = nodes_of("first\n key:: v\n");
470 assert_eq!(n[0].span, Some((8, 15)));
471 assert!(
473 nodes_of("- k:: v\n")
474 .iter()
475 .all(|n| n.kind != NodeKind::InlineField)
476 );
477 assert_eq!(
479 nodes_of("[k:: v]\n")
480 .iter()
481 .filter(|n| n.kind == NodeKind::InlineField)
482 .count(),
483 1
484 );
485 let n = nodes_of("a:: 1\r\nb:: 2\u{2028}c:: 3\n");
487 let spans: Vec<_> = n
488 .iter()
489 .map(|n| (n.name.as_deref().unwrap(), n.span.unwrap()))
490 .collect();
491 assert_eq!(spans, [("a", (0, 5)), ("b", (7, 12)), ("c", (15, 20))]);
492 let n = nodes_of("Key_1:: v \n");
494 assert_eq!(
495 (n[0].name.as_deref(), n[0].value.as_deref(), n[0].span),
496 (Some("Key_1"), Some("v"), Some((0, 11)))
497 );
498 assert_eq!(nodes_of("k::\n")[0].value.as_deref(), Some(""));
499 }
500
501 #[test]
502 fn code_is_not_prose() {
503 assert!(nodes_of("```\n[t](x) [[w]] ^a k:: v\n```\n").is_empty());
504 assert!(nodes_of("see `[t](x)` and `[[w]]`\n").is_empty());
505 let n = nodes_of("`x` [t](y)\n");
506 assert_eq!(n[0].span, Some((4, 10)));
507 }
508
509 #[test]
510 fn containers_project_once_per_level() {
511 let n = nodes_of("- see [t](x)\n");
512 assert_eq!(
513 view(&n),
514 vec![
515 (NodeKind::Link, "b_0", Some("t"), Some("x"), Some((6, 12))),
516 (NodeKind::Link, "b_1", Some("t"), Some("x"), Some((6, 12))),
517 ]
518 );
519 let n = nodes_of("> [[w]]\n");
520 assert_eq!(n[0].block_id, "b_0");
521 assert_eq!(n[0].span, Some((2, 7)));
522 assert_eq!(n[1].block_id, "b_1");
523 assert_eq!(n[1].span, Some((0, 5)));
524 }
525
526 #[test]
527 fn ordinals_count_per_kind_and_block() {
528 let n = nodes_of("[a](x) [b](y) [[w]]\n\n[c](z)\n");
529 let rows = node_rows("d_0", &n);
530 let view: Vec<(NodeKind, &str, u32)> = rows
531 .iter()
532 .map(|r| (r.node.kind, r.node.block_id.as_str(), r.ordinal))
533 .collect();
534 assert_eq!(
535 view,
536 [
537 (NodeKind::Link, "b_0", 0),
538 (NodeKind::Link, "b_0", 1),
539 (NodeKind::Wikilink, "b_0", 0),
540 (NodeKind::Link, "b_1", 0),
541 ]
542 );
543 assert_eq!(rows[0].node_id, node_id("d_0", "b_0", "md:link", 0));
544 assert_eq!(rows[1].node_id, node_id("d_0", "b_0", "md:link", 1));
545 let mut ids: Vec<&str> = rows.iter().map(|r| r.node_id.as_str()).collect();
546 ids.sort_unstable();
547 ids.dedup();
548 assert_eq!(ids.len(), 4);
549 }
550
551 #[test]
552 fn section_shape() {
553 let s = ProjectedNode::section("b_0", "Title", 1, 0, 3);
554 assert_eq!(s.kind, NodeKind::Section);
555 assert_eq!(s.name.as_deref(), Some("Title"));
556 assert_eq!(s.value, None);
557 assert_eq!(s.span, None);
558 assert_eq!(
559 Json::Object(s.attrs.clone()),
560 serde_json::json!({"level": 1, "first_ordinal": 0, "last_ordinal": 3})
561 );
562 assert_eq!(NodeKind::parse("md:section"), Some(NodeKind::Section));
563 assert_eq!(NodeKind::parse("md:x"), None);
564 for k in [
565 NodeKind::Link,
566 NodeKind::Wikilink,
567 NodeKind::Task,
568 NodeKind::Anchor,
569 NodeKind::InlineField,
570 NodeKind::Section,
571 ] {
572 assert_eq!(NodeKind::parse(k.as_str()), Some(k));
573 }
574 }
575}