kward 0.80.0 → 0.81.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/doc/composer.md +5 -8
  6. data/doc/configuration.md +13 -3
  7. data/doc/permissions.md +1 -1
  8. data/doc/rpc.md +2 -1
  9. data/doc/sandboxing.md +4 -4
  10. data/doc/security.md +6 -9
  11. data/doc/shell.md +161 -196
  12. data/doc/skills.md +19 -5
  13. data/doc/tabs.md +1 -1
  14. data/doc/usage.md +6 -5
  15. data/lib/kward/agent.rb +11 -6
  16. data/lib/kward/ansi.rb +9 -1
  17. data/lib/kward/cli/commands.rb +7 -0
  18. data/lib/kward/cli/interactive_turn.rb +1 -1
  19. data/lib/kward/cli/memory_commands.rb +2 -2
  20. data/lib/kward/cli/plugins.rb +1 -1
  21. data/lib/kward/cli/project_skills.rb +99 -0
  22. data/lib/kward/cli/project_skills_commands.rb +87 -0
  23. data/lib/kward/cli/prompt_interface.rb +54 -4
  24. data/lib/kward/cli/rendering.rb +28 -2
  25. data/lib/kward/cli/runtime_helpers.rb +157 -19
  26. data/lib/kward/cli/sessions.rb +6 -4
  27. data/lib/kward/cli/settings.rb +1 -1
  28. data/lib/kward/cli/slash_commands.rb +7 -1
  29. data/lib/kward/cli/tabs.rb +4 -1
  30. data/lib/kward/cli.rb +21 -1
  31. data/lib/kward/compactor.rb +7 -2
  32. data/lib/kward/config_files.rb +30 -9
  33. data/lib/kward/conversation.rb +8 -5
  34. data/lib/kward/ekwsh.rb +37 -16
  35. data/lib/kward/hooks/audit_log.rb +5 -2
  36. data/lib/kward/interactive_pty_runner.rb +88 -22
  37. data/lib/kward/plugin_registry.rb +20 -13
  38. data/lib/kward/prompt_interface/composer_controller.rb +13 -1
  39. data/lib/kward/prompt_interface/editor/controller.rb +4 -0
  40. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +35 -10
  41. data/lib/kward/prompt_interface/key_handler.rb +78 -43
  42. data/lib/kward/prompt_interface/overlay_renderer.rb +12 -0
  43. data/lib/kward/prompt_interface/screen.rb +5 -0
  44. data/lib/kward/prompt_interface.rb +93 -21
  45. data/lib/kward/prompts/commands.rb +2 -0
  46. data/lib/kward/prompts/templates.rb +11 -6
  47. data/lib/kward/prompts.rb +6 -6
  48. data/lib/kward/rpc/server.rb +1 -0
  49. data/lib/kward/session_store.rb +3 -2
  50. data/lib/kward/skills/registry.rb +67 -12
  51. data/lib/kward/skills/trust_coordinator.rb +45 -0
  52. data/lib/kward/skills/trust_store.rb +107 -0
  53. data/lib/kward/telemetry/logger.rb +5 -2
  54. data/lib/kward/version.rb +1 -1
  55. metadata +5 -1
data/lib/kward/ekwsh.rb CHANGED
@@ -13,7 +13,7 @@ module Kward
13
13
  class Ekwsh
14
14
  Result = Struct.new(:output, :exit_status, :exit_shell, :clear, :open_editor_path, :interactive_command, :streamed, keyword_init: true)
15
15
  Completion = Struct.new(:range, :replacement, :candidates, keyword_init: true)
16
- BUILTINS = %w[alias cd pwd export unset unalias clear exit logout pty].freeze
16
+ BUILTINS = %w[alias capture cd pwd export unset unalias clear exit logout pty].freeze
17
17
  DEFAULT_SHELL = "/bin/sh"
18
18
  DEFAULT_TIMEOUT_SECONDS = 300
19
19
  DEFAULT_MAX_OUTPUT_BYTES = 1_048_576
@@ -72,6 +72,23 @@ module Kward
72
72
  Completion.new(range: token[:range], replacement: replacement, candidates: candidates)
73
73
  end
74
74
 
