Skip to content

Commit 44afb3c

Browse files
authored
Merge pull request #186 from glennsarti/align-hash-rockets
(GH-177) Add auto-align hash rocket feature
2 parents 4f0a80f + 87fa677 commit 44afb3c

File tree

5 files changed

+356
-4
lines changed

5 files changed

+356
-4
lines changed
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# frozen_string_literal: true
2+
3+
require 'puppet-lint'
4+
5+
module PuppetLanguageServer
6+
module Manifest
7+
class FormatOnTypeProvider
8+
class << self
9+
def instance
10+
@instance ||= new
11+
end
12+
end
13+
14+
def format(content, line, char, trigger_character, formatting_options)
15+
result = []
16+
# Abort if the user has pressed something other than `>`
17+
return result unless trigger_character == '>'
18+
# Abort if the formatting is tab based. Can't do that yet
19+
return result unless formatting_options['insertSpaces'] == true
20+
# Abort if content is too big
21+
return result if content.length > 4096
22+
23+
lexer = PuppetLint::Lexer.new
24+
tokens = lexer.tokenise(content)
25+
26+
# Find where in the manifest the cursor is
27+
cursor_token = find_token_by_location(tokens, line, char)
28+
return result if cursor_token.nil?
29+
# The cursor should be at the end of a hashrocket, otherwise exit
30+
return result unless cursor_token.type == :FARROW
31+
32+
# Find the start of the hash with respect to the cursor
33+
start_brace = cursor_token.prev_token_of(:LBRACE, skip_blocks: true)
34+
# Find the end of the hash with respect to the cursor
35+
end_brace = cursor_token.next_token_of(:RBRACE, skip_blocks: true)
36+
37+
# The line count between the start and end brace needs to be at least 2 lines. Otherwise there's nothing to align to
38+
return result if end_brace.nil? || start_brace.nil? || end_brace.line - start_brace.line <= 2
39+
40+
# Find all hashrockets '=>' between the hash braces, ignoring nested hashes
41+
farrows = []
42+
farrow_token = start_brace
43+
lines = []
44+
loop do
45+
farrow_token = farrow_token.next_token_of(:FARROW, skip_blocks: true)
46+
# if there are no more hashrockets, or we've gone past the end_brace, we can exit the loop
47+
break if farrow_token.nil? || farrow_token.line > end_brace.line
48+
# if there's a hashrocket AFTER the closing brace (why?) then we can also exit the loop
49+
break if farrow_token.line == end_brace.line && farrow_token.character > end_brace.character
50+
# Check for multiple hashrockets on the same line. If we find some, then we can't do any automated indentation
51+
return result if lines.include?(farrow_token.line)
52+
lines << farrow_token.line
53+
farrows << { token: farrow_token }
54+
end
55+
56+
# Now we have a list of farrows, time for figure out the indentation marks
57+
farrows.each do |item|
58+
item.merge!(calculate_indentation_info(item[:token]))
59+
end
60+
61+
# Now we have the list of indentations we can find the biggest
62+
max_indent = -1
63+
farrows.each do |info|
64+
max_indent = info[:indent] if info[:indent] > max_indent
65+
end
66+
# No valid indentations found
67+
return result if max_indent == -1
68+
69+
# Now we have the indent size, generate all of the required TextEdits
70+
farrows.each do |info|
71+
# Ignore invalid hashrockets
72+
next if info[:indent] == -1
73+
end_name_token = info[:name_token].column + info[:name_token].to_manifest.length
74+
begin_farrow_token = info[:token].column
75+
new_whitespace = max_indent - end_name_token
76+
# If the whitespace is already what we want, then ignore it.
77+
next if begin_farrow_token - end_name_token == new_whitespace
78+
79+
# Create the TextEdit
80+
result << LSP::TextEdit.new.from_h!(
81+
'newText' => ' ' * new_whitespace,
82+
'range' => LSP.create_range(info[:token].line - 1, end_name_token - 1, info[:token].line - 1, begin_farrow_token - 1)
83+
)
84+
end
85+
result
86+
end
87+
88+
private
89+
90+
VALID_TOKEN_TYPES = %i[NAME STRING SSTRING].freeze
91+
92+
def find_token_by_location(tokens, line, character)
93+
return nil if tokens.empty?
94+
# Puppet Lint uses base 1, but LSP is base 0, so adjust accordingly
95+
cursor_line = line + 1
96+
cursor_column = character + 1
97+
idx = -1
98+
while idx < tokens.count
99+
idx += 1
100+
# if the token is on previous lines keep looking...
101+
next if tokens[idx].line < cursor_line
102+
# return nil if we skipped over the line we need
103+
return nil if tokens[idx].line > cursor_line
104+
# return nil if we skipped over the character position we need
105+
return nil if tokens[idx].column > cursor_column
106+
# return the token if it starts on the cursor column we are interested in
107+
return tokens[idx] if tokens[idx].column == cursor_column
108+
end_column = tokens[idx].column + tokens[idx].to_manifest.length
109+
# return the token it the cursor column is within the token string
110+
return tokens[idx] if cursor_column <= end_column
111+
# otherwise, keep on searching
112+
end
113+
nil
114+
end
115+
116+
def calculate_indentation_info(farrow_token)
117+
result = { indent: -1 }
118+
# This is not a valid hashrocket if there's no previous tokens
119+
return result if farrow_token.prev_token.nil?
120+
if VALID_TOKEN_TYPES.include?(farrow_token.prev_token.type)
121+
# Someone forgot the whitespace! e.g. ensure=>
122+
result[:indent] = farrow_token.column + 1
123+
result[:name_token] = farrow_token.prev_token
124+
return result
125+
end
126+
if farrow_token.prev_token.type == :WHITESPACE
127+
# If the whitespace has no previous token (which shouldn't happen) or the thing before the whitespace is not a property name this it not a valid hashrocket
128+
return result if farrow_token.prev_token.prev_token.nil?
129+
return result unless VALID_TOKEN_TYPES.include?(farrow_token.prev_token.prev_token.type)
130+
result[:name_token] = farrow_token.prev_token.prev_token
131+
result[:indent] = farrow_token.prev_token.column + 1 # The indent is the whitespace column + 1
132+
end
133+
result
134+
end
135+
end
136+
end
137+
end

