klenod-lsp 0.0.29 → 0.0.31

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9a6fa41793e20e187ce323020348b56930bfb1d3ca84431ff124ea917cec1f7
4
- data.tar.gz: 2a3ee612b372207dfbb7f4e86874d6237476d6159b701b68add8b55ff58c2f9d
3
+ metadata.gz: 0d5602eca5221374939807c3f33d43a9ac7794453a73b049e579a77ac229b551
4
+ data.tar.gz: 8dd3d801a3a62fc2405b84a221afef3a8ca9b2577a0a33bc3ac73a3a26579651
5
5
  SHA512:
6
- metadata.gz: 1e20ea7abb80b7c3cfd250d286adb2372ade08b85d9927794a63e13df10e1626be7b32b41135f57095da7a8243735dc9caac3b3a0fe82604028e3cd9246c6c85
7
- data.tar.gz: b6cde269621b3ee407f5bf6a504be3537e16a2a21c34ec81d23a35f5c683e6233ece936c7192b8f248f7d9067dfb0fa1216ce0341a9892dd5b3a03868dd60f9f
6
+ metadata.gz: cd906ff2ed13c6ab65b62b7215b5d8db1fe2b1999a41a0cd517d9dbe7a44f9ffb0729925c3eed473836f2fab32e61d478d58780445f504e3d13d19a0c10ca829
7
+ data.tar.gz: 3f3ca9d50efaaae456a5eece277790d95369e5276585d1c13b742dcf4144bf09a919d1eebbab4c7ecbfc2c58f60a829bae584cab42b681473f9ca50cc1c7ccab
data/README.md CHANGED
@@ -43,6 +43,29 @@ bundle exec klenod lsp
43
43
  The `klenod` command comes from the `klenod` meta gem. The meta gem does not
44
44
  install `klenod-lsp` automatically.
45
45
 
46
+ ## Checking a whole project
47
+
48
+ `klenod check` reports the editor's diagnostics for every Ruby, Haml, and CSS
49
+ file under the source directory, like a linter:
50
+
51
+ ```sh
52
+ bundle exec klenod check
53
+ bundle exec klenod check src/components src/pages/Home.haml
54
+ ```
55
+
56
+ ```
57
+ src/pages/Home.haml
58
+ 4:3 warning `Card` is imported but never used
59
+ 9:12 error Could not resolve "./Missing"
60
+
61
+ 1 error, 1 warning, 42 files checked.
62
+ ```
63
+
64
+ It collects the whole module graph first, so cross-file diagnostics such as
65
+ unknown props and scoped classes are included. Paths limit which files are
66
+ reported. The command exits with status 1 when it reports any diagnostic.
67
+ Output is colored in a terminal; set `NO_COLOR` to turn colors off.
68
+
46
69
  Configure the editor to run that command for `.haml` files. Ruby and CSS files
47
70
  can also use it alongside their usual language servers.
48
71
 