75
+ def expand_alias(command, interactive: false)
76
+ words = shell_words(command)
77
+ return command if words.empty? || BUILTINS.include?(words.first)
78
+ return command unless @aliases[words.first]
79
+
80
+ rest = command.sub(/\A\s*#{Regexp.escape(words.first)}\b\s*/, "")
81
+ expanded = [@aliases.fetch(words.first), rest].reject(&:empty?).join(" ")
82
+ interactive ? expanded.sub(/\A\s*(?:capture|pty)(?:\s+|\z)/, "") : expanded
83
+ rescue ArgumentError
84
+ command
85
+ end
86
+
87
+ def editor_command_result(command, display_command: command)
88
+ expanded_command = expand_alias(command, interactive: true)
89
+ kward_command_result(expanded_command, display_command: display_command)
90
+ end
91
+
75
92
  private
76
93
 
77
94
  def configure_rbenv_environment
@@ -276,7 +293,7 @@ module Kward
276
293
  Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
277
294
  end
278
295
 
279
- def builtin_result(command, display_command: command)
296
+ def builtin_result(command, display_command: command, cancellation: nil, &block)
280
297
  words = shell_words(command)
281
298
  return nil if words.empty?
282
299
  assignment_result = persist_assignments(display_command, words)
@@ -297,6 +314,8 @@ module Kward
297
314
  unset_variables(display_command, words)
298
315
  when "clear"
299
316
  Result.new(output: "", exit_status: 0, clear: true)
317
+ when "capture"
318
+ captured_command_result(command, display_command: display_command, cancellation: cancellation, &block)
300
319
  when "pty"
301
320
  interactive_pty_result(command, display_command: display_command)
302
321
  else
@@ -306,13 +325,26 @@ module Kward
306
325
  Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
307
326
  end
308
327
 
328
+ def captured_command_result(command, display_command:, cancellation: nil, &block)
329
+ captured_command = command.sub(/\A\s*capture(?:\s+|\z)/, "")
330
+ if captured_command.empty?
331
+ return Result.new(output: "#{command_echo(display_command)}Usage: capture <command>\n", exit_status: 2)
332
+ end
333
+
334
+ execute(captured_command, display_command: display_command, cancellation: cancellation, &block)
335
+ end
336
+
309
337
  def interactive_pty_result(command, display_command: command)
310
338
  interactive_command = command.sub(/\A\s*pty(?:\s+|\z)/, "")
311
339
  if interactive_command.empty?
312
340
  return Result.new(output: "#{command_echo(display_command)}Usage: pty <command>\n", exit_status: 2)
313
341
  end
314
342
 
315
- Result.new(output: "#{command_echo(display_command)}[interactive PTY session started]\n", exit_status: 0, interactive_command: interactive_command)
343
+ interactive_command_result(interactive_command, display_command: display_command)
344
+ end
345
+
346
+ def interactive_command_result(command, display_command: command)
347
+ Result.new(output: command_echo(display_command), exit_status: 0, interactive_command: command)
316
348
  end
317
349
 
318
350
  def shell_words(command)
@@ -361,29 +393,18 @@ module Kward
361
393
  name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_-]*\z/) && !BUILTINS.include?(name.to_s)
362
394
  end
363
395
 
