browsable-lsp 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 87c10b50ca49f864d9d2b0793c69ecf77db137a97eccac37c9ab21ba76490006
4
+ data.tar.gz: 44ff7ce5d57863ee5d6b2b9fd914bff2e16cf18b1b8292c7eb32400d39336af6
5
+ SHA512:
6
+ metadata.gz: 36f4c19a6bb502bf67a31260a6a96496a5fec457c6b282c9452ec7e8a627d1fbc45390866fa8661c6d8da7203c0411a52300bc7b8df15972108408a5b72f8bc4
7
+ data.tar.gz: c0a514689ca863de6dc31d5eb53141a26f466021b9a170bd1dd3649cc1d1fea8c010ce736e7156872fdc2c36b8afacdc010dc4a9831494821553e2fe212a81e6
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `browsable-lsp` gem are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0]
11
+
12
+ - Initial release.
data/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # browsable-lsp
2
+
3
+ **A Language Server Protocol server for [`browsable`](../browsable).**
4
+
5
+ `browsable-lsp` exposes browsable's browser-compatibility audit to your editor.
6
+ As you open and edit CSS, ERB, HTML, and JavaScript files, it reports — inline —
7
+ which features your code uses that fall outside your project's `allow_browser`
8
+ target.
9
+
10
+ > Part of the [`browsable` monorepo](https://github.com/romanhood/browsable).
11
+ > Neovim users want [`browsable.nvim`](../browsable.nvim) instead — it bundles
12
+ > this server's wiring.
13
+
14
+ ## What is a language server?
15
+
16
+ A *language server* is a background program your editor talks to over a small
17
+ JSON-RPC protocol (the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/)).
18
+ The editor tells the server which files you open and edit; the server tells the
19
+ editor what to underline. One server works in every LSP-capable editor, so
20
+ browsable's analysis is written once and reused everywhere.
21
+
22
+ `browsable-lsp` communicates over stdio and pushes **diagnostics** — the
23
+ squiggly underlines — for each document you touch.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ gem install browsable-lsp
29
+ ```
30
+
31
+ This installs the `browsable-lsp` executable. Confirm it is on your `PATH`:
32
+
33
+ ```bash
34
+ which browsable-lsp
35
+ ```
36
+
37
+ ERB and HTML are audited in-process and need nothing else. For CSS and
38
+ JavaScript diagnostics, install stylelint and eslint as described in the
39
+ [`browsable` README](../browsable/README.md#system-dependencies--the-doctor-workflow)
40
+ (`browsable doctor` will guide you).
41
+
42
+ ## Severity mapping
43
+
44
+ | browsable category | LSP severity |
45
+ | --------------------------- | -------------- |
46
+ | `below_target` | Error |
47
+ | `baseline_newly_available` | Warning |
48
+ | `baseline_widely_available` | Information |
49
+
50
+ A diagnostic reads, for example:
51
+
52
+ > The `popover` attribute requires Firefox 125+, but your `:modern`
53
+ > `allow_browser` policy permits Firefox 121.
54
+
55
+ ## Editor setup
56
+
57
+ Configuration is **inherited from the browsable gem** — `browsable-lsp` discovers
58
+ `config/browsable.yml` / `.browsable.yml` from the workspace root exactly as the
59
+ CLI does. There is nothing to configure in the server itself.
60
+
61
+ ### VS Code
62
+
63
+ There is no dedicated extension yet. Use a generic LSP bridge such as
64
+ [`vscode-glspc`](https://marketplace.visualstudio.com/items?itemName=qugu.glspc)
65
+ and point it at the executable:
66
+
67
+ ```jsonc
68
+ // settings.json
69
+ {
70
+ "glspc.languageServerPath": "browsable-lsp",
71
+ "glspc.languageId": ["css", "html", "erb", "javascript"]
72
+ }
73
+ ```
74
+
75
+ ### Helix
76
+
77
+ Add to `~/.config/helix/languages.toml`:
78
+
79
+ ```toml
80
+ [language-server.browsable]
81
+ command = "browsable-lsp"
82
+
83
+ [[language]]
84
+ name = "erb"
85
+ language-servers = ["browsable"]
86
+
87
+ [[language]]
88
+ name = "css"
89
+ language-servers = ["browsable"]
90
+ ```
91
+
92
+ ### Zed
93
+
94
+ In your Zed settings, register the server (Zed discovers stdio servers via an
95
+ extension or the `lsp` settings block):
96
+
97
+ ```jsonc
98
+ {
99
+ "lsp": {
100
+ "browsable-lsp": {
101
+ "binary": { "path": "browsable-lsp" }
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ ### Neovim
108
+
109
+ Don't wire this up by hand — install [`browsable.nvim`](../browsable.nvim),
110
+ which configures the client, the filetypes, and the root-directory detection for
111
+ you.
112
+
113
+ ## How it works
114
+
115
+ ```
116
+ editor ──(LSP/JSON-RPC over stdio)──▶ browsable-lsp
117
+
118
+
119
+ Browsable::Analyzers (the core gem)
120
+
121
+
122
+ Findings ──▶ LSP diagnostics
123
+ ```
124
+
125
+ On `textDocument/didOpen` and `textDocument/didChange`, the server runs
126
+ browsable's analyzers against the buffer's *in-memory* contents (no need to
127
+ save), converts the Findings to LSP diagnostics, and publishes them.
128
+
129
+ ## Contributing
130
+
131
+ This gem lives in the `browsable-lsp/` subdirectory of the
132
+ [monorepo](https://github.com/romanhood/browsable):
133
+
134
+ ```bash
135
+ cd browsable-lsp
136
+ bundle install
137
+ bundle exec rspec
138
+ ```
139
+
140
+ ## License
141
+
142
+ MIT — see the [LICENSE](../LICENSE) at the monorepo root.
data/exe/browsable-lsp ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The binary editors invoke. browsable-lsp speaks LSP over stdio, the standard
5
+ # convention — there are no arguments to parse.
6
+
7
+ require "browsable-lsp"
8
+
9
+ Browsable::LSP::Server.new.start
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Browsable
6
+ module LSP
7
+ # Audits a single document's contents and converts the resulting browsable
8
+ # Findings into LSP diagnostic hashes ready for textDocument/publishDiagnostics.
9
+ class Diagnostics
10
+ # browsable severity -> LSP DiagnosticSeverity
11
+ # below_target -> Error (1)
12
+ # baseline_newly_available -> Warning (2)
13
+ # baseline_widely_available -> Information (3)
14
+ SEVERITY = { error: 1, warning: 2, info: 3 }.freeze
15
+
16
+ def self.for(uri:, content:, root: Dir.pwd)
17
+ new(uri: uri, content: content, root: root).call
18
+ end
19
+
20
+ def initialize(uri:, content:, root:)
21
+ @uri = uri
22
+ @content = content.to_s
23
+ @root = root
24
+ end
25
+
26
+ def call
27
+ findings.map { |finding| to_diagnostic(finding) }
28
+ rescue StandardError
29
+ # A diagnostics pass must never crash the editor session.
30
+ []
31
+ end
32
+
33
+ private
34
+
35
+ def findings
36
+ config = Browsable::Config.load(root: @root)
37
+ analyzer_class = analyzer_for(path)
38
+ return [] unless analyzer_class
39
+
40
+ analyzer = analyzer_class.new(target: config.target, config: config)
41
+
42
+ if analyzer.is_a?(Browsable::Analyzers::ERB)
43
+ # ERB/HTML analysis is in-process — audit the buffer contents directly.
44
+ analyzer.analyze_source(@content, file: path)
45
+ else
46
+ # CSS/JS need a real file for stylelint/eslint.
47
+ # TODO(v0.2): debounce changes and reuse a per-document temp file.
48
+ audit_via_tempfile(analyzer)
49
+ end
50
+ end
51
+
52
+ def audit_via_tempfile(analyzer)
53
+ Dir.mktmpdir("browsable-lsp") do |dir|
54
+ tmp = File.join(dir, File.basename(path))
55
+ File.write(tmp, @content)
56
+ analyzer.analyze([tmp])
57
+ end
58
+ end
59
+
60
+ def analyzer_for(file)
61
+ case File.extname(file).downcase
62
+ when ".erb" then Browsable::Analyzers::ERB
63
+ when ".html", ".htm" then Browsable::Analyzers::HTML
64
+ when ".css", ".scss" then Browsable::Analyzers::CSS
65
+ when ".js", ".mjs" then Browsable::Analyzers::Javascript
66
+ end
67
+ end
68
+
69
+ def path
70
+ @path ||= @uri.to_s.sub(%r{\Afile://}, "")
71
+ end
72
+
73
+ def to_diagnostic(finding)
74
+ line = [finding.line.to_i - 1, 0].max
75
+ character = [finding.column.to_i - 1, 0].max
76
+ span = finding.feature_name.to_s.length.clamp(1, 200)
77
+
78
+ {
79
+ range: {
80
+ start: { line: line, character: character },
81
+ end: { line: line, character: character + span }
82
+ },
83
+ severity: SEVERITY.fetch(finding.severity, 2),
84
+ source: "browsable",
85
+ code: finding.feature_id,
86
+ message: finding.message
87
+ }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module LSP
5
+ module Handlers
6
+ # Handles textDocument/didChange — re-audits a document as it is edited.
7
+ class DidChange
8
+ def initialize(server)
9
+ @server = server
10
+ end
11
+
12
+ def call(params)
13
+ uri = params.dig("textDocument", "uri")
14
+ return unless uri
15
+
16
+ # Full-sync mode: the final content change carries the whole document.
17
+ text = Array(params["contentChanges"]).last&.fetch("text", nil)
18
+ return if text.nil?
19
+
20
+ @server.store(uri, text)
21
+ @server.publish_diagnostics(uri, text)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module LSP
5
+ module Handlers
6
+ # Handles textDocument/didOpen — audits a freshly-opened document and
7
+ # publishes its diagnostics.
8
+ class DidOpen
9
+ def initialize(server)
10
+ @server = server
11
+ end
12
+
13
+ def call(params)
14
+ document = params["textDocument"] || {}
15
+ uri = document["uri"]
16
+ return unless uri
17
+
18
+ text = document["text"].to_s
19
+ @server.store(uri, text)
20
+ @server.publish_diagnostics(uri, text)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module LSP
5
+ module Handlers
6
+ # Handles the `initialize` request: advertises the server's capabilities.
7
+ class Initialize
8
+ def call(_params)
9
+ {
10
+ capabilities: {
11
+ # 1 = Full document sync — the client resends the whole buffer on
12
+ # every change. Simple and fine for the file sizes browsable sees.
13
+ textDocumentSync: { openClose: true, change: 1 }
14
+ },
15
+ serverInfo: { name: "browsable-lsp", version: Browsable::LSP::VERSION }
16
+ }
17
+ end
18
+
19
+ # Best-effort extraction of the workspace root from initialize params.
20
+ # browsable's Config is then loaded relative to it.
21
+ def self.workspace_root(params)
22
+ uri = params["rootUri"] || params.dig("workspaceFolders", 0, "uri")
23
+ return params["rootPath"] if uri.nil?
24
+
25
+ uri.to_s.sub(%r{\Afile://}, "")
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module LSP
5
+ # The JSON-RPC server loop.
6
+ #
7
+ # Reads LSP messages from stdin, dispatches them to handlers, and writes
8
+ # responses and diagnostics back to stdout — the standard stdio LSP
9
+ # convention. All compatibility analysis is delegated to the browsable gem;
10
+ # this class only speaks the protocol.
11
+ class Server
12
+ def initialize(input: $stdin, output: $stdout)
13
+ # Io::Reader/Writer take an explicit IO (the Stdio:: subclasses hard-code
14
+ # STDIN/STDOUT); passing $stdin/$stdout keeps the default behaviour while
15
+ # letting tests drive the server over StringIO pipes.
16
+ @reader = ::LanguageServer::Protocol::Transport::Io::Reader.new(input)
17
+ @writer = ::LanguageServer::Protocol::Transport::Io::Writer.new(output)
18
+ @documents = {}
19
+ @workspace_root = Dir.pwd
20
+ @shutdown_requested = false
21
+ end
22
+
23
+ # Block reading messages until the client disconnects or sends `exit`.
24
+ def start
25
+ @reader.read { |message| dispatch(normalize(message)) }
26
+ end
27
+
28
+ # Cache the latest known contents of a document.
29
+ def store(uri, content)
30
+ @documents[uri] = content
31
+ end
32
+
33
+ # Audit `content` and push its diagnostics to the client.
34
+ def publish_diagnostics(uri, content)
35
+ diagnostics = Diagnostics.for(uri: uri, content: content, root: @workspace_root)
36
+ notify("textDocument/publishDiagnostics", { uri: uri, diagnostics: diagnostics })
37
+ end
38
+
39
+ private
40
+
41
+ def dispatch(message)
42
+ method = message["method"]
43
+ id = message["id"]
44
+ params = message["params"] || {}
45
+
46
+ case method
47
+ when "initialize"
48
+ @workspace_root = Handlers::Initialize.workspace_root(params) || @workspace_root
49
+ respond(id, Handlers::Initialize.new.call(params))
50
+ when "initialized"
51
+ nil # nothing to do — diagnostics are pushed on open/change
52
+ when "textDocument/didOpen"
53
+ Handlers::DidOpen.new(self).call(params)
54
+ when "textDocument/didChange"
55
+ Handlers::DidChange.new(self).call(params)
56
+ when "textDocument/didClose"
57
+ @documents.delete(params.dig("textDocument", "uri"))
58
+ when "shutdown"
59
+ @shutdown_requested = true
60
+ respond(id, nil)
61
+ when "exit"
62
+ exit(@shutdown_requested ? 0 : 1)
63
+ # TODO(v0.2): textDocument/codeAction — offer "Add @supports fallback"
64
+ # and "Tighten allow_browser to require Safari 15.4+" as quick fixes.
65
+ end
66
+ rescue StandardError => e
67
+ log("error handling #{method}: #{e.class}: #{e.message}")
68
+ end
69
+
70
+ def respond(id, result)
71
+ write(jsonrpc: "2.0", id: id, result: result)
72
+ end
73
+
74
+ def notify(method, params)
75
+ write(jsonrpc: "2.0", method: method, params: params)
76
+ end
77
+
78
+ def write(message)
79
+ @writer.write(message)
80
+ end
81
+
82
+ # Diagnostics go to stderr — stdout is reserved for the JSON-RPC channel.
83
+ def log(text)
84
+ warn("[browsable-lsp] #{text}")
85
+ end
86
+
87
+ # Normalize every hash key to a String so handlers need not care whether
88
+ # the transport produced symbol or string keys.
89
+ def normalize(object)
90
+ case object
91
+ when Hash then object.each_with_object({}) { |(k, v), h| h[k.to_s] = normalize(v) }
92
+ when Array then object.map { |element| normalize(element) }
93
+ else object
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module LSP
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # browsable-lsp — a Language Server Protocol server built on the browsable gem.
4
+ #
5
+ # The core gem already sets up Zeitwerk for the Browsable namespace; this small
6
+ # companion gem uses an explicit require manifest instead. With only a handful
7
+ # of files, a flat manifest is clearer than a second autoloader co-managing the
8
+ # shared Browsable:: namespace.
9
+
10
+ require "browsable"
11
+ require "language_server-protocol"
12
+
13
+ require_relative "browsable/lsp/version"
14
+ require_relative "browsable/lsp/diagnostics"
15
+ require_relative "browsable/lsp/handlers/initialize"
16
+ require_relative "browsable/lsp/handlers/did_open"
17
+ require_relative "browsable/lsp/handlers/did_change"
18
+ require_relative "browsable/lsp/server"
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: browsable-lsp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Roman Hood
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-05-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: browsable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: language_server-protocol
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.17'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.17'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.13'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.13'
69
+ description: |
70
+ browsable-lsp exposes browsable's browser-compatibility audit as a Language
71
+ Server Protocol server, so editors can show inline diagnostics as you type.
72
+ It wraps the browsable gem's analyzers and speaks LSP over stdio.
73
+ email:
74
+ - roman.hood@aigility.com
75
+ executables:
76
+ - browsable-lsp
77
+ extensions: []
78
+ extra_rdoc_files: []
79
+ files:
80
+ - CHANGELOG.md
81
+ - README.md
82
+ - exe/browsable-lsp
83
+ - lib/browsable-lsp.rb
84
+ - lib/browsable/lsp/diagnostics.rb
85
+ - lib/browsable/lsp/handlers/did_change.rb
86
+ - lib/browsable/lsp/handlers/did_open.rb
87
+ - lib/browsable/lsp/handlers/initialize.rb
88
+ - lib/browsable/lsp/server.rb
89
+ - lib/browsable/lsp/version.rb
90
+ homepage: https://github.com/romanhood/browsable
91
+ licenses:
92
+ - MIT
93
+ metadata:
94
+ homepage_uri: https://github.com/romanhood/browsable
95
+ source_code_uri: https://github.com/romanhood/browsable/tree/main/browsable-lsp
96
+ changelog_uri: https://github.com/romanhood/browsable/blob/main/browsable-lsp/CHANGELOG.md
97
+ bug_tracker_uri: https://github.com/romanhood/browsable/issues
98
+ rubygems_mfa_required: 'true'
99
+ post_install_message:
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '3.2'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubygems_version: 3.5.22
115
+ signing_key:
116
+ specification_version: 4
117
+ summary: Language Server Protocol server for browsable.
118
+ test_files: []