diff --git a/CHANGELOG.md b/CHANGELOG.md index 51590a91..0447b8ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how ## Unreleased +- ([GH-56](https://github.com/lingua-pupuli/puppet-editor-services/issues/56)) Add DocumentSymbol Support + ## 0.14.0 - 2018-08-17 ### Fixed diff --git a/lib/languageserver/constants.rb b/lib/languageserver/constants.rb index fa77275c..72732cb7 100644 --- a/lib/languageserver/constants.rb +++ b/lib/languageserver/constants.rb @@ -40,6 +40,13 @@ module LanguageServer SYMBOLKIND_NUMBER = 16 SYMBOLKIND_BOOLEAN = 17 SYMBOLKIND_ARRAY = 18 + SYMBOLKIND_OBJECT = 19 + SYMBOLKIND_KEY = 20 + SYMBOLKIND_NULL = 21 + SYMBOLKIND_ENUMMEMBER = 22 + SYMBOLKIND_STRUCT = 23 + SYMBOLKIND_EVENT = 24 + SYMBOLKIND_OPERATOR = 25 TEXTDOCUMENTSYNCKIND_NONE = 0 TEXTDOCUMENTSYNCKIND_FULL = 1 diff --git a/lib/languageserver/document_symbol.rb b/lib/languageserver/document_symbol.rb new file mode 100644 index 00000000..3c47a15c --- /dev/null +++ b/lib/languageserver/document_symbol.rb @@ -0,0 +1,79 @@ +module LanguageServer + # /** + # * Represents programming constructs like variables, classes, interfaces etc. that appear in a document. Document symbols can be + # * hierarchical and they have two ranges: one that encloses its definition and one that points to its most interesting range, + # * e.g. the range of an identifier. + # */ + # export class DocumentSymbol { + # /** + # * The name of this symbol. + # */ + # name: string; + # /** + # * More detail for this symbol, e.g the signature of a function. + # */ + # detail?: string; + # /** + # * The kind of this symbol. + # */ + # kind: SymbolKind; + # /** + # * Indicates if this symbol is deprecated. + # */ + # deprecated?: boolean; + # /** + # * The range enclosing this symbol not including leading/trailing whitespace but everything else + # * like comments. This information is typically used to determine if the clients cursor is + # * inside the symbol to reveal in the symbol in the UI. + # */ + # range: Range; + # /** + # * The range that should be selected and revealed when this symbol is being picked, e.g the name of a function. + # * Must be contained by the `range`. + # */ + # selectionRange: Range; + # /** + # * Children of this symbol, e.g. properties of a class. + # */ + # children?: DocumentSymbol[]; + # } + module DocumentSymbol + def self.create(options) + result = {} + raise('name is a required field for DocumentSymbol') if options['name'].nil? + raise('kind is a required field for DocumentSymbol') if options['kind'].nil? + raise('range is a required field for DocumentSymbol') if options['range'].nil? + raise('selectionRange is a required field for DocumentSymbol') if options['selectionRange'].nil? + + result['name'] = options['name'] + result['kind'] = options['kind'] + result['detail'] = options['detail'] unless options['detail'].nil? + result['deprecated'] = options['deprecated'] unless options['deprecated'].nil? + result['children'] = options['children'] unless options['children'].nil? + + result['range'] = { + 'start' => { + 'line' => options['range'][0], + 'character' => options['range'][1] + }, + 'end' => { + 'line' => options['range'][2], + 'character' => options['range'][3] + } + } + + result['selectionRange'] = { + 'start' => { + 'line' => options['selectionRange'][0], + 'character' => options['selectionRange'][1] + }, + 'end' => { + 'line' => options['selectionRange'][2], + 'character' => options['selectionRange'][3] + } + } + + result + end + end +end diff --git a/lib/languageserver/languageserver.rb b/lib/languageserver/languageserver.rb index 22db2209..cdcb84e1 100644 --- a/lib/languageserver/languageserver.rb +++ b/lib/languageserver/languageserver.rb @@ -1,4 +1,4 @@ -%w[constants diagnostic completion_list completion_item hover location puppet_version puppet_compilation puppet_fix_diagnostic_errors].each do |lib| +%w[constants diagnostic completion_list completion_item document_symbol hover location puppet_version puppet_compilation puppet_fix_diagnostic_errors].each do |lib| begin require "languageserver/#{lib}" rescue LoadError diff --git a/lib/puppet-languageserver/manifest/document_symbol_provider.rb b/lib/puppet-languageserver/manifest/document_symbol_provider.rb new file mode 100644 index 00000000..243f503c --- /dev/null +++ b/lib/puppet-languageserver/manifest/document_symbol_provider.rb @@ -0,0 +1,151 @@ +module PuppetLanguageServer + module Manifest + module DocumentSymbolProvider + def self.extract_document_symbols(content) + parser = Puppet::Pops::Parser::Parser.new + result = parser.parse_string(content, '') + + if result.model.respond_to? :eAllContents + # We are unable to build a document symbol tree for Puppet 4 AST + return [] + end + symbols = [] + recurse_document_symbols(result.model, '', nil, symbols) # [] + + symbols + end + + def self.create_range_array(offset, length, locator) + start_line = locator.line_for_offset(offset) - 1 + start_char = locator.pos_on_line(offset) - 1 + end_line = locator.line_for_offset(offset + length) - 1 + end_char = locator.pos_on_line(offset + length) - 1 + + [start_line, start_char, end_line, end_char] + end + + def self.create_range_object(offset, length, locator) + result = create_range_array(offset, length, locator) + { + 'start' => { + 'line' => result[0], + 'character' => result[1] + }, + 'end' => { + 'line' => result[2], + 'character' => result[3] + } + } + end + + def self.locator_text(offset, length, locator) + locator.string.slice(offset, length) + end + + def self.recurse_document_symbols(object, path, parentsymbol, symbollist) + # POPS Object Model + # https://github.com/puppetlabs/puppet/blob/master/lib/puppet/pops/model/ast.pp + + # Path is just an internal path for debugging + # path = path + '/' + object.class.to_s[object.class.to_s.rindex('::')..-1] + + this_symbol = nil + + case object.class.to_s + # Puppet Resources + when 'Puppet::Pops::Model::ResourceExpression' + this_symbol = LanguageServer::DocumentSymbol.create( + 'name' => object.type_name.value, + 'kind' => LanguageServer::SYMBOLKIND_METHOD, + 'detail' => object.type_name.value, + 'range' => create_range_array(object.offset, object.length, object.locator), + 'selectionRange' => create_range_array(object.offset, object.length, object.locator), + 'children' => [] + ) + + when 'Puppet::Pops::Model::ResourceBody' + # We modify the parent symbol with the resource information, + # mainly we care about the resource title. + parentsymbol['name'] = parentsymbol['name'] + ': ' + locator_text(object.title.offset, object.title.length, object.title.locator) + parentsymbol['detail'] = parentsymbol['name'] + parentsymbol['selectionRange'] = create_range_object(object.title.offset, object.title.length, object.locator) + + when 'Puppet::Pops::Model::AttributeOperation' + attr_name = object.attribute_name + this_symbol = LanguageServer::DocumentSymbol.create( + 'name' => attr_name, + 'kind' => LanguageServer::SYMBOLKIND_VARIABLE, + 'detail' => attr_name, + 'range' => create_range_array(object.offset, object.length, object.locator), + 'selectionRange' => create_range_array(object.offset, attr_name.length, object.locator), + 'children' => [] + ) + + # Puppet Class + when 'Puppet::Pops::Model::HostClassDefinition' + this_symbol = LanguageServer::DocumentSymbol.create( + 'name' => object.name, + 'kind' => LanguageServer::SYMBOLKIND_CLASS, + 'detail' => object.name, + 'range' => create_range_array(object.offset, object.length, object.locator), + 'selectionRange' => create_range_array(object.offset, object.length, object.locator), + 'children' => [] + ) + # Load in the class parameters + object.parameters.each do |param| + param_symbol = LanguageServer::DocumentSymbol.create( + 'name' => '$' + param.name, + 'kind' => LanguageServer::SYMBOLKIND_PROPERTY, + 'detail' => '$' + param.name, + 'range' => create_range_array(param.offset, param.length, param.locator), + 'selectionRange' => create_range_array(param.offset, param.length, param.locator), + 'children' => [] + ) + this_symbol['children'].push(param_symbol) + end + + # Puppet Defined Type + when 'Puppet::Pops::Model::ResourceTypeDefinition' + this_symbol = LanguageServer::DocumentSymbol.create( + 'name' => object.name, + 'kind' => LanguageServer::SYMBOLKIND_CLASS, + 'detail' => object.name, + 'range' => create_range_array(object.offset, object.length, object.locator), + 'selectionRange' => create_range_array(object.offset, object.length, object.locator), + 'children' => [] + ) + # Load in the class parameters + object.parameters.each do |param| + param_symbol = LanguageServer::DocumentSymbol.create( + 'name' => '$' + param.name, + 'kind' => LanguageServer::SYMBOLKIND_FIELD, + 'detail' => '$' + param.name, + 'range' => create_range_array(param.offset, param.length, param.locator), + 'selectionRange' => create_range_array(param.offset, param.length, param.locator), + 'children' => [] + ) + this_symbol['children'].push(param_symbol) + end + + when 'Puppet::Pops::Model::AssignmentExpression' + this_symbol = LanguageServer::DocumentSymbol.create( + 'name' => '$' + object.left_expr.expr.value, + 'kind' => LanguageServer::SYMBOLKIND_VARIABLE, + 'detail' => '$' + object.left_expr.expr.value, + 'range' => create_range_array(object.left_expr.offset, object.left_expr.length, object.left_expr.locator), + 'selectionRange' => create_range_array(object.left_expr.offset, object.left_expr.length, object.left_expr.locator), + 'children' => [] + ) + + end + + object._pcore_contents do |item| + recurse_document_symbols(item, path, this_symbol.nil? ? parentsymbol : this_symbol, symbollist) + end + + return if this_symbol.nil? + parentsymbol.nil? ? symbollist.push(this_symbol) : parentsymbol['children'].push(this_symbol) + end + end + end +end diff --git a/lib/puppet-languageserver/message_router.rb b/lib/puppet-languageserver/message_router.rb index 3f0fb7dd..6d470429 100644 --- a/lib/puppet-languageserver/message_router.rb +++ b/lib/puppet-languageserver/message_router.rb @@ -149,6 +149,21 @@ def receive_request(request) request.reply_result(nil) end + when 'textDocument/documentSymbol' + file_uri = request.params['textDocument']['uri'] + content = documents.document(file_uri) + begin + case documents.document_type(file_uri) + when :manifest + result = PuppetLanguageServer::Manifest::DocumentSymbolProvider.extract_document_symbols(content) + request.reply_result(result) + else + raise "Unable to provide definition on #{file_uri}" + end + rescue StandardError => exception + PuppetLanguageServer.log_message(:error, "(textDocument/documentSymbol) #{exception}") + request.reply_result(nil) + end else PuppetLanguageServer.log_message(:error, "Unknown RPC method #{request.rpc_method}") end diff --git a/lib/puppet-languageserver/providers.rb b/lib/puppet-languageserver/providers.rb index af5ee1cf..9c88636c 100644 --- a/lib/puppet-languageserver/providers.rb +++ b/lib/puppet-languageserver/providers.rb @@ -2,6 +2,7 @@ epp/validation_provider manifest/completion_provider manifest/definition_provider + manifest/document_symbol_provider manifest/validation_provider manifest/hover_provider puppetfile/r10k/module/base diff --git a/lib/puppet-languageserver/server_capabilities.rb b/lib/puppet-languageserver/server_capabilities.rb index 3258c3b3..fa138bf7 100644 --- a/lib/puppet-languageserver/server_capabilities.rb +++ b/lib/puppet-languageserver/server_capabilities.rb @@ -10,7 +10,8 @@ def self.capabilities 'resolveProvider' => true, 'triggerCharacters' => ['>', '$', '[', '='] }, - 'definitionProvider' => true + 'definitionProvider' => true, + 'documentSymbolProvider' => true } end end diff --git a/spec/languageserver/unit/puppet-languageserver/manifest/document_symbol_provider_spec.rb b/spec/languageserver/unit/puppet-languageserver/manifest/document_symbol_provider_spec.rb new file mode 100644 index 00000000..3bbf85a8 --- /dev/null +++ b/spec/languageserver/unit/puppet-languageserver/manifest/document_symbol_provider_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' + +RSpec::Matchers.define :be_document_symbol do |name, kind, start_line, start_char, end_line, end_char| + match do |actual| + actual['name'] == name && + actual['kind'] == kind && + actual['range']['start']['line'] == start_line && + actual['range']['start']['character'] == start_char && + actual['range']['end']['line'] == end_line && + actual['range']['end']['character'] == end_char + end + + failure_message do |actual| + "expected that symbol called '#{actual['name']}' of type '#{actual['kind']}' located at " + + "(#{actual['range']['start']['line']}, #{actual['range']['start']['character']}, " + + "#{actual['range']['end']['line']}, #{actual['range']['end']['character']}) would be " + + "a document symbol called '#{name}', of type '#{kind}' located at (#{start_line}, #{start_char}, #{end_line}, #{end_char})" + end + + description do + "be a document symbol called '#{name}' of type #{kind} located at #{start_line}, #{start_char}, #{end_line}, #{end_char}" + end +end + +describe 'PuppetLanguageServer::Manifest::DocumentSymbolProvider' do + let(:subject) { PuppetLanguageServer::Manifest::DocumentSymbolProvider } + + context 'with Puppet 4.0 and below', :if => Gem::Version.new(Puppet.version) < Gem::Version.new('5.0.0') do + describe '#extract_document_symbols' do + it 'should always return an empty array' do + content = <<-EOT + class foo { + user { 'alice': + } + } + EOT + result = subject.extract_document_symbols(content) + + expect(result).to eq([]) + end + end + end + + context 'with Puppet 5.0 and above', :if => Gem::Version.new(Puppet.version) >= Gem::Version.new('5.0.0') do + describe '#extract_document_symbols' do + it 'should find a class in the document root' do + content = "class foo {\n}" + result = subject.extract_document_symbols(content) + expect(result.count).to eq(1) + expect(result[0]).to be_document_symbol('foo', LanguageServer::SYMBOLKIND_CLASS, 0, 0, 1, 1) + end + + it 'should find a resource in the document root' do + content = "user { 'alice':\n}" + result = subject.extract_document_symbols(content) + + expect(result.count).to eq(1) + expect(result[0]).to be_document_symbol("user: 'alice'", LanguageServer::SYMBOLKIND_METHOD, 0, 0, 1, 1) + end + + it 'should find a single line class in the document root' do + content = "class foo(String $var1 = 'value1', String $var2 = 'value2') {\n}" + result = subject.extract_document_symbols(content) + + expect(result.count).to eq(1) + expect(result[0]).to be_document_symbol('foo', LanguageServer::SYMBOLKIND_CLASS, 0, 0, 1, 1) + expect(result[0]['children'].count).to eq(2) + expect(result[0]['children'][0]).to be_document_symbol('$var1', LanguageServer::SYMBOLKIND_PROPERTY, 0, 17, 0, 22) + expect(result[0]['children'][1]).to be_document_symbol('$var2', LanguageServer::SYMBOLKIND_PROPERTY, 0, 42, 0, 47) + end + + it 'should find a multi line class in the document root' do + content = "class foo(\n String $var1 = 'value1',\n String $var2 = 'value2',\n) {\n}" + result = subject.extract_document_symbols(content) + + expect(result.count).to eq(1) + expect(result[0]).to be_document_symbol('foo', LanguageServer::SYMBOLKIND_CLASS, 0, 0, 4, 1) + expect(result[0]['children'].count).to eq(2) + expect(result[0]['children'][0]).to be_document_symbol('$var1', LanguageServer::SYMBOLKIND_PROPERTY, 1, 9, 1, 14) + expect(result[0]['children'][1]).to be_document_symbol('$var2', LanguageServer::SYMBOLKIND_PROPERTY, 2, 9, 2, 14) + end + + it 'should find a simple resource in a class' do + content = "class foo {\n user { 'alice':\n }\n}" + result = subject.extract_document_symbols(content) + + expect(result.count).to eq(1) + expect(result[0]).to be_document_symbol('foo', LanguageServer::SYMBOLKIND_CLASS, 0, 0, 3, 1) + expect(result[0]['children'].count).to eq(1) + expect(result[0]['children'][0]).to be_document_symbol("user: 'alice'", LanguageServer::SYMBOLKIND_METHOD, 1, 2, 2, 3) + end + end + end +end