@@ -90,6 +113,17 @@ exit Klenod::LSP::Server.new(
90
113
  - `input:` and `output:` — the JSON-RPC streams; default to stdin and stdout.
91
114
  - `logger:` — server logging; defaults to stderr.
92
115
 
116
+ `Klenod::LSP::Check` runs the same checks without a server. It takes the same
117
+ `context:`, `entrypoints:`, and `logger:`:
118
+
119
+ ```ruby
120
+ results = Klenod::LSP::Check.new(context: context, entrypoints: config.entrypoints).call
121
+ exit Klenod::LSP::Check.report(results, output: $stdout).zero? ? 0 : 1
122
+ ```
123
+
124
+ `call` accepts absolute file and directory paths to limit the files checked.
125
+ `report` prints each diagnostic and a summary, and returns the diagnostic count.
126
+
93
127
  The web example has a complete runner at `example/web/bin/lsp`.
94
128
 
95
129
  ## How it works
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "logger"
5
+
6
+ require_relative "documents"
7
+ require_relative "graph_index"
8
+ require_relative "languages"
9
+ require_relative "workspace"
10
+
11
+ module Klenod
12
+ module LSP
13
+ # Reports the language server's diagnostics for every source file at
14
+ # once, for terminals and CI.
15
+ #
16
+ # The graph index collects the whole application first, as the server
17
+ # does in the background, so cross-file diagnostics such as unknown props
18
+ # and scoped classes are complete. Nothing is ever evaluated.
19
+ class Check
20
+ Constant = LanguageServer::Protocol::Constant
21
+
22
+ SEVERITY_NAMES = {
23
+ Constant::DiagnosticSeverity::ERROR => "error",
24
+ Constant::DiagnosticSeverity::WARNING => "warning",
25
+ Constant::DiagnosticSeverity::INFORMATION => "info",
26
+ Constant::DiagnosticSeverity::HINT => "hint"
27
+ }.freeze
28
+
29
+ COLORS = {
30
+ path: "\e[4m",
31
+ location: "\e[2m",
32
+ error: "\e[31m",
33
+ warning: "\e[33m",
34
+ info: "\e[1;34m",
35
+ hint: "\e[2m",
36
+ detail: "\e[2m",
37
+ code: "\e[36m",
38
+ success: "\e[1;32m"
39
+ }.freeze
40
+ RESET = "\e[0m"
41
+ CODE = /`([^`\n]+)`/
42
+
43
+ # One checked file and its diagnostics, which may be empty.
44
+ Result = Data.define(:path, :diagnostics)
45
+
46
+ def initialize(context:, entrypoints: [], logger: nil)
47
+ @workspace = Workspace.new(context: context)
48
+ @index = GraphIndex.new(
49
+ workspace: @workspace,
50
+ entrypoints: entrypoints,
51
+ logger: logger || Logger.new($stderr, progname: "klenod-lsp", level: :warn)
52
+ )
53
+ end
54
+
55
+ # Checks the source files below `paths`, or all of them when `paths` is
56
+ # empty. Paths are absolute files or directories.
57
+ def call(paths = [])
58
+ Sync do |task|
59
+ @index.start(task)
60
+ @index.wait
61
+ end
62
+
63
+ @index.source_module_ids.filter_map do |module_id|
64
+ path = @workspace.path_for_module_id(module_id)
65
+ next unless selected?(path, paths)
66
+
67
+ document = Document.new(uri: @workspace.uri_for_path(path), path: path, module_id: module_id, text: File.read(path), version: nil)
68
+ language = Languages.for(document)
69
+ next unless language
70
+
71
+ Result.new(path: path, diagnostics: language.diagnostics(@workspace.analyze(module_id, document.text), @workspace, @index))
72
+ end
73
+ end
74
+
75
+ # Prints the diagnostics grouped by file, like ESLint's stylish format,
76
+ # with paths relative to `root`, then a summary:
77
+ #
78
+ # src/pages/Home.haml
79
+ # 4:3 warning `Card` is imported but never used
80
+ # 9:12 error Could not resolve "./Missing"
81
+ #
82
+ # 1 error, 1 warning, 42 files checked.
83
+ #
84
+ # Columns share one width across all files, so every row lines up.
85
+ # Returns the number of diagnostics. Colors default to on for a terminal
86
+ # unless NO_COLOR is set.
87
+ def self.report(results, output:, root: Dir.pwd, color: color?(output))
88
+ paint = color ? ->(name, text) { "#{COLORS.fetch(name)}#{text}#{RESET}" } : ->(_name, text) { text }
89
+ files = results.reject { |result| result.diagnostics.empty? }.map do |result|
90
+ rows = result.diagnostics
91
+ .sort_by { |diagnostic| [diagnostic.range.start.line, diagnostic.range.start.character] }
92
+ .map do |diagnostic|
93
+ start = diagnostic.range.start
94
+ [start.line + 1, start.character + 1, SEVERITY_NAMES.fetch(diagnostic.severity), diagnostic.message]
95
+ end
96
+ [result.path, rows]
97
+ end
98
+ rows = files.flat_map(&:last)
99
+
100
+ # Line numbers align right and columns left, so the colons line up.
101
+ line_width = rows.map { |line, _, _, _| line.to_s.length }.max
102
+ column_width = rows.map { |_, column, _, _| column.to_s.length }.max
103
+ severity_width = rows.map { |_, _, severity, _| severity.length }.max
104
+ indent = " " * (2 + line_width.to_i + 1 + column_width.to_i + 2 + severity_width.to_i + 2)
105
+
106
+ files.each do |path, file_rows|
107
+ output.puts paint.call(:path, display_path(path, root))
108
+ file_rows.each do |line, column, severity, message|
109
+ location = "#{line.to_s.rjust(line_width)}:#{column.to_s.ljust(column_width)}"
110
+ first_line, *rest = message.lines(chomp: true)
111
+ first_line = highlight(first_line) if color
112
+ output.puts " #{paint.call(:location, location)} #{paint.call(severity.to_sym, severity.ljust(severity_width))} #{first_line}"
113
+ rest.each { |text| output.puts "#{indent}#{color ? highlight(text, COLORS.fetch(:detail)) : text}" }
114
+ end
115
+ output.puts
116
+ end
117
+
118
+ diagnostics = results.flat_map(&:diagnostics)
119
+ output.puts summary(results, diagnostics, paint)
120
+ diagnostics.length
121
+ end
122
+
123
+ def self.summary(results, diagnostics, paint = ->(_name, text) { text })
124
+ files = "#{results.length} #{(results.length == 1) ? "file" : "files"} checked"
125
+ return "#{paint.call(:success, "No problems found")}, #{files}." if diagnostics.empty?
126
+
127
+ counts = diagnostics.group_by(&:severity).sort.map do |severity, group|
128
+ name = SEVERITY_NAMES.fetch(severity)
129
+ paint.call(name.to_sym, "#{group.length} #{(group.length == 1) ? name : "#{name}s"}")
130
+ end
131
+ "#{counts.join(", ")}, #{files}."
132
+ end
133
+
134
+ # Colors backticked names such as `Card` in place of their backticks.
135
+ # `base` is the color of the surrounding text, restored after each name.
136
+ def self.highlight(text, base = nil)
137
+ highlighted = text.gsub(CODE) { "#{COLORS.fetch(:code)}#{Regexp.last_match(1)}#{RESET}#{base}" }
138
+ base ? "#{base}#{highlighted}#{RESET}" : highlighted
139
+ end
140
+
141
+ def self.color?(output, env = ENV)
142
+ return false if env.key?("NO_COLOR")
143
+
144
+ output.respond_to?(:tty?) && output.tty?
145
+ end
146
+
147
+ def self.display_path(path, root)
148
+ relative = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
149
+ relative.start_with?("../") ? path : relative
150
+ end
151
+
152
+ private
153
+
154
+ def selected?(path, paths)
155
+ return true if paths.empty?
156
+
157
+ paths.any? { |selected| path == selected || path.start_with?("#{selected}/") }
158
+ end
159
+ end
160
+ end
161
+ end
@@ -7,24 +7,54 @@ require_relative "../lsp"
7
7
  module Klenod
8
8
  module LSP
9
9
  module CLI
10
+ # Loads the nearest klenod.config.rb and yields it from its base
11
+ # directory. Returns 1 when there is no config.
12
+ def self.with_config(output)
13
+ config_path = Klenod::Build::ConfigLoader.find
14
+ unless config_path
15
+ output.puts "Could not find klenod.config.rb"
16
+ return 1
17
+ end
18
+
19
+ config = nil
20
+ Dir.chdir(File.dirname(config_path)) do
21
+ config = Klenod::Build::ConfigLoader.load(config_path)
22
+ end
23
+
24
+ Dir.chdir(config.base_dir) { yield config }
25
+ end
26
+
10
27
  class Command < Samovar::Command
11
28
  self.description = "Start a language server for editors."
12
29
 
13
30
  def call
14
- config_path = Klenod::Build::ConfigLoader.find
15
- unless config_path
16
- output.puts "Could not find klenod.config.rb"
17
- return 1
31
+ CLI.with_config(output) do |config|
32
+ context = config.context(mode: :development, analysis: true)
33
+ Klenod::LSP::Server.new(context: context, entrypoints: config.entrypoints).start
18
34
  end
35
+ end
36
+ end
37
+
38
+ class CheckCommand < Samovar::Command
39
+ self.description = "Report language server diagnostics for every source file."
19
40
 
20
- config = nil
21
- Dir.chdir(File.dirname(config_path)) do
22
- config = Klenod::Build::ConfigLoader.load(config_path)
41
+ many :paths, "Files or directories to check. Defaults to the whole source directory."
42
+
43
+ def call
44
+ root = Dir.pwd
45
+ paths = Array(@paths)
46
+ missing = paths.reject { |path| File.exist?(path) }
47
+ unless missing.empty?
48
+ missing.each { |path| output.puts "No such file or directory: #{path}" }
49
+ return 1
23
50
  end
51
+ # The source directory is a real path, so selections must be too.
52
+ paths = paths.map { |path| File.realpath(path) }
24
53
 
25
- Dir.chdir(config.base_dir) do
54
+ CLI.with_config(output) do |config|
26
55
  context = config.context(mode: :development, analysis: true)
27
- Klenod::LSP::Server.new(context: context, entrypoints: config.entrypoints).start
56
+ results = Klenod::LSP::Check.new(context: context, entrypoints: config.entrypoints).call(paths)
57
+ Klenod::LSP::Check.report(results, output: output, root: root).zero? ? 0 : 1
28
58
  end
29
59
  end
30
60
  end
@@ -125,6 +125,18 @@ module Klenod
125
125
  end
126
126
  end
127
127
 
128
+ # Every Ruby, Haml, and CSS file under the source directory, skipping
129
+ # dotfiles and dot directories.
130
+ def source_module_ids
131
+ Dir.glob("**/*", base: @workspace.source_dir).sort.filter_map do |relative|
132
+ next if relative.split("/").any? { |segment| segment.start_with?(".") }
133
+ next unless ROOT_EXTENSIONS.include?(File.extname(relative))
134
+ next unless File.file?(File.join(@workspace.source_dir, relative))
135
+
136
+ Klenod::Build::ModuleId.new("app:/#{relative}")
137
+ end
138
+ end
139
+
128
140
  private
129
141
 
130
142
  def root_module_ids
@@ -133,13 +145,7 @@ module Klenod
133
145
  module_id = resolve_entrypoint(specifier)
134
146
  roots << module_id if module_id
135
147
  end
136
- Dir.glob("**/*", base: @workspace.source_dir).sort.each do |relative|
137
- next if relative.split("/").any? { |segment| segment.start_with?(".") }
138
- next unless ROOT_EXTENSIONS.include?(File.extname(relative))
139
- next unless File.file?(File.join(@workspace.source_dir, relative))
140
-
141
- roots << Klenod::Build::ModuleId.new("app:/#{relative}")
142
- end
148
+ roots.concat(source_module_ids)
143
149
  roots.uniq
144
150
  end
145
151
 
@@ -22,6 +22,7 @@ module Klenod
22
22
  SHORTHAND_PREFIX = /\A\s*(?:%[A-Za-z][\w:-]*)?(?:[.#][\w-]+)*\.(?<partial>[\w-]*)\z/
23
23
  LOOKUP_PREFIX = /ClassNames\[:(?<partial>[\w-]*)\z/
24
24
  STYLE_KINDS = %i[companion_style inline_style].freeze
25
+ FILTER_HEAD = /\A\s*:\w/
25
26
 
26
27
  Occurrence = Data.define(:name, :span)
27
28
  Definition = Data.define(:module_id, :generated)
@@ -64,10 +65,14 @@ module Klenod
64
65
  end
65
66
  end
66
67
 
68
+ # Shorthand classes are only read outside filters, so a `:ruby`
69
+ # method chain such as `.freeze` is not a class. `ClassNames[:name]`
70
+ # lookups count everywhere.
67
71
  def occurrences(lines)
72
+ filter_body = filter_body_lines(lines)
68
73
  lines.each_with_index.flat_map do |line_text, index|
69
74
  found = []
70
- if (head = TAG_HEAD.match(line_text))
75
+ if !filter_body.include?(index) && (head = TAG_HEAD.match(line_text))
71
76
  offset = head.begin(:shorthand)
72
77
  Text.each_match(head[:shorthand], index, SHORTHAND_CLASS, group: :name) do |match, span|
73
78
  found << Occurrence.new(match[:name], span.with(start_character: span.start_character + offset, end_character: span.end_character + offset))
@@ -79,11 +84,24 @@ module Klenod
79
84
  end
80
85
 
81
86
  def occurrence_at(lines, position)
82
- line_text = lines[position.line]
83
- return nil unless line_text
87
+ return nil unless lines[position.line]
84
88
 
85
- occurrences([line_text]).map { |occurrence| occurrence.with(span: occurrence.span.with(line: position.line)) }
86
- .find { |occurrence| occurrence.span.include?(position) }
89
+ occurrences(lines).find { |occurrence| occurrence.span.include?(position) }
90
+ end
91
+
92
+ # Indexes of the lines inside filters: every line after a `:name`
93
+ # line that is blank or indented deeper than it, as Haml reads them.
94
+ # Indentation alone also works while the document does not parse.
95
+ def filter_body_lines(lines)
96
+ filter_indent = nil
97
+ lines.each_with_index.with_object(Set.new) do |(line_text, index), body|
98
+ indent = line_text[/\A\s*/].length
99
+ if filter_indent && (line_text.strip.empty? || indent > filter_indent)
100
+ body << index
101
+ else
102
+ filter_indent = line_text.match?(FILTER_HEAD) ? indent : nil
103
+ end
104
+ end
87
105
  end
88
106
 
89
107
  # Warnings for classes the stylesheets do not define. Components
@@ -99,7 +117,7 @@ module Klenod
99
117
  range: occurrence.span.to_range,
100
118
  severity: Constant::DiagnosticSeverity::WARNING,
101
119
  source: "klenod",
102
- message: "Unknown CSS class #{occurrence.name.inspect}: not defined in #{style_names(map, analysis).join(" or ")}"
120
+ message: "Unknown CSS class `#{occurrence.name}`: not defined in #{style_names(map, analysis).map { |name| "`#{name}`" }.join(" or ")}"
103
121
  )