364
- def expand_alias(command)
365
- words = shell_words(command)
366
- return command if words.empty? || BUILTINS.include?(words.first)
367
- return command unless @aliases[words.first]
368
-
369
- rest = command.sub(/\A\s*#{Regexp.escape(words.first)}\b\s*/, "")
370
- [@aliases.fetch(words.first), rest].reject(&:empty?).join(" ")
371
- rescue ArgumentError
372
- command
373
- end
374
-
375
396
  def run_expanded_command(command, cancellation: nil, &block)
376
397
  expanded_command = expand_alias(command)
377
398
  exit_result = exit_result(expanded_command, display_command: command)
378
399
  return exit_result if exit_result
379
400
 
380
- builtin_result = builtin_result(expanded_command, display_command: command)
401
+ builtin_result = builtin_result(expanded_command, display_command: command, cancellation: cancellation, &block)
381
402
  return builtin_result if builtin_result
382
403
 
383
404
  kward_result = kward_command_result(expanded_command, display_command: command)
384
405
  return kward_result if kward_result
385
406
 
386
- execute(expanded_command, display_command: command, cancellation: cancellation, &block)
407
+ interactive_command_result(expanded_command, display_command: command)
387
408
  end
388
409
 
389
410
  def kward_command_result(command, display_command: command)
@@ -11,13 +11,14 @@ module Kward
11
11
  class AuditLog
12
12
  DEFAULT_MAX_BYTES = 10 * 1024 * 1024
13
13
 
14
- def initialize(path: nil, config_path: ConfigFiles.config_path, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr)
14
+ def initialize(path: nil, config_path: ConfigFiles.config_path, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr, warning_sink: nil)
15
15
  @path = path
16
16
  @config_path = config_path
17
17
  @max_bytes = max_bytes.to_i.positive? ? max_bytes.to_i : DEFAULT_MAX_BYTES
18
18
  @clock = clock
19
19
  @monotonic_clock = monotonic_clock
20
20
  @error_output = error_output
21
+ @warning_sink = warning_sink
21
22
  @mutex = Mutex.new
22
23
  @warned = false
23
24
  end
@@ -112,7 +113,9 @@ module Kward
112
113
  return if @warned
113
114
 
114
115
  @warned = true
115
- @error_output&.puts("Warning: hook audit logging failed: #{error.message}")
116
+ message = "Warning: hook audit logging failed: #{error.message}"
117
+ sink = @warning_sink || ConfigFiles.warning_sink
118
+ sink ? sink.call(message) : @error_output&.puts(message)
116
119
  rescue StandardError
117
120
  nil
118
121
  end
@@ -9,7 +9,7 @@ module Kward
9
9
  # This is intentionally low level: UI orchestration decides when terminal
10
10
  # ownership is handed to the child process and how the result is presented.
11
11
  class InteractivePtyRunner
12
- Result = Struct.new(:exit_status, keyword_init: true)
12
+ Result = Struct.new(:exit_status, :input_forwarded, keyword_init: true)
13
13
 
14
14
  READ_SIZE = 4096
15
15
 
@@ -18,34 +18,27 @@ module Kward
18
18
  @window_size = nil
19
19
  end
20
20
 
21
- def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, output: $stdout)
21
+ def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, output: $stdout, &block)
22
22
  pid = nil
23
23
  status = nil
24
+ writer = nil
25
+ input_forwarded = false
24
26
 
25
- PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, writer, child_pid|
27
+ PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, child_writer, child_pid|
26
28
  pid = child_pid
29
+ writer = child_writer
27
30
  update_window_size(reader, pid)
28
31
  with_raw_input(input) do
29
32
  drain_initial_input(input, writer)
30
- loop do
31
- update_window_size(reader, pid)
32
- readable = IO.select([reader, input], nil, nil, 0.02)&.first || []
33
- forward_pty_output(reader, output) if readable.include?(reader)
34
- forward_input(input, writer) if readable.include?(input)
35
- if (finished_status = finished_status(pid))
36
- status = finished_status
37
- break
38
- end
39
- rescue Errno::EIO, IOError
40
- break
41
- end
33
+ status, input_forwarded = forward_io(reader, writer, pid, input, output, &block)
42
34
  end
43
35
  status ||= wait_for_status(pid)
44
- ensure
45
- writer&.close unless writer&.closed?
46
36
  end
47
37
 
48
- Result.new(exit_status: exit_status(status))
38
+ Result.new(exit_status: exit_status(status), input_forwarded: input_forwarded)
39
+ ensure
40
+ writer&.close unless writer&.closed?
41
+ terminate_and_reap(pid) if pid && status.nil?
49
42
  end
50
43
 
51
44
  private
@@ -53,8 +46,14 @@ module Kward
53
46
  def with_raw_input(input)
54
47
  return yield unless input.respond_to?(:raw)
55
48
 
56
- input.raw { yield }
49
+ yielded = false
50
+ input.raw do
51
+ yielded = true
52
+ yield
53
+ end
57
54
  rescue Errno::ENOTTY
55
+ raise if yielded
56
+
58
57
  yield
59
58
  end
60
59
 
@@ -70,22 +69,67 @@ module Kward
70
69
  nil
71
70
  end
72
71
 
