diff --git a/stdlib/docs/shell.md b/stdlib/docs/shell.md index 9b5f68d8..253de42e 100644 --- a/stdlib/docs/shell.md +++ b/stdlib/docs/shell.md @@ -1,6 +1,6 @@ # @stdlib/shell - Shell Utilities -Provides utilities for building shell commands safely, escaping arguments, parsing output, and executing commands. +Provides utilities for building shell commands safely, escaping arguments, parsing output, and executing commands. Includes Bash-like features such as brace expansion and range generation for shell scripting. ## Import @@ -8,6 +8,178 @@ Provides utilities for building shell commands safely, escaping arguments, parsi import { escape, quote, run, run_capture } from "@stdlib/shell"; import { which, command_exists, env_or } from "@stdlib/shell"; import { build_command, pipe, and_then } from "@stdlib/shell"; +import { sh, sh_ok, last_exit } from "@stdlib/shell"; +import { range, char_range, expand } from "@stdlib/shell"; +``` + +## Quick Shell Execution + +For quick scripting, use `sh()` to run shell commands directly (like Bash). + +### sh(cmd: string): string + +Run a shell command and return stdout (trimmed). Throws on failure. + +```hemlock +// Simple command +let date = sh("date"); +print(date); // "Mon Jan 20 10:30:00 UTC 2025" + +// Pipes work naturally +let count = sh("ls -la | wc -l"); +print("Files: " + count); + +// Command substitution +let kernel = sh("uname -r"); +print("Kernel: " + kernel); + +// Environment variables expand +let home = sh("echo $HOME"); +``` + +### sh_ok(cmd: string): bool + +Run a shell command, return true if it succeeded (doesn't throw). + +```hemlock +if (sh_ok("which docker")) { + print("Docker is installed"); +} + +if (sh_ok("test -f /etc/passwd")) { + print("File exists"); +} + +// Check multiple conditions +if (sh_ok("[ -d /tmp ] && [ -w /tmp ]")) { + print("/tmp is writable"); +} +``` + +### last_exit(): i32 + +Get the exit code from the last `sh()` or `sh_ok()` call. + +```hemlock +sh_ok("grep pattern file.txt"); +if (last_exit() == 0) { + print("Pattern found"); +} else if (last_exit() == 1) { + print("Pattern not found"); +} else { + print("Error occurred"); +} +``` + +## Range Generation + +Generate numeric and character sequences like Bash's `{1..10}`. + +### range(start, end, step?): array + +Generate a numeric range (inclusive). + +```hemlock +range(1, 5); // [1, 2, 3, 4, 5] +range(5, 1); // [5, 4, 3, 2, 1] (auto-detects direction) +range(0, 10, 2); // [0, 2, 4, 6, 8, 10] +range(10, 0, -2); // [10, 8, 6, 4, 2, 0] + +// Use in loops +for (i in range(1, 10)) { + print("Count: " + i); +} + +// Generate file indices +for (n in range(1, 100)) { + let filename = "file" + n + ".txt"; + // ... +} +``` + +### char_range(start, end): array + +Generate a character range. + +```hemlock +char_range('a', 'z'); // ['a', 'b', ..., 'z'] +char_range('A', 'F'); // ['A', 'B', 'C', 'D', 'E', 'F'] +char_range('z', 'a'); // ['z', 'y', ..., 'a'] (reverse) +char_range('0', '9'); // ['0', '1', ..., '9'] +``` + +## Brace Expansion + +Expand Bash-style brace expressions. + +### expand(pattern: string): array + +Expand brace patterns like Bash. + +```hemlock +// List expansion +expand("{a,b,c}"); // ["a", "b", "c"] +expand("{foo,bar,baz}"); // ["foo", "bar", "baz"] + +// Numeric range +expand("{1..5}"); // ["1", "2", "3", "4", "5"] +expand("{5..1}"); // ["5", "4", "3", "2", "1"] +expand("{1..10..2}"); // ["1", "3", "5", "7", "9"] + +// Character range +expand("{a..f}"); // ["a", "b", "c", "d", "e", "f"] +expand("{A..Z}"); // ["A", "B", ..., "Z"] + +// Zero-padded ranges +expand("{01..05}"); // ["01", "02", "03", "04", "05"] +expand("{001..100}"); // ["001", "002", ..., "100"] + +// With prefix and suffix +expand("file{1..3}.txt"); // ["file1.txt", "file2.txt", "file3.txt"] +expand("test_{a,b,c}.log"); // ["test_a.log", "test_b.log", "test_c.log"] + +// Multiple braces (cartesian product) +expand("{a,b}{1,2}"); // ["a1", "a2", "b1", "b2"] +expand("{x,y}{1..3}"); // ["x1", "x2", "x3", "y1", "y2", "y3"] + +// Nested braces +expand("{{a,b},{c,d}}"); // ["a", "b", "c", "d"] + +// Real-world examples +expand("server{01..03}.example.com"); +// ["server01.example.com", "server02.example.com", "server03.example.com"] + +expand("backup_{mon,wed,fri}.tar.gz"); +// ["backup_mon.tar.gz", "backup_wed.tar.gz", "backup_fri.tar.gz"] + +expand("/var/log/{syslog,auth,kern}.log"); +// ["/var/log/syslog.log", "/var/log/auth.log", "/var/log/kern.log"] +``` + +### Scripting Example + +```hemlock +import { sh, expand, range } from "@stdlib/shell"; + +// Process multiple files +let files = expand("data_{2020..2024}.csv"); +for (f in files) { + print("Processing: " + f); + sh("gzip " + f); +} + +// Generate server list +let servers = expand("web{01..10}.prod.local"); +for (server in servers) { + if (sh_ok("ping -c 1 " + server)) { + print(server + " is up"); + } +} + +// Batch operations +for (i in range(1, 100)) { + sh("curl -o page" + i + ".html https://example.com/page/" + i); +} ``` ## Argument Escaping diff --git a/stdlib/shell.hml b/stdlib/shell.hml index 113f7416..d0e41b9e 100644 --- a/stdlib/shell.hml +++ b/stdlib/shell.hml @@ -484,6 +484,399 @@ export fn redirect_all(command: string, file: string): string { return command + " > " + quote(file) + " 2>&1"; } +// ============================================================================ +// Quick Shell Execution +// ============================================================================ + +// Last exit code from sh() or run() +let _last_exit_code: i32 = 0; + +// Run a raw shell string directly (like Bash) +// This is the quick-and-dirty way to run shell commands. +// For safer execution with proper quoting, use run() with arrays. +// Parameters: +// cmd: string - Shell command string to execute +// Returns: string - stdout output (trimmed) +// Throws: on command failure +export fn sh(cmd: string): string { + let result = exec_cmd(cmd); + _last_exit_code = result["exit_code"]; + if (result["exit_code"] != 0) { + throw "Command failed with code " + result["exit_code"]; + } + let output = result["output"]; + // Trim trailing newline + if (output.length > 0 && output.char_at(output.length - 1) == '\n') { + output = output.slice(0, output.length - 1); + } + return output; +} + +// Run a raw shell string, returning success status (doesn't throw) +// Parameters: +// cmd: string - Shell command string to execute +// Returns: bool - true if command succeeded +export fn sh_ok(cmd: string): bool { + let result = exec_cmd(cmd); + _last_exit_code = result["exit_code"]; + return result["exit_code"] == 0; +} + +// Get the exit code from the last sh() or sh_ok() call +// Returns: i32 - exit code (0 = success) +export fn last_exit(): i32 { + return _last_exit_code; +} + +// ============================================================================ +// Range Generation (Bash-like {1..10}) +// ============================================================================ + +// Generate a numeric range as an array +// Parameters: +// start: i32 - Starting number (inclusive) +// end: i32 - Ending number (inclusive) +// step: i32 - Step increment (default: 1 or -1 based on direction) +// Returns: array - Array of numbers in the range +export fn range(start: i32, end: i32, step?: 0): array { + let result: array = []; + + // Auto-determine step if not provided + let s = step; + if (s == 0) { + if (start <= end) { + s = 1; + } else { + s = -1; + } + } + + // Validate step direction + if (s > 0 && start > end) { + return result; // Empty array for invalid range + } + if (s < 0 && start < end) { + return result; // Empty array for invalid range + } + if (s == 0) { + return result; // Prevent infinite loop + } + + let i = start; + if (s > 0) { + while (i <= end) { + result.push(i); + i = i + s; + } + } else { + while (i >= end) { + result.push(i); + i = i + s; + } + } + + return result; +} + +// Generate a character range (e.g., 'a' to 'z') +// Parameters: +// start: rune - Starting character (inclusive) +// end: rune - Ending character (inclusive) +// Returns: array - Array of single-character strings in the range +export fn char_range(start: rune, end: rune): array { + let result: array = []; + let s = i32(start); + let e = i32(end); + + // Build lookup string for ASCII printable range + let chars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"; + + if (s <= e) { + let i = s; + while (i <= e) { + // Convert code point to character via lookup + let ch = code_to_char(i, chars); + if (ch != null) { + result.push(ch); + } + i = i + 1; + } + } else { + let i = s; + while (i >= e) { + let ch = code_to_char(i, chars); + if (ch != null) { + result.push(ch); + } + i = i - 1; + } + } + + return result; +} + +// Internal: Convert ASCII code to character string +fn code_to_char(code: i32, lookup: string) { + // ASCII printable range starts at 32 (space) + let offset = code - 32; + if (offset >= 0 && offset < lookup.length) { + return "" + lookup.char_at(offset); + } + return null; +} + +// ============================================================================ +// Brace Expansion (Bash-like {a,b,c} and {1..5}) +// ============================================================================ + +// Expand a brace expression like Bash +// Supports: +// - List expansion: {a,b,c} -> ["a", "b", "c"] +// - Range expansion: {1..5} -> ["1", "2", "3", "4", "5"] +// - Character range: {a..z} -> ["a", "b", ..., "z"] +// - With prefix/suffix: file{1..3}.txt -> ["file1.txt", "file2.txt", "file3.txt"] +// - Multiple braces: {a,b}{1,2} -> ["a1", "a2", "b1", "b2"] +// Parameters: +// pattern: string - Brace expression to expand +// Returns: array - Array of expanded strings +export fn expand(pattern: string): array { + return expand_braces(pattern); +} + +// Internal: Expand braces recursively +fn expand_braces(pattern: string): array { + // Find first brace group + let brace_start = -1; + let brace_end = -1; + let depth = 0; + + let i = 0; + while (i < pattern.length) { + let ch = pattern.char_at(i); + if (ch == '{') { + if (depth == 0) { + brace_start = i; + } + depth = depth + 1; + } else if (ch == '}') { + depth = depth - 1; + if (depth == 0 && brace_start >= 0) { + brace_end = i; + break; + } + } + i = i + 1; + } + + // No braces found, return pattern as-is + if (brace_start < 0 || brace_end < 0) { + return [pattern]; + } + + // Extract prefix, brace content, and suffix + let prefix = pattern.slice(0, brace_start); + let content = pattern.slice(brace_start + 1, brace_end); + let suffix = pattern.slice(brace_end + 1, pattern.length); + + // Parse brace content + let expansions = parse_brace_content(content); + + // Combine with prefix/suffix and recursively expand suffix + let result: array = []; + let j = 0; + while (j < expansions.length) { + let expanded = prefix + expansions[j] + suffix; + // Recursively expand any remaining braces + let sub_expanded = expand_braces(expanded); + let k = 0; + while (k < sub_expanded.length) { + result.push(sub_expanded[k]); + k = k + 1; + } + j = j + 1; + } + + return result; +} + +// Internal: Parse brace content (list or range) +fn parse_brace_content(content: string): array { + // Check for range pattern: start..end or start..end..step + let dot_dot = content.find(".."); + if (dot_dot >= 0) { + return parse_brace_range(content); + } + + // Otherwise, split by comma (respecting nested braces) + return split_brace_list(content); +} + +// Internal: Parse range like 1..5 or a..z or 1..10..2 +fn parse_brace_range(content: string): array { + let parts = split_dotdot(content); + + if (parts.length < 2) { + return [content]; // Invalid range, return as-is + } + + let start_str = parts[0]; + let end_str = parts[1]; + let step_str = ""; + if (parts.length >= 3) { + step_str = parts[2]; + } + + // Check if it's a character range + if (start_str.length == 1 && end_str.length == 1) { + let start_ch = start_str.char_at(0); + let end_ch = end_str.char_at(0); + + // Check if both are letters or both are digits + if ((is_letter(start_ch) && is_letter(end_ch)) || + (is_digit(start_ch) && is_digit(end_ch) && step_str == "")) { + // Character range + let chars = char_range(start_ch, end_ch); + let result: array = []; + let i = 0; + while (i < chars.length) { + result.push("" + chars[i]); + i = i + 1; + } + return result; + } + } + + // Numeric range + let start_num = parse_int(start_str); + let end_num = parse_int(end_str); + let step_num = 0; + if (step_str != "") { + step_num = parse_int(step_str); + } + + if (start_num == null || end_num == null) { + return [content]; // Invalid numbers, return as-is + } + + // Handle padding (e.g., 01..05 -> 01, 02, 03, 04, 05) + let pad_width = 0; + if (start_str.length > 1 && start_str.char_at(0) == '0') { + pad_width = start_str.length; + } + if (end_str.length > 1 && end_str.char_at(0) == '0') { + if (end_str.length > pad_width) { + pad_width = end_str.length; + } + } + + let nums = range(start_num, end_num, step_num); + let result: array = []; + let i = 0; + while (i < nums.length) { + let s = "" + nums[i]; + // Apply zero-padding if needed + if (pad_width > 0) { + while (s.length < pad_width) { + s = "0" + s; + } + } + result.push(s); + i = i + 1; + } + return result; +} + +// Internal: Split by ".." +fn split_dotdot(s: string): array { + let result: array = []; + let current = ""; + let i = 0; + + while (i < s.length) { + if (i + 1 < s.length && s.char_at(i) == '.' && s.char_at(i + 1) == '.') { + result.push(current); + current = ""; + i = i + 2; + } else { + current = current + s.char_at(i); + i = i + 1; + } + } + result.push(current); + return result; +} + +// Internal: Split comma-separated list respecting nested braces +fn split_brace_list(content: string): array { + let result: array = []; + let current = ""; + let depth = 0; + + let i = 0; + while (i < content.length) { + let ch = content.char_at(i); + if (ch == '{') { + depth = depth + 1; + current = current + ch; + } else if (ch == '}') { + depth = depth - 1; + current = current + ch; + } else if (ch == ',' && depth == 0) { + result.push(current); + current = ""; + } else { + current = current + ch; + } + i = i + 1; + } + + result.push(current); + return result; +} + +// Internal: Check if character is a letter +fn is_letter(ch: rune): bool { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); +} + +// Internal: Check if character is a digit +fn is_digit(ch: rune): bool { + return ch >= '0' && ch <= '9'; +} + +// Internal: Parse integer from string (simple implementation) +fn parse_int(s: string) { + if (s.length == 0) { + return null; + } + + let negative = false; + let start = 0; + + if (s.char_at(0) == '-') { + negative = true; + start = 1; + } else if (s.char_at(0) == '+') { + start = 1; + } + + let result = 0; + let i = start; + while (i < s.length) { + let ch = s.char_at(i); + if (ch < '0' || ch > '9') { + return null; // Invalid character + } + result = result * 10 + (i32(ch) - i32('0')); + i = i + 1; + } + + if (negative) { + result = -result; + } + return result; +} + // ============================================================================ // Built-in Wrappers // ============================================================================ diff --git a/tests/parity/modules/stdlib_shell.expected b/tests/parity/modules/stdlib_shell.expected new file mode 100644 index 00000000..6ffa5b2c --- /dev/null +++ b/tests/parity/modules/stdlib_shell.expected @@ -0,0 +1,35 @@ +=== range() tests === +range(1,5): 1,2,3,4,5 +range(5,1): 5,4,3,2,1 +range(0,10,2): 0,2,4,6,8,10 +range(10,0,-2): 10,8,6,4,2,0 +range(5,5): 5 +range(5,1,1) empty: 0 +range(-3,3): -3,-2,-1,0,1,2,3 +=== char_range() tests === +char_range(a,f): a,b,c,d,e,f +char_range(A,D): A,B,C,D +char_range(z,w): z,y,x,w +char_range(0,5): 0,1,2,3,4,5 +char_range(x,x): x +=== expand() tests === +expand {a,b,c}: a,b,c +expand {1..5}: 1,2,3,4,5 +expand {5..1}: 5,4,3,2,1 +expand {a..e}: a,b,c,d,e +expand file{1..3}.txt: file1.txt,file2.txt,file3.txt +expand test_{a,b,c}.log: test_a.log,test_b.log,test_c.log +expand {a,b}{1,2}: a1,a2,b1,b2 +expand {01..05}: 01,02,03,04,05 +expand {1..10..2}: 1,3,5,7,9 +expand plain: plain +expand {}: +expand server{01..03}.local: server01.local,server02.local,server03.local +=== sh()/sh_ok() tests === +sh_ok(true): true +sh_ok(false): false +last_exit after false: 1 +sh_ok(test 1 -eq 1): true +sh(echo hello): hello +sh(printf abc): abc +=== Done === diff --git a/tests/parity/modules/stdlib_shell.hml b/tests/parity/modules/stdlib_shell.hml new file mode 100644 index 00000000..e7cfeea3 --- /dev/null +++ b/tests/parity/modules/stdlib_shell.hml @@ -0,0 +1,143 @@ +// Parity test for @stdlib/shell module (new functions: range, expand, char_range) +import { range, char_range, expand, sh, sh_ok, last_exit } from "@stdlib/shell"; + +// ============================================================================ +// Test range() function +// ============================================================================ +print("=== range() tests ==="); + +// Basic ascending range +let r1 = range(1, 5); +print("range(1,5): " + r1.join(",")); + +// Descending range (auto-detects) +let r2 = range(5, 1); +print("range(5,1): " + r2.join(",")); + +// With step +let r3 = range(0, 10, 2); +print("range(0,10,2): " + r3.join(",")); + +// Negative step +let r4 = range(10, 0, -2); +print("range(10,0,-2): " + r4.join(",")); + +// Single element +let r5 = range(5, 5); +print("range(5,5): " + r5.join(",")); + +// Empty range (invalid direction) +let r6 = range(5, 1, 1); +print("range(5,1,1) empty: " + r6.length); + +// Negative numbers +let r7 = range(-3, 3); +print("range(-3,3): " + r7.join(",")); + +// ============================================================================ +// Test char_range() function +// ============================================================================ +print("=== char_range() tests ==="); + +// Lowercase letters +let c1 = char_range('a', 'f'); +print("char_range(a,f): " + c1.join(",")); + +// Uppercase letters +let c2 = char_range('A', 'D'); +print("char_range(A,D): " + c2.join(",")); + +// Reverse range +let c3 = char_range('z', 'w'); +print("char_range(z,w): " + c3.join(",")); + +// Digits +let c4 = char_range('0', '5'); +print("char_range(0,5): " + c4.join(",")); + +// Single character +let c5 = char_range('x', 'x'); +print("char_range(x,x): " + c5.join(",")); + +// ============================================================================ +// Test expand() function +// ============================================================================ +print("=== expand() tests ==="); + +// Simple list +let e1 = expand("{a,b,c}"); +print("expand {a,b,c}: " + e1.join(",")); + +// Numeric range +let e2 = expand("{1..5}"); +print("expand {1..5}: " + e2.join(",")); + +// Reverse numeric range +let e3 = expand("{5..1}"); +print("expand {5..1}: " + e3.join(",")); + +// Character range +let e4 = expand("{a..e}"); +print("expand {a..e}: " + e4.join(",")); + +// With prefix and suffix +let e5 = expand("file{1..3}.txt"); +print("expand file{1..3}.txt: " + e5.join(",")); + +// List with prefix/suffix +let e6 = expand("test_{a,b,c}.log"); +print("expand test_{a,b,c}.log: " + e6.join(",")); + +// Multiple braces (cartesian product) +let e7 = expand("{a,b}{1,2}"); +print("expand {a,b}{1,2}: " + e7.join(",")); + +// Zero-padded range +let e8 = expand("{01..05}"); +print("expand {01..05}: " + e8.join(",")); + +// Range with step +let e9 = expand("{1..10..2}"); +print("expand {1..10..2}: " + e9.join(",")); + +// No braces (unchanged) +let e10 = expand("plain"); +print("expand plain: " + e10.join(",")); + +// Empty braces content +let e11 = expand("{}"); +print("expand {}: " + e11.join(",")); + +// Complex example +let e12 = expand("server{01..03}.local"); +print("expand server{01..03}.local: " + e12.join(",")); + +// ============================================================================ +// Test sh() and sh_ok() +// ============================================================================ +print("=== sh()/sh_ok() tests ==="); + +// sh_ok with true command +let ok1 = sh_ok("true"); +print("sh_ok(true): " + ok1); + +// sh_ok with false command +let ok2 = sh_ok("false"); +print("sh_ok(false): " + ok2); + +// last_exit after false +print("last_exit after false: " + last_exit()); + +// sh_ok with test +let ok3 = sh_ok("test 1 -eq 1"); +print("sh_ok(test 1 -eq 1): " + ok3); + +// sh with echo (basic) +let out1 = sh("echo hello"); +print("sh(echo hello): " + out1); + +// sh with printf (no quotes to avoid interpreter warning) +let out2 = sh("printf abc"); +print("sh(printf abc): " + out2); + +print("=== Done ===");