Skip to main content

clippy_say/
lib.rs

1use std::cmp::max;
2
3use itertools::Itertools;
4use unicode_width::UnicodeWidthStr;
5
6const CLIPPY: &str = "
7        ⢀⣀⣤⣤⣄⣀
8      ⢠⣶⠟⣉⣤⣢⣄⡪⢝⢦⡀
9     ⢰⡿⢁⣾⠟⠉⠉⠉⠹⣧⣃⢳⡀
10   ⢀⣀⣼⡏⣼⠃     ⢹⣏⣸⡅
11 ⢀⣴⡿⠿⣿⠃⣿      ⣸⣷⣿⣶⣄
12⠠⠞⠁ ⢠⣿⠌⣿      ⣿⡇⣿⠛⠛⠿⣄
13  ⢀⣠⠾⠿⠾⣷⡀   ⡠⢶⠛⠹⠿⢶⣄ ⠈
14 ⢠⠋ ⢀⣁⡀⠘⠙⣦⡀⠘⠈ ⣠⣤⡀ ⠻⣦
15 ⢀  ⢾⣿⣿  ⢘⣧⠇⡀⠘⢿⣿⠏  ⡿
16 ⠈⢧⡀⠈⣉⡁ ⣤⡞ ⠘⢢⣀⡄ ⢠⣠⠾⠃
17   ⠉⣷⡖⣶⡛⠉    ⣿⡏⣿⠋⠁
18    ⢻⡇⣽⢺⣱⡄   ⣿⢇⡏  ⣰⡖⣦
19    ⣿⡇⣿⢻⠸⡇   ⣿⢰⡏⢀⣾⢳⡾⠉
20    ⣿⡄⡿⣿⠘⡁  ⠐⣿⢸⡇⣾⢇⡿
21    ⣿⠐⣟⣧⢰   ⢸⣿⢺⠆⣿⢸⡇
22    ⣿⠡⣟⣿⢸⡇  ⢸⣇⢿⠆⣿⢸⡅
23    ⣿⠡⣏⣿⡸⡅  ⣼⢏⣼⠆⣿⢸⠃
24    ⣿⠰⣿⠹⣶⣭⣖⣪⣵⡾⠏⢠⣿⢸⡁
25    ⣿⢂⡷ ⠈⠉⠘⠉⠉  ⠸⣿⢼⡀
26    ⣿⡍⢿⡀       ⣸⠇⣼
27    ⠹⣯⡎⡻⢦⣀⣀⣀⣀⡤⠞⣉⣼⠃
28     ⠈⠻⢷⣦⣢⣬⣤⣤⣶⠾⠋
29         ⠉⠉⠉
30";
31
32const TEXT_BUBBLE_LEFT_SIDE: &str = "   ⣿⡇  ";
33const TEXT_BUBBLE_TOP_LEFT: &str = "   ⣴⡾⠿";
34const TEXT_BUBBLE_TOP_SIDE: &str = "⠿";
35const TEXT_BUBBLE_TOP_RIGHT: &str = "⠿⢷⣦";
36const TEXT_BUBBLE_RIGHT_SIDE: &str = "  ⢸⣿";
37const TEXT_BUBBLE_BOTTOM_RIGHT: &str = "⣾⠟";
38const TEXT_BUBBLE_BOTTOM_SIDE: &str = "⣶";
39const TEXT_BUBBLE_BOTTOM_LEFT: &str = "   ⠙⣿⡆       ⣴⣶";
40const TEXT_BUBBLE_TAIL: &str = "   ⢰⣿     ⢀⣠⣾⠟⠋
41  ⣠⣿⠃ ⢀⣠⣤⣾⠟⠋
42  ⢿⣷⡾⠿⠟⠛⠉
43";
44
45pub fn clippy() -> String {
46    CLIPPY.to_string()
47}
48
49pub fn clippy_say(text: impl ToString) -> String {
50    let mut clippy = CLIPPY.to_string();
51
52    // Create the speech bubble
53    let speech_bubble = say(text);
54
55    // Get the heights of the two pieces of text to combine.
56    let clippy_height = clippy.lines().count();
57    let speech_bubble_height = speech_bubble.lines().count();
58
59    // Add newlines to the top of clippy if the speech bubble is taller than him. We don't want the
60    // speech bubble's tail to be below him.
61    let difference = speech_bubble_height.saturating_sub(clippy_height);
62    for _ in 0..difference {
63        clippy.insert(0, '\n');
64    }
65
66    horizontal_stack(clippy, speech_bubble)
67}
68
69pub fn say(text: impl ToString) -> String {
70    let minimum_text_width = TEXT_BUBBLE_BOTTOM_LEFT.width() + TEXT_BUBBLE_BOTTOM_RIGHT.width()
71        - TEXT_BUBBLE_LEFT_SIDE.width()
72        - TEXT_BUBBLE_RIGHT_SIDE.width();
73
74    debug_assert_eq!(TEXT_BUBBLE_LEFT_SIDE.width(), 7);
75
76    let text = text.to_string();
77    // Get the length of the longest line
78    let mut full_width = longest_line_size(&text);
79    if full_width < minimum_text_width {
80        full_width = minimum_text_width
81    }
82
83    let mut output = String::new();
84    output.push_str(&top(full_width));
85    output.push_str(&empty_line(full_width));
86
87    for line in text.lines() {
88        output.push_str(&speech_line(line, full_width));
89    }
90    output.push_str(&empty_line(full_width));
91    output.push_str(&bottom(full_width));
92    output
93}
94
95fn top(text_width: usize) -> String {
96    let speech_bubble_width = speech_bubble_width(text_width);
97    let top_sides_needed = speech_bubble_width
98        .saturating_sub(TEXT_BUBBLE_TOP_LEFT.width() + TEXT_BUBBLE_TOP_RIGHT.width());
99
100    let top_side = TEXT_BUBBLE_TOP_SIDE.repeat(top_sides_needed);
101    format!("{TEXT_BUBBLE_TOP_LEFT}{top_side}{TEXT_BUBBLE_TOP_RIGHT}\n")
102}
103
104fn bottom(text_width: usize) -> String {
105    let speech_bubble_width = speech_bubble_width(text_width);
106    let bottom_sides_needed = speech_bubble_width
107        .saturating_sub(TEXT_BUBBLE_BOTTOM_LEFT.width() + TEXT_BUBBLE_BOTTOM_RIGHT.width());
108
109    let bottom_side = TEXT_BUBBLE_BOTTOM_SIDE.repeat(bottom_sides_needed);
110    format!("{TEXT_BUBBLE_BOTTOM_LEFT}{bottom_side}{TEXT_BUBBLE_BOTTOM_RIGHT}\n{TEXT_BUBBLE_TAIL}")
111}
112
113fn speech_line(text: &str, text_width: usize) -> String {
114    let mut padded_text = text.to_string();
115    padded_text.push_str(&" ".repeat(text_width.saturating_sub(padded_text.width())));
116    format!("{TEXT_BUBBLE_LEFT_SIDE}{padded_text}{TEXT_BUBBLE_RIGHT_SIDE}\n",)
117}
118
119fn empty_line(text_width: usize) -> String {
120    speech_line("", text_width)
121}
122
123fn longest_line_size(text: &str) -> usize {
124    text.lines().map(|s| s.width()).max().map_or(0, |v| v)
125}
126
127fn speech_bubble_width(text_width: usize) -> usize {
128    TEXT_BUBBLE_LEFT_SIDE.width() + text_width + TEXT_BUBBLE_RIGHT_SIDE.width()
129}
130
131fn horizontal_stack(str1: impl ToString, str2: impl ToString) -> String {
132    // Split both strings in to columns from the contained newlines.
133    let stack1: Vec<String> = str1.to_string().lines().map_into().collect_vec();
134    let stack2: Vec<String> = str2.to_string().lines().map_into().collect_vec();
135    let tallest_stack_height = max(stack1.len(), stack2.len());
136
137    // Get the longest line from the first stack. All lines from `str2` must start being added at
138    // this index.
139    let stack1_longest_line_length = longest_line_size(&str1.to_string());
140
141    let mut output = String::new();
142    for i in 0..tallest_stack_height {
143        let stack1_str = stack1.get(i).map_or("", |v| v);
144        let stack2_str = stack2.get(i).map_or("", |v| v);
145
146        // Get the number of spaces to add to the line
147        let spaces_to_add = stack1_longest_line_length - stack1_str.width();
148
149        // Create the output for this line
150        output.push_str(stack1_str);
151        output.push_str(&" ".repeat(spaces_to_add));
152        output.push_str(stack2_str);
153        output.push('\n');
154    }
155    // Remove the last newline
156    output.pop();
157
158    output
159}
160
161#[cfg(test)]
162mod test {
163    use super::{clippy_say, horizontal_stack, longest_line_size, say};
164    use test_case::test_case;
165
166    #[test_case("hello", 5; "with 1 line")]
167    #[test_case("hello\ngoodbye", 7; "with 2 lines")]
168    #[test_case("hello\ngoodbye\n", 7; "with extra newline")]
169    fn longest_line(text: &str, expected: usize) {
170        let actual = longest_line_size(text);
171        assert_eq!(expected, actual);
172    }
173
174    #[test_case("123\n123", "456\n456", "123456\n123456"; "with no formatting")]
175    #[test_case("12345\n123", "678\n456", "12345678\n123  456"; "with formatting")]
176    #[test_case("123\n123\n123", "456\n456", "123456\n123456\n123"; "with more lines in str1")]
177    #[test_case("123\n123", "456\n456\n456", "123456\n123456\n   456"; "with more lines in str2")]
178    fn stack(str1: &str, str2: &str, expected: &str) {
179        let actual = horizontal_stack(str1, str2);
180        assert_eq!(expected, actual);
181    }
182
183    #[test]
184    fn test_say_smol() {
185        let text = "smol";
186        let expected = format!(
187            "   ⣴⡾⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⢷⣦
188   ⣿⡇          ⢸⣿
189   ⣿⡇  {text}    ⢸⣿
190   ⣿⡇          ⢸⣿
191   ⠙⣿⡆       ⣴⣶⣾⠟
192   ⢰⣿     ⢀⣠⣾⠟⠋
193  ⣠⣿⠃ ⢀⣠⣤⣾⠟⠋
194  ⢿⣷⡾⠿⠟⠛⠉
195"
196        );
197        let actual = say(text);
198
199        println!("{expected}");
200        println!("{actual}");
201
202        assert_eq!(expected, actual)
203    }
204
205    #[test]
206    fn test_say() {
207        let text = "This is a test message on it's own";
208        let expected = format!(
209            "   ⣴⡾⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⢷⣦
210   ⣿⡇                                      ⢸⣿
211   ⣿⡇  {text}  ⢸⣿
212   ⣿⡇                                      ⢸⣿
213   ⠙⣿⡆       ⣴⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣾⠟
214   ⢰⣿     ⢀⣠⣾⠟⠋
215  ⣠⣿⠃ ⢀⣠⣤⣾⠟⠋
216  ⢿⣷⡾⠿⠟⠛⠉
217"
218        );
219        let actual = say(text);
220
221        println!("{expected}");
222        println!("{actual}");
223
224        assert_eq!(expected, actual)
225    }
226
227    #[test]
228    fn test_clippy_say() {
229        let text = "This is a test message on it's own";
230        let actual = clippy_say(text);
231        println!("{}", actual)
232    }
233}