72
+ def forward_io(reader, writer, pid, input, output, &block)
73
+ input_open = true
74
+ input_forwarded = false
75
+ loop do
76
+ update_window_size(reader, pid)
77
+ readers = [reader]
78
+ readers << input if input_open
79
+ readable = IO.select(readers, nil, nil, 0.02)&.first || []
80
+ forward_pty_output(reader, output, &block) if readable.include?(reader)
81
+ if input_open && readable.include?(input)
82
+ input_open, forwarded = forward_input(input, writer)
83
+ input_forwarded ||= forwarded
84
+ end
85
+
86
+ status = finished_status(pid)
87
+ next unless status
88
+
89
+ return [finish_stopped_process(pid), input_forwarded] if status.stopped?
90
+
91
+ drain_pty_output(reader, output, &block)
92
+ return [status, input_forwarded]
93
+ rescue Errno::EIO
94
+ return [wait_for_status(pid), input_forwarded]
95
+ end
96
+ end
97
+
73
98
  def forward_pty_output(reader, output)
74
99
  chunk = reader.read_nonblock(READ_SIZE, exception: false)
75
100
  return if chunk.nil? || chunk == :wait_readable
76
101
 
77
102
  output.write(chunk)
78
103
  output.flush if output.respond_to?(:flush)
104
+ yield chunk if block_given?
105
+ end
106
+
107
+ def drain_pty_output(reader, output)
108
+ loop do
109
+ readable, = IO.select([reader], nil, nil, 0.02)
110
+ break unless readable
111
+
112
+ chunk = reader.read_nonblock(READ_SIZE, exception: false)
113
+ break if chunk.nil? || chunk == :wait_readable
114
+
115
+ output.write(chunk)
116
+ yield chunk if block_given?
117
+ end
118
+ output.flush if output.respond_to?(:flush)
119
+ rescue Errno::EIO
120
+ nil
79
121
  end
80
122
 
81
123
  def forward_input(input, writer)
82
124
  chunk = input.read_nonblock(READ_SIZE, exception: false)
83
- return if chunk.nil? || chunk == :wait_readable
125
+ return [false, false] if chunk.nil?
126
+ return [true, false] if chunk == :wait_readable
84
127
 
85
128
  writer.write(chunk)
86
129
  writer.flush
130
+ [true, true]
87
131
  rescue Errno::EIO, Errno::EPIPE, IOError
88
- nil
132
+ [false, false]
89
133
  end
90
134
 
91
135
  def update_window_size(reader, pid)
@@ -111,12 +155,18 @@ module Kward
111
155
  def finished_status(pid)
112
156
  return unless pid
113
157
 
114
- finished_pid, status = Process.wait2(pid, Process::WNOHANG)
158
+ finished_pid, status = Process.wait2(pid, Process::WNOHANG | Process::WUNTRACED)
115
159
  status if finished_pid
116
160
  rescue Errno::ECHILD
117
161
  nil
118
162
  end
119
163
 
164
+ def finish_stopped_process(pid)
165
+ signal_process("CONT", -pid) || signal_process("CONT", pid)
166
+ terminate_process_group(pid)
167
+ wait_for_status(pid)
168
+ end
169
+
120
170
  def wait_for_status(pid)
121
171
  return unless pid
122
172
 
@@ -134,6 +184,22 @@ module Kward
134
184
  1
135
185
  end
136
186
 
187
+ def terminate_and_reap(pid)
188
+ terminate_process_group(pid)
189
+ wait_for_status(pid)
190
+ end
191
+
192
+ def terminate_process_group(pid)
193
+ signal_process("TERM", -pid) || signal_process("TERM", pid)
194
+ deadline = Time.now + 0.2
195
+ while Time.now < deadline
196
+ return unless process_running?(pid)
197
+
198
+ sleep 0.02
199
+ end
200
+ signal_process("KILL", -pid) || signal_process("KILL", pid)
201
+ end
202
+
137
203
  def process_running?(pid)
138
204
  Process.kill(0, pid)
139
205
  true
@@ -313,16 +313,19 @@ module Kward
313
313
  class << self
314
314
  attr_accessor :loading_registry, :loading_path
315
315
 