104
122
  end
105
123
  end
@@ -127,7 +145,8 @@ module Klenod
127
145
  end
128
146
 
129
147
  def completion_items(prefix, position, analysis, workspace, index)
130
- match = SHORTHAND_PREFIX.match(prefix) || LOOKUP_PREFIX.match(prefix)
148
+ in_filter = filter_body_lines(analysis.lines).include?(position.line)
149
+ match = (!in_filter && SHORTHAND_PREFIX.match(prefix)) || LOOKUP_PREFIX.match(prefix)
131
150
  return nil unless match
132
151
 
133
152
  map = map(analysis, workspace, index)
@@ -13,9 +13,9 @@ module Klenod
13
13
  class Haml
14
14
  # The props a `%Component` tag passes, checked against the props the
15
15
  # component reads. Only keys written out on the tag line count:
16
- # `%Foo(bar="1"){ baz: 2 }`. A splat makes the tag unverifiable, and
17
- # a component that reads no `$props`, or reads them all with `$*`,
18
- # is left alone.
16
+ # `%Foo(bar="1"){ baz: 2 }`. A splat or attributes spanning several
17
+ # lines make the tag unverifiable, and a component that reads no
18
+ # `$props`, or reads them all with `$*`, is left alone.
19
19
  module Props
20
20
  Interface = LanguageServer::Protocol::Interface
