55use bash_ast:: { init, parse_to_json, schema_json} ;
66use std:: env;
77use std:: fs;
8- use std:: io:: { self , BufRead , Write } ;
8+ use std:: io:: { self , BufRead , IsTerminal , Write } ;
99use std:: process:: ExitCode ;
1010
1111const VERSION : & str = env ! ( "CARGO_PKG_VERSION" ) ;
@@ -22,7 +22,7 @@ DESCRIPTION:
2222 compatibility with bash syntax.
2323
2424ARGUMENTS:
25- [FILE] Bash script file to parse. If omitted, reads from stdin.
25+ [FILE] Bash script file to parse. Use '-' to read from stdin explicitly .
2626
2727OPTIONS:
2828 -h, --help Print this help message and exit
@@ -34,12 +34,15 @@ EXAMPLES:
3434 # Parse a script file
3535 bash-ast script.sh
3636
37- # Parse from stdin
37+ # Parse from stdin (piped)
3838 echo 'echo hello' | bash-ast
3939
4040 # Parse inline with here-string
4141 bash-ast <<< 'for i in a b c; do echo $i; done'
4242
43+ # Read from stdin interactively (use '-' to wait for input)
44+ bash-ast -
45+
4346 # Compact output for piping
4447 bash-ast -c script.sh | jq '.commands[]'
4548
@@ -82,7 +85,9 @@ MORE INFO:
8285
8386fn main ( ) -> ExitCode {
8487 let args: Vec < String > = env:: args ( ) . collect ( ) ;
85- run ( & args[ 1 ..] , io:: stdin ( ) . lock ( ) , io:: stdout ( ) , io:: stderr ( ) )
88+ let stdin = io:: stdin ( ) ;
89+ let is_tty = stdin. is_terminal ( ) ;
90+ run ( & args[ 1 ..] , stdin. lock ( ) , io:: stdout ( ) , io:: stderr ( ) , is_tty)
8691}
8792
8893#[ derive( Debug , Default ) ]
@@ -105,6 +110,7 @@ fn parse_args(args: &[String]) -> Result<Config, String> {
105110 "-V" | "--version" => config. version = true ,
106111 "-c" | "--compact" => config. compact = true ,
107112 "-s" | "--schema" => config. schema = true ,
113+ "-" => positional. push ( arg. clone ( ) ) , // `-` means read from stdin
108114 s if s. starts_with ( '-' ) => {
109115 return Err ( format ! (
110116 "Unknown option: {s}\n Try 'bash-ast --help' for usage."
@@ -126,7 +132,13 @@ fn parse_args(args: &[String]) -> Result<Config, String> {
126132}
127133
128134/// Run the CLI with the given arguments and input/output streams
129- fn run < R , W , E > ( args : & [ String ] , mut input : R , mut output : W , mut error : E ) -> ExitCode
135+ fn run < R , W , E > (
136+ args : & [ String ] ,
137+ mut input : R ,
138+ mut output : W ,
139+ mut error : E ,
140+ stdin_is_tty : bool ,
141+ ) -> ExitCode
130142where
131143 R : BufRead ,
132144 W : Write ,
@@ -141,8 +153,10 @@ where
141153 }
142154 } ;
143155
144- // Handle --help
145- if config. help {
156+ // Handle --help, or show help if no file/stdin and stdin is a TTY (no piped input)
157+ // Users can use `-` to explicitly read from stdin even in a TTY
158+ let reading_stdin = config. file . as_deref ( ) == Some ( "-" ) ;
159+ if config. help || ( config. file . is_none ( ) && !config. schema && stdin_is_tty && !reading_stdin) {
146160 let _ = write ! ( output, "{HELP}" ) ;
147161 return ExitCode :: SUCCESS ;
148162 }
@@ -160,22 +174,23 @@ where
160174 return ExitCode :: SUCCESS ;
161175 }
162176
163- // Read script from file or stdin
164- let script = if let Some ( ref path) = config. file {
165- match fs:: read_to_string ( path) {
177+ // Read script from file or stdin (use "-" to explicitly read from stdin)
178+ let script = match config. file . as_deref ( ) {
179+ Some ( "-" ) | None => {
180+ let mut content = String :: new ( ) ;
181+ if let Err ( e) = input. read_to_string ( & mut content) {
182+ let _ = writeln ! ( error, "Error reading stdin: {e}" ) ;
183+ return ExitCode :: from ( 1 ) ;
184+ }
185+ content
186+ }
187+ Some ( path) => match fs:: read_to_string ( path) {
166188 Ok ( content) => content,
167189 Err ( e) => {
168190 let _ = writeln ! ( error, "Error reading '{path}': {e}" ) ;
169191 return ExitCode :: from ( 1 ) ;
170192 }
171- }
172- } else {
173- let mut content = String :: new ( ) ;
174- if let Err ( e) = input. read_to_string ( & mut content) {
175- let _ = writeln ! ( error, "Error reading stdin: {e}" ) ;
176- return ExitCode :: from ( 1 ) ;
177- }
178- content
193+ } ,
179194 } ;
180195
181196 // Initialize bash parser
@@ -208,14 +223,19 @@ mod tests {
208223 }
209224
210225 impl TestRun {
211- /// Run CLI with given args and stdin content
226+ /// Run CLI with given args and stdin content (simulates piped input)
212227 fn new ( cli_args : & [ & str ] , stdin : & str ) -> Self {
228+ Self :: with_tty ( cli_args, stdin, false )
229+ }
230+
231+ /// Run CLI with given args, stdin content, and TTY flag
232+ fn with_tty ( cli_args : & [ & str ] , stdin : & str , stdin_is_tty : bool ) -> Self {
213233 let input = Cursor :: new ( stdin. to_string ( ) ) ;
214234 let mut output = Vec :: new ( ) ;
215235 let mut error = Vec :: new ( ) ;
216236
217237 let args: Vec < String > = cli_args. iter ( ) . map ( |& s| s. to_string ( ) ) . collect ( ) ;
218- let exit_code = run ( & args, input, & mut output, & mut error) ;
238+ let exit_code = run ( & args, input, & mut output, & mut error, stdin_is_tty ) ;
219239
220240 Self {
221241 exit_code,
@@ -346,4 +366,39 @@ mod tests {
346366 assert ! ( t. stdout. contains( "\" type\" : \" for\" " ) ) ;
347367 assert ! ( t. stderr. is_empty( ) ) ;
348368 }
369+
370+ #[ test]
371+ fn test_no_args_tty_shows_help ( ) {
372+ // When stdin is a TTY and no args given, show help
373+ let t = TestRun :: with_tty ( & [ ] , "" , true ) ;
374+ assert ! ( t. success( ) ) ;
375+ assert ! ( t. stdout. contains( "USAGE:" ) ) ;
376+ assert ! ( t. stdout. contains( "bash-ast" ) ) ;
377+ assert ! ( t. stderr. is_empty( ) ) ;
378+ }
379+
380+ #[ test]
381+ fn test_no_args_piped_empty_is_error ( ) {
382+ // When stdin is piped (not TTY) but empty, it's an error
383+ let t = TestRun :: with_tty ( & [ ] , "" , false ) ;
384+ assert_eq ! ( t. exit_code, ExitCode :: from( 1 ) ) ;
385+ assert ! ( t. stderr. contains( "Error" ) ) ;
386+ }
387+
388+ #[ test]
389+ fn test_dash_reads_stdin_even_on_tty ( ) {
390+ // Using `-` explicitly reads from stdin, even if it's a TTY
391+ let t = TestRun :: with_tty ( & [ "-" ] , "echo hello" , true ) ;
392+ assert ! ( t. success( ) ) ;
393+ assert ! ( t. stdout. contains( "\" type\" : \" simple\" " ) ) ;
394+ assert ! ( t. stdout. contains( "echo" ) ) ;
395+ }
396+
397+ #[ test]
398+ fn test_dash_reads_stdin_piped ( ) {
399+ // Using `-` works the same as no arg when piped
400+ let t = TestRun :: new ( & [ "-" ] , "echo hello" ) ;
401+ assert ! ( t. success( ) ) ;
402+ assert ! ( t. stdout. contains( "\" type\" : \" simple\" " ) ) ;
403+ }
349404}
0 commit comments