1
/*
2
 * Hurl (https://hurl.dev)
3
 * Copyright (C) 2025 Orange
4
 *
5
 * Licensed under the Apache License, Version 2.0 (the "License");
6
 * you may not use this file except in compliance with the License.
7
 * You may obtain a copy of the License at
8
 *
9
 *          http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 * Unless required by applicable law or agreed to in writing, software
12
 * distributed under the License is distributed on an "AS IS" BASIS,
13
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 * See the License for the specific language governing permissions and
15
 * limitations under the License.
16
 *
17
 */
18
use colored::Colorize;
19

            
20
use crate::text::style::{Color, Style};
21

            
22
/// A String with style.
23
///
24
/// A styled string can be composed of styled parts (tokens). A token has a style (an optional
25
/// foreground color with a bold attribute) and a string content.
26
///
27
/// A styled string can be rendered as plain text, or as a string with
28
/// [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code). It's useful to make text
29
/// that can be colored or plain at runtime.
30
#[derive(Clone, Debug, Default, PartialEq, Eq)]
31
pub struct StyledString {
32
    /// A list of tokens (styled parts).
33
    tokens: Vec<Token>,
34
}
35

            
36
/// Represents part of a [`StyledString`].
37
#[derive(Clone, Debug, PartialEq, Eq)]
38
struct Token {
39
    /// The string content of the token.
40
    content: String,
41
    /// The style of the token, a foreground color with a bold attribute.
42
    style: Style,
43
}
44

            
45
/// The format in which a [`StyledString`] can be rendered.
46
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
47
pub enum Format {
48
    Plain,
49
    Ansi,
50
}
51

            
52
impl StyledString {
53
    /// Creates an empty instance of a styled string.
54
83365
    pub fn new() -> StyledString {
55
83365
        StyledString { tokens: vec![] }
56
    }
57

            
58
    /// Appends a given `content` without any style onto this end of this `StyledString`
59
145665
    pub fn push(&mut self, content: &str) {
60
145665
        self.push_with(content, Style::new());
61
    }
62

            
63
    /// Appends a given `content` with a specific `style`.
64
248945
    pub fn push_with(&mut self, content: &str, style: Style) {
65
248945
        let token = Token::new(content, style);
66
248945
        self.push_token(token);
67
    }
68

            
69
358500
    fn push_token(&mut self, token: Token) {
70
        // Concatenate content to last token if it has the same style
71
358500
        if let Some(last) = self.tokens.last_mut() {
72
275220
            if last.style == token.style {
73
56145
                last.content.push_str(&token.content);
74
56145
                return;
75
            }
76
        }
77
302355
        self.tokens.push(token);
78
    }
79

            
80
    /// Renders a styled string given a `format`.
81
40695
    pub fn to_string(&self, format: Format) -> String {
82
40695
        self.tokens
83
40695
            .iter()
84
239059
            .map(|token| token.to_string(format))
85
40695
            .collect::<Vec<String>>()
86
40695
            .join("")
87
    }
88

            
89
    /// Appends a styled string.
90
61185
    pub fn append(&mut self, other: StyledString) {
91
170740
        for token in other.tokens {
92
109555
            self.push_token(token);
93
        }
94
    }
95

            
96
    /// Splits a styled string to a given list of [`StyledString`], given a `delimiter`.
97
5705
    pub fn split(&self, delimiter: char) -> Vec<StyledString> {
98
5705
        let mut items = vec![];
99
5705
        let mut item = StyledString::new();
100
22485
        for token in &self.tokens {
101
16780
            let mut substrings = token.content.split(delimiter).collect::<Vec<&str>>();
102
16780
            let first = substrings.remove(0);
103
16780
            if !first.is_empty() {
104
14075
                item.push_with(first, token.style);
105
            }
106
25235
            for substring in substrings {
107
8455
                items.push(item);
108
8455
                item = StyledString::new();
109
8455
                if !substring.is_empty() {
110
                    item.push_with(substring, token.style);
111
                }
112
            }
113
        }
114
5705
        items.push(item);
115
5705
        items
116
    }
117

            
118
    /// Tests if this styled string ends with a given `value`, no matter what's the style of the string.
119
5620
    pub fn ends_with(&self, value: &str) -> bool {
120
5620
        self.to_string(Format::Plain).ends_with(value)
121
    }
122

            
123
    /// Returns the length of visible chars.
124
215
    pub fn len(&self) -> usize {
125
241
        self.tokens.iter().fold(0, |acc, t| acc + t.content.len())
126
    }
127

            
128
    /// Checks if this string is empty.
129
215
    pub fn is_empty(&self) -> bool {
130
215
        self.len() == 0
131
    }
132

            
133
    /// Add newlines so each lines of this string has a maximum of `max_width` chars.
134
    pub fn wrap(&self, max_width: usize) -> StyledString {
135
        let mut string = StyledString::new();
136
        let mut width = 0;
137

            
138
        for token in &self.tokens {
139
            let mut chunk = String::new();
140
            let mut it = token.content.chars().peekable();
141

            
142
            // Iterate over each chars of the current token, splitting the current
143
            // token if necessary
144
            while let Some(c) = it.next() {
145
                chunk.push(c);
146
                width += 1;
147

            
148
                if width >= max_width {
149
                    let token = Token::new(&chunk, token.style);
150
                    string.push_token(token);
151
                    if it.peek().is_some() {
152
                        // New lines are always plain
153
                        let nl = Token::new("\n", Style::new());
154
                        string.push_token(nl);
155
                    }
156
                    chunk = String::new();
157
                    width = 0;
158
                }
159
            }
160

            
161
            // Append the last chunk
162
            if !chunk.is_empty() {
163
                let token = Token::new(&chunk, token.style);
164
                string.push_token(token);
165
            }
166
        }
167
        string
168
    }
169

            
170
    /// Truncates this `StyledString`, removing all contents.
171
120
    pub fn clear(&mut self) {
172
120
        self.tokens.clear();
173
    }
174
}
175

            
176
/// Represents part of a styled string.
177
impl Token {
178
248945
    fn new(content: &str, style: Style) -> Token {
179
248945
        let content = content.to_string();
180
248945
        Token { content, style }
181
    }
182

            
183
230920
    fn to_string(&self, format: Format) -> String {
184
230920
        match format {
185
225415
            Format::Plain => self.plain(),
186
5505
            Format::Ansi => self.ansi(),
187
        }
188
    }
189

            
190
225415
    fn plain(&self) -> String {
191
225415
        self.content.to_string()
192
    }
193

            
194
5505
    fn ansi(&self) -> String {
195
5505
        let mut s = self.content.to_string();
196
5505
        if let Some(color) = &self.style.fg {
197
2680
            s = match color {
198
                Color::Blue => {
199
1485
                    if self.style.bold {
200
1485
                        s.blue().bold().to_string()
201
                    } else {
202
                        s.blue().to_string()
203
                    }
204
                }
205
                Color::BrightBlack => {
206
335
                    if self.style.bold {
207
                        s.bright_black().bold().to_string()
208
                    } else {
209
335
                        s.bright_black().to_string()
210
                    }
211
                }
212
                Color::Cyan => {
213
330
                    if self.style.bold {
214
330
                        s.cyan().bold().to_string()
215
                    } else {
216
                        s.cyan().to_string()
217
                    }
218
                }
219
                Color::Green => {
220
60
                    if self.style.bold {
221
50
                        s.green().bold().to_string()
222
                    } else {
223
10
                        s.green().to_string()
224
                    }
225
                }
226
                Color::Magenta => {
227
                    if self.style.bold {
228
                        s.magenta().bold().to_string()
229
                    } else {
230
                        s.magenta().to_string()
231
                    }
232
                }
233
                Color::Purple => {
234
                    if self.style.bold {
235
                        s.purple().bold().to_string()
236
                    } else {
237
                        s.purple().to_string()
238
                    }
239
                }
240
                Color::Red => {
241
460
                    if self.style.bold {
242
455
                        s.red().bold().to_string()
243
                    } else {
244
5
                        s.red().to_string()
245
                    }
246
                }
247
                Color::Yellow => {
248
10
                    if self.style.bold {
249
5
                        s.yellow().bold().to_string()
250
                    } else {
251
5
                        s.yellow().to_string()
252
                    }
253
                }
254
            };
255
2825
        } else if self.style.bold {
256
340
            s = s.bold().to_string();
257
        }
258
5505
        s
259
    }
260
}
261

            
262
#[cfg(test)]
263
mod tests {
264
    use super::*;
265

            
266
    #[test]
267
    fn test_hello() {
268
        // For the crate colored to output ANSI escape code in test environment.
269
        crate::text::init_crate_colored();
270

            
271
        let mut message = StyledString::new();
272
        message.push("Hello ");
273
        message.push_with("Bob", Style::new().red());
274
        message.push("!");
275
        assert_eq!(message.to_string(Format::Plain), "Hello Bob!");
276
        assert_eq!(
277
            message.to_string(Format::Ansi),
278
            "Hello \u{1b}[31mBob\u{1b}[0m!"
279
        );
280
    }
281

            
282
    #[test]
283
    fn test_push() {
284
        let mut message = StyledString::new();
285
        message.push("Hello");
286
        message.push(" ");
287
        message.push_with("Bob", Style::new().red());
288
        message.push("!");
289

            
290
        assert_eq!(
291
            message,
292
            StyledString {
293
                tokens: vec![
294
                    Token {
295
                        content: "Hello ".to_string(),
296
                        style: Style::new()
297
                    },
298
                    Token {
299
                        content: "Bob".to_string(),
300
                        style: Style::new().red()
301
                    },
302
                    Token {
303
                        content: "!".to_string(),
304
                        style: Style::new()
305
                    },
306
                ],
307
            }
308
        );
309
    }
310

            
311
    #[test]
312
    fn test_append() {
313
        let mut message1 = StyledString::new();
314
        message1.push("Hello ");
315
        message1.push_with("Bob", Style::new().red());
316
        message1.push("!");
317
        let mut message2 = StyledString::new();
318
        message2.push("Hi ");
319
        message2.push_with("Bill", Style::new().red());
320
        message2.push("!");
321

            
322
        let mut messages = StyledString::new();
323
        messages.push("Hello ");
324
        messages.push_with("Bob", Style::new().red());
325
        messages.push("!");
326
        messages.push("Hi ");
327
        messages.push_with("Bill", Style::new().red());
328
        messages.push("!");
329

            
330
        message1.append(message2);
331
        assert_eq!(message1, messages);
332
    }
333

            
334
    #[test]
335
    fn test_split() {
336
        let mut line = StyledString::new();
337
        line.push("Hello,Hi,");
338
        line.push_with("Hola", Style::new().red());
339
        line.push(",Bye,");
340
        line.push_with("Adios", Style::new().red());
341

            
342
        let mut item0 = StyledString::new();
343
        item0.push("Hello");
344
        let mut item1 = StyledString::new();
345
        item1.push("Hi");
346
        let mut item2 = StyledString::new();
347
        item2.push_with("Hola", Style::new().red());
348
        let mut item3 = StyledString::new();
349
        item3.push("Bye");
350
        let mut item4 = StyledString::new();
351
        item4.push_with("Adios", Style::new().red());
352
        assert_eq!(line.split(','), vec![item0, item1, item2, item3, item4]);
353

            
354
        // Test empty items
355
        let mut line = StyledString::new();
356
        line.push("0,,2,");
357

            
358
        let mut item0 = StyledString::new();
359
        item0.push("0");
360
        let item1 = StyledString::new();
361
        let mut item2 = StyledString::new();
362
        item2.push("2");
363
        let item3 = StyledString::new();
364
        assert_eq!(line.split(','), vec![item0, item1, item2, item3]);
365
    }
366

            
367
    #[test]
368
    fn test_ends_with() {
369
        let mut line = StyledString::new();
370
        line.push("Hello,Hi,");
371
        assert!(line.ends_with(","));
372
        assert!(!line.ends_with("\n"));
373
    }
374

            
375
    #[test]
376
    fn compare_with_crate_colored() {
377
        // These tests are used to check regression against the [colored crate](https://crates.io/crates/colored).
378
        // A short-term objective is to remove the colored crates to manage ansi colors.
379
        let mut message = StyledString::new();
380
        message.push_with("foo", Style::new().red().bold());
381
        assert_eq!(
382
            "foo".red().bold().to_string(),
383
            message.to_string(Format::Ansi),
384
        );
385

            
386
        let mut message = StyledString::new();
387
        message.push_with("bar", Style::new().bold());
388
        assert_eq!("bar".bold().to_string(), message.to_string(Format::Ansi),);
389
    }
390

            
391
    #[test]
392
    fn wrap_single_plain_token() {
393
        let mut line = StyledString::new();
394
        line.push("aaaabbbbcccc");
395

            
396
        let mut wrapped = StyledString::new();
397
        wrapped.push("aaaa\nbbbb\ncccc");
398

            
399
        assert_eq!(line.wrap(4), wrapped);
400
        assert_eq!(line.len(), 12);
401
        assert_eq!(line.wrap(4).len(), 14);
402
    }
403

            
404
    #[test]
405
    fn wrap_single_styled_token() {
406
        let mut line = StyledString::new();
407
        line.push_with("aaaabbbbcccc", Style::new().blue());
408

            
409
        let mut wrapped = StyledString::new();
410
        wrapped.push_with("aaaa", Style::new().blue());
411
        wrapped.push("\n");
412
        wrapped.push_with("bbbb", Style::new().blue());
413
        wrapped.push("\n");
414
        wrapped.push_with("cccc", Style::new().blue());
415

            
416
        assert_eq!(line.wrap(4), wrapped);
417
        assert_eq!(line.len(), 12);
418
        assert_eq!(line.wrap(4).len(), 14);
419
    }
420

            
421
    #[test]
422
    fn wrap_multi_styled_token() {
423
        let mut line = StyledString::new();
424
        line.push_with("aaa", Style::new().blue());
425
        line.push_with("ab", Style::new().green());
426
        line.push_with("bbbccc", Style::new().yellow());
427
        line.push_with("cee", Style::new().purple());
428

            
429
        let mut wrapped = StyledString::new();
430
        wrapped.push_with("aaa", Style::new().blue());
431
        wrapped.push_with("a", Style::new().green());
432
        wrapped.push("\n");
433
        wrapped.push_with("b", Style::new().green());
434
        wrapped.push_with("bbb", Style::new().yellow());
435
        wrapped.push("\n");
436
        wrapped.push_with("ccc", Style::new().yellow());
437
        wrapped.push_with("c", Style::new().purple());
438
        wrapped.push("\n");
439
        wrapped.push_with("ee", Style::new().purple());
440

            
441
        assert_eq!(line.wrap(4), wrapped);
442
    }
443
}