From ac925fd4e3be4ba52cac47fcafca150c69546f29 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Fri, 3 Jul 2026 17:52:02 +0200 Subject: [PATCH] feat: add --completions flag for shell completion generation (issue #175) Generates self-contained shell completion scripts for bash, zsh, and fish. - New module src/completions.zig with generators for all three shells - bash: _sql-pipe() function via _init_completion + compgen - zsh: #compdef script with _arguments specs - fish: complete -c entries for every flag - Includes completions for -I/-O format values and --completions shell values - Integration tests with syntax validation (bash -n, zsh -n, fish --no-execute) --- README.md | 1 + build.zig | 92 ++++++++++++++++++++++ docs/sql-pipe.1.scd | 10 +++ src/args.zig | 26 +++++++ src/completions.zig | 182 ++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 13 ++++ 6 files changed, 324 insertions(+) create mode 100644 src/completions.zig diff --git a/README.md b/README.md index 191de94..8e9d282 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,7 @@ When `-f` is used, all positional arguments are treated as data files (no positi | `-f`, `--file ` | Read SQL query from file instead of command line | | `-v`, `--verbose` | Print `Loaded rows in s` to stderr after loading (always on TTY; forced with flag) | | `-s`, `--silent` | Suppress `Loaded rows in s` and the progress counter from stderr unconditionally. Cannot be combined with `-v`/`--verbose` | +| `--completions ` | Generate shell completion script for `bash`, `zsh`, or `fish` to stdout | | `-h`, `--help` | Show usage help and exit | | `-V`, `--version` | Print version and exit | | `--` | End of options — treat all remaining arguments as files or query | diff --git a/build.zig b/build.zig index 598af7a..746da12 100644 --- a/build.zig +++ b/build.zig @@ -3027,4 +3027,96 @@ pub fn build(b: *std.Build) void { }); test_explain_empty_stdin.step.dependOn(b.getInstallStep()); test_step.dependOn(&test_explain_empty_stdin.step); + + // ─── --completions integration tests (issue #175) ──────────────────────────── + + // Integration test 175a: --completions bash generates valid output (contains compete) + const test_completions_bash = b.addSystemCommand(&.{ + "bash", "-c", + \\./zig-out/bin/sql-pipe --completions bash | grep -q 'complete -F _sql-pipe sql-pipe' + }); + test_completions_bash.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_completions_bash.step); + + // Integration test 175b: --completions zsh generates valid output (contains #compdef) + const test_completions_zsh = b.addSystemCommand(&.{ + "bash", "-c", + \\./zig-out/bin/sql-pipe --completions zsh | grep -q '#compdef sql-pipe' + }); + test_completions_zsh.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_completions_zsh.step); + + // Integration test 175c: --completions fish generates valid output (contains complete -c) + const test_completions_fish = b.addSystemCommand(&.{ + "bash", "-c", + \\./zig-out/bin/sql-pipe --completions fish | grep -q 'complete -c sql-pipe' + }); + test_completions_fish.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_completions_fish.step); + + // Integration test 175d: --completions with unknown shell exits 1 + const test_completions_invalid = b.addSystemCommand(&.{ + "bash", "-c", + \\msg=$(./zig-out/bin/sql-pipe --completions invalid 2>&1 >/dev/null; echo "EXIT:$?") + \\echo "$msg" | grep -q 'unknown shell' && echo "$msg" | grep -q 'EXIT:1' + }); + test_completions_invalid.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_completions_invalid.step); + + // Integration test 175e: --completions without value exits 1 + const test_completions_missing = b.addSystemCommand(&.{ + "bash", "-c", + \\./zig-out/bin/sql-pipe --completions 2>&1 >/dev/null; test $? -eq 1 + }); + test_completions_missing.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_completions_missing.step); + + // Integration test 175f: --completions=zsh (equals syntax) works + const test_completions_equals = b.addSystemCommand(&.{ + "bash", "-c", + \\./zig-out/bin/sql-pipe --completions=zsh | grep -q '_sql-pipe' + }); + test_completions_equals.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_completions_equals.step); + + // Integration test 175g: --help contains --completions + const test_completions_help = b.addSystemCommand(&.{ + "bash", "-c", + \\./zig-out/bin/sql-pipe --help 2>&1 >/dev/null | grep -q -- '--completions' + }); + test_completions_help.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_completions_help.step); + + // Integration test 175h: Bash completion script has valid syntax + const test_completions_bash_syntax = b.addSystemCommand(&.{ + "bash", "-c", + \\script=$(./zig-out/bin/sql-pipe --completions bash) + \\# Define _init_completion stub and check syntax with bash -n + \\stub='_init_completion() { :; }; complete -F _sql-pipe sql-pipe; '"$script"';' + \\echo "$stub" | bash -n 2>&1 || exit 1 + }); + test_completions_bash_syntax.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_completions_bash_syntax.step); + + // Integration test 175i: Zsh completion script has valid syntax (skip if zsh not available) + const test_completions_zsh_syntax = b.addSystemCommand(&.{ + "bash", "-c", + \\script=$(./zig-out/bin/sql-pipe --completions zsh) + \\if command -v zsh >/dev/null 2>&1; then + \\ echo "$script" | zsh -n 2>&1 || exit 1 + \\fi + }); + test_completions_zsh_syntax.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_completions_zsh_syntax.step); + + // Integration test 175j: Fish completion script has valid syntax (skip if fish not available) + const test_completions_fish_syntax = b.addSystemCommand(&.{ + "bash", "-c", + \\script=$(./zig-out/bin/sql-pipe --completions fish) + \\if command -v fish >/dev/null 2>&1; then + \\ echo "$script" | fish --no-execute 2>&1 || exit 1 + \\fi + }); + test_completions_fish_syntax.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_completions_fish_syntax.step); } diff --git a/docs/sql-pipe.1.scd b/docs/sql-pipe.1.scd index a1ae203..8c32f3b 100644 --- a/docs/sql-pipe.1.scd +++ b/docs/sql-pipe.1.scd @@ -13,6 +13,7 @@ SYNOPSIS *sql-pipe* --stats [OPTIONS] [] *sql-pipe* --schema [OPTIONS] [...] *sql-pipe* --explain [OPTIONS] [] + *sql-pipe* --completions DESCRIPTION sql-pipe reads CSV, JSON, NDJSON, or XML data from standard input and/or @@ -192,6 +193,15 @@ OPTIONS attribute. Example: *--html-class 'data-table sortable'* produces **. + *--completions* + Generate a shell completion script and print it to standard + output. Supported shells: *bash*, *zsh*, *fish*. The script + is self-contained with no external dependencies. Redirect to + the appropriate completions directory for your shell: + *--completions bash > /usr/share/bash-completion/completions/sql-pipe*, + *--completions zsh > /usr/share/zsh/site-functions/_sql-pipe*, + *--completions fish > ~/.config/fish/completions/sql-pipe.fish*. + *-f, --file* Read the SQL query from instead of the command line. When this flag is used, all positional arguments are treated as data diff --git a/src/args.zig b/src/args.zig index 5ae78e4..12005cb 100644 --- a/src/args.zig +++ b/src/args.zig @@ -78,6 +78,7 @@ pub const SqlPipeError = error{ ExplainWithFlags, MissingNullValue, MissingHtmlClassValue, + InvalidCompletionsShell, }; pub const ParsedArgs = struct { @@ -202,6 +203,20 @@ pub const SchemaArgs = struct { type_inference: bool, }; +pub const CompletionsShell = enum { + bash, + zsh, + fish, + + pub fn parse(s: []const u8) error{InvalidCompletionsShell}!CompletionsShell { + return std.meta.stringToEnum(CompletionsShell, s) orelse error.InvalidCompletionsShell; + } +}; + +pub const CompletionsArgs = struct { + shell: CompletionsShell, +}; + pub const ArgsResult = union(enum) { /// Normal execution: run the query. parsed: ParsedArgs, @@ -219,6 +234,8 @@ pub const ArgsResult = union(enum) { stats: StatsArgs, /// User requested --schema: print inferred CREATE TABLE DDL. schema: SchemaArgs, + /// User requested --completions: generate shell completion script. + completions: CompletionsArgs, }; pub fn printUsage(writer: *std.Io.Writer) !void { @@ -279,6 +296,7 @@ pub fn printUsage(writer: *std.Io.Writer) !void { \\ --null-value Custom NULL representation in output (default: "NULL" for CSV/TSV/table) \\ --html-class CSS class name for the HTML
element (-O html only) \\ -f, --file Read SQL query from file instead of command line + \\ --completions Generate shell completion script (bash, zsh, fish) \\ -h, --help Show this help message and exit \\ -V, --version Show version and exit \\ @@ -512,6 +530,14 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP html_class = arg["--html-class=".len..]; } else if (std.mem.eql(u8, arg, "--no-table")) { table_mode = .never; + } else if (std.mem.eql(u8, arg, "--completions")) { + i += 1; + if (i >= args.len) return error.InvalidCompletionsShell; + const shell = CompletionsShell.parse(args[i]) catch return error.InvalidCompletionsShell; + return .{ .completions = CompletionsArgs{ .shell = shell } }; + } else if (std.mem.startsWith(u8, arg, "--completions=")) { + const shell = CompletionsShell.parse(arg["--completions=".len..]) catch return error.InvalidCompletionsShell; + return .{ .completions = CompletionsArgs{ .shell = shell } }; } else if (std.mem.eql(u8, arg, "-f") or std.mem.eql(u8, arg, "--file")) { i += 1; if (i >= args.len) return error.InvalidQueryFile; diff --git a/src/completions.zig b/src/completions.zig new file mode 100644 index 0000000..c045dfb --- /dev/null +++ b/src/completions.zig @@ -0,0 +1,182 @@ +const std = @import("std"); +const args = @import("args.zig"); + +const CompletionsShell = args.CompletionsShell; + +pub fn generateCompletions(shell: CompletionsShell, writer: *std.Io.Writer) !void { + switch (shell) { + .bash => try generateBash(writer), + .zsh => try generateZsh(writer), + .fish => try generateFish(writer), + } +} + +fn generateBash(writer: *std.Io.Writer) !void { + try writer.writeAll( + \\_sql-pipe() { + \\ local cur prev words cword + \\ _init_completion -s || return + \\ + \\ case $prev in + \\ -d|--delimiter) + \\ return + \\ ;; + \\ -I|--input-format) + \\ COMPREPLY=($(compgen -W "csv tsv json ndjson xml" -- "$cur")) + \\ return + \\ ;; + \\ -O|--output-format) + \\ COMPREPLY=($(compgen -W "csv tsv json ndjson xml markdown md html sql" -- "$cur")) + \\ return + \\ ;; + \\ --completions) + \\ COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur")) + \\ return + \\ ;; + \\ --max-rows|--sql-table|--null-value|--html-class|--output|--xml-root|--xml-row|--json-path) + \\ return + \\ ;; + \\ --sample) + \\ COMPREPLY=($(compgen -W "1 5 10 25 50 100 500 1000" -- "$cur")) + \\ return + \\ ;; + \\ -f|--file) + \\ _filedir + \\ return + \\ ;; + \\ esac + \\ + \\ if [[ "$cur" == -* ]]; then + \\ COMPREPLY=($(compgen -W ' + \\ --delimiter -d + \\ --tsv + \\ --input-format -I + \\ --output-format -O + \\ --json + \\ --sql-table + \\ --no-type-inference + \\ --header -H + \\ --max-rows + \\ --verbose -v + \\ --silent -s + \\ --validate + \\ --columns + \\ --sample + \\ --stats --profile + \\ --schema + \\ --output + \\ --xml-root + \\ --xml-row + \\ --json-path + \\ --disk + \\ --explain + \\ --table --no-table + \\ --null-value + \\ --html-class + \\ --completions + \\ --file -f + \\ --help -h + \\ --version -V + \\ ' -- "$cur")) + \\ else + \\ _filedir + \\ fi + \\} + \\complete -F _sql-pipe sql-pipe + \\ + ); +} + +fn generateZsh(writer: *std.Io.Writer) !void { + try writer.writeAll( + \\#compdef sql-pipe + \\ + \\_sql-pipe() { + \\ local -a opts + \\ opts=( + \\ '(-d --delimiter)'{-d+,--delimiter=}'[Input field delimiter]:delimiter:' + \\ '--tsv[Tab-separated input alias]' + \\ '(-I --input-format)'{-I+,--input-format=}'[Input format]:format:(csv tsv json ndjson xml)' + \\ '(-O --output-format)'{-O+,--output-format=}'[Output format]:format:(csv tsv json ndjson xml markdown html sql)' + \\ '--json[Alias for --output-format json]' + \\ '--sql-table=[SQL INSERT table name]:table name:' + \\ '--no-type-inference[Treat all columns as TEXT]' + \\ '(-H --header)'{-H,--header}'[Print column names as first output row]' + \\ '--max-rows=[Row limit]:rows:' + \\ '(-v --verbose)'{-v,--verbose}'[Force row count to stderr]' + \\ '(-s --silent)'{-s,--silent}'[Suppress row count output]' + \\ '--validate[Parse input and print summary]' + \\ '--columns[List column names]' + \\ '--sample::Number of rows:(1 5 10 25 50 100 500 1000)' + \\ '--stats[Compute per-column statistics]' + \\ '--profile[Alias for --stats]' + \\ '--schema[Print inferred CREATE TABLE DDL]' + \\ '--output=[Write results to file]:file:_files' + \\ '--xml-root=[Root element name]:name:' + \\ '--xml-row=[Row element name]:name:' + \\ '--json-path=[Path to JSON array]:path:' + \\ '--disk[Use file-backed temp database]' + \\ '--explain[Print query plan to stderr]' + \\ '--table[Force table output]' + \\ '--no-table[Force CSV output]' + \\ '--null-value=[Custom NULL representation]:string:' + \\ '--html-class=[HTML table CSS class]:class:' + \\ '--completions=[Generate shell completions]:shell:(bash zsh fish)' + \\ '(-f --file)'{-f+,--file=}'[Read SQL query from file]:file:_files' + \\ '(-h --help)'{-h,--help}'[Show help message]' + \\ '(-V --version)'{-V,--version}'[Show version]' + \\ '*:file:_files' + \\ ) + \\ _arguments $opts + \\} + \\ + \\_sql-pipe + \\ + ); +} + +fn generateFish(writer: *std.Io.Writer) !void { + try writer.writeAll( + \\complete -c sql-pipe -f + \\ + \\# Input options + \\complete -c sql-pipe -s d -l delimiter -r -d "Input field delimiter" + \\complete -c sql-pipe -l tsv -d "Alias for --delimiter \\'\\\\t\\'" + \\complete -c sql-pipe -s I -l input-format -r -f -a "csv tsv json ndjson xml" -d "Input format" + \\complete -c sql-pipe -s O -l output-format -r -f -a "csv tsv json ndjson xml markdown html sql" -d "Output format" + \\complete -c sql-pipe -l json -d "Alias for --output-format json" + \\ + \\# Query options + \\complete -c sql-pipe -l sql-table -r -d "Target table for SQL INSERT" + \\complete -c sql-pipe -l no-type-inference -d "Treat all columns as TEXT" + \\complete -c sql-pipe -s H -l header -d "Print column names as first output row" + \\complete -c sql-pipe -l max-rows -r -d "Stop if more than N data rows read" + \\complete -c sql-pipe -s v -l verbose -d "Force row count to stderr" + \\complete -c sql-pipe -s s -l silent -d "Suppress row count output" + \\complete -c sql-pipe -l validate -d "Parse input and print summary" + \\complete -c sql-pipe -l columns -d "List column names and exit" + \\complete -c sql-pipe -l sample -r -f -a "1 5 10 25 50 100 500 1000" -d "Print schema and sample rows" + \\complete -c sql-pipe -l stats -d "Compute per-column statistics" + \\complete -c sql-pipe -l profile -d "Alias for --stats" + \\complete -c sql-pipe -l schema -d "Print inferred CREATE TABLE DDL" + \\ + \\# Output options + \\complete -c sql-pipe -l output -r -d "Write results to file" + \\complete -c sql-pipe -l xml-root -r -d "Root element name for XML" + \\complete -c sql-pipe -l xml-row -r -d "Row element name for XML" + \\complete -c sql-pipe -l json-path -r -d "Path to JSON array" + \\complete -c sql-pipe -l disk -d "Use file-backed temp database" + \\complete -c sql-pipe -l explain -d "Print query plan to stderr" + \\complete -c sql-pipe -l table -d "Force pretty-printed table output" + \\complete -c sql-pipe -l no-table -d "Force CSV output" + \\complete -c sql-pipe -l null-value -r -d "Custom NULL representation" + \\complete -c sql-pipe -l html-class -r -d "CSS class for HTML table" + \\ + \\# Meta options + \\complete -c sql-pipe -l completions -r -f -a "bash zsh fish" -d "Generate shell completions" + \\complete -c sql-pipe -s f -l file -r -d "Read SQL query from file" + \\complete -c sql-pipe -s h -l help -d "Show help message" + \\complete -c sql-pipe -s V -l version -d "Show version" + \\ + ); +} diff --git a/src/main.zig b/src/main.zig index 2dc6955..1b1d5c0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -15,6 +15,7 @@ const validate_mode = @import("modes/validate.zig"); const sample_mode = @import("modes/sample.zig"); const stats_mode = @import("modes/stats.zig"); const schema_mode = @import("modes/schema.zig"); +const completions_mod = @import("completions.zig"); const VERSION: []const u8 = build_options.version; @@ -304,6 +305,7 @@ pub fn main(init: std.process.Init.Minimal) void { error.ExplainWithFlags => fatal("--explain cannot be combined with --columns, --validate, --sample, --stats, --schema, or --output", stderr_writer, .usage, .{}), error.MissingNullValue => fatal("--null-value requires a value", stderr_writer, .usage, .{}), error.MissingHtmlClassValue => fatal("--html-class requires a value", stderr_writer, .usage, .{}), + error.InvalidCompletionsShell => fatal("unknown shell; supported: bash, zsh, fish", stderr_writer, .usage, .{}), else => {}, } printUsage(stderr_writer) catch |werr| std.log.err("failed to write usage: {}", .{werr}); @@ -371,6 +373,17 @@ pub fn main(init: std.process.Init.Minimal) void { std.log.err("failed to flush stderr: {}", .{err}); }; }, + .completions => |comp_args| { + completions_mod.generateCompletions(comp_args.shell, stdout_writer) catch |err| { + std.log.err("failed to write completions: {}", .{err}); + }; + stdout_file_writer.flush() catch |err| { + std.log.err("failed to flush stdout: {}", .{err}); + }; + stderr_file_writer.flush() catch |err| { + std.log.err("failed to flush stderr: {}", .{err}); + }; + }, .parsed => |mut_parsed| { var parsed = mut_parsed; parsed.has_stdin = has_stdin;