lib/puppet-languageserver/message_router.rb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,31 @@ def receive_request(request)
206206
end
207207

208208
when 'textDocument/onTypeFormatting'
209-
request.reply_result(nil)
209+
unless client.format_on_type
210+
request.reply_result(nil)
211+
return
212+
end
213+
file_uri = request.params['textDocument']['uri']
214+
line_num = request.params['position']['line']
215+
char_num = request.params['position']['character']
216+
content = documents.document(file_uri)
217+
begin
218+
case documents.document_type(file_uri)
219+
when :manifest
220+
request.reply_result(PuppetLanguageServer::Manifest::FormatOnTypeProvider.instance.format(
221+
content,
222+
line_num,
223+
char_num,
224+
request.params['ch'],
225+
request.params['options']
226+
))
227+
else
228+
raise "Unable to format on type on #{file_uri}"
229+
end
230+
rescue StandardError => e
231+
PuppetLanguageServer.log_message(:error, "(textDocument/onTypeFormatting) #{e}")
232+
request.reply_result(nil)
233+
end
210234

211235
when 'textDocument/signatureHelp'
212236
file_uri = request.params['textDocument']['uri']

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/format_on_type_provider
89
manifest/signature_provider
910
manifest/validation_provider
1011
manifest/hover_provider
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
require 'spec_helper'
2+
3+
describe 'PuppetLanguageServer::Manifest::FormatOnTypeProvider' do
4+
let(:subject) { PuppetLanguageServer::Manifest::FormatOnTypeProvider.new }
5+
6+
describe '::instance' do
7+
it 'should exist' do
8+
expect(PuppetLanguageServer::Manifest::FormatOnTypeProvider).to respond_to(:instance)
9+
end
10+
11+
it 'should return the same object' do
12+
object1 = PuppetLanguageServer::Manifest::FormatOnTypeProvider.instance
13+
object2 = PuppetLanguageServer::Manifest::FormatOnTypeProvider.instance
14+
expect(object1).to eq(object2)
15+
end
16+
end
17+
18+
describe '#format' do
19+
let(:formatting_options) do
20+
LSP::FormattingOptions.new.tap do |item|
21+
item.tabSize = 2
22+
item.insertSpaces = true
23+
end.to_h
24+
end
25+
26+
[' ', '=', ','].each do |trigger|
27+
context "given a trigger character of '#{trigger}'" do
28+
it 'should return an empty array' do
29+
result = subject.format("{\n oneline =>\n}\n", 1, 1, trigger, formatting_options)
30+
expect(result).to eq([])
31+
end
32+
end
33+
end
34+
35+
context "given a trigger character of greater-than '>'" do
36+
let(:trigger_character) { '>' }
37+
let(:content) do <<-MANIFEST
38+
user {
39+
ensure=> 'something',
40+
password =>
41+
name => {
42+
'abc' => '123',
43+
'def' => '789',
44+
},
45+
name2 => 'correct',
46+
}
47+
MANIFEST
48+
end
49+
let(:valid_cursor) { { line: 2, char: 15 } }
50+
let(:inside_cursor) { { line: 5, char: 15 } }
51+
52+
it 'should return an empty array if the cursor is not on a hashrocket' do
53+
result = subject.format(content, 1, 1, trigger_character, formatting_options)
54+
expect(result).to eq([])
55+
end
56+
57+
it 'should return an empty array if the formatting options uses tabs' do
58+
result = subject.format(content, valid_cursor[:line], valid_cursor[:char], trigger_character, formatting_options.tap { |i| i['insertSpaces'] = false} )
59+
expect(result).to eq([])
60+
end
61+
62+
it 'should return an empty array if the document is large' do
63+
large_content = content + ' ' * 4096
64+
result = subject.format(large_content, valid_cursor[:line], valid_cursor[:char], trigger_character, formatting_options)
65+
expect(result).to eq([])
66+
end
67+
68+
# Valid hashrocket key tests
69+
[
70+
{ name: 'bare name', text: 'barename' },
71+
{ name: 'single quoted string', text: '\'name\'' },
72+
{ name: 'double quoted string', text: '"name"' },
73+
].each do |testcase|
74+
context "and given a manifest with #{testcase[:name]}" do
75+
let(:content) { "{\n a =>\n ##TESTCASE## => 'value'\n}\n"}
76+
77+
it 'should return an empty' do
78+
result = subject.format(content.gsub('##TESTCASE##', testcase[:text]), 1, 6, trigger_character, formatting_options)
79+
# The expected TextEdit should edit the `a =>`
80+
expect(result.count).to eq(1)
81+
expect(result[0].range.start.line).to eq(1)
82+
expect(result[0].range.start.character).to eq(3)
83+
expect(result[0].range.end.line).to eq(1)
84+
expect(result[0].range.end.character).to eq(4)
85+
end
86+
end
87+
end
88+
89+
it 'should have valid text edits in the outer hash' do
90+
result = subject.format(content, valid_cursor[:line], valid_cursor[:char], trigger_character, formatting_options)
91+
92+
expect(result.count).to eq(3)
93+
expect(result[0].to_h).to eq({"range"=>{"start"=>{"character"=>8, "line"=>1}, "end"=>{"character"=>8, "line"=>1}}, "newText"=>" "})
94+
expect(result[1].to_h).to eq({"range"=>{"start"=>{"character"=>10, "line"=>2}, "end"=>{"character"=>13, "line"=>2}}, "newText"=>" "})
95+
expect(result[2].to_h).to eq({"range"=>{"start"=>{"character"=>6, "line"=>3}, "end"=>{"character"=>7, "line"=>3}}, "newText"=>" "})
96+
end
97+
98+
it 'should have valid text edits in the inner hash' do
99+
result = subject.format(content, inside_cursor[:line], inside_cursor[:char], trigger_character, formatting_options)
100+
101+
expect(result.count).to eq(1)
102+
expect(result[0].to_h).to eq({"range"=>{"start"=>{"character"=>9, "line"=>5}, "end"=>{"character"=>13, "line"=>5}}, "newText"=>" "})
103+
end
104+
105+
# Invalid scenarios
106+
[
107+
{ name: 'only one line', content: "{\n oneline =>\n}\n" },
108+
{ name: 'there is nothing to indent', content: "{\n oneline =>\n nextline12 => 'value',\n}\n" },
109+
{ name: 'no starting Left Brace', content: "\n oneline =>\n nextline12 => 'value',\n}\n" },
110+
{ name: 'no ending Right Brace', content: "{\n oneline =>\n nextline12 => 'value',\n\n" },
111+
{ name: 'hashrockets on the same line', content: "{\n oneline => , nextline12 => 'value',\n\n"},
112+
{ name: 'invalid text before the hashrocket', content: "{\n String[] =>\n nextline => 'value',\n}\n" },
113+
].each do |testcase|
114+
context "and given a manifest with #{testcase[:name]}" do
115+
it 'should return an empty' do
116+
result = subject.format(testcase[:content], 1, 15, trigger_character, formatting_options)
117+
expect(result).to eq([])
118+
end
119+
end
120+
end
121+
end
122+
end
123+
end

