command-stream 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/js/src/$.ansi.mjs +147 -0
  2. package/js/src/$.mjs +49 -6382
  3. package/js/src/$.process-runner-base.mjs +563 -0
  4. package/js/src/$.process-runner-execution.mjs +1497 -0
  5. package/js/src/$.process-runner-orchestration.mjs +250 -0
  6. package/js/src/$.process-runner-pipeline.mjs +1162 -0
  7. package/js/src/$.process-runner-stream-kill.mjs +312 -0
  8. package/js/src/$.process-runner-virtual.mjs +297 -0
  9. package/js/src/$.quote.mjs +161 -0
  10. package/js/src/$.result.mjs +23 -0
  11. package/js/src/$.shell-settings.mjs +84 -0
  12. package/js/src/$.shell.mjs +157 -0
  13. package/js/src/$.state.mjs +401 -0
  14. package/js/src/$.stream-emitter.mjs +111 -0
  15. package/js/src/$.stream-utils.mjs +390 -0
  16. package/js/src/$.trace.mjs +36 -0
  17. package/js/src/$.utils.mjs +2 -23
  18. package/js/src/$.virtual-commands.mjs +113 -0
  19. package/js/src/commands/$.which.mjs +3 -1
  20. package/js/src/commands/index.mjs +24 -0
  21. package/js/src/shell-parser.mjs +125 -83
  22. package/js/tests/resource-cleanup-internals.test.mjs +22 -24
  23. package/js/tests/sigint-cleanup.test.mjs +3 -0
  24. package/package.json +1 -1
  25. package/rust/src/ansi.rs +194 -0
  26. package/rust/src/events.rs +305 -0
  27. package/rust/src/lib.rs +71 -60
  28. package/rust/src/macros.rs +165 -0
  29. package/rust/src/pipeline.rs +411 -0
  30. package/rust/src/quote.rs +161 -0
  31. package/rust/src/state.rs +333 -0
  32. package/rust/src/stream.rs +369 -0
  33. package/rust/src/trace.rs +152 -0
  34. package/rust/src/utils.rs +53 -158
  35. package/rust/tests/events.rs +207 -0
  36. package/rust/tests/macros.rs +77 -0
  37. package/rust/tests/pipeline.rs +93 -0
  38. package/rust/tests/state.rs +207 -0
  39. package/rust/tests/stream.rs +102 -0
@@ -22,6 +22,119 @@ const TokenType = {
22
22
  EOF: 'eof',
23
23
  };
24
24
 
