Skip to main content

osp_cli/ui/
inline.rs

1use crate::ui::document::{LineBlock, LinePart};
2use crate::ui::style::{StyleOverrides, StyleToken, apply_style_with_theme_overrides};
3use crate::ui::theme::ThemeDefinition;
4
5/// Parses lightweight inline markup into styled line parts.
6///
7/// Recognizes backtick-delimited code, single-asterisk muted text, and
8/// double-asterisk emphasized text. Escaped marker characters are preserved.
9///
10/// # Examples
11///
12/// ```
13/// use osp_cli::ui::{StyleToken, parts_from_inline};
14///
15/// let parts = parts_from_inline("Use `uid` and *optional* **flags**");
16///
17/// assert_eq!(parts[0].text, "Use ");
18/// assert_eq!(parts[1].token, Some(StyleToken::Key));
19/// assert_eq!(parts[1].text, "uid");
20/// assert_eq!(parts[3].token, Some(StyleToken::Muted));
21/// assert_eq!(parts[5].token, Some(StyleToken::PanelBorder));
22/// ```
23pub fn parts_from_inline(text: &str) -> Vec<LinePart> {
24    let mut parts: Vec<LinePart> = Vec::new();
25    let mut buf = String::new();
26    let chars: Vec<char> = text.chars().collect();
27    let mut i = 0usize;
28
29    let flush = |parts: &mut Vec<LinePart>, buf: &mut String| {
30        if !buf.is_empty() {
31            parts.push(LinePart {
32                text: buf.clone(),
33                token: None,
34            });
35            buf.clear();
36        }
37    };
38
39    while i < chars.len() {
40        let ch = chars[i];
41        if ch == '\\' && i + 1 < chars.len() {
42            buf.push(chars[i + 1]);
43            i += 2;
44            continue;
45        }
46
47        if ch == '`' {
48            let fence = if i + 1 < chars.len() && chars[i + 1] == '`' {
49                2
50            } else {
51                1
52            };
53            let mut end = i + fence;
54            while end + fence - 1 < chars.len() {
55                if chars[end..end + fence].iter().all(|c| *c == '`') {
56                    flush(&mut parts, &mut buf);
57                    let content: String = chars[i + fence..end].iter().collect();
58                    parts.push(LinePart {
59                        text: content,
60                        token: Some(StyleToken::Key),
61                    });
62                    i = end + fence;
63                    break;
64                }
65                end += 1;
66            }
67            if end + fence - 1 < chars.len() {
68                continue;
69            }
70        }
71
72        if ch == '*' && i + 1 < chars.len() && chars[i + 1] == '*' {
73            let mut end = i + 2;
74            while end + 1 < chars.len() {
75                if chars[end] == '*' && chars[end + 1] == '*' {
76                    flush(&mut parts, &mut buf);
77                    let content: String = chars[i + 2..end].iter().collect();
78                    parts.push(LinePart {
79                        text: content,
80                        token: Some(StyleToken::PanelBorder),
81                    });
82                    i = end + 2;
83                    break;
84                }
85                end += 1;
86            }
87            if end + 1 < chars.len() {
88                continue;
89            }
90        }
91
92        if ch == '*' {
93            let mut end = i + 1;
94            while end < chars.len() {
95                if chars[end] == '*' {
96                    flush(&mut parts, &mut buf);
97                    let content: String = chars[i + 1..end].iter().collect();
98                    parts.push(LinePart {
99                        text: content,
100                        token: Some(StyleToken::Muted),
101                    });
102                    i = end + 1;
103                    break;
104                }
105                end += 1;
106            }
107            if end < chars.len() {
108                continue;
109            }
110        }
111
112        buf.push(ch);
113        i += 1;
114    }
115
116    flush(&mut parts, &mut buf);
117    parts
118}
119
120/// Parses inline markup and wraps the result in a single [`LineBlock`].
121///
122/// This keeps the block model aligned with the richer document renderer while
123/// still letting callers use the lightweight inline grammar.
124///
125/// # Examples
126///
127/// ```
128/// use osp_cli::ui::{StyleToken, line_from_inline};
129///
130/// let line = line_from_inline("`uid`");
131///
132/// assert_eq!(line.parts.len(), 1);
133/// assert_eq!(line.parts[0].token, Some(StyleToken::Key));
134/// assert_eq!(line.parts[0].text, "uid");
135/// ```
136pub fn line_from_inline(text: &str) -> LineBlock {
137    LineBlock {
138        parts: parts_from_inline(text),
139    }
140}
141
142/// Renders lightweight inline markup to a styled string.
143///
144/// Returns plain text when `color` is `false` so callers can reuse the same
145/// content path for terminals, copy buffers, and test assertions.
146///
147/// # Examples
148///
149/// ```
150/// use osp_cli::ui::{StyleOverrides, render_inline, resolve_theme};
151///
152/// let rendered = render_inline(
153///     "Use `uid`",
154///     false,
155///     &resolve_theme("dracula"),
156///     &StyleOverrides::default(),
157/// );
158///
159/// assert_eq!(rendered, "Use uid");
160/// ```
161pub fn render_inline(
162    text: &str,
163    color: bool,
164    theme: &ThemeDefinition,
165    overrides: &StyleOverrides,
166) -> String {
167    let mut out = String::new();
168    for part in parts_from_inline(text) {
169        if let Some(token) = part.token {
170            out.push_str(&apply_style_with_theme_overrides(
171                &part.text, token, color, theme, overrides,
172            ));
173        } else {
174            out.push_str(&part.text);
175        }
176    }
177    out
178}
179
180#[cfg(test)]
181mod tests;