Skip to main content

babyrite/
utils.rs

1//! Utility functions for babyrite.
2
3/// Returns the language identifier for syntax highlighting based on file extension.
4///
5/// Used for code block language hints when expanding GitHub permalinks.
6pub fn language_from_extension(extension: &str) -> &str {
7    match extension.to_lowercase().as_str() {
8        "rs" => "rust",
9        "py" => "python",
10        "js" => "javascript",
11        "ts" => "typescript",
12        "jsx" => "jsx",
13        "tsx" => "tsx",
14        "rb" => "ruby",
15        "go" => "go",
16        "java" => "java",
17        "kt" | "kts" => "kotlin",
18        "c" | "h" => "c",
19        "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp",
20        "cs" => "csharp",
21        "swift" => "swift",
22        "php" => "php",
23        "scala" => "scala",
24        "sh" | "bash" | "zsh" | "fish" => "bash",
25        "ps1" => "powershell",
26        "html" | "htm" => "html",
27        "css" => "css",
28        "scss" => "scss",
29        "sass" => "sass",
30        "less" => "less",
31        "json" => "json",
32        "yaml" | "yml" => "yaml",
33        "toml" => "toml",
34        "xml" => "xml",
35        "sql" => "sql",
36        "md" | "markdown" => "markdown",
37        "dockerfile" => "dockerfile",
38        "lua" => "lua",
39        "r" => "r",
40        "dart" => "dart",
41        "zig" => "zig",
42        "nim" => "nim",
43        "ex" | "exs" => "elixir",
44        "erl" | "hrl" => "erlang",
45        "hs" => "haskell",
46        "ml" | "mli" => "ocaml",
47        "clj" | "cljs" => "clojure",
48        "tf" => "hcl",
49        "vue" => "vue",
50        "svelte" => "svelte",
51        "graphql" | "gql" => "graphql",
52        "proto" => "protobuf",
53        "makefile" | "mk" => "makefile",
54        _ => extension,
55    }
56}
57
58/// Returns the language identifier for syntax highlighting based on a file path.
59///
60/// Uses the file extension when present; extensionless filenames
61/// (e.g. `Dockerfile`, `Makefile`) are looked up by name.
62pub fn language_for_path(path: &str) -> &str {
63    let filename = path.rsplit('/').next().unwrap_or(path);
64    match filename.rsplit_once('.') {
65        Some((_, ext)) => language_from_extension(ext),
66        None => language_from_extension(filename),
67    }
68}
69
70/// Rewrites `text` so it cannot terminate a Discord code fence.
71///
72/// Discord closes a code block at the first ``` it encounters, so fetched file
73/// content containing one would escape the fence and have the remainder
74/// rendered as markdown. Runs of backticks are kept below three by wedging in a
75/// zero-width space, which leaves the text visually unchanged. Single and double
76/// backticks are untouched, so ordinary code (template literals, shell quoting)
77/// still displays as written.
78pub fn defuse_code_fences(text: &str) -> String {
79    let mut out = String::with_capacity(text.len());
80    let mut backticks = 0;
81    for c in text.chars() {
82        if c == '`' {
83            if backticks == 2 {
84                out.push('\u{200b}');
85                backticks = 0;
86            }
87            backticks += 1;
88        } else {
89            backticks = 0;
90        }
91        out.push(c);
92    }
93    out
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn known_extensions() {
102        assert_eq!(language_from_extension("rs"), "rust");
103        assert_eq!(language_from_extension("py"), "python");
104        assert_eq!(language_from_extension("js"), "javascript");
105        assert_eq!(language_from_extension("ts"), "typescript");
106        assert_eq!(language_from_extension("go"), "go");
107        assert_eq!(language_from_extension("java"), "java");
108        assert_eq!(language_from_extension("cpp"), "cpp");
109        assert_eq!(language_from_extension("cc"), "cpp");
110        assert_eq!(language_from_extension("c"), "c");
111        assert_eq!(language_from_extension("h"), "c");
112        assert_eq!(language_from_extension("hpp"), "cpp");
113        assert_eq!(language_from_extension("cs"), "csharp");
114        assert_eq!(language_from_extension("rb"), "ruby");
115        assert_eq!(language_from_extension("kt"), "kotlin");
116        assert_eq!(language_from_extension("kts"), "kotlin");
117        assert_eq!(language_from_extension("sh"), "bash");
118        assert_eq!(language_from_extension("bash"), "bash");
119        assert_eq!(language_from_extension("zsh"), "bash");
120        assert_eq!(language_from_extension("fish"), "bash");
121        assert_eq!(language_from_extension("yaml"), "yaml");
122        assert_eq!(language_from_extension("yml"), "yaml");
123        assert_eq!(language_from_extension("json"), "json");
124        assert_eq!(language_from_extension("toml"), "toml");
125        assert_eq!(language_from_extension("md"), "markdown");
126        assert_eq!(language_from_extension("ex"), "elixir");
127        assert_eq!(language_from_extension("exs"), "elixir");
128        assert_eq!(language_from_extension("hs"), "haskell");
129        assert_eq!(language_from_extension("tf"), "hcl");
130        assert_eq!(language_from_extension("vue"), "vue");
131        assert_eq!(language_from_extension("svelte"), "svelte");
132        assert_eq!(language_from_extension("proto"), "protobuf");
133        assert_eq!(language_from_extension("graphql"), "graphql");
134        assert_eq!(language_from_extension("gql"), "graphql");
135    }
136
137    #[test]
138    fn case_insensitive() {
139        assert_eq!(language_from_extension("RS"), "rust");
140        assert_eq!(language_from_extension("Py"), "python");
141        assert_eq!(language_from_extension("JS"), "javascript");
142    }
143
144    #[test]
145    fn unknown_extension_returns_as_is() {
146        assert_eq!(language_from_extension("xyz"), "xyz");
147        assert_eq!(language_from_extension("foo"), "foo");
148    }
149
150    #[test]
151    fn extensionless_filenames() {
152        assert_eq!(language_from_extension("Dockerfile"), "dockerfile");
153        assert_eq!(language_from_extension("dockerfile"), "dockerfile");
154        assert_eq!(language_from_extension("Makefile"), "makefile");
155        assert_eq!(language_from_extension("makefile"), "makefile");
156    }
157
158    #[test]
159    fn language_for_path_basic_extension() {
160        assert_eq!(language_for_path("src/main.rs"), "rust");
161    }
162
163    #[test]
164    fn language_for_path_dockerfile_in_subdir() {
165        assert_eq!(language_for_path("docker/Dockerfile"), "dockerfile");
166    }
167
168    #[test]
169    fn language_for_path_dotted_directory() {
170        assert_eq!(language_for_path("some.config/Dockerfile"), "dockerfile");
171    }
172
173    #[test]
174    fn language_for_path_makefile_in_subdir() {
175        assert_eq!(language_for_path("build/Makefile"), "makefile");
176    }
177
178    #[test]
179    fn language_for_path_multiple_dots() {
180        assert_eq!(language_for_path("file.test.ts"), "typescript");
181    }
182
183    #[test]
184    fn language_for_path_dotfile() {
185        assert_eq!(language_for_path(".gitignore"), "gitignore");
186    }
187
188    /// The number of consecutive backticks a run of `n` is reduced to.
189    fn longest_backtick_run(text: &str) -> usize {
190        text.chars()
191            .fold((0, 0), |(longest, run), c| {
192                let run = if c == '`' { run + 1 } else { 0 };
193                (longest.max(run), run)
194            })
195            .0
196    }
197
198    #[test]
199    fn code_without_backticks_is_unchanged() {
200        assert_eq!(defuse_code_fences("fn main() {}"), "fn main() {}");
201    }
202
203    #[test]
204    fn short_backtick_runs_are_preserved() {
205        // Inline code and shell quoting must survive verbatim.
206        assert_eq!(defuse_code_fences("let s = `a`;"), "let s = `a`;");
207        assert_eq!(defuse_code_fences("``double``"), "``double``");
208    }
209
210    #[test]
211    fn no_backtick_run_survives_at_fence_length() {
212        // Any run of three or more would close the fence Discord opened.
213        for run in 3..=8 {
214            let input = "`".repeat(run);
215            assert!(
216                longest_backtick_run(&defuse_code_fences(&input)) < 3,
217                "a run of {run} backticks was not defused"
218            );
219        }
220    }
221
222    #[test]
223    fn defusing_keeps_surrounding_text() {
224        let defused = defuse_code_fences("before```after");
225        assert!(longest_backtick_run(&defused) < 3);
226        assert!(defused.starts_with("before"));
227        assert!(defused.ends_with("after"));
228        assert_eq!(defused.matches('`').count(), 3);
229    }
230}