klenod-lsp 0.0.15

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.
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "language_server-protocol"
4
+
5
+ require "klenod/build/module_id"
6
+
7
+ require_relative "diagnostics"
8
+ require_relative "languages"
9
+
10
+ module Klenod
11
+ module LSP
12
+ # Workspace edits that keep imports valid when the editor renames or
13
+ # moves files or folders under the source directory.
14
+ #
15
+ # Importers get their literals rewritten to the new location, and a
16
+ # moved module gets its own relative imports rewritten from its new
17
+ # directory. Each literal keeps its style: root-relative stays
18
+ # root-relative, `./` stays `./`, bare stays bare, and an extension is
19
+ # kept only when the literal had one. Edits are keyed by the files' old
20
+ # URIs because the client applies them before renaming.
21
+ module Renames
22
+ Interface = LanguageServer::Protocol::Interface
23
+
24
+ Move = Data.define(:old_path, :new_path) do
25
+ def apply(path)
26
+ return new_path if path == old_path
27
+ return path unless path.start_with?("#{old_path}/")
28
+
29
+ new_path + path.delete_prefix(old_path)
30
+ end
31
+ end
32
+
33
+ module_function
34
+
35
+ def call(files, index, workspace)
36
+ moves = files.filter_map do |old_uri, new_uri|
37
+ old_path = workspace.path_for_uri(old_uri)
38
+ new_path = workspace.path_for_uri(new_uri)
39
+ Move.new(old_path, new_path) if old_path && new_path
40
+ end
41
+ return nil if moves.empty?
42
+
43
+ changes = Hash.new { |hash, uri| hash[uri] = [] }
44
+ moves.each do |move|
45
+ moved_module_ids(move, index, workspace).each do |module_id|
46
+ index.ensure_collected(module_id)
47
+ importer_edits(module_id, moves, index, workspace, changes)
48
+ own_edits(module_id, moves, index, workspace, changes)
49
+ end
50
+ end
51
+ return nil if changes.empty?
52
+
53
+ sorted = changes.transform_values { |edits| edits.sort_by { |edit| [edit.range.start.line, edit.range.start.character] } }
54
+ Interface::WorkspaceEdit.new(changes: sorted)
55
+ end
56
+
57
+ def moved_module_ids(move, index, workspace)
58
+ if File.directory?(move.old_path)
59
+ index.records.keys.select do |module_id|
60
+ path = workspace.path_for_module_id(module_id)
61
+ path&.start_with?("#{move.old_path}/")
62
+ end
63
+ else
64
+ module_id = workspace.module_id_for_path(move.old_path)
65
+ module_id ? [module_id] : []
66
+ end
67
+ end
68
+
69
+ def importer_edits(module_id, moves, index, workspace, changes)
70
+ new_target_path = apply_moves(workspace.path_for_module_id(module_id), moves)
71
+
72
+ index.dependents(module_id).each do |importer_id|
73
+ record = index.record(importer_id)
74
+ importer_path = record && workspace.path_for_module_id(importer_id)
75
+ next unless importer_path
76
+
77
+ lines = record.source.lines(chomp: true)
78
+ importer_new_path = apply_moves(importer_path, moves)
79
+ record.resolved_dependencies.each do |resolved|
80
+ next unless resolved.module_id == module_id
81
+
82
+ add_edit(changes, workspace.uri_for_path(importer_path), lines, resolved.dependency.specifier.to_s, importer_new_path, new_target_path, workspace)
83
+ end
84
+ end
85
+ end
86
+
87
+ def own_edits(module_id, moves, index, workspace, changes)
88
+ record = index.record(module_id)
89
+ path = record && workspace.path_for_module_id(module_id)
90
+ return unless path
91
+
92
+ lines = record.source.lines(chomp: true)
93
+ new_path = apply_moves(path, moves)
94
+ record.resolved_dependencies.each do |resolved|
95
+ specifier = resolved.dependency.specifier.to_s
96
+ next if specifier.start_with?("/") || specifier.match?(Klenod::Build::ModuleId::SCHEME_PATTERN)
97
+
98
+ target_path = workspace.path_for_module_id(resolved.module_id)
99
+ next unless target_path
100
+
101
+ add_edit(changes, workspace.uri_for_path(path), lines, specifier, new_path, apply_moves(target_path, moves), workspace)
102
+ end
103
+ end
104
+
105
+ def add_edit(changes, uri, lines, specifier, importer_path, target_path, workspace)
106
+ replacement = rewrite_specifier(specifier, importer_path, target_path, workspace.source_dir)
107
+ return if replacement == specifier
108
+
109
+ syntax = Languages.syntax_for(File.extname(workspace.path_for_uri(uri).to_s))
110
+ Diagnostics.literal_spans(specifier, lines, syntax: syntax).each do |span|
111
+ next if changes[uri].any? { |edit| edit.range.start.line == span.line && edit.range.start.character == span.start_character }
112
+
113
+ changes[uri] << Interface::TextEdit.new(range: span.to_range, new_text: replacement)
114
+ end
115
+ end
116
+
117
+ def rewrite_specifier(specifier, importer_path, target_path, source_dir)
118
+ path_part, query = specifier.split("?", 2)
119
+ target = File.extname(path_part).empty? ? target_path.delete_suffix(File.extname(target_path)) : target_path
120
+
121
+ rewritten =
122
+ if path_part.start_with?("/")
123
+ "/#{Pathname.new(target).relative_path_from(Pathname.new(source_dir))}"
124
+ else
125
+ relative = Pathname.new(target).relative_path_from(Pathname.new(File.dirname(importer_path))).to_s
126
+ if path_part.start_with?("./")
127
+ relative.start_with?("../") ? relative : "./#{relative}"
128
+ else
129
+ relative
130
+ end
131
+ end
132
+
133
+ query ? "#{rewritten}?#{query}" : rewritten
134
+ end
135
+
136
+ def apply_moves(path, moves)
137
+ moves.reduce(path) { |current, move| move.apply(current) }
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "klenod/build/plugins/router_plugin"
4
+
5
+ module Klenod
6
+ module LSP
7
+ # What the router plugin knows about a module: the route a page or
8
+ # handler serves, the routes a layout wraps, or the special view it is.
9
+ # The manifest is discovered from the pages directory and kept until
10
+ # files change.
11
+ class Routes
12
+ def initialize(workspace)
13
+ @workspace = workspace
14
+ @manifest = nil
15
+ @discovered = false
16
+ end
17
+
18
+ def invalidate
19
+ @manifest = nil
20
+ @discovered = false
21
+ end
22
+
23
+ def descriptions(module_id)
24
+ manifest = self.manifest
25
+ return [] unless manifest
26
+
27
+ id = module_id.to_s
28
+ descriptions = []
29
+ manifest.routes.each do |route|
30
+ descriptions << "Route #{route.path}" if [route.page_module_id, route.handler_module_id].compact.any? { |candidate| candidate.to_s == id }
31
+ end
32
+ wrapped = manifest.routes.count { |route| [*route.layout_module_ids, route.slot_layout_module_id].compact.any? { |candidate| candidate.to_s == id } }
33
+ descriptions << "Layout for #{wrapped} #{(wrapped == 1) ? "route" : "routes"}" if wrapped.positive?
34
+ manifest.special_views.each do |view|
35
+ descriptions << "#{view.kind.to_s.tr("_", " ").capitalize} view for #{view.path}" if view.view_module_id.to_s == id
36
+ end
37
+ descriptions
38
+ end
39
+
40
+ # Markdown for a hover on a route module: the route and its params,
41
+ # the page and handler files, and the layouts wrapping it; for a layout,
42
+ # the routes it wraps; for a special view, its kind and scope.
43
+ def hover_markdown(module_id)
44
+ manifest = self.manifest
45
+ return nil unless manifest
46
+
47
+ id = module_id.to_s
48
+ sections = []
49
+ manifest.routes.each do |route|
50
+ next unless [route.page_module_id, route.handler_module_id].compact.any? { |candidate| candidate.to_s == id }
51
+
52
+ lines = ["**Route** `#{route.path}` · `#{directory_form(route)}`"]
53
+ params = route.params
54
+ lines << "Params: #{params.map { |param| "`#{param.name}` (#{param.kind})" }.join(", ")}" unless params.empty?
55
+ lines << "Page: `#{route.page_module_id.path}`" if route.page_module_id
56
+ lines << "Handler: `#{route.handler_module_id.path}`" if route.handler_module_id
57
+ lines << layouts_line(route.layout_module_ids)
58
+ sections << lines.compact.join("\n\n")
59
+ end
60
+
61
+ wrapped = manifest.routes.select { |route| [*route.layout_module_ids, route.slot_layout_module_id].compact.any? { |candidate| candidate.to_s == id } }
62
+ unless wrapped.empty?
63
+ listed = wrapped.first(10).map { |route| "`#{route.path}`" }
64
+ listed << "…" if wrapped.length > 10
65
+ sections << "**Layout** for #{wrapped.length} #{(wrapped.length == 1) ? "route" : "routes"}: #{listed.join(", ")}"
66
+ end
67
+
68
+ manifest.special_views.each do |view|
69
+ next unless view.view_module_id.to_s == id
70
+
71
+ lines = ["**#{view.kind.to_s.tr("_", " ").capitalize} view** for `#{view.path}`"]
72
+ lines << "Status: #{view.status}" if view.status
73
+ lines << layouts_line(view.layout_module_ids)
74
+ sections << lines.compact.join("\n\n")
75
+ end
76
+
77
+ sections.empty? ? nil : sections.join("\n\n---\n\n")
78
+ end
79
+
80
+ private
81
+
82
+ # The directory spelling of a route, e.g. `pages/docs/[slug]`, next to
83
+ # the URL pattern the router matches, e.g. `/docs/:slug`.
84
+ def directory_form(route)
85
+ File.dirname(route.module_id.path)
86
+ end
87
+
88
+ def layouts_line(layout_module_ids)
89
+ return nil if layout_module_ids.nil? || layout_module_ids.empty?
90
+
91
+ "Layouts: #{layout_module_ids.map { |layout_id| "`#{layout_id.path}`" }.join(" → ")}"
92
+ end
93
+
94
+ def manifest
95
+ return @manifest if @discovered
96
+
97
+ @discovered = true
98
+ plugin = @workspace.context.graph.plugins.find { |candidate| candidate.is_a?(Klenod::Build::Plugins::RouterPlugin::Plugin) }
99
+ @manifest = plugin&.discover(source_dir: @workspace.source_dir)
100
+ rescue Klenod::Build::Error
101
+ @manifest = nil
102
+ end
103
+ end
104
+ end
105
+ end