1use rdx_ast::*;
2use rdx_transform::Transform;
3
4pub 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 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 if matches!(&nodes[i], Node::Link(_) | Node::Image(_)) {
89 i += 1;
90 continue;
91 }
92
93 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 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
205fn 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 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 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}