rubydex 0.2.9 → 0.4.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 +4 -4
- data/README.md +110 -6
- data/THIRD_PARTY_LICENSES.html +271 -2
- data/exe/rdx +2 -73
- data/ext/rubydex/config.c +140 -0
- data/ext/rubydex/config.h +16 -0
- data/ext/rubydex/declaration.c +1 -1
- data/ext/rubydex/definition.c +32 -4
- data/ext/rubydex/diagnostic.c +75 -1
- data/ext/rubydex/diagnostic.h +2 -0
- data/ext/rubydex/graph.c +27 -48
- data/ext/rubydex/graph.h +6 -0
- data/ext/rubydex/query.c +487 -0
- data/ext/rubydex/query.h +8 -0
- data/ext/rubydex/reference.c +60 -0
- data/ext/rubydex/rubydex.c +4 -0
- data/ext/rubydex/utils.c +23 -4
- data/ext/rubydex/utils.h +5 -0
- data/lib/ruby_lsp/rubydex/addon.rb +211 -0
- data/lib/rubydex/cli/command/console.rb +55 -0
- data/lib/rubydex/cli/command/lint/explain.rb +74 -0
- data/lib/rubydex/cli/command/lint.rb +202 -0
- data/lib/rubydex/cli/command/mcp.rb +30 -0
- data/lib/rubydex/cli/command/query.rb +70 -0
- data/lib/rubydex/cli/command/skill.rb +69 -0
- data/lib/rubydex/cli/command.rb +168 -0
- data/lib/rubydex/cli.rb +93 -0
- data/lib/rubydex/config.rb +59 -0
- data/lib/rubydex/diagnostic.rb +12 -3
- data/lib/rubydex/errors.rb +42 -1
- data/lib/rubydex/graph.rb +10 -3
- data/lib/rubydex/linter/custom_rule.rb +97 -0
- data/lib/rubydex/linter/helpers/path_helpers.rb +78 -0
- data/lib/rubydex/linter/helpers/source_access_helpers.rb +31 -0
- data/lib/rubydex/linter/rule_loader.rb +36 -0
- data/lib/rubydex/linter/rule_test_case.rb +343 -0
- data/lib/rubydex/linter/runner.rb +56 -0
- data/lib/rubydex/linter.rb +19 -0
- data/lib/rubydex/location.rb +3 -0
- data/lib/rubydex/mcp_server.rb +1 -2
- data/lib/rubydex/related_information.rb +17 -0
- data/lib/rubydex/rule.rb +33 -0
- data/lib/rubydex/severity.rb +70 -0
- data/lib/rubydex/skill.rb +88 -0
- data/lib/rubydex/skill_registry.rb +62 -0
- data/lib/rubydex/version.rb +1 -1
- data/lib/rubydex.rb +6 -0
- data/lib/rubydex_linter/rules/rule_structure.rb +125 -0
- data/rbi/rubydex.rbi +578 -15
- data/rust/Cargo.lock +7 -0
- data/rust/rubydex/Cargo.toml +1 -0
- data/rust/rubydex/benches/graph_memory.rs +20 -4
- data/rust/rubydex/src/compile_assertions.rs +15 -0
- data/rust/rubydex/src/config.rs +538 -157
- data/rust/rubydex/src/diagnostic.rs +66 -40
- data/rust/rubydex/src/errors.rs +0 -1
- data/rust/rubydex/src/indexing/local_graph.rs +6 -5
- data/rust/rubydex/src/indexing/rbs_indexer.rs +284 -8
- data/rust/rubydex/src/indexing/ruby_indexer.rs +59 -70
- data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +195 -86
- data/rust/rubydex/src/lib.rs +1 -0
- data/rust/rubydex/src/listing.rs +26 -1
- data/rust/rubydex/src/main.rs +9 -128
- data/rust/rubydex/src/model/declaration.rs +301 -229
- data/rust/rubydex/src/model/definitions.rs +27 -26
- data/rust/rubydex/src/model/document.rs +43 -7
- data/rust/rubydex/src/model/graph.rs +67 -68
- data/rust/rubydex/src/model/id.rs +55 -0
- data/rust/rubydex/src/model/ids.rs +21 -9
- data/rust/rubydex/src/model/name.rs +88 -19
- data/rust/rubydex/src/model/references.rs +16 -13
- data/rust/rubydex/src/operation/ruby_builder.rs +78 -104
- data/rust/rubydex/src/path_helpers.rs +77 -0
- data/rust/rubydex/src/query/cypher/schema.rs +853 -0
- data/rust/rubydex/src/query/cypher/schema_info.rs +161 -0
- data/rust/rubydex/src/query/cypher/tests.rs +253 -0
- data/rust/rubydex/src/query/cypher.rs +54 -0
- data/rust/rubydex/src/query.rs +125 -43
- data/rust/rubydex/src/resolution.rs +368 -395
- data/rust/rubydex/src/resolution_tests.rs +504 -78
- data/rust/rubydex/src/test_utils/context.rs +2 -1
- data/rust/rubydex/src/test_utils/graph_test.rs +26 -12
- data/rust/rubydex/src/test_utils/local_graph_test.rs +19 -0
- data/rust/rubydex/tests/cli.rs +4 -4
- data/rust/rubydex-sys/src/config_api.rs +205 -0
- data/rust/rubydex-sys/src/cypher_api.rs +791 -0
- data/rust/rubydex-sys/src/declaration_api.rs +6 -3
- data/rust/rubydex-sys/src/definition_api.rs +27 -7
- data/rust/rubydex-sys/src/diagnostic_api.rs +77 -8
- data/rust/rubydex-sys/src/graph_api.rs +31 -68
- data/rust/rubydex-sys/src/lib.rs +2 -0
- data/rust/rubydex-sys/src/name_api.rs +2 -6
- data/rust/rubydex-sys/src/reference_api.rs +58 -12
- data/rust/rubydex-sys/src/utils.rs +37 -0
- data/skills/send-private-method/SKILL.md +133 -0
- metadata +37 -2
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubydex/cli/command"
|
|
4
|
+
|
|
5
|
+
module Rubydex
|
|
6
|
+
module CLI
|
|
7
|
+
class Command
|
|
8
|
+
# `rdx skill [ID]` — lists available skill ids, or loads and prints a skill's body by id.
|
|
9
|
+
class Skill < Command
|
|
10
|
+
command "skill"
|
|
11
|
+
arguments "[ID]"
|
|
12
|
+
summary <<~TEXT
|
|
13
|
+
Load a Rubydex skill from the library by id. With no id, list the available skills.
|
|
14
|
+
TEXT
|
|
15
|
+
|
|
16
|
+
#: -> void
|
|
17
|
+
def run
|
|
18
|
+
parse_options!
|
|
19
|
+
|
|
20
|
+
id = argv.shift
|
|
21
|
+
abort_with_usage("unexpected argument: #{argv.first}") unless argv.empty?
|
|
22
|
+
|
|
23
|
+
id ? print_skill(id) : list_skills
|
|
24
|
+
rescue Rubydex::SkillError => e
|
|
25
|
+
abort(e.message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
#: -> void
|
|
31
|
+
def list_skills
|
|
32
|
+
ids = registry.ids
|
|
33
|
+
|
|
34
|
+
if ids.empty?
|
|
35
|
+
puts(available_skills)
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
ids.each { |id| puts(id) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
#: (String id) -> void
|
|
43
|
+
def print_skill(id)
|
|
44
|
+
puts(registry.fetch(id).body)
|
|
45
|
+
rescue Rubydex::UnknownSkillError => e
|
|
46
|
+
abort("#{e.message}. #{available_skills}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
#: -> String
|
|
50
|
+
def available_skills
|
|
51
|
+
ids = registry.ids
|
|
52
|
+
|
|
53
|
+
ids.empty? ? "No skills are available." : "Available skills: #{ids.join(", ")}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
#: -> Rubydex::SkillRegistry
|
|
57
|
+
def registry
|
|
58
|
+
require "rubydex/skill_registry"
|
|
59
|
+
@registry ||= Rubydex::SkillRegistry.load(skills_directory)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#: -> String
|
|
63
|
+
def skills_directory
|
|
64
|
+
File.expand_path("../../../../skills", __dir__)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Rubydex
|
|
6
|
+
module CLI
|
|
7
|
+
# Base class for `rdx` subcommands. A subcommand parses its own options out of `argv` and, when
|
|
8
|
+
# it needs one, builds the workspace graph through {#build_graph}.
|
|
9
|
+
#
|
|
10
|
+
# Subclasses live one per file in `lib/rubydex/cli/command/` and describe themselves with a
|
|
11
|
+
# small DSL, which supplies both the name they are dispatched under and their entry in
|
|
12
|
+
# `rdx help`:
|
|
13
|
+
#
|
|
14
|
+
# class Query < Command
|
|
15
|
+
# command "query"
|
|
16
|
+
# arguments "<CYPHER>"
|
|
17
|
+
# summary "Run a Cypher query against the workspace graph and print the result."
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# There is no registration list to keep in step: {.all} loads every file in that directory and
|
|
21
|
+
# discovers the commands through `Class#subclasses`.
|
|
22
|
+
class Command
|
|
23
|
+
class << self
|
|
24
|
+
# The subcommand name declared by {.command}. `nil` for a class that declares none, which is
|
|
25
|
+
# how an abstract intermediate class opts out of being dispatched.
|
|
26
|
+
#: String?
|
|
27
|
+
attr_reader :command_name
|
|
28
|
+
|
|
29
|
+
# Declares the subcommand name this class is dispatched under.
|
|
30
|
+
#
|
|
31
|
+
# Deliberately not called `name`: that would shadow `Class#name`, which Ruby uses when it
|
|
32
|
+
# builds error messages, so a `NoMethodError` on a command would report "an instance of
|
|
33
|
+
# query" instead of naming the class.
|
|
34
|
+
#: (String value) -> void
|
|
35
|
+
def command(value)
|
|
36
|
+
# Deliberately checked against {.declared} rather than {.find}: this runs while a command
|
|
37
|
+
# file is still being loaded, and `find` would glob in every other command file first.
|
|
38
|
+
clash = declared.find { |other| !other.equal?(self) && other.command_name == value }
|
|
39
|
+
raise ArgumentError, "command `#{value}` is already declared by #{clash}" if clash
|
|
40
|
+
|
|
41
|
+
@command_name = value #: String?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Declares the argument spec shown after the command name in `rdx help`, e.g. `"<CYPHER>"`.
|
|
45
|
+
# Called without an argument, returns the declared spec.
|
|
46
|
+
#: (?String? spec) -> String?
|
|
47
|
+
def arguments(spec = nil)
|
|
48
|
+
return @arguments unless spec
|
|
49
|
+
|
|
50
|
+
@arguments = spec #: String?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Declares the description shown in `rdx help`. May span several lines, which are aligned
|
|
54
|
+
# under the first when the command list is rendered. Called without an argument, returns the
|
|
55
|
+
# declared description.
|
|
56
|
+
#: (?String? text) -> String?
|
|
57
|
+
def summary(text = nil)
|
|
58
|
+
return @summary unless text
|
|
59
|
+
|
|
60
|
+
@summary = text.strip #: String?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# How the command is spelled in a usage line, e.g. `"query <CYPHER>"`.
|
|
64
|
+
#: -> String
|
|
65
|
+
def usage_form
|
|
66
|
+
[command_name, arguments].compact.join(" ")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The subcommands that are currently loaded, in alphabetical order. Loads nothing itself, so
|
|
70
|
+
# it is safe to call while a command file is still being evaluated.
|
|
71
|
+
#: -> Array[singleton(Command)]
|
|
72
|
+
def declared
|
|
73
|
+
Command.subclasses.select(&:command_name).sort_by(&:command_name)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Every subcommand, in alphabetical order. Loads the command files first, so the answer does
|
|
77
|
+
# not depend on what happens to have been required already.
|
|
78
|
+
#: -> Array[singleton(Command)]
|
|
79
|
+
def all
|
|
80
|
+
Dir.glob(File.expand_path("command/*.rb", __dir__)).sort.each { |file| require(file) }
|
|
81
|
+
|
|
82
|
+
declared
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The subcommand declared under `name`, if any.
|
|
86
|
+
#: (String? name) -> singleton(Command)?
|
|
87
|
+
def find(name)
|
|
88
|
+
all.find { |command| command.command_name == name }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
#: (Array[String] argv) -> void
|
|
93
|
+
def initialize(argv)
|
|
94
|
+
@argv = argv
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
#: -> void
|
|
98
|
+
def run
|
|
99
|
+
raise NotImplementedError, "#{self.class} must implement #run"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
#: Array[String]
|
|
105
|
+
attr_reader :argv
|
|
106
|
+
|
|
107
|
+
#: (String message) -> void
|
|
108
|
+
def abort_with_usage(message)
|
|
109
|
+
warn(message)
|
|
110
|
+
warn("")
|
|
111
|
+
warn(@parser)
|
|
112
|
+
exit(1)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Parses this command's options out of `argv`, with a banner derived from the command's own
|
|
116
|
+
# declaration. `-h`/`--help` prints the parser and exits, so every subcommand documents itself
|
|
117
|
+
# the same way. Pass `options: true` when the command accepts options beyond `--help`.
|
|
118
|
+
#
|
|
119
|
+
# A bad option reports the message and the usage text, so every subcommand rejects bad input
|
|
120
|
+
# the same way.
|
|
121
|
+
#: (?options: bool) ?{ (OptionParser parser) -> void } -> void
|
|
122
|
+
def parse_options!(options: false)
|
|
123
|
+
banner = +"Usage: rdx #{self.class.usage_form}"
|
|
124
|
+
banner << " [options]" if options
|
|
125
|
+
|
|
126
|
+
@parser = OptionParser.new do |p|
|
|
127
|
+
p.banner = banner
|
|
128
|
+
yield(p) if block_given?
|
|
129
|
+
p.on("-h", "--help", "Show this help") do
|
|
130
|
+
puts(p)
|
|
131
|
+
exit
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
@parser.parse!(argv)
|
|
136
|
+
rescue OptionParser::ParseError => e
|
|
137
|
+
abort_with_usage(e.message)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
#: -> String
|
|
141
|
+
def current_workspace_path
|
|
142
|
+
Bundler.root.to_s
|
|
143
|
+
rescue Bundler::GemfileNotFound
|
|
144
|
+
Dir.pwd
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Builds the workspace graph, sending progress messages to `progress_io`.
|
|
148
|
+
#: (IO progress_io, ?workspace_path: String, ?config: Rubydex::Config) -> Rubydex::Graph
|
|
149
|
+
def build_graph(progress_io, workspace_path: Dir.pwd, config: Rubydex::Config.load(workspace_path))
|
|
150
|
+
graph = Rubydex::Graph.new
|
|
151
|
+
graph.load_config(config)
|
|
152
|
+
with_timer(progress_io, "Indexing workspace...") { graph.index_workspace }
|
|
153
|
+
with_timer(progress_io, "Resolving graph...") { graph.resolve }
|
|
154
|
+
graph
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
#: [T] (IO io, String message) { -> T } -> T
|
|
158
|
+
def with_timer(io, message)
|
|
159
|
+
io.print(message)
|
|
160
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
161
|
+
result = yield
|
|
162
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
|
|
163
|
+
io.puts(" finished in #{duration.round(2)}ms")
|
|
164
|
+
result
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
data/lib/rubydex/cli.rb
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubydex
|
|
4
|
+
# Command-line entry point for the `rdx` executable. It handles the top-level flags, resolves the
|
|
5
|
+
# subcommand and delegates to it, which keeps `exe/rdx` a thin shim.
|
|
6
|
+
#
|
|
7
|
+
# Subcommands live one per file in `lib/rubydex/cli/command/` and declare the name they answer to
|
|
8
|
+
# (see {CLI::Command}). Both dispatch and the `rdx help` command list are driven by that
|
|
9
|
+
# declaration, so adding a subcommand means adding a file — there is no dispatch table or usage
|
|
10
|
+
# text to keep in step.
|
|
11
|
+
module CLI
|
|
12
|
+
HEADER = "Usage: rdx <command> [options]" #: String
|
|
13
|
+
FOOTER = "Run `rdx <command> --help` for command-specific options." #: String
|
|
14
|
+
|
|
15
|
+
# `help` is served by the top-level flags rather than by a Command subclass, so its entry is
|
|
16
|
+
# listed here to keep it in the generated command list.
|
|
17
|
+
HELP_ENTRY = ["help", "Show this help message"].freeze #: [String, String]
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Entry point used by `exe/rdx`.
|
|
21
|
+
#: (?Array[String] argv) -> void
|
|
22
|
+
def start(argv = ARGV)
|
|
23
|
+
return if handle_top_level_flags(argv)
|
|
24
|
+
|
|
25
|
+
# Everything past this point needs the native extension.
|
|
26
|
+
require "rubydex"
|
|
27
|
+
|
|
28
|
+
dispatch(argv.shift, argv)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Reports `message`, then the top-level usage text, and exits non-zero.
|
|
34
|
+
#: (String message) -> void
|
|
35
|
+
def abort_with_usage(message)
|
|
36
|
+
warn(message)
|
|
37
|
+
warn("")
|
|
38
|
+
warn(usage)
|
|
39
|
+
exit(1)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The top-level usage text, built from the declared commands.
|
|
43
|
+
#: -> String
|
|
44
|
+
def usage
|
|
45
|
+
require "rubydex/cli/command"
|
|
46
|
+
|
|
47
|
+
entries = Command.all.map { |command| [command.usage_form, command.summary.to_s] }
|
|
48
|
+
entries << HELP_ENTRY.dup
|
|
49
|
+
|
|
50
|
+
width = entries.map { |form, _| form.length }.max + 3
|
|
51
|
+
lines = entries.flat_map { |form, summary| describe(form, summary, width) }
|
|
52
|
+
|
|
53
|
+
"#{[HEADER, "", "Commands:", *lines, "", FOOTER].join("\n")}\n"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Renders one entry of the command list, aligning any continuation lines of `summary` under
|
|
57
|
+
# the first.
|
|
58
|
+
#: (String form, String summary, Integer width) -> Array[String]
|
|
59
|
+
def describe(form, summary, width)
|
|
60
|
+
first, *rest = summary.split("\n")
|
|
61
|
+
|
|
62
|
+
[" #{form.ljust(width)}#{first}", *rest.map { |line| "#{" " * (width + 2)}#{line}" }]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Handles `--version` / `--help` / a bare invocation, returning whether the invocation was
|
|
66
|
+
# handled so `start` returns exactly once rather than exiting from inside every branch.
|
|
67
|
+
#: (Array[String] argv) -> bool
|
|
68
|
+
def handle_top_level_flags(argv)
|
|
69
|
+
case argv.first
|
|
70
|
+
when "--version", "version"
|
|
71
|
+
require "rubydex/version"
|
|
72
|
+
puts("v#{Rubydex::VERSION}")
|
|
73
|
+
true
|
|
74
|
+
when nil, "-h", "--help", "help"
|
|
75
|
+
puts(usage)
|
|
76
|
+
true
|
|
77
|
+
else
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
#: (String? name, Array[String] argv) -> void
|
|
83
|
+
def dispatch(name, argv)
|
|
84
|
+
require "rubydex/cli/command"
|
|
85
|
+
|
|
86
|
+
command_class = Command.find(name)
|
|
87
|
+
abort_with_usage("unknown command: #{name}") unless command_class
|
|
88
|
+
|
|
89
|
+
command_class.new(argv).run
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubydex
|
|
4
|
+
# The linter's settings, read from the `[linter]` section of the configuration file.
|
|
5
|
+
class LinterConfig
|
|
6
|
+
# The configured rules, keyed by rule name. Only rules the configuration file mentions appear here, so a rule that
|
|
7
|
+
# was never configured is absent rather than present with its defaults.
|
|
8
|
+
#
|
|
9
|
+
#: Hash[String, RuleConfig]
|
|
10
|
+
attr_reader :rules
|
|
11
|
+
|
|
12
|
+
#: (Hash[String, RuleConfig]) -> void
|
|
13
|
+
def initialize(rules)
|
|
14
|
+
@rules = rules.freeze
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: (singleton(Rule) rule_class) -> bool
|
|
19
|
+
def rule_enabled?(rule_class)
|
|
20
|
+
rule = @rules[rule_class.rule_name]
|
|
21
|
+
!rule || rule.enabled?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
#: (singleton(Rule) rule_class) -> Array[String]
|
|
25
|
+
def excludes_for(rule_class)
|
|
26
|
+
@rules[rule_class.rule_name]&.exclude_patterns || []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
#: (singleton(Rule) rule_class) -> singleton(Severity::Base)?
|
|
30
|
+
def severity_for(rule_class)
|
|
31
|
+
@rules[rule_class.rule_name]&.severity
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The settings of a single linter rule, read from a `[linter.rules.RuleName]` table.
|
|
36
|
+
class RuleConfig
|
|
37
|
+
#: String
|
|
38
|
+
attr_reader :name
|
|
39
|
+
|
|
40
|
+
#: Array[String]
|
|
41
|
+
attr_reader :exclude_patterns
|
|
42
|
+
|
|
43
|
+
#: singleton(Severity::Base)?
|
|
44
|
+
attr_reader :severity
|
|
45
|
+
|
|
46
|
+
#: (String, bool, ?Array[String], ?singleton(Severity::Base)?) -> void
|
|
47
|
+
def initialize(name, enabled, exclude_patterns = [], severity = nil)
|
|
48
|
+
@name = name
|
|
49
|
+
@enabled = enabled
|
|
50
|
+
@exclude_patterns = exclude_patterns
|
|
51
|
+
@severity = severity
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#: () -> bool
|
|
55
|
+
def enabled?
|
|
56
|
+
@enabled
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/rubydex/diagnostic.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Rubydex
|
|
4
4
|
class Diagnostic
|
|
5
|
-
#:
|
|
5
|
+
#: singleton(Rule)
|
|
6
6
|
attr_reader :rule
|
|
7
7
|
|
|
8
8
|
#: String
|
|
@@ -11,11 +11,20 @@ module Rubydex
|
|
|
11
11
|
#: Location
|
|
12
12
|
attr_reader :location
|
|
13
13
|
|
|
14
|
-
#:
|
|
15
|
-
|
|
14
|
+
#: Array[RelatedInformation]
|
|
15
|
+
attr_reader :related_information
|
|
16
|
+
|
|
17
|
+
#: (
|
|
18
|
+
#| rule: singleton(Rule),
|
|
19
|
+
#| message: String,
|
|
20
|
+
#| location: Location,
|
|
21
|
+
#| ?related_information: Array[RelatedInformation],
|
|
22
|
+
#| ) -> void
|
|
23
|
+
def initialize(rule:, message:, location:, related_information: [])
|
|
16
24
|
@rule = rule
|
|
17
25
|
@message = message
|
|
18
26
|
@location = location
|
|
27
|
+
@related_information = related_information
|
|
19
28
|
end
|
|
20
29
|
end
|
|
21
30
|
end
|
data/lib/rubydex/errors.rb
CHANGED
|
@@ -6,6 +6,47 @@ module Rubydex
|
|
|
6
6
|
# Raised when `MethodAliasDefinition#target` walks an alias chain that loops back on itself.
|
|
7
7
|
class AliasCycleError < Error; end
|
|
8
8
|
|
|
9
|
-
# Raised by `
|
|
9
|
+
# Raised by `Config.load` when the workspace does not exist, or when its config file cannot be read or is malformed.
|
|
10
|
+
# A workspace with no config file at all is not an error.
|
|
10
11
|
class ConfigError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised by `Skill.load` when the file cannot be read, is missing frontmatter, has malformed YAML, or lacks the
|
|
14
|
+
# required `name` or `description` fields.
|
|
15
|
+
# Every failure to produce a Skill is a `SkillError`, so a caller rescues one class instead
|
|
16
|
+
# of the union of what `File.read` and Psych happen to raise.
|
|
17
|
+
class SkillError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised by `SkillRegistry#fetch` when the library has no skill under the requested id.
|
|
20
|
+
class UnknownSkillError < SkillError; end
|
|
21
|
+
|
|
22
|
+
# Raised by `SkillRegistry.load` when the skill directory doesn't exist.
|
|
23
|
+
class UnknownSkillDirectoryError < SkillError; end
|
|
24
|
+
|
|
25
|
+
# Raised when a Cypher query cannot be parsed or cannot run. `Rubydex::Query` raises one of its
|
|
26
|
+
# subclasses; rescue this class to catch either.
|
|
27
|
+
class QueryError < Error; end
|
|
28
|
+
|
|
29
|
+
# Raised by `Query.parse` when the query is not valid Cypher.
|
|
30
|
+
class QuerySyntaxError < QueryError; end
|
|
31
|
+
|
|
32
|
+
# Raised by `Query#run` when a parsed query fails while it runs against a graph, for example
|
|
33
|
+
# because it names an unknown property or relationship type.
|
|
34
|
+
class QueryExecutionError < QueryError; end
|
|
35
|
+
|
|
36
|
+
# Raised when a query result names a node that the graph no longer holds, because the graph
|
|
37
|
+
# changed after the query ran. Reading the rows would silently turn that column from a
|
|
38
|
+
# `Declaration`, `Definition`, or `Document` handle into a plain String, so it raises instead.
|
|
39
|
+
# `render`, `columns`, `size`, and `empty?` still work, because they read the executed result set
|
|
40
|
+
# and never touch the graph.
|
|
41
|
+
#
|
|
42
|
+
# The check runs while a row is built, so it covers the rows that a walk has not reached yet. Two
|
|
43
|
+
# cases fall outside it:
|
|
44
|
+
#
|
|
45
|
+
# - A handle that a walk already handed out. Such a handle resolves against the graph on each
|
|
46
|
+
# call, so a later change to the graph can make it stale. Handles from `Graph#[]` share that
|
|
47
|
+
# property. This error says nothing about them.
|
|
48
|
+
# - A re-index that keeps the ids. A declaration id comes from the name, so a file indexed again
|
|
49
|
+
# under the same names still resolves, and this error does not fire, even though the
|
|
50
|
+
# definitions and ancestors behind that name may differ.
|
|
51
|
+
class StaleQueryResultError < QueryError; end
|
|
11
52
|
end
|
data/lib/rubydex/graph.rb
CHANGED
|
@@ -7,9 +7,16 @@ module Rubydex
|
|
|
7
7
|
class Graph
|
|
8
8
|
INDEXABLE_EXTENSIONS = [".rb", ".rake", ".rbs", ".ru"].freeze
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
class << self
|
|
11
|
+
# Creates a new graph with the loaded configuration. For use cases where the graph must be shared between
|
|
12
|
+
# different tools, do not use this. Create and own a `Config` object instead.
|
|
13
|
+
#
|
|
14
|
+
#: (String) -> instance
|
|
15
|
+
def configure_for_workspace(workspace_path)
|
|
16
|
+
graph = new
|
|
17
|
+
graph.load_config(Config.load(workspace_path))
|
|
18
|
+
graph
|
|
19
|
+
end
|
|
13
20
|
end
|
|
14
21
|
|
|
15
22
|
# Index all files and dependencies of the workspace that exists in `workspace_path`
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubydex
|
|
4
|
+
module Linter
|
|
5
|
+
# Base class for semantic lint rules, which collect their diagnostics by walking a resolved graph.
|
|
6
|
+
# @abstract
|
|
7
|
+
class CustomRule < Rule
|
|
8
|
+
#: Graph
|
|
9
|
+
attr_reader :graph
|
|
10
|
+
|
|
11
|
+
#: LinterConfig
|
|
12
|
+
attr_reader :config
|
|
13
|
+
|
|
14
|
+
#: Array[Diagnostic]
|
|
15
|
+
attr_reader :diagnostics
|
|
16
|
+
|
|
17
|
+
#: (Graph, config: LinterConfig) -> void
|
|
18
|
+
def initialize(graph, config:)
|
|
19
|
+
super()
|
|
20
|
+
@graph = graph
|
|
21
|
+
@config = config
|
|
22
|
+
@diagnostics = [] #: Array[Diagnostic]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @abstract
|
|
26
|
+
#: () -> void
|
|
27
|
+
def lint
|
|
28
|
+
raise NotImplementedError, "Subclasses must implement the lint method"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Anchors a diagnostic on a definition's name token, falling back to its full range when no name location exists.
|
|
32
|
+
#: (Definition) -> Location
|
|
33
|
+
def diagnostic_location(definition)
|
|
34
|
+
definition.name_location || definition.location
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns every class inheriting from +base_name+, excluding the base class itself.
|
|
38
|
+
#: (String) -> Enumerable[Rubydex::Class]
|
|
39
|
+
def child_classes(base_name)
|
|
40
|
+
required_namespace(base_name).descendants.lazy.filter_map do |child_declaration|
|
|
41
|
+
next unless child_declaration.is_a?(Rubydex::Class)
|
|
42
|
+
next if child_declaration.name == base_name
|
|
43
|
+
|
|
44
|
+
child_declaration
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
#: (String) -> Namespace
|
|
49
|
+
def required_namespace(name)
|
|
50
|
+
declaration = graph[name]
|
|
51
|
+
return declaration if declaration.is_a?(Namespace)
|
|
52
|
+
|
|
53
|
+
raise MissingGraphDependencyError.new(rule_name, "`#{name}`")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
#: (Namespace, String) -> Rubydex::Method
|
|
57
|
+
def required_method(namespace, method_name)
|
|
58
|
+
method = namespace.find_member(method_name)
|
|
59
|
+
return method if method.is_a?(Rubydex::Method)
|
|
60
|
+
|
|
61
|
+
raise MissingGraphDependencyError.new(rule_name, "`#{namespace.name}##{method_name}`")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
#: () -> String
|
|
65
|
+
def rule_name
|
|
66
|
+
self.class.rule_name
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
protected
|
|
70
|
+
|
|
71
|
+
#: (
|
|
72
|
+
#| String,
|
|
73
|
+
#| Location,
|
|
74
|
+
#| ?related_information: Array[RelatedInformation],
|
|
75
|
+
#| ) -> void
|
|
76
|
+
def add_diagnostic(message, location, related_information: [])
|
|
77
|
+
@diagnostics << Diagnostic.new(
|
|
78
|
+
rule: self.class,
|
|
79
|
+
message: message,
|
|
80
|
+
location: location,
|
|
81
|
+
related_information: related_information,
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
class MissingGraphDependencyError < StandardError
|
|
87
|
+
#: (String, String) -> void
|
|
88
|
+
def initialize(rule_name, dependency)
|
|
89
|
+
super(
|
|
90
|
+
"Rubydex linter rule `#{rule_name}` requires #{dependency} to exist in the Rubydex graph. " \
|
|
91
|
+
"This is a rule setup error, not a clean lint result; ensure the source that defines " \
|
|
92
|
+
"the dependency is indexed and Rubydex can resolve it.",
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Rubydex
|
|
7
|
+
module Linter
|
|
8
|
+
module Helpers
|
|
9
|
+
# @requires_ancestor: Rubydex::Linter::CustomRule
|
|
10
|
+
module PathHelpers
|
|
11
|
+
EXCLUDE_FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH #: Integer
|
|
12
|
+
TEST_PATHS = ["test/**", "**/test/**", "**/*_test.rb"].freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
#: (String, Array[String], workspace: String, ?flags: Integer) -> bool
|
|
16
|
+
def path_matches_patterns?(path, patterns, workspace:, flags: 0)
|
|
17
|
+
return false unless path == workspace || path.start_with?("#{workspace}/")
|
|
18
|
+
|
|
19
|
+
relative_path = path.delete_prefix("#{workspace}/")
|
|
20
|
+
patterns.any? { |pattern| File.fnmatch?(pattern, relative_path, flags) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns the path to show users for a location: relative to the workspace for files inside it, the absolute
|
|
24
|
+
# path for files outside it (dependencies, for example) and the URI opaque for non file URIs, so that
|
|
25
|
+
# `untitled:Untitled-1` displays as `Untitled-1`.
|
|
26
|
+
#
|
|
27
|
+
#: (Location, workspace: String) -> String
|
|
28
|
+
def display_path(location, workspace:)
|
|
29
|
+
path = location.to_file_path
|
|
30
|
+
return path unless path == workspace || path.start_with?("#{workspace}/")
|
|
31
|
+
|
|
32
|
+
Pathname.new(path).relative_path_from(workspace).to_s
|
|
33
|
+
rescue Location::NotFileUriError
|
|
34
|
+
URI(location.uri).opaque || location.uri
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#: (Enumerable[Rubydex::Definition], Array[String]) -> Array[Rubydex::Definition]
|
|
39
|
+
def reject_definitions_in_paths(definitions, excluded_patterns)
|
|
40
|
+
workspace = graph.workspace_path
|
|
41
|
+
|
|
42
|
+
definitions.reject do |definition|
|
|
43
|
+
path = path_for_definition(definition)
|
|
44
|
+
path.nil? || (path != workspace && !path.start_with?("#{workspace}/")) ||
|
|
45
|
+
path_matches_patterns?(path, excluded_patterns)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
#: (Enumerable[Rubydex::Definition], Array[String]) -> Array[Rubydex::Definition]
|
|
50
|
+
def select_definitions_in_paths(definitions, patterns)
|
|
51
|
+
definitions.select do |definition|
|
|
52
|
+
path = path_for_definition(definition)
|
|
53
|
+
path && path_matches_patterns?(path, patterns)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
#: (String, Array[String]) -> bool
|
|
60
|
+
def path_matches_patterns?(path, patterns)
|
|
61
|
+
PathHelpers.path_matches_patterns?(path, patterns, workspace: graph.workspace_path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
#: (String) -> bool
|
|
65
|
+
def test_path?(path)
|
|
66
|
+
path_matches_patterns?(path, TEST_PATHS)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
#: (Rubydex::Definition) -> String?
|
|
70
|
+
def path_for_definition(definition)
|
|
71
|
+
definition.location.to_file_path
|
|
72
|
+
rescue Location::NotFileUriError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|