25
+ /**
26
+ * Parse a word token from the command string, handling quotes and escapes
27
+ * @param {string} command - The command string
28
+ * @param {number} startIndex - Starting position
29
+ * @returns {{word: string, endIndex: number}} Parsed word and end position
30
+ */
31
+ function parseWord(command, startIndex) {
32
+ let word = '';
33
+ let i = startIndex;
34
+ let inQuote = false;
35
+ let quoteChar = '';
36
+
37
+ while (i < command.length) {
38
+ const char = command[i];
39
+
40
+ if (!inQuote) {
41
+ const result = parseUnquotedChar(command, i, char, word);
42
+ if (result.done) {
43
+ return { word: result.word, endIndex: i };
44
+ }
45
+ word = result.word;
46
+ i = result.index;
47
+ if (result.startQuote) {
48
+ inQuote = true;
49
+ quoteChar = result.startQuote;
50
+ }
51
+ } else {
52
+ const result = parseQuotedChar(command, i, char, word, quoteChar);
53
+ word = result.word;
54
+ i = result.index;
55
+ if (result.endQuote) {
56
+ inQuote = false;
57
+ quoteChar = '';
58
+ }
59
+ }
60
+ }
61
+
62
+ return { word, endIndex: i };
63
+ }
64
+
65
+ /**
66
+ * Parse a character when not inside quotes
67
+ */
68
+ function parseUnquotedChar(command, i, char, word) {
69
+ if (char === '"' || char === "'") {
70
+ return { word: word + char, index: i + 1, startQuote: char };
71
+ }
72
+ if (/\s/.test(char) || '&|;()<>'.includes(char)) {
73
+ return { word, done: true };
74
+ }
75
+ if (char === '\\' && i + 1 < command.length) {
76
+ const escaped = command[i + 1];
77
+ return { word: word + char + escaped, index: i + 2 };
78
+ }
79
+ return { word: word + char, index: i + 1 };
80
+ }
81
+
82
+ /**
83
+ * Parse a character when inside quotes
84
+ */
85
+ function parseQuotedChar(command, i, char, word, quoteChar) {
86
+ if (char === quoteChar && command[i - 1] !== '\\') {
87
+ return { word: word + char, index: i + 1, endQuote: true };
88
+ }
89
+ if (
90
+ char === '\\' &&
91
+ i + 1 < command.length &&
92
+ (command[i + 1] === quoteChar || command[i + 1] === '\\')
93
+ ) {
94
+ const escaped = command[i + 1];
95
+ return { word: word + char + escaped, index: i + 2 };
96
+ }
97
+ return { word: word + char, index: i + 1 };
98
+ }
99
+
100
+ /**
101
+ * Try to match an operator at the current position
102
+ * @returns {{type: string, value: string, length: number} | null}
103
+ */
104
+ function matchOperator(command, i) {
105
+ const twoChar = command.slice(i, i + 2);
106
+ const oneChar = command[i];
107
+
108
+ if (twoChar === '&&') {
109
+ return { type: TokenType.AND, value: '&&', length: 2 };
110
+ }
111
+ if (twoChar === '||') {
112
+ return { type: TokenType.OR, value: '||', length: 2 };
113
+ }
114
+ if (twoChar === '>>') {
115
+ return { type: TokenType.REDIRECT_APPEND, value: '>>', length: 2 };
116
+ }
117
+ if (oneChar === '|') {
118
+ return { type: TokenType.PIPE, value: '|', length: 1 };
119
+ }
120
+ if (oneChar === ';') {
121
+ return { type: TokenType.SEMICOLON, value: ';', length: 1 };
122
+ }
123
+ if (oneChar === '(') {
124
+ return { type: TokenType.LPAREN, value: '(', length: 1 };
125
+ }
126
+ if (oneChar === ')') {
127
+ return { type: TokenType.RPAREN, value: ')', length: 1 };
128
+ }
129
+ if (oneChar === '>') {
130
+ return { type: TokenType.REDIRECT_OUT, value: '>', length: 1 };
131
+ }
132
+ if (oneChar === '<') {
133
+ return { type: TokenType.REDIRECT_IN, value: '<', length: 1 };
134
+ }
135
+ return null;
136
+ }
137
+
25
138
  /**
26
139
  * Tokenize a shell command string
27
140
  */
@@ -40,90 +153,19 @@ function tokenize(command) {
40
153
  }
41
154
 
42
155
  // Check for operators
43
- if (command[i] === '&' && command[i + 1] === '&') {
44
- tokens.push({ type: TokenType.AND, value: '&&' });
45
- i += 2;
46
- } else if (command[i] === '|' && command[i + 1] === '|') {
47
- tokens.push({ type: TokenType.OR, value: '||' });
48
- i += 2;
49
- } else if (command[i] === '|') {
50
- tokens.push({ type: TokenType.PIPE, value: '|' });
51
- i++;
52
- } else if (command[i] === ';') {
53
- tokens.push({ type: TokenType.SEMICOLON, value: ';' });
54
- i++;
55
- } else if (command[i] === '(') {
56
- tokens.push({ type: TokenType.LPAREN, value: '(' });
57
- i++;
58
- } else if (command[i] === ')') {
59
- tokens.push({ type: TokenType.RPAREN, value: ')' });
60
- i++;
61
- } else if (command[i] === '>' && command[i + 1] === '>') {
62
- tokens.push({ type: TokenType.REDIRECT_APPEND, value: '>>' });
63
- i += 2;
64
- } else if (command[i] === '>') {
65
- tokens.push({ type: TokenType.REDIRECT_OUT, value: '>' });
66
- i++;
67
- } else if (command[i] === '<') {
68
- tokens.push({ type: TokenType.REDIRECT_IN, value: '<' });
69
- i++;
70
- } else {
71
- // Parse word (respecting quotes)
72
- let word = '';
73
- let inQuote = false;
74
- let quoteChar = '';
75
-
76
- while (i < command.length) {
77
- const char = command[i];
78
-
79
- if (!inQuote) {
80
- if (char === '"' || char === "'") {
81
- inQuote = true;
82
- quoteChar = char;
83
- word += char;
84
- i++;
85
- } else if (/\s/.test(char) || '&|;()<>'.includes(char)) {
86
- break;
87
- } else if (char === '\\' && i + 1 < command.length) {
88
- // Handle escape sequences
89
- word += char;
90
- i++;
91
- if (i < command.length) {
92
- word += command[i];
93
- i++;
94
- }
95
- } else {
96
- word += char;
97
- i++;
98
- }
99
- } else {
100
- if (char === quoteChar && command[i - 1] !== '\\') {
101
- inQuote = false;
102
- quoteChar = '';
103
- word += char;
104
- i++;
105
- } else if (
106
- char === '\\' &&
107
- i + 1 < command.length &&
108
- (command[i + 1] === quoteChar || command[i + 1] === '\\')
109
- ) {
110
- // Handle escaped quotes and backslashes inside quotes
111
- word += char;
112
- i++;
113
- if (i < command.length) {
114
- word += command[i];
115
- i++;
116
- }
117
- } else {
118
- word += char;
119
- i++;
120
- }
121
- }
122
- }
156
+ const operator = matchOperator(command, i);
157
+ if (operator) {
158
+ tokens.push({ type: operator.type, value: operator.value });
159
+ i += operator.length;
160
+ continue;
161
+ }
123
162
 
124
- if (word) {
125
- tokens.push({ type: TokenType.WORD, value: word });
126
- }
163
+ // Parse word (respecting quotes)
164
+ const { word, endIndex } = parseWord(command, i);
165
+ i = endIndex;
166
+
167
+ if (word) {
168
+ tokens.push({ type: TokenType.WORD, value: word });
127
169
  }
128
170
  }
