Class: RubyTestIDE::Server

Inherits:
Object
  • Object
show all
Defined in:
lib/ruby_test_ide/server.rb

Overview

-------------------------------------------------------------------- http

Constant Summary collapse

PUBLIC =
File.join(ROOT, 'public')
VENDOR =
File.join(PUBLIC, 'vendor')
STATIC_CONTENT_TYPES =
{
  '.js' => 'text/javascript; charset=utf-8',
  '.css' => 'text/css; charset=utf-8',
  '.ttf' => 'font/ttf',
}.freeze

Instance Method Summary collapse

Constructor Details

#initialize(port) ⇒ Server

Returns a new instance of Server.



457
458
459
# File 'lib/ruby_test_ide/server.rb', line 457

def initialize(port)
  @port = port
end

Instance Method Details

#api_complete(req) ⇒ Object



621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
# File 'lib/ruby_test_ide/server.rb', line 621

def api_complete(req)
  code = req['code'].to_s
  lines = code.split("\n", -1)
  line = req['line'].to_i          # 1-based (monaco convention)
  column = req['column'].to_i      # 1-based
  before = (lines[line - 1] || '')[0, [column - 1, 0].max]
  receiver, prefix = RubyTestIDE.split_target(before.to_s)
  code_before = lines[0, line - 1].join("\n")

  result = RubyTestIDE.run_worker(
    { 'op' => 'complete', 'code_before' => code_before,
      'receiver' => receiver, 'prefix' => prefix }.merge(file_context(req)),
    timeout: RUNNER_TIMEOUT
  )
  result.merge('receiver' => receiver, 'prefix' => prefix)
end

#api_diagnostics(req) ⇒ Object



677
678
679
# File 'lib/ruby_test_ide/server.rb', line 677

def api_diagnostics(req)
  { 'diagnostics' => RubyTestIDE.syntax_diagnostics(req['code'].to_s) }
end

#api_hover(req) ⇒ Object



638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
# File 'lib/ruby_test_ide/server.rb', line 638

def api_hover(req)
  code = req['code'].to_s
  lines = code.split("\n", -1)
  line = req['line'].to_i
  word = req['word'].to_s
  word_start = req['wordStartColumn'].to_i
  before_word = (lines[line - 1] || '')[0, [word_start - 1, 0].max].to_s

  receiver = nil
  if before_word.end_with?('.') && !before_word.end_with?('..')
    start = RubyTestIDE.receiver_start(before_word, before_word.length - 1)
    receiver = before_word[start...-1]
    receiver = nil if receiver.to_s.strip.empty?
  end
  # For hover, context = everything up to and including the previous line,
  # plus the current line itself when it parses on its own.
  code_before = lines[0, line - 1].join("\n")

  result = RubyTestIDE.run_worker(
    { 'op' => 'hover', 'code_before' => code_before,
      'receiver' => receiver, 'name' => word }.merge(file_context(req)),
    timeout: RUNNER_TIMEOUT
  )

  if (src = result['source']) && src.start_with?("#{WORKSPACE}/")
    result['source_rel'] = src.delete_prefix("#{WORKSPACE}/")
  end
  if result['kind'] == 'method'
    result['doc'] = DOCS.lookup(
      "#{result['owner']}##{word}",
      "#{result['receiver_class']}##{word}",
      "#{result['owner']}.#{word}"
    )
  elsif %w[variable constant class].include?(result['kind'])
    result['doc'] = DOCS.lookup(result['kind'] == 'class' ? result['name'] : result['class'])
  end
  result
end

#api_learnObject

Run the configured learn commands (any test framework or script) with the observer preloaded, then persist the merged type observations — completion/hover consult these when live eval can't help.



689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
# File 'lib/ruby_test_ide/server.rb', line 689

