1
- use codex_core :: bash :: try_parse_bash ;
1
+ use ratatui :: style :: Style ;
2
2
use ratatui:: style:: Stylize ;
3
3
use ratatui:: text:: Line ;
4
4
use ratatui:: text:: Span ;
5
+ use std:: sync:: OnceLock ;
6
+ use tree_sitter_highlight:: Highlight ;
7
+ use tree_sitter_highlight:: HighlightConfiguration ;
8
+ use tree_sitter_highlight:: HighlightEvent ;
9
+ use tree_sitter_highlight:: Highlighter ;
5
10
6
- /// Convert the full bash script into per-line styled content by first
7
- /// computing operator-dimmed spans across the entire script, then splitting
8
- /// by newlines and dimming heredoc body lines. Performs a single parse and
9
- /// reuses it for both highlighting and heredoc detection.
10
- pub ( crate ) fn highlight_bash_to_lines ( script : & str ) -> Vec < Line < ' static > > {
11
- // Parse once; use the tree for both highlighting and heredoc body detection.
12
- let spans: Vec < Span < ' static > > = if let Some ( tree) = try_parse_bash ( script) {
13
- // Single walk: collect operator ranges and heredoc rows.
14
- let root = tree. root_node ( ) ;
15
- let mut cursor = root. walk ( ) ;
16
- let mut stack = vec ! [ root] ;
17
- let mut ranges: Vec < ( usize , usize ) > = Vec :: new ( ) ;
18
- while let Some ( node) = stack. pop ( ) {
19
- if !node. is_named ( ) && !node. is_extra ( ) {
20
- let kind = node. kind ( ) ;
21
- let is_quote = matches ! ( kind, "\" " | "'" | "`" ) ;
22
- let is_whitespace = kind. trim ( ) . is_empty ( ) ;
23
- if !is_quote && !is_whitespace {
24
- ranges. push ( ( node. start_byte ( ) , node. end_byte ( ) ) ) ;
25
- }
26
- } else if node. kind ( ) == "heredoc_body" {
27
- ranges. push ( ( node. start_byte ( ) , node. end_byte ( ) ) ) ;
28
- }
29
- for child in node. children ( & mut cursor) {
30
- stack. push ( child) ;
31
- }
11
+ // Ref: https://github.com/tree-sitter/tree-sitter-bash/blob/master/queries/highlights.scm
12
+ #[ derive( Copy , Clone ) ]
13
+ enum BashHighlight {
14
+ Comment ,
15
+ Constant ,
16
+ Embedded ,
17
+ Function ,
18
+ Keyword ,
19
+ Number ,
20
+ Operator ,
21
+ Property ,
22
+ String ,
23
+ }
24
+
25
+ impl BashHighlight {
26
+ const ALL : [ Self ; 9 ] = [
27
+ Self :: Comment ,
28
+ Self :: Constant ,
29
+ Self :: Embedded ,
30
+ Self :: Function ,
31
+ Self :: Keyword ,
32
+ Self :: Number ,
33
+ Self :: Operator ,
34
+ Self :: Property ,
35
+ Self :: String ,
36
+ ] ;
37
+
38
+ const fn as_str ( self ) -> & ' static str {
39
+ match self {
40
+ Self :: Comment => "comment" ,
41
+ Self :: Constant => "constant" ,
42
+ Self :: Embedded => "embedded" ,
43
+ Self :: Function => "function" ,
44
+ Self :: Keyword => "keyword" ,
45
+ Self :: Number => "number" ,
46
+ Self :: Operator => "operator" ,
47
+ Self :: Property => "property" ,
48
+ Self :: String => "string" ,
32
49
}
33
- if ranges. is_empty ( ) {
34
- ranges. push ( ( script. len ( ) , script. len ( ) ) ) ;
50
+ }
51
+
52
+ fn style ( self ) -> Style {
53
+ match self {
54
+ Self :: Comment | Self :: Operator | Self :: String => Style :: default ( ) . dim ( ) ,
55
+ _ => Style :: default ( ) ,
35
56
}
36
- ranges. sort_by_key ( |( st, _) | * st) ;
37
- let mut spans: Vec < Span < ' static > > = Vec :: new ( ) ;
38
- let mut i = 0usize ;
39
- for ( start, end) in ranges. into_iter ( ) {
40
- let dim_start = start. max ( i) ;
41
- let dim_end = end;
42
- if dim_start < dim_end {
43
- if dim_start > i {
44
- spans. push ( script[ i..dim_start] . to_string ( ) . into ( ) ) ;
45
- }
46
- spans. push ( script[ dim_start..dim_end] . to_string ( ) . dim ( ) ) ;
47
- i = dim_end;
48
- }
57
+ }
58
+ }
59
+
60
+ static HIGHLIGHT_CONFIG : OnceLock < HighlightConfiguration > = OnceLock :: new ( ) ;
61
+
62
+ fn highlight_names ( ) -> & ' static [ & ' static str ] {
63
+ static NAMES : OnceLock < [ & ' static str ; BashHighlight :: ALL . len ( ) ] > = OnceLock :: new ( ) ;
64
+ NAMES
65
+ . get_or_init ( || BashHighlight :: ALL . map ( BashHighlight :: as_str) )
66
+ . as_slice ( )
67
+ }
68
+
69
+ fn highlight_config ( ) -> & ' static HighlightConfiguration {
70
+ HIGHLIGHT_CONFIG . get_or_init ( || {
71
+ let language = tree_sitter_bash:: LANGUAGE . into ( ) ;
72
+ #[ expect( clippy:: expect_used) ]
73
+ let mut config = HighlightConfiguration :: new (
74
+ language,
75
+ "bash" ,
76
+ tree_sitter_bash:: HIGHLIGHT_QUERY ,
77
+ "" ,
78
+ "" ,
79
+ )
80
+ . expect ( "load bash highlight query" ) ;
81
+ config. configure ( highlight_names ( ) ) ;
82
+ config
83
+ } )
84
+ }
85
+
86
+ fn highlight_for ( highlight : Highlight ) -> BashHighlight {
87
+ BashHighlight :: ALL [ highlight. 0 ]
88
+ }
89
+
90
+ fn push_segment ( lines : & mut Vec < Line < ' static > > , segment : & str , style : Option < Style > ) {
91
+ for ( i, part) in segment. split ( '\n' ) . enumerate ( ) {
92
+ if i > 0 {
93
+ lines. push ( Line :: from ( "" ) ) ;
49
94
}
50
- if i < script . len ( ) {
51
- spans . push ( script [ i.. ] . to_string ( ) . into ( ) ) ;
95
+ if part . is_empty ( ) {
96
+ continue ;
52
97
}
53
- spans
54
- } else {
55
- vec ! [ script. to_string( ) . into( ) ]
56
- } ;
57
- // Split spans into lines preserving style boundaries and highlights across newlines.
98
+ let span = match style {
99
+ Some ( style) => Span :: styled ( part. to_string ( ) , style) ,
100
+ None => part. to_string ( ) . into ( ) ,
101
+ } ;
102
+ if let Some ( last) = lines. last_mut ( ) {
103
+ last. spans . push ( span) ;
104
+ }
105
+ }
106
+ }
107
+
108
+ /// Convert a bash script into per-line styled content using tree-sitter's
109
+ /// bash highlight query. The highlighter is streamed so multi-line content is
110
+ /// split into `Line`s while preserving style boundaries.
111
+ pub ( crate ) fn highlight_bash_to_lines ( script : & str ) -> Vec < Line < ' static > > {
112
+ let mut highlighter = Highlighter :: new ( ) ;
113
+ let iterator =
114
+ match highlighter. highlight ( highlight_config ( ) , script. as_bytes ( ) , None , |_| None ) {
115
+ Ok ( iter) => iter,
116
+ Err ( _) => return vec ! [ script. to_string( ) . into( ) ] ,
117
+ } ;
118
+
58
119
let mut lines: Vec < Line < ' static > > = vec ! [ Line :: from( "" ) ] ;
59
- for sp in spans {
60
- let style = sp. style ;
61
- let text = sp. content . into_owned ( ) ;
62
- for ( i, part) in text. split ( '\n' ) . enumerate ( ) {
63
- if i > 0 {
64
- lines. push ( Line :: from ( "" ) ) ;
65
- }
66
- if part. is_empty ( ) {
67
- continue ;
120
+ let mut highlight_stack: Vec < Highlight > = Vec :: new ( ) ;
121
+
122
+ for event in iterator {
123
+ match event {
124
+ Ok ( HighlightEvent :: HighlightStart ( highlight) ) => highlight_stack. push ( highlight) ,
125
+ Ok ( HighlightEvent :: HighlightEnd ) => {
126
+ highlight_stack. pop ( ) ;
68
127
}
69
- let span = Span {
70
- style ,
71
- content : std :: borrow :: Cow :: Owned ( part . to_string ( ) ) ,
72
- } ;
73
- if let Some ( last ) = lines . last_mut ( ) {
74
- last . spans . push ( span ) ;
128
+ Ok ( HighlightEvent :: Source { start , end } ) => {
129
+ if start == end {
130
+ continue ;
131
+ }
132
+ let style = highlight_stack . last ( ) . map ( |h| highlight_for ( * h ) . style ( ) ) ;
133
+ push_segment ( & mut lines , & script [ start..end ] , style ) ;
75
134
}
135
+ Err ( _) => return vec ! [ script. to_string( ) . into( ) ] ,
76
136
}
77
137
}
78
- lines
138
+
139
+ if lines. is_empty ( ) {
140
+ vec ! [ Line :: from( "" ) ]
141
+ } else {
142
+ lines
143
+ }
79
144
}
80
145
81
146
#[ cfg( test) ]
@@ -84,11 +149,8 @@ mod tests {
84
149
use pretty_assertions:: assert_eq;
85
150
use ratatui:: style:: Modifier ;
86
151
87
- #[ test]
88
- fn dims_expected_bash_operators ( ) {
89
- let s = "echo foo && bar || baz | qux & (echo hi)" ;
90
- let lines = highlight_bash_to_lines ( s) ;
91
- let reconstructed: String = lines
152
+ fn reconstructed ( lines : & [ Line < ' static > ] ) -> String {
153
+ lines
92
154
. iter ( )
93
155
. map ( |l| {
94
156
l. spans
@@ -97,49 +159,78 @@ mod tests {
97
159
. collect :: < String > ( )
98
160
} )
99
161
. collect :: < Vec < _ > > ( )
100
- . join ( "\n " ) ;
101
- assert_eq ! ( reconstructed , s ) ;
162
+ . join ( "\n " )
163
+ }
102
164
103
- fn is_dim ( span : & Span < ' _ > ) -> bool {
104
- span. style . add_modifier . contains ( Modifier :: DIM )
105
- }
106
- let dimmed: Vec < String > = lines
165
+ fn dimmed_tokens ( lines : & [ Line < ' static > ] ) -> Vec < String > {
166
+ lines
107
167
. iter ( )
108
168
. flat_map ( |l| l. spans . iter ( ) )
109
- . filter ( |sp| is_dim ( sp ) )
169
+ . filter ( |sp| sp . style . add_modifier . contains ( Modifier :: DIM ) )
110
170
. map ( |sp| sp. content . clone ( ) . into_owned ( ) )
111
- . collect ( ) ;
112
- assert_eq ! ( dimmed, vec![ "&&" , "||" , "|" , "&" , "(" , ")" ] ) ;
171
+ . map ( |token| token. trim ( ) . to_string ( ) )
172
+ . filter ( |token| !token. is_empty ( ) )
173
+ . collect ( )
174
+ }
175
+
176
+ #[ test]
177
+ fn dims_expected_bash_operators ( ) {
178
+ let s = "echo foo && bar || baz | qux & (echo hi)" ;
179
+ let lines = highlight_bash_to_lines ( s) ;
180
+ assert_eq ! ( reconstructed( & lines) , s) ;
181
+
182
+ let dimmed = dimmed_tokens ( & lines) ;
183
+ assert ! ( dimmed. contains( & "&&" . to_string( ) ) ) ;
184
+ assert ! ( dimmed. contains( & "|" . to_string( ) ) ) ;
185
+ assert ! ( !dimmed. contains( & "echo" . to_string( ) ) ) ;
113
186
}
114
187
115
188
#[ test]
116
- fn does_not_dim_quotes_but_dims_other_punct ( ) {
189
+ fn dims_redirects_and_strings ( ) {
117
190
let s = "echo \" hi\" > out.txt; echo 'ok'" ;
118
191
let lines = highlight_bash_to_lines ( s) ;
119
- let reconstructed: String = lines
120
- . iter ( )
121
- . map ( |l| {
122
- l. spans
123
- . iter ( )
124
- . map ( |sp| sp. content . clone ( ) )
125
- . collect :: < String > ( )
126
- } )
127
- . collect :: < Vec < _ > > ( )
128
- . join ( "\n " ) ;
129
- assert_eq ! ( reconstructed, s) ;
192
+ assert_eq ! ( reconstructed( & lines) , s) ;
130
193
131
- fn is_dim ( span : & Span < ' _ > ) -> bool {
132
- span. style . add_modifier . contains ( Modifier :: DIM )
133
- }
134
- let dimmed: Vec < String > = lines
135
- . iter ( )
136
- . flat_map ( |l| l. spans . iter ( ) )
137
- . filter ( |sp| is_dim ( sp) )
138
- . map ( |sp| sp. content . clone ( ) . into_owned ( ) )
139
- . collect ( ) ;
194
+ let dimmed = dimmed_tokens ( & lines) ;
140
195
assert ! ( dimmed. contains( & ">" . to_string( ) ) ) ;
141
- assert ! ( dimmed. contains( & ";" . to_string( ) ) ) ;
142
- assert ! ( !dimmed. contains( & "\" " . to_string( ) ) ) ;
143
- assert ! ( !dimmed. contains( & "'" . to_string( ) ) ) ;
196
+ assert ! ( dimmed. contains( & "\" hi\" " . to_string( ) ) ) ;
197
+ assert ! ( dimmed. contains( & "'ok'" . to_string( ) ) ) ;
198
+ }
199
+
200
+ #[ test]
201
+ fn highlights_command_and_strings ( ) {
202
+ let s = "echo \" hi\" " ;
203
+ let lines = highlight_bash_to_lines ( s) ;
204
+ let mut echo_style = None ;
205
+ let mut string_style = None ;
206
+ for span in & lines[ 0 ] . spans {
207
+ let text = span. content . as_ref ( ) ;
208
+ if text == "echo" {
209
+ echo_style = Some ( span. style ) ;
210
+ }
211
+ if text == "\" hi\" " {
212
+ string_style = Some ( span. style ) ;
213
+ }
214
+ }
215
+ let echo_style = echo_style. expect ( "echo span missing" ) ;
216
+ let string_style = string_style. expect ( "string span missing" ) ;
217
+ assert ! ( echo_style. fg. is_none( ) ) ;
218
+ assert ! ( !echo_style. add_modifier. contains( Modifier :: DIM ) ) ;
219
+ assert ! ( string_style. add_modifier. contains( Modifier :: DIM ) ) ;
220
+ }
221
+
222
+ #[ test]
223
+ fn highlights_heredoc_body_as_string ( ) {
224
+ let s = "cat <<EOF\n heredoc body\n EOF" ;
225
+ let lines = highlight_bash_to_lines ( s) ;
226
+ let body_line = & lines[ 1 ] ;
227
+ let mut body_style = None ;
228
+ for span in & body_line. spans {
229
+ if span. content . as_ref ( ) == "heredoc body" {
230
+ body_style = Some ( span. style ) ;
231
+ }
232
+ }
233
+ let body_style = body_style. expect ( "missing heredoc span" ) ;
234
+ assert ! ( body_style. add_modifier. contains( Modifier :: DIM ) ) ;
144
235
}
145
236
}
0 commit comments