@@ -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
1011fn 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
72123pub 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\n line2" ) ;
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}
0 commit comments