def api_learn
  config = RubyTestIDE.load_config
  return { 'error' => config['config_error'] } if config['config_error']

  commands = Array(config['learn'] || RubyTestIDE.default_learn_commands).map(&:to_s)
  if commands.empty?
    return { 'error' => 'nothing to run: add learn: commands to ' \
                        '.ruby_ide.yaml or create test/**/*_test.rb / spec files' }
  end
  timeout = (config['timeout'] || LEARN_TIMEOUT).to_i.clamp(1, 600)

  require 'tmpdir'
  require 'fileutils'
  merged = nil
  results = nil
  Dir.mktmpdir('ruby-ide-obs') do |obs_dir|
    results = commands.map { |cmd| RubyTestIDE.run_learn_command(cmd, obs_dir, timeout) }
    merged = RubyTestIDE.merge_observations(Dir.glob(File.join(obs_dir, 'obs-*.json')))
  end
  unless merged.empty?
    FileUtils.mkdir_p(File.dirname(OBSERVATIONS))
    File.write(OBSERVATIONS, JSON.generate(merged))
  end
  { 'commands' => results,
    'passed' => results.all? { |r| r['ok'] },
    'methods_observed' => merged.size }
end

#api_read_file(params) ⇒ Object

---- endpoints

Raises:

  • (Errno::ENOENT)


597
598
599
600
601
602
# File 'lib/ruby_test_ide/server.rb', line 597

def api_read_file(params)
  abs = RubyTestIDE.workspace_path(params['path'])
  raise Errno::ENOENT, params['path'] unless File.file?(abs)

  { 'path' => params['path'], 'content' => File.read(abs) }
end

#api_run(req) ⇒ Object



681
682
683
684
# File 'lib/ruby_test_ide/server.rb', line 681

def api_run(req)
  RubyTestIDE.run_worker({ 'op' => 'run', 'code' => req['code'].to_s }.merge(file_context(req)),
                     timeout: RUNNER_TIMEOUT)
end

#api_save_file(req) ⇒ Object



604
605
606
607
608
609
610
# File 'lib/ruby_test_ide/server.rb', line 604

def api_save_file(req)
  abs = RubyTestIDE.workspace_path(req['path'])
  require 'fileutils'
  FileUtils.mkdir_p(File.dirname(abs))
  File.write(abs, req['content'].to_s)
  { 'ok' => true, 'path' => req['path'], 'size' => File.size(abs) }
end

#file_context(req) ⇒ Object

The file's real location, so the worker can chdir there and make require_relative resolve against the workspace.



614
615
616
617
618
619
# File 'lib/ruby_test_ide/server.rb', line 614

def file_context(req)
  abs = RubyTestIDE.workspace_path(req['path'])
  { 'path' => abs, 'dir' => File.dirname(abs), 'root' => WORKSPACE }
rescue ArgumentError
  {}
end

#handle_client(sock) ⇒ Object



489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/ruby_test_ide/server.rb', line 489

def handle_client(sock)
  request_line = sock.gets
  return unless request_line

  verb, path, = request_line.split
  headers = {}
  while (line = sock.gets) && line != "\r\n"
    k, v = line.split(': ', 2)
    headers[k.to_s.downcase] = v.to_s.strip
  end
  body = headers['content-length'] ? sock.read(headers['content-length'].to_i) : ''

  status, ctype, payload, cache = route(verb, path, body, headers)
  sock.write("HTTP/1.1 #{status}\r\n" \
             "Content-Type: #{ctype}\r\n" \
             "Content-Length: #{payload.bytesize}\r\n" \
             "Cache-Control: #{cache || 'no-store'}\r\n" \
             "Connection: close\r\n\r\n")
  sock.write(payload)
end

#jsonObject



587
588
589
590
591
592
593
# File 'lib/ruby_test_ide/server.rb', line 587

def json
  payload = yield
  ['200 OK', 'application/json; charset=utf-8', JSON.generate(payload)]
rescue StandardError => e
  ['500 Internal Server Error', 'application/json',
   JSON.generate('error' => "#{e.class}: #{e.message}")]
end

#parse(body) ⇒ Object



568
569
570
571
572
# File 'lib/ruby_test_ide/server.rb', line 568

def parse(body)
  JSON.parse(body.to_s)
rescue StandardError
  {}
end

#route(verb, full_path, body, headers) ⇒ Object



510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
# File 'lib/ruby_test_ide/server.rb', line 510

