klenod-lsp 0.0.29 → 0.0.30

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: 4e64b40c339a0f3b2e2338120f4fce1a631202dad834e0ca30566048db1f5f0f
4
+ data.tar.gz: 5458d0f7574457c17214c08e84db53ecb14677fa75f89dffe229de1f1dac8543
5
5
  SHA512:
6
- metadata.gz: 1e20ea7abb80b7c3cfd250d286adb2372ade08b85d9927794a63e13df10e1626be7b32b41135f57095da7a8243735dc9caac3b3a0fe82604028e3cd9246c6c85
7
- data.tar.gz: b6cde269621b3ee407f5bf6a504be3537e16a2a21c34ec81d23a35f5c683e6233ece936c7192b8f248f7d9067dfb0fa1216ce0341a9892dd5b3a03868dd60f9f
6
+ metadata.gz: a448b50f1d21c57317f13893c48ee376560cd4324900b2d8fbd1ab841c7d849c6f4d1be99237e4f7893149dafbeb15137ab997220c5ad6c968fb8245ff627e4a
7
+ data.tar.gz: 9093c27e1d87afee0d355d121371aa9c37ec555a03e1e9090dc52ee4a0e18f619406b2d8de7f83dfd81d8d0679991af3546f9c5865e5eccd6a04c982bb5eeea8
data/README.md CHANGED
@@ -43,6 +43,26 @@ 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:4:3: warning: Card is imported but never used
58
+ src/pages/Home.haml:9:12: error: Could not resolve "./Missing"
59
+ 1 error, 1 warning, 42 files checked.
60
+ ```
61
+
62
+ It collects the whole module graph first, so cross-file diagnostics such as
63
+ unknown props and scoped classes are included. Paths limit which files are
64
+ reported. The command exits with status 1 when it reports any diagnostic.
65
+
46
66
  Configure the editor to run that command for `.haml` files. Ruby and CSS files
47
67
  can also use it alongside their usual language servers.
48
68
 
@@ -90,6 +110,17 @@ exit Klenod::LSP::Server.new(
90
110
  - `input:` and `output:` — the JSON-RPC streams; default to stdin and stdout.
91
111
  - `logger:` — server logging; defaults to stderr.
92
112
 
113
+ `Klenod::LSP::Check` runs the same checks without a server. It takes the same
114
+ `context:`, `entrypoints:`, and `logger:`:
115
+
116
+ ```ruby
117
+ results = Klenod::LSP::Check.new(context: context, entrypoints: config.entrypoints).call
118
+ exit Klenod::LSP::Check.report(results, output: $stdout).zero? ? 0 : 1
119
+ ```
120
+
121
+ `call` accepts absolute file and directory paths to limit the files checked.
122
+ `report` prints each diagnostic and a summary, and returns the diagnostic count.
123
+
93
124
  The web example has a complete runner at `example/web/bin/lsp`.
94
125
 
95
126
  ## How it works
@@ -0,0 +1,107 @@
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
+ # One checked file and its diagnostics, which may be empty.
30
+ Result = Data.define(:path, :diagnostics)
31
+
32
+ def initialize(context:, entrypoints: [], logger: nil)
33
+ @workspace = Workspace.new(context: context)
34
+ @index = GraphIndex.new(
35
+ workspace: @workspace,
36
+ entrypoints: entrypoints,
37
+ logger: logger || Logger.new($stderr, progname: "klenod-lsp", level: :warn)
38
+ )
39
+ end
40
+
41
+ # Checks the source files below `paths`, or all of them when `paths` is
42
+ # empty. Paths are absolute files or directories.
43
+ def call(paths = [])
44
+ Sync do |task|
45
+ @index.start(task)
46
+ @index.wait
47
+ end
48
+
49
+ @index.source_module_ids.filter_map do |module_id|
50
+ path = @workspace.path_for_module_id(module_id)
51
+ next unless selected?(path, paths)
52
+
53
+ document = Document.new(uri: @workspace.uri_for_path(path), path: path, module_id: module_id, text: File.read(path), version: nil)
54
+ language = Languages.for(document)
55
+ next unless language
56
+
57
+ Result.new(path: path, diagnostics: language.diagnostics(@workspace.analyze(module_id, document.text), @workspace, @index))
58
+ end
59
+ end
60
+
61
+ # Prints one `path:line:column: severity: message` entry per diagnostic
62
+ # with paths relative to `root`, then a summary. Returns the number of
63
+ # diagnostics.
64
+ def self.report(results, output:, root: Dir.pwd)
65
+ diagnostics = results.flat_map do |result|
66
+ result.diagnostics
67
+ .sort_by { |diagnostic| [diagnostic.range.start.line, diagnostic.range.start.character] }
68
+ .map { |diagnostic| [display_path(result.path, root), diagnostic] }
69
+ end
70
+
71
+ diagnostics.each do |path, diagnostic|
72
+ start = diagnostic.range.start
73
+ first_line, *rest = diagnostic.message.lines(chomp: true)
74
+ output.puts "#{path}:#{start.line + 1}:#{start.character + 1}: #{SEVERITY_NAMES.fetch(diagnostic.severity)}: #{first_line}"
75
+ rest.each { |line| output.puts " #{line}" }
76
+ end
77
+
78
+ output.puts summary(results, diagnostics.map(&:last))
79
+ diagnostics.length
80
+ end
81
+
82
+ def self.summary(results, diagnostics)
83
+ files = "#{results.length} #{(results.length == 1) ? "file" : "files"} checked"
84
+ return "No problems found, #{files}." if diagnostics.empty?
85
+
86
+ counts = diagnostics.group_by(&:severity).sort.map do |severity, group|
87
+ name = SEVERITY_NAMES.fetch(severity)
88
+ "#{group.length} #{(group.length == 1) ? name : "#{name}s"}"
89
+ end
90
+ "#{counts.join(", ")}, #{files}."
91
+ end
92
+
93
+ def self.display_path(path, root)
94
+ relative = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
95
+ relative.start_with?("../") ? path : relative
96
+ end
97
+
98
+ private
99
+
100
+ def selected?(path, paths)
101
+ return true if paths.empty?
102
+
103
+ paths.any? { |selected| path == selected || path.start_with?("#{selected}/") }
104
+ end
105
+ end
106
+ end
107
+ 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
@@ -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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Klenod
4
4
  module LSP
5
- VERSION = "0.0.29"
5
+ VERSION = "0.0.30"
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.30
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.30
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.30
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