Skip to main content

rdx_github/
lib.rs

1use rdx_ast::*;
2use rdx_transform::Transform;
3
4/// Converts GitHub-style references in text to link nodes.
5///
6/// Supported patterns:
7/// - `#123` → link to issue/PR
8/// - `@username` → link to user profile
9/// - 7+ hex chars → link to commit
10///
11/// # Configuration
12///
13/// Set `repo` directly, or add `github: owner/repo` to your document's frontmatter.
14///
15/// # Example
16///
17/// ```rust
18/// use rdx_transform::Pipeline;
19/// use rdx_github::GithubReferences;
20///
21/// let root = Pipeline::new()
22///     .add(GithubReferences::new("rdx-lang/rdx"))
23///     .run("Fixed #42 by @octocat.\n");
24/// ```
25pub struct GithubReferences {
26    pub repo: String,
27    pub base_url: String,
28}
29
30impl Default for GithubReferences {
31    fn default() -> Self {
32        GithubReferences {
33            repo: String::new(),
34            base_url: "https://github.com".to_string(),
35        }
36    }
37}
38
39impl GithubReferences {
40    pub fn new(repo: &str) -> Self {
41        GithubReferences {
42            repo: repo.to_string(),
43            base_url: "https://github.com".to_string(),
44        }
45    }
46
47    pub fn with_base_url(mut self, url: &str) -> Self {
48        self.base_url = url.to_string();
49        self
50    }
51}
52
53impl Transform for GithubReferences {
54    fn name(&self) -> &str {
55        "github-references"
56    }
57
58    fn transform(&self, root: &mut Root, _source: &str) {
59        let repo = if self.repo.is_empty() {
60            // Try frontmatter
61            root.frontmatter
62                .as_ref()
63                .and_then(|fm| fm.get("github"))
64                .and_then(|v| v.as_str())
65                .map(|s| s.to_string())
66        } else {
67            Some(self.repo.clone())
68        };
69
70        let Some(repo) = repo else { return };
71        let cfg = ResolvedConfig {
72            repo,
73            base_url: &self.base_url,
74        };
75        transform_nodes(&mut root.children, &cfg);
76    }
77}
78
79struct ResolvedConfig<'a> {
80    repo: String,
81    base_url: &'a str,
82}
83
84fn transform_nodes(nodes: &mut Vec<Node>, cfg: &ResolvedConfig) {
85    let mut i = 0;
86    while i < nodes.len() {
87        // Skip Link/Image children to avoid creating nested links
88        if matches!(&nodes[i], Node::Link(_) | Node::Image(_)) {
89            i += 1;
90            continue;
91        }
92
93        // Recurse into children of non-text nodes
94        if !matches!(&nodes[i], Node::Text(_)) {
95            if let Some(children) = nodes[i].children_mut() {
96                transform_nodes(children, cfg);
97            }
98            i += 1;
99            continue;
100        }
101
102        // Extract text node, try to expand
103        let Node::Text(ref text_node) = nodes[i] else {
104            i += 1;
105            continue;
106        };
107        let refs = find_references(&text_node.value);
108        if refs.is_empty() {
109            i += 1;
110            continue;
111        }
112
113        let old = nodes.remove(i);
114        let text_node = match old {
115            Node::Text(t) => t,
116            _ => unreachable!(),
117        };
118        let expanded = expand_text(text_node, cfg);
119        let count = expanded.len();
120        for (j, node) in expanded.into_iter().enumerate() {
121            nodes.insert(i + j, node);
122        }
123        i += count;
124    }
125}
126
127struct Reference {
128    kind: RefKind,
129    start: usize,
130    end: usize,
131    value: String,
132}
133
134enum RefKind {
135    Issue,
136    User,
137    Commit,
138}
139
140fn find_references(text: &str) -> Vec<Reference> {
141    let mut refs = Vec::new();
142    let bytes = text.as_bytes();
143    let mut i = 0;
144
145    while i < bytes.len() {
146        if bytes[i] == b'#' {
147            let start = i;
148            i += 1;
149            let num_start = i;
150            while i < bytes.len() && bytes[i].is_ascii_digit() {
151                i += 1;
152            }
153            if i > num_start && (start == 0 || !bytes[start - 1].is_ascii_alphanumeric()) {
154                refs.push(Reference {
155                    kind: RefKind::Issue,
156                    start,
157                    end: i,
158                    value: text[num_start..i].to_string(),
159                });
160                continue;
161            }
162        } else if bytes[i] == b'@' {
163            let start = i;
164            i += 1;
165            let name_start = i;
166            while i < bytes.len()
167                && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
168            {
169                i += 1;
170            }
171            if i > name_start && (start == 0 || bytes[start - 1].is_ascii_whitespace()) {
172                refs.push(Reference {
173                    kind: RefKind::User,
174                    start,
175                    end: i,
176                    value: text[name_start..i].to_string(),
177                });
178                continue;
179            }
180        } else if bytes[i].is_ascii_hexdigit() && (i == 0 || !bytes[i - 1].is_ascii_alphanumeric())
181        {
182            let start = i;
183            while i < bytes.len() && bytes[i].is_ascii_hexdigit() {
184                i += 1;
185            }
186            let len = i - start;
187            if (7..=40).contains(&len) && (i >= bytes.len() || !bytes[i].is_ascii_alphanumeric()) {
188                let has_letter = text[start..i].bytes().any(|b| b.is_ascii_alphabetic());
189                if has_letter {
190                    refs.push(Reference {
191                        kind: RefKind::Commit,
192                        start,
193                        end: i,
194                        value: text[start..i].to_string(),
195                    });
196                    continue;
197                }
198            }
199        }
200        i += 1;
201    }
202    refs
203}
204
205/// Build a position for a sub-span of a text node, offsetting from the
206/// original start position. Columns are approximate (assumes single-line text).
207fn sub_position(base: &Position, byte_start: usize, byte_end: usize) -> Position {
208    Position {
209        start: Point {
210            line: base.start.line,
211            column: base.start.column + byte_start,
212            offset: base.start.offset + byte_start,
213        },
214        end: Point {
215            line: base.start.line,
216            column: base.start.column + byte_end,
217            offset: base.start.offset + byte_end,
218        },
219    }
220}
221
222fn expand_text(text_node: TextNode, cfg: &ResolvedConfig) -> Vec<Node> {
223    let refs = find_references(&text_node.value);
224    if refs.is_empty() {
225        return vec![Node::Text(text_node)];
226    }
227
228    let mut result = Vec::new();
229    let mut last_end = 0;
230    let base = &text_node.position;
231
232    for r in &refs {
233        if r.start > last_end {
234            result.push(Node::Text(TextNode {
235                value: text_node.value[last_end..r.start].to_string(),
236                position: sub_position(base, last_end, r.start),
237            }));
238        }
239
240        let (url, display) = match r.kind {
241            RefKind::Issue => (
242                format!("{}/{}/issues/{}", cfg.base_url, cfg.repo, r.value),
243                format!("#{}", r.value),
244            ),
245            RefKind::User => (
246                format!("{}/{}", cfg.base_url, r.value),
247                format!("@{}", r.value),
248            ),
249            RefKind::Commit => (
250                format!("{}/{}/commit/{}", cfg.base_url, cfg.repo, r.value),
251                r.value[..7.min(r.value.len())].to_string(),
252            ),
253        };
254
255        let ref_pos = sub_position(base, r.start, r.end);
256        result.push(Node::Link(LinkNode {
257            url,
258            title: None,
259            children: vec![Node::Text(TextNode {
260                value: display,
261                position: ref_pos.clone(),
262            })],
263            position: ref_pos,
264        }));
265
266        last_end = r.end;
267    }
268
269    if last_end < text_node.value.len() {
270        result.push(Node::Text(TextNode {
271            value: text_node.value[last_end..].to_string(),
272            position: sub_position(base, last_end, text_node.value.len()),
273        }));
274    }
275
276    result
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use rdx_transform::Pipeline;
283
284    #[test]
285    fn issue_reference() {
286        let root = Pipeline::new()
287            .add(GithubReferences::new("rdx-lang/rdx"))
288            .run("See #42 for details.\n");
289
290        match &root.children[0] {
291            Node::Paragraph(p) => {
292                let has_link = p
293                    .children
294                    .iter()
295                    .any(|n| matches!(n, Node::Link(l) if l.url.contains("/issues/42")));
296                assert!(has_link, "Should have issue link: {:?}", p.children);
297            }
298            other => panic!("Expected paragraph, got {:?}", other),
299        }
300    }
301
302    #[test]
303    fn user_reference() {
304        let root = Pipeline::new()
305            .add(GithubReferences::new("rdx-lang/rdx"))
306            .run("Thanks @octocat for the fix.\n");
307
308        match &root.children[0] {
309            Node::Paragraph(p) => {
310                let has_link = p
311                    .children
312                    .iter()
313                    .any(|n| matches!(n, Node::Link(l) if l.url.contains("/octocat")));
314                assert!(has_link, "Should have user link: {:?}", p.children);
315            }
316            other => panic!("Expected paragraph, got {:?}", other),
317        }
318    }
319
320    #[test]
321    fn commit_reference() {
322        let root = Pipeline::new()
323            .add(GithubReferences::new("rdx-lang/rdx"))
324            .run("Fixed in abc1234def.\n");
325
326        match &root.children[0] {
327            Node::Paragraph(p) => {
328                let has_link = p
329                    .children
330                    .iter()
331                    .any(|n| matches!(n, Node::Link(l) if l.url.contains("/commit/")));
332                assert!(has_link, "Should have commit link: {:?}", p.children);
333            }
334            other => panic!("Expected paragraph, got {:?}", other),
335        }
336    }
337
338    #[test]
339    fn no_transform_without_repo() {
340        let root = Pipeline::new()
341            .add(GithubReferences::default())
342            .run("See #42.\n");
343
344        match &root.children[0] {
345            Node::Paragraph(p) => {
346                let has_link = p.children.iter().any(|n| matches!(n, Node::Link(_)));
347                assert!(!has_link, "Should not transform without repo");
348            }
349            other => panic!("Expected paragraph, got {:?}", other),
350        }
351    }
352
353    #[test]
354    fn no_nested_links() {
355        // A markdown link whose text contains #123 should NOT become link-inside-link
356        let root = Pipeline::new()
357            .add(GithubReferences::new("rdx-lang/rdx"))
358            .run("See [issue #123](https://example.com) for details.\n");
359
360        match &root.children[0] {
361            Node::Paragraph(p) => {
362                for node in &p.children {
363                    if let Node::Link(l) = node {
364                        // No child of a link should itself be a link
365                        let has_nested = l.children.iter().any(|c| matches!(c, Node::Link(_)));
366                        assert!(
367                            !has_nested,
368                            "Should not create nested links: {:?}",
369                            l.children
370                        );
371                    }
372                }
373            }
374            other => panic!("Expected paragraph, got {:?}", other),
375        }
376    }
377
378    #[test]
379    fn repo_from_frontmatter() {
380        let root = Pipeline::new()
381            .add(GithubReferences::default())
382            .run("---\ngithub: rdx-lang/rdx\n---\nSee #42.\n");
383
384        let has_link = root.children.iter().any(|n| {
385            if let Node::Paragraph(p) = n {
386                p.children
387                    .iter()
388                    .any(|c| matches!(c, Node::Link(l) if l.url.contains("/issues/42")))
389            } else {
390                false
391            }
392        });
393        assert!(
394            has_link,
395            "Should transform with repo from frontmatter: {:?}",
396            root.children
397        );
398    }
399}