316
- def load(paths: ConfigFiles.plugin_paths, reserved_commands: [])
317
- registry = new(reserved_commands: reserved_commands)
316
+ def load(paths: nil, reserved_commands: [], warning_sink: nil)
317
+ warning_sink ||= ConfigFiles.warning_sink
318
+ paths ||= ConfigFiles.plugin_paths(warning_sink: warning_sink)
319
+ registry = new(reserved_commands: reserved_commands, warning_sink: warning_sink)
318
320
  paths.each { |path| registry.load_file(path) }
319
321
  registry
320
322
  end
321
323
  end
322
324
 
323
325
  # Creates an object for trusted plugin loading and dispatch.
324
- def initialize(reserved_commands: [])
326
+ def initialize(reserved_commands: [], warning_sink: nil)
325
327
  @reserved_commands = reserved_commands.map(&:to_s)
328
+ @warning_sink = warning_sink
326
329
  @commands = {}
327
330
  @interactive_commands = {}
328
331
  @tab_types = {}
@@ -419,7 +422,7 @@ module Kward
419
422
  rendered = entry[:renderer].call(context)
420
423
  parts << rendered.to_s unless rendered.to_s.empty?
421
424
  rescue StandardError => e
422
- warn "Warning: Kward plugin prompt context error in #{entry[:path]}: #{e.message}"
425
+ emit_warning "Warning: Kward plugin prompt context error in #{entry[:path]}: #{e.message}"
423
426
  end
424
427
  parts.empty? ? nil : parts.join("\n\n")
425
428
  end
@@ -431,7 +434,7 @@ module Kward
431
434
  @transcript_event_handlers.each do |entry|
432
435
  entry[:handler].call(transcript_event, context)
433
436
  rescue StandardError => e
434
- warn "Warning: Kward plugin transcript event error in #{entry[:path]}: #{e.message}"
437
+ emit_warning "Warning: Kward plugin transcript event error in #{entry[:path]}: #{e.message}"
435
438
  end
436
439
  nil
437
440
  end
@@ -444,7 +447,7 @@ module Kward
444
447
  Kernel.load(path, true)
445
448
  @paths << path
446
449
  rescue StandardError => e
447
- warn "Warning: skipping Kward plugin #{path}: #{e.message}"
450
+ emit_warning "Warning: skipping Kward plugin #{path}: #{e.message}"
448
451
  ensure
449
452
  self.class.loading_registry = previous_registry
450
453
  self.class.loading_path = previous_path
@@ -462,11 +465,11 @@ module Kward
462
465
  raise "Plugin command /#{name} requires a handler" unless handler
463
466
 
464
467
  if @reserved_commands.include?(name)
465
- warn "Warning: skipping Kward plugin command /#{name}: reserved command"
468
+ emit_warning "Warning: skipping Kward plugin command /#{name}: reserved command"
466
469
  return nil
467
470
  end
468
471
  if @commands.key?(name)
469
- warn "Warning: skipping duplicate Kward plugin command /#{name}: #{path}"
472
+ emit_warning "Warning: skipping duplicate Kward plugin command /#{name}: #{path}"
470
473
  return nil
471
474
  end
472
475
 
@@ -485,11 +488,11 @@ module Kward
485
488
  raise "Interactive command /#{name} requires a handler" unless handler
486
489
 
487
490
  if @reserved_commands.include?(name) || @commands.key?(name)
488
- warn "Warning: skipping Kward interactive command /#{name}: reserved command"
491
+ emit_warning "Warning: skipping Kward interactive command /#{name}: reserved command"
489
492
  return nil
490
493
  end
491
494
  if @interactive_commands.key?(name)
492
- warn "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
495
+ emit_warning "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
493
496
  return nil
494
497
  end
495
498
 
@@ -512,7 +515,7 @@ module Kward
512
515
  raise "Plugin tab type #{name} requires a handler" unless handler
513
516
 
514
517
  if @tab_types.key?(name) || @tab_types_by_id.key?(id)
515
- warn "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
518
+ emit_warning "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
516
519
  return nil
517
520
  end
518
521
 
@@ -529,7 +532,7 @@ module Kward
529
532
  raise "Plugin transport #{name} requires a handler" unless handler
530
533
 
531
534
  if @transports.key?(name) || @transports_by_id.key?(id)
532
- warn "Warning: skipping duplicate Kward plugin transport #{id}: #{path}"
535
+ emit_warning "Warning: skipping duplicate Kward plugin transport #{id}: #{path}"
533
536
  return nil
