Skip to content

Commit b636ae1

Browse files
committed
Add Unix socket server mode for low-latency IPC
- New --server / -S option to run as a Unix socket server - NDJSON protocol: parse, to_bash, schema, ping, shutdown methods - ~0.1-0.2ms latency, zero new dependencies (uses std only) - 51 new tests for server functionality - Updated README with server mode documentation
1 parent 2148dc5 commit b636ae1

File tree

5 files changed

+1312
-9
lines changed

5 files changed

+1312
-9
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "bash-ast"
3-
version = "0.0.0-dev"
3+
version = "0.3.0"
44
edition = "2021"
55
license = "GPL-3.0"
66
description = "Parse bash scripts to JSON AST using bash's actual parser"

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Parse bash scripts to JSON AST using GNU Bash's actual parser, and convert AST b
1111
- **100% bash compatibility**: Uses the actual GNU Bash parser via FFI
1212
- **JSON output**: Serializes the AST to JSON for easy consumption
1313
- **Round-trip support**: Convert AST back to bash with `--to-bash`
14+
- **Server mode**: Low-latency Unix socket server for editor/tool integration
1415
- **All bash constructs**: Supports all 16 bash command types including:
1516
- Simple commands (`cmd arg1 arg2`)
1617
- Pipelines (`cmd1 | cmd2`)
@@ -135,6 +136,32 @@ echo 'for i in a b c; do echo $i; done' | ./target/release/bash-ast
135136
echo 'for i in a b c; do echo $i; done' | ./target/release/bash-ast | ./target/release/bash-ast -b
136137
```
137138

139+
### Server Mode
140+
141+
For low-latency integration with editors and tools, run as a Unix socket server:
142+
143+
```bash
144+
# Start server (default: $XDG_RUNTIME_DIR/bash-ast.sock or /tmp/bash-ast.sock)
145+
bash-ast --server
146+
147+
# Or specify a custom socket path
148+
bash-ast --server /tmp/my-parser.sock
149+
```
150+
151+
Send newline-delimited JSON requests:
152+
153+
```bash
154+
# Parse bash to AST
155+
echo '{"method":"parse","script":"echo hello"}' | nc -U /tmp/bash-ast.sock
156+
# → {"result":{"type":"simple","words":[{"word":"echo"},{"word":"hello"}],...}}
157+
158+
# Convert AST back to bash
159+
echo '{"method":"to_bash","ast":{"type":"simple","words":[{"word":"echo"}],"redirects":[]}}' | nc -U /tmp/bash-ast.sock
160+
# → {"result":"echo"}
161+
162+
# Other methods: schema, ping, shutdown
163+
```
164+
138165
### Library
139166

140167
```rust

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ mod ast;
4545
mod bash_init;
4646
mod convert;
4747
mod ffi;
48+
pub mod server;
4849
mod to_bash;
4950

5051
pub use ast::*;

src/main.rs

Lines changed: 133 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//!
33
//! Parses bash scripts and outputs JSON AST.
44
5+
use bash_ast::server::{default_socket_path, Server};
56
use bash_ast::{init, parse_to_json, schema_json, to_bash, Command};
67
use std::env;
78
use std::fs;
@@ -10,11 +11,12 @@ use std::process::ExitCode;
1011

1112
const 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
1516
USAGE:
1617
bash-ast [OPTIONS] [FILE]
1718
command | bash-ast [OPTIONS]
19+
bash-ast --server [SOCKET_PATH]
1820
1921
DESCRIPTION:
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
2830
OPTIONS:
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
3538
EXAMPLES:
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+
5778
OUTPUT:
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:
86107
MORE INFO:
87108
Repository: https://github.com/cv/bash-ast
88109
License: GPL-3.0 (due to linkage with GNU Bash)
89-
";
110+
"#;
90111

91112
fn 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

109132
fn 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.\nTry '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.\nTry '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

Comments
 (0)