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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +110 -6
  3. data/THIRD_PARTY_LICENSES.html +271 -2
  4. data/exe/rdx +2 -73
  5. data/ext/rubydex/config.c +140 -0
  6. data/ext/rubydex/config.h +16 -0
  7. data/ext/rubydex/declaration.c +1 -1
  8. data/ext/rubydex/definition.c +32 -4
  9. data/ext/rubydex/diagnostic.c +75 -1
  10. data/ext/rubydex/diagnostic.h +2 -0
  11. data/ext/rubydex/graph.c +27 -48
  12. data/ext/rubydex/graph.h +6 -0
  13. data/ext/rubydex/query.c +487 -0
  14. data/ext/rubydex/query.h +8 -0
  15. data/ext/rubydex/reference.c +60 -0
  16. data/ext/rubydex/rubydex.c +4 -0
  17. data/ext/rubydex/utils.c +23 -4
  18. data/ext/rubydex/utils.h +5 -0
  19. data/lib/ruby_lsp/rubydex/addon.rb +211 -0
  20. data/lib/rubydex/cli/command/console.rb +55 -0
  21. data/lib/rubydex/cli/command/lint/explain.rb +74 -0
  22. data/lib/rubydex/cli/command/lint.rb +202 -0
  23. data/lib/rubydex/cli/command/mcp.rb +30 -0
  24. data/lib/rubydex/cli/command/query.rb +70 -0
  25. data/lib/rubydex/cli/command/skill.rb +69 -0
  26. data/lib/rubydex/cli/command.rb +168 -0
  27. data/lib/rubydex/cli.rb +93 -0
  28. data/lib/rubydex/config.rb +59 -0
  29. data/lib/rubydex/diagnostic.rb +12 -3
  30. data/lib/rubydex/errors.rb +42 -1
  31. data/lib/rubydex/graph.rb +10 -3
  32. data/lib/rubydex/linter/custom_rule.rb +97 -0
  33. data/lib/rubydex/linter/helpers/path_helpers.rb +78 -0
  34. data/lib/rubydex/linter/helpers/source_access_helpers.rb +31 -0
  35. data/lib/rubydex/linter/rule_loader.rb +36 -0
  36. data/lib/rubydex/linter/rule_test_case.rb +343 -0
  37. data/lib/rubydex/linter/runner.rb +56 -0
  38. data/lib/rubydex/linter.rb +19 -0
  39. data/lib/rubydex/location.rb +3 -0
  40. data/lib/rubydex/mcp_server.rb +1 -2
  41. data/lib/rubydex/related_information.rb +17 -0
  42. data/lib/rubydex/rule.rb +33 -0
  43. data/lib/rubydex/severity.rb +70 -0
  44. data/lib/rubydex/skill.rb +88 -0
  45. data/lib/rubydex/skill_registry.rb +62 -0
  46. data/lib/rubydex/version.rb +1 -1
  47. data/lib/rubydex.rb +6 -0
  48. data/lib/rubydex_linter/rules/rule_structure.rb +125 -0
  49. data/rbi/rubydex.rbi +578 -15
  50. data/rust/Cargo.lock +7 -0
  51. data/rust/rubydex/Cargo.toml +1 -0
  52. data/rust/rubydex/benches/graph_memory.rs +20 -4
  53. data/rust/rubydex/src/compile_assertions.rs +15 -0
  54. data/rust/rubydex/src/config.rs +538 -157
  55. data/rust/rubydex/src/diagnostic.rs +66 -40
  56. data/rust/rubydex/src/errors.rs +0 -1
  57. data/rust/rubydex/src/indexing/local_graph.rs +6 -5
  58. data/rust/rubydex/src/indexing/rbs_indexer.rs +284 -8
  59. data/rust/rubydex/src/indexing/ruby_indexer.rs +59 -70
  60. data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +195 -86
  61. data/rust/rubydex/src/lib.rs +1 -0
  62. data/rust/rubydex/src/listing.rs +26 -1
  63. data/rust/rubydex/src/main.rs +9 -128
  64. data/rust/rubydex/src/model/declaration.rs +301 -229
  65. data/rust/rubydex/src/model/definitions.rs +27 -26
  66. data/rust/rubydex/src/model/document.rs +43 -7
  67. data/rust/rubydex/src/model/graph.rs +67 -68
  68. data/rust/rubydex/src/model/id.rs +55 -0
  69. data/rust/rubydex/src/model/ids.rs +21 -9
  70. data/rust/rubydex/src/model/name.rs +88 -19
  71. data/rust/rubydex/src/model/references.rs +16 -13
  72. data/rust/rubydex/src/operation/ruby_builder.rs +78 -104
  73. data/rust/rubydex/src/path_helpers.rs +77 -0
  74. data/rust/rubydex/src/query/cypher/schema.rs +853 -0
  75. data/rust/rubydex/src/query/cypher/schema_info.rs +161 -0
  76. data/rust/rubydex/src/query/cypher/tests.rs +253 -0
  77. data/rust/rubydex/src/query/cypher.rs +54 -0
  78. data/rust/rubydex/src/query.rs +125 -43
  79. data/rust/rubydex/src/resolution.rs +368 -395
  80. data/rust/rubydex/src/resolution_tests.rs +504 -78
  81. data/rust/rubydex/src/test_utils/context.rs +2 -1
  82. data/rust/rubydex/src/test_utils/graph_test.rs +26 -12
  83. data/rust/rubydex/src/test_utils/local_graph_test.rs +19 -0
  84. data/rust/rubydex/tests/cli.rs +4 -4
  85. data/rust/rubydex-sys/src/config_api.rs +205 -0
  86. data/rust/rubydex-sys/src/cypher_api.rs +791 -0
  87. data/rust/rubydex-sys/src/declaration_api.rs +6 -3
  88. data/rust/rubydex-sys/src/definition_api.rs +27 -7
  89. data/rust/rubydex-sys/src/diagnostic_api.rs +77 -8
  90. data/rust/rubydex-sys/src/graph_api.rs +31 -68
  91. data/rust/rubydex-sys/src/lib.rs +2 -0
  92. data/rust/rubydex-sys/src/name_api.rs +2 -6
  93. data/rust/rubydex-sys/src/reference_api.rs +58 -12
  94. data/rust/rubydex-sys/src/utils.rs +37 -0
  95. data/skills/send-private-method/SKILL.md +133 -0
  96. 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
@@ -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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Rubydex
4
4
  class Diagnostic
5
- #: Symbol
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
- #: (rule: Symbol, message: String, location: Location) -> void
15
- def initialize(rule:, message:, location:)
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
@@ -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 `Graph#load_config` when the requested config file does not exist, cannot be read, or is malformed
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
- #: (?workspace_path: String?) -> void
11
- def initialize(workspace_path: nil)
12
- self.workspace_path = workspace_path if workspace_path
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