534
537
  end
535
538
 
@@ -543,11 +546,15 @@ module Kward
543
546
  def register_footer(path: nil, &renderer)
544
547
  raise "Plugin footer requires a renderer" unless renderer
545
548
 
546
- warn "Warning: replacing Kward plugin footer from #{@footer_path}: #{path}" if @footer
549
+ emit_warning "Warning: replacing Kward plugin footer from #{@footer_path}: #{path}" if @footer
547
550
  @footer = renderer
548
551
  @footer_path = path
549
552
  end
550
553
 
554
+ def emit_warning(message)
555
+ @warning_sink ? @warning_sink.call(message) : warn(message)
556
+ end
557
+
551
558
  def register_transcript_event(path: nil, &handler)
552
559
  raise "Plugin transcript event requires a handler" unless handler
553
560
 
@@ -256,11 +256,23 @@ module Kward
256
256
  def submit_input
257
257
  value = submitted_input
258
258
  add_history(composer_input)
259
- clear_finished_input_locked(reset_history: true)
259
+ if !@busy && value.strip.empty?
260
+ @output_io.flush
261
+ return value
262
+ end
263
+
264
+ clear_submitted_input_locked
260
265
  @output_io.flush
261
266
  value
262
267
  end
263
268
 
269
+ def clear_submitted_input_locked
270
+ self.composer_input = ""
271
+ self.composer_cursor = 0
272
+ @composer.clear_attachments
273
+ reset_history_search
274
+ end
275
+
264
276
  def clear_finished_input_locked(reset_history: false)
265
277
  if @busy
266
278
  clear_prompt_for_output_locked
@@ -827,6 +827,10 @@ module Kward
827
827
  modifier = sequence[:modifier]
828
828
  queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
829
829
 
830
+ if ctrl_modifier?(modifier) && ctrl_code(code) == 113
831
+ return close_editor
832
+ end
833
+
830
834
  if ctrl_modifier?(modifier) && ctrl_code(code) == 102
831
835
  return editor_search_active? ? editor_search_append(key) : editor_search_begin
832
836
  end
