├── .dockerignore ├── .gitignore ├── .rubocop.yml ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── Dockerfile.development ├── Gemfile ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── benchmark ├── rubocop └── ruby_wc ├── bin ├── _guard-core ├── ci ├── console ├── guard ├── language_server-ruby ├── m ├── rubocop └── setup ├── circle.yml ├── docker-compose.ci.yml ├── docker-compose.override.yml ├── docker-compose.yml ├── exe └── language_server-ruby ├── language_server.gemspec ├── lib ├── language_server.rb └── language_server │ ├── completion_provider │ └── ad_hoc.rb │ ├── definition_provider │ └── ad_hoc.rb │ ├── file_store.rb │ ├── linter │ ├── rubocop.rb │ └── ruby_wc.rb │ ├── logger.rb │ ├── project.rb │ ├── project │ ├── node.rb │ └── parser.rb │ └── version.rb ├── package.json └── test ├── fixtures └── rubocop.yml ├── language_server ├── linter │ ├── rubocop_test.rb │ └── ruby_wc_test.rb ├── project │ └── parser_test.rb └── project_test.rb └── test_helper.rb /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .??* 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /vendor/bundle/ 11 | /node_modules/ 12 | /.rubocop-* 13 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - https://github.com/onk/onkcop/raw/v0.52.0.0/config/rubocop.yml 3 | 4 | AllCops: 5 | TargetRubyVersion: 2.5 6 | Exclude: 7 | - 'bin/**/*' 8 | 9 | # TODO: Remove this config after droping Ruby 2.2 support 10 | Layout/IndentHeredoc: 11 | Include: 12 | - 'test/**/*' 13 | 14 | Lint/AssignmentInCondition: 15 | Enabled: false 16 | 17 | Lint/HandleExceptions: 18 | Enabled: false 19 | 20 | Naming/HeredocDelimiterNaming: 21 | Enabled: false 22 | 23 | Style/BlockDelimiters: 24 | Enabled: false 25 | 26 | Style/MultilineBlockChain: 27 | Enabled: false 28 | 29 | Style/Semicolon: 30 | Enabled: false 31 | 32 | Metrics/AbcSize: 33 | Max: 50 34 | 35 | Metrics/BlockLength: 36 | Max: 30 37 | 38 | Metrics/MethodLength: 39 | Max: 40 40 | 41 | Metrics/ModuleLength: 42 | Enabled: false 43 | 44 | Metrics/LineLength: 45 | Max: 200 46 | Exclude: 47 | # To use power_assert, we must pub assertion in one line 48 | - 'test/**/*' 49 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ruby-lsc.commandWithArgs": ["docker-compose", "run", "--rm", "ruby-2-6"], 3 | "files.exclude": { 4 | "vendor": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## Not released 4 | 5 | ## 0.11.0 6 | 7 | - Remove rcodetools integration (for now) 8 | - Bump language_server-protocol 9 | 10 | ## 0.10.0 11 | 12 | - Add --experimental-features flag 13 | - Currently rubocop integration and adhoc provider are very unstable... 14 | 15 | ## 0.9.0 16 | 17 | - rubocop support #27 18 | 19 | ## 0.8.0 20 | 21 | - Use RubyVM::InstructionSequence.compile instead of open3 22 | 23 | ## 0.7.0 24 | 25 | - Add binstub bin/language_server-ruby 26 | - Stop using docker-sync for development 27 | - Bump language_server-protocol 28 | 29 | ## 0.6.0 30 | 31 | - Rename exe file name language_server -> language_server-ruby 32 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at mtsmfm@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.0.0-alpine 2 | 3 | RUN apk add --no-cache git 4 | 5 | WORKDIR /app 6 | 7 | COPY lib/language_server/version.rb /app/lib/language_server/ 8 | COPY Gemfile language_server.gemspec /app/ 9 | 10 | RUN bundle install --without development 11 | 12 | COPY lib /app/lib/ 13 | COPY exe /app/exe/ 14 | 15 | CMD ["bundle", "exec", "exe/language_server-ruby"] 16 | -------------------------------------------------------------------------------- /Dockerfile.development: -------------------------------------------------------------------------------- 1 | ARG RUBY_VERSION= 2 | FROM ruby:$RUBY_VERSION 3 | 4 | RUN apt-get update && apt-get install less -y 5 | RUN groupadd --gid 1000 ruby && useradd --uid 1000 --gid ruby --shell /bin/bash --create-home ruby 6 | RUN mkdir /app /vendor && chown -R ruby:ruby /app /vendor 7 | 8 | ENV LANG=C.UTF-8 \ 9 | BUNDLE_PATH=/vendor/bundle/$RUBY_VERSION \ 10 | BUNDLE_JOBS=4 11 | 12 | USER ruby 13 | WORKDIR /app 14 | CMD ["bin/language_server-ruby", "--experimental-features"] 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in language_server.gemspec 4 | gemspec 5 | 6 | gem "rubocop", "0.52.1" 7 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # More info at https://github.com/guard/guard#readme 2 | 3 | guard :minitest, all_after_pass: true do 4 | # with Minitest::Unit 5 | watch(%r{^test/(.*)\/?test_(.*)\.rb$}) 6 | watch(%r{^test/(.*)\/?(.*)_test\.rb$}) 7 | watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}test_#{m[2]}.rb" } 8 | watch(%r{^test/test_helper\.rb$}) { "test" } 9 | end 10 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Fumiaki MATSUSHIMA 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LanguageServer 2 | 3 | [Alpha quality] 4 | 5 | A Ruby Language Server implementation. 6 | 7 | ## Installation 8 | 9 | If you are using vscode, install [ruby-lsc](https://marketplace.visualstudio.com/items?itemName=mtsmfm.ruby-lsc) extension. 10 | 11 | ### Docker 12 | 13 | Simply you can pull from [docker hub](https://hub.docker.com/r/mtsmfm/language_server-ruby/) 14 | 15 | $ docker pull mtsmfm/language_server-ruby 16 | 17 | ### Ruby gem 18 | 19 | Add this line to your application's Gemfile: 20 | 21 | ```ruby 22 | gem 'language_server' 23 | ``` 24 | 25 | And then execute: 26 | 27 | $ bundle 28 | 29 | Or install it yourself as: 30 | 31 | $ gem install language_server 32 | 33 | ## Usage 34 | 35 | Currently, language_server-ruby supports only stdio to communicate. 36 | 37 | ### Docker 38 | 39 | $ docker run mtsmfm/language_server-ruby 40 | 41 | ### Ruby gem 42 | 43 | $ language_server-ruby 44 | 45 | ## Development 46 | 47 | ### Requirements 48 | 49 | #### docker, docker-compose 50 | 51 | https://docs.docker.com/engine/installation 52 | 53 | ### Setup 54 | 55 | $ git clone https://github.com/mtsmfm/language_server-ruby.git 56 | $ cd language_server-ruby 57 | $ docker-compose run app bin/setup 58 | 59 | ### Run test 60 | 61 | You can run test via m: 62 | 63 | $ docker-compose run app bin/m 64 | $ docker-compose run ruby-3-0 bin/m 65 | 66 | Or run via guard: 67 | 68 | $ docker-compose run app bin/guard 69 | $ docker-compose run ruby-3-0 bin/m 70 | 71 | ## Contributing 72 | 73 | Bug reports and pull requests are welcome on GitHub at https://github.com/mtsmfm/language_server-ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 74 | 75 | 76 | ## License 77 | 78 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 79 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | t.warning = false 9 | end 10 | 11 | task :default => :test 12 | -------------------------------------------------------------------------------- /benchmark/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "benchmark/ips" 5 | require "language_server" 6 | 7 | error_code = <<-EOS 8 | require "foo 9 | if a == "\\n" 10 | EOS 11 | warn_code = <<-EOS 12 | a = 1 13 | EOS 14 | valid_code = File.read(__FILE__) 15 | 16 | require "pry-byebug" 17 | 18 | LanguageServer::Linter::Rubocop.new(valid_code).call 19 | Benchmark.ips do |x| 20 | x.report(`git log --pretty=oneline --abbrev-commit -n 1`) do 21 | [error_code, warn_code, valid_code].each do |code| 22 | LanguageServer::Linter::Rubocop.new(code).call 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /benchmark/ruby_wc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "benchmark/ips" 5 | require "language_server" 6 | 7 | error_code = <<-EOS 8 | require "foo 9 | if a == "\\n" 10 | EOS 11 | warn_code = <<-EOS 12 | a = 1 13 | EOS 14 | valid_code = File.read(__FILE__) 15 | 16 | Benchmark.ips do |x| 17 | x.report do 18 | [error_code, warn_code, valid_code].each do |code| 19 | LanguageServer::Linter::RubyWC.new(code).call 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /bin/_guard-core: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application '_guard-core' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require "pathname" 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require "rubygems" 15 | require "bundler/setup" 16 | 17 | load Gem.bin_path("guard", "_guard-core") 18 | -------------------------------------------------------------------------------- /bin/ci: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -ex 4 | 5 | bin/setup 6 | bin/rubocop 7 | bin/m 8 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "language_server" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | def project 10 | @project ||= LanguageServer::Project.new( 11 | LanguageServer::FileStore.new(load_paths: $LOAD_PATH, remote_root: ENV['LANGUAGE_SERVER_RUBY_REMOTE_ROOT'], local_root: Dir.getwd) 12 | ) 13 | end 14 | 15 | # (If you use this, don't forget to add pry to your Gemfile!) 16 | require "pry-byebug" 17 | Pry.start 18 | -------------------------------------------------------------------------------- /bin/guard: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'guard' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require "pathname" 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require "rubygems" 15 | require "bundler/setup" 16 | 17 | load Gem.bin_path("guard", "guard") 18 | -------------------------------------------------------------------------------- /bin/language_server-ruby: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["BUNDLE_GEMFILE"] ||= File.join(__dir__, "..", "Gemfile") 4 | 5 | require "rubygems" 6 | require "bundler/setup" 7 | 8 | load File.join(__dir__, "..", "exe", "language_server-ruby") 9 | -------------------------------------------------------------------------------- /bin/m: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'm' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require "pathname" 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require "rubygems" 15 | require "bundler/setup" 16 | 17 | load Gem.bin_path("m", "m") 18 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'rubocop' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require "pathname" 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require "rubygems" 15 | require "bundler/setup" 16 | 17 | load Gem.bin_path("rubocop", "rubocop") 18 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | ruby-3-0: &test 4 | docker: 5 | - image: tmaier/docker-compose 6 | environment: 7 | COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml 8 | steps: 9 | - checkout 10 | - setup_remote_docker 11 | - run: 12 | name: setup 13 | command: | 14 | set -x 15 | docker info 16 | docker volume create project 17 | docker create -v project:/app --name project busybox chown -R 1000:1000 /app 18 | docker cp . project:/app 19 | docker start project 20 | docker-compose build $CIRCLE_JOB 21 | - run: docker-compose run $CIRCLE_JOB bin/ci 22 | ruby-2-7: 23 | <<: *test 24 | ruby-2-6: 25 | <<: *test 26 | ruby-2-5: 27 | <<: *test 28 | test-docker-build: 29 | docker: 30 | - image: docker 31 | steps: 32 | - checkout 33 | - setup_remote_docker: 34 | version: 17.05.0-ce 35 | - run: docker build . 36 | workflows: 37 | version: 2 38 | test: 39 | jobs: 40 | - ruby-3-0 41 | - ruby-2-7 42 | - ruby-2-6 43 | - ruby-2-5 44 | - test-docker-build 45 | -------------------------------------------------------------------------------- /docker-compose.ci.yml: -------------------------------------------------------------------------------- 1 | version: "3.0" 2 | services: 3 | ruby-3-0: &latest 4 | volumes: 5 | - project:/app 6 | ruby-2-7: 7 | <<: *latest 8 | ruby-2-6: 9 | <<: *latest 10 | ruby-2-5: 11 | <<: *latest 12 | volumes: 13 | project: 14 | external: true 15 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | version: "3.0" 2 | services: 3 | ruby-3-0: &latest 4 | volumes: 5 | - .:/app:cached 6 | - $HOME/.gitconfig:/home/ruby/.gitconfig:ro 7 | - $HOME/.ssh:/home/ruby/.ssh:ro 8 | - $HOME/.gem:/home/ruby/.gem 9 | ruby-2-6: 10 | <<: *latest 11 | ruby-2-7: 12 | <<: *latest 13 | ruby-2-5: 14 | <<: *latest 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.0" 2 | services: 3 | ruby-3-0: &latest 4 | build: &build 5 | context: . 6 | dockerfile: Dockerfile.development 7 | args: 8 | RUBY_VERSION: 3.0.0 9 | environment: 10 | LANGUAGE_SERVER_RUBY_REMOTE_ROOT: $PWD 11 | volumes: 12 | - vendor:/vendor 13 | - home:/home/ruby 14 | ruby-2-7: 15 | <<: *latest 16 | build: 17 | <<: *build 18 | args: 19 | RUBY_VERSION: 2.7.2 20 | ruby-2-6: 21 | <<: *latest 22 | build: 23 | <<: *build 24 | args: 25 | RUBY_VERSION: 2.6.6 26 | ruby-2-5: 27 | <<: *latest 28 | build: 29 | <<: *build 30 | args: 31 | RUBY_VERSION: 2.5.8 32 | volumes: 33 | home: 34 | vendor: 35 | -------------------------------------------------------------------------------- /exe/language_server-ruby: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | if Dir.exist?(File.join(__dir__, "..", ".git")) 4 | $LOAD_PATH << File.join(__dir__, "..", "lib") 5 | end 6 | 7 | require "language_server" 8 | require "optparse" 9 | 10 | options = { experimental_features_enabled: false } 11 | opt_parser = OptionParser.new do |opts| 12 | opts.banner = "Usage: language_server-ruby [options]" 13 | 14 | opts.on("--experimental-features", "Enable experimental features") do |e| 15 | options[:experimental_features_enabled] = e 16 | end 17 | 18 | opts.on("-v", "--version", "Show version") do 19 | puts LanguageServer::VERSION 20 | exit 21 | end 22 | end 23 | begin 24 | opt_parser.parse! 25 | rescue OptionParser::InvalidOption 26 | puts opt_parser 27 | exit 1 28 | end 29 | 30 | LanguageServer.run(options) 31 | -------------------------------------------------------------------------------- /language_server.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "language_server/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "language_server" 8 | spec.version = LanguageServer::VERSION 9 | spec.authors = ["Fumiaki MATSUSHIMA"] 10 | spec.email = ["mtsmfm@gmail.com"] 11 | 12 | spec.summary = "A Ruby Language Server implementation" 13 | spec.description = "A Ruby Language Server implementation" 14 | spec.homepage = "https://github.com/mtsmfm/language_server-ruby" 15 | spec.license = "MIT" 16 | 17 | spec.files = begin 18 | `git ls-files -z`.split("\x0").reject do |f| 19 | f.match(%r{^(test|spec|features)/}) 20 | end 21 | rescue 22 | Dir.glob("**/*").reject { |path| File.directory?(path) } 23 | end 24 | spec.bindir = "exe" 25 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 26 | spec.require_paths = ["lib"] 27 | 28 | spec.required_ruby_version = ">= 2.5.0" 29 | 30 | spec.add_dependency "language_server-protocol", "3.7.0.0" 31 | 32 | spec.add_development_dependency "awesome_print" 33 | spec.add_development_dependency "benchmark-ips" 34 | spec.add_development_dependency "bundler" 35 | spec.add_development_dependency "guard" 36 | spec.add_development_dependency "guard-minitest" 37 | spec.add_development_dependency "m" 38 | spec.add_development_dependency "minitest", "~> 5.0" 39 | spec.add_development_dependency "minitest-color" 40 | spec.add_development_dependency "minitest-power_assert" 41 | spec.add_development_dependency "pry-byebug" 42 | spec.add_development_dependency "rake", "~> 12.2" 43 | end 44 | -------------------------------------------------------------------------------- /lib/language_server.rb: -------------------------------------------------------------------------------- 1 | require "language_server/version" 2 | require "language_server/logger" 3 | require "language_server/protocol" 4 | require "language_server/linter/ruby_wc" 5 | require "language_server/linter/rubocop" 6 | require "language_server/completion_provider/ad_hoc" 7 | require "language_server/definition_provider/ad_hoc" 8 | require "language_server/file_store" 9 | require "language_server/project" 10 | 11 | require "json" 12 | 13 | module LanguageServer 14 | class << self 15 | attr_accessor :options 16 | 17 | def run(options) 18 | self.options = options 19 | 20 | writer = Protocol::Transport::Stdio::Writer.new 21 | reader = Protocol::Transport::Stdio::Reader.new 22 | variables = {} 23 | 24 | class << writer 25 | def respond(id:, result:) 26 | write(id: id, result: result) 27 | 28 | LanguageServer.logger.debug("Respond: id: #{id}, result: #{JSON.pretty_generate(result)}") 29 | end 30 | 31 | def notify(method:, params: {}) 32 | write(method: method, params: params) 33 | 34 | LanguageServer.logger.debug("Notify: method: #{method}, params: #{JSON.pretty_generate(params)}") 35 | end 36 | end 37 | 38 | reader.read do |request| 39 | logger.debug("Receive: #{JSON.pretty_generate(request)}") 40 | 41 | method = request[:method].to_sym 42 | 43 | subscriber = subscribers[method] 44 | 45 | if subscriber 46 | keys = subscriber.parameters.map(&:last) 47 | result = subscriber.call( 48 | **{ 49 | request: request, notifier: writer.method(:notify), variables: variables 50 | }.merge(variables).select { |k, _| keys.include?(k) }, 51 | ) 52 | 53 | if request[:id] 54 | writer.respond(id: request[:id], result: result) 55 | end 56 | else 57 | logger.debug("Ignore: #{method}") 58 | end 59 | end 60 | end 61 | 62 | def rubocop_enabled? 63 | options[:experimental_features_enabled] 64 | end 65 | 66 | def adhoc_enabled? 67 | options[:experimental_features_enabled] 68 | end 69 | 70 | def subscribers 71 | @subscribers ||= {} 72 | end 73 | 74 | def on(method, &callback) 75 | subscribers[method] = callback 76 | end 77 | end 78 | 79 | on :initialize do |request:, variables:| 80 | variables[:file_store] = FileStore.new(load_paths: $LOAD_PATH, remote_root: request[:params][:rootPath], local_root: Dir.getwd) 81 | variables[:project] = 82 | if LanguageServer.adhoc_enabled? 83 | variables[:project] = Project.new(variables[:file_store]) 84 | end 85 | 86 | Protocol::Interface::InitializeResult.new( 87 | capabilities: Protocol::Interface::ServerCapabilities.new( 88 | text_document_sync: Protocol::Interface::TextDocumentSyncOptions.new( 89 | change: Protocol::Constant::TextDocumentSyncKind::FULL, 90 | ), 91 | completion_provider: Protocol::Interface::CompletionOptions.new( 92 | resolve_provider: true, 93 | trigger_characters: %w[.], 94 | ), 95 | definition_provider: LanguageServer.adhoc_enabled?, 96 | ), 97 | ) 98 | end 99 | 100 | on :shutdown do 101 | exit 102 | end 103 | 104 | on :"textDocument/didChange" do |request:, notifier:, file_store:, project:| 105 | uri = request[:params][:textDocument][:uri] 106 | text = request[:params][:contentChanges][0][:text] 107 | file_store.cache(uri, text) 108 | project.recalculate_result(uri) if LanguageServer.adhoc_enabled? 109 | 110 | diagnostics = Linter::RubyWC.new(text).call 111 | diagnostics += Linter::Rubocop.new(text).call if LanguageServer.rubocop_enabled? 112 | 113 | diagnostics = diagnostics.map do |error| 114 | Protocol::Interface::Diagnostic.new( 115 | message: error.message, 116 | severity: error.warning? ? Protocol::Constant::DiagnosticSeverity::WARNING : Protocol::Constant::DiagnosticSeverity::ERROR, 117 | range: Protocol::Interface::Range.new( 118 | start: Protocol::Interface::Position.new( 119 | line: error.line_num, 120 | character: error.characters.min, 121 | ), 122 | end: Protocol::Interface::Position.new( 123 | line: error.line_num, 124 | character: error.characters.max, 125 | ), 126 | ), 127 | ) 128 | end 129 | 130 | notifier.call( 131 | method: :"textDocument/publishDiagnostics", 132 | params: Protocol::Interface::PublishDiagnosticsParams.new( 133 | uri: uri, 134 | diagnostics: diagnostics, 135 | ), 136 | ) 137 | end 138 | 139 | on :"textDocument/completion" do |request:, project:| 140 | uri = request[:params][:textDocument][:uri] 141 | line, character = request[:params][:position].fetch_values(:line, :character).map(&:to_i) 142 | 143 | if LanguageServer.adhoc_enabled? 144 | [ 145 | CompletionProvider::AdHoc.new(uri: uri, line: line, character: character, project: project), 146 | ].flat_map(&:call) 147 | end 148 | end 149 | 150 | on :"textDocument/definition" do |request:, project:| 151 | uri = request[:params][:textDocument][:uri] 152 | line, character = request[:params][:position].fetch_values(:line, :character).map(&:to_i) 153 | 154 | if LanguageServer.adhoc_enabled? 155 | [ 156 | DefinitionProvider::AdHoc.new(uri: uri, line: line, character: character, project: project), 157 | ].flat_map(&:call) 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /lib/language_server/completion_provider/ad_hoc.rb: -------------------------------------------------------------------------------- 1 | require "language_server/project" 2 | 3 | module LanguageServer 4 | module CompletionProvider 5 | class AdHoc 6 | def initialize(uri:, line:, character:, project:) 7 | @uri = uri 8 | @line = line 9 | @character = character 10 | @project = project 11 | end 12 | 13 | def call 14 | (project.constants(uri: uri, line: line, character: character).map { |c| 15 | Protocol::Interface::CompletionItem.new( 16 | label: c.name, 17 | detail: c.full_name, 18 | documentation: "#{c.remote_path}##{c.lineno}", 19 | kind: Protocol::Constant::CompletionItemKind::ENUM, 20 | ) 21 | } + 22 | project.classes(uri: uri, line: line, character: character).map { |c| 23 | Protocol::Interface::CompletionItem.new( 24 | label: c.name, 25 | detail: c.full_name, 26 | documentation: "#{c.remote_path}##{c.lineno}", 27 | kind: Protocol::Constant::CompletionItemKind::CLASS, 28 | ) 29 | } + 30 | project.modules(uri: uri, line: line, character: character).map { |m| 31 | Protocol::Interface::CompletionItem.new( 32 | label: m.name, 33 | detail: m.full_name, 34 | documentation: "#{m.remote_path}##{m.lineno}", 35 | kind: Protocol::Constant::CompletionItemKind::MODULE, 36 | ) 37 | }).uniq(&:label) 38 | end 39 | 40 | private 41 | 42 | attr_reader :uri, :line, :character, :project 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/language_server/definition_provider/ad_hoc.rb: -------------------------------------------------------------------------------- 1 | module LanguageServer 2 | module DefinitionProvider 3 | class AdHoc 4 | def initialize(uri:, line:, character:, project:) 5 | @uri = uri 6 | @line = line 7 | @character = character 8 | @project = project 9 | end 10 | 11 | def call 12 | project.find_definitions(uri: uri, line: line, character: character).map do |n| 13 | Protocol::Interface::Location.new( 14 | uri: "file://#{n.remote_path}", 15 | range: Protocol::Interface::Range.new( 16 | start: Protocol::Interface::Position.new( 17 | line: n.lines.begin, 18 | character: 0, 19 | ), 20 | end: Protocol::Interface::Position.new( 21 | line: n.lines.end, 22 | character: 0, 23 | ), 24 | ), 25 | ) 26 | end 27 | end 28 | 29 | private 30 | 31 | attr_reader :uri, :line, :character, :project 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/language_server/file_store.rb: -------------------------------------------------------------------------------- 1 | require "uri" 2 | 3 | module LanguageServer 4 | class FileStore 5 | include Enumerable 6 | 7 | class FilePath 8 | class << self 9 | def from_remote_uri(remote_root:, local_root:, remote_uri:) 10 | new(remote_root: remote_root, local_root: local_root, local_path: URI(remote_uri).path.sub(remote_root, local_root)) 11 | end 12 | end 13 | 14 | attr_reader :local_root, :remote_root, :local_path 15 | 16 | def initialize(remote_root:, local_root:, local_path:) 17 | @remote_root = remote_root 18 | @local_root = local_root 19 | @local_path = local_path 20 | end 21 | 22 | def remote_path 23 | @remote_path ||= local_path.sub(local_root, remote_root) 24 | end 25 | 26 | def eql?(other) 27 | self.class == other.class && remote_path == other.remote_path 28 | end 29 | 30 | def ==(other) 31 | eql?(other) 32 | end 33 | 34 | def hash 35 | self.remote_path.hash 36 | end 37 | end 38 | 39 | def initialize(load_paths: [], remote_root: Dir.getwd, local_root: Dir.getwd) 40 | @load_paths = load_paths 41 | @remote_root = remote_root 42 | @local_root = local_root 43 | end 44 | 45 | def cache(remote_uri, content) 46 | cache_store[path_from_remote_uri(remote_uri)] = content 47 | end 48 | 49 | def path_from_remote_uri(remote_uri) 50 | FilePath.from_remote_uri(local_root: local_root, remote_root: remote_root, remote_uri: remote_uri) 51 | end 52 | 53 | def read(path) 54 | if exists_on_cache?(path) 55 | read_from_cache(path) 56 | else 57 | read_from_local(path) 58 | end 59 | end 60 | 61 | def read_remote_uri(remote_uri) 62 | read(path_from_remote_uri(remote_uri)) 63 | end 64 | 65 | def each(&block) 66 | all_paths.each do |path| 67 | yield(read(path), path) 68 | end 69 | end 70 | 71 | private 72 | 73 | attr_reader :load_paths, :remote_root, :local_root 74 | 75 | def all_paths 76 | (cache_store.keys + load_paths.flat_map { |path| 77 | Dir.glob(File.join(path, "**", "*.rb")) 78 | }.map { |path| 79 | FilePath.new(local_root: local_root, remote_root: remote_root, local_path: path) 80 | }).uniq 81 | end 82 | 83 | def exists_on_cache?(path) 84 | cache_store.has_key?(path) 85 | end 86 | 87 | def read_from_cache(path) 88 | cache_store[path] 89 | end 90 | 91 | def read_from_local(path) 92 | File.read(path.local_path) 93 | end 94 | 95 | def cache_store 96 | @cache_store ||= {} 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/language_server/linter/rubocop.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require "rubocop" 3 | rescue LoadError 4 | end 5 | 6 | module LanguageServer 7 | module Linter 8 | class Rubocop 9 | def initialize(source, config_path = "") 10 | @source = source 11 | @config_path = config_path 12 | end 13 | 14 | def call 15 | return [] unless defined? ::RuboCop 16 | args = [] 17 | args += ["--config", @config_path] if @config_path != "" 18 | args += ["--format", "json", "--stdin", "lsp_buffer.rb"] 19 | o = nil 20 | begin 21 | $stdin = StringIO.new(@source) 22 | $stdout = StringIO.new 23 | config_store = ::RuboCop::ConfigStore.new 24 | options, paths = ::RuboCop::Options.new.parse(args) 25 | config_store.options_config = options[:config] if options[:config] 26 | runner = ::RuboCop::Runner.new(options, config_store) 27 | runner.run(paths) 28 | o = $stdout.string 29 | ensure 30 | $stdin = STDIN 31 | $stdout = STDOUT 32 | end 33 | return [] unless o 34 | JSON. 35 | parse(o)["files"].map { |v| v["offenses"] }. 36 | flatten. 37 | map { |v| Error.new(line_num: v["location"]["line"].to_i - 1, message: v["message"], type: convert_type(v["severity"])) } 38 | end 39 | 40 | private 41 | 42 | def convert_type(type) 43 | case type 44 | when "refactor" then "warning" 45 | when "convention" then "warning" 46 | when "warning" then "warning" 47 | when "error" then "error" 48 | when "fatal" then "error" 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/language_server/linter/ruby_wc.rb: -------------------------------------------------------------------------------- 1 | require "open3" 2 | 3 | module LanguageServer 4 | module Linter 5 | class Error 6 | attr_reader :line_num, :characters, :message, :type 7 | 8 | def initialize(line_num:, characters: 0..0, message:, type:) 9 | @line_num = line_num 10 | @characters = characters 11 | @message = message 12 | @type = type 13 | end 14 | 15 | def warning? 16 | @type == "warning" 17 | end 18 | 19 | def ==(other) 20 | line_num == other.line_num && characters == other.characters && message == other.message 21 | end 22 | end 23 | 24 | class RubyWC 25 | def initialize(source) 26 | @source = source 27 | end 28 | 29 | def call 30 | error_message.scan(/.+:(\d+):\s*(.+?)[,:]\s(.+)/).map do |line_num, type, message| 31 | Error.new(line_num: line_num.to_i - 1, characters: get_characters_from_error_message(error_message, line_num.to_i - 1), message: message, type: type) 32 | end 33 | end 34 | 35 | private 36 | 37 | def error_message 38 | _, err, = Open3.capture3("ruby -wc", stdin_data: @source) 39 | err 40 | end 41 | 42 | def get_characters_from_error_message(error_message, line_index) 43 | error_mark_included_line = error_message.split("\n")[2] 44 | 45 | if !error_mark_included_line.nil? && character_start = error_mark_included_line.index("^") 46 | Range.new(character_start, character_start + 1) 47 | else 48 | Range.new(0, @source.split("\n")[line_index].length - 1) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/language_server/logger.rb: -------------------------------------------------------------------------------- 1 | require "logger" 2 | 3 | module LanguageServer 4 | class << self 5 | def logger 6 | @logger ||= ::Logger.new(STDERR, formatter: Formatter.new) 7 | end 8 | end 9 | 10 | class Formatter 11 | RESET = "\e[0m".freeze 12 | RED = "\e[31m".freeze 13 | YELLOW = "\e[33m".freeze 14 | 15 | def call(severity, *rest) 16 | msg = default_message(severity, *rest) 17 | case severity 18 | when "ERROR" 19 | RED + msg + RESET 20 | when "WARN" 21 | YELLOW + msg + RESET 22 | else 23 | msg 24 | end 25 | end 26 | 27 | private 28 | 29 | def default_message(*args) 30 | default_formatter.call(*args) 31 | end 32 | 33 | def default_formatter 34 | @default_formatter ||= ::Logger::Formatter.new 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/language_server/project.rb: -------------------------------------------------------------------------------- 1 | require "language_server/project/parser" 2 | 3 | module LanguageServer 4 | class Project 5 | def initialize(file_store) 6 | @file_store = file_store 7 | @result_store = {} 8 | 9 | fetch_result 10 | end 11 | 12 | def find_definitions(uri:, line:, character:) 13 | result = result_store[file_store.path_from_remote_uri(uri)] 14 | 15 | ref = result.refs.select { |node| node.lines.include?(line) && node.characters.include?(character) }.min_by { |node| node.characters.size } 16 | 17 | return [] unless ref 18 | 19 | lazy_modules.select { |n| n.full_name == ref.full_name }.force + lazy_classes.select { |n| n.full_name == ref.full_name }.force 20 | end 21 | 22 | def recalculate_result(uri) 23 | path = file_store.path_from_remote_uri(uri) 24 | calculate(file_store.read(path), path) 25 | end 26 | 27 | def constants(uri: nil, line: nil, character: nil) 28 | node = find_nearest_node(uri: uri, line: line, character: character) if uri && line && character 29 | 30 | lazy_constants.select { |n| n.names[0..-2] == Array(node && node.names).first(n.names.size - 1) }.force 31 | end 32 | 33 | def modules(uri: nil, line: nil, character: nil) 34 | node = find_nearest_node(uri: uri, line: line, character: character) if uri && line && character 35 | 36 | lazy_modules.select { |n| n.names[0..-2] == Array(node && node.names).first(n.names.size - 1) }.force 37 | end 38 | 39 | def classes(uri: nil, line: nil, character: nil) 40 | node = find_nearest_node(uri: uri, line: line, character: character) if uri && line && character 41 | 42 | lazy_classes.select { |n| n.names[0..-2] == Array(node && node.names).first(n.names.size - 1) }.force 43 | end 44 | 45 | private 46 | 47 | attr_reader :file_store, :result_store 48 | 49 | def lazy_constants 50 | result_store.each_value.lazy.flat_map(&:constants) 51 | end 52 | 53 | def lazy_modules 54 | result_store.each_value.lazy.flat_map(&:modules) 55 | end 56 | 57 | def lazy_classes 58 | result_store.each_value.lazy.flat_map(&:classes) 59 | end 60 | 61 | def fetch_result 62 | file_store.each { |content, path| 63 | calculate(content, path) 64 | } 65 | end 66 | 67 | def find_nearest_node(uri:, line:, character:) 68 | result = result_store[file_store.path_from_remote_uri(uri)] 69 | 70 | (result.modules + result.classes).select { |node| node.lines.include?(line) }.min_by { |node| node.lines.size } 71 | end 72 | 73 | def calculate(content, path) 74 | begin 75 | result = Parser.parse(content, path) 76 | rescue => e 77 | LanguageServer.logger.warn("Parse failed (local: #{path.local_path}, remote: #{path.remote_path})") 78 | LanguageServer.logger.warn(e) 79 | end 80 | 81 | result_store[path] = result if result 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/language_server/project/node.rb: -------------------------------------------------------------------------------- 1 | module LanguageServer 2 | class Project 3 | class Node 4 | class << self 5 | def attributes(*attrs) 6 | attr_accessor(*attrs) 7 | attribute_names.concat(attrs) 8 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 9 | def initialize(#{attribute_names.map { |n| "#{n}:" }.join(",")}) 10 | #{attribute_names.map { |n| 11 | "@#{n} = #{n}" 12 | }.join("\n")} 13 | end 14 | RUBY 15 | end 16 | 17 | def attribute_names 18 | @attribute_names ||= superclass.respond_to?(:attribute_names) ? superclass.attribute_names.dup : [] 19 | end 20 | end 21 | 22 | attributes :lineno, :character, :path 23 | 24 | def remote_path 25 | path.remote_path 26 | end 27 | 28 | def local_path 29 | path.local_path 30 | end 31 | 32 | def eql?(other) 33 | other.instance_of?(self.class) && attributes == other.attributes 34 | end 35 | 36 | def ==(other) 37 | eql?(other) 38 | end 39 | 40 | def hash 41 | self.attributes.hash 42 | end 43 | 44 | def attributes 45 | self.class.attribute_names.map { |a| 46 | [a, public_send(a)] 47 | }.to_h 48 | end 49 | end 50 | 51 | class Constant < Node 52 | attributes :namespaces, :name, :value 53 | 54 | def unshift_namespace(class_or_module) 55 | namespaces.unshift(class_or_module) 56 | end 57 | 58 | def names 59 | namespaces.flat_map(&:names) + [name] 60 | end 61 | 62 | def full_name 63 | names.join("::") 64 | end 65 | end 66 | 67 | class LiteralValue < Node 68 | attributes :value 69 | end 70 | 71 | class Module < Node 72 | attributes :constant, :children 73 | 74 | %i[name namespaces full_name names].each do |m| 75 | define_method(m) { constant.__send__(m) } 76 | end 77 | 78 | def unshift_namespace(class_or_module) 79 | constant.unshift_namespace(class_or_module) 80 | end 81 | 82 | def lines 83 | constant.lineno..lineno 84 | end 85 | 86 | def inspect 87 | "" 88 | end 89 | end 90 | 91 | class Class < Module 92 | attributes :superclass 93 | 94 | def inspect 95 | "" 96 | end 97 | end 98 | 99 | class VarRef < Node 100 | attributes :node 101 | 102 | def lines 103 | node.lineno..node.lineno 104 | end 105 | 106 | def characters 107 | node.character..(character - 1) 108 | end 109 | 110 | def unshift_namespace(class_or_module) 111 | node.unshift_namespace(class_or_module) if node.respond_to?(:unshift_namespace) 112 | end 113 | 114 | def names 115 | node.names 116 | end 117 | 118 | def name 119 | node.name 120 | end 121 | 122 | def full_name 123 | names.join("::") 124 | end 125 | 126 | def inspect 127 | "" 128 | end 129 | end 130 | 131 | class ConstPathRef < Node 132 | attributes :nodes 133 | 134 | def lines 135 | (nodes.first.lineno)..(nodes.last.lineno) 136 | end 137 | 138 | def characters 139 | (nodes.first.characters.begin)..(nodes.last.character) 140 | end 141 | 142 | def unshift_namespace(class_or_module) 143 | nodes.first.unshift_namespace(class_or_module) 144 | end 145 | 146 | def name 147 | nodes.last.name 148 | end 149 | 150 | def names 151 | nodes.flat_map(&:names) 152 | end 153 | 154 | def full_name 155 | names.join("::") 156 | end 157 | 158 | def inspect 159 | "" 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /lib/language_server/project/parser.rb: -------------------------------------------------------------------------------- 1 | require "language_server/project/node" 2 | require "ripper" 3 | 4 | module LanguageServer 5 | class Project 6 | class Parser < Ripper 7 | class Result 8 | attr_reader :constants, :classes, :modules, :refs 9 | 10 | def initialize 11 | @constants = [] 12 | @classes = [] 13 | @modules = [] 14 | @refs = [] 15 | end 16 | end 17 | 18 | class << self 19 | def parse(src, path = nil) 20 | new(src, path).tap(&:parse).result 21 | end 22 | end 23 | 24 | attr_reader :result 25 | 26 | def initialize(src, path) 27 | super(src, path && path.remote_path) 28 | 29 | @path = path 30 | @result = Result.new 31 | end 32 | 33 | private 34 | 35 | alias_method :character, :column 36 | 37 | attr_reader :path 38 | 39 | def lineno 40 | # Language Server Protocol's lineno is zero origin 41 | super - 1 42 | end 43 | 44 | def on_var_ref(node) 45 | if node.instance_of?(Constant) 46 | build_node(VarRef, node: node).tap do |n| 47 | result.refs << n 48 | end 49 | else 50 | node 51 | end 52 | end 53 | 54 | def on_const_path_ref(*nodes) 55 | if nodes.all? { |n| [Constant, ConstPathRef, VarRef].include?(n.class) } 56 | build_node(ConstPathRef, nodes: nodes).tap do |n| 57 | result.refs << n 58 | end 59 | else 60 | nodes 61 | end 62 | end 63 | 64 | def on_const(name) 65 | build_node(Constant, namespaces: [], name: name, value: nil) 66 | end 67 | 68 | def on_def(*args) 69 | args.flatten.compact 70 | end 71 | 72 | def on_int(value) 73 | build_node(LiteralValue, value: value.to_i) 74 | end 75 | 76 | def on_stmts_add(*args) 77 | args.flatten.compact 78 | end 79 | 80 | def on_assign(left, right) 81 | result.constants << left if left.instance_of?(Constant) 82 | 83 | left.value = right if left.respond_to?(:value) # TODO: remove this condition 84 | left 85 | end 86 | 87 | def on_module(constant, children) 88 | cn = children.select { |child| child.respond_to?(:unshift_namespace) } 89 | 90 | build_node(Module, constant: constant, children: cn).tap do |m| 91 | result.modules << m 92 | cn.each { |child| child.unshift_namespace(m) } 93 | end 94 | end 95 | 96 | def on_class(constant, superclass, children) 97 | cn = children.select { |child| child.respond_to?(:unshift_namespace) } 98 | 99 | build_node(Class, constant: constant, superclass: superclass, children: cn).tap do |c| 100 | result.classes << c 101 | cn.each { |child| child.unshift_namespace(c) } 102 | end 103 | end 104 | 105 | def build_node(klass, **args) 106 | klass.new(**{ lineno: lineno, character: character, path: path }.merge(args)) 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/language_server/version.rb: -------------------------------------------------------------------------------- 1 | module LanguageServer 2 | VERSION = "0.11.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@types/es6-promise": "^0.0.32", 4 | "@types/node": "^7.0.18", 5 | "handlebars": "^4.0.8", 6 | "isomorphic-fetch": "^2.2.1", 7 | "ts-node": "^3.0.4", 8 | "typescript": "^2.3.2" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Lint/UselessAssignment: 5 | Enabled: true 6 | StringLiterals: 7 | EnforcedStyle: single_quotes 8 | -------------------------------------------------------------------------------- /test/language_server/linter/rubocop_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module LanguageServer::Linter 4 | class RubocopTest < Minitest::Test 5 | def setup 6 | @config_path = File.expand_path("./../../../fixtures/rubocop.yml", __FILE__) 7 | end 8 | 9 | def test_fatal_error 10 | source = 'require "foo' 11 | actual = Rubocop.new(source, @config_path).call 12 | assert do 13 | actual.first == Error.new(line_num: 0, message: "Lint/Syntax: unterminated string meets end of file\n(Using Ruby 2.1 parser; configure using `TargetRubyVersion` parameter, under `AllCops`)", type: "error") 14 | end 15 | end 16 | 17 | def test_warning_error 18 | source = "a = 'a'" 19 | actual = Rubocop.new(source, @config_path).call 20 | assert do 21 | actual.first == Error.new(line_num: 0, message: "Lint/UselessAssignment: Useless assignment to variable - `a`.", type: "warning") 22 | end 23 | end 24 | 25 | def test_convention_error 26 | source = 'require "foo"' 27 | actual = Rubocop.new(source, @config_path).call 28 | assert do 29 | actual.first == Error.new(line_num: 0, message: "Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.", type: "warning") 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/language_server/linter/ruby_wc_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module LanguageServer::Linter 4 | class RubyWCTest < Minitest::Test 5 | def test_error 6 | linter = RubyWC.new(<<-EOS.strip_heredoc) 7 | require "foo 8 | if a == "\\n" 9 | EOS 10 | 11 | # :2: syntax error, unexpected $undefined, expecting end-of-input 12 | # if a == "\n" 13 | # ^ 14 | 15 | characters = 16 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.5.0") 17 | 9..10 18 | else 19 | 10..11 20 | end 21 | 22 | message = 23 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.6.0") 24 | "unexpected backslash, expecting end-of-input" 25 | else 26 | "unexpected $undefined, expecting end-of-input" 27 | end 28 | 29 | assert { 30 | linter.call == [Error.new(line_num: 1, characters: characters, message: message, type: "syntax error")] 31 | } 32 | end 33 | 34 | def test_warn 35 | linter = RubyWC.new(<<-EOS.strip_heredoc) 36 | a = 1 37 | EOS 38 | 39 | assert { 40 | linter.call == [Error.new(line_num: 0, characters: 0..4, message: "assigned but unused variable - a", type: "warning")] 41 | } 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/language_server/project/parser_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module LanguageServer 4 | class Project 5 | class ParserTest < Minitest::Test 6 | def test_toplevel_const 7 | body = <<-EOS.strip_heredoc 8 | TOP_LEVEL_CONST = 1 9 | EOS 10 | 11 | result = Parser.parse(body) 12 | 13 | assert { result.constants.map(&:full_name) == %w[TOP_LEVEL_CONST] } 14 | end 15 | 16 | def test_const_within_class_and_module 17 | body = <<-EOS.strip_heredoc 18 | class Cls 19 | module Mod 20 | CONST = 1 21 | end 22 | end 23 | EOS 24 | 25 | result = Parser.parse(body) 26 | 27 | assert { result.modules.map(&:full_name) == %w[Cls::Mod] } 28 | assert { result.constants.map(&:full_name) == %w[Cls::Mod::CONST] } 29 | end 30 | 31 | def test_class 32 | body = <<-EOS.strip_heredoc 33 | class Hi 34 | end 35 | EOS 36 | 37 | result = Parser.parse(body) 38 | 39 | assert { result.classes.map(&:full_name) == %w[Hi] } 40 | end 41 | 42 | def test_module 43 | body = <<-EOS.strip_heredoc 44 | module Hi 45 | end 46 | EOS 47 | 48 | result = Parser.parse(body) 49 | 50 | assert { result.modules.map(&:full_name) == %w[Hi] } 51 | end 52 | 53 | def test_module_with_method 54 | body = <<-EOS.strip_heredoc 55 | module Hi 56 | def initialize 57 | end 58 | end 59 | EOS 60 | 61 | result = Parser.parse(body) 62 | 63 | assert { result.modules.map(&:full_name) == %w[Hi] } 64 | end 65 | 66 | def test_ref 67 | result = Parser.parse(<<-EOS.strip_heredoc) 68 | A 69 | EOS 70 | 71 | assert { result.refs.map { |r| [r.full_name, r.characters] } == [["A", 0..1]] } 72 | end 73 | 74 | def test_ref_2 75 | result = Parser.parse(<<-EOS.strip_heredoc) 76 | A::B 77 | EOS 78 | 79 | assert { result.refs.map { |r| [r.full_name, r.characters] } == [["A", 0..2], ["A::B", 0..3]] } 80 | end 81 | 82 | def test_ref_3 83 | result = Parser.parse(<<-EOS.strip_heredoc) 84 | A::B::C 85 | EOS 86 | 87 | assert { result.refs.map { |r| [r.full_name, r.characters] } == [["A", 0..2], ["A::B", 0..3], ["A::B::C", 0..6]] } 88 | end 89 | 90 | def test_ref_within_module 91 | result = Parser.parse(<<-EOS.strip_heredoc) 92 | module A 93 | B 94 | end 95 | EOS 96 | 97 | assert { result.refs.map { |r| [r.full_name, r.characters] } == [["A::B", 2..3]] } 98 | 99 | result = Parser.parse(<<-EOS.strip_heredoc) 100 | module A 101 | B::C 102 | end 103 | EOS 104 | 105 | assert { result.refs.map { |r| [r.full_name, r.characters] } == [["A::B", 2..4], ["A::B::C", 2..5]] } 106 | end 107 | 108 | def test_const_ref_with_method 109 | result = Parser.parse(<<-EOS.strip_heredoc) 110 | class.self::FOO 111 | EOS 112 | 113 | assert { result.refs == [] } 114 | end 115 | 116 | def test_inline_nested_class 117 | result = Parser.parse(<<-EOS.strip_heredoc) 118 | module A 119 | module B 120 | class C::D::E 121 | end 122 | end 123 | end 124 | EOS 125 | 126 | assert { result.classes.map(&:full_name) == %w[A::B::C::D::E] } 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /test/language_server/project_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module LanguageServer 4 | class ProjectTest < Minitest::Test 5 | def test_const_within_class_and_module 6 | store = FileStore.new 7 | store.cache("file:///a.rb", <<-EOS.strip_heredoc) 8 | class A 9 | module A 10 | class C 11 | end 12 | end 13 | end 14 | EOS 15 | 16 | store.cache("file:///b.rb", <<-EOS.strip_heredoc) 17 | class A 18 | module B 19 | end 20 | end 21 | EOS 22 | 23 | store.cache("file:///c.rb", <<-EOS.strip_heredoc) 24 | class B 25 | module C 26 | end 27 | end 28 | EOS 29 | 30 | uri = "file:///foo.rb" 31 | store.cache(uri, <<-EOS.strip_heredoc) 32 | 33 | class A 34 | module A 35 | def hi 36 | end 37 | end 38 | end 39 | EOS 40 | 41 | project = Project.new(store) 42 | assert { project.modules(uri: uri, line: 0, character: 0).map(&:full_name) == [] } 43 | assert { project.modules(uri: uri, line: 1, character: 0).map(&:full_name).uniq.sort == %w[A::A A::B].sort } 44 | 45 | assert { project.classes(uri: uri, line: 0, character: 0).map(&:full_name).uniq.sort == %w[A B].sort } 46 | assert { project.classes(uri: uri, line: 1, character: 0).map(&:full_name).uniq.sort == %w[A B].sort } 47 | assert { project.classes(uri: uri, line: 2, character: 0).map(&:full_name).uniq.sort == %w[A B A::A::C].sort } 48 | end 49 | 50 | def test_find_definition 51 | store = FileStore.new 52 | store.cache("file:///a.rb", <<-EOS.strip_heredoc) 53 | class A 54 | module A 55 | class C 56 | def foo 57 | end 58 | end 59 | end 60 | end 61 | EOS 62 | 63 | store.cache("file:///b.rb", <<-EOS.strip_heredoc) 64 | class A 65 | def hi 66 | A::C.foo 67 | end 68 | end 69 | EOS 70 | 71 | project = Project.new(store) 72 | 73 | assert { project.find_definitions(uri: "file:///b.rb", line: 2, character: 6).first.full_name == "A::A" } 74 | assert { project.find_definitions(uri: "file:///b.rb", line: 2, character: 7).first.full_name == "A::A::C" } 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 2 | require "bundler" 3 | Bundler.require(:default, :development) 4 | 5 | require "minitest/autorun" 6 | require "power_assert" 7 | 8 | class String 9 | def strip_heredoc 10 | min = scan(/^[ \t]*(?=\S)/).min 11 | indent = min ? min.size : 0 12 | gsub(/^[ \t]{#{indent}}/, "") 13 | end 14 | end 15 | --------------------------------------------------------------------------------