Skip to content

Commit ee48694

Browse files
stephenleoclaude
andauthored
Story 7.6: Starship-compatible format field (#56) (#63)
* Story 7.6: Starship-compatible format field with $line_break and styled spans (#56) Add `format` field to CshipConfig supporting Starship-style format strings with `$line_break` for multi-row output and `[content](style)` ANSI spans. Fix parse_line to scan for both `[` and `$` simultaneously, preventing styled spans after literal text from being silently eaten as plain text. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix: stop $token name parser at '[' to prevent absorbing adjacent styled spans Without this, $cship.model[sep](fg:red) (no space) would consume the entire [sep](fg:red) as part of the token name, silently dropping the styled span. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1bbdbe4 commit ee48694

File tree

5 files changed

+245
-23
lines changed

5 files changed

+245
-23
lines changed

src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ pub struct CshipConfig {
66
/// `lines` array — each element is a format string for one statusline row.
77
/// Example: `["$cship.model $git_branch", "$cship.cost"]`
88
pub lines: Option<Vec<String>>,
9+
/// Starship-compatible top-level format string. Split on `$line_break` to produce
10+
/// multiple rows. Takes priority over `lines` when both are set.
11+
pub format: Option<String>,
912
/// Configuration for the `[cship.model]` section.
1013
pub model: Option<ModelConfig>,
1114
pub cost: Option<CostConfig>,

src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ fn main() {
6969
// Render and emit — main.rs is the SOLE owner of stdout.
7070
// println! is the ONLY stdout write in the rendering pipeline.
7171
let lines = cfg.lines.as_deref().unwrap_or(&[]);
72-
if !lines.is_empty() {
72+
if cfg.format.is_some() || !lines.is_empty() {
7373
let output = cship::renderer::render(lines, &ctx, &cfg);
7474
if !output.is_empty() {
7575
println!("{output}");

src/renderer.rs

Lines changed: 216 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ enum Token {
55
Native(String),
66
Passthrough(String),
77
Literal(String), // bare text preserved verbatim
8+
StyledSpan { content: String, style: String },
89
}
910

1011
fn parse_line(line: &str) -> Vec<Token> {
@@ -14,31 +15,78 @@ fn parse_line(line: &str) -> Vec<Token> {
1415
while pos < line.len() {
1516
let remaining = &line[pos..];
1617

17-
if let Some(dollar_pos) = remaining.find('$') {
18-
// Text before the '$' token is a literal (if non-empty)
19-
if dollar_pos > 0 {
20-
tokens.push(Token::Literal(remaining[..dollar_pos].to_string()));
18+
// Check for styled span: [content](style) — only when '[' is at current position
19+
if let Some(after_bracket) = remaining.strip_prefix('[') {
20+
if let Some(close_bracket_offset) = after_bracket.find("](") {
21+
let content = &after_bracket[..close_bracket_offset];
22+
let after_open_paren = &after_bracket[close_bracket_offset + 2..]; // skip "]("
23+
if let Some(close_paren) = after_open_paren.find(')') {
24+
let style = &after_open_paren[..close_paren];
25+
tokens.push(Token::StyledSpan {
26+
content: content.to_string(),
27+
style: style.to_string(),
28+
});
29+
// advance: 1 ('[') + close_bracket_offset + 2 ('](') + close_paren + 1 (')')
30+
pos += 1 + close_bracket_offset + 2 + close_paren + 1;
31+
continue;
32+
}
33+
}
34+
// No matching ](...) found — emit literal "[" and advance one char
35+
tokens.push(Token::Literal("[".to_string()));
36+
pos += 1;
37+
continue;
38+
}
39+
40+
// Find next special character: '$' or '[' — process whichever comes first
41+
let next_dollar = remaining.find('$');
42+
let next_bracket = remaining.find('[');
43+
let next_pos = match (next_dollar, next_bracket) {
44+
(Some(d), Some(b)) => Some(d.min(b)),
45+
(Some(d), None) => Some(d),
46+
(None, Some(b)) => Some(b),
47+
(None, None) => None,
48+
};
49+
50+
match next_pos {
51+
Some(special_pos) if special_pos > 0 => {
52+
// Literal text before the next special character
53+
tokens.push(Token::Literal(remaining[..special_pos].to_string()));
54+
pos += special_pos;
55+
// Re-enter loop — next iteration handles '[' or '$' at position 0
2156
}
22-
// Read the token name (from after '$' to next whitespace or end)
23-
let after_dollar = &remaining[dollar_pos + 1..];
24-
let name_end = after_dollar
25-
.find(char::is_whitespace)
26-
.unwrap_or(after_dollar.len());
27-
let name = &after_dollar[..name_end];
28-
if !name.is_empty() {
29-
if name.starts_with("cship.") {
30-
tokens.push(Token::Native(name.to_string()));
31-
} else {
32-
tokens.push(Token::Passthrough(name.to_string()));
57+
Some(_) => {
58+
// special_pos == 0 and not '[' (handled above) → must be '$'
59+
let after_dollar = &remaining[1..];
60+
let name_end = after_dollar
61+
.find(|c: char| c.is_whitespace() || c == '[')
62+
.unwrap_or(after_dollar.len());
63+
let name = &after_dollar[..name_end];
64+
if !name.is_empty() {
65+
if name == "fill" {
66+
// $fill is deferred (future $cship.flex feature) — emit empty, warn once
67+
static FILL_WARNED: std::sync::atomic::AtomicBool =
68+
std::sync::atomic::AtomicBool::new(false);
69+
if !FILL_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
70+
tracing::warn!(
71+
"cship: $fill is not yet supported (deferred to $cship.flex); rendering as empty"
72+
);
73+
}
74+
tokens.push(Token::Literal(String::new()));
75+
} else if name.starts_with("cship.") {
76+
tokens.push(Token::Native(name.to_string()));
77+
} else {
78+
tokens.push(Token::Passthrough(name.to_string()));
79+
}
3380
}
81+
pos += 1 + name_end;
3482
}
35-
pos += dollar_pos + 1 + name_end;
36-
} else {
37-
// No more '$' — remainder is all literal text
38-
if !remaining.is_empty() {
39-
tokens.push(Token::Literal(remaining.to_string()));
83+
None => {
84+
// No more special characters — remainder is all literal text
85+
if !remaining.is_empty() {
86+
tokens.push(Token::Literal(remaining.to_string()));
87+
}
88+
break;
4089
}
41-
break;
4290
}
4391
}
4492

@@ -63,14 +111,32 @@ fn render_line(line: &str, ctx: &Context, cfg: &CshipConfig) -> String {
63111
Token::Literal(text) => {
64112
parts.push(text);
65113
}
114+
Token::StyledSpan { content, style } => {
115+
parts.push(crate::ansi::apply_style(&content, Some(&style)));
116+
}
66117
}
67118
}
68119

69120
parts.join("") // No separator — spacing is encoded in Literal tokens
70121
}
71122

72123
pub fn render(lines: &[String], ctx: &Context, cfg: &CshipConfig) -> String {
73-
lines
124+
// cfg.format takes priority over lines; split on "$line_break" to produce rows
125+
let owned_lines: Vec<String>;
126+
let effective_lines: &[String] = if let Some(format_str) = &cfg.format {
127+
if !lines.is_empty() {
128+
tracing::warn!("cship: format field is set — ignoring lines config");
129+
}
130+
owned_lines = format_str
131+
.split("$line_break")
132+
.map(|s| s.to_string())
133+
.collect();
134+
&owned_lines
135+
} else {
136+
lines
137+
};
138+
139+
effective_lines
74140
.iter()
75141
.map(|line| render_line(line, ctx, cfg))
76142
.filter(|line| !line.is_empty())
@@ -114,6 +180,90 @@ mod tests {
114180
);
115181
}
116182

183+
#[test]
184+
fn test_parse_line_styled_span_with_content() {
185+
let tokens = parse_line("[text](bold green)");
186+
assert_eq!(tokens.len(), 1);
187+
assert!(
188+
matches!(&tokens[0], Token::StyledSpan { content, style } if content == "text" && style == "bold green")
189+
);
190+
}
191+
192+
#[test]
193+
fn test_parse_line_empty_styled_span() {
194+
let tokens = parse_line("[](fg:#3d414a bg:#5f6366)");
195+
assert_eq!(tokens.len(), 1);
196+
assert!(
197+
matches!(&tokens[0], Token::StyledSpan { content, style } if content.is_empty() && style == "fg:#3d414a bg:#5f6366")
198+
);
199+
}
200+
201+
#[test]
202+
fn test_parse_line_unclosed_bracket_literal() {
203+
let tokens = parse_line("[note:");
204+
// "[" emitted as Literal, then "note:" as Literal
205+
assert_eq!(tokens.len(), 2);
206+
assert!(matches!(&tokens[0], Token::Literal(t) if t == "["));
207+
assert!(matches!(&tokens[1], Token::Literal(t) if t == "note:"));
208+
}
209+
210+
#[test]
211+
fn test_parse_line_mixed_span_and_native() {
212+
let tokens = parse_line("[bold](bold) $cship.model");
213+
assert_eq!(tokens.len(), 3);
214+
assert!(
215+
matches!(&tokens[0], Token::StyledSpan { content, style } if content == "bold" && style == "bold")
216+
);
217+
assert!(matches!(&tokens[1], Token::Literal(t) if t == " "));
218+
assert!(matches!(&tokens[2], Token::Native(n) if n == "cship.model"));
219+
}
220+
221+
#[test]
222+
fn test_parse_line_spaces_styled_span() {
223+
// AC6: two spaces with foreground color
224+
let tokens = parse_line("[ ](fg:#ffc878)");
225+
assert_eq!(tokens.len(), 1);
226+
assert!(
227+
matches!(&tokens[0], Token::StyledSpan { content, style } if content == " " && style == "fg:#ffc878")
228+
);
229+
}
230+
231+
#[test]
232+
fn test_parse_line_styled_span_after_literal_text() {
233+
// Regression: styled spans preceded by literal text must still be parsed
234+
let tokens = parse_line("prefix [text](bold green)");
235+
assert_eq!(tokens.len(), 2);
236+
assert!(matches!(&tokens[0], Token::Literal(t) if t == "prefix "));
237+
assert!(
238+
matches!(&tokens[1], Token::StyledSpan { content, style } if content == "text" && style == "bold green")
239+
);
240+
}
241+
242+
#[test]
243+
fn test_parse_line_styled_span_adjacent_to_token_no_space() {
244+
// Regression: $token[span](style) without space must not absorb '[' into token name
245+
let tokens = parse_line("$cship.model[sep](fg:red)");
246+
assert_eq!(tokens.len(), 2);
247+
assert!(matches!(&tokens[0], Token::Native(n) if n == "cship.model"));
248+
assert!(
249+
matches!(&tokens[1], Token::StyledSpan { content, style } if content == "sep" && style == "fg:red")
250+
);
251+
}
252+
253+
#[test]
254+
fn test_parse_line_styled_span_after_native_token() {
255+
// Regression: styled span after $token must be parsed (not eaten as literal)
256+
let tokens = parse_line("$cship.model [sep](fg:red) $cship.cost");
257+
assert_eq!(tokens.len(), 5);
258+
assert!(matches!(&tokens[0], Token::Native(n) if n == "cship.model"));
259+
assert!(matches!(&tokens[1], Token::Literal(t) if t == " "));
260+
assert!(
261+
matches!(&tokens[2], Token::StyledSpan { content, style } if content == "sep" && style == "fg:red")
262+
);
263+
assert!(matches!(&tokens[3], Token::Literal(t) if t == " "));
264+
assert!(matches!(&tokens[4], Token::Native(n) if n == "cship.cost"));
265+
}
266+
117267
#[test]
118268
fn test_render_empty_lines_is_empty() {
119269
let ctx = Context::default();
@@ -148,4 +298,48 @@ mod tests {
148298
let result = render_line("in: $cship.context_window.total_input_tokens", &ctx, &cfg);
149299
assert_eq!(result, "in: 15234");
150300
}
301+
302+
#[test]
303+
fn test_render_format_field_line_break() {
304+
let ctx = Context::default();
305+
let cfg = CshipConfig {
306+
format: Some("line1$line_breakline2".to_string()),
307+
..Default::default()
308+
};
309+
let result = render(&[], &ctx, &cfg);
310+
assert_eq!(result, "line1\nline2");
311+
}
312+
313+
#[test]
314+
fn test_render_format_takes_priority_over_lines() {
315+
let ctx = Context::default();
316+
let cfg = CshipConfig {
317+
format: Some("from_format".to_string()),
318+
lines: Some(vec!["from_lines".to_string()]),
319+
..Default::default()
320+
};
321+
let lines = cfg.lines.as_deref().unwrap_or(&[]);
322+
let result = render(lines, &ctx, &cfg);
323+
assert_eq!(result, "from_format");
324+
}
325+
326+
#[test]
327+
fn test_render_lines_unchanged_when_no_format() {
328+
let ctx = Context::default();
329+
let cfg = CshipConfig {
330+
lines: Some(vec!["hello".to_string()]),
331+
..Default::default()
332+
};
333+
let lines = cfg.lines.as_deref().unwrap_or(&[]);
334+
let result = render(lines, &ctx, &cfg);
335+
assert_eq!(result, "hello");
336+
}
337+
338+
#[test]
339+
fn test_render_fill_token_renders_empty() {
340+
let ctx = Context::default();
341+
let cfg = CshipConfig::default();
342+
let result = render_line("$fill", &ctx, &cfg);
343+
assert_eq!(result, "");
344+
}
151345
}

tests/cli.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,3 +966,26 @@ fn test_literal_text_in_lines_preserved() {
966966
.success()
967967
.stdout(predicate::str::contains("in: 15234"));
968968
}
969+
970+
// ── Story 7.6: Starship-compatible format field integration tests ──────────
971+
972+
#[test]
973+
fn test_format_field_line_break_produces_two_rows() {
974+
let json = std::fs::read_to_string("tests/fixtures/sample_input_full.json").unwrap();
975+
// format_line_break.toml: format = "$cship.model$line_break$cship.model"
976+
let output = cship()
977+
.args(["--config", "tests/fixtures/format_line_break.toml"])
978+
.write_stdin(json)
979+
.output()
980+
.unwrap();
981+
assert!(output.status.success());
982+
let stdout = String::from_utf8_lossy(&output.stdout);
983+
let lines: Vec<&str> = stdout.trim_end_matches('\n').split('\n').collect();
984+
assert_eq!(
985+
lines.len(),
986+
2,
987+
"expected 2 lines from format with $line_break; got: {stdout:?}"
988+
);
989+
assert!(lines[0].contains("Opus"), "line 0: {}", lines[0]);
990+
assert!(lines[1].contains("Opus"), "line 1: {}", lines[1]);
991+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[cship]
2+
format = "$cship.model$line_break$cship.model"

0 commit comments

Comments
 (0)