Upgrade to Pro — share decks privately, control downloads, hide ads and more …

RailsConf 2023

RailsConf 2023

These are slides for my keynote at RailsConf 2023

Aaron Patterson

May 03, 2023
Tweet

More Decks by Aaron Patterson

Other Decks in Technology

Transcript

  1. —Eileen Uchitelle “I remain on the Rails core team for

    you. To make Rails better for the community”
  2. Example Spring Java Bean This is how we programmed in

    the early 2000's <!-- target bean to be referenced by name --> <bean id="testBean" class="org.springframework.beans.TestBean" scope="prototype"> <property name="age" value="10"/> <property name="spouse"> <bean class="org.springframework.beans.TestBean"> <property name="age" value="11"/> </bean> </property> </bean> <!-- will result in 10, which is the value of property 'age' of bean 'testBean' --> <util:property-path id="name" path="testBean.age"/>
  3. -- VPP (Very Patient Person) Hi, large language models like

    ChatGPT don't actually “know" exactly how to use command line tools like GitHub CLI, so they make up command invocations that sound plausible but may or may not exist.
  4. Configuring Vim Add clangd support # Tell Vim to find

    vim-lsp packadd vim-lsp # Use clangd if available if executable('clangd') au User lsp_setup call lsp#register_server({ \ 'name': 'clangd', \ 'cmd': ['clangd'], \ 'allowlist': ['c'], \ }) endif Use the “clangd” command Only enable it on C files
  5. Language Server Protocol Just JSON with a header Content-Length: 1234\r\n

    Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n \r\n { cool:"stuff",live:"stream"...} Content-Type is optional!
  6. Event Message Reader Read Messages from $stdin and Parse module

    LSP class Reader def initialize @io = $stdin.binmode end def read buffer = @io.gets("\r\n\r\n") content_length = buffer.match(/Content-Length: (\d+)/i)[1].to_i message = @io.read(content_length) JSON.parse message, symbolize_names: true end end end Read Header in to a Buffer Get Content Length Read JSON event and parse
  7. Event Message Writer Write Hash as JSON to $stdout module

    LSP class Writer def initialize @io = $stdout.binmode end def write response str = JSON.dump(response.merge("jsonrpc" => "2.0")) @io.write "Content-Length: #{str.bytesize}\r\n" @io.write "\r\n" @io.write str @io.flush end end end Add Required Key Calculate and write length Send JSON Body
  8. Event Loop module LSP def self.run reader = Reader.new writer

    = Writer.new # Handle events subscriber = LSP::Events.new loop do # Read an event message = reader.read # Ask the handler to handle the event subscriber.handle message[:method], message, writer end end end
  9. First Event “initialize” event Content-Length: 2683\r\n \r\n {"id":1,"jsonrpc":"2.0","method":"initialize","params":{"rootUri":"file:///Users/aaron/git/lsp-stream","capabilities":{"workspace": {"workspaceFolders":false,"configuration":true,"symbol":{"dynamicRegistration":false},"applyEdit":true},"window": {"workDoneProgress":false},"textDocument":{"callHierarchy":{"dynamicRegistration":false},"rename":

    {"prepareSupport":true,"dynamicRegistration":false,"prepareSupportDefaultBehavior":1},"codeAction": {"isPreferredSupport":true,"disabledSupport":true,"codeActionLiteralSupport":{"codeActionKind":{"valueSet": ["","quickfix","refactor","refactor.extract","refactor.inline","refactor.rewrite","source","source.organizeImports"]}},"dynamicRegistration":false },"completion":{"completionItem":{"snippetSupport":false,"resolveSupport":{"properties":["additionalTextEdits"]},"documentationFormat": ["markdown","plaintext"]},"dynamicRegistration":false,"completionItemKind":{"valueSet": [10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,1,2,3,4,5,6,7,8,9]}},"formatting":{"dynamicRegistration":false},"codeLens": {"dynamicRegistration":false},"inlayHint":{"dynamicRegistration":false},"hover":{"dynamicRegistration":false,"contentFormat": ["markdown","plaintext"]},"rangeFormatting":{"dynamicRegistration":false},"declaration": {"dynamicRegistration":false,"linkSupport":true},"references":{"dynamicRegistration":false},"typeHierarchy": {"dynamicRegistration":false},"foldingRange":{"rangeLimit":5000,"dynamicRegistration":false,"lineFoldingOnly":true},"documentSymbol": {"symbolKind":{"valueSet": [10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,1,2,3,4,5,6,7,8,9]},"dynamicRegistration":false,"labelSupport":false,"hierarchicalDocumentSymb olSupport":false},"publishDiagnostics":{"relatedInformation":true},"synchronization": {"dynamicRegistration":false,"willSaveWaitUntil":false,"willSave":false,"didSave":true},"documentHighlight": {"dynamicRegistration":false},"implementation":{"dynamicRegistration":false,"linkSupport":true},"typeDefinition": {"dynamicRegistration":false,"linkSupport":true},"semanticTokens":{"serverCancelSupport":false,"requests": {"full":false,"range":false},"multilineTokenSupport":false,"dynamicRegistration":false,"overlappingTokenSupport":false,"tokenTypes": ["type","class","enum","interface","struct","typeParameter","parameter","variable","property","enumMember","event","function","method","macro","ke yword","modifier","comment","string","number","regexp","operator"],"tokenModifiers":[],"formats":["relative"]},"signatureHelp": {"dynamicRegistration":false},"definition":{"dynamicRegistration":false,"linkSupport":true}}},"rootPath":"/Users/aaron/git/lsp- stream","clientInfo":{"name":"vim-lsp"},"processId":51035,"trace":"off"}}
  10. First Event “initialize” event Content-Length: 2683\r\n \r\n {"id":1,"jsonrpc":"2.0","method":"initialize","params":{"rootUri":"file:///Users/aaron/git/lsp-stream","capabilities":{"workspace": {"workspaceFolders":false,"configuration":true,"symbol":{"dynamicRegistration":false},"applyEdit":true},"window": {"workDoneProgress":false},"textDocument":{"callHierarchy":{"dynamicRegistration":false},"rename":

    {"prepareSupport":true,"dynamicRegistration":false,"prepareSupportDefaultBehavior":1},"codeAction": {"isPreferredSupport":true,"disabledSupport":true,"codeActionLiteralSupport":{"codeActionKind":{"valueSet": ["","quickfix","refactor","refactor.extract","refactor.inline","refactor.rewrite","source","source.organizeImports"]}},"dynamicRegistration":false },"completion":{"completionItem":{"snippetSupport":false,"resolveSupport":{"properties":["additionalTextEdits"]},"documentationFormat": ["markdown","plaintext"]},"dynamicRegistration":false,"completionItemKind":{"valueSet": [10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,1,2,3,4,5,6,7,8,9]}},"formatting":{"dynamicRegistration":false},"codeLens": {"dynamicRegistration":false},"inlayHint":{"dynamicRegistration":false},"hover":{"dynamicRegistration":false,"contentFormat": ["markdown","plaintext"]},"rangeFormatting":{"dynamicRegistration":false},"declaration": {"dynamicRegistration":false,"linkSupport":true},"references":{"dynamicRegistration":false},"typeHierarchy": {"dynamicRegistration":false},"foldingRange":{"rangeLimit":5000,"dynamicRegistration":false,"lineFoldingOnly":true},"documentSymbol": {"symbolKind":{"valueSet": [10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,1,2,3,4,5,6,7,8,9]},"dynamicRegistration":false,"labelSupport":false,"hierarchicalDocumentSymb olSupport":false},"publishDiagnostics":{"relatedInformation":true},"synchronization": {"dynamicRegistration":false,"willSaveWaitUntil":false,"willSave":false,"didSave":true},"documentHighlight": {"dynamicRegistration":false},"implementation":{"dynamicRegistration":false,"linkSupport":true},"typeDefinition": {"dynamicRegistration":false,"linkSupport":true},"semanticTokens":{"serverCancelSupport":false,"requests": {"full":false,"range":false},"multilineTokenSupport":false,"dynamicRegistration":false,"overlappingTokenSupport":false,"tokenTypes": ["type","class","enum","interface","struct","typeParameter","parameter","variable","property","enumMember","event","function","method","macro","ke yword","modifier","comment","string","number","regexp","operator"],"tokenModifiers":[],"formats":["relative"]},"signatureHelp": {"dynamicRegistration":false},"definition":{"dynamicRegistration":false,"linkSupport":true}}},"rootPath":"/Users/aaron/git/lsp- stream","clientInfo":{"name":"vim-lsp"},"processId":51035,"trace":"off"}}
  11. Event Handling Dispatch to methods based on event names module

    LSP class Events DISPATCH = { "initialize" => :on_initialize, "textDocument/didSave" => :did_save } def handle method, message, writer send(DISPATCH.fetch(method) { :unknown }, message, writer) end def on_initialize message, writer # ... end end end Map event names to methods Look up methods and call them
  12. Initialize Message Tells the editor what features your server supports

    (server § editor) module LSP class Events def on_initialize message, writer result = { "capabilities" => { "textDocumentSync" => { "openClose" => true, "change" => 1,"save" => true } } } writer.write(id: message[:id], result: result) end end end Open / Close Change Save
  13. Save Events Parse the File, Report Document Diagnostics module LSP

    class Events def did_save message, writer doc = message.dig(:params, :textDocument) file = doc[:uri].delete_prefix("file://") result = { :uri => doc[:uri], :diagnostics => [ ] } error = check_syntax file if error line_number = error.message[/(?<=:)\d+/].to_i line = File.readlines(file)[line_number - 1] result = { :uri => doc[:uri], :diagnostics => [ { "range" => { "start" => { "character" => 0, "line" => line_number - 1 }, "end" => { "character" => line.bytesize, "line" => line_number - 1 }, }, "message" => error.message.lines.first, "severity" => 1 }, ], } end writer.write(method: "textDocument/publishDiagnostics", params: result) end end end Set our default response Check Syntax Extract the Line Information Construct Error Report Send Error Report
  14. Checking Syntax Try compiling the file def check_syntax file RubyVM::InstructionSequence.compile_file(file)

    nil rescue SyntaxError => e e # only return syntax errors rescue Exception nil # ignore anything else end
  15. Entire Language Server It fits on one slide! #!/Users/aaron/.rubies/arm64/ruby-trunk/bin/ruby require

    "json" module LSP class Writer def initialize @io = $stdout.binmode end def write response str = JSON.dump(response.merge("jsonrpc" => "2.0")) @io.write "Content-Length: #{str.bytesize}\r\n" @io.write "\r\n" @io.write str @io.flush end end class Reader def initialize @io = $stdin.binmode end def read buffer = @io.gets("\r\n\r\n") content_length = buffer.match(/Content-Length: (\d+)/i)[1].to_i message = @io.read(content_length) JSON.parse message, symbolize_names: true end end class Events DISPATCH = { "initialize" => :on_initialize, "textDocument/didSave" => :did_save } def handle method, message, writer send DISPATCH.fetch(method) { :unknown }, message, writer end def on_initialize message, writer result = { "capabilities" => { "textDocumentSync" => { "openClose" => true, "change" => 1,"save" => true } } } writer.write(id: message[:id], result: result) end
  16. — Me, in my head, but now out loud at

    RailsConf “What if Rails had a built-in Language Server?”
  17. Fetching Active Record Columns Check that the constant inherits from

    Active Record if token && token =~ /^[A-Z]/ begin const = Object.const_get(token) value = "# #{const.name}\n" if const < ActiveRecord::Base name_header = "Column Name" type_header = "Column Type" info = [[name_header, type_header]] + const.columns.map { |column| ["`" + column.name.to_s + "`", column.type.to_s] } max_name_len = info.map(&:first).sort_by(&:length).last.length max_type_len = info.map(&:last).sort_by(&:length).last.length name_header, type_header = *info.shift value << ("| " + name_header.ljust(max_name_len)) value << (" | " + type_header.ljust(max_type_len) + " |\n") value << ("| " + ("-" * max_name_len)) value << (" | " + ("-" * max_type_len) + " |\n") info.each do |name, type| value << ("| " + name.ljust(max_name_len)) value << (" | " + type.ljust(max_type_len) + " |\n") end end rescue NameError end end
  18. Fetching Active Record Columns Check that the constant inherits from

    Active Record if token && token =~ /^[A-Z]/ begin const = Object.const_get(token) value = "# #{const.name}\n" if const < ActiveRecord::Base name_header = "Column Name" type_header = "Column Type" info = [[name_header, type_header]] + const.columns.map { |column| ["`" + column.name.to_s + "`", column.type.to_s] } max_name_len = info.map(&:first).sort_by(&:length).last.length max_type_len = info.map(&:last).sort_by(&:length).last.length name_header, type_header = *info.shift value << ("| " + name_header.ljust(max_name_len)) value << (" | " + type_header.ljust(max_type_len) + " |\n") value << ("| " + ("-" * max_name_len)) value << (" | " + ("-" * max_type_len) + " |\n") info.each do |name, type| value << ("| " + name.ljust(max_name_len)) value << (" | " + type.ljust(max_type_len) + " |\n") end end rescue NameError end end
  19. Fetching Active Record Columns Check that the constant inherits from

    Active Record if token && token =~ /^[A-Z]/ begin const = Object.const_get(token) value = "# #{const.name}\n" if const < ActiveRecord::Base name_header = "Column Name" type_header = "Column Type" info = [[name_header, type_header]] + const.columns.map { |column| ["`" + column.name.to_s + "`", column.type.to_s] } max_name_len = info.map(&:first).sort_by(&:length).last.length max_type_len = info.map(&:last).sort_by(&:length).last.length name_header, type_header = *info.shift value << ("| " + name_header.ljust(max_name_len)) value << (" | " + type_header.ljust(max_type_len) + " |\n") value << ("| " + ("-" * max_name_len)) value << (" | " + ("-" * max_type_len) + " |\n") info.each do |name, type| value << ("| " + name.ljust(max_name_len)) value << (" | " + type.ljust(max_type_len) + " |\n") end end rescue NameError end end
  20. URL Helper Information “rake routes” already knows this info! value

    = '' if token && token =~ /^([a-z_]+)(_path|_url)$/ # check if it's a route helper if Rails.application.routes.named_routes.key?($1) route = Rails.application.routes.named_routes.get($1) controller = route.requirements[:controller] action = route.requirements[:action] value = "* URI Pattern: `#{route.path.spec}`\n* Controller#Action: `#{controller} ##{action}`" else value = "Something else" end else # Other Stuff end
  21. URL Helper Information “rake routes” already knows this info! value

    = '' if token && token =~ /^([a-z_]+)(_path|_url)$/ # check if it's a route helper if Rails.application.routes.named_routes.key?($1) route = Rails.application.routes.named_routes.get($1) controller = route.requirements[:controller] action = route.requirements[:action] value = "* URI Pattern: `#{route.path.spec}`\n* Controller#Action: `#{controller} ##{action}`" else value = "Something else" end else # Other Stuff end
  22. URL Helper Information “rake routes” already knows this info! value

    = '' if token && token =~ /^([a-z_]+)(_path|_url)$/ # check if it's a route helper if Rails.application.routes.named_routes.key?($1) route = Rails.application.routes.named_routes.get($1) controller = route.requirements[:controller] action = route.requirements[:action] value = "* URI Pattern: `#{route.path.spec}`\n* Controller#Action: `#{controller} ##{action}`" else value = "Something else" end else # Other Stuff end
  23. Route Source Location bin/rails routes -E [aaron@tc-lan-adapter㷊 ~/g/blogsite (main)]$ bin/rails

    routes -E --[ Route 1 ]-------------------------- Prefix | users Verb | GET URI | /users(.:format) Controller#Action | users#index Source Location | config/routes.rb:6 --[ Route 2 ]-------------------------- Prefix | Verb | POST URI | /users(.:format) Controller#Action | users#create Source Location | config/routes.rb:6 --[ Route 3 ]-------------------------- Prefix | new_user Verb | GET URI | /users/new(.:format) Controller#Action | users#new Source Location | config/routes.rb:6 Rails 7.1!
  24. Route Source Location Language Server Lookup if Rails.application.routes.named_routes.key?($1) route =

    Rails.application.routes.named_routes.get($1) file, line = route.source_location.split(':') file = File.join(@root, file) char = File.readlines(file)[line.to_i - 1].index(/[^\s]/) uri = "file://" + file result = { :uri => uri, :range => { start: { line: line.to_i - 1, character: char }, end: { line: line.to_i, character: 0 } } } writer.write(id: request[:id], result: result) end Find the source line Figure out the column Send info back to editor
  25. ERB is converted to Ruby Exceptions in the generated code

    must be mapped to the source <p style="color: green"><%= notice %></p> <h1>Users</h1> <div id="users"> <% @users.each do |user| %> <%= render user %> <p> <%= link_to "Show this user", user %> </p> <% end %> </div> <%= link_to "New user", new_user_path %> Source ERB #coding:ASCII-8BIT _erbout = +''; _erbout.<< "<p style=\"color: green\">".freeze; _erbout.<<(( notice ).to_s); _erbout.<< "</p>\n\n<h1>Users</ h1>\n\n<div id=\"users\">\n ".freeze ; @users.each do |user| ; _erbout.<< "\n ".freeze ; _erbout.<<(( render user ).to_s); _erbout.<< "\n <p>\n ".freeze ; _erbout.<<(( link_to "Show this user", user ).to_s); _erbout.<< "\n </p>\n ".freeze ; end ; _erbout.<< "\n</div>\n\n".freeze ; _erbout.<<(( link_to "New user", new_user_path ).to_s); _erbout.<< "\n".freeze ; _erbout Evaluated Ruby
  26. Language Server Integration Simply Monkey Patch Rails! (Sorry Eileen) class

    ActionDispatch::DebugView def send_exception ex resp = { uri: "file://" + ex.file_name, diagnostics: [ "range" => { "start" => { "character" => 0, "line" => (ex.line_number.to_i - 1) }, "end" => { "character" => 65536, "line" => (ex.line_number.to_i - 1) }, }, "severity" => 1, "message" => ex.message ] } Refreshing::LSP::ERROR_QUEUE << [:error, resp] end end
  27. Language Server Integration Cont. Pop off the queue and send

    to the editor Thread.new do while item = ERROR_QUEUE.pop type, val = *item if type == :clear subscriber.files.each do |file, version| val = { uri: file, version: version, diagnostics: [] } writer.write(method: "textDocument/publishDiagnostics", params: val) end else val[:version] = subscriber.files[val[:uri]] writer.write(method: "textDocument/publishDiagnostics", params: val) end end end