tarazed 0.1.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.
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ module Tarazed
6
+ module Windows
7
+ class Library
8
+ def initialize(name)
9
+ @handle = Fiddle.dlopen(name)
10
+ @functions = {}
11
+ end
12
+
13
+ def fn(name, arguments, result, need_gvl: true)
14
+ @functions[[name, arguments, result, need_gvl]] ||= Fiddle::Function.new(
15
+ @handle[name.to_s], arguments, result, name: name.to_s, need_gvl: need_gvl
16
+ )
17
+ end
18
+ end
19
+
20
+ # Windows ConPTY backend. PTY owns the shared grid and VT parser.
21
+ class ConPTY
22
+ P, I, U, N, V = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_UINT, Fiddle::TYPE_SIZE_T,
23
+ Fiddle::TYPE_VOID
24
+ private_constant :P, :I, :U, :N, :V
25
+
26
+ attr_reader :pid
27
+
28
+ def initialize(command: nil, columns: 80, rows: 24, cwd: nil, env: {}, kernel: nil)
29
+ raise Error, "ConPTY requires 64-bit Windows Ruby" unless Fiddle::SIZEOF_VOIDP == 8
30
+
31
+ @kernel = kernel || Library.new("kernel32.dll")
32
+ @output, @pending, @closed, @eof = Queue.new, +"".b, false, false
33
+ input_read, @input_write = pipe
34
+ @output_read, output_write = pipe
35
+ console = [0].pack("J")
36
+ check_hresult(function(:CreatePseudoConsole, [I, P, P, U, P], I).call(
37
+ coordinate(columns, rows), input_read, output_write, 0, console
38
+ ))
39
+ @console = console.unpack1("J")
40
+ attributes = process_attributes(@console)
41
+ @process, @pid = spawn(command, cwd, env, attributes)
42
+ start_reader
43
+ rescue Fiddle::DLError, LoadError => error
44
+ release
45
+ raise Error, "ConPTY is unavailable: #{error.message}"
46
+ rescue
47
+ release
48
+ raise
49
+ ensure
50
+ begin
51
+ function(:DeleteProcThreadAttributeList, [P], V).call(attributes) if attributes
52
+ rescue StandardError
53
+ nil
54
+ end
55
+ close_handle(input_read)
56
+ close_handle(output_write)
57
+ end
58
+
59
+ # CommandLineToArgvW/CRT escaping.
60
+ def self.quote_argument(value)
61
+ return value unless value.empty? || value.match?(/[\s"]/)
62
+
63
+ '"' + value.gsub(/(\\*)"/) { Regexp.last_match(1) * 2 + '\\"' }
64
+ .sub(/(\\+)\z/) { Regexp.last_match(1) * 2 } + '"'
65
+ end
66
+
67
+ def read_available(limit: 65_536)
68
+ raise ArgumentError, "limit must be positive" unless limit.is_a?(Integer) && limit.positive?
69
+
70
+ until @output.empty?
71
+ bytes = @output.pop
72
+ if bytes.nil?
73
+ @eof = true
74
+ break
75
+ end
76
+ @pending << bytes
77
+ end
78
+ return nil if @pending.empty? && @eof
79
+
80
+ @pending.slice!(0, limit)
81
+ end
82
+
83
+ def pending? = !@pending.empty? || !@output.empty?
84
+
85
+ def write(bytes)
86
+ raise IOError, "terminal closed" if @closed
87
+
88
+ offset = 0
89
+ while offset < bytes.bytesize
90
+ chunk = bytes.byteslice(offset, bytes.bytesize - offset)
91
+ count = [0].pack("I")
92
+ check(function(:WriteFile, [P, P, U, P, P], I, need_gvl: false).call(
93
+ @input_write, chunk, chunk.bytesize, count, 0
94
+ ), "WriteFile")
95
+ written = count.unpack1("I")
96
+ raise IOError, "ConPTY write made no progress" if written.zero?
97
+
98
+ offset += written
99
+ end
100
+ offset
101
+ end
102
+
103
+ def resize(columns, rows)
104
+ check_hresult(function(:ResizePseudoConsole, [P, I], I).call(@console, coordinate(columns, rows)))
105
+ end
106
+
107
+ def alive?
108
+ !@closed && function(:WaitForSingleObject, [P, U], U).call(@process, 0) == 258
109
+ end
110
+
111
+ def close
112
+ return self if @closed
113
+
114
+ @closed = true
115
+ close_handle(@input_write)
116
+ @input_write = nil
117
+ close_console
118
+ @reader&.join(2)
119
+ close_handle(@output_read)
120
+ close_handle(@process)
121
+ @output_read = @process = nil
122
+ self
123
+ end
124
+
125
+ private
126
+
127
+ def function(name, arguments, result, need_gvl: true)
128
+ @kernel.fn(name, arguments, result, need_gvl: need_gvl)
129
+ end
130
+
131
+ def process_attributes(console)
132
+ size = [0].pack("J")
133
+ function(:InitializeProcThreadAttributeList, [P, U, U, P], I).call(0, 1, 0, size)
134
+ attributes = Fiddle::Pointer.malloc(size.unpack1("J"), Fiddle::RUBY_FREE)
135
+ check(function(:InitializeProcThreadAttributeList, [P, U, U, P], I).call(
136
+ attributes, 1, 0, size
137
+ ), "InitializeProcThreadAttributeList")
138
+ check(function(:UpdateProcThreadAttribute, [P, U, N, P, N, P, P], I).call(
139
+ attributes, 0, 0x20016, console, Fiddle::SIZEOF_VOIDP, 0, 0
140
+ ), "UpdateProcThreadAttribute")
141
+ attributes
142
+ rescue
143
+ safely { function(:DeleteProcThreadAttributeList, [P], V).call(attributes) } if attributes
144
+ raise
145
+ end
146
+
147
+ def spawn(command, cwd, env, attributes)
148
+ startup = "\0".b * 112
149
+ startup[0, 4] = [112].pack("I")
150
+ startup[60, 4] = [0x100].pack("I") # STARTF_USESTDHANDLES
151
+ startup[104, 8] = [attributes.to_i].pack("J")
152
+ info = "\0".b * 24
153
+ command ||= ENV.fetch("COMSPEC", "cmd.exe")
154
+ raise ArgumentError, "terminal command required" if command.respond_to?(:empty?) && command.empty?
155
+
156
+ line = if command.is_a?(Array)
157
+ command.map { |part| self.class.quote_argument(part.to_s) }.join(" ")
158
+ else
159
+ command.to_s
160
+ end
161
+ line = wide(line)
162
+ directory = cwd ? wide(File.expand_path(cwd)) : nil
163
+ environment = environment_block(env)
164
+ check(function(:CreateProcessW, [P, P, P, P, I, U, P, P, P, P], I).call(
165
+ 0, line, 0, 0, 0, 0x00080400, environment || 0, directory || 0, startup, info
166
+ ), "CreateProcessW")
167
+ process, thread, pid = info.unpack("JJI")
168
+ close_handle(thread)
169
+ [process, pid]
170
+ end
171
+
172
+ def environment_block(overrides)
173
+ return if overrides.empty?
174
+
175
+ environment = ENV.to_h
176
+ overrides.each do |key, value|
177
+ key = key.to_s
178
+ value.nil? ? environment.delete(key) : environment[key] = value.to_s
179
+ end
180
+ (environment.sort_by { |key, _| key.downcase }.map { |key, value| "#{key}=#{value}\0" }.join + "\0")
181
+ .encode("UTF-16LE").b
182
+ end
183
+
184
+ def wide(text) = text.encode("UTF-16LE").b + "\0\0"
185
+
186
+ def coordinate(columns, rows)
187
+ unless columns.is_a?(Integer) && rows.is_a?(Integer) && columns.between?(1, 32_767) && rows.between?(1, 32_767)
188
+ raise ArgumentError, "invalid terminal size"
189
+ end
190
+
191
+ [columns, rows].pack("s2").unpack1("l")
192
+ end
193
+
194
+ def pipe
195
+ read_handle, write_handle = [0].pack("J"), [0].pack("J")
196
+ check(function(:CreatePipe, [P, P, P, U], I).call(read_handle, write_handle, 0, 0), "CreatePipe")
197
+ [read_handle.unpack1("J"), write_handle.unpack1("J")]
198
+ end
199
+
200
+ def start_reader
201
+ @reader = Thread.new do
202
+ loop do
203
+ bytes, count = "\0".b * 65_536, [0].pack("I")
204
+ ok = function(:ReadFile, [P, P, U, P, P], I, need_gvl: false).call(
205
+ @output_read, bytes, bytes.bytesize, count, 0
206
+ )
207
+ break if ok.zero? || count.unpack1("I").zero?
208
+
209
+ @output << bytes.byteslice(0, count.unpack1("I"))
210
+ end
211
+ ensure
212
+ @output << nil
213
+ end
214
+ end
215
+
216
+ def close_console
217
+ return unless @console
218
+
219
+ function(:ClosePseudoConsole, [P], V, need_gvl: false).call(@console)
220
+ @console = nil
221
+ end
222
+
223
+ def release
224
+ safely { close_handle(@input_write) }
225
+ safely { close_console }
226
+ @reader&.join(2)
227
+ safely { close_handle(@output_read) }
228
+ safely { close_handle(@process) }
229
+ @input_write = @output_read = @process = nil
230
+ end
231
+
232
+ def safely
233
+ yield
234
+ rescue StandardError
235
+ nil
236
+ end
237
+
238
+ def close_handle(handle)
239
+ function(:CloseHandle, [P], I).call(handle) if handle && !handle.zero?
240
+ end
241
+
242
+ def check(result, name)
243
+ raise SystemCallError.new(name, Fiddle.last_error) if result.zero?
244
+
245
+ result
246
+ end
247
+
248
+ def check_hresult(result)
249
+ raise Error, "ConPTY failure 0x#{(result & 0xffffffff).to_s(16)}" unless (result & 0x80000000).zero?
250
+
251
+ result
252
+ end
253
+ end
254
+ end
255
+ end
data/lib/tarazed.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tarazed/version"
4
+ require_relative "tarazed/cell"
5
+ require_relative "tarazed/scrollback"
6
+ require_relative "tarazed/grid"
7
+ require_relative "tarazed/vt"
8
+ require_relative "tarazed/pty"
9
+
10
+ module Tarazed
11
+ class Error < StandardError; end
12
+ end
data/sig/tarazed.rbs ADDED
@@ -0,0 +1,139 @@
1
+ module Tarazed
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ type color = Integer | [Integer, Integer, Integer] | nil
8
+ type attributes = Hash[Symbol, bool | Integer | [Integer, Integer, Integer]]
9
+ type mouse_action = :press | :move | :release
10
+
11
+ class Cell < Struct[untyped]
12
+ attr_accessor text: String
13
+ attr_accessor width: Integer
14
+ attr_accessor foreground: color
15
+ attr_accessor background: color
16
+ attr_accessor attributes: attributes
17
+ attr_accessor hyperlink: String?
18
+
19
+ def self.new: (text: String, width: Integer, ?foreground: color, ?background: color,
20
+ attributes: attributes, ?hyperlink: String?) -> instance
21
+ end
22
+
23
+ class Scrollback
24
+ include Enumerable[Array[Cell]]
25
+
26
+ attr_reader limit: Integer
27
+
28
+ def initialize: (Integer limit) -> void
29
+ def clear: () -> Array[Array[Cell]]
30
+ def length: () -> Integer
31
+ alias size length
32
+ def []: (Integer index) -> Array[Cell]?
33
+ def each: () -> Enumerator[Array[Cell], Array[Array[Cell]]]
34
+ | () { (Array[Cell]) -> void } -> Array[Array[Cell]]
35
+ def push: (Array[Cell] cells) -> Array[Array[Cell]]?
36
+ end
37
+
38
+ class Grid
39
+ attr_reader columns: Integer
40
+ attr_reader rows: Integer
41
+ attr_reader cells: Array[Array[Cell]]
42
+ attr_reader scrollback: Scrollback
43
+ attr_reader cursor_x: Integer
44
+ attr_reader cursor_y: Integer
45
+ attr_reader scroll_top: Integer
46
+ attr_reader scroll_bottom: Integer
47
+ attr_accessor foreground: color
48
+ attr_accessor background: color
49
+ attr_accessor attributes: attributes
50
+ attr_accessor hyperlink: String?
51
+ attr_accessor autowrap: bool
52
+ attr_accessor insert_mode: bool
53
+ attr_accessor origin_mode: bool
54
+ attr_accessor cursor_visible: bool
55
+
56
+ def initialize: (?columns: Integer, ?rows: Integer, ?scrollback: Integer) -> void
57
+ def reset: () -> void
58
+ def []: (Integer row, ?nil column) -> Array[Cell]?
59
+ | (Integer row, Integer column) -> Cell?
60
+ def alternate?: () -> bool
61
+ def lines: () -> Array[String]
62
+ def text: () -> String
63
+ def put: (String char) -> void
64
+ def put_ascii: (String text) -> void
65
+ def self.width: (String char) -> Integer
66
+ def move: (?x: Integer, ?y: Integer, ?relative: bool) -> void
67
+ def position: (Integer row, Integer column) -> void
68
+ def carriage_return: () -> void
69
+ def backspace: () -> void
70
+ def linefeed: () -> void
71
+ def reverse_index: () -> void
72
+ def tab: (?Integer count, ?backward: bool) -> void
73
+ def tab_set: () -> Array[Integer]
74
+ def tab_clear: (?all: bool) -> void
75
+ def margins: (?Integer top, ?Integer bottom) -> void
76
+ def scroll_up: (?Integer count) -> void
77
+ def scroll_down: (?Integer count) -> void
78
+ def erase_display: (?Integer mode) -> void
79
+ def erase_line: (?Integer mode) -> void
80
+ def erase_characters: (?Integer count) -> void
81
+ def insert_characters: (?Integer count) -> void
82
+ def delete_characters: (?Integer count) -> void
83
+ def insert_lines: (?Integer count) -> void
84
+ def delete_lines: (?Integer count) -> void
85
+ def save_cursor: () -> void
86
+ def restore_cursor: () -> void
87
+ def alternate: (bool enable, ?save: bool) -> void
88
+ def resize: (columns: Integer, rows: Integer) -> void
89
+ def selection: ([Integer, Integer] start, [Integer, Integer] finish, ?history: bool) -> String
90
+ def links: (Integer row) -> Array[{url: String, column: Integer, end_column: Integer}]
91
+ def file_paths: (Integer row, ?cwd: String) -> Array[{path: String, line: Integer, column: Integer}]
92
+ end
93
+
94
+ class VT
95
+ DEC_GRAPHICS: Hash[String, String]
96
+
97
+ attr_reader grid: Grid
98
+ attr_reader title: String?
99
+ attr_reader cwd: String?
100
+ attr_reader modes: Hash[Integer | Symbol, bool]
101
+ attr_reader replies: Array[String]
102
+ attr_reader bell_count: Integer
103
+
104
+ def initialize: (?Grid grid) ?{ (String) -> void } -> void
105
+ def feed: (String bytes) -> self
106
+ def paste: (String text) -> String
107
+ def mouse: (button: Symbol | Integer | nil, column: Integer, row: Integer, ?action: mouse_action,
108
+ ?shift: bool, ?alt: bool, ?control: bool) -> String
109
+ def key: (String | Symbol name, ?shift: bool, ?alt: bool, ?control: bool) -> String
110
+ end
111
+
112
+ class PTY
113
+ attr_reader reader: IO?
114
+ attr_reader writer: IO?
115
+ attr_reader pid: Integer
116
+ attr_reader grid: Grid
117
+ attr_reader vt: VT
118
+ attr_reader status: Process::Status?
119
+ attr_reader initial_cwd: String
120
+ attr_reader command_name: String
121
+
122
+ def initialize: (?command: String | Array[String], ?cwd: String, ?columns: Integer, ?rows: Integer,
123
+ ?env: Hash[String, String?], ?scrollback: Integer, ?queue_limit_bytes: Integer) -> void
124
+ def read: (?timeout: Numeric, ?max_bytes: Integer, ?max_seconds: Numeric?) -> String?
125
+ def pending?: () -> bool
126
+ def write: (String bytes) -> Integer
127
+ def paste: (String text) -> Integer
128
+ def key: (String | Symbol name, ?shift: bool, ?alt: bool, ?control: bool) -> Integer
129
+ def mouse: (button: Symbol | Integer | nil, column: Integer, row: Integer, ?action: mouse_action,
130
+ ?shift: bool, ?alt: bool, ?control: bool) -> Integer
131
+ def resize: (columns: Integer, rows: Integer) -> self
132
+ def signal: (?String | Integer name) -> (Integer | false)
133
+ def alive?: () -> bool
134
+ def busy?: () -> bool
135
+ def foreground_process_name: (?now: Numeric) -> String?
136
+ def close: () -> self
137
+ end
138
+
139
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tarazed
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: fiddle
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: unicode-display_width
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.2'
40
+ email:
41
+ - t.yudai92@gmail.com
42
+ executables: []
43
+ extensions: []
44
+ extra_rdoc_files: []
45
+ files:
46
+ - CHANGELOG.md
47
+ - LICENSE.txt
48
+ - README.md
49
+ - docs/adr/000-template.md
50
+ - docs/adr/001-terminal-core-boundary.md
51
+ - docs/adr/README.md
52
+ - lib/tarazed.rb
53
+ - lib/tarazed/cell.rb
54
+ - lib/tarazed/grid.rb
55
+ - lib/tarazed/pty.rb
56
+ - lib/tarazed/scrollback.rb
57
+ - lib/tarazed/version.rb
58
+ - lib/tarazed/vt.rb
59
+ - lib/tarazed/windows/conpty.rb
60
+ - sig/tarazed.rbs
61
+ homepage: https://github.com/noxdea/tarazed
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ source_code_uri: https://github.com/noxdea/tarazed
66
+ changelog_uri: https://github.com/noxdea/tarazed/blob/main/CHANGELOG.md
67
+ allowed_push_host: https://rubygems.org
68
+ rubygems_mfa_required: 'true'
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '3.1'
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 4.0.16
84
+ specification_version: 4
85
+ summary: A pure Ruby terminal emulator core
86
+ test_files: []