def route(verb, full_path, body, headers)
  path, query = full_path.to_s.split('?', 2)
  params = query ? URI.decode_www_form(query).to_h : {}

  # /health (used by process supervisors / readiness probes) and
  # /vendor/* (the vendored Monaco library — no user data) never need
  # the token. Everything else does: this server evaluates arbitrary
  # code and reads/writes the workspace on request.
  case [verb, path]
  in ['GET', '/health']
    return ['200 OK', 'application/json', '{"ok":true}']
  in ['GET', %r{\A/vendor/}]
    return serve_static(path)
  else
    # falls through to the auth-gated routes below
  end

  token = headers['x-ruby-test-ide-token'] || params['token']
  return unauthorized(html: path == '/' || path == '/index.html') unless RubyTestIDE.token_matches?(token)

  case [verb, path]
  in ['GET', '/'] | ['GET', '/index.html']
    # Cache-bust vendored assets with the gem version: serve_static sets
    # a 1-year immutable cache, so a stale browser cache across a gem
    # upgrade would otherwise serve last version's Monaco forever. The
    # token is only embedded here because we've just verified it above —
    # the client then sends it back as a header on every /api/* call.
    html = File.read(File.join(PUBLIC, 'index.html'))
               .gsub('__RTI_VERSION__', RubyTestIDE::VERSION)
               .gsub('__RTI_TOKEN__', RubyTestIDE::TOKEN)
    ['200 OK', 'text/html; charset=utf-8', html]
  in ['GET', '/api/files']        then json { { 'files' => RubyTestIDE.workspace_files } }
  in ['GET', '/api/file']         then json { api_read_file(params) }
  in ['POST', '/api/file']        then json { api_save_file(parse(body)) }
  in ['POST', '/api/complete']    then json { api_complete(parse(body)) }
  in ['POST', '/api/hover']       then json { api_hover(parse(body)) }
  in ['POST', '/api/diagnostics'] then json { api_diagnostics(parse(body)) }
  in ['POST', '/api/run']         then json { api_run(parse(body)) }
  in ['POST', '/api/learn']       then json { api_learn }
  else
    ['404 Not Found', 'text/plain', 'not found']
  end
rescue Errno::ENOENT
  ['404 Not Found', 'text/plain', 'not found']
end

#serve_static(path) ⇒ Object

Serves vendored front-end assets (Monaco) so the IDE works with no internet access beyond the initial gem install. Content is fixed at gem-build time and keyed by RubyTestIDE::VERSION in the URL the browser requests, so it's safe to cache indefinitely.



578
579
580
581
582
583
584
585
# File 'lib/ruby_test_ide/server.rb', line 578

def serve_static(path)
  rel = path.delete_prefix('/vendor/')
  abs = File.expand_path(File.join(VENDOR, rel))
  return ['404 Not Found', 'text/plain', 'not found'] unless abs.start_with?("#{VENDOR}/") && File.file?(abs)

  ctype = STATIC_CONTENT_TYPES[File.extname(abs)] || 'application/octet-stream'
  ['200 OK', ctype, File.binread(abs), 'public, max-age=31536000, immutable']
end

#startObject



461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
# File 'lib/ruby_test_ide/server.rb', line 461

def start
  server = TCPServer.new('127.0.0.1', @port)
  url = "http://localhost:#{@port}/"
  url += "?token=#{RubyTestIDE::TOKEN}" unless RubyTestIDE::AUTH_DISABLED
  puts "RubyTestIDE listening on #{url} (ruby #{RUBY_VERSION})"
  # This line is the only way to learn the token when stdout is
  # redirected to a file — puts alone can sit in a buffer indefinitely
  # on a long-running server that never exits on its own.
  $stdout.flush
  if RubyTestIDE::AUTH_DISABLED
    warn 'WARNING: started with --no-auth — anyone who can reach this port can execute code as you.'
  end
  loop do
    client = server.accept
    Thread.new(client) do |sock|
      handle_client(sock)
    rescue StandardError => e
      warn "client error: #{e.class}: #{e.message}"
    ensure
      begin
        sock.close
      rescue StandardError
        nil
      end
    end
  end
end

#unauthorized(html:) ⇒ Object



556
557
558
559
560
561
562
563
564
565
566
# File 'lib/ruby_test_ide/server.rb', line 556

def unauthorized(html:)
  if html
    ['401 Unauthorized', 'text/plain; charset=utf-8',
     "401 Unauthorized\n\n" \
     "Missing or invalid token. Open the URL printed in the terminal " \
     "where you ran ruby-test-ide (it includes ?token=...).\n"]
  else
    ['401 Unauthorized', 'application/json; charset=utf-8',
     JSON.generate('error' => 'unauthorized: missing or invalid token')]
  end
end