@@ -23,10 +23,12 @@ module Kward
23
23
  LANGUAGE_DEFINITIONS = {
24
24
  javascript: {
25
25
  extensions: %w[.js .jsx .mjs .cjs],
26
+ block_comment: true,
26
27
  keywords: %w[async await break case catch class const continue debugger default delete do else export extends false finally for from function if import in instanceof let new null of return static super switch this throw true try typeof undefined var void while with yield]
27
28
  },
28
29
  typescript: {
29
30
  extensions: %w[.ts .tsx],
31
+ block_comment: true,
30
32
  keywords: %w[abstract any as async await boolean break case catch class const constructor continue debugger declare default delete do else enum export extends false finally for from function if implements import in infer instanceof interface is keyof let module namespace never new null number object of private protected public readonly return static string super switch symbol this throw true try type typeof undefined unknown var void while with yield]
31
33
  },
32
34
  shell: {
@@ -62,34 +64,42 @@ module Kward
62
64
  },
63
65
  go: {
64
66
  extensions: %w[.go],
67
+ block_comment: true,
65
68
  keywords: %w[break case chan const continue default defer else fallthrough false for func go goto if import interface map nil package range return select struct switch true type var]
66
69
  },
67
70
  rust: {
68
71
  extensions: %w[.rs],
72
+ block_comment: true,
69
73
  keywords: %w[as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while]
70
74
  },
71
75
  java: {
72
76
  extensions: %w[.java],
77
+ block_comment: true,
73
78
  keywords: %w[abstract assert boolean break byte case catch char class const continue default do double else enum extends false final finally float for if implements import instanceof int interface long native new null package private protected public return short static strictfp super switch synchronized this throw throws transient true try void volatile while]
74
79
  },
75
80
  csharp: {
76
81
  extensions: %w[.cs],
82
+ block_comment: true,
77
83
  keywords: %w[abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while var async await]
78
84
  },
79
85
  c: {
80
86
  extensions: %w[.c .h],
87
+ block_comment: true,
81
88
  keywords: %w[auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while]
82
89
  },
83
90
  cpp: {
84
91
  extensions: %w[.cc .cpp .cxx .hpp .hh .hxx],
92
+ block_comment: true,
85
93
  keywords: %w[alignas alignof and asm auto bool break case catch char char16_t char32_t class const constexpr const_cast continue decltype default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new noexcept nullptr operator or private protected public register reinterpret_cast return short signed sizeof static static_assert static_cast struct switch template this throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while]
86
94
  },
87
95
  swift: {
88
96
  extensions: %w[.swift],
97
+ block_comment: true,
89
98
  keywords: %w[as associatedtype break case catch class continue default defer deinit do else enum extension false fileprivate for func guard if import in init inout internal is let nil open operator private protocol public repeat rethrows return self Self static struct subscript super switch throw throws true try typealias var where while]
90
99
  },
91
100
  kotlin: {
92
101
  extensions: %w[.kt .kts],
102
+ block_comment: true,
93
103
  keywords: %w[as break class continue do else false for fun if in interface is null object package return super this throw true try typealias typeof val var when while by catch constructor delegate dynamic field file finally get import init param property receiver set setparam where actual abstract annotation companion const crossinline data enum expect external final infix inline inner internal lateinit noinline open operator out override private protected public reified sealed suspend tailrec vararg]
94
104
  },
95
105
  lua: {
@@ -296,8 +306,7 @@ module Kward
296
306
  return line.to_s unless definition
297
307
 
298
308
  text = line.to_s
299
- return colored(text, :gray) if editor_c_style_block_comment_line?(line_index)
300
-
309
+ return colored(text, :gray) if definition[:block_comment] && editor_c_style_block_comment_line?(line_index)
301
310
  marker = definition[:line_comment] || "//"
302
311
  comment_index = editor_comment_index(text, marker)
303
312
  return editor_highlight_generic_code(text, definition[:keywords]) unless comment_index
@@ -313,7 +322,8 @@ module Kward
313
322
  end
314
323
 
315
324
  def editor_generic_pattern(keywords)
316
- /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b\d+(?:\.\d+)?\b|\b[A-Z]\w*\b|\b(?:#{Regexp.union(keywords)})\b)/
325
+ @editor_generic_patterns ||= {}
326
+ @editor_generic_patterns[keywords] ||= /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b\d+(?:\.\d+)?\b|\b[A-Z]\w*\b|\b(?:#{Regexp.union(keywords)})\b)/
317
327
  end
318
328
 
319
329
  def editor_highlight_generic_token(token, keywords)
@@ -333,16 +343,31 @@ module Kward
333
343
  def editor_c_style_block_comment_line?(line_index)
334
344
  return false unless line_index && @editor_state
335
345
 
336
- in_comment = false
337
- @editor_state.lines.first(line_index.to_i + 1).each_with_index do |line, index|
346
+ lines = @editor_state.lines
347
+ unless @editor_block_comment_lines.equal?(lines)
348
+ @editor_block_comment_lines = lines
349
+ @editor_block_comment_states = []
350
+ @editor_block_comment_in_comment = false
351
+ end
352
+
353
+ target = line_index.to_i
354
+ return false if target.negative?
355
+ return @editor_block_comment_states[target] if target < @editor_block_comment_states.length
356
+
357
+ (@editor_block_comment_states.length..target).each do |index|
358
+ line = lines[index].to_s
338
359
  starts_block = editor_comment_index(line, "/*")
339
- ends_block = in_comment && line.include?("*/")
340
- return true if index == line_index && (in_comment || starts_block)
360
+ ends_block = @editor_block_comment_in_comment && line.include?("*/")
361
+ @editor_block_comment_states << (@editor_block_comment_in_comment || !starts_block.nil?)
341
362
 
342
- in_comment = true if starts_block && !line[starts_block..].to_s.include?("*/")
343
- in_comment = false if ends_block
363
+ if starts_block && !line[starts_block..].to_s.include?("*/")
364
+ @editor_block_comment_in_comment = true
365
+ elsif ends_block
366
+ @editor_block_comment_in_comment = false
367
+ end
344
368
  end
345
- false
369
+
370
+ @editor_block_comment_states[target]
346
371
  end
347
372
 
348
373
  def editor_comment_index(line, marker)