21
21
  Constant = LanguageServer::Protocol::Constant
@@ -31,7 +31,10 @@ module Klenod
31
31
  /(?<=[{,]|\A)\s*"(?<key>[^"]+)"\s*=>/
32
32
  ].freeze
33
33
  SPLAT = /\*\*/
34
- IMPLICIT = %w[children].freeze
34
+ CLOSERS = {"{" => "}", "(" => ")", "[" => "]"}.freeze
35
+ # Props every component accepts: Haml passes `children`, `%Foo[item]`
36
+ # passes `key`, and `slot` places the component in its parent's slot.
37
+ IMPLICIT = %w[children key slot].freeze
35
38
 
36
39
  Key = Data.define(:name, :span)
37
40
 
@@ -57,16 +60,23 @@ module Klenod
57
60
  next [] unless props && !props.splat && !props.names.empty?
58
61
 
59
62
  allowed = props.names + IMPLICIT
60
- keys.reject { |key| allowed.include?(key.name) }.map { |key| diagnostic(key, tag[:name], allowed) }
63
+ keys.reject { |key| allowed.include?(prop_name(key.name)) }.map { |key| diagnostic(key, tag[:name], allowed) }
61
64
  end
62
65
  end
63
66
 
64
- # Keys from the tag's attribute regions, or nil when a splat makes
65
- # them unknowable.
67
+ # Components receive `video-id` as `$video_id`, like `merge_props`
68
+ # in the Haml plugin's helper source.
69
+ def prop_name(key)
70
+ key.tr("-", "_")
71
+ end
72
+
73
+ # Keys from the tag's attribute regions, or nil when a splat or an
74
+ # attribute list continuing on the next lines makes them unknowable.
66
75
  def explicit_keys(rest, line_index, offset)
