Module: Mbeditor::LspDiagnosticsTranslator
- Defined in:
- app/services/mbeditor/lsp_diagnostics_translator.rb
Overview
Translates an LSP DocumentDiagnosticReport into the marker shape the editor
already uses for RuboCop offenses, so ruby-lsp diagnostics flow through the
existing pipeline (markers state -> setModelMarkers -> quick-fix lightbulbs)
with no frontend changes beyond honouring source.
LSP ranges are 0-based; editor markers are 1-based.
Constant Summary collapse
- SEVERITIES =
LSP DiagnosticSeverity -> the severity strings cop_severity also emits. Mirrors ruby-lsp's own RUBOCOP_TO_LSP_SEVERITY in reverse: rubocop's
infobecomes HINT(4) and convention/refactor become INFORMATION(3), so the two must map back to distinct editor severities. { 1 => "error", 2 => "warning", 3 => "info", 4 => "hint" }.freeze
- UNCORRECTABLE_SUFFIX =
ruby-lsp appends this to non-correctable RuboCop messages; it's noise in a Monaco hover.
"This offense is not auto-correctable."- UNNECESSARY_COP =
Cops whose offense means "this code does nothing" rather than "this code is wrong". Monaco fades these out (MarkerTag.Unnecessary) instead of squiggling them, which is the whole point of greying out dead code.
ruby-lsp emits no DiagnosticTag of its own, so the cop name is the only signal available — and it's the same signal on the plain-rubocop /lint path, which is why this predicate is public and shared by both.
ponytail: name heuristic, not an enumeration. Upgrade path is an explicit cop list if a badly-named cop ever gets faded by mistake.
/Unused|Useless|Redundant|Unreachable|Deprecat/.freeze
- UNNECESSARY_MESSAGE =
Prism reports the same dead code as Lint/UselessAssignment and Lint/UnreachableCode, but as a parser warning with no cop name — so both markers land on the same range. Fading only the RuboCop one leaves the Prism squiggle drawn on top and nothing looks greyed out at all.
/\Aassigned but unused variable|\Astatement not reached|\Aunused literal/.freeze
- MAX_CODE_ACTIONS =
Most actions a diagnostic can carry. RuboCop sends two (autocorrect and disable-for-this-line); the cap is a bound on a payload we don't control.
5- MAX_EDIT_BYTES =
Refuse to ship an absurd replacement into the browser. RuboCop's replacements are a few characters; anything near this is a bug or an attack, not a quick fix.
64 * 1024
Class Method Summary collapse
-
.call(result, uri = nil) ⇒ Object
uriis the request's own document URI. - .clean_message(message) ⇒ Object
- .extract_code(code) ⇒ Object
- .rubocop_source?(source) ⇒ Boolean
-
.sanitize_code_actions(diag, uri) ⇒ Object
ruby-lsp embeds the complete edits for each RuboCop fix in the diagnostic itself.
-
.sanitize_edit(edit) ⇒ Object
One LSP TextEdit -> the 1-based shape the rest of the marker uses.
- .translate_one(diag, uri = nil) ⇒ Object
- .unnecessary?(cop_name, message = nil) ⇒ Boolean
Class Method Details
.call(result, uri = nil) ⇒ Object
uri is the request's own document URI. Code actions are only accepted
when every edit they carry targets exactly that document — see
sanitize_code_actions.
53 54 55 56 57 58 59 60 61 62 |
# File 'app/services/mbeditor/lsp_diagnostics_translator.rb', line 53 def call(result, uri = nil) items = case result when Hash then Array(result["items"]) when Array then result else [] end markers = items.filter_map { |diag| translate_one(diag, uri) } { markers: markers, summary: { "offense_count" => markers.length } } end |
.clean_message(message) ⇒ Object
166 167 168 |
# File 'app/services/mbeditor/lsp_diagnostics_translator.rb', line 166 def () .to_s.sub(UNCORRECTABLE_SUFFIX, "").strip end |
.extract_code(code) ⇒ Object
158 159 160 161 162 163 164 |
# File 'app/services/mbeditor/lsp_diagnostics_translator.rb', line 158 def extract_code(code) case code when Hash then code["value"].to_s when nil then "" else code.to_s end end |
.rubocop_source?(source) ⇒ Boolean
154 155 156 |
# File 'app/services/mbeditor/lsp_diagnostics_translator.rb', line 154 def rubocop_source?(source) source.to_s.match?(/rubocop/i) end |
.sanitize_code_actions(diag, uri) ⇒ Object
ruby-lsp embeds the complete edits for each RuboCop fix in the diagnostic
itself. Its textDocument/codeAction handler does nothing but echo these
back (see Requests::CodeActions#perform), so lifting them here lets the
editor apply a fix with no further request at all -- no codeAction call
and no rubocop -A subprocess.
This is a trust boundary: the payload arrives from a subprocess and is applied to the user's buffer. An action is taken only if every edit in it targets the very document we asked about.
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'app/services/mbeditor/lsp_diagnostics_translator.rb', line 108 def sanitize_code_actions(diag, uri) return [] if uri.nil? Array(diag.dig("data", "code_actions")).first(MAX_CODE_ACTIONS).filter_map do |action| next unless action.is_a?(Hash) changes = Array(action.dig("edit", "documentChanges")) next if changes.empty? # Any edit aimed at another file disqualifies the whole action. RuboCop # fixes are always single-file, so this rejects nothing legitimate. next unless changes.all? { |c| c.is_a?(Hash) && c.dig("textDocument", "uri") == uri } edits = changes.flat_map { |c| Array(c["edits"]) }.filter_map { |edit| sanitize_edit(edit) } next if edits.empty? { title: action["title"].to_s, edits: edits } end end |
.sanitize_edit(edit) ⇒ Object
One LSP TextEdit -> the 1-based shape the rest of the marker uses.
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'app/services/mbeditor/lsp_diagnostics_translator.rb', line 128 def sanitize_edit(edit) return nil unless edit.is_a?(Hash) text = edit["newText"].to_s return nil if text.bytesize > MAX_EDIT_BYTES range = edit["range"] return nil unless range.is_a?(Hash) { startLine: (range.dig("start", "line") || 0) + 1, startCol: (range.dig("start", "character") || 0) + 1, endLine: (range.dig("end", "line") || range.dig("start", "line") || 0) + 1, endCol: (range.dig("end", "character") || range.dig("start", "character") || 0) + 1, text: text } end |
.translate_one(diag, uri = nil) ⇒ Object
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
# File 'app/services/mbeditor/lsp_diagnostics_translator.rb', line 64 def translate_one(diag, uri = nil) return nil unless diag.is_a?(Hash) range = diag["range"] || {} start_line = (range.dig("start", "line") || 0) + 1 start_col = (range.dig("start", "character") || 0) + 1 end_line = (range.dig("end", "line") || range.dig("start", "line") || 0) + 1 end_col = (range.dig("end", "character") || range.dig("start", "character") || 0) + 1 end_line = start_line if end_line < start_line end_col = start_col + 1 if end_line == start_line && end_col <= start_col cop_name = extract_code(diag["code"]) = (diag["message"]) fixes = sanitize_code_actions(diag, uri) { severity: SEVERITIES.fetch(diag["severity"], "info"), copName: cop_name, correctable: diag.dig("data", "correctable") == true, # Only RuboCop-sourced markers may claim the 'rubocop' source: the # quick-fix provider filters on it and sends copName to /quick_fix, # which runs `rubocop -A`. A Prism syntax error must not get a # lightbulb that can't fix anything. source: rubocop_source?(diag["source"]) ? "rubocop" : diag["source"].to_s.downcase, message: cop_name.empty? ? : "[#{cop_name}] #{}", startLine: start_line, startCol: start_col, endLine: end_line, endCol: end_col, unnecessary: unnecessary?(cop_name, ), # RuboCop >= 1.64 ships a docs URL per cop; Monaco renders it as a link # on the marker's code. Absent for non-RuboCop diagnostics. # NB: the LSP wire key is camelCase. codeHref: diag.dig("codeDescription", "href") }.tap { |marker| marker[:fixes] = fixes if fixes.any? } end |
.unnecessary?(cop_name, message = nil) ⇒ Boolean
146 147 148 149 150 151 152 |
# File 'app/services/mbeditor/lsp_diagnostics_translator.rb', line 146 def unnecessary?(cop_name, = nil) return true if UNNECESSARY_COP.match?(cop_name.to_s) # Only consult the message when there is no cop to go on, so a RuboCop # offense is never faded because of a phrase inside its description. cop_name.to_s.empty? && UNNECESSARY_MESSAGE.match?(.to_s) end |