Skip to content

Commit 4ef1a57

Browse files
authored
Merge pull request #302 from glennsarti/can-i-lex
(GH-306) Add a syntax aware code folding provider
2 parents fe22f7a + f8a6671 commit 4ef1a57

File tree

12 files changed

+956
-61
lines changed

12 files changed

+956
-61
lines changed
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# frozen_string_literal: true
2+
3+
require 'puppet-languageserver/puppet_lexer_helper'
4+
require 'lsp/lsp'
5+
6+
module PuppetLanguageServer
7+
module Manifest
8+
class FoldingProvider
9+
class << self
10+
def instance
11+
@instance ||= new
12+
end
13+
14+
def supported?
15+
# Folding is only supported on Puppet 6.3.0 and above
16+
# Requires - https://github.com/puppetlabs/puppet/commit/6d375ab4d735779031d49ab8631bd9d161a9c3e3
17+
@supported ||= Gem::Version.new(Puppet.version) >= Gem::Version.new('6.3.0')
18+
end
19+
end
20+
21+
REGION_NONE = nil
22+
REGION_COMMENT = 'comment'
23+
REGION_REGION = 'region'
24+
25+
def start_region?(text)
26+
!(text =~ %r{^#\s*region\b}).nil?
27+
end
28+
29+
def end_region?(text)
30+
!(text =~ %r{^#\s*endregion\b}).nil?
31+
end
32+
33+
def folding_ranges(tokens, show_last_line = false)
34+
return nil unless self.class.supported?
35+
ranges = {}
36+
37+
brace_stack = []
38+
brack_stack = []
39+
comment_stack = []
40+
41+
index = 0
42+
until index > tokens.length - 1
43+
case tokens[index][0]
44+
# Find comments
45+
when :TOKEN_COMMENT
46+
if block_comment?(index, tokens)
47+
comment = tokens[index][1].locator.extract_text(tokens[index][1].offset, tokens[index][1].length)
48+
if start_region?(comment) # rubocop:disable Metrics/BlockNesting
49+
comment_stack.push(tokens[index][1])
50+
elsif end_region?(comment) && !comment_stack.empty? # rubocop:disable Metrics/BlockNesting
51+
add_range!(create_range_span_tokens(comment_stack.pop, tokens[index][1], REGION_REGION), ranges)
52+
else
53+
index = process_block_comment!(index, tokens, ranges)
54+
end
55+
end
56+
57+
# Find old style comments /* -> */
58+
when :TOKEN_MLCOMMENT
59+
add_range!(create_range_whole_token(tokens[index][1], REGION_COMMENT), ranges)
60+
61+
# Find matching braces { -> } and select brace ?{ -> }
62+
when :LBRACE, :SELBRACE
63+
brace_stack.push(tokens[index][1])
64+
when :RBRACE
65+
add_range!(create_range_span_tokens(brace_stack.pop, tokens[index][1], REGION_NONE), ranges) unless brace_stack.empty?
66+
67+
# Find matching braces [ -> ], list and index
68+
when :LISTSTART, :LBRACK
69+
brack_stack.push(tokens[index][1])
70+
when :RBRACK
71+
add_range!(create_range_span_tokens(brack_stack.pop, tokens[index][1], REGION_NONE), ranges) unless brack_stack.empty?
72+
73+
# Find matching Heredoc and heredoc sublocations
74+
when :HEREDOC
75+
# Need to check if the next token is :SUBLOCATE
76+
if index < tokens.length - 2 && tokens[index + 1][0] == :SUBLOCATE # rubocop:disable Style/IfUnlessModifier
77+
add_range!(create_range_heredoc(tokens[index][1], tokens[index + 1][1], REGION_NONE), ranges)
78+
end
79+
end
80+
81+
index += 1
82+
end
83+
84+
# If we are showing the last line then decrement the EndLine by one, if possible
85+
if show_last_line
86+
ranges.values.each do |range|
87+
range.endLine = [range.startLine, range.endLine - 1].max
88+
range.endCharacter = 0 # We don't know where the previous line actually ends so set it to zero
89+
end
90+
end
91+
92+
ranges.values
93+
end
94+
95+
private
96+
97+
# region Internal Helper methods to call locator methods on Locators or SubLocators
98+
def line_for_offset(token, offset = nil)
99+
locator_method_with_offset(token, :line_for_offset, offset || token.offset)
100+
end
101+
102+
def pos_on_line(token, offset = nil)
103+
locator_method_with_offset(token, :pos_on_line, offset || token.offset)
104+
end
105+
106+
def locator_method_with_offset(token, method_name, offset)
107+
if token.locator.is_a?(Puppet::Pops::Parser::Locator::SubLocator)
108+
global_offset, = token.locator.to_global(offset, token.length)
109+
token.locator.locator.send(method_name, global_offset)
110+
else
111+
token.locator.send(method_name, offset)
112+
end
113+
end
114+
115+
def extract_text(token)
116+
if token.locator.is_a?(Puppet::Pops::Parser::Locator::SubLocator)
117+
global_offset, global_length = token.locator.to_global(token.offset, token.length)
118+
token.locator.locator.extract_text(global_offset, global_length)
119+
else
120+
token.locator.extract_text(token.offset, token.length)
121+
end
122+
end
123+
# endregion
124+
125+
# Return nil if not valid range
126+
def create_range_span_tokens(start_token, end_token, kind)
127+
start_line = line_for_offset(start_token) - 1
128+
end_line = line_for_offset(end_token) - 1
129+
return nil if start_line == end_line
130+
LSP::FoldingRange.new({
131+
'startLine' => start_line,
132+
'startCharacter' => pos_on_line(start_token) - 1,
133+
'endLine' => end_line,
134+
'endCharacter' => pos_on_line(end_token, end_token.offset + end_token.length) - 1,
135+
'kind' => kind
136+
})
137+
end
138+
139+
# Return nil if not valid range
140+
def create_range_whole_token(token, kind)
141+
start_line = line_for_offset(token) - 1
142+
end_line = line_for_offset(token, token.offset + token.length) - 1
143+
return nil if start_line == end_line
144+
LSP::FoldingRange.new({
145+
'startLine' => start_line,
146+
'startCharacter' => pos_on_line(token) - 1,
147+
'endLine' => end_line,
148+
'endCharacter' => pos_on_line(token, token.offset + token.length) - 1,
149+
'kind' => kind
150+
})
151+
end
152+
153+
# Return nil if not valid range
154+
def create_range_heredoc(heredoc_token, subloc_token, kind)
155+
start_line = line_for_offset(heredoc_token) - 1
156+
# The lexer does not output the end heredoc_token. Instead we
157+
# use the heredoc sublocator endline and add one
158+
end_line = line_for_offset(heredoc_token, heredoc_token.offset + heredoc_token.length + subloc_token.length)
159+
return nil if start_line == end_line
160+
LSP::FoldingRange.new({
161+
'startLine' => start_line,
162+
'startCharacter' => pos_on_line(heredoc_token) - 1,
163+
'endLine' => end_line,
164+
# We don't know where the end token for the Heredoc is, so just assume it's at the start of the line
165+
'endCharacter' => 0,
166+
'kind' => kind
167+
})
168+
end
169+
170+
# Adds a FoldingReference to the list and enforces ordering rules e.g. Only one fold per start line
171+
def add_range!(range, ranges)
172+
# Make sure the arguments are correct
173+
return nil if range.nil? || ranges.nil?
174+
175+
# Ignore the range if there is an existing one which is bigger
176+
return nil unless ranges[range.startLine].nil? || ranges[range.startLine].endLine < range.endLine
177+
ranges[range.startLine] = range
178+
nil
179+
end
180+
181+
# Returns new index position
182+
def process_block_comment!(index, tokens, ranges)
183+
start_index = index
184+
line_num = line_for_offset(tokens[index][1])
185+
while index < tokens.length - 2
186+
break unless tokens[index + 1][0] == :TOKEN_COMMENT
187+
next_line = line_for_offset(tokens[index + 1][1])
188+
# Tokens must be on contiguous lines
189+
break unless next_line == line_num + 1
190+
# Must not be a region comment
191+
comment = extract_text(tokens[index + 1][1])
192+
break if start_region?(comment) || end_region?(comment)
193+
# It's a block comment
194+
line_num = next_line
195+
index += 1
196+
end
197+
198+
return index if start_index == index
199+
200+
add_range!(create_range_span_tokens(tokens[start_index][1], tokens[index][1], REGION_COMMENT), ranges)
201+
index
202+
end
203+
204+
def block_comment?(index, tokens)
205+
# Has to be a comment token
206+
return false unless tokens[index][0] == :TOKEN_COMMENT
207+
# If it's the first token then it has to be at the start of a line
208+
return true if index.zero?
209+
# It has to be the first token on this line
210+
this_token_line = line_for_offset(tokens[index][1])
211+
prev_token_line = line_for_offset(tokens[index - 1][1])
212+
213+
this_token_line != prev_token_line
214+
end
215+
end
216+
end
217+
end

lib/puppet-languageserver/message_handler.rb

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,12 @@ def request_initialize(_, json_rpc_message)
2929
PuppetLanguageServer.log_message(:debug, 'Received initialize method')
3030

3131
language_client.parse_lsp_initialize!(json_rpc_message.params)
32+
static_folding_provider = !language_client.client_capability('textDocument', 'foldingRange', 'dynamicRegistration') &&
33+
PuppetLanguageServer::ServerCapabilites.folding_provider_supported?
3234
# Setup static registrations if dynamic registration is not available
3335
info = {
34-
:documentOnTypeFormattingProvider => !language_client.client_capability('textDocument', 'onTypeFormatting', 'dynamicRegistration')
36+
:documentOnTypeFormattingProvider => !language_client.client_capability('textDocument', 'onTypeFormatting', 'dynamicRegistration'),
37+
:foldingRangeProvider => static_folding_provider
3538
}
3639

3740
# Configure the document store
@@ -162,6 +165,20 @@ def request_textdocument_completion(_, json_rpc_message)
162165
LSP::CompletionList.new('isIncomplete' => false, 'items' => [])
163166
end
164167

168+
def request_textdocument_foldingrange(_, json_rpc_message)
169+
return nil unless language_client.folding_range
170+
file_uri = json_rpc_message.params['textDocument']['uri']
171+
case documents.document_type(file_uri)
172+
when :manifest
173+
PuppetLanguageServer::Manifest::FoldingProvider.instance.folding_ranges(documents.document_tokens(file_uri))
174+
else
175+
raise "Unable to provide folding ranages on #{file_uri}"
176+
end
177+
rescue StandardError => e
178+
PuppetLanguageServer.log_message(:error, "(textDocument/foldingRange) #{e}")
179+
nil
180+
end
181+
165182
def request_completionitem_resolve(_, json_rpc_message)
166183
PuppetLanguageServer::Manifest::CompletionProvider.resolve(session_state, LSP::CompletionItem.new(json_rpc_message.params))
167184
rescue StandardError => e
@@ -285,6 +302,7 @@ def notification_initialized(_, _json_rpc_message)
285302
"Unable to use Puppet version '#{server_options[:puppet_version]}' as it is not available. Using version '#{Puppet.version}' instead."
286303
)
287304
end
305+
288306
# Register for workspace setting changes if it's supported
289307
if language_client.client_capability('workspace', 'didChangeConfiguration', 'dynamicRegistration') == true
290308
language_client.register_capability('workspace/didChangeConfiguration')

lib/puppet-languageserver/providers.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
manifest/completion_provider
66
manifest/definition_provider
77
manifest/document_symbol_provider
8+
manifest/folding_provider
89
manifest/format_on_type_provider
910
manifest/signature_provider
1011
manifest/validation_provider
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
module Puppet
4+
module Pops
5+
module Parser
6+
# This Lexer adds code to create comment tokens.
7+
# The default lexer just throws them out
8+
# Ref - https://github.com/puppetlabs/puppet-specifications/blob/master/language/lexical_structure.md#comments
9+
class Lexer2WithComments < Puppet::Pops::Parser::Lexer2
10+
# The PATTERN_COMMENT in lexer2 also consumes the trailing \r in the token and
11+
# we don't want that.
12+
PATTERN_COMMENT_NO_WS = %r{#[^\r\n]*}.freeze
13+
14+
TOKEN_COMMENT = [:COMMENT, '#', 1].freeze
15+
TOKEN_MLCOMMENT = [:MLCOMMENT, nil, 0].freeze
16+
17+
def initialize
18+
super
19+
20+
# Remove the selector for line comments so we can add our own
21+
@new_selector = @selector.reject { |k, _v| k == '#' }
22+
23+
# Add code to scan line comments
24+
@new_selector['#'] = lambda {
25+
scn = @scanner
26+
before = scn.pos
27+
value = scn.scan(PATTERN_COMMENT_NO_WS)
28+
29+
if value
30+
emit_completed([:TOKEN_COMMENT, value[1..-1].freeze, scn.pos - before], before)
31+
else
32+
# It's probably not possible to EVER get here ... but just incase
33+
emit(TOKEN_COMMENT, before)
34+
end
35+
}.freeze
36+
37+
# Add code to scan multi-line comments
38+
old_lambda = @new_selector['/']
39+
@new_selector['/'] = lambda {
40+
scn = @scanner
41+
la = scn.peek(2)
42+
if la[1] == '*'
43+
before = scn.pos
44+
value = scn.scan(PATTERN_MLCOMMENT)
45+
return emit_completed([:TOKEN_MLCOMMENT, value[2..-3].freeze, scn.pos - before], before) if value
46+
end
47+
old_lambda.call
48+
}.freeze
49+
@new_selector.freeze
50+
@selector = @new_selector
51+
end
52+
end
53+
end
54+
end
55+
end

lib/puppet-languageserver/server_capabilities.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
module PuppetLanguageServer
66
module ServerCapabilites
7+
def self.folding_provider_supported?
8+
@folding_provider ||= PuppetLanguageServer::Manifest::FoldingProvider.supported?
9+
end
10+
711
def self.capabilities(options = {})
812
# https://github.com/Microsoft/language-server-protocol/blob/master/protocol.md#initialize-request
913

@@ -22,6 +26,7 @@ def self.capabilities(options = {})
2226
}
2327
}
2428
value['documentOnTypeFormattingProvider'] = document_on_type_formatting_options if options[:documentOnTypeFormattingProvider]
29+
value['foldingRangeProvider'] = folding_range_provider_options if options[:foldingRangeProvider]
2530
value
2631
end
2732

@@ -31,6 +36,10 @@ def self.document_on_type_formatting_options
3136
}
3237
end
3338

39+
def self.folding_range_provider_options
40+
true
41+
end
42+
3443
def self.no_capabilities
3544
# Any empty hash denotes no capabilities at all
3645
{

0 commit comments

Comments
 (0)