22//!
33//! Parses bash scripts and outputs JSON AST.
44
5+ use bash_ast:: server:: { default_socket_path, Server } ;
56use bash_ast:: { init, parse_to_json, schema_json, to_bash, Command } ;
67use std:: env;
78use std:: fs;
@@ -10,11 +11,12 @@ use std::process::ExitCode;
1011
1112const VERSION : & str = env ! ( "CARGO_PKG_VERSION" ) ;
1213
13- const HELP : & str = r"bash-ast - Parse bash scripts to JSON AST
14+ const HELP : & str = r# "bash-ast - Parse bash scripts to JSON AST
1415
1516USAGE:
1617 bash-ast [OPTIONS] [FILE]
1718 command | bash-ast [OPTIONS]
19+ bash-ast --server [SOCKET_PATH]
1820
1921DESCRIPTION:
2022 Parses bash scripts using GNU Bash's actual parser (via FFI) and outputs
@@ -26,11 +28,12 @@ ARGUMENTS:
2628 Use '-' to read from stdin explicitly.
2729
2830OPTIONS:
29- -h, --help Print this help message and exit
30- -V, --version Print version information and exit
31- -c, --compact Output compact JSON (default: pretty-printed)
32- -s, --schema Print JSON Schema for the AST and exit
33- -b, --to-bash Convert JSON AST back to bash script
31+ -h, --help Print this help message and exit
32+ -V, --version Print version information and exit
33+ -c, --compact Output compact JSON (default: pretty-printed)
34+ -s, --schema Print JSON Schema for the AST and exit
35+ -b, --to-bash Convert JSON AST back to bash script
36+ -S, --server [PATH] Start Unix socket server (default: $XDG_RUNTIME_DIR/bash-ast.sock)
3437
3538EXAMPLES:
3639 # Parse a script file
@@ -54,6 +57,24 @@ EXAMPLES:
5457 # Convert JSON AST back to bash
5558 bash-ast script.sh | bash-ast --to-bash
5659
60+ # Start server mode (low-latency Unix socket)
61+ bash-ast --server
62+ bash-ast --server /tmp/my-bash-ast.sock
63+
64+ SERVER MODE:
65+ In server mode, bash-ast listens on a Unix socket for NDJSON requests.
66+ Each request/response is a single line of JSON.
67+
68+ Methods:
69+ {"method":"parse","script":"echo hello"} Parse bash to AST
70+ {"method":"to_bash","ast":{...}} Convert AST to bash
71+ {"method":"schema"} Get JSON Schema
72+ {"method":"ping"} Health check
73+ {"method":"shutdown"} Stop the server
74+
75+ Example client (bash):
76+ echo '{"method":"parse","script":"echo hi"}' | nc -U /tmp/bash-ast.sock
77+
5778OUTPUT:
5879 On success, prints JSON AST to stdout and exits with code 0.
5980 On error, prints error message to stderr and exits with code 1.
@@ -86,7 +107,7 @@ SUPPORTED CONSTRUCTS:
86107MORE INFO:
87108 Repository: https://github.com/cv/bash-ast
88109 License: GPL-3.0 (due to linkage with GNU Bash)
89- " ;
110+ "# ;
90111
91112fn main ( ) -> ExitCode {
92113 let args: Vec < String > = env:: args ( ) . collect ( ) ;
@@ -103,20 +124,32 @@ struct Config {
103124 compact : bool ,
104125 schema : bool ,
105126 to_bash : bool ,
127+ server : bool ,
128+ socket_path : Option < String > ,
106129 file : Option < String > ,
107130}
108131
109132fn parse_args ( args : & [ String ] ) -> Result < Config , String > {
110133 let mut config = Config :: default ( ) ;
111134 let mut positional = Vec :: new ( ) ;
135+ let mut args_iter = args. iter ( ) . peekable ( ) ;
112136
113- for arg in args {
137+ while let Some ( arg) = args_iter . next ( ) {
114138 match arg. as_str ( ) {
115139 "-h" | "--help" => config. help = true ,
116140 "-V" | "--version" => config. version = true ,
117141 "-c" | "--compact" => config. compact = true ,
118142 "-s" | "--schema" => config. schema = true ,
119143 "-b" | "--to-bash" => config. to_bash = true ,
144+ "-S" | "--server" => {
145+ config. server = true ;
146+ // Check if next arg is a socket path (not another option)
147+ if let Some ( next) = args_iter. peek ( ) {
148+ if !next. starts_with ( '-' ) {
149+ config. socket_path = Some ( args_iter. next ( ) . unwrap ( ) . clone ( ) ) ;
150+ }
151+ }
152+ }
120153 "-" => positional. push ( arg. clone ( ) ) , // `-` means read from stdin
121154 s if s. starts_with ( '-' ) => {
122155 return Err ( format ! (
@@ -127,6 +160,13 @@ fn parse_args(args: &[String]) -> Result<Config, String> {
127160 }
128161 }
129162
163+ if config. server && !positional. is_empty ( ) {
164+ return Err (
165+ "Cannot specify file when using --server mode.\n Try 'bash-ast --help' for usage."
166+ . to_string ( ) ,
167+ ) ;
168+ }
169+
130170 if positional. len ( ) > 1 {
131171 return Err (
132172 "Too many arguments. Expected at most one file.\n Try 'bash-ast --help' for usage."
@@ -181,6 +221,17 @@ where
181221 return ExitCode :: SUCCESS ;
182222 }
183223
224+ // Handle --server
225+ if config. server {
226+ let socket_path = config. socket_path . unwrap_or_else ( default_socket_path) ;
227+ let server = Server :: with_path ( & socket_path) ;
228+ if let Err ( e) = server. run ( ) {
229+ let _ = writeln ! ( error, "Server error: {e}" ) ;
230+ return ExitCode :: from ( 1 ) ;
231+ }
232+ return ExitCode :: SUCCESS ;
233+ }
234+
184235 // Read content from file or stdin (use "-" to explicitly read from stdin)
185236 let content = match config. file . as_deref ( ) {
186237 Some ( "-" ) | None => {
@@ -457,4 +508,78 @@ mod tests {
457508 assert ! ( t. success( ) ) ;
458509 assert ! ( t. stdout. contains( "for i in a b c; do echo $i; done" ) ) ;
459510 }
511+
512+ // ==================== Server Option Tests ====================
513+
514+ #[ test]
515+ fn test_help_shows_server_option ( ) {
516+ // Help text should mention --server option
517+ let t = TestRun :: new ( & [ "--help" ] , "" ) ;
518+ assert ! ( t. success( ) ) ;
519+ assert ! ( t. stdout. contains( "--server" ) ) ;
520+ assert ! ( t. stdout. contains( "-S" ) ) ;
521+ assert ! ( t. stdout. contains( "SERVER MODE" ) ) ;
522+ assert ! ( t. stdout. contains( "Unix socket" ) ) ;
523+ }
524+
525+ #[ test]
526+ fn test_parse_args_server_default_path ( ) {
527+ // --server without path should use default
528+ let args: Vec < String > = vec ! [ "--server" . to_string( ) ] ;
529+ let config = parse_args ( & args) . unwrap ( ) ;
530+ assert ! ( config. server) ;
531+ assert ! ( config. socket_path. is_none( ) ) ;
532+ }
533+
534+ #[ test]
535+ fn test_parse_args_server_with_path ( ) {
536+ // --server with path should capture the path
537+ let args: Vec < String > = vec ! [ "--server" . to_string( ) , "/tmp/my.sock" . to_string( ) ] ;
538+ let config = parse_args ( & args) . unwrap ( ) ;
539+ assert ! ( config. server) ;
540+ assert_eq ! ( config. socket_path, Some ( "/tmp/my.sock" . to_string( ) ) ) ;
541+ }
542+
543+ #[ test]
544+ fn test_parse_args_server_short ( ) {
545+ // -S should work same as --server
546+ let args: Vec < String > = vec ! [ "-S" . to_string( ) ] ;
547+ let config = parse_args ( & args) . unwrap ( ) ;
548+ assert ! ( config. server) ;
549+ assert ! ( config. socket_path. is_none( ) ) ;
550+ }
551+
552+ #[ test]
553+ fn test_parse_args_server_short_with_path ( ) {
554+ // -S with path should capture the path
555+ let args: Vec < String > = vec ! [ "-S" . to_string( ) , "/custom/path.sock" . to_string( ) ] ;
556+ let config = parse_args ( & args) . unwrap ( ) ;
557+ assert ! ( config. server) ;
558+ assert_eq ! ( config. socket_path, Some ( "/custom/path.sock" . to_string( ) ) ) ;
559+ }
560+
561+ #[ test]
562+ fn test_parse_args_server_ignores_options_as_path ( ) {
563+ // --server followed by another option should not consume it as path
564+ let args: Vec < String > = vec ! [ "--server" . to_string( ) , "--compact" . to_string( ) ] ;
565+ let config = parse_args ( & args) . unwrap ( ) ;
566+ assert ! ( config. server) ;
567+ assert ! ( config. socket_path. is_none( ) ) ;
568+ assert ! ( config. compact) ;
569+ }
570+
571+ #[ test]
572+ fn test_parse_args_server_with_positional_error ( ) {
573+ // --server mode followed by a positional after another option should error
574+ // e.g., --server --compact script.sh
575+ let args: Vec < String > = vec ! [
576+ "--server" . to_string( ) ,
577+ "--compact" . to_string( ) ,
578+ "script.sh" . to_string( ) ,
579+ ] ;
580+ let result = parse_args ( & args) ;
581+ assert ! ( result. is_err( ) ) ;
582+ let err = result. unwrap_err ( ) ;
583+ assert ! ( err. contains( "Cannot specify file" ) ) ;
584+ }
460585}
0 commit comments