129
171
 
@@ -8,19 +8,27 @@ import { fileURLToPath } from 'url';
8
8
  const __filename = fileURLToPath(import.meta.url);
9
9
  const __dirname = path.dirname(__filename);
10
10
 
11
+ // Helper to check if a listener is a command-stream SIGINT handler
12
+ function isCommandStreamListener(l) {
13
+ const str = l.toString();
14
+ return (
15
+ str.includes('findActiveRunners') ||
16
+ str.includes('forwardSigintToRunners') ||
17
+ str.includes('handleSigintExit') ||
18
+ str.includes('activeProcessRunners') ||
19
+ str.includes('ProcessRunner') ||
20
+ str.includes('activeChildren')
21
+ );
22
+ }
23
+
11
24
  // Helper to access internal state for testing
12
25
  // This is a testing-only approach to verify cleanup
13
26
  function getInternalState() {
14
27
  // We'll use process listeners as a proxy for internal state
15
28
  const sigintListeners = process.listeners('SIGINT');
16
- const commandStreamListeners = sigintListeners.filter((l) => {
17
- const str = l.toString();
18
- return (
19
- str.includes('activeProcessRunners') ||
20
- str.includes('ProcessRunner') ||
21
- str.includes('activeChildren')
22
- );
23
- });
29
+ const commandStreamListeners = sigintListeners.filter(
30
+ isCommandStreamListener
31
+ );
24
32
 
25
33
  return {
26
34
  sigintHandlerCount: commandStreamListeners.length,
@@ -50,14 +58,9 @@ describe('Resource Cleanup Internal Verification', () => {
50
58
 
51
59
  // Force remove any command-stream SIGINT listeners
52
60
  const sigintListeners = process.listeners('SIGINT');
53
- const commandStreamListeners = sigintListeners.filter((l) => {
54
- const str = l.toString();
55
- return (
56
- str.includes('activeProcessRunners') ||
57
- str.includes('ProcessRunner') ||
58
- str.includes('activeChildren')
59
- );
60
- });
61
+ const commandStreamListeners = sigintListeners.filter(
62
+ isCommandStreamListener
63
+ );
61
64
 
62
65
  commandStreamListeners.forEach((listener) => {
63
66
  process.removeListener('SIGINT', listener);
@@ -431,14 +434,9 @@ describe('Resource Cleanup Internal Verification', () => {
431
434
  `Pipeline error test left behind ${state.sigintHandlerCount - initialState.sigintHandlerCount} handlers, forcing cleanup...`
432
435
  );
433
436
  const sigintListeners = process.listeners('SIGINT');
434
- const commandStreamListeners = sigintListeners.filter((l) => {
435
- const str = l.toString();
436
- return (
437
- str.includes('activeProcessRunners') ||
438
- str.includes('ProcessRunner') ||
439
- str.includes('activeChildren')
440
- );
441
- });
437
+ const commandStreamListeners = sigintListeners.filter(
438
+ isCommandStreamListener
439
+ );
442
440
 
443
441
  commandStreamListeners.forEach((listener) => {
444
442
  process.removeListener('SIGINT', listener);
@@ -82,6 +82,9 @@ describe.skipIf(isWindows)('SIGINT Handler Cleanup Tests', () => {
82
82
  const ourListeners = process.listeners('SIGINT').filter((l) => {
83
83
  const str = l.toString();
84
84
  return (
85
+ str.includes('findActiveRunners') ||
86
+ str.includes('forwardSigintToRunners') ||
87
+ str.includes('handleSigintExit') ||
85
88
  str.includes('activeProcessRunners') ||
86
89
  str.includes('ProcessRunner') ||
87
90
  str.includes('activeChildren')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "command-stream",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Modern $ shell utility library with streaming, async iteration, and EventEmitter support, optimized for Bun runtime",
5
5
  "type": "module",
6
6
  "main": "js/src/$.mjs",
@@ -0,0 +1,194 @@
1
+ //! ANSI control character utilities for command-stream
2
+ //!
3
+ //! This module handles stripping and processing of ANSI escape codes
4
+ //! and control characters from text output.
5
+
6
+ /// ANSI control character utilities
7
+ pub struct AnsiUtils;
8
+
9
+ impl AnsiUtils {
10
+ /// Strip ANSI escape sequences from text
11
+ ///
12
+ /// Removes color codes, cursor movement, and other ANSI escape sequences
13
+ /// while preserving the actual text content.
14
+ ///
15
+ /// # Examples
16
+ ///
17
+ /// ```
18
+ /// use command_stream::ansi::AnsiUtils;
19
+ ///
20
+ /// let text = "\x1b[31mRed text\x1b[0m";
21
+ /// assert_eq!(AnsiUtils::strip_ansi(text), "Red text");
22
+ /// ```
23
+ pub fn strip_ansi(text: &str) -> String {
24
+ let re = regex::Regex::new(r"\x1b\[[0-9;]*[mGKHFJ]").unwrap();
25
+ re.replace_all(text, "").to_string()
26
+ }
27
+
28
+ /// Strip control characters from text, preserving newlines, carriage returns, and tabs
29
+ ///
30
+ /// Removes control characters (ASCII 0x00-0x1F and 0x7F) except:
31
+ /// - Newlines (\n = 0x0A)
32
+ /// - Carriage returns (\r = 0x0D)
33
+ /// - Tabs (\t = 0x09)
34
+ ///
35
+ /// # Examples
36
+ ///
37
+ /// ```
38
+ /// use command_stream::ansi::AnsiUtils;
39
+ ///
40
+ /// let text = "Hello\x00World\nNew line\tTab";
41
+ /// assert_eq!(AnsiUtils::strip_control_chars(text), "HelloWorld\nNew line\tTab");
42
+ /// ```
43
+ pub fn strip_control_chars(text: &str) -> String {
44
+ text.chars()
45
+ .filter(|c| {
46
+ // Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09)
47
+ !matches!(*c as u32,
48
+ 0x00..=0x08 | 0x0B | 0x0C | 0x0E..=0x1F | 0x7F
49
+ )
50
+ })
51
+ .collect()
52
+ }
53
+
54
+ /// Strip both ANSI sequences and control characters
55
+ ///
56
+ /// Combines `strip_ansi` and `strip_control_chars` for complete text cleaning.
57
+ pub fn strip_all(text: &str) -> String {
58
+ Self::strip_control_chars(&Self::strip_ansi(text))
59
+ }
60
+
61
+ /// Clean data for processing (strips ANSI and control chars)
62
+ ///
63
+ /// Alias for `strip_all` - provides semantic clarity when processing
64
+ /// data that needs to be cleaned for further processing.
65
+ pub fn clean_for_processing(data: &str) -> String {
66
+ Self::strip_all(data)
67
+ }
68
+ }
69
+
70
+ /// Configuration for ANSI handling
71
+ ///
72
+ /// Controls how ANSI escape codes and control characters are processed
73
+ /// in command output.
74
+ #[derive(Debug, Clone)]
75
+ pub struct AnsiConfig {
76
+ /// Whether to preserve ANSI escape sequences in output
77
+ pub preserve_ansi: bool,
78
+ /// Whether to preserve control characters in output
79
+ pub preserve_control_chars: bool,
80
+ }
81
+
82
+ impl Default for AnsiConfig {
83
+ fn default() -> Self {
84
+ AnsiConfig {
85
+ preserve_ansi: true,
86
+ preserve_control_chars: true,
87
+ }
88
+ }
89
+ }
90
+
91
+ impl AnsiConfig {
92
+ /// Create a new AnsiConfig that preserves everything (default)
93
+ pub fn new() -> Self {
94
+ Self::default()
95
+ }
96
+
97
+ /// Create a config that strips all ANSI and control characters
98
+ pub fn strip_all() -> Self {
99
+ AnsiConfig {
100
+ preserve_ansi: false,
101
+ preserve_control_chars: false,
102
+ }
103
+ }
104
+
105
+ /// Process output according to config settings
106
+ ///
107
+ /// Applies the configured stripping rules to the input data.
108
+ pub fn process_output(&self, data: &str) -> String {
109
+ if !self.preserve_ansi && !self.preserve_control_chars {
110
+ AnsiUtils::clean_for_processing(data)
111
+ } else if !self.preserve_ansi {
112
+ AnsiUtils::strip_ansi(data)
113
+ } else if !self.preserve_control_chars {
114
+ AnsiUtils::strip_control_chars(data)
115
+ } else {
116
+ data.to_string()
117
+ }
118
+ }
119
+ }
120
+
121
+ #[cfg(test)]
122
+ mod tests {
123
+ use super::*;
124
+
125
+ #[test]
126
+ fn test_strip_ansi() {
127
+ let text = "\x1b[31mRed text\x1b[0m";
128
+ assert_eq!(AnsiUtils::strip_ansi(text), "Red text");
129
+ }
130
+
131
+ #[test]
132
+ fn test_strip_ansi_multiple_codes() {
133
+ let text = "\x1b[1m\x1b[32mBold Green\x1b[0m Normal";
134
+ assert_eq!(AnsiUtils::strip_ansi(text), "Bold Green Normal");
135
+ }
136
+
137
+ #[test]
138
+ fn test_strip_control_chars() {
139
+ let text = "Hello\x00World\nNew line\tTab";
140
+ assert_eq!(
141
+ AnsiUtils::strip_control_chars(text),
142
+ "HelloWorld\nNew line\tTab"
143
+ );
144
+ }
145
+
146
+ #[test]
147
+ fn test_strip_control_chars_preserves_whitespace() {
148
+ let text = "Line1\nLine2\r\nLine3\tTabbed";
149
+ assert_eq!(
150
+ AnsiUtils::strip_control_chars(text),
151
+ "Line1\nLine2\r\nLine3\tTabbed"
152
+ );
153
+ }
154
+
155
+ #[test]
156
+ fn test_strip_all() {
157
+ let text = "\x1b[31mRed\x00text\x1b[0m";
158
+ assert_eq!(AnsiUtils::strip_all(text), "Redtext");
159
+ }
160
+
161
+ #[test]
162
+ fn test_ansi_config_default() {
163
+ let config = AnsiConfig::default();
164
+ let text = "\x1b[31mRed\x00text\x1b[0m";
165
+ assert_eq!(config.process_output(text), text);
166
+ }
167
+
168
+ #[test]
169
+ fn test_ansi_config_strip_all() {
170
+ let config = AnsiConfig::strip_all();
171
+ let text = "\x1b[31mRed\x00text\x1b[0m";
172
+ assert_eq!(config.process_output(text), "Redtext");
173
+ }
174
+
175
+ #[test]
176
+ fn test_ansi_config_strip_ansi_only() {
177
+ let config = AnsiConfig {
178
+ preserve_ansi: false,
179
+ preserve_control_chars: true,
180
+ };
181
+ let text = "\x1b[31mRed text\x1b[0m";
182
+ assert_eq!(config.process_output(text), "Red text");
183
+ }
184
+
185
+ #[test]
186
+ fn test_ansi_config_strip_control_only() {
187
+ let config = AnsiConfig {
188
+ preserve_ansi: true,
189
+ preserve_control_chars: false,
190
+ };
191
+ let text = "Hello\x00World";
192
+ assert_eq!(config.process_output(text), "HelloWorld");
193
+ }
194
+ }