1use tree_sitter::Language;
2
3pub struct ManifestSpec {
47 pub filename: &'static str,
49 pub name_key: &'static str,
52 pub self_names: &'static [&'static str],
54 pub normalize: fn(&str) -> String,
57}
58
59#[derive(Debug, Clone)]
62pub struct ModuleRoot {
63 pub name: String,
64 pub dir: String,
66 pub language: &'static str,
67}
68
69pub fn manifest_root(rel_path: &str, content: &str) -> Option<ModuleRoot> {
72 let base = rel_path.rsplit('/').next()?;
73 let spec = LANGUAGES
74 .iter()
75 .find(|l| l.manifest.is_some_and(|m| m.filename == base))?;
76 let m = spec.manifest?;
77 let name = content.lines().find_map(|line| {
78 let rest = line.trim().strip_prefix(m.name_key)?;
79 let rest = rest.trim_start();
80 let rest = rest.strip_prefix('=').unwrap_or(rest).trim();
81 let name = rest.trim_matches('"').trim();
82 (!name.is_empty() && !name.contains(' ')).then(|| name.to_string())
83 })?;
84 let dir = rel_path
85 .rsplit_once('/')
86 .map(|(d, _)| d.to_string())
87 .unwrap_or_default();
88 Some(ModuleRoot {
89 name: (m.normalize)(&name),
90 dir,
91 language: spec.name,
92 })
93}
94
95pub struct InlineSpec {
101 pub grammar: fn() -> Language,
102 pub query_source: &'static str,
103 pub container_kinds: &'static [&'static str],
106}
107
108pub struct LanguageSpec {
109 pub name: &'static str,
110 pub extensions: &'static [&'static str],
111 pub grammar: fn() -> Language,
112 pub query_source: &'static str,
113 pub comment_kinds: &'static [&'static str],
114 pub module_path: fn(&str) -> Vec<String>,
118 pub path_separators: &'static [&'static str],
120 pub absolutize: fn(path: &str, file: &str) -> Vec<String>,
124 pub receivers: &'static [&'static str],
127 pub doc_skip_kinds: &'static [&'static str],
131 pub manifest: Option<&'static ManifestSpec>,
134 pub inline: Option<&'static InlineSpec>,
138 pub file_refs: bool,
145 pub implicit_interfaces: bool,
150}
151
152fn rust_normalize(name: &str) -> String {
153 name.replace('-', "_")
154}
155
156static RUST_MANIFEST: ManifestSpec = ManifestSpec {
157 filename: "Cargo.toml",
158 name_key: "name",
159 self_names: &["crate"],
160 normalize: rust_normalize,
161};
162
163fn identity_normalize(name: &str) -> String {
164 name.to_string()
165}
166
167static GO_MANIFEST: ManifestSpec = ManifestSpec {
171 filename: "go.mod",
172 name_key: "module",
173 self_names: &[],
174 normalize: identity_normalize,
175};
176
177fn split_all(path: &str, separators: &[&str]) -> Vec<String> {
178 let mut segments = vec![path.to_string()];
179 for sep in separators {
180 segments = segments
181 .iter()
182 .flat_map(|s| s.split(sep).map(str::to_string))
183 .collect();
184 }
185 segments.into_iter().filter(|s| !s.is_empty()).collect()
186}
187
188fn dirname_segments(file: &str) -> Vec<String> {
189 let mut segments: Vec<String> = file.split('/').map(str::to_string).collect();
190 segments.pop();
191 segments
192}
193
194fn rust_absolutize(path: &str, file: &str) -> Vec<String> {
197 let mut module = rust_module_path(file);
198 let mut rest = path;
199 if let Some(r) = rest.strip_prefix("self::") {
200 rest = r;
201 } else {
202 while let Some(r) = rest.strip_prefix("super::") {
203 module.pop();
204 rest = r;
205 }
206 if rest.len() == path.len() {
207 if path.starts_with("crate::") {
208 return split_all(path, &["::", "."]);
209 }
210 module.extend(split_all(path, &["::", "."]));
213 return module;
214 }
215 }
216 module.extend(split_all(rest, &["::", "."]));
217 module
218}
219
220fn go_absolutize(path: &str, _file: &str) -> Vec<String> {
221 split_all(path, &["/", "."])
222}
223
224fn proto_absolutize(path: &str, _file: &str) -> Vec<String> {
229 split_all(path.strip_suffix(".proto").unwrap_or(path), &["/", "."])
230}
231
232fn python_absolutize(path: &str, file: &str) -> Vec<String> {
235 let dots = path.len() - path.trim_start_matches('.').len();
236 if dots == 0 {
237 return split_all(path, &["."]);
238 }
239 let mut base = dirname_segments(file);
240 for _ in 1..dots {
241 base.pop();
242 }
243 base.extend(split_all(&path[dots..], &["."]));
244 base
245}
246
247fn typescript_absolutize(path: &str, file: &str) -> Vec<String> {
249 if !path.starts_with('.') {
250 return split_all(path, &["/", "."]);
251 }
252 let mut base = dirname_segments(file);
253 let mut rest = path;
254 if let Some(r) = rest.strip_prefix('/') {
257 base.clear();
258 rest = r;
259 }
260 loop {
261 if let Some(r) = rest.strip_prefix("./") {
262 rest = r;
263 } else if let Some(r) = rest.strip_prefix("../") {
264 base.pop();
265 rest = r;
266 } else {
267 break;
268 }
269 }
270 base.extend(split_all(rest, &["/"]));
271 base
272}
273
274fn rust_grammar() -> Language {
275 tree_sitter_rust::LANGUAGE.into()
276}
277
278fn go_grammar() -> Language {
279 tree_sitter_go::LANGUAGE.into()
280}
281
282fn rust_module_path(file: &str) -> Vec<String> {
286 let trimmed = file.strip_suffix(".rs").unwrap_or(file);
287 let after_src = trimmed.rsplit_once("src/").map_or(trimmed, |(_, r)| r);
288 let mut segments = vec!["crate".to_string()];
289 for seg in after_src.split('/') {
290 if !matches!(seg, "lib" | "main" | "mod" | "") {
291 segments.push(seg.to_string());
292 }
293 }
294 segments
295}
296
297fn go_module_path(file: &str) -> Vec<String> {
299 let mut segments: Vec<String> = file.split('/').map(str::to_string).collect();
300 segments.pop(); segments
302}
303
304fn python_grammar() -> Language {
305 tree_sitter_python::LANGUAGE.into()
306}
307
308fn bash_grammar() -> Language {
309 tree_sitter_bash::LANGUAGE.into()
310}
311
312fn bash_module_path(file: &str) -> Vec<String> {
315 let trimmed = file
316 .strip_suffix(".sh")
317 .or_else(|| file.strip_suffix(".bash"))
318 .unwrap_or(file);
319 trimmed
320 .split('/')
321 .filter(|s| !s.is_empty())
322 .map(str::to_string)
323 .collect()
324}
325
326fn bash_absolutize(path: &str, file: &str) -> Vec<String> {
329 let trimmed = path.trim();
330 let dir_relative = [
331 "$(dirname \"$0\")/",
332 "$(dirname $0)/",
333 "${BASH_SOURCE%/*}/",
334 "./",
335 ]
336 .iter()
337 .find_map(|p| trimmed.strip_prefix(p));
338 let stripped = |s: &str| {
339 s.strip_suffix(".sh")
340 .or_else(|| s.strip_suffix(".bash"))
341 .unwrap_or(s)
342 .to_string()
343 };
344 match dir_relative {
345 Some(rest) => {
346 let mut base = dirname_segments(file);
347 base.extend(rest.split('/').filter(|s| !s.is_empty()).map(stripped));
348 base
349 }
350 None => trimmed
351 .split('/')
352 .filter(|s| !s.is_empty() && *s != ".")
353 .map(stripped)
354 .collect(),
355 }
356}
357
358fn cpp_grammar() -> Language {
359 tree_sitter_cpp::LANGUAGE.into()
360}
361
362fn cpp_module_path(file: &str) -> Vec<String> {
365 let trimmed = file.rsplit_once('.').map_or(file, |(stem, _)| stem);
366 trimmed
367 .split('/')
368 .filter(|s| !s.is_empty())
369 .map(str::to_string)
370 .collect()
371}
372
373fn cpp_absolutize(path: &str, file: &str) -> Vec<String> {
377 let trimmed = path.trim().trim_matches(['<', '>']);
378 let no_ext = trimmed.rsplit_once('.').map_or(trimmed, |(stem, ext)| {
379 if matches!(
380 ext,
381 "h" | "hh" | "hpp" | "hxx" | "cpp" | "cc" | "cxx" | "inl"
382 ) {
383 stem
384 } else {
385 trimmed
386 }
387 });
388 if let Some(rest) = no_ext.strip_prefix("./") {
389 let mut base = dirname_segments(file);
390 base.extend(
391 rest.split('/')
392 .filter(|s| !s.is_empty())
393 .map(str::to_string),
394 );
395 return base;
396 }
397 no_ext
400 .replace("->", ".")
401 .split(['/', ':', '.'])
402 .filter(|s| !s.is_empty())
403 .map(str::to_string)
404 .collect()
405}
406
407fn proto_grammar() -> Language {
408 tree_sitter_proto::LANGUAGE.into()
409}
410
411fn proto_module_path(file: &str) -> Vec<String> {
415 let trimmed = file.strip_suffix(".proto").unwrap_or(file);
416 trimmed
417 .split('/')
418 .filter(|s| !s.is_empty())
419 .map(str::to_string)
420 .collect()
421}
422
423fn typescript_grammar() -> Language {
424 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
425}
426
427fn python_module_path(file: &str) -> Vec<String> {
429 let trimmed = file.strip_suffix(".py").unwrap_or(file);
430 trimmed
431 .split('/')
432 .filter(|s| !matches!(*s, "__init__" | ""))
433 .map(str::to_string)
434 .collect()
435}
436
437fn typescript_module_path(file: &str) -> Vec<String> {
439 let trimmed = file
440 .strip_suffix(".tsx")
441 .or_else(|| file.strip_suffix(".ts"))
442 .unwrap_or(file);
443 trimmed
444 .split('/')
445 .filter(|s| !matches!(*s, "index" | ""))
446 .map(str::to_string)
447 .collect()
448}
449
450fn javascript_grammar() -> Language {
451 tree_sitter_javascript::LANGUAGE.into()
452}
453
454fn c_grammar() -> Language {
455 tree_sitter_c::LANGUAGE.into()
456}
457
458fn java_grammar() -> Language {
459 tree_sitter_java::LANGUAGE.into()
460}
461
462fn csharp_grammar() -> Language {
463 tree_sitter_c_sharp::LANGUAGE.into()
464}
465
466fn csharp_module_path(file: &str) -> Vec<String> {
473 let mut segments: Vec<String> = file.split('/').map(str::to_string).collect();
474 segments.pop(); segments
476}
477
478fn markdown_grammar() -> Language {
479 tree_sitter_md::LANGUAGE.into()
480}
481
482fn markdown_inline_grammar() -> Language {
483 tree_sitter_md::INLINE_LANGUAGE.into()
484}
485
486static MARKDOWN_INLINE: InlineSpec = InlineSpec {
490 grammar: markdown_inline_grammar,
491 query_source: include_str!("../queries/markdown-inline.scm"),
492 container_kinds: &["inline"],
493};
494
495fn markdown_absolutize(path: &str, file: &str) -> Vec<String> {
499 let mut base = dirname_segments(file);
500 let mut rest = path;
501 if let Some(r) = rest.strip_prefix('/') {
504 base.clear();
505 rest = r;
506 }
507 loop {
508 if let Some(r) = rest.strip_prefix("./") {
509 rest = r;
510 } else if let Some(r) = rest.strip_prefix("../") {
511 base.pop();
512 rest = r;
513 } else {
514 break;
515 }
516 }
517 let rest = rest
518 .strip_suffix(".md")
519 .or_else(|| rest.strip_suffix(".markdown"))
520 .unwrap_or(rest);
521 base.extend(
522 rest.split('/')
523 .filter(|s| !s.is_empty())
524 .map(str::to_string),
525 );
526 base
527}
528
529fn markdown_module_path(file: &str) -> Vec<String> {
531 let trimmed = file
532 .strip_suffix(".md")
533 .or_else(|| file.strip_suffix(".markdown"))
534 .unwrap_or(file);
535 trimmed
536 .split('/')
537 .filter(|s| !s.is_empty())
538 .map(str::to_string)
539 .collect()
540}
541
542fn sql_grammar() -> Language {
543 tree_sitter_sequel::LANGUAGE.into()
544}
545
546fn javascript_module_path(file: &str) -> Vec<String> {
547 let trimmed = file
548 .strip_suffix(".jsx")
549 .or_else(|| file.strip_suffix(".mjs"))
550 .or_else(|| file.strip_suffix(".cjs"))
551 .or_else(|| file.strip_suffix(".js"))
552 .unwrap_or(file);
553 trimmed
554 .split('/')
555 .filter(|s| !matches!(*s, "index" | ""))
556 .map(str::to_string)
557 .collect()
558}
559
560fn javascript_absolutize(path: &str, file: &str) -> Vec<String> {
561 typescript_absolutize(path, file)
562}
563
564fn c_module_path(file: &str) -> Vec<String> {
565 cpp_module_path(file)
566}
567
568fn c_absolutize(path: &str, file: &str) -> Vec<String> {
569 cpp_absolutize(path, file)
570}
571
572fn java_module_path(file: &str) -> Vec<String> {
579 dirname_segments(file)
580 .into_iter()
581 .filter(|s| !s.is_empty())
582 .collect()
583}
584
585fn java_absolutize(path: &str, _file: &str) -> Vec<String> {
589 let head = path.split('(').next().unwrap_or(path);
590 head.split('.')
591 .map(str::trim)
592 .filter(|s| !s.is_empty())
593 .map(str::to_string)
594 .collect()
595}
596
597fn sql_module_path(file: &str) -> Vec<String> {
606 const SQL_LAYOUT_DIRS: &[&str] = &[
609 "migrations",
610 "migration",
611 "queries",
612 "query",
613 "schema",
614 "schemas",
615 "seeds",
616 "seed",
617 "views",
618 "tables",
619 "functions",
620 "procedures",
621 "triggers",
622 "indexes",
623 "ddl",
624 "dml",
625 "sql",
626 "db",
627 "database",
628 ];
629 let dir = file.rsplit_once('/').map_or("", |(dir, _)| dir);
630 let mut segments: Vec<String> = dir
631 .split('/')
632 .filter(|s| !s.is_empty())
633 .map(str::to_string)
634 .collect();
635 while segments
636 .last()
637 .is_some_and(|s| SQL_LAYOUT_DIRS.contains(&s.to_ascii_lowercase().as_str()))
638 {
639 segments.pop();
640 }
641 segments
642}
643
644fn dotted_absolutize(path: &str, _file: &str) -> Vec<String> {
647 path.trim()
648 .split('.')
649 .filter(|s| !s.is_empty())
650 .map(str::to_string)
651 .collect()
652}
653
654pub static LANGUAGES: &[LanguageSpec] = &[
655 LanguageSpec {
656 name: "rust",
657 extensions: &["rs"],
658 grammar: rust_grammar,
659 query_source: include_str!("../queries/rust.scm"),
660 comment_kinds: &["line_comment", "block_comment"],
661 module_path: rust_module_path,
662 path_separators: &["::", "."],
663 absolutize: rust_absolutize,
664 receivers: &["self", "Self"],
665 doc_skip_kinds: &[],
666 manifest: Some(&RUST_MANIFEST),
667 inline: None,
668 file_refs: false,
669 implicit_interfaces: false,
670 },
671 LanguageSpec {
672 name: "go",
673 extensions: &["go"],
674 grammar: go_grammar,
675 query_source: include_str!("../queries/go.scm"),
676 comment_kinds: &["comment"],
677 module_path: go_module_path,
678 path_separators: &["/", "."],
679 absolutize: go_absolutize,
680 receivers: &[],
681 doc_skip_kinds: &[],
682 manifest: Some(&GO_MANIFEST),
683 inline: None,
684 file_refs: false,
685 implicit_interfaces: true,
686 },
687 LanguageSpec {
688 name: "python",
689 extensions: &["py"],
690 grammar: python_grammar,
691 query_source: include_str!("../queries/python.scm"),
692 comment_kinds: &["comment"],
693 module_path: python_module_path,
694 path_separators: &["."],
695 absolutize: python_absolutize,
696 receivers: &["self", "cls"],
697 doc_skip_kinds: &[],
698 manifest: None,
699 inline: None,
700 file_refs: false,
701 implicit_interfaces: false,
702 },
703 LanguageSpec {
704 name: "typescript",
705 extensions: &["ts", "tsx"],
706 grammar: typescript_grammar,
707 query_source: include_str!("../queries/typescript.scm"),
708 comment_kinds: &["comment"],
709 module_path: typescript_module_path,
710 path_separators: &["/", "."],
711 absolutize: typescript_absolutize,
712 receivers: &["this"],
713 doc_skip_kinds: &[],
714 manifest: None,
715 inline: None,
716 file_refs: false,
717 implicit_interfaces: false,
718 },
719 LanguageSpec {
720 name: "bash",
721 extensions: &["sh", "bash"],
722 grammar: bash_grammar,
723 query_source: include_str!("../queries/bash.scm"),
724 comment_kinds: &["comment"],
725 module_path: bash_module_path,
726 path_separators: &["/"],
727 absolutize: bash_absolutize,
728 receivers: &[],
729 doc_skip_kinds: &[],
730 manifest: None,
731 inline: None,
732 file_refs: false,
733 implicit_interfaces: false,
734 },
735 LanguageSpec {
736 name: "proto",
737 extensions: &["proto"],
738 grammar: proto_grammar,
739 query_source: include_str!("../queries/proto.scm"),
740 comment_kinds: &["comment"],
741 module_path: proto_module_path,
742 path_separators: &["/", "."],
743 absolutize: proto_absolutize,
744 receivers: &[],
745 doc_skip_kinds: &[],
746 manifest: None,
747 inline: None,
748 file_refs: false,
749 implicit_interfaces: false,
750 },
751 LanguageSpec {
752 name: "cpp",
753 extensions: &["cpp", "cc", "cxx", "hpp", "hh", "hxx", "h"],
754 grammar: cpp_grammar,
755 query_source: include_str!("../queries/cpp.scm"),
756 comment_kinds: &["comment"],
757 module_path: cpp_module_path,
758 path_separators: &["/", "::"],
759 absolutize: cpp_absolutize,
760 receivers: &["this"],
761 doc_skip_kinds: &["expression_statement"],
762 manifest: None,
763 inline: None,
764 file_refs: false,
765 implicit_interfaces: false,
766 },
767 LanguageSpec {
768 name: "javascript",
769 extensions: &["js", "jsx", "mjs", "cjs"],
770 grammar: javascript_grammar,
771 query_source: include_str!("../queries/javascript.scm"),
772 comment_kinds: &["comment"],
773 module_path: javascript_module_path,
774 path_separators: &["/", "."],
775 absolutize: javascript_absolutize,
776 receivers: &["this"],
777 doc_skip_kinds: &[],
778 manifest: None,
779 inline: None,
780 file_refs: false,
781 implicit_interfaces: false,
782 },
783 LanguageSpec {
784 name: "c",
785 extensions: &["c"],
786 grammar: c_grammar,
787 query_source: include_str!("../queries/c.scm"),
788 comment_kinds: &["comment"],
789 module_path: c_module_path,
790 path_separators: &["/"],
791 absolutize: c_absolutize,
792 receivers: &[],
793 doc_skip_kinds: &[],
794 manifest: None,
795 inline: None,
796 file_refs: false,
797 implicit_interfaces: false,
798 },
799 LanguageSpec {
800 name: "java",
801 extensions: &["java"],
802 grammar: java_grammar,
803 query_source: include_str!("../queries/java.scm"),
804 comment_kinds: &["line_comment", "block_comment"],
805 module_path: java_module_path,
806 path_separators: &["."],
807 absolutize: java_absolutize,
808 receivers: &["this"],
809 doc_skip_kinds: &[],
810 manifest: None,
811 inline: None,
812 file_refs: false,
813 implicit_interfaces: false,
814 },
815 LanguageSpec {
816 name: "csharp",
817 extensions: &["cs"],
818 grammar: csharp_grammar,
819 query_source: include_str!("../queries/csharp.scm"),
820 comment_kinds: &["comment"],
821 module_path: csharp_module_path,
822 path_separators: &["."],
823 absolutize: dotted_absolutize,
824 receivers: &["this", "base"],
825 doc_skip_kinds: &[],
826 manifest: None,
827 inline: None,
828 file_refs: false,
829 implicit_interfaces: false,
830 },
831 LanguageSpec {
832 name: "sql",
833 extensions: &["sql"],
834 grammar: sql_grammar,
835 query_source: include_str!("../queries/sql.scm"),
836 comment_kinds: &["comment", "marginalia"],
837 module_path: sql_module_path,
838 path_separators: &["."],
839 absolutize: dotted_absolutize,
840 receivers: &[],
841 doc_skip_kinds: &[],
842 manifest: None,
843 inline: None,
844 file_refs: false,
845 implicit_interfaces: false,
846 },
847 LanguageSpec {
848 name: "markdown",
849 extensions: &["md", "markdown"],
850 grammar: markdown_grammar,
851 query_source: include_str!("../queries/markdown.scm"),
852 comment_kinds: &[],
853 module_path: markdown_module_path,
854 path_separators: &["/"],
855 absolutize: markdown_absolutize,
856 receivers: &[],
857 doc_skip_kinds: &[],
858 manifest: None,
859 inline: Some(&MARKDOWN_INLINE),
860 file_refs: true,
861 implicit_interfaces: false,
862 },
863];
864
865pub fn spec_for_path(path: &str) -> Option<&'static LanguageSpec> {
867 let ext = path.rsplit('.').next()?;
868 LANGUAGES.iter().find(|spec| spec.extensions.contains(&ext))
869}
870
871#[cfg(test)]
872mod tests {
873 use super::sql_module_path;
874
875 #[test]
876 fn sql_namespace_is_the_database_root() {
877 assert!(sql_module_path("migrations/001.sql").is_empty());
879 assert!(sql_module_path("queries/q.sql").is_empty());
880 assert!(sql_module_path("db/Migrations/001.sql").is_empty());
881 assert_eq!(sql_module_path("svc_a/db/migrations/001.sql"), ["svc_a"]);
882 assert_eq!(sql_module_path("svc_a/db/queries/q.sql"), ["svc_a"]);
883 assert_eq!(sql_module_path("analytics/report.sql"), ["analytics"]);
885 assert!(sql_module_path("schema.sql").is_empty());
886 }
887}