vtparser 0.1.0 → 0.3.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d622edd5fbb0b4f47cb9831699c466aeabc3da9c6bed99370cc7d99d169c8c02
4
- data.tar.gz: 33eccc5b843d8c5d734a06d5b2b3d389462c23ea568bf9456ed55a38d6f1a50a
3
+ metadata.gz: cbb3fedd9971980bdb4389680f7ad7f52a82ec573b9bdecc3a30ecd1fb22f102
4
+ data.tar.gz: 5f12efd5ffe0f23656dcd2520f835b64815a423f9cbb7353b98eac372b0a2303
5
5
  SHA512:
6
- metadata.gz: 0e5c6dd30582e24e0ad63b142446280b1be31d51cbae543b78c0019bf626ce2d477ea7bf002ab3707c7dd8056df886d33304eaa2a9509c30a9cac7ac6148e4b9
7
- data.tar.gz: ab1f60a0cdd8e079274bb8a7d6a285287b81a3fe1304462086ccdb71910fe09451faadaa4f3ce8935b6a63fdc579a856e069558e7183c8a7c659924e7ad32417
6
+ metadata.gz: 60c450670c31b4fee417acdefab3f6ed5287351929a6afcbdda46d68d4ba72e5db76731184136d014b9c656b026649fbed0fe5811740532b5402ce7961946fc2
7
+ data.tar.gz: 2d7ec6bdb03a5ef18df04e461cc199a9fe60d4b838d39f772db14d9aec70b95cbc90f5fc2b2cf42314a9a91d0aff70e5ca48f7185de754ead3e70061acdb501d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2024-10-05
4
+
5
+ - Added more examples `colorswap.rb`, `analyze.rb`, `roundtrip.rb`
6
+ - Renamed KeyboardEvent to KeyEvent
7
+ - Added support for BEL instead of ESC to terminate OSC sequences (xterm uses this)
8
+ - Encapsulate action in class Action and added instance methods
9
+ - Fixed serialization issue for private mode CSI commands
10
+ - Added PtySupport::spawn to easily spawn a command and link stdin/out to the parser
11
+ - Switch PTY to `raw` mode to capture keys, disable echo.
12
+ - Added support for piping output to files from examples.
13
+
14
+ ## [0.2.0] - 2024-10-03
15
+
16
+ - Add keyevent handling
17
+
3
18
  ## [0.1.0] - 2024-10-01
4
19
 
5
20
  - Initial release
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # VT 100 Parser Gem
2
2
 
3
- This gem is a parser for VT100 terminal escape sequences. It is based on the C code from https://github.com/haberman/vtparse/.
3
+ This gem is a parser for VT100 terminal escape sequences. It is based on the C code from https://github.com/haberman/vtparse/ and implements the statemachine from https://www.vt100.net/emu/dec_ansi_parser.
4
+
5
+ The purpose of this Gem is to have a relatively easy way to filter/modify the output of child/sub-processes (for instance launched via `PTY::spawn`) which use animation or colors.
6
+
7
+ Uses keyboard mapping logic from https://github.com/vidarh/keyboard_map/
4
8
 
5
9
  ## Background on VT100 Escape Sequences
6
10
 
@@ -22,26 +26,46 @@ gem install vtparser
22
26
 
23
27
  ## Basic Usage
24
28
 