spec/languageserver/unit/puppet-languageserver/message_router_spec.rb

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,7 @@
859859
context 'given a textDocument/onTypeFormatting request' do
860860
let(:request_rpc_method) { 'textDocument/onTypeFormatting' }
861861
let(:file_uri) { MANIFEST_FILENAME }
862+
let(:file_content) { "{\n a =>\n name => 'value'\n}\n" }
862863
let(:line_num) { 1 }
863864
let(:char_num) { 6 }
864865
let(:trigger_char) { '>' }
@@ -874,11 +875,77 @@
874875
'ch' => trigger_char,
875876
'options' => formatting_options
876877
} }
878+
let(:provider) { PuppetLanguageServer::Manifest::FormatOnTypeProvider.new }
877879

878-
it 'should not log an error message' do
879-
expect(PuppetLanguageServer).to_not receive(:log_message).with(:error,"Unknown RPC method #{request_rpc_method}")
880+
before(:each) do
881+
subject.documents.clear
882+
subject.documents.set_document(file_uri,file_content, 0)
883+
end
880884

881-
subject.receive_request(request)
885+
context 'with client.format_on_type set to false' do
886+
before(:each) do
887+
allow(subject.client).to receive(:format_on_type).and_return(false)
888+
end
889+
890+
it 'should reply with nil' do
891+
expect(request).to receive(:reply_result).with(nil)
892+
subject.receive_request(request)
893+
end
894+
end
895+
896+
context 'with client.format_on_type set to true' do
897+
before(:each) do
898+
allow(subject.client).to receive(:format_on_type).and_return(true)
899+
end
900+
901+
context 'for a file the server does not understand' do
902+
let(:file_uri) { UNKNOWN_FILENAME }
903+
904+
it 'should log an error message' do
905+
expect(PuppetLanguageServer).to receive(:log_message).with(:error,/Unable to format on type on/)
906+
907+
subject.receive_request(request)
908+
end
909+
910+
it 'should reply with nil' do
911+
expect(request).to receive(:reply_result).with(nil)
912+
913+
subject.receive_request(request)
914+
end
915+
end
916+
917+
context 'for a puppet manifest file' do
918+
let(:file_uri) { MANIFEST_FILENAME }
919+
920+
before(:each) do
921+
allow(PuppetLanguageServer::Manifest::FormatOnTypeProvider).to receive(:instance).and_return(provider)
922+
end
923+
924+
it 'should call format method on the Format On Type provider' do
925+
expect(provider).to receive(:format)
926+
.with(file_content, line_num, char_num, trigger_char, formatting_options).and_return('something')
927+
928+
result = subject.receive_request(request)
929+
end
930+
931+
context 'and an error occurs during formatting' do
932+
before(:each) do
933+
expect(provider).to receive(:format).and_raise('MockError')
934+
end
935+
936+
it 'should log an error message' do
937+
expect(PuppetLanguageServer).to receive(:log_message).with(:error,/MockError/)
938+
939+
subject.receive_request(request)
940+
end
941+
942+
it 'should reply with nil' do
943+
expect(request).to receive(:reply_result).with(nil)
944+
945+
subject.receive_request(request)
946+
end
947+
end
948+
end
882949
end
883950
end
884951

0 commit comments

Comments
 (0)