Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

Developer Tooling For The Modern Hotwire & Rail...

Developer Tooling For The Modern Hotwire & Rails Era @ RedDotRubyConf 2024, Singapore

The evolution of developer experience tooling has been a game-changer in how we build and debug web applications.

This talk aims to showcase the path toward enriching the Ruby on Rails ecosystem with advanced DX tools, focusing on the implementation of Language Server Protocols (LSP) for Stimulus and Turbo.

Drawing inspiration from the rapid advancements in JavaScript tooling, we explore the horizon of possibilities for Rails developers.

The session will extend beyond LSPs, to explore the potential of browser extensions and development tools tailored specifically for Turbo, Stimulus, and Hotwire.

Marco Roth

July 25, 2024
Tweet

More Decks by Marco Roth

Other Decks in Programming

Transcript

  1. 👋 Marco Roth t @marcoroth_ M @[email protected] g marcoroth.dev g

    @marcoroth Full-Stack Developer & Open Source Contributor
  2. Developer Tooling For The Modern Hotwire & Rails Era Marco

    Roth Full-Stack Developer & Open Source Contributor
  3. X

  4. { id: 1, method: "textDocument/completion", params: { "textDocument": { "uri":

    "file:///.../lsp-example/.../test.txt" }, "position": { "line": 1, "character": 7 }, "context": { "triggerKind": 1 } } }
  5. { id: 1, method: "textDocument/completion", params: { "textDocument": { "uri":

    "file:///.../lsp-example/.../test.txt" }, "position": { "line": 1, "character": 7 }, "context": { "triggerKind": 1 } } }
  6. { id: 1, method: "textDocument/completion", params: { "textDocument": { "uri":

    "file:///.../lsp-example/.../test.txt" }, "position": { "line": 1, "character": 7 }, "context": { "triggerKind": 1 } } }
  7. { id: 1, result: [ { "label": "TypeScript", "kind": 1,

    "data": 1 }, { "label": "JavaScript", "kind": 1, "data": 2 } ] }
  8. import { IHTMLDataProvider } from "vscode-html-languageservice" export class StimulusHTMLDataProvider implements

    IHTMLDataProvider { provideTags() { ... } provideAttributes(tag: string) { ... } provideValues(tag: string, attribute: string) { ... } }
  9. import { IHTMLDataProvider } from "vscode-html-languageservice" export class StimulusHTMLDataProvider implements

    IHTMLDataProvider { provideTags() { ... } provideAttributes(tag: string) { ... } provideValues(tag: string, attribute: string) { ... } }
  10. class ControllerDefinition { readonly path: string methods: Array<string> = []

    targets: Array<string> = [] classes: Array<string> = [] values: { [key: string]: Value } = {} }
  11. import { Parser as AcornParser } from "acorn" import {

    simple as walk } from "acorn-walk" export class Parser { parseController(code: string, filename: string) { ... } }
  12. export default class extends Controller { static targets = ["name",

    "output"] connect() { console.log("Connect") } greet() { this.outputTarget.textContent = `Hello ${this.nameTarget.value}` } disconnect() { console.log("Disconnect") } }
  13. export default class extends Controller { static targets = ["name",

    "output"] connect() { console.log("Connect") } greet() { this.outputTarget.textContent = `Hello ${this.nameTarget.value}` } disconnect() { console.log("Disconnect") } }
  14. const pattern = "app/javascript/controllers/**/*_controller.js" const controllerFiles = await glob(pattern) controllerFiles.forEach(async

    path => { const code = await fs.readFile(path, "utf8") parser.parseController(code, path) })
  15. parseController(code: string, filename: string) { const ast = this.parse(code) const

    controller = new ControllerDefinition(filename) walk(ast, { MethodDefinition(node) { if (node.kind === "method") { controller.methods.push(node.key.name) } }, }) }
  16. parseController(code: string, filename: string) { const ast = this.parse(code) const

    controller = new ControllerDefinition(filename) walk(ast, { MethodDefinition(node) { if (node.kind === "method") { controller.methods.push(node.key.name) } }, }) }
  17. parseController(code: string, filename: string) { const ast = this.parse(code) const

    controller = new ControllerDefinition(filename) walk(ast, { MethodDefinition(node) { if (node.kind === "method") { controller.methods.push(node.key.name) } }, }) }
  18. parseController(code: string, filename: string) { const ast = this.parse(code) const

    controller = new ControllerDefinition(filename) walk(ast, { MethodDefinition(node) { if (node.kind === "method") { controller.methods.push(node.key.name) } }, }) }
  19. provideAttributes(tag: string) { const targets = controllers.map(controller => `data-${controller.identifier}-target` )

    return [ { name: "data-controller" }, { name: "data-action" }, ...targets, ] }
  20. provideAttributes(tag: string) { const targets = controllers.map(controller => `data-${controller.identifier}-target` )

    return [ { name: "data-controller" }, { name: "data-action" }, ...targets, ] }
  21. provideAttributes(tag: string) { const targets = controllers.map(controller => `data-${controller.identifier}-target` )

    return [ { name: "data-controller" }, { name: "data-action" }, ...targets, ] }
  22. provideValues(_tag: string, attribute: string) { if (attribute === "data-controller") {

    return this.controllers.map(controller => controller.identifier ) } const match = attribute.match(/data-(.+)-target/) const controller = this.controllers.find(controller => controller.identifier == match[1] ) return controller.targets }
  23. provideValues(_tag: string, attribute: string) { if (attribute === "data-controller") {

    return this.controllers.map(controller => controller.identifier ) } const match = attribute.match(/data-(.+)-target/) const controller = this.controllers.find(controller => controller.identifier == match[1] ) return controller.targets }
  24. import { application } from "./application" import ClipboardController from "./clipboard_controller.js"

    application.register("clipboard", ClipboardController) import FileUploadController from "./file_upload_controller.js" application.register("file-upload", FileUploadController) f app/javascript/controllers/index.js
  25. import { application } from "./application" import ClipboardController from "./clipboard_controller.js"

    application.register("clipboard", ClipboardController) import FileUploadController from "./file_upload_controller.js" application.register("file-upload", FileUploadController) f app/javascript/controllers/index.js
  26. import { application } from "controllers/application" import { eagerLoadControllersFrom }

    from "@hotwired/stimulus-loading" eagerLoadControllersFrom("controllers", application)
  27. $ stimulus-lint Stimulus Lint is inspecting 25 files ........................ 25

    files inspected, 0 offenses detected, 0 offenses autocorrectable
  28. <%= tag.div( data: { controller: "filter", filter_open_class: "border-white", filter_close_class: "hover:bg-gray-100

    border-gray-300" } ) %> Full support for HTML+ERB and Rails-specific helpers
  29. So I started to dabble and explore what would be

    possible with a new HTML-aware ERB parser
  30. The idea is to power the ERB support with the

    new HTML-aware parser in these tools Stimulus LSP Turbo LSP Stimulus Lint Ruby LSP (maybe?)
  31. <article id="<%= dom_id(article) %>"></article> <input <% if true %> type="text"

    <% end %> /> <% @posts.each do |post| %> <h1><%= post.title %></h1> <% end %> <%= content_tag(:p, "Hello world!") %> <%= tag.div tag.p("Hello world!") %> <%= tag.p do %> Hello world! <% end %> <%= tag.div( data: { controller: "hello", action: "click->hello#greet" } ) %>
  32. In which the ERB view helpers also have an HTML

    tag node for Rails View Helpers
  33. This would also open to door to support Haml, Slim,

    Liquid, Blade and other template languages
  34. And we don ’ t really need to care what

    templating language it actually is
  35. If this is done right it could also help with

    other tooling such as linters, formatters, and code navigation tools.
  36. <!-- main.html.erb --> <div data-controller="hello"> <%= render partial: "input" %>

    </div> <!-- _input.html.erb --> <input data-hello-target="name" type="text">
  37. <!-- main.html.erb --> <div data-controller="hello"> <%= render partial: "input" %>

    </div> <!-- _input.html.erb --> <input data-hello-target="name" type="text">
  38. <!-- main.html.erb --> <div data-controller="hello"></div> <%= render partial: "input" %>

    <!-- _input.html.erb --> <input data-hello-target="name" type="text">
  39. I am not sure how feasible it is, but the

    ERB parser would make it a lot more approachable.
  40. Turbo Streams Debug Bar Pause Streams Processing Step through Streams

    Revert Turbo Stream Actions Show Diff of what an action changed and more…
  41. Turbo Morphing Debug Turbo Morphing Visual Diffs Turbo Form Submission

    Debug Turbo Streams Header Debug ActionCable / Turbo Rails Cable Debug Turbo Frame Lazy Loading Debug Stimulus Controller Tree-View Panel … Ideas & Roadmap
  42. I believe that tools like this are part of the

    reason why a language/framework keeps it ’ s relevancy
  43. X

  44. I want to help build the tools needed for the

    future of Rails applications.
  45. Hotwire Weekly • A new Hotwire-focused newsletter • Delivered Weekly

    • Explore what ’ s happening in the world of Hotwire • Progress and updates while we are building out hotwire.io • Been running since Oct 2023 • Over 1350+ subscribers and growing WEEKLY