67
76
  keys = []
68
77
  RubyRegions.attribute_regions(rest).each do |region_offset, region|
69
78
  return nil if region.start_with?("{") && region.match?(SPLAT)
79
+ return nil unless region.end_with?(CLOSERS.fetch(region[0]))
70
80
 
71
81
  patterns = region.start_with?("(") ? [HTML_KEY] : HASH_KEYS
72
82
  inner = region[1...-1]
@@ -93,9 +103,9 @@ module Klenod
93
103
  end
94
104
 
95
105
  def diagnostic(key, tag_name, allowed)
96
- suggestion = DidYouMean::SpellChecker.new(dictionary: allowed).correct(key.name).first
97
- message = "Unknown prop #{key.name.inspect} for #{tag_name}"
98
- message += "; did you mean #{suggestion.inspect}?" if suggestion
106
+ suggestion = DidYouMean::SpellChecker.new(dictionary: allowed).correct(prop_name(key.name)).first
107
+ message = "Unknown prop `#{key.name}` for `#{tag_name}`"
108
+ message += "; did you mean `#{suggestion}`?" if suggestion
99
109
 
100
110
  Interface::Diagnostic.new(
101
111
  range: key.span.to_range,
@@ -9,7 +9,8 @@ module Klenod
9
9
  # Executable Ruby regions in a Haml document: Ruby filters, script
10
10
  # lines, tag attributes, and printed tag content.
11
11
  module RubyRegions
12
- TAG_HEAD = /%[A-Za-z][\w:-]*(?:[.#][\w-]+)*/
12
+ # `%name.class#id`, or `.class#id` for an implicit `%div`.
13
+ TAG_HEAD = /\A\s*(?:%[A-Za-z][\w:-]*(?:[.#][\w-]+)*|(?:[.#][\w-]+)+)/
13
14
 
14
15
  module_function
15
16
 
@@ -35,22 +36,50 @@ module Klenod
35
36
  when :script, :silent_script
36
37
  yield line_index, 0, lines[line_index].to_s
37
38
  when :tag
38
- tag(node, lines[line_index].to_s, line_index, &block)
39
+ tag(node, lines, line_index, &block)
39
40
  end
40
41
  each_node(node.children, lines, &block) unless node.type == :filter
41
42
  end
42
43
  end
43
44
 
44
- def tag(node, line_text, line_index)
45
+ def tag(node, lines, line_index, &block)
46
+ line_text = lines[line_index].to_s
45
47
  head = TAG_HEAD.match(line_text)
46
48
  return unless head
47
49
 
48
50
  rest_start = head.end(0)
49
51
  rest = line_text[rest_start..].to_s
52
+ regions = attribute_regions(rest)
50
53
  if node.value[:parse]
51
54
  yield line_index, rest_start, rest
52
55
  else
53
- attribute_regions(rest).each { |offset, text| yield line_index, rest_start + offset, text }
56
+ regions.each { |offset, text| yield line_index, rest_start + offset, text }
57
+ end
58
+ continuation(lines, line_index + 1, regions.last&.last, parse: node.value[:parse], &block)
59
+ end
60
+
61
+ # The lines of an attribute region left open on the tag line, up to
62
+ # and including its closer. A tag printing Ruby content keeps the
63
+ # rest of the closing line too: `}= value`.
64
+ def continuation(lines, line_index, region, parse: false)
65
+ return unless region
66
+
67
+ opener = region[0]
68
+ closer = {"{" => "}", "(" => ")", "[" => "]"}.fetch(opener)
69
+ depth = region.count(opener) - region.count(closer)
70
+ while depth.positive? && line_index < lines.length
71
+ line_text = lines[line_index].to_s
72
+ length = line_text.length
73
+ line_text.each_char.with_index do |char, index|
74
+ depth += 1 if char == opener
75
+ depth -= 1 if char == closer
76
+ next unless depth.zero?
77
+
78
+ length = index + 1 unless parse
79
+ break
80
+ end
81
+ yield line_index, 0, line_text[0...length]
82
+ line_index += 1
54
83
  end
55
84
  end
56
85
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "language_server-protocol"
4
+ require "prism"
4
5
 
5
6
  require "klenod/build/module_id"
6
7
 
@@ -116,6 +117,7 @@ module Klenod
116
117
  tokens = []
117
118
  slots = []
118
119
  Haml::RubyRegions.each(source, lines) do |_line_index, _start_character, ruby_source|
120
+ ruby_source = without_comment(ruby_source)
119
121
  tokens.concat(ruby_source.scan(PROP_TOKEN).flatten)
120
122
  slots.concat(ruby_source.scan(SLOT_TOKEN).flatten)
121
123
  end
@@ -126,6 +128,13 @@ module Klenod
126
128
  )
127
129
  end
128
130
 
131
+ # The Ruby before a trailing `# comment`, so commented-out code reads
132
+ # no props. Prism tells a comment from `#` inside a string.
133
+ def without_comment(ruby_source)
134
+ comment = Prism.lex(ruby_source).value.find { |token, _state| token.type == :COMMENT }&.first
135
+ comment ? ruby_source.byteslice(0, comment.location.start_offset) : ruby_source
136
+ end
137
+
129
138
  # Completion items for the path being typed inside an import literal,
130
139
  # or nil when the line prefix is not inside one.
131
140
  def completion_items(prefix, position, analysis, workspace, syntax: Syntax::Ruby)
@@ -391,7 +400,7 @@ module Klenod
391
400
  range: span.to_range,
392
401
  severity: Imports::Constant::DiagnosticSeverity::WARNING,
393
402
  source: "klenod",
394
- message: "#{name} is imported but never used",
403
+ message: "`#{name}` is imported but never used",
395
404
  tags: [Imports::Constant::DiagnosticTag::UNNECESSARY]
396
405
  )
397
406
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Klenod
4
4
  module LSP
5
- VERSION = "0.0.29"
5
+ VERSION = "0.0.31"
6
6
  end
7
7
  end
data/lib/klenod/lsp.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "lsp/version"
4
+ require_relative "lsp/check"
4
5
  require_relative "lsp/server"
5
6
 
6
7
  module Klenod
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: klenod-lsp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.29
4
+ version: 0.0.31
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrés Alin
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.0.29
18
+ version: 0.0.31
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.0.29
25
+ version: 0.0.31
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: language_server-protocol
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -48,6 +48,7 @@ files:
48
48
  - README.md
49
49
  - lib/klenod/lsp.rb
50
50
  - lib/klenod/lsp/analysis.rb
51
+ - lib/klenod/lsp/check.rb
51
52
  - lib/klenod/lsp/cli.rb
52
53
  - lib/klenod/lsp/diagnostics.rb
53
54
  - lib/klenod/lsp/documents.rb