25
- See the minimal example below and the [`examples`](https://github.com/coezbek/vtparser/examples) directory for more examples.
29
+ See the minimal example below:
26
30
 
27
31
  ```ruby
28
- require 'vtparser'
32
+ require_relative '../lib/vtparser'
29
33
 
30
34
  # Instantiate the parser with a block to handle actions
31
- parser = VTParser.new do |action, ch, intermediate_chars, params|
35
+ parser = VTParser.new do |action|
32
36
 
33
37
  # For this minimal example, we'll just turn everything back strings to print
34
- print VtParser::to_ansi(action, ch, intermediate_chars, params)
38
+ print action.to_ansi
35
39
 
36
40
  end
37
41
 
38
- # Sample input containing ANSI escape sequences
39
- input = "\e[31mHello, \e[1mWorld!\e[0m"
42
+ # Sample input containing ANSI escape sequences (red text, bold text)
43
+ input = "\e[31mHello, \e[1mWorld!\e[0m\n"
40
44
 
41
45
  # Parse the input
42
46
  parser.parse(input)
43
47
  ```
44
48
 
49
+ Further samples in the [`examples directory`](https://github.com/coezbek/vtparser/tree/main/examples):
50
+
51
+ - [`echo_keys.rb`](https://github.com/coezbek/vtparser/tree/main/examples/echo_keys.rb): Echoes the keys pressed by the user
52
+ - [`indent_cli.rb`](https://github.com/coezbek/vtparser/tree/main/examples/indent_cli.rb): Indents the output of simple command line tools
53
+ - [`colorswap.rb`](https://github.com/coezbek/vtparser/tree/main/examples/colorswap.rb): Swaps the colors red / green in the input program
54
+ - [`analyze.rb`](https://github.com/coezbek/vtparser/tree/main/examples/analyze.rb): Output all VT100 escape sequences written by the subprocess.
55
+ - [`roundtrip.rb`](https://github.com/coezbek/vtparser/tree/main/examples/roundtrip.rb): Runs the given command and compares characters written by the command to the output of running the characters through the parser and serializing the actions back `to_ansi`. If the parser works correctly the output should be the same.
56
+
57
+ ## Limitations
58
+
59
+ - The parser is based on the implementation https://github.com/haberman/vtparse/ and based on a state machine which precedes Unicode. As such it does not have state transitions for Unicode characters. Rather, it will output them as `:ignore` actions. In case unicode characters are used inside escape sequences, the parser will likely not be able to handle them correctly.
60
+
61
+ - The state machine does not expose all input characters to the implementation in relationship to the `DSC` (Device Control String) sequences. In particular the "Final Character" is swallowed by the statemachine from https://www.vt100.net/emu/dec_ansi_parser. To circumvent this limitation, I have modified the parser to expose the final character as intermediate_chars to the `:hook` action.
62
+
63
+ - The parser only outputs full `actions`. So triggering an event for the `ESC` key doesn't work (as expected).
64
+
65
+ - The parser does not emit actions for commands which are 'interrupted' by another command."
66
+
67
+ - The parser does not explain any of the actions.
68
+
45
69
  ## Development
46
70
 
47
71
  After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -0,0 +1,21 @@
1
+ require_relative '../lib/vtparser'
2
+ require 'pty'
3
+ require 'tty-prompt' # for winsize call below
4
+
5
+ #
6
+ # This example demonstrates how to use the VTParser to analyze the VT100 escape sequences outputted by a program.
7
+ #
8
+
9
+ # Get the command from ARGV
10
+ command = ARGV.join(' ')
11
+ if command.empty?
12
+ puts "Usage: ruby indent_cli.rb '<command>'"
13
+ exit 1
14
+ end
15
+
16
+ # Instantiate the parser with a block to handle actions
17
+ parser = VTParser.new do |action|
18
+ puts action.inspect + "\r\n"
19
+ end
20
+
21
+ parser.spawn(command)
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ #
4
+ # This script outputs alternating red and green text to demonstrate the color-swapping functionality.
5
+ #
6
+
7
+ # ANSI escape sequences for colors
8
+ COLOR_RED = "\e[31m"
9
+ COLOR_GREEN = "\e[32m"
10
+ RESET_COLOR = "\e[0m"
11
+
12
+ # Infinite loop to print red and green text alternately
13
+ loop do
14
+ print "\r#{COLOR_RED}This is red text#{RESET_COLOR} "
15
+ sleep 1
16
+ print "\r#{COLOR_GREEN}This is green text#{RESET_COLOR}"
17
+ sleep 1
18
+ end
@@ -0,0 +1,40 @@
1
+ require 'pty'
2
+ require 'rainbow/refinement' # for colorizing output
3
+ using Rainbow
4
+ require_relative '../lib/vtparser'
5
+
6
+ #
7
+ # 'swap_colors_cli.rb' - Example for vtparser
8
+ #
9
+ # This example demonstrates how to use VTParser to swap colors (red to green, green to red) of a simple tty program.
10
+ #
11
+ # Run with `ruby swap_colors_cli.rb <command>`, where <command> is the command you want to run.
12
+ #
13
+
14
+ # Get the command from ARGV
15
+ command = ARGV.join(' ')
16
+ if command.empty?
17
+ puts "Usage: ruby swap_colors_cli.rb '<command>'"
18
+ exit 1
19
+ end
20
+
21
+ # VT100 color codes for red and green
22
+ COLOR_RED = "\e[31m"
23
+ COLOR_GREEN = "\e[32m"
24
+ RESET_COLOR = "\e[0m"
25
+
26
+ # VTParser block to swap red and green colors in the output
27
+ parser = VTParser.new do |action, ch, intermediate_chars, params|
28
+ to_output = VTParser::to_ansi(action, ch, intermediate_chars, params)
29
+
30
+ case to_output
31
+ when COLOR_RED
32
+ print COLOR_GREEN # Swap red to green
33
+ when COLOR_GREEN
34
+ print COLOR_RED # Swap green to red
35
+ else
36
+ print to_output # Default behavior for other output
37
+ end
38
+ end
39
+
40
+ parser.spawn(command)
@@ -0,0 +1,27 @@
1
+ require 'io/console'
2
+ require_relative '../lib/vtparser'
3
+
4
+ #
5
+ # Example for how to switch to raw mode to output infos about each keypress
6
+ #
7
+ STDIN.raw do |io|
8
+
9
+ parser = VTParser.new do |action, ch, intermediate_chars, params|
10
+
11
+ puts " New VTParser action: #{action}, ch: #{ch.inspect}, ch0x: #{ch.ord.to_s(16)}, intermediate_chars: #{intermediate_chars}, params: #{params}\r\n"
12
+
13
+ parser.to_key(action, ch, intermediate_chars, params) do |event|
14
+
15
+ puts " Keyevent: #{event.to_sym.inspect} #{event.inspect}\r\n"
16
+ exit(1) if event.to_sym == :ctrl_c
17
+
18
+ end
19
+ end
20
+
21
+ loop do
22
+ ch = $stdin.getch
23
+
24
+ puts "Getch: #{ch.inspect}\r\n"
25
+ parser.parse ch
26
+ end
27
+ end
@@ -4,6 +4,19 @@ require 'rainbow/refinement' # for colorizing output
4
4
  using Rainbow
5
5
  require_relative '../lib/vtparser'
6
6
 
7
+ #
8
+ # 'indent_cli.rb' - Example for vtparser
9
+ #
10
+ # This example demonstrates how to use the VTParser to indent the output of simple (!) tty programs
11
+ # with colorized or animated output.
12
+ #
13
+ # Run with `ruby indent_cli.rb <command>`` where <command> is the command you want to run.
14
+ #
15
+ # Two simple examples are included:
16
+ # - A simple spinner animation is included in `examples/spinner.rb`: `ruby indent_cli.rb 'ruby spinner.rb'`
17
+ # - A simple progress bar animation is included in `examples/progress.rb`: `ruby indent_cli.rb 'ruby progress.rb'`
18
+ #
19
+
7
20
  # Get the command from ARGV
8
21
  command = ARGV.join(' ')
9
22
  if command.empty?
@@ -12,56 +25,103 @@ if command.empty?
12
25
  end
13
26
 
14
27
  line_indent = ' ▐ '.yellow
28
+ line_indent_length = 6
29
+
30
+ #
31
+ # Use VTParser to process the VT100 escape sequences outputted by nested program and prepend the line_indent text.
32
+ #
15
33
  first_line = true
16
- parser = VTParser.new do |action, ch, intermediate_chars, params|
34
+ parser = VTParser.new do |action|
35
+
36
+ ch = action.ch
37
+ intermediate_chars = action.intermediate_chars
38
+ params = action.params
39
+ action_type = action.action_type
40
+ to_output = action.to_ansi
41
+
17
42
  print line_indent if first_line
18
43
  first_line = false
19
44
 
20
- to_output = VTParser::to_ansi(action, ch, intermediate_chars, params)
45
+ if $DEBUG && (action_type != :print || !(ch =~ /\P{Cc}/))
46
+ puts action.inspect
47
+ end
21
48
 
22
- case action
49
+ # Handle newlines, carriage returns, and cursor movement
50
+ case action_type
23
51
  when :print, :execute, :put, :osc_put
24
- if ch == "\n" || ch == "\r"
52
+ if ch == "\r" # || ch == "\n"
25
53
  print ch
26
54
  print line_indent
27
55
  next
28
56
  end
29
57
  when :csi_dispatch
30
- if to_output == "\e[2K"
58
+ if to_output == "\e[2K" # Clear line
31
59
  print "\e[2K"
32
60
  print line_indent
33
61
  next
34
62
  else
35
- if ch == 'G'
36
- # puts "to_output: #{to_output.inspect} action: #{action} ch: #{ch.inspect}"
37
- # && parser.params.size == 1
38
- print "\e[#{parser.params[0] + 6}G"
39
-
63
+ if ch == 'G' # Cursor movement to column
64
+ print "\e[#{parser.params[0] + line_indent_length}G"
40
65
  next
41
66
  end
42
67
  end
43
68
  end
44
69
 
70
+ if $DEBUG && (action_type != :print || !(ch =~ /\P{Cc}/))
71
+ puts "\r\n"
72
+ puts action.inspect
73
+ puts "\r\n"
74
+ # sleep 5
75
+ end
76
+
45
77
  print to_output
46
78
  end
47
79
 
48
80
  begin
49
81
  PTY.spawn(command) do |stdout_and_stderr, stdin, pid|
50
82
 
51
- Thread.new do
52
- while pid != nil
53
- stdin.write(STDIN.readpartial(1024)) # Requires user to press enter!
83
+ # Input Thread
84
+ input_thread = Thread.new do
85
+
86
+ STDIN.raw do |io|
87
+ loop do
88
+ break if pid.nil?
89
+ begin
90
+ if io.wait_readable(0.1)
91
+ data = io.read_nonblock(1024)
92
+ stdin.write data
93
+ end
94
+ rescue IO::WaitReadable
95
+ # No input available right now
96
+ rescue EOFError
97
+ break
98
+ rescue Errno::EIO
99
+ break
100
+ end
101
+ end
54
102
  end
55
- rescue => e
56
- puts "Error: #{e}"
57
- exit(0)
58
103
  end
59
104
 
105
+ # Pipe stdout and stderr to the parser
60
106
  begin
61
- stdout_and_stderr.winsize = $stdout.winsize
62
107
 
108
+ begin
109
+ winsize = $stdout.winsize
110
+ rescue Errno::ENOTTY
111
+ winsize = [0, 120] # Default to 120 columns
112
+ end
113
+ # Ensure the child process has the proper window size, because
114
+ # - tools such as yarn use it to identify tty mode
115
+ # - some tools use it to determine the width of the terminal for formatting
116
+ stdout_and_stderr.winsize = [winsize.first, winsize.last - line_indent_length]
117
+
63
118
  stdout_and_stderr.each_char do |char|
64
119
 
120
+ char = block.call(char) if block_given?
121
+ next if char.nil?
122
+
123
+ # puts Action.inspect_char(char) + "\r\n"
124
+ # Pass to parser
65
125
  parser.parse(char)
66
126
 
67
127
  end
@@ -72,13 +132,12 @@ begin
72
132
  # Wait for the child process to exit
73
133
  Process.wait(pid)
74
134
  pid = nil
75
- exit_status = $?.exitstatus
76
- result = exit_status == 0
77
-
78
- # Clear the line, reset the cursor to the start of the line
79
- print "\e[2K\e[1G"
135
+ input_thread.join
80
136
  end
81
137
 
82
138
  rescue PTY::ChildExited => e
83
139
  puts "The child process exited: #{e}"
84
- end
140
+ end
141
+
142
+ # Clear and reset the cursor to the start of the line
143
+ puts "\e[2K\e[1G"
@@ -0,0 +1,15 @@
1
+ require_relative '../lib/vtparser'
2
+
3
+ # Instantiate the parser with a block to handle actions
4
+ parser = VTParser.new do |action, ch, intermediate_chars, params|
5
+
6
+ # For this minimal example, we'll just turn everything back strings to print
7
+ print VTParser::to_ansi(action, ch, intermediate_chars, params)
8
+
9
+ end
10
+
11
+ # Sample input containing ANSI escape sequences (red text, bold text)
12
+ input = "\e[31mHello, \e[1mWorld!\e[0m\n"
13
+
14
+ # Parse the input
15
+ parser.parse(input)
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ #
4
+ # Helper script to be used to demonstrate `indent_cli.rb`. It displays a full-width animated progress bar.
5
+ #
6
+
7
+ require 'ruby-progressbar'
8
+
9
+ # Hide the cursor
10
+ print "\e[?25l"
11
+
12
+ loop do
13
+ progressbar = ProgressBar.create
14
+ 99.times {
15
+ progressbar.increment
16
+ sleep 0.02
17
+ }
18
+ print "\r"
19
+ end
@@ -0,0 +1,83 @@
1
+ require_relative '../lib/vtparser'
2
+ require 'pty'
3
+ require 'tty-prompt' # for winsize call below
4
+
5
+ COLOR_GREEN = "\e[32m"
6
+ RESET_COLOR = "\e[0m"
7
+
8
+ #
9
+ # This example checks if the output of VTParser is identical to the data fed into parser.
10
+ #
11
+ # Examples you can try:
12
+ #
13
+ # ruby roundtrip.rb 'vim' # Exit with :q
14
+ # ruby roundtrip.rb 'ls -la'
15
+ # ruby roundtrip.rb 'less' # Exit with q
16
+ # ruby roundtrip.rb 'top'
17
+ # Screensavers:
18
+ # ruby roundtrip.rb 'cmatrix' # sudo apt-get install cmatrix
19
+ # ruby roundtrip.rb 'neo' # install from https://github.com/st3w/neo
20
+ # Note: that upon termination a mismatch will be detected, because neo will cancel an on-going CSI sequence by sending an ESC character.
21
+ #
22
+
23
+ # Get the command from ARGV
24
+ command = ARGV.join(' ')
25
+ if command.empty?
26
+ puts "Usage: ruby indent_cli.rb '<command>'"
27
+ exit 1
28
+ end
29
+
30
+ captured = []
31
+ previous_actions = []
32
+ previous_characters = []
33
+
34
+ # Instantiate the parser with a block to handle actions
35
+ parser = VTParser.new do |action|
36
+
37
+ from_parser = action.to_ansi
38
+ from_parser.each_char.with_index do |char, i|
39
+ if captured.empty?
40
+ puts "ERROR: Parser has extra character after parsing: #{char.inspect}\r\n"
41
+ exit(1)
42
+ end
43
+ if char != captured.first
44
+ puts "\r\n"
45
+ puts "ERROR: Parser output does not match input with index #{i}: #{char.inspect} != #{captured.first.inspect}\r\n"
46
+ puts "Current captured characters: #{captured.inspect}\r\n"
47
+ puts "Current parser output: #{from_parser.inspect}\r\n"
48
+ puts "Previous characters: #{previous_characters.join.inspect}\r\n"
49
+
50
+ puts "\r\n"
51
+ previous_actions.each_with_index do |prev_action, i|
52
+ puts "Previous action -#{(previous_actions.length - i).to_s.rjust(2)}: #{prev_action.inspect}\r\n"
53
+ end
54
+ puts "Current action : #{action.inspect}\r\n"
55
+
56
+ exit(1)
57
+ end
58
+ matched_char = captured.shift
59
+ previous_characters << matched_char
60
+ if previous_characters.size > 20
61
+ previous_characters.shift
62
+ end
63
+
64
+ print "#{COLOR_GREEN}.#{RESET_COLOR}"
65
+ end
66
+
67
+ previous_actions << action
68
+ if previous_actions.size > 20
69
+ previous_actions.shift
70
+ end
71
+
72
+ end
73
+
74
+ parser.spawn(command) do |char|
75
+
76
+ captured << char
77
+
78
+ next char
79
+ end
80
+
81
+ puts
82
+ puts "#{COLOR_GREEN}SUCCESS: Parser output matches input.#{RESET_COLOR}"
83
+
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ #
4
+ # Helper script to be used to demonstrate `indent_cli.rb`
5
+ #
6
+
7
+ # See: https://github.com/sindresorhus/cli-spinners/blob/main/spinners.json
8
+ frames = [
9
+ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"
10
+ ]
11
+ interval = 0.08 # 80 milliseconds
12
+
13
+ # Infinite loop to display the animation until interrupted
14
+ loop do
15
+ frames.each do |frame|
16
+ print "\r #{frame} " # "\r" moves the cursor back to the start of the line
17
+ sleep(interval)
18
+ end
19
+ end
@@ -0,0 +1,177 @@
1
+ require 'set'
2
+
3
+ #
4
+ # Logic taken from vidarh/keyboard_map gem
5
+ #
6
+ # https://github.com/vidarh/keyboard_map
7
+ #
8
+ # See examples/keymap.rb for usage
9
+ #
10
+
11
+ class KeyEvent
12
+ attr_reader :modifiers, :key, :args
13
+
14
+ def initialize(key, *modifiers, args: nil)
15
+ @key = key
16
+ @args = args
17
+ @modifiers = modifiers.map(&:to_sym).to_set
18
+ end
19
+
20
+ def to_s
21
+ (modifiers.to_a.sort << key).join('_')
22
+ end
23
+
24
+ def to_sym
25
+ to_s.to_sym
26
+ end
27
+
28
+ def ==(other)
29
+ case other
30
+ when KeyEvent
31
+ self.modifiers == other.modifiers && self.key == other.key
32
+ when Symbol
33
+ self.to_sym == other
34
+ else
35
+ self.to_s == other
36
+ end
37
+ end
38
+ end
39
+
40
+ module Keymap
41
+
42
+ SINGLE_KEY_EVENT = {
43
+ "\t" => :tab,
44
+ "\r" => :enter,
45
+ "\n" => :enter,
46
+ "\u007F" => :backspace
47
+ }.freeze
48
+
49
+ CSI_BASIC_MAP = {
50
+ "A" => :up,
51
+ "B" => :down,
52
+ "C" => :right,
53
+ "D" => :left,
54
+ "E" => :keypad_5,
55
+ "F" => :end,
56
+ "H" => :home,
57
+ }.freeze
58
+
59
+ CSI_TILDE_MAP = {
60
+ "1" => :home,
61
+ "2" => :insert,
62
+ "3" => :delete,
63
+ "4" => :end,
64
+ "5" => :page_up,
65
+ "6" => :page_down,
66
+ "15" => :f5,
67
+ "17" => :f6,
68
+ "18" => :f7,
69
+ "19" => :f8,
70
+ "20" => :f9,
71
+ "21" => :f10,
72
+ "23" => :f11,
73
+ "24" => :f12,
74
+ }.freeze
75
+
76
+ SS3_KEY_MAP = {
77
+ "P" => :f1,
78
+ "Q" => :f2,
79
+ "R" => :f3,
80
+ "S" => :f4,
81
+ }.freeze
82
+
83
+ def map_modifiers(mod)
84
+ return [] if mod.nil? || mod < 2
85
+ modifiers = []
86
+ mod = mod - 1 # Subtract 1 to align with modifier bits
87
+ modifiers << :shift if mod & 1 != 0
88
+ modifiers << :alt if mod & 2 != 0
89
+ modifiers << :ctrl if mod & 4 != 0
90
+ modifiers
91
+ end
92
+
93
+ def to_key(action, ch, intermediate_chars, params, &block)
94
+
95
+ case action
96
+ when :execute, :print, :ignore
97
+ # Control characters (e.g., Ctrl+C)
98
+ key = map_control_character(ch)
99
+ yield key if key
100
+ #when :print, :ignore
101
+ # Regular printable characters
102
+ #yield KeyEvent.new(ch)
103
+ when :esc_dispatch
104
+ # ESC sequences without intermediates
105
+ if intermediate_chars == ''
106
+ key = process_esc_sequence(ch)
107
+ yield key if key
108
+ else
109
+ # Handle other ESC sequences if necessary
110
+ end
111
+ when :csi_dispatch
112
+ key = process_csi_sequence(params, intermediate_chars, ch)
113
+ yield key if key
114
+ when :collect, :param, :clear
115
+ # Handled internally; no action needed here
116
+ else
117
+ # Handle other actions if necessary
118
+ end
119
+ end
120
+
121
+ def map_control_character(ch)
122
+ if SINGLE_KEY_EVENT.key?(ch)
123
+ return KeyEvent.new(SINGLE_KEY_EVENT[ch])
124
+ elsif ch.ord.between?(0x01, 0x1A)
125
+ # Ctrl+A to Ctrl+Z
126
+ key = (ch.ord + 96).chr
127
+ return KeyEvent.new(key, :ctrl)
128
+ else
129
+ return KeyEvent.new(ch)
130
+ end
131
+ end
132
+
133
+ def process_esc_sequence(final_char)
134
+ case final_char
135
+ when 'Z'
136
+ # Shift+Tab
137
+ return KeyEvent.new(:tab, :shift)
138
+ when "\e"
139
+ # Double ESC
140
+ return KeyEvent.new(:esc)
141
+ else
142
+ # Meta key (Alt) combinations
143
+ if final_char.ord.between?(0x20, 0x7E)
144
+ return KeyEvent.new(final_char, :meta)
145
+ else
146
+ # Handle other ESC sequences if necessary
147
+ end
148
+ end
149
+ end
150
+
151
+ def process_csi_sequence(params, intermediate_chars, final_char)
152
+ key = nil
153
+ modifiers = []
154
+ params = params.map(&:to_i)
155
+
156
+ if intermediate_chars == ''
157
+ if final_char == '~'
158
+ # Sequences like ESC [ 1 ~
159
+ key = CSI_TILDE_MAP[params[0].to_s]
160
+ modifiers = map_modifiers(params[1]) if params.size > 1
161
+ else
162
+ # Sequences like ESC [ A
163
+ key = CSI_BASIC_MAP[final_char]
164
+ modifiers = map_modifiers(params[0]) if params.size > 0
165
+ end
166
+ else
167
+ # Handle intermediates if necessary
168
+ end
169
+
170
+ if key
171
+ return KeyEvent.new(key, *modifiers)
172
+ else
173
+ # Handle unrecognized sequences
174
+ end
175
+ end
176
+
177
+ end
@@ -1,10 +1,76 @@
1
+ require_relative "keymap"
2
+ require_relative "pty_support"
3
+
4
+ class Action
5
+
6
+ attr_reader :action_type, :ch, :private_mode_intermediate_char, :intermediate_chars, :params
7
+
8
+ def initialize(action_type, ch, private_mode_intermediate_char, intermediate_chars, params)
9
+ @action_type = action_type
10
+ @ch = ch
11
+ @intermediate_chars = intermediate_chars
12
+ @private_mode_intermediate_char = private_mode_intermediate_char
13
+ @params = params
14
+ end
15
+
16
+ def to_s
17
+ to_ansi
18
+ end
19
+
20
+ def inspect
21
+ "ansi: #{to_ansi.inspect.ljust(16)} " +
22
+ "action: #{@action_type.to_s.ljust(12)} #{Action.inspect_char(@ch)} " +
23
+ "private mode: #{"'#{@private_mode_intermediate_char}'".ljust(3)} " +
24
+ "params: '#{@params.inspect.ljust(20)}' " +
25
+ "intermediate_chars: '#{@intermediate_chars}'"
26
+ end
27
+
28
+ def self.inspect_char(ch)
29
+ "ch: #{ch.inspect.ljust(4)} (ord=#{ch ? ch.ord.to_s.rjust(4) : " "}, hex=0x#{ch ? ("%02x" % ch.ord).rjust(6) : " "})"
30
+ end
31
+
32
+ def to_ansi
33
+
34
+ case @action_type
35
+ when :print, :execute, :put, :osc_put, :ignore
36
+ # Output the character
37
+ return @ch if @ch
38
+ when :hook
39
+ return "\eP#{@intermediate_chars}"
40
+ when :esc_dispatch
41
+ return "\e#{@intermediate_chars}#{@ch}"
42
+ when :csi_dispatch
43
+ # Output ESC [ followed by parameters, intermediates, and final character
44
+ return "\e[#{@private_mode_intermediate_char}#{@params.join(';')}#{@intermediate_chars}#{@ch}"
45
+ when :osc_start
46
+ return "\e]"
47
+ when :osc_end
48
+ return '' # "\x07" # BEL character to end OSC
49
+ when :unhook
50
+ return "" # \e must come from ESCAPE state
51
+ when :clear # Clear action is called when a command is interrupted by a new command (there is unfinished content in the parser)
52
+
53
+ else
54
+ raise "Unknown action type: #{@action_type}"
55
+ end
56
+
57
+ raise
58
+ end
59
+
60
+ end
61
+
62
+
1
63
  class VTParser
2
- attr_reader :intermediate_chars, :params
64
+ attr_reader :private_mode_intermediate_char, :intermediate_chars, :params
65
+
66
+ include Keymap
67
+ include PtySupport
3
68
 
4
69
  def initialize(&block)
5
70
  @callback = block
6
71
  @state = :GROUND
7
72
  @intermediate_chars = ''
73
+ @private_mode_intermediate_char = ''
8
74
  @params = []
9
75
  @ignore_flagged = false
10
76
  initialize_states
@@ -64,7 +130,7 @@ class VTParser
64
130
  0x3a => :CSI_IGNORE,
65
131
  (0x30..0x39) => [:param, :CSI_PARAM],
66
132
  0x3b => [:param, :CSI_PARAM],
67
- (0x3c..0x3f) => [:collect, :CSI_PARAM],
133
+ (0x3c..0x3f) => [:private_mode_collect, :CSI_PARAM],
68
134
  (0x40..0x7e) => [:csi_dispatch, :GROUND],
69
135
  },
70
136
  :CSI_PARAM => {
@@ -145,7 +211,9 @@ class VTParser
145
211
  },
146
212
  :OSC_STRING => {
147
213
  :on_entry => :osc_start,
148
- (0x00..0x17) => :ignore,
214
+ (0x00..0x06) => :ignore,
215
+ (0x07) => [:osc_put, :GROUND], # BEL character for xterm compatibility
216
+ (0x08..0x17) => :ignore,
149
217
  0x19 => :ignore,
150
218
  (0x1c..0x1f) => :ignore,
151
219
  (0x20..0x7f) => :osc_put,
@@ -249,15 +317,15 @@ class VTParser
249
317
 
250
318
  def handle_action(action, ch)
251
319
  case action
252
- when :execute, :print, :esc_dispatch, :csi_dispatch, :hook, :put, :unhook, :osc_start, :osc_put, :osc_end
253
- @callback.call(action, ch, intermediate_chars, params) if @callback
254
- when :ignore
255
- # Do nothing
256
- @callback.call(action, ch, intermediate_chars, params) if @callback
320
+ when :private_mode_collect
321
+ raise "Private mode intermediate char already set" unless @private_mode_intermediate_char.empty?
322
+ @private_mode_intermediate_char = ch
323
+ return
257
324
  when :collect
258
325
  unless @ignore_flagged
259
326
  @intermediate_chars << ch
260
327
  end
328
+ return
261
329
  when :param
262
330
  if ch == ';'
263
331
  @params << 0
@@ -267,39 +335,25 @@ class VTParser
267
335
  end
268
336
  @params[-1] = @params[-1] * 10 + (ch.ord - '0'.ord)
269
337
  end
338
+ return
270
339
  when :clear
340
+
341
+ # Warning: If ESC is sent in the middle of a command, the command is cleared and there is no callback being called.
342
+
271
343
  @intermediate_chars = ''
344
+ @private_mode_intermediate_char = ''
272
345
  @params = []
273
346
  @ignore_flagged = false
274
- else
275
- @callback.call(:error, ch, intermediate_chars, params) if @callback
276
- end
277
- end
278
347
 
279
- def self.to_ansi(action, ch, intermediate_chars, params)
280
-
281
- case action
282
- when :print, :execute, :put, :osc_put, :ignore
283
- # Output the character
284
- return ch if ch
285
- when :hook
286
- return "\eP#{intermediate_chars}"
287
- when :esc_dispatch
288
- return "\e#{intermediate_chars}#{ch}"
289
- when :csi_dispatch
290
- # Output ESC [ followed by parameters, intermediates, and final character
291
- return "\e[#{params.join(';')}#{intermediate_chars}#{ch}"
292
- when :osc_start
293
- return "\e]"
294
- when :osc_end
295
- return "\x07" # BEL character to end OSC
296
- when :unhook
297
- return "" # \e must come from ESCAPE state
298
348
  else
299
- raise "Unknown action: #{action}"
300
- end
349
+ # when :execute, :print, :esc_dispatch, :csi_dispatch, :hook, :put, :unhook, :osc_start, :osc_put, :osc_end, :ignore
350
+ @callback.call(Action.new(action, ch, private_mode_intermediate_char, intermediate_chars, params)) if @callback
301
351
 
302
- raise
352
+ @intermediate_chars = ''
353
+ @private_mode_intermediate_char = ''
354
+ @params = []
355
+ @ignore_flagged = false
356
+ end
303
357
  end
304
358
 
305
359
  end
@@ -0,0 +1,81 @@
1
+ #
2
+ # This module can only be included in class which has a parse method to receive the next character read.
3
+ #
4
+ module PtySupport
5
+
6
+ #
7
+ # Spawn the given command using PTY::spawn, connect pipes and calls parse for each character read.
8
+ #
9
+ # Caution: While this command is running, STDIN will be in raw mode.
10
+ #
11
+ # If a block is given, it will be called for each character read, and the character
12
+ # will be replaced by the block's return value. If nil is returned by block, the character will be dropped.
13
+ #
14
+ def spawn(command, &block)
15
+
16
+ begin
17
+ PTY.spawn(command) do |stdout_and_stderr, stdin, pid|
18
+
19
+ # Input Thread
20
+ input_thread = Thread.new do
21
+
22
+ STDIN.raw do |io|
23
+ loop do
24
+ break if pid.nil?
25
+ begin
26
+ if io.wait_readable(0.1)
27
+ data = io.read_nonblock(1024)
28
+ stdin.write data
29
+ end
30
+ rescue IO::WaitReadable
31
+ # No input available right now
32
+ rescue EOFError
33
+ break
34
+ rescue Errno::EIO
35
+ break
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ # Pipe stdout and stderr to the parser
42
+ begin
43
+
44
+ begin
45
+ winsize = $stdout.winsize
46
+ rescue Errno::ENOTTY
47
+ winsize = [0, 120] # Default to 120 columns
48
+ end
49
+ # Ensure the child process has the proper window size, because
50
+ # - tools such as yarn use it to identify tty mode
51
+ # - some tools use it to determine the width of the terminal for formatting
52
+ stdout_and_stderr.winsize = winsize
53
+
54
+ stdout_and_stderr.each_char do |char|
55
+
56
+ char = block.call(char) if block_given?
57
+ next if char.nil?
58
+
59
+ # puts Action.inspect_char(char) + "\r\n"
60
+ # Pass to parser
61
+ parse(char)
62
+
63
+ end
64
+ rescue Errno::EIO
65
+ # End of output
66
+ end
67
+
68
+ # Wait for the child process to exit
69
+ Process.wait(pid)
70
+ pid = nil
71
+ input_thread.join
72
+ return exit_status = $?.exitstatus
73
+
74
+ end
75
+
76
+ rescue PTY::ChildExited => e
77
+ puts "The child process exited: #{e}"
78
+ end
79
+ end
80
+
81
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vtparser
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vtparser
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christopher Oezbek
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-10-01 00:00:00.000000000 Z
11
+ date: 2024-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: tty-prompt
@@ -58,14 +58,28 @@ dependencies:
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: 0.40.0
61
+ version: '0.40'
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: 0.40.0
68
+ version: '0.40'
69
+ - !ruby/object:Gem::Dependency
70
+ name: ruby-progressbar
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
69
83
  description: A pure Ruby VT100 parser that can be used to parse ANSI escape sequences.
70
84
  email:
71
85
  - c.oezbek@gmail.com
@@ -73,13 +87,22 @@ executables: []
73
87
  extensions: []
74
88
  extra_rdoc_files: []
75
89
  files:
76
- - ".bash_history"
77
90
  - CHANGELOG.md
78
91
  - README.md
79
92
  - Rakefile
93
+ - examples/analyze.rb
94
+ - examples/colors.rb
95
+ - examples/colorswap.rb
96
+ - examples/echo_keys.rb
80
97
  - examples/indent_cli.rb
98
+ - examples/minimal.rb
99
+ - examples/progress.rb
100
+ - examples/roundtrip.rb
101
+ - examples/spinner.rb
81
102
  - lib/vtparser.rb
103
+ - lib/vtparser/keymap.rb
82
104
  - lib/vtparser/parser.rb
105
+ - lib/vtparser/pty_support.rb
83
106
  - lib/vtparser/version.rb
84
107
  homepage: https://github.com/coezbek/vtparser
85
108
  licenses: []
@@ -103,7 +126,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
103
126
  - !ruby/object:Gem::Version
104
127
  version: '0'
105
128
  requirements: []
106
- rubygems_version: 3.4.19
129
+ rubygems_version: 3.5.20
107
130
  signing_key:
108
131
  specification_version: 4
109
132
  summary: Pure Ruby VT100 parser
data/.bash_history DELETED
@@ -1,117 +0,0 @@
1
- rspec
2
- rspec
3
- rspec
4
- rspec
5
- rspec
6
- rspec
7
- rspec
8
- rspec
9
- rspec
10
- rspec
11
- rspec ./spec/vtparser_spec.rb:153
12
- rspec ./spec/vtparser_spec.rb:153
13
- rspec ./spec/vtparser_spec.rb:153
14
- rspec ./spec/vtparser_spec.rb:153
15
- rspec ./spec/vtparser_spec.rb:153
16
- rspec ./spec/vtparser_spec.rb:153
17
- rspec
18
- rspec
19
- rspec
20
- rspec ./spec/vtparser_spec.rb:37
21
- rspec ./spec/vtparser_spec.rb:37
22
- rspec
23
- rspec
24
- rspec expect(output).to eq(input)
25
- rspec ./spec/vtparser_spec.rb:156
26
- rspec ./spec/vtparser_spec.rb:156
27
- rspec ./spec/vtparser_spec.rb:156
28
- rspec ./spec/vtparser_spec.rb:156
29
- rspec
30
- rspec
31
- D
32
- rspec ./spec/vtparser_spec.rb
33
- rspec ./spec/vtparser_spec.rb:137
34
- rspec ./spec/vtparser_spec.rb:137
35
- rspec ./spec/vtparser_spec.rb:137
36
- rspec ./spec/vtparser_spec.rb:137
37
- rspec ./spec/vtparser_spec.rb:137
38
- rspec ./spec/vtparser_spec.rb:137
39
- rspec ./spec/vtparser_spec.rb:137
40
- rspec ./spec/vtparser_spec.rb:137
41
- rspec ./spec/vtparser_spec.rb
42
- rspec ./spec/vtparser_spec.rb
43
- rspec ./spec/vtparser_spec.rb
44
- bundle
45
- rspec ./spec/vtparser_spec.rb
46
- rspec ./spec/vtparser_spec.rb
47
- ruby lib/vtparser.rb 'yarn add --dev esbuild from "."'
48
- ruby lib/vtparser/parser.rb 'yarn add --dev esbuild from "."'
49
- ruby lib/vtparser/parser.rb 'yarn add --dev esbuild from "."'
50
- ruby lib/vtparser/parser.rb 'yarn add --dev esbuild from "."'
51
- ruby lib/vtparser/parser.rb 'yarn add --dev esbuild from "."'
52
- ruby lib/vtparser/parser.rb 'yarn add --dev esbuild from "."'
53
- bundle
54
- bundle
55
- ruby lib/vtparser/parser.rb 'yarn add --dev esbuild from "."'
56
- irb
57
- ruby lib/vtparser/parser.rb 'yarn add --dev esbuild from "."'
58
- ruby lib/vtparser/parser.rb 'yarn add --dev esbuild from "."'
59
- ruby lib/vtparser/parser.rb 'yarn add --dev esbuild from "."'
60
- ruby lib/vtparser/parser.rb 'joe'
61
- joe
62
- ruby lib/vtparser/parser.rb 'vim'
63
- vim
64
- ruby example/indent_cli.rb 'vim'
65
- ruby examples/indent_cli.rb 'vim'
66
- ruby examples/indent_cli.rb 'vim'
67
- ruby examples/indent_cli.rb 'vim'
68
- ruby examples/indent_cli.rb 'joe'
69
- ruby examples/indent_cli.rb 'joe'
70
- ruby examples/indent_cli.rb 'joe'
71
- lsss
72
- less
73
- less README.md
74
- ls
75
- exit
76
- ls
77
- ruby examples/indent_cli.rb 'less'
78
- ruby examples/indent_cli.rb 'less README.md'
79
- ruby examples/indent_cli.rb 'less README.md'
80
- ruby examples/indent_cli.rb 'less README.md'
81
- ruby examples/indent_cli.rb 'less README.md'
82
- ruby examples/indent_cli.rb 'less README.md'
83
- ruby examples/indent_cli.rb 'less README.md'
84
- ruby examples/indent_cli.rb 'less README.md'
85
- ruby examples/indent_cli.rb 'less README.md'
86
- ruby examples/indent_cli.rb 'less README.md'
87
- ruby examples/indent_cli.rb 'less README.md'
88
- ruby examples/indent_cli.rb 'less README.md'
89
- ruby examples/indent_cli.rb 'less README.md'
90
- ruby examples/indent_cli.rb 'less README.md'
91
- ruby examples/indent_cli.rb 'less README.md'
92
- less README.md
93
- ruby examples/indent_cli.rb 'less README.md'
94
- ruby examples/indent_cli.rb 'less README.md'
95
- ruby examples/indent_cli.rb 'less README.md'
96
- ruby examples/indent_cli.rb 'less README.md'
97
- ruby examples/indent_cli.rb 'less README.md'
98
- ruby examples/indent_cli.rb 'less README.md'
99
- ruby examples/indent_cli.rb 'less README.md'
100
- ruby examples/indent_cli.rb 'less README.md'
101
- ruby examples/indent_cli.rb 'less README.md'
102
- ruby examples/indent_cli.rb 'less README.md'
103
- ruby examples/test.rb
104
- kkllllijjaaa
105
- ruby examples/test.rb
106
- ruby examples/test.rb
107
- ruby examples/test.rb
108
- ruby examples/test.rb
109
- ruby examples/test.rb
110
- ruby examples/test.rb
111
- ruby examples/test.rb
112
- ruby examples/test.rb
113
- ruby examples/test.rb
114
- ruby examples/test.rb
115
- ruby examples/indent_cli.rb
116
- ruby examples/indent_cli.rb 'bundle gem test17'
117
- ruby examples/indent_cli.rb 'bundle gem test18'