├── test ├── fixtures │ ├── ident.rb │ ├── program.rb │ ├── fcall.rb │ ├── period.rb │ ├── field.rb │ ├── top_const_ref.rb │ ├── tstring_content.rb │ ├── const_path_ref.rb │ ├── method_add_block.rb │ ├── op.rb │ ├── void_stmt.rb │ ├── backtick.rb │ ├── block_arg.rb │ ├── const_path_field.rb │ ├── kw.rb │ ├── top_const_field.rb │ ├── backref.rb │ ├── const.rb │ ├── cvar.rb │ ├── gvar.rb │ ├── imaginary.rb │ ├── ivar.rb │ ├── vcall.rb │ ├── float_literal.rb │ ├── return0.rb │ ├── zsuper.rb │ ├── rational_literal.rb │ ├── symbol_literal.rb │ ├── string_concat.rb │ ├── opassign.rb │ ├── pinned_begin.rb │ ├── begin.rb │ ├── const_ref.rb │ ├── label.rb │ ├── massign.rb │ ├── excessed_comma.rb │ ├── redo.rb │ ├── word.rb │ ├── method_add_arg.rb │ ├── paren.rb │ ├── brace_block.rb │ ├── unary.rb │ ├── yield0.rb │ ├── block_var.rb │ ├── rescue_mod.rb │ ├── string_dvar.rb │ ├── embdoc.rb │ ├── mrhs.rb │ ├── command_def_endless.rb │ ├── int.rb │ ├── var_field.rb │ ├── var_alias.rb │ ├── CHAR.rb │ ├── aref.rb │ ├── retry.rb │ ├── args_forward.rb │ ├── ensure.rb │ ├── var_field_rassign.rb │ ├── var_ref.rb │ ├── case.rb │ ├── mlhs.rb │ ├── rest_param.rb │ ├── kwrest_param.rb │ ├── heredoc_beg.rb │ ├── mlhs_paren.rb │ ├── module.rb │ ├── aref_field.rb │ ├── dot2.rb │ ├── else.rb │ ├── end_content.rb │ ├── statements.rb │ ├── string_embexpr.rb │ ├── dot3.rb │ ├── dyna_symbol.rb │ ├── sclass.rb │ ├── elsif.rb │ ├── arg_paren.rb │ ├── arg_star.rb │ ├── defined.rb │ ├── qwords.rb │ ├── assoc_splat.rb │ ├── not.rb │ ├── qsymbols.rb │ ├── def.rb │ ├── access_ctrl.rb │ ├── args.rb │ ├── words.rb │ ├── yield.rb │ ├── defs.rb │ ├── symbols.rb │ ├── super.rb │ ├── until.rb │ ├── while.rb │ ├── if_mod.rb │ ├── arg_block.rb │ ├── binary.rb │ ├── class.rb │ ├── until_mod.rb │ ├── while_mod.rb │ ├── unless_mod.rb │ ├── do_block.rb │ ├── unless.rb │ ├── end_block.rb │ ├── begin_block.rb │ ├── bare_assoc_hash.rb │ ├── def_endless.rb │ ├── ifop.rb │ ├── undef.rb │ ├── command.rb │ ├── in.rb │ ├── string_literal.rb │ ├── hash.rb │ ├── for.rb │ ├── alias.rb │ ├── xstring_literal.rb │ ├── bodystmt.rb │ ├── rescue.rb │ ├── rassign.rb │ ├── return.rb │ ├── assign.rb │ ├── regexp_literal.rb │ ├── if.rb │ ├── break.rb │ ├── when.rb │ ├── assoc.rb │ ├── hshptn.rb │ ├── next.rb │ ├── call.rb │ ├── params.rb │ ├── aryptn.rb │ ├── fndptn.rb │ ├── array_literal.rb │ ├── lambda.rb │ ├── heredoc.rb │ └── command_call.rb ├── encoded.rb ├── quotes_test.rb ├── idempotency_test.rb ├── location_test.rb ├── plugin │ ├── disable_auto_ternary_test.rb │ ├── single_quotes_test.rb │ └── trailing_comma_test.rb ├── language_server │ └── inlay_hints_test.rb ├── mutation_test.rb ├── syntax_tree_test.rb ├── rake_test.rb ├── formatting_test.rb ├── ractor_test.rb ├── visitor_test.rb ├── search_test.rb ├── parser_test.rb ├── index_test.rb ├── test_helper.rb └── language_server_test.rb ├── .gitattributes ├── Gemfile ├── lib ├── syntax_tree │ ├── version.rb │ ├── rake_tasks.rb │ ├── plugin │ │ ├── single_quotes.rb │ │ ├── trailing_comma.rb │ │ └── disable_auto_ternary.rb │ ├── search.rb │ ├── rake │ │ ├── check_task.rb │ │ ├── write_task.rb │ │ └── task.rb │ ├── json_visitor.rb │ ├── mermaid_visitor.rb │ ├── pretty_print_visitor.rb │ ├── match_visitor.rb │ ├── basic_visitor.rb │ ├── mermaid.rb │ ├── formatter.rb │ ├── reflection.rb │ ├── pattern.rb │ ├── database.rb │ └── with_scope.rb └── syntax_tree.rb ├── .gitignore ├── bin ├── console ├── profile └── bench ├── exe └── stree ├── .github ├── dependabot.yml └── workflows │ ├── auto-merge.yml │ ├── main.yml │ └── gh-pages.yml ├── Rakefile ├── LICENSE ├── syntax_tree.gemspec ├── Gemfile.lock ├── config └── rubocop.yml ├── CODE_OF_CONDUCT.md └── .rubocop.yml /test/fixtures/ident.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo 3 | -------------------------------------------------------------------------------- /test/fixtures/program.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo 3 | -------------------------------------------------------------------------------- /test/fixtures/fcall.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo(bar) 3 | -------------------------------------------------------------------------------- /test/fixtures/period.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo.bar 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | bin/* linguist-language=Ruby 2 | -------------------------------------------------------------------------------- /test/fixtures/field.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo.bar = baz 3 | -------------------------------------------------------------------------------- /test/fixtures/top_const_ref.rb: -------------------------------------------------------------------------------- 1 | % 2 | ::Foo 3 | -------------------------------------------------------------------------------- /test/fixtures/tstring_content.rb: -------------------------------------------------------------------------------- 1 | % 2 | "foo" 3 | -------------------------------------------------------------------------------- /test/fixtures/const_path_ref.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo::Bar 3 | -------------------------------------------------------------------------------- /test/fixtures/method_add_block.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo {} 3 | -------------------------------------------------------------------------------- /test/fixtures/op.rb: -------------------------------------------------------------------------------- 1 | % 2 | def +(other) 3 | end 4 | -------------------------------------------------------------------------------- /test/fixtures/void_stmt.rb: -------------------------------------------------------------------------------- 1 | % 2 | ;;; 3 | - 4 | 5 | -------------------------------------------------------------------------------- /test/fixtures/backtick.rb: -------------------------------------------------------------------------------- 1 | % 2 | def `(value) 3 | end 4 | -------------------------------------------------------------------------------- /test/fixtures/block_arg.rb: -------------------------------------------------------------------------------- 1 | % 2 | def foo(&bar) 3 | end 4 | -------------------------------------------------------------------------------- /test/fixtures/const_path_field.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo::Bar = baz 3 | -------------------------------------------------------------------------------- /test/fixtures/kw.rb: -------------------------------------------------------------------------------- 1 | % 2 | :if 3 | % 4 | def if 5 | end 6 | -------------------------------------------------------------------------------- /test/fixtures/top_const_field.rb: -------------------------------------------------------------------------------- 1 | % 2 | ::Foo = baz 3 | -------------------------------------------------------------------------------- /test/fixtures/backref.rb: -------------------------------------------------------------------------------- 1 | % 2 | $1 3 | % 4 | $1 # comment 5 | -------------------------------------------------------------------------------- /test/fixtures/const.rb: -------------------------------------------------------------------------------- 1 | % 2 | Foo 3 | % 4 | Foo # comment 5 | -------------------------------------------------------------------------------- /test/fixtures/cvar.rb: -------------------------------------------------------------------------------- 1 | % 2 | @@foo 3 | % 4 | @@foo # comment 5 | -------------------------------------------------------------------------------- /test/fixtures/gvar.rb: -------------------------------------------------------------------------------- 1 | % 2 | $foo 3 | % 4 | $foo # comment 5 | -------------------------------------------------------------------------------- /test/fixtures/imaginary.rb: -------------------------------------------------------------------------------- 1 | % 2 | 1i 3 | % 4 | 1i # comment 5 | -------------------------------------------------------------------------------- /test/fixtures/ivar.rb: -------------------------------------------------------------------------------- 1 | % 2 | @foo 3 | % 4 | @foo # comment 5 | -------------------------------------------------------------------------------- /test/fixtures/vcall.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo 3 | % 4 | foo # comment 5 | -------------------------------------------------------------------------------- /test/fixtures/float_literal.rb: -------------------------------------------------------------------------------- 1 | % 2 | 1.0 3 | % 4 | 1.0 # comment 5 | -------------------------------------------------------------------------------- /test/fixtures/return0.rb: -------------------------------------------------------------------------------- 1 | % 2 | return 3 | % 4 | return # comment 5 | -------------------------------------------------------------------------------- /test/fixtures/zsuper.rb: -------------------------------------------------------------------------------- 1 | % 2 | super 3 | % 4 | super # comment 5 | -------------------------------------------------------------------------------- /test/fixtures/rational_literal.rb: -------------------------------------------------------------------------------- 1 | % 2 | 1r 3 | % 4 | 1r # comment 5 | -------------------------------------------------------------------------------- /test/fixtures/symbol_literal.rb: -------------------------------------------------------------------------------- 1 | % 2 | :foo 3 | % 4 | :foo # comment 5 | -------------------------------------------------------------------------------- /test/encoded.rb: -------------------------------------------------------------------------------- 1 | # encoding: Shift_JIS 2 | # frozen_string_literal: true 3 | -------------------------------------------------------------------------------- /test/fixtures/string_concat.rb: -------------------------------------------------------------------------------- 1 | % 2 | "foo" \ 3 | "bar" \ 4 | "baz" 5 | -------------------------------------------------------------------------------- /test/fixtures/opassign.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo += bar 3 | % 4 | foo += # comment 5 | bar 6 | -------------------------------------------------------------------------------- /test/fixtures/pinned_begin.rb: -------------------------------------------------------------------------------- 1 | % 2 | case value 3 | in ^(expression) 4 | end 5 | -------------------------------------------------------------------------------- /test/fixtures/begin.rb: -------------------------------------------------------------------------------- 1 | % 2 | begin 3 | end 4 | % 5 | begin 6 | expression 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/const_ref.rb: -------------------------------------------------------------------------------- 1 | % 2 | class Foo 3 | end 4 | % 5 | class Foo::Bar 6 | end 7 | -------------------------------------------------------------------------------- /test/fixtures/label.rb: -------------------------------------------------------------------------------- 1 | % 2 | { foo: bar } 3 | % 4 | case foo 5 | in bar: 6 | end 7 | -------------------------------------------------------------------------------- /test/fixtures/massign.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo, bar = baz, qux 3 | % 4 | foo, bar, = baz, qux 5 | -------------------------------------------------------------------------------- /test/fixtures/excessed_comma.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo.each do |bar, baz,| 3 | # comment 4 | end 5 | -------------------------------------------------------------------------------- /test/fixtures/redo.rb: -------------------------------------------------------------------------------- 1 | % 2 | tap { redo } 3 | % 4 | tap do 5 | redo # comment 6 | end 7 | -------------------------------------------------------------------------------- /test/fixtures/word.rb: -------------------------------------------------------------------------------- 1 | % 2 | %W[foo] 3 | % 4 | %W[foo\ bar] 5 | % 6 | %W[foo#{bar}baz] 7 | -------------------------------------------------------------------------------- /test/fixtures/method_add_arg.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo(bar) 3 | % 4 | foo.bar(baz) 5 | % 6 | foo.() 7 | % 8 | foo? 9 | -------------------------------------------------------------------------------- /test/fixtures/paren.rb: -------------------------------------------------------------------------------- 1 | % 2 | (foo + bar) 3 | % 4 | ( 5 | foo 6 | bar 7 | ) 8 | % 9 | (foo) 10 | -------------------------------------------------------------------------------- /test/fixtures/brace_block.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo {} 3 | % 4 | foo { # comment 5 | } 6 | - 7 | foo do # comment 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/unary.rb: -------------------------------------------------------------------------------- 1 | % 2 | !foo 3 | % # https://github.com/prettier/plugin-ruby/issues/764 4 | !(foo&.>(0)) 5 | -------------------------------------------------------------------------------- /test/fixtures/yield0.rb: -------------------------------------------------------------------------------- 1 | % 2 | def foo 3 | yield 4 | end 5 | % 6 | def foo 7 | yield # comment 8 | end 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "fiddle" 8 | -------------------------------------------------------------------------------- /lib/syntax_tree/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | VERSION = "6.3.0" 5 | end 6 | -------------------------------------------------------------------------------- /test/fixtures/block_var.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo { |bar, baz| } 3 | % 4 | foo { |bar; baz| } 5 | % 6 | foo { |bar, baz; qux, qaz| } 7 | -------------------------------------------------------------------------------- /test/fixtures/rescue_mod.rb: -------------------------------------------------------------------------------- 1 | % 2 | bar rescue foo 3 | - 4 | begin 5 | bar 6 | rescue StandardError 7 | foo 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/string_dvar.rb: -------------------------------------------------------------------------------- 1 | % 2 | "#@foo" 3 | - 4 | "#{@foo}" 5 | % 6 | "#@foo" # comment 7 | - 8 | "#{@foo}" # comment 9 | -------------------------------------------------------------------------------- /test/fixtures/embdoc.rb: -------------------------------------------------------------------------------- 1 | % 2 | =begin 3 | comment 4 | =end 5 | % 6 | module Foo 7 | =begin 8 | comment 9 | =end 10 | end 11 | -------------------------------------------------------------------------------- /test/fixtures/mrhs.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo = bar, baz 3 | % 4 | foo = bar, *baz, qux 5 | % 6 | foo = *bar, baz 7 | % 8 | foo = bar, *baz 9 | -------------------------------------------------------------------------------- /test/fixtures/command_def_endless.rb: -------------------------------------------------------------------------------- 1 | % 2 | meta1 def foo = 1 3 | % 4 | meta2 meta1 def foo = 1 5 | % 6 | meta3 meta2 meta1 def foo = 1 7 | -------------------------------------------------------------------------------- /test/fixtures/int.rb: -------------------------------------------------------------------------------- 1 | % 2 | 1 3 | % 4 | 1 # comment 5 | % 6 | 12345 7 | - 8 | 12_345 9 | % 10 | 2020_01_01 11 | % 12 | 0b11111 13 | -------------------------------------------------------------------------------- /test/fixtures/var_field.rb: -------------------------------------------------------------------------------- 1 | % 2 | Foo = bar 3 | % 4 | @@foo = bar 5 | % 6 | $foo = bar 7 | % 8 | foo = bar 9 | % 10 | @foo = bar 11 | -------------------------------------------------------------------------------- /lib/syntax_tree/rake_tasks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "rake/check_task" 4 | require_relative "rake/write_task" 5 | -------------------------------------------------------------------------------- /test/fixtures/var_alias.rb: -------------------------------------------------------------------------------- 1 | % 2 | alias $1 $foo 3 | % 4 | alias $foo $bar 5 | % 6 | alias $1 $foo # comment 7 | % 8 | alias $foo $bar # comment 9 | -------------------------------------------------------------------------------- /test/fixtures/CHAR.rb: -------------------------------------------------------------------------------- 1 | % 2 | ?a 3 | - 4 | "a" 5 | % 6 | ?\C-a 7 | % 8 | ?\M-a 9 | % 10 | ?\M-\C-a 11 | % 12 | ?a # comment 13 | - 14 | "a" # comment 15 | -------------------------------------------------------------------------------- /test/fixtures/aref.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo[bar] 3 | % 4 | foo[] 5 | % 6 | foo[ 7 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 8 | ] 9 | -------------------------------------------------------------------------------- /test/fixtures/retry.rb: -------------------------------------------------------------------------------- 1 | % 2 | begin 3 | rescue StandardError 4 | retry 5 | end 6 | % 7 | begin 8 | rescue StandardError 9 | retry # comment 10 | end 11 | -------------------------------------------------------------------------------- /lib/syntax_tree/plugin/single_quotes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | class Formatter 5 | SINGLE_QUOTES = true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/syntax_tree/plugin/trailing_comma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | class Formatter 5 | TRAILING_COMMA = true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/args_forward.rb: -------------------------------------------------------------------------------- 1 | % # >= 2.7.3 2 | def foo(...) 3 | bar(:baz, ...) 4 | end 5 | % # >= 3.1.0 6 | def foo(foo, bar = baz, ...) 7 | bar(:baz, ...) 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/ensure.rb: -------------------------------------------------------------------------------- 1 | % 2 | begin 3 | ensure 4 | end 5 | % 6 | begin 7 | ensure 8 | foo 9 | end 10 | % 11 | begin 12 | ensure 13 | # comment 14 | end 15 | -------------------------------------------------------------------------------- /test/fixtures/var_field_rassign.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo in bar 3 | % 4 | bar = 1 5 | foo in ^bar 6 | % 7 | foo in ^@bar 8 | % 9 | foo in ^@@bar 10 | % 11 | foo in ^$gvar 12 | -------------------------------------------------------------------------------- /test/fixtures/var_ref.rb: -------------------------------------------------------------------------------- 1 | % 2 | Foo 3 | % 4 | @@foo 5 | % 6 | $foo 7 | % 8 | foo 9 | % 10 | @foo 11 | % 12 | self 13 | % 14 | true 15 | % 16 | false 17 | % 18 | nil 19 | -------------------------------------------------------------------------------- /lib/syntax_tree/plugin/disable_auto_ternary.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | class Formatter 5 | DISABLE_AUTO_TERNARY = true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /pkg/ 6 | /rdocs/ 7 | /sorbet/ 8 | /spec/reports/ 9 | /tmp/ 10 | /vendor/ 11 | 12 | test.rb 13 | query.txt 14 | -------------------------------------------------------------------------------- /test/fixtures/case.rb: -------------------------------------------------------------------------------- 1 | % 2 | case foo 3 | when bar 4 | baz 5 | end 6 | % 7 | case 8 | when bar 9 | baz 10 | end 11 | % 12 | case # comment 13 | when foo 14 | bar 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/mlhs.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo, bar = baz 3 | % 4 | foo, bar, = baz 5 | % 6 | foo, *bar, baz = baz 7 | % 8 | foo, *bar, baz = baz 9 | % 10 | foo1, foo2, *bar, baz1, baz2 = baz 11 | -------------------------------------------------------------------------------- /test/fixtures/rest_param.rb: -------------------------------------------------------------------------------- 1 | % 2 | def foo(*bar) 3 | end 4 | % 5 | def foo(*) 6 | end 7 | % 8 | def foo( 9 | *bar # comment 10 | ) 11 | end 12 | % 13 | def foo( 14 | * # comment 15 | ) 16 | end 17 | -------------------------------------------------------------------------------- /test/fixtures/kwrest_param.rb: -------------------------------------------------------------------------------- 1 | % 2 | def foo(**bar) 3 | end 4 | % 5 | def foo(**) 6 | end 7 | % 8 | def foo( 9 | **bar # comment 10 | ) 11 | end 12 | % 13 | def foo( 14 | ** # comment 15 | ) 16 | end 17 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "syntax_tree" 6 | require "syntax_tree/reflection" 7 | 8 | require "irb" 9 | IRB.start(__FILE__) 10 | -------------------------------------------------------------------------------- /test/fixtures/heredoc_beg.rb: -------------------------------------------------------------------------------- 1 | % 2 | <<-FOO 3 | FOO 4 | % 5 | <<~FOO 6 | FOO 7 | % 8 | <<-`FOO` 9 | FOO 10 | % 11 | <<-FOO.strip 12 | FOO 13 | % 14 | <<~FOO.strip 15 | FOO 16 | % 17 | <<-`FOO`.strip 18 | FOO 19 | -------------------------------------------------------------------------------- /exe/stree: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $:.unshift(File.expand_path("../lib", __dir__)) 5 | 6 | require "syntax_tree" 7 | require "syntax_tree/cli" 8 | 9 | exit(SyntaxTree::CLI.run(ARGV)) 10 | -------------------------------------------------------------------------------- /test/fixtures/mlhs_paren.rb: -------------------------------------------------------------------------------- 1 | % 2 | (foo, bar) = baz 3 | - 4 | foo, bar = baz 5 | % 6 | foo, (bar, baz) = baz 7 | % 8 | (foo, bar), baz = baz 9 | % 10 | foo, (bar, baz,) = baz 11 | % 12 | ((foo,)) = bar 13 | - 14 | foo, = bar 15 | -------------------------------------------------------------------------------- /test/fixtures/module.rb: -------------------------------------------------------------------------------- 1 | % 2 | module Foo 3 | end 4 | % 5 | module Foo 6 | foo 7 | end 8 | % 9 | module Foo 10 | # comment 11 | end 12 | % 13 | module Foo # comment 14 | end 15 | % 16 | module Foo 17 | module Bar 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/fixtures/aref_field.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo[bar] = baz 3 | % 4 | foo[] = baz 5 | % 6 | foo[ 7 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 8 | ] = baz 9 | % 10 | foo[bar] # comment 11 | % 12 | foo[bar] += baz 13 | -------------------------------------------------------------------------------- /test/fixtures/dot2.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo..bar 3 | % 4 | foo.. 5 | % 6 | ..bar 7 | % 8 | foo..bar # comment 9 | % 10 | foo.. # comment 11 | % 12 | ..bar # comment 13 | % 14 | if foo == bar .. foo == baz 15 | end 16 | % 17 | unless foo == bar .. foo == baz 18 | end 19 | -------------------------------------------------------------------------------- /test/fixtures/else.rb: -------------------------------------------------------------------------------- 1 | % 2 | case 3 | when foo 4 | else 5 | end 6 | % 7 | if foo 8 | else 9 | end 10 | % 11 | case 12 | when foo 13 | else 14 | bar 15 | end 16 | % 17 | if foo 18 | else 19 | bar 20 | end 21 | % 22 | if foo 23 | else # bar 24 | end 25 | -------------------------------------------------------------------------------- /test/fixtures/end_content.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo = bar 3 | 4 | __END__ 5 | /‾‾‾‾‾\ /‾/ /‾/ /‾‾‾‾‾\ |‾| /‾/ 6 | / /‾‾/ / / / / / / /‾‾/ / | |/ / 7 | / ‾‾‾ / / / / / / ‾‾‾_/ | / 8 | / /‾\ \‾ / /_/ / / /‾‾/ | / / 9 | |_/ /_/ |_____/ |__‾_‾_/ |__/ 10 | -------------------------------------------------------------------------------- /test/fixtures/statements.rb: -------------------------------------------------------------------------------- 1 | % 2 | # comment1 3 | # comment2 4 | % 5 | foo do 6 | # comment1 7 | # comment2 8 | end 9 | % 10 | foo 11 | 12 | 13 | bar 14 | - 15 | foo 16 | 17 | bar 18 | % 19 | foo; bar 20 | - 21 | foo 22 | bar 23 | % 24 | "#{foo; bar}" 25 | -------------------------------------------------------------------------------- /test/fixtures/string_embexpr.rb: -------------------------------------------------------------------------------- 1 | % 2 | "foo #{bar}" 3 | % 4 | "foo #{super}" 5 | % 6 | "#{bar} foo" 7 | % 8 | "foo #{"bar #{baz} bar"} foo" 9 | % 10 | "#{foo; bar}" 11 | % 12 | "#{if foo; foooooooooooooooooooooooooooooooooooooo; else; barrrrrrrrrrrrrrrr; end}" 13 | -------------------------------------------------------------------------------- /test/fixtures/dot3.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo...bar 3 | % 4 | foo... 5 | % 6 | ...bar 7 | % 8 | foo...bar # comment 9 | % 10 | foo... # comment 11 | % 12 | ...bar # comment 13 | % 14 | if foo == bar ... foo == baz 15 | end 16 | % 17 | unless foo == bar ... foo == baz 18 | end 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "bundler" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /test/fixtures/dyna_symbol.rb: -------------------------------------------------------------------------------- 1 | % 2 | :'foo' 3 | - 4 | :"foo" 5 | % 6 | :"foo" 7 | % 8 | :'foo #{bar}' 9 | % 10 | :"foo #{bar}" 11 | % 12 | %s[foo #{bar}] 13 | - 14 | :'foo #{bar}' 15 | % 16 | { %s[foo] => bar } 17 | - 18 | { foo: bar } 19 | % 20 | %s[ 21 | foo 22 | ] 23 | -------------------------------------------------------------------------------- /test/fixtures/sclass.rb: -------------------------------------------------------------------------------- 1 | % 2 | class << self 3 | foo 4 | end 5 | % 6 | class << foo 7 | bar 8 | end 9 | % 10 | class << self # comment 11 | foo 12 | end 13 | % 14 | class << self 15 | # comment 16 | end 17 | % 18 | class << self 19 | # comment1 20 | # comment2 21 | end 22 | -------------------------------------------------------------------------------- /test/fixtures/elsif.rb: -------------------------------------------------------------------------------- 1 | % 2 | if foo 3 | bar 4 | elsif baz 5 | end 6 | % 7 | if foo 8 | bar 9 | elsif baz 10 | qux 11 | end 12 | % 13 | if foo 14 | bar 15 | elsif baz 16 | qux 17 | else 18 | qyz 19 | end 20 | % 21 | if true 22 | elsif false # comment1 23 | # comment2 24 | end 25 | -------------------------------------------------------------------------------- /test/fixtures/arg_paren.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo(bar) 3 | % 4 | foo() 5 | % 6 | foo(barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr) 7 | - 8 | foo( 9 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 10 | ) 11 | % 12 | foo( 13 | bar 14 | ) 15 | - 16 | foo(bar) 17 | -------------------------------------------------------------------------------- /test/fixtures/arg_star.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo(*bar) 3 | % 4 | foo( 5 | *bar 6 | ) 7 | - 8 | foo(*bar) 9 | % 10 | foo(*bar.baz) 11 | % 12 | foo(*bar.bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz) 13 | - 14 | foo( 15 | *bar.bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 16 | ) 17 | -------------------------------------------------------------------------------- /test/fixtures/defined.rb: -------------------------------------------------------------------------------- 1 | % 2 | defined?(foo) 3 | % 4 | defined?(foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo) 5 | - 6 | defined?( 7 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 8 | ) 9 | % 10 | defined?( 11 | foo 12 | ) 13 | - 14 | defined?(foo) 15 | -------------------------------------------------------------------------------- /test/fixtures/qwords.rb: -------------------------------------------------------------------------------- 1 | % 2 | %w[foo bar] 3 | % 4 | %w[fooooooooooooooooooooooooooooooooooooo barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr] 5 | - 6 | %w[ 7 | fooooooooooooooooooooooooooooooooooooo 8 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 9 | ] 10 | % 11 | %w[ 12 | foo 13 | ] 14 | - 15 | %w[foo] 16 | % 17 | %w[foo] # comment 18 | % 19 | %w{foo[]} 20 | -------------------------------------------------------------------------------- /test/fixtures/assoc_splat.rb: -------------------------------------------------------------------------------- 1 | % 2 | { **foo } 3 | % 4 | { **foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo } 5 | - 6 | { 7 | **foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 8 | } 9 | % 10 | { 11 | **foo 12 | } 13 | - 14 | { **foo } 15 | % # >= 3.2.0 16 | def foo(**) 17 | bar(**) 18 | end 19 | -------------------------------------------------------------------------------- /test/fixtures/not.rb: -------------------------------------------------------------------------------- 1 | % 2 | not() 3 | % 4 | not () 5 | % 6 | not foo 7 | % 8 | not(foo) 9 | % 10 | not (foo) 11 | % 12 | if foo 13 | not bar 14 | else 15 | baz 16 | end 17 | - 18 | foo ? not(bar) : baz 19 | % 20 | if foooooooooooooooooooooooooooooooooooooooooo 21 | not bar 22 | else 23 | bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 24 | end 25 | -------------------------------------------------------------------------------- /test/fixtures/qsymbols.rb: -------------------------------------------------------------------------------- 1 | % 2 | %i[foo bar] 3 | % 4 | %i[fooooooooooooooooooooooooooooooooooooo barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr] 5 | - 6 | %i[ 7 | fooooooooooooooooooooooooooooooooooooo 8 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 9 | ] 10 | % 11 | %i[ 12 | foo 13 | ] 14 | - 15 | %i[foo] 16 | % 17 | %i[foo] # comment 18 | % 19 | %i{foo[]} 20 | -------------------------------------------------------------------------------- /test/fixtures/def.rb: -------------------------------------------------------------------------------- 1 | % 2 | def foo(bar) 3 | baz 4 | end 5 | % 6 | def foo bar 7 | baz 8 | end 9 | - 10 | def foo(bar) 11 | baz 12 | end 13 | % 14 | def foo(bar) # comment 15 | end 16 | % 17 | def foo() 18 | end 19 | % 20 | def foo() # comment 21 | end 22 | % 23 | def foo( # comment 24 | ) 25 | end 26 | % 27 | def 28 | =begin 29 | =end 30 | a 31 | end 32 | -------------------------------------------------------------------------------- /test/fixtures/access_ctrl.rb: -------------------------------------------------------------------------------- 1 | % 2 | class Foo 3 | private 4 | end 5 | % 6 | class Foo 7 | private 8 | def foo 9 | end 10 | end 11 | - 12 | class Foo 13 | private 14 | 15 | def foo 16 | end 17 | end 18 | % 19 | class Foo 20 | def foo 21 | end 22 | private 23 | end 24 | - 25 | class Foo 26 | def foo 27 | end 28 | 29 | private 30 | end 31 | -------------------------------------------------------------------------------- /test/fixtures/args.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo(bar, baz) 3 | % 4 | foo(barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr, baz) 5 | - 6 | foo( 7 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr, 8 | baz 9 | ) 10 | % 11 | foo( 12 | bar, 13 | baz 14 | ) 15 | - 16 | foo(bar, baz) 17 | % 18 | foo( 19 | bar, # comment 20 | baz 21 | ) 22 | -------------------------------------------------------------------------------- /test/fixtures/words.rb: -------------------------------------------------------------------------------- 1 | % 2 | %W[foo bar] 3 | % 4 | %W[foo #{bar}] 5 | % 6 | %W[fooooooooooooooooooooooooooooooooooooo barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr] 7 | - 8 | %W[ 9 | fooooooooooooooooooooooooooooooooooooo 10 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 11 | ] 12 | % 13 | %W[ 14 | foo 15 | ] 16 | - 17 | %W[foo] 18 | % 19 | %W[foo] # comment 20 | % 21 | %W{foo[]} 22 | -------------------------------------------------------------------------------- /test/fixtures/yield.rb: -------------------------------------------------------------------------------- 1 | % 2 | def foo 3 | yield foo 4 | end 5 | % 6 | def foo 7 | yield(foo) 8 | end 9 | % 10 | def foo 11 | yield foo, bar 12 | end 13 | % 14 | def foo 15 | yield(foo, bar) 16 | end 17 | % 18 | def foo 19 | yield foo # comment 20 | end 21 | % 22 | def foo 23 | yield(foo) # comment 24 | end 25 | % 26 | def foo 27 | yield( # comment 28 | foo 29 | ) 30 | end 31 | -------------------------------------------------------------------------------- /test/fixtures/defs.rb: -------------------------------------------------------------------------------- 1 | % 2 | def foo.foo(bar) 3 | baz 4 | end 5 | % 6 | def foo.foo bar 7 | baz 8 | end 9 | - 10 | def foo.foo(bar) 11 | baz 12 | end 13 | % 14 | def foo.foo(bar) # comment 15 | end 16 | % 17 | def foo.foo() 18 | end 19 | % 20 | def foo.foo() # comment 21 | end 22 | % 23 | def foo.foo( # comment 24 | ) 25 | end 26 | % 27 | def foo::foo 28 | end 29 | - 30 | def foo.foo 31 | end 32 | -------------------------------------------------------------------------------- /test/fixtures/symbols.rb: -------------------------------------------------------------------------------- 1 | % 2 | %I[foo bar] 3 | % 4 | %I[foo #{bar}] 5 | % 6 | %I[fooooooooooooooooooooooooooooooooooooo barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr] 7 | - 8 | %I[ 9 | fooooooooooooooooooooooooooooooooooooo 10 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 11 | ] 12 | % 13 | %I[ 14 | foo 15 | ] 16 | - 17 | %I[foo] 18 | % 19 | %I[foo] # comment 20 | % 21 | %I{foo[]} 22 | % 23 | :\ 24 | =begin 25 | =end 26 | symbol 27 | -------------------------------------------------------------------------------- /test/fixtures/super.rb: -------------------------------------------------------------------------------- 1 | % 2 | super() 3 | % 4 | super foo 5 | % 6 | super(foo) 7 | % 8 | super foo, bar 9 | % 10 | super(foo, bar) 11 | % 12 | super() # comment 13 | % 14 | super foo # comment 15 | % 16 | super(foo) # comment 17 | % 18 | super foo, bar # comment 19 | % 20 | super(foo, bar) # comment 21 | % 22 | super foo, # comment1 23 | bar # comment2 24 | % 25 | super( 26 | foo, # comment1 27 | bar # comment2 28 | ) 29 | -------------------------------------------------------------------------------- /test/quotes_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | module SyntaxTree 6 | class QuotesTest < Minitest::Test 7 | def test_normalize 8 | content = "'aaa' \"bbb\" \\'ccc\\' \\\"ddd\\\"" 9 | enclosing = "\"" 10 | 11 | result = Quotes.normalize(content, enclosing) 12 | assert_equal "'aaa' \\\"bbb\\\" \\'ccc\\' \\\"ddd\\\"", result 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/until.rb: -------------------------------------------------------------------------------- 1 | % 2 | until foo 3 | end 4 | % 5 | until foo 6 | bar 7 | end 8 | - 9 | bar until foo 10 | % 11 | until fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 12 | bar 13 | end 14 | % 15 | foo = until bar do baz end 16 | - 17 | foo = (baz until bar) 18 | % 19 | until foo += 1 20 | foo 21 | end 22 | % 23 | until (foo += 1) 24 | foo 25 | end 26 | % 27 | until true # comment1 28 | # comment2 29 | end 30 | -------------------------------------------------------------------------------- /test/fixtures/while.rb: -------------------------------------------------------------------------------- 1 | % 2 | while foo 3 | end 4 | % 5 | while foo 6 | bar 7 | end 8 | - 9 | bar while foo 10 | % 11 | while fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 12 | bar 13 | end 14 | % 15 | foo = while bar do baz end 16 | - 17 | foo = (baz while bar) 18 | % 19 | while foo += 1 20 | foo 21 | end 22 | % 23 | while (foo += 1) 24 | foo 25 | end 26 | % 27 | while true # comment1 28 | # comment2 29 | end 30 | -------------------------------------------------------------------------------- /test/fixtures/if_mod.rb: -------------------------------------------------------------------------------- 1 | % 2 | bar if foo 3 | % 4 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr if foo 5 | - 6 | if foo 7 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 8 | end 9 | % 10 | bar if foo # comment 11 | % 12 | foo = barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr if foo 13 | - 14 | foo = 15 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr if foo 16 | -------------------------------------------------------------------------------- /test/fixtures/arg_block.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo(&bar) 3 | % 4 | foo( 5 | &bar 6 | ) 7 | - 8 | foo(&bar) 9 | % 10 | foo(&bar.baz) 11 | % 12 | foo(&bar.bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz) 13 | - 14 | foo( 15 | &bar.bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 16 | ) 17 | % # >= 3.1.0 18 | def foo(&) 19 | bar(&) 20 | end 21 | % # https://github.com/ruby-syntax-tree/syntax_tree/issues/45 22 | foo.instance_exec(&T.must(block)) 23 | -------------------------------------------------------------------------------- /test/fixtures/binary.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo + bar 3 | % 4 | foo << bar 5 | % 6 | foo << barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr << barrrrrrrrrrrrr << barrrrrrrrrrrrrrrrrr 7 | - 8 | foo << barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr << barrrrrrrrrrrrr << 9 | barrrrrrrrrrrrrrrrrr 10 | % 11 | foo**bar 12 | % 13 | foo * barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 14 | - 15 | foo * 16 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 17 | -------------------------------------------------------------------------------- /test/fixtures/class.rb: -------------------------------------------------------------------------------- 1 | % 2 | class Foo 3 | end 4 | % 5 | class Foo 6 | foo 7 | end 8 | % 9 | class Foo 10 | # comment 11 | end 12 | % 13 | class Foo # comment 14 | end 15 | % 16 | module Foo 17 | class Bar 18 | end 19 | end 20 | % 21 | class Foo < foo 22 | end 23 | % 24 | class Foo < foo 25 | foo 26 | end 27 | % 28 | class Foo < foo 29 | # comment 30 | end 31 | % 32 | class Foo < foo # comment 33 | end 34 | % 35 | module Foo 36 | class Bar < foo 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/fixtures/until_mod.rb: -------------------------------------------------------------------------------- 1 | % 2 | bar until foo 3 | % 4 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr until foo 5 | - 6 | until foo 7 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 8 | end 9 | % 10 | bar until foo # comment 11 | % 12 | foo = barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr until foo 13 | - 14 | foo = 15 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr until foo 16 | -------------------------------------------------------------------------------- /test/fixtures/while_mod.rb: -------------------------------------------------------------------------------- 1 | % 2 | bar while foo 3 | % 4 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr while foo 5 | - 6 | while foo 7 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 8 | end 9 | % 10 | bar while foo # comment 11 | % 12 | foo = barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr while foo 13 | - 14 | foo = 15 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr while foo 16 | -------------------------------------------------------------------------------- /test/fixtures/unless_mod.rb: -------------------------------------------------------------------------------- 1 | % 2 | bar unless foo 3 | % 4 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr unless foo 5 | - 6 | unless foo 7 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 8 | end 9 | % 10 | bar unless foo # comment 11 | % 12 | foo = barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr unless foo 13 | - 14 | foo = 15 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr unless foo 16 | -------------------------------------------------------------------------------- /test/fixtures/do_block.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo do 3 | end 4 | - 5 | foo {} 6 | % 7 | foo do 8 | # comment 9 | end 10 | % 11 | foo do # comment 12 | end 13 | % 14 | foo :bar do 15 | baz 16 | end 17 | % 18 | sig do 19 | override.params(contacts: Contact::ActiveRecord_Relation).returns( 20 | Customer::ActiveRecord_Relation 21 | ) 22 | end 23 | - 24 | sig do 25 | override 26 | .params(contacts: Contact::ActiveRecord_Relation) 27 | .returns(Customer::ActiveRecord_Relation) 28 | end 29 | -------------------------------------------------------------------------------- /test/fixtures/unless.rb: -------------------------------------------------------------------------------- 1 | % 2 | unless foo 3 | end 4 | % 5 | unless foo 6 | else 7 | end 8 | % 9 | unless foo 10 | bar 11 | end 12 | - 13 | bar unless foo 14 | % 15 | unless foo 16 | bar 17 | else 18 | end 19 | % 20 | foo = unless bar then baz end 21 | - 22 | foo = (baz unless bar) 23 | % 24 | unless foo += 1 25 | foo 26 | end 27 | % 28 | unless (foo += 1) 29 | foo 30 | end 31 | % 32 | unless foo 33 | a ? b : c 34 | end 35 | % 36 | unless true # comment1 37 | # comment2 38 | end 39 | -------------------------------------------------------------------------------- /test/fixtures/end_block.rb: -------------------------------------------------------------------------------- 1 | % 2 | END { foo } 3 | % 4 | END { 5 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 6 | } 7 | % 8 | END { 9 | foo 10 | } 11 | - 12 | END { foo } 13 | % 14 | END { foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo } 15 | - 16 | END { 17 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 18 | } 19 | % 20 | END { # comment 21 | foo 22 | } 23 | % 24 | END { 25 | # comment 26 | foo 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/begin_block.rb: -------------------------------------------------------------------------------- 1 | % 2 | BEGIN { foo } 3 | % 4 | BEGIN { 5 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 6 | } 7 | % 8 | BEGIN { 9 | foo 10 | } 11 | - 12 | BEGIN { foo } 13 | % 14 | BEGIN { foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo } 15 | - 16 | BEGIN { 17 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 18 | } 19 | % 20 | BEGIN { # comment 21 | foo 22 | } 23 | % 24 | BEGIN { 25 | # comment 26 | foo 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/bare_assoc_hash.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo(bar: bar) 3 | % 4 | foo(:bar => bar) 5 | - 6 | foo(bar: bar) 7 | % 8 | foo(:"bar" => bar) 9 | - 10 | foo(bar: bar) 11 | % 12 | foo(bar => bar, baz: baz) 13 | - 14 | foo(bar => bar, :baz => baz) 15 | % 16 | foo(bar => bar, "baz": baz) 17 | - 18 | foo(bar => bar, :"baz" => baz) 19 | % 20 | foo(bar: barrrrrrrrrrrrrrrrrrrrrrrrrrrrrr, baz: bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzz) 21 | - 22 | foo( 23 | bar: barrrrrrrrrrrrrrrrrrrrrrrrrrrrrr, 24 | baz: bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 25 | ) 26 | -------------------------------------------------------------------------------- /test/fixtures/def_endless.rb: -------------------------------------------------------------------------------- 1 | % 2 | def foo = bar 3 | % 4 | def foo(bar) = baz 5 | % 6 | def foo() = bar 7 | % # >= 3.1.0 8 | def foo = bar baz 9 | % # >= 3.1.0 10 | def self.foo = bar 11 | % # >= 3.1.0 12 | def self.foo(bar) = baz 13 | % # >= 3.1.0 14 | def self.foo() = bar 15 | % # >= 3.1.0 16 | def self.foo = bar baz 17 | % 18 | begin 19 | true 20 | rescue StandardError 21 | false 22 | end 23 | 24 | def foo? = true 25 | % 26 | def a() 27 | =begin 28 | =end 29 | =1 30 | - 31 | def a() = 32 | =begin 33 | =end 34 | 1 35 | -------------------------------------------------------------------------------- /test/fixtures/ifop.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo ? bar : baz 3 | % 4 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo ? bar : baz 5 | - 6 | if foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 7 | bar 8 | else 9 | baz 10 | end 11 | % 12 | foo bar ? 1 : 2 13 | % 14 | tap { foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo ? break : baz } 15 | - 16 | tap do 17 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo ? 18 | break : 19 | baz 20 | end 21 | -------------------------------------------------------------------------------- /test/fixtures/undef.rb: -------------------------------------------------------------------------------- 1 | % 2 | undef foo 3 | % 4 | undef foo, bar 5 | % 6 | undef foooooooooooooooooooooooooooooooooooooo, barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 7 | - 8 | undef foooooooooooooooooooooooooooooooooooooo, 9 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 10 | % 11 | undef foo # comment 12 | % 13 | undef foo, # comment 14 | bar 15 | % 16 | undef foo, # comment1 17 | bar, # comment2 18 | baz 19 | % 20 | undef foo, 21 | bar # comment 22 | - 23 | undef foo, bar # comment 24 | % 25 | undef :"foo", :"bar" 26 | -------------------------------------------------------------------------------- /test/fixtures/command.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo bar 3 | % 4 | foo barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr, bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 5 | - 6 | foo barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr, 7 | bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 8 | % 9 | meta1 def foo 10 | end 11 | % 12 | meta2 meta1 def foo 13 | end 14 | % 15 | meta3 meta2 meta1 def foo 16 | end 17 | % 18 | meta1 def self.foo 19 | end 20 | % 21 | meta2 meta1 def self.foo 22 | end 23 | % 24 | meta3 meta2 meta1 def self.foo 25 | end 26 | % 27 | foo bar {} 28 | % 29 | foo bar do 30 | end 31 | -------------------------------------------------------------------------------- /test/fixtures/in.rb: -------------------------------------------------------------------------------- 1 | % 2 | case foo 3 | in foo 4 | end 5 | % 6 | case foo 7 | in foo 8 | baz 9 | end 10 | % 11 | case foo 12 | in fooooooooooooooooooooooooooooooooooooo, barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 13 | baz 14 | end 15 | - 16 | case foo 17 | in [ 18 | fooooooooooooooooooooooooooooooooooooo, 19 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 20 | ] 21 | baz 22 | end 23 | % 24 | case foo 25 | in foo 26 | in bar 27 | end 28 | % 29 | case foo 30 | in bar 31 | # comment 32 | end 33 | % 34 | case foo 35 | in bar if baz 36 | end 37 | -------------------------------------------------------------------------------- /test/fixtures/string_literal.rb: -------------------------------------------------------------------------------- 1 | % 2 | %(foo \\ bar) 3 | % 4 | %[foo \\ bar] 5 | % 6 | %{foo \\ bar} 7 | % 8 | % 9 | % 10 | %|foo \\ bar| 11 | % 12 | %q(foo \\ bar) 13 | % 14 | %q[foo \\ bar] 15 | % 16 | %q{foo \\ bar} 17 | % 18 | %q 19 | % 20 | %q|foo \\ bar| 21 | % 22 | %Q(foo \\ bar) 23 | % 24 | %Q[foo \\ bar] 25 | % 26 | %Q{foo \\ bar} 27 | % 28 | %Q 29 | % 30 | %Q|foo \\ bar| 31 | % 32 | '' 33 | - 34 | "" 35 | % 36 | 'foo' 37 | - 38 | "foo" 39 | % 40 | 'foo #{bar}' 41 | % 42 | '"foo"' 43 | - 44 | '"foo"' 45 | % 46 | "'foo'" 47 | - 48 | "'foo'" 49 | -------------------------------------------------------------------------------- /test/fixtures/hash.rb: -------------------------------------------------------------------------------- 1 | % 2 | {} 3 | % 4 | { bar: bar } 5 | % 6 | { :bar => bar } 7 | - 8 | { bar: bar } 9 | % 10 | { :"bar" => bar } 11 | - 12 | { bar: bar } 13 | % 14 | { bar => bar, baz: baz } 15 | - 16 | { bar => bar, :baz => baz } 17 | % 18 | { bar => bar, "baz": baz } 19 | - 20 | { bar => bar, :"baz" => baz } 21 | % 22 | { bar: barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr, baz: bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz } 23 | - 24 | { 25 | bar: barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr, 26 | baz: bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 27 | } 28 | % 29 | { 30 | # comment 31 | } 32 | % # >= 3.1.0 33 | { foo:, "bar" => "baz" } 34 | -------------------------------------------------------------------------------- /test/fixtures/for.rb: -------------------------------------------------------------------------------- 1 | % 2 | for foo in bar 3 | end 4 | % 5 | for foo in bar 6 | foo 7 | end 8 | % 9 | for foo in bar 10 | # comment 11 | end 12 | % 13 | for foo, bar, baz in bar 14 | end 15 | % 16 | for foo, bar, baz in bar 17 | foo 18 | end 19 | % 20 | for foo, bar, baz in bar 21 | # comment 22 | end 23 | % 24 | foo do 25 | # comment 26 | for bar in baz do 27 | bar 28 | end 29 | end 30 | - 31 | foo do 32 | # comment 33 | for bar in baz 34 | bar 35 | end 36 | end 37 | % 38 | for foo, in [[foo, bar]] 39 | foo 40 | end 41 | % 42 | for foo in bar # comment1 43 | # comment2 44 | end 45 | -------------------------------------------------------------------------------- /test/fixtures/alias.rb: -------------------------------------------------------------------------------- 1 | % 2 | alias foo bar 3 | % 4 | alias << push 5 | % 6 | alias in within 7 | % 8 | alias in IN 9 | % 10 | alias :foo :bar 11 | - 12 | alias foo bar 13 | % 14 | alias :"foo" :bar 15 | - 16 | alias :"foo" bar 17 | % 18 | alias :foo :"bar" 19 | - 20 | alias foo :"bar" 21 | % 22 | alias foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo bar 23 | - 24 | alias foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 25 | bar 26 | % 27 | alias foo bar # comment 28 | % 29 | alias foo # comment 30 | bar 31 | % 32 | alias foo # comment1 33 | bar # comment2 34 | -------------------------------------------------------------------------------- /lib/syntax_tree/search.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | # Provides an interface for searching for a pattern of nodes against a 5 | # subtree of an AST. 6 | class Search 7 | attr_reader :pattern 8 | 9 | def initialize(pattern) 10 | @pattern = pattern 11 | end 12 | 13 | def scan(root) 14 | return to_enum(__method__, root) unless block_given? 15 | queue = [root] 16 | 17 | until queue.empty? 18 | node = queue.shift 19 | next unless node 20 | 21 | yield node if pattern.call(node) 22 | queue += node.child_nodes 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/fixtures/xstring_literal.rb: -------------------------------------------------------------------------------- 1 | % 2 | `foo` 3 | % 4 | `foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo` 5 | % 6 | `foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo`.to_s 7 | % 8 | %x[foo] 9 | - 10 | `foo` 11 | % 12 | %x[foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo] 13 | - 14 | `foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo` 15 | % 16 | %x[foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo].to_s 17 | - 18 | `foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo`.to_s 19 | % 20 | `foo` # comment 21 | -------------------------------------------------------------------------------- /test/idempotency_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | return if !ENV["CI"] || RUBY_ENGINE == "truffleruby" 4 | require_relative "test_helper" 5 | 6 | module SyntaxTree 7 | class IdempotencyTest < Minitest::Test 8 | Dir[File.join(RbConfig::CONFIG["libdir"], "**/*.rb")].each do |filepath| 9 | define_method(:"test_#{filepath}") do 10 | source = SyntaxTree.read(filepath) 11 | formatted = SyntaxTree.format(source) 12 | 13 | assert_equal( 14 | formatted, 15 | SyntaxTree.format(formatted), 16 | "expected #{filepath} to be formatted idempotently" 17 | ) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/syntax_tree/rake/check_task.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "task" 4 | 5 | module SyntaxTree 6 | module Rake 7 | # A Rake task that runs check on a set of source files. 8 | # 9 | # Example: 10 | # 11 | # require "syntax_tree/rake/check_task" 12 | # 13 | # SyntaxTree::Rake::CheckTask.new do |t| 14 | # t.source_files = "{app,config,lib}/**/*.rb" 15 | # end 16 | # 17 | # This will create task that can be run with: 18 | # 19 | # rake stree:check 20 | # 21 | class CheckTask < Task 22 | private 23 | 24 | def command 25 | "check" 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/syntax_tree/rake/write_task.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "task" 4 | 5 | module SyntaxTree 6 | module Rake 7 | # A Rake task that runs write on a set of source files. 8 | # 9 | # Example: 10 | # 11 | # require "syntax_tree/rake/write_task" 12 | # 13 | # SyntaxTree::Rake::WriteTask.new do |t| 14 | # t.source_files = "{app,config,lib}/**/*.rb" 15 | # end 16 | # 17 | # This will create task that can be run with: 18 | # 19 | # rake stree:write 20 | # 21 | class WriteTask < Task 22 | private 23 | 24 | def command 25 | "write" 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/fixtures/bodystmt.rb: -------------------------------------------------------------------------------- 1 | % 2 | begin 3 | foo 4 | rescue Foo 5 | foo 6 | rescue Bar 7 | foo 8 | else 9 | foo 10 | ensure 11 | foo 12 | end 13 | % 14 | begin 15 | foo 16 | rescue Foo 17 | foo 18 | rescue Bar 19 | foo 20 | end 21 | % 22 | begin 23 | foo 24 | rescue Foo 25 | foo 26 | rescue Bar 27 | foo 28 | else 29 | foo 30 | end 31 | % 32 | begin 33 | foo 34 | ensure 35 | foo 36 | end 37 | % 38 | begin 39 | rescue StandardError 40 | else # else 41 | end 42 | % 43 | begin 44 | ensure # ensure 45 | end 46 | % 47 | begin 48 | rescue # rescue 49 | else # else 50 | ensure # ensure 51 | end 52 | - 53 | begin 54 | rescue StandardError # rescue 55 | else # else 56 | ensure # ensure 57 | end 58 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2.4.0 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | run: gh pr merge --auto --merge "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 23 | -------------------------------------------------------------------------------- /test/fixtures/rescue.rb: -------------------------------------------------------------------------------- 1 | % 2 | begin 3 | rescue 4 | end 5 | - 6 | begin 7 | rescue StandardError 8 | end 9 | % 10 | begin 11 | rescue => foo 12 | bar 13 | end 14 | % 15 | begin 16 | rescue Foo 17 | bar 18 | end 19 | % 20 | begin 21 | rescue Foo => foo 22 | bar 23 | end 24 | % 25 | begin 26 | rescue Foo, Bar 27 | end 28 | % 29 | begin 30 | rescue Foo, *Bar 31 | end 32 | % 33 | begin 34 | rescue Foo, Bar => foo 35 | end 36 | % 37 | begin 38 | rescue Foo, *Bar => foo 39 | end 40 | % # https://github.com/prettier/plugin-ruby/pull/1000 41 | begin 42 | rescue ::Foo 43 | end 44 | % 45 | begin 46 | rescue Foo 47 | rescue Bar 48 | end 49 | % 50 | begin 51 | rescue Foo # comment 52 | end 53 | % 54 | begin 55 | rescue Foo, *Bar # comment 56 | end 57 | -------------------------------------------------------------------------------- /test/fixtures/rassign.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo in bar 3 | % 4 | foo => bar 5 | % 6 | foooooooooooooooooooooooooooooooooooooo in barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 7 | - 8 | foooooooooooooooooooooooooooooooooooooo in 9 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 10 | % 11 | foooooooooooooooooooooooooooooooooooooo => barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 12 | - 13 | foooooooooooooooooooooooooooooooooooooo => 14 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 15 | % 16 | foo => [ 17 | ConstantConstantConstant, 18 | ConstantConstantConstant, 19 | ConstantConstantConstant, 20 | ConstantConstantConstant, 21 | ConstantConstantConstant 22 | ] 23 | % 24 | a in Integer 25 | b => [Integer => c] 26 | % 27 | case [0] 28 | when 0 29 | { a: 0 } => { a: } 30 | puts a 31 | end 32 | -------------------------------------------------------------------------------- /test/fixtures/return.rb: -------------------------------------------------------------------------------- 1 | % 2 | return 3 | % 4 | return foo 5 | % 6 | return foo, bar 7 | % 8 | return(foo) 9 | % 10 | return fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 11 | - 12 | return( 13 | fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 14 | ) 15 | % 16 | return(fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo) 17 | - 18 | return( 19 | fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 20 | ) 21 | % 22 | return (foo), bar 23 | % 24 | return( 25 | foo 26 | bar 27 | ) 28 | % 29 | return([1, 2, 3]) 30 | - 31 | return 1, 2, 3 32 | % 33 | return [1, 2, 3] 34 | - 35 | return 1, 2, 3 36 | % 37 | return [] 38 | % 39 | return [1] 40 | % 41 | return :foo => "bar" 42 | -------------------------------------------------------------------------------- /test/fixtures/assign.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo = bar 3 | % 4 | foo = 5 | begin 6 | bar 7 | end 8 | % 9 | foo = <<~HERE 10 | bar 11 | HERE 12 | % 13 | foo = %s[ 14 | bar 15 | ] 16 | % 17 | foo = barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 18 | - 19 | foo = 20 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 21 | % 22 | foo = [barrrrrrrrrrrrrrrrrrrrr, barrrrrrrrrrrrrrrrrrrrr, barrrrrrrrrrrrrrrrrrrrr] 23 | - 24 | foo = [ 25 | barrrrrrrrrrrrrrrrrrrrr, 26 | barrrrrrrrrrrrrrrrrrrrr, 27 | barrrrrrrrrrrrrrrrrrrrr 28 | ] 29 | % 30 | foo = { bar1: bazzzzzzzzzzzzzzz, bar2: bazzzzzzzzzzzzzzz, bar3: bazzzzzzzzzzzzzzz } 31 | - 32 | foo = { 33 | bar1: bazzzzzzzzzzzzzzz, 34 | bar2: bazzzzzzzzzzzzzzz, 35 | bar3: bazzzzzzzzzzzzzzz 36 | } 37 | -------------------------------------------------------------------------------- /bin/profile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/inline" 5 | 6 | gemfile do 7 | source "https://rubygems.org" 8 | gem "stackprof" 9 | gem "prettier_print" 10 | end 11 | 12 | $:.unshift(File.expand_path("../lib", __dir__)) 13 | require "syntax_tree" 14 | 15 | StackProf.run(mode: :cpu, out: "tmp/profile.dump", raw: true) do 16 | Dir[File.join(RbConfig::CONFIG["libdir"], "**/*.rb")].each do |filepath| 17 | SyntaxTree.format(SyntaxTree.read(filepath)) 18 | end 19 | end 20 | 21 | File.open("tmp/flamegraph.html", "w") do |file| 22 | report = Marshal.load(IO.binread("tmp/profile.dump")) 23 | StackProf::Report.new(report).print_text 24 | StackProf::Report.new(report).print_d3_flamegraph(file) 25 | end 26 | 27 | `open tmp/flamegraph.html` 28 | -------------------------------------------------------------------------------- /test/location_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | module SyntaxTree 6 | class LocationTest < Minitest::Test 7 | def test_lines 8 | location = Location.fixed(line: 1, char: 0, column: 0) 9 | location = location.to(Location.fixed(line: 3, char: 3, column: 3)) 10 | 11 | assert_equal(1..3, location.lines) 12 | end 13 | 14 | def test_deconstruct 15 | location = Location.fixed(line: 1, char: 0, column: 0) 16 | 17 | assert_equal(1, location.start_line) 18 | assert_equal(0, location.start_char) 19 | assert_equal(0, location.start_column) 20 | end 21 | 22 | def test_deconstruct_keys 23 | location = Location.fixed(line: 1, char: 0, column: 0) 24 | 25 | assert_equal(1, location.start_line) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/fixtures/regexp_literal.rb: -------------------------------------------------------------------------------- 1 | % 2 | /foo/ 3 | % 4 | %r{foo} 5 | - 6 | /foo/ 7 | % 8 | %r/foo/ 9 | - 10 | /foo/ 11 | % 12 | %r[foo] 13 | - 14 | /foo/ 15 | % 16 | %r(foo) 17 | - 18 | /foo/ 19 | % 20 | %r{foo/bar/baz} 21 | % 22 | /foo #{bar} baz/ 23 | % 24 | /foo/i 25 | % 26 | %r{foo/bar/baz}mi 27 | % 28 | /#$&/ 29 | - 30 | /#{$&}/ 31 | % 32 | %r(a{b/c}) 33 | % 34 | %r[a}b/c] 35 | % 36 | %r(a}bc) 37 | - 38 | /a}bc/ 39 | % 40 | /\\A 41 | [[:digit:]]+ # 1 or more digits before the decimal point 42 | (\\. # Decimal point 43 | [[:digit:]]+ # 1 or more digits after the decimal point 44 | )? # The decimal point and following digits are optional 45 | \\Z/x 46 | % 47 | foo %r{ bar} 48 | % 49 | foo %r{= bar} 50 | % 51 | foo(/ bar/) 52 | % 53 | /foo\/bar/ 54 | - 55 | %r{foo/bar} 56 | % 57 | /foo\/bar\/#{baz}/ 58 | - 59 | %r{foo/bar/#{baz}} 60 | -------------------------------------------------------------------------------- /test/plugin/disable_auto_ternary_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | module SyntaxTree 6 | class DisableTernaryTest < Minitest::Test 7 | def test_short_if_else_unchanged 8 | assert_format(<<~RUBY) 9 | if true 10 | 1 11 | else 12 | 2 13 | end 14 | RUBY 15 | end 16 | 17 | def test_short_ternary_unchanged 18 | assert_format("true ? 1 : 2\n") 19 | end 20 | 21 | private 22 | 23 | def assert_format(expected, source = expected) 24 | options = Formatter::Options.new(disable_auto_ternary: true) 25 | formatter = Formatter.new(source, [], options: options) 26 | SyntaxTree.parse(source).format(formatter) 27 | 28 | formatter.flush 29 | assert_equal(expected, formatter.output.join) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/fixtures/if.rb: -------------------------------------------------------------------------------- 1 | % 2 | if foo 3 | end 4 | % 5 | if foo 6 | else 7 | end 8 | % 9 | if foo 10 | bar 11 | end 12 | - 13 | bar if foo 14 | % 15 | if foo 16 | bar 17 | else 18 | end 19 | % 20 | foo = if bar then baz end 21 | - 22 | foo = (baz if bar) 23 | % 24 | if foo += 1 25 | foo 26 | end 27 | % 28 | if (foo += 1) 29 | foo 30 | end 31 | % 32 | if foo 33 | a ? b : c 34 | end 35 | % 36 | if foo {} 37 | end 38 | % 39 | if not a 40 | b 41 | else 42 | c 43 | end 44 | % 45 | if not(a) 46 | b 47 | else 48 | c 49 | end 50 | - 51 | not(a) ? b : c 52 | % 53 | (if foo then bar else baz end) 54 | - 55 | ( 56 | if foo 57 | bar 58 | else 59 | baz 60 | end 61 | ) 62 | % 63 | if (x = x + 1).to_i 64 | x 65 | end 66 | % 67 | if true # comment1 68 | # comment2 69 | end 70 | % 71 | result = 72 | if false && val = 1 73 | "A" 74 | else 75 | "B" 76 | end 77 | -------------------------------------------------------------------------------- /test/fixtures/break.rb: -------------------------------------------------------------------------------- 1 | % 2 | tap { break } 3 | % 4 | tap { break foo } 5 | % 6 | tap { break foo, bar } 7 | % 8 | tap { break(foo) } 9 | % 10 | tap { break fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo } 11 | - 12 | tap do 13 | break( 14 | fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 15 | ) 16 | end 17 | % 18 | tap { break(fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo) } 19 | - 20 | tap do 21 | break( 22 | fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 23 | ) 24 | end 25 | % 26 | tap { break (foo), bar } 27 | % 28 | tap do 29 | break( 30 | foo 31 | bar 32 | ) 33 | end 34 | % 35 | tap { break foo.bar :baz do |qux| qux end } 36 | - 37 | tap do 38 | break( 39 | foo.bar :baz do |qux| 40 | qux 41 | end 42 | ) 43 | end 44 | % 45 | tap { break :foo => "bar" } 46 | -------------------------------------------------------------------------------- /test/fixtures/when.rb: -------------------------------------------------------------------------------- 1 | % 2 | case 3 | when foo 4 | end 5 | % 6 | case 7 | when foo, bar 8 | baz 9 | end 10 | % 11 | case 12 | when foooooooooooooooooooooooooooooooooooo, barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 13 | baz 14 | end 15 | - 16 | case 17 | when foooooooooooooooooooooooooooooooooooo, 18 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 19 | baz 20 | end 21 | % 22 | case 23 | when foo then bar 24 | end 25 | - 26 | case 27 | when foo 28 | bar 29 | end 30 | % 31 | case 32 | when foooooooooooooooooo, barrrrrrrrrrrrrrrrrr, bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 33 | end 34 | - 35 | case 36 | when foooooooooooooooooo, barrrrrrrrrrrrrrrrrr, 37 | bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 38 | end 39 | % 40 | case 41 | when foo 42 | when bar 43 | end 44 | % 45 | case 46 | when foo 47 | else 48 | end 49 | % 50 | case 51 | when foo.. then 52 | end 53 | % 54 | case 55 | when foo... then 56 | end 57 | % 58 | case 59 | when foo # comment 60 | end 61 | -------------------------------------------------------------------------------- /test/fixtures/assoc.rb: -------------------------------------------------------------------------------- 1 | % 2 | { foo: bar } 3 | % 4 | { foo: barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr } 5 | - 6 | { 7 | foo: 8 | barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 9 | } 10 | % 11 | { 12 | foo: 13 | bar 14 | } 15 | - 16 | { foo: bar } 17 | % 18 | { 19 | foo: [ 20 | fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 21 | ] 22 | } 23 | % 24 | { 25 | foo: { 26 | fooooooooooooooooooooooooooooooooo: ooooooooooooooooooooooooooooooooooooooo 27 | } 28 | } 29 | % 30 | { 31 | foo: -> do 32 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 33 | end 34 | } 35 | % 36 | { 37 | foo: %w[ 38 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 39 | ] 40 | } 41 | % # >= 3.1.0 42 | { foo: } 43 | % 44 | { "foo": "bar" } 45 | - 46 | { foo: "bar" } 47 | % 48 | { "foo #{bar}": "baz" } 49 | % 50 | { "foo=": "baz" } 51 | % # >= 3.1.0 52 | { bar => 1, baz: } 53 | % # >= 3.1.0 54 | { baz:, bar => 1 } 55 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | ci: 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | ruby: 13 | - '2.7.0' 14 | - '3.0' 15 | - '3.1' 16 | - '3.2' 17 | - '3.3' 18 | - '3.4' 19 | - truffleruby-head 20 | name: CI 21 | runs-on: ubuntu-latest 22 | env: 23 | CI: true 24 | # TESTOPTS: --verbose 25 | steps: 26 | - uses: actions/checkout@master 27 | - uses: ruby/setup-ruby@v1 28 | with: 29 | bundler-cache: true 30 | ruby-version: ${{ matrix.ruby }} 31 | - name: Test 32 | run: bundle exec rake test 33 | 34 | check: 35 | name: Check 36 | runs-on: ubuntu-latest 37 | env: 38 | CI: true 39 | steps: 40 | - uses: actions/checkout@master 41 | - uses: ruby/setup-ruby@v1 42 | with: 43 | bundler-cache: true 44 | ruby-version: '3.2' 45 | - name: Check 46 | run: | 47 | bundle exec rake stree:check 48 | bundle exec rubocop 49 | -------------------------------------------------------------------------------- /test/fixtures/hshptn.rb: -------------------------------------------------------------------------------- 1 | % 2 | case foo 3 | in ** then 4 | end 5 | % 6 | case foo 7 | in bar: 8 | end 9 | % 10 | case foo 11 | in bar: bar 12 | end 13 | % 14 | case foo 15 | in bar:, baz: 16 | end 17 | - 18 | case foo 19 | in { bar:, baz: } 20 | end 21 | % 22 | case foo 23 | in bar: bar, baz: baz 24 | end 25 | - 26 | case foo 27 | in { bar: bar, baz: baz } 28 | end 29 | % 30 | case foo 31 | in **bar 32 | end 33 | % # >= 2.7.3 34 | case foo 35 | in { 36 | foo:, # comment1 37 | bar: # comment2 38 | } 39 | baz 40 | end 41 | % 42 | case foo 43 | in Foo[bar:] 44 | end 45 | % 46 | case foo 47 | in Foo[bar: bar] 48 | end 49 | % 50 | case foo 51 | in Foo[bar:, baz:] 52 | end 53 | % 54 | case foo 55 | in Foo[bar: bar, baz: baz] 56 | end 57 | % 58 | case foo 59 | in Foo[**bar] 60 | end 61 | % 62 | case foo 63 | in {} 64 | end 65 | % 66 | case foo 67 | in {} then 68 | end 69 | - 70 | case foo 71 | in {} 72 | end 73 | % 74 | case foo 75 | in **nil 76 | end 77 | % 78 | case foo 79 | in bar, { baz:, **nil } 80 | in qux: 81 | end 82 | - 83 | case foo 84 | in [bar, { baz:, **nil }] 85 | in qux: 86 | end 87 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | require "syntax_tree/rake_tasks" 6 | 7 | Rake.add_rakelib "tasks" 8 | 9 | Rake::TestTask.new(:test) do |t| 10 | t.libs << "test" 11 | t.libs << "lib" 12 | t.test_files = FileList["test/**/*_test.rb"] 13 | end 14 | 15 | task default: :test 16 | 17 | configure = ->(task) do 18 | task.source_files = 19 | FileList[ 20 | %w[ 21 | Gemfile 22 | Rakefile 23 | syntax_tree.gemspec 24 | lib/**/*.rb 25 | tasks/*.rake 26 | test/*.rb 27 | ] 28 | ] 29 | 30 | # Since Syntax Tree supports back to Ruby 2.7.0, we need to make sure that we 31 | # format our code such that it's compatible with that version. This actually 32 | # has very little effect on the output, the only change at the moment is that 33 | # Ruby < 2.7.3 didn't allow a newline before the closing brace of a hash 34 | # pattern. 35 | task.target_ruby_version = Gem::Version.new("2.7.0") 36 | end 37 | 38 | SyntaxTree::Rake::CheckTask.new(&configure) 39 | SyntaxTree::Rake::WriteTask.new(&configure) 40 | -------------------------------------------------------------------------------- /test/language_server/inlay_hints_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | require "syntax_tree/language_server" 5 | 6 | module SyntaxTree 7 | class LanguageServer 8 | class InlayHintsTest < Minitest::Test 9 | def test_assignments_in_parameters 10 | assert_hints(2, "def foo(a = b = c); end") 11 | end 12 | 13 | def test_operators_in_binaries 14 | assert_hints(2, "1 + 2 * 3") 15 | end 16 | 17 | def test_binaries_in_assignments 18 | assert_hints(2, "a = 1 + 2") 19 | end 20 | 21 | def test_nested_ternaries 22 | assert_hints(2, "a ? b : c ? d : e") 23 | end 24 | 25 | def test_bare_rescue 26 | assert_hints(1, "begin; rescue; end") 27 | end 28 | 29 | def test_unary_in_binary 30 | assert_hints(2, "-a + b") 31 | end 32 | 33 | private 34 | 35 | def assert_hints(expected, source) 36 | visitor = InlayHints.new 37 | SyntaxTree.parse(source).accept(visitor) 38 | 39 | assert_equal(expected, visitor.hints.length) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-present Kevin Newton 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 | -------------------------------------------------------------------------------- /syntax_tree.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/syntax_tree/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "syntax_tree" 7 | spec.version = SyntaxTree::VERSION 8 | spec.authors = ["Kevin Newton"] 9 | spec.email = ["kddnewton@gmail.com"] 10 | 11 | spec.summary = "A parser based on ripper" 12 | spec.homepage = "https://github.com/kddnewton/syntax_tree" 13 | spec.license = "MIT" 14 | spec.metadata = { "rubygems_mfa_required" => "true" } 15 | 16 | spec.files = 17 | Dir.chdir(__dir__) do 18 | `git ls-files -z`.split("\x0") 19 | .reject { |f| f.match(%r{^(test|spec|features)/}) } 20 | end 21 | 22 | spec.required_ruby_version = ">= 2.7.0" 23 | 24 | spec.bindir = "exe" 25 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 26 | spec.require_paths = %w[lib] 27 | 28 | spec.add_dependency "prettier_print", ">= 1.2.0" 29 | 30 | spec.add_development_dependency "bundler" 31 | spec.add_development_dependency "minitest" 32 | spec.add_development_dependency "rake" 33 | spec.add_development_dependency "rubocop" 34 | spec.add_development_dependency "simplecov" 35 | end 36 | -------------------------------------------------------------------------------- /bin/bench: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/inline" 5 | 6 | gemfile do 7 | source "https://rubygems.org" 8 | gem "benchmark-ips" 9 | gem "parser", require: "parser/current" 10 | gem "ruby_parser" 11 | end 12 | 13 | $:.unshift(File.expand_path("../lib", __dir__)) 14 | require "syntax_tree" 15 | 16 | def compare(filepath) 17 | prefix = "#{File.expand_path("..", __dir__)}/" 18 | puts "=== #{filepath.delete_prefix(prefix)} ===" 19 | 20 | source = File.read(filepath) 21 | 22 | Benchmark.ips do |x| 23 | x.report("syntax_tree") { SyntaxTree.parse(source) } 24 | x.report("parser") { Parser::CurrentRuby.parse(source) } 25 | x.report("ruby_parser") { RubyParser.new.parse(source) } 26 | x.compare! 27 | end 28 | end 29 | 30 | filepaths = ARGV 31 | 32 | # If the user didn't supply any files to parse to benchmark, then we're going to 33 | # default to parsing this file and the main syntax_tree file (a small and large 34 | # file). 35 | if filepaths.empty? 36 | filepaths = [ 37 | File.expand_path("bench", __dir__), 38 | File.expand_path("../lib/syntax_tree/node.rb", __dir__) 39 | ] 40 | end 41 | 42 | filepaths.each { |filepath| compare(filepath) } 43 | -------------------------------------------------------------------------------- /test/mutation_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | module SyntaxTree 6 | class MutationTest < Minitest::Test 7 | def test_mutates_based_on_patterns 8 | source = <<~RUBY 9 | if a = b 10 | c 11 | end 12 | RUBY 13 | 14 | expected = <<~RUBY 15 | if (a = b) 16 | c 17 | end 18 | RUBY 19 | 20 | program = SyntaxTree.parse(source).accept(build_mutation) 21 | assert_equal(expected, SyntaxTree::Formatter.format(source, program)) 22 | end 23 | 24 | private 25 | 26 | def build_mutation 27 | SyntaxTree.mutation do |mutation| 28 | mutation.mutate("IfNode[predicate: Assign | OpAssign]") do |node| 29 | # Get the existing If's predicate node 30 | predicate = node.predicate 31 | 32 | # Create a new predicate node that wraps the existing predicate node 33 | # in parentheses 34 | predicate = 35 | SyntaxTree::Paren.new( 36 | lparen: SyntaxTree::LParen.default, 37 | contents: predicate, 38 | location: predicate.location 39 | ) 40 | 41 | # Return a copy of this node with the new predicate 42 | node.copy(predicate: predicate) 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/syntax_tree/json_visitor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module SyntaxTree 6 | # This visitor transforms the AST into a hash that contains only primitives 7 | # that can be easily serialized into JSON. 8 | class JSONVisitor < FieldVisitor 9 | attr_reader :target 10 | 11 | def initialize 12 | @target = nil 13 | end 14 | 15 | private 16 | 17 | def comments(node) 18 | target[:comments] = visit_all(node.comments) 19 | end 20 | 21 | def field(name, value) 22 | target[name] = value.is_a?(Node) ? visit(value) : value 23 | end 24 | 25 | def list(name, values) 26 | target[name] = visit_all(values) 27 | end 28 | 29 | def node(node, type) 30 | previous = @target 31 | @target = { type: type, location: visit_location(node.location) } 32 | yield 33 | @target 34 | ensure 35 | @target = previous 36 | end 37 | 38 | def pairs(name, values) 39 | target[name] = values.map { |(key, value)| [visit(key), visit(value)] } 40 | end 41 | 42 | def text(name, value) 43 | target[name] = value 44 | end 45 | 46 | def visit_location(location) 47 | [ 48 | location.start_line, 49 | location.start_char, 50 | location.end_line, 51 | location.end_char 52 | ] 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/fixtures/next.rb: -------------------------------------------------------------------------------- 1 | % 2 | tap { next } 3 | % 4 | tap { next foo } 5 | % 6 | tap { next foo, bar } 7 | % 8 | tap { next(foo) } 9 | % 10 | tap { next fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo } 11 | - 12 | tap do 13 | next( 14 | fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 15 | ) 16 | end 17 | % 18 | tap { next(fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo) } 19 | - 20 | tap do 21 | next( 22 | fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 23 | ) 24 | end 25 | % 26 | tap { next (foo), bar } 27 | % 28 | tap do 29 | next( 30 | foo 31 | bar 32 | ) 33 | end 34 | % 35 | tap { next(1) } 36 | - 37 | tap { next 1 } 38 | % 39 | tap { next(1.0) } 40 | - 41 | tap { next 1.0 } 42 | % 43 | tap { next($a) } 44 | - 45 | tap { next $a } 46 | % 47 | tap { next(@@a) } 48 | - 49 | tap { next @@a } 50 | % 51 | tap { next(self) } 52 | - 53 | tap { next self } 54 | % 55 | tap { next(@a) } 56 | - 57 | tap { next @a } 58 | % 59 | tap { next(A) } 60 | - 61 | tap { next A } 62 | % 63 | tap { next([]) } 64 | - 65 | tap { next [] } 66 | % 67 | tap { next([1]) } 68 | - 69 | tap { next [1] } 70 | % 71 | tap { next([1, 2]) } 72 | - 73 | tap { next 1, 2 } 74 | % 75 | tap { next fun foo do end } 76 | - 77 | tap do 78 | next( 79 | fun foo do 80 | end 81 | ) 82 | end 83 | -------------------------------------------------------------------------------- /test/fixtures/call.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo.bar 3 | % 4 | foo.bar(baz) 5 | % 6 | foo.() 7 | % 8 | foo::() 9 | - 10 | foo.() 11 | % 12 | foo.(1) 13 | % 14 | foo::(1) 15 | - 16 | foo.(1) 17 | % 18 | foo.bar.baz.qux 19 | % 20 | fooooooooooooooooo.barrrrrrrrrrrrrrrrrrr {}.bazzzzzzzzzzzzzzzzzzzzzzzzzz.quxxxxxxxxx 21 | - 22 | fooooooooooooooooo 23 | .barrrrrrrrrrrrrrrrrrr {} 24 | .bazzzzzzzzzzzzzzzzzzzzzzzzzz 25 | .quxxxxxxxxx 26 | % 27 | foo. # comment 28 | bar 29 | % 30 | foo 31 | .bar 32 | .baz # comment 33 | .qux 34 | .quux 35 | % 36 | foo 37 | .bar 38 | .baz. 39 | # comment 40 | qux 41 | .quux 42 | % 43 | { a: 1, b: 2 }.fooooooooooooooooo.barrrrrrrrrrrrrrrrrrr.bazzzzzzzzzzzz.quxxxxxxxxxxxx 44 | - 45 | { a: 1, b: 2 }.fooooooooooooooooo 46 | .barrrrrrrrrrrrrrrrrrr 47 | .bazzzzzzzzzzzz 48 | .quxxxxxxxxxxxx 49 | % 50 | fooooooooooooooooo.barrrrrrrrrrrrrrrrrrr.bazzzzzzzzzzzz.quxxxxxxxxxxxx.each { block } 51 | - 52 | fooooooooooooooooo.barrrrrrrrrrrrrrrrrrr.bazzzzzzzzzzzz.quxxxxxxxxxxxx.each do 53 | block 54 | end 55 | % 56 | foo.bar.baz.each do 57 | block1 58 | block2 59 | end 60 | % 61 | a b do 62 | end.c d 63 | % 64 | self. 65 | =begin 66 | =end 67 | to_s 68 | % 69 | fooooooooooooooooooooooooooooooooooo.barrrrrrrrrrrrrrrrrrrrrrrrrrrrrr.where.not(:id).order(:id) 70 | - 71 | fooooooooooooooooooooooooooooooooooo 72 | .barrrrrrrrrrrrrrrrrrrrrrrrrrrrrr 73 | .where.not(:id) 74 | .order(:id) 75 | -------------------------------------------------------------------------------- /test/fixtures/params.rb: -------------------------------------------------------------------------------- 1 | % 2 | def foo(req) 3 | end 4 | % 5 | def foo(req1, req2) 6 | end 7 | % 8 | def foo(optl = foo) 9 | end 10 | % 11 | def foo(optl1 = foo, optl2 = bar) 12 | end 13 | % 14 | def foo(*) 15 | end 16 | % 17 | def foo(*rest) 18 | end 19 | % # >= 2.7.3 20 | def foo(...) 21 | end 22 | % 23 | def foo(*, post) 24 | end 25 | % 26 | def foo(*, post1, post2) 27 | end 28 | % 29 | def foo(key:) 30 | end 31 | % 32 | def foo(key1:, key2:) 33 | end 34 | % 35 | def foo(key: foo) 36 | end 37 | % 38 | def foo(key1: foo, key2: bar) 39 | end 40 | % 41 | def foo(**) 42 | end 43 | % 44 | def foo(**kwrest) 45 | end 46 | % 47 | def foo(&block) 48 | end 49 | % 50 | def foo(req1, req2, optl = foo, *rest, key1:, key2: bar, **kwrest, &block) 51 | end 52 | % 53 | foo { |req| } 54 | % 55 | foo { |req1, req2| } 56 | % 57 | foo { |optl = foo| } 58 | % 59 | foo { |optl1 = foo, optl2 = bar| } 60 | % 61 | foo { |*| } 62 | % 63 | foo { |*rest| } 64 | % 65 | foo { |req,| } 66 | % 67 | foo { |*, post| } 68 | % 69 | foo { |*, post1, post2| } 70 | % 71 | foo { |key:| } 72 | % 73 | foo { |key1:, key2:| } 74 | % 75 | foo { |key: foo| } 76 | % 77 | foo { |key1: foo, key2: bar| } 78 | % 79 | foo { |**| } 80 | % 81 | foo { |**kwrest| } 82 | % 83 | foo { |&block| } 84 | % 85 | foo { |req1, req2, optl = foo, *rest, key1:, key2: bar, **kwrest, &block| } 86 | % 87 | foo do |foooooooooooooooooooooooooooooooooooooo, barrrrrrrrrrrrrrrrrrrrrrrrrrrrr| 88 | end 89 | -------------------------------------------------------------------------------- /test/plugin/single_quotes_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | module SyntaxTree 6 | class SingleQuotesTest < Minitest::Test 7 | def test_empty_string_literal 8 | assert_format("''\n", "\"\"") 9 | end 10 | 11 | def test_character_literal_with_double_quote 12 | assert_format("'\"'\n", "?\"") 13 | end 14 | 15 | def test_character_literal_with_singlee_quote 16 | assert_format("'\\''\n", "?'") 17 | end 18 | 19 | def test_string_literal 20 | assert_format("'string'\n", "\"string\"") 21 | end 22 | 23 | def test_string_literal_with_interpolation 24 | assert_format("\"\#{foo}\"\n") 25 | end 26 | 27 | def test_dyna_symbol 28 | assert_format(":'symbol'\n", ":\"symbol\"") 29 | end 30 | 31 | def test_single_quote_in_string 32 | assert_format("\"str'ing\"\n") 33 | end 34 | 35 | def test_label 36 | assert_format( 37 | "{ foo => foo, :'bar' => bar }\n", 38 | "{ foo => foo, \"bar\": bar }" 39 | ) 40 | end 41 | 42 | private 43 | 44 | def assert_format(expected, source = expected) 45 | options = Formatter::Options.new(quote: "'") 46 | formatter = Formatter.new(source, [], options: options) 47 | SyntaxTree.parse(source).format(formatter) 48 | 49 | formatter.flush 50 | assert_equal(expected, formatter.output.join) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/fixtures/aryptn.rb: -------------------------------------------------------------------------------- 1 | % 2 | case foo 3 | in [] 4 | end 5 | % 6 | case foo 7 | in [] then 8 | end 9 | - 10 | case foo 11 | in [] 12 | end 13 | % 14 | case foo 15 | in * then 16 | end 17 | - 18 | case foo 19 | in [*] 20 | end 21 | % 22 | case foo 23 | in _, _ 24 | end 25 | - 26 | case foo 27 | in [_, _] 28 | end 29 | % 30 | case foo 31 | in bar, baz 32 | end 33 | - 34 | case foo 35 | in [bar, baz] 36 | end 37 | % 38 | case foo 39 | in [bar] 40 | end 41 | % 42 | case foo 43 | in [bar] 44 | in [baz] 45 | end 46 | % 47 | case foo 48 | in [bar, baz] 49 | end 50 | % 51 | case foo 52 | in bar, *baz 53 | end 54 | - 55 | case foo 56 | in [bar, *baz] 57 | end 58 | % 59 | case foo 60 | in *bar, baz 61 | end 62 | - 63 | case foo 64 | in [*bar, baz] 65 | end 66 | % 67 | case foo 68 | in bar, *, baz 69 | end 70 | - 71 | case foo 72 | in [bar, *, baz] 73 | end 74 | % 75 | case foo 76 | in *, bar, baz 77 | end 78 | - 79 | case foo 80 | in [*, bar, baz] 81 | end 82 | % 83 | case foo 84 | in Constant[bar] 85 | end 86 | % 87 | case foo 88 | in Constant(bar) 89 | end 90 | - 91 | case foo 92 | in Constant[bar] 93 | end 94 | % 95 | case foo 96 | in Constant[bar, baz] 97 | end 98 | % 99 | case foo 100 | in bar, [baz, _] => qux 101 | end 102 | - 103 | case foo 104 | in [bar, [baz, _] => qux] 105 | end 106 | % 107 | case foo 108 | in bar, baz if bar == baz 109 | end 110 | - 111 | case foo 112 | in [bar, baz] if bar == baz 113 | end 114 | -------------------------------------------------------------------------------- /test/fixtures/fndptn.rb: -------------------------------------------------------------------------------- 1 | % 2 | case foo 3 | in *, bar, * then 4 | end 5 | - 6 | case foo 7 | in [*, bar, *] 8 | end 9 | % 10 | case foo 11 | in *, bar, *baz 12 | end 13 | - 14 | case foo 15 | in [*, bar, *baz] 16 | end 17 | % 18 | case foo 19 | in *foo, bar, *baz 20 | end 21 | - 22 | case foo 23 | in [*foo, bar, *baz] 24 | end 25 | % 26 | case foo 27 | in [*, bar, *] 28 | end 29 | % 30 | case foo 31 | in [*, bar, baz, qux, *] 32 | end 33 | % 34 | case foo 35 | in [*foo, bar, *] 36 | end 37 | % 38 | case foo 39 | in [*, bar, *baz] 40 | end 41 | % 42 | case foo 43 | in [*foo, bar, *baz] 44 | end 45 | % 46 | case foo 47 | in Foo[*, bar, *] 48 | end 49 | % 50 | case foo 51 | in Foo[*, bar, baz, qux, *] 52 | end 53 | % 54 | case foo 55 | in Foo[*foo, bar, *] 56 | end 57 | % 58 | case foo 59 | in Foo[*, bar, *baz] 60 | end 61 | % 62 | case foo 63 | in Foo[*foo, bar, *baz] 64 | end 65 | % 66 | case foo 67 | in Foo(*, bar, *) 68 | end 69 | - 70 | case foo 71 | in Foo[*, bar, *] 72 | end 73 | % 74 | case foo 75 | in Foo(*, bar, baz, qux, *) 76 | end 77 | - 78 | case foo 79 | in Foo[*, bar, baz, qux, *] 80 | end 81 | % 82 | case foo 83 | in Foo(*foo, bar, *) 84 | end 85 | - 86 | case foo 87 | in Foo[*foo, bar, *] 88 | end 89 | % 90 | case foo 91 | in Foo(*, bar, *baz) 92 | end 93 | - 94 | case foo 95 | in Foo[*, bar, *baz] 96 | end 97 | % 98 | case foo 99 | in Foo(*foo, bar, *baz) 100 | end 101 | - 102 | case foo 103 | in Foo[*foo, bar, *baz] 104 | end 105 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy rdoc to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow one concurrent deployment 18 | concurrency: 19 | group: "pages" 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | # Build job 24 | build: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v6 29 | - name: Setup Pages 30 | uses: actions/configure-pages@v5 31 | - name: Set up Ruby 32 | uses: ruby/setup-ruby@v1 33 | with: 34 | bundler-cache: true 35 | ruby-version: '3.1' 36 | - name: Generate docs 37 | run: | 38 | gem install rdoc 39 | rdoc --main README.md --op _site --exclude={Gemfile,Rakefile,"coverage/*","vendor/*","bin/*","test/*","tmp/*"} 40 | cp -r doc _site/doc 41 | - name: Upload artifact 42 | uses: actions/upload-pages-artifact@v4 43 | 44 | # Deployment job 45 | deploy: 46 | environment: 47 | name: github-pages 48 | url: ${{ steps.deployment.outputs.page_url }} 49 | runs-on: ubuntu-latest 50 | needs: build 51 | steps: 52 | - name: Deploy to GitHub Pages 53 | id: deployment 54 | uses: actions/deploy-pages@v4 55 | -------------------------------------------------------------------------------- /test/syntax_tree_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | module SyntaxTree 6 | class SyntaxTreeTest < Minitest::Test 7 | def test_empty 8 | void_stmt = SyntaxTree.parse("").statements.body.first 9 | assert_kind_of(VoidStmt, void_stmt) 10 | end 11 | 12 | def test_multibyte 13 | assign = SyntaxTree.parse("🎉 + 🎉").statements.body.first 14 | assert_equal(5, assign.location.end_char) 15 | end 16 | 17 | def test_next_statement_start 18 | source = <<~SOURCE 19 | def method # comment 20 | expression 21 | end 22 | SOURCE 23 | 24 | bodystmt = SyntaxTree.parse(source).statements.body.first.bodystmt 25 | assert_equal(20, bodystmt.start_char) 26 | end 27 | 28 | def test_parse_error 29 | assert_raises(Parser::ParseError) { SyntaxTree.parse("<>") } 30 | end 31 | 32 | def test_marshalable 33 | node = SyntaxTree.parse("1 + 2") 34 | assert_operator(node, :===, Marshal.load(Marshal.dump(node))) 35 | end 36 | 37 | def test_maxwidth_format 38 | assert_equal("foo +\n bar\n", SyntaxTree.format("foo + bar", 5)) 39 | end 40 | 41 | def test_read 42 | source = SyntaxTree.read(__FILE__) 43 | assert_equal(Encoding.default_external, source.encoding) 44 | 45 | source = SyntaxTree.read(File.expand_path("encoded.rb", __dir__)) 46 | assert_equal(Encoding::Shift_JIS, source.encoding) 47 | end 48 | 49 | def test_version 50 | refute_nil(VERSION) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/fixtures/array_literal.rb: -------------------------------------------------------------------------------- 1 | % 2 | [] 3 | % 4 | [foo, bar, baz] 5 | % 6 | [foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo, bar, baz] 7 | - 8 | [ 9 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo, 10 | bar, 11 | baz 12 | ] 13 | % 14 | [ 15 | foo, 16 | bar, 17 | baz 18 | ] 19 | - 20 | [foo, bar, baz] 21 | % 22 | fooooooooooooooooo = 1 23 | [fooooooooooooooooo, fooooooooooooooooo, fooooooooooooooooo, fooooooooooooooooo, fooooooooooooooooo, fooooooooooooooooo, fooooooooooooooooo, fooooooooooooooooo, fooooooooooooooooo, fooooooooooooooooo] 24 | - 25 | fooooooooooooooooo = 1 26 | [ 27 | fooooooooooooooooo, 28 | fooooooooooooooooo, 29 | fooooooooooooooooo, 30 | fooooooooooooooooo, 31 | fooooooooooooooooo, 32 | fooooooooooooooooo, 33 | fooooooooooooooooo, 34 | fooooooooooooooooo, 35 | fooooooooooooooooo, 36 | fooooooooooooooooo 37 | ] 38 | % 39 | [ 40 | # comment 41 | ] 42 | % 43 | ["foo"] 44 | % 45 | ["foo", "bar"] 46 | - 47 | %w[foo bar] 48 | % 49 | ["f", ?b] 50 | - 51 | %w[f b] 52 | % 53 | [ 54 | "foo", 55 | "bar" # comment 56 | ] 57 | % 58 | ["foo", "bar"] # comment 59 | - 60 | %w[foo bar] # comment 61 | % 62 | ["foo", :bar] 63 | % 64 | ["foo", "#{bar}"] 65 | % 66 | ["foo", " bar "] 67 | % 68 | ["foo", "bar\n"] 69 | % 70 | ["foo", "bar]"] 71 | % 72 | [:foo] 73 | % 74 | [:foo, :bar] 75 | - 76 | %i[foo bar] 77 | % 78 | [ 79 | :foo, 80 | :bar # comment 81 | ] 82 | % 83 | [:foo, :bar] # comment 84 | - 85 | %i[foo bar] # comment 86 | % 87 | [:foo, "bar"] 88 | % 89 | [:foo, :"bar"] 90 | % 91 | [foo, bar] # comment 92 | -------------------------------------------------------------------------------- /test/rake_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | require "syntax_tree/rake_tasks" 5 | 6 | module SyntaxTree 7 | module Rake 8 | class CheckTaskTest < Minitest::Test 9 | Invocation = Struct.new(:args) 10 | 11 | def test_task_command 12 | assert_raises(NotImplementedError) { Task.new.command } 13 | end 14 | 15 | def test_check_task 16 | source_files = "{app,config,lib}/**/*.rb" 17 | 18 | CheckTask.new do |t| 19 | t.source_files = source_files 20 | t.print_width = 100 21 | t.target_ruby_version = Gem::Version.new("2.6.0") 22 | end 23 | 24 | expected = [ 25 | "check", 26 | "--print-width=100", 27 | "--target-ruby-version=2.6.0", 28 | source_files 29 | ] 30 | 31 | invocation = invoke("stree:check") 32 | assert_equal(expected, invocation.args) 33 | end 34 | 35 | def test_write_task 36 | source_files = "{app,config,lib}/**/*.rb" 37 | WriteTask.new { |t| t.source_files = source_files } 38 | 39 | invocation = invoke("stree:write") 40 | assert_equal(["write", source_files], invocation.args) 41 | end 42 | 43 | private 44 | 45 | def invoke(task_name) 46 | invocation = nil 47 | stub = ->(args) { invocation = Invocation.new(args) } 48 | 49 | assert_raises SystemExit do 50 | SyntaxTree::CLI.stub(:run, stub) { ::Rake::Task[task_name].invoke } 51 | end 52 | 53 | invocation 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/formatting_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | module SyntaxTree 6 | class FormattingTest < Minitest::Test 7 | Fixtures.each_fixture do |fixture| 8 | define_method(:"test_formatted_#{fixture.name}") do 9 | assert_equal(fixture.formatted, SyntaxTree.format(fixture.source)) 10 | assert_syntax_tree(SyntaxTree.parse(fixture.source)) 11 | end 12 | end 13 | 14 | def test_format_class_level 15 | source = "1+1" 16 | 17 | assert_equal( 18 | "1 + 1\n", 19 | Formatter.format(source, SyntaxTree.parse(source)) 20 | ) 21 | end 22 | 23 | def test_stree_ignore 24 | source = <<~SOURCE 25 | # stree-ignore 26 | 1+1 27 | SOURCE 28 | 29 | assert_equal(source, SyntaxTree.format(source)) 30 | end 31 | 32 | def test_formatting_with_different_indentation_level 33 | source = <<~SOURCE 34 | def foo 35 | puts "a" 36 | end 37 | SOURCE 38 | 39 | # Default indentation 40 | assert_equal(source, SyntaxTree.format(source)) 41 | 42 | # Level 2 43 | assert_equal(<<-EXPECTED.chomp, SyntaxTree.format(source, 80, 2).rstrip) 44 | def foo 45 | puts "a" 46 | end 47 | EXPECTED 48 | 49 | # Level 4 50 | assert_equal(<<-EXPECTED.chomp, SyntaxTree.format(source, 80, 4).rstrip) 51 | def foo 52 | puts "a" 53 | end 54 | EXPECTED 55 | 56 | # Level 6 57 | assert_equal(<<-EXPECTED.chomp, SyntaxTree.format(source, 80, 6).rstrip) 58 | def foo 59 | puts "a" 60 | end 61 | EXPECTED 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/ractor_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Don't run this test if we're in a version of Ruby that doesn't have Ractors. 4 | return unless defined?(Ractor) 5 | 6 | # Don't run this version on Ruby 3.0.0. For some reason it just hangs within the 7 | # main Ractor waiting for this children. Not going to investigate it since it's 8 | # already been fixed in 3.1.0. 9 | return if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.1.0") 10 | 11 | require_relative "test_helper" 12 | 13 | module SyntaxTree 14 | class RactorTest < Minitest::Test 15 | def test_formatting 16 | ractors = 17 | filepaths.map do |filepath| 18 | # At the moment we have to parse in the main Ractor because Ripper is 19 | # not marked as a Ractor-safe extension. 20 | source = SyntaxTree.read(filepath) 21 | program = SyntaxTree.parse(source) 22 | 23 | with_silenced_warnings do 24 | Ractor.new(source, program, name: filepath) do |source, program| 25 | SyntaxTree::Formatter.format(source, program) 26 | end 27 | end 28 | end 29 | 30 | ractors.each { |ractor| assert_kind_of String, ractor.take } 31 | end 32 | 33 | private 34 | 35 | def filepaths 36 | Dir.glob(File.expand_path("../lib/syntax_tree/plugin/*.rb", __dir__)) 37 | end 38 | 39 | # Ractors still warn about usage, so I'm disabling that warning here just to 40 | # have clean test output. 41 | def with_silenced_warnings 42 | previous = $VERBOSE 43 | 44 | begin 45 | $VERBOSE = nil 46 | yield 47 | ensure 48 | $VERBOSE = previous 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | syntax_tree (6.3.0) 5 | prettier_print (>= 1.2.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | ast (2.4.3) 11 | docile (1.4.1) 12 | fiddle (1.1.8) 13 | json (2.15.2) 14 | language_server-protocol (3.17.0.5) 15 | lint_roller (1.1.0) 16 | minitest (5.26.1) 17 | parallel (1.27.0) 18 | parser (3.3.10.0) 19 | ast (~> 2.4.1) 20 | racc 21 | prettier_print (1.2.1) 22 | prism (1.6.0) 23 | racc (1.8.1) 24 | rainbow (3.1.1) 25 | rake (13.3.1) 26 | regexp_parser (2.11.3) 27 | rubocop (1.81.7) 28 | json (~> 2.3) 29 | language_server-protocol (~> 3.17.0.2) 30 | lint_roller (~> 1.1.0) 31 | parallel (~> 1.10) 32 | parser (>= 3.3.0.2) 33 | rainbow (>= 2.2.2, < 4.0) 34 | regexp_parser (>= 2.9.3, < 3.0) 35 | rubocop-ast (>= 1.47.1, < 2.0) 36 | ruby-progressbar (~> 1.7) 37 | unicode-display_width (>= 2.4.0, < 4.0) 38 | rubocop-ast (1.47.1) 39 | parser (>= 3.3.7.2) 40 | prism (~> 1.4) 41 | ruby-progressbar (1.13.0) 42 | simplecov (0.22.0) 43 | docile (~> 1.1) 44 | simplecov-html (~> 0.11) 45 | simplecov_json_formatter (~> 0.1) 46 | simplecov-html (0.13.1) 47 | simplecov_json_formatter (0.1.4) 48 | unicode-display_width (3.2.0) 49 | unicode-emoji (~> 4.1) 50 | unicode-emoji (4.1.0) 51 | 52 | PLATFORMS 53 | arm64-darwin-21 54 | ruby 55 | x86_64-darwin-19 56 | x86_64-darwin-21 57 | x86_64-linux 58 | 59 | DEPENDENCIES 60 | bundler 61 | fiddle 62 | minitest 63 | rake 64 | rubocop 65 | simplecov 66 | syntax_tree! 67 | 68 | BUNDLED WITH 69 | 2.3.6 70 | -------------------------------------------------------------------------------- /lib/syntax_tree/mermaid_visitor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | # This visitor transforms the AST into a mermaid flow chart. 5 | class MermaidVisitor < FieldVisitor 6 | attr_reader :flowchart, :target 7 | 8 | def initialize 9 | @flowchart = Mermaid.flowchart 10 | @target = nil 11 | end 12 | 13 | def visit_program(node) 14 | super 15 | flowchart.render 16 | end 17 | 18 | private 19 | 20 | def comments(node) 21 | # Ignore 22 | end 23 | 24 | def field(name, value) 25 | case value 26 | when nil 27 | # skip 28 | when Node 29 | flowchart.link(target, visit(value), name) 30 | else 31 | to = 32 | flowchart.node("#{target.id}_#{name}", value.inspect, shape: :stadium) 33 | flowchart.link(target, to, name) 34 | end 35 | end 36 | 37 | def list(name, values) 38 | values.each_with_index do |value, index| 39 | field("#{name}[#{index}]", value) 40 | end 41 | end 42 | 43 | def node(node, type) 44 | previous_target = target 45 | 46 | begin 47 | @target = flowchart.node("node_#{node.object_id}", type) 48 | yield 49 | @target 50 | ensure 51 | @target = previous_target 52 | end 53 | end 54 | 55 | def pairs(name, values) 56 | values.each_with_index do |(key, value), index| 57 | to = flowchart.node("#{target.id}_#{name}_#{index}", shape: :circle) 58 | 59 | flowchart.link(target, to, "#{name}[#{index}]") 60 | flowchart.link(to, visit(key), "[0]") 61 | flowchart.link(to, visit(value), "[1]") if value 62 | end 63 | end 64 | 65 | def text(name, value) 66 | field(name, value) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/visitor_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | module SyntaxTree 6 | class VisitorTest < Minitest::Test 7 | def test_visit_tree 8 | parsed_tree = SyntaxTree.parse(<<~RUBY) 9 | class Foo 10 | def foo; end 11 | 12 | class Bar 13 | def bar; end 14 | end 15 | end 16 | 17 | def baz; end 18 | RUBY 19 | 20 | visitor = DummyVisitor.new 21 | visitor.visit(parsed_tree) 22 | assert_equal(%w[Foo foo Bar bar baz], visitor.visited_nodes) 23 | end 24 | 25 | class DummyVisitor < Visitor 26 | attr_reader :visited_nodes 27 | 28 | def initialize 29 | super 30 | @visited_nodes = [] 31 | end 32 | 33 | visit_methods do 34 | def visit_class(node) 35 | @visited_nodes << node.constant.constant.value 36 | super 37 | end 38 | 39 | def visit_def(node) 40 | @visited_nodes << node.name.value 41 | end 42 | end 43 | end 44 | 45 | if defined?(DidYouMean.correct_error) 46 | def test_visit_method_correction 47 | error = assert_raises { Visitor.visit_method(:visit_binar) } 48 | message = 49 | if Exception.method_defined?(:detailed_message) 50 | error.detailed_message 51 | else 52 | error.message 53 | end 54 | 55 | assert_match(/visit_binary/, message) 56 | end 57 | end 58 | 59 | class VisitMethodsTestVisitor < BasicVisitor 60 | end 61 | 62 | def test_visit_methods 63 | VisitMethodsTestVisitor.visit_methods do 64 | assert_raises(BasicVisitor::VisitMethodError) do 65 | # In reality, this would be a method defined using the def keyword, 66 | # but we're using method_added here to trigger the checker so that we 67 | # aren't defining methods dynamically in the test suite. 68 | VisitMethodsTestVisitor.method_added(:visit_foo) 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/fixtures/lambda.rb: -------------------------------------------------------------------------------- 1 | % 2 | -> {} 3 | % 4 | -> { foo } 5 | % 6 | ->(foo, bar) { baz } 7 | % 8 | -> foo { bar } 9 | - 10 | ->(foo) { bar } 11 | % 12 | -> () { foo } 13 | - 14 | -> { foo } 15 | % 16 | -> { fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo } 17 | - 18 | -> do 19 | fooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 20 | end 21 | % 22 | ->(foo) { foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo } 23 | - 24 | ->(foo) do 25 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 26 | end 27 | % 28 | command foo, ->(bar) { bar } 29 | % 30 | command foo, ->(bar) { barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr } 31 | - 32 | command foo, 33 | ->(bar) { barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr } 34 | % 35 | command.call foo, ->(bar) { bar } 36 | % 37 | command.call foo, ->(bar) { barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr } 38 | - 39 | command.call foo, 40 | ->(bar) { barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr } 41 | % 42 | -> { -> foo do bar end.baz }.qux 43 | - 44 | -> { ->(foo) { bar }.baz }.qux 45 | % 46 | ->(;a) {} 47 | - 48 | ->(; a) {} 49 | % 50 | ->(; a) {} 51 | % 52 | ->(; a,b) {} 53 | - 54 | ->(; a, b) {} 55 | % 56 | ->(; a, b) {} 57 | % 58 | ->(; 59 | a 60 | ) {} 61 | - 62 | ->(; a) {} 63 | % 64 | ->(; a , 65 | b 66 | ) {} 67 | - 68 | ->(; a, b) {} 69 | % 70 | ->(a = (b; c)) {} 71 | - 72 | ->( 73 | a = ( 74 | b 75 | c 76 | ) 77 | ) do 78 | end 79 | % 80 | -> do # comment1 81 | # comment2 82 | end 83 | % # multiline lambda in a command 84 | command "arg" do 85 | -> { 86 | multi 87 | line 88 | } 89 | end 90 | - 91 | command "arg" do 92 | -> do 93 | multi 94 | line 95 | end 96 | end 97 | % # multiline lambda in a command call 98 | command.call "arg" do 99 | -> { 100 | multi 101 | line 102 | } 103 | end 104 | - 105 | command.call "arg" do 106 | -> do 107 | multi 108 | line 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/syntax_tree/pretty_print_visitor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | # This visitor pretty-prints the AST into an equivalent s-expression. 5 | class PrettyPrintVisitor < FieldVisitor 6 | attr_reader :q 7 | 8 | def initialize(q) 9 | @q = q 10 | end 11 | 12 | # This is here because we need to make sure the operator is cast to a string 13 | # before we print it out. 14 | def visit_binary(node) 15 | node(node, "binary") do 16 | field("left", node.left) 17 | text("operator", node.operator.to_s) 18 | field("right", node.right) 19 | comments(node) 20 | end 21 | end 22 | 23 | # This is here to make it a little nicer to look at labels since they 24 | # typically have their : at the end of the value. 25 | def visit_label(node) 26 | node(node, "label") do 27 | q.breakable 28 | q.text(":") 29 | q.text(node.value[0...-1]) 30 | comments(node) 31 | end 32 | end 33 | 34 | private 35 | 36 | def comments(node) 37 | return if node.comments.empty? 38 | 39 | q.breakable 40 | q.group(2, "(", ")") do 41 | q.seplist(node.comments) { |comment| q.pp(comment) } 42 | end 43 | end 44 | 45 | def field(_name, value) 46 | q.breakable 47 | q.pp(value) 48 | end 49 | 50 | def list(_name, values) 51 | q.breakable 52 | q.group(2, "(", ")") { q.seplist(values) { |value| q.pp(value) } } 53 | end 54 | 55 | def node(_node, type) 56 | q.group(2, "(", ")") do 57 | q.text(type) 58 | yield 59 | end 60 | end 61 | 62 | def pairs(_name, values) 63 | q.group(2, "(", ")") do 64 | q.seplist(values) do |(key, value)| 65 | q.pp(key) 66 | 67 | if value 68 | q.text("=") 69 | q.group(2) do 70 | q.breakable("") 71 | q.pp(value) 72 | end 73 | end 74 | end 75 | end 76 | end 77 | 78 | def text(_name, value) 79 | q.breakable 80 | q.text(value) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/fixtures/heredoc.rb: -------------------------------------------------------------------------------- 1 | % 2 | <<-FOO 3 | bar 4 | FOO 5 | % 6 | <<-FOO 7 | bar 8 | #{baz} 9 | FOO 10 | % 11 | <<-FOO 12 | foo 13 | #{<<-BAR} 14 | bar 15 | BAR 16 | FOO 17 | % 18 | <<-FOO 19 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 20 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 21 | #{foo} 22 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 23 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 24 | FOO 25 | % 26 | def foo 27 | <<~FOO.strip 28 | foo 29 | FOO 30 | end 31 | % 32 | <<~FOO 33 | bar 34 | FOO 35 | % 36 | <<~FOO 37 | bar 38 | #{baz} 39 | FOO 40 | % 41 | <<~FOO 42 | foo 43 | #{<<~BAR} 44 | bar 45 | BAR 46 | FOO 47 | % 48 | <<~FOO 49 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 50 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 51 | #{foo} 52 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 53 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 54 | FOO 55 | % 56 | def foo 57 | <<~FOO.strip 58 | foo 59 | FOO 60 | end 61 | % 62 | call(foo, bar, baz, <<~FOO) 63 | foo 64 | FOO 65 | % 66 | call(foo, bar, baz, <<~FOO, <<~BAR) 67 | foo 68 | FOO 69 | bar 70 | BAR 71 | % 72 | command foo, bar, baz, <<~FOO 73 | foo 74 | FOO 75 | % 76 | command foo, bar, baz, <<~FOO, <<~BAR 77 | foo 78 | FOO 79 | bar 80 | BAR 81 | % 82 | command.call foo, bar, baz, <<~FOO 83 | foo 84 | FOO 85 | % 86 | command.call foo, bar, baz, <<~FOO, <<~BAR 87 | foo 88 | FOO 89 | bar 90 | BAR 91 | % 92 | foo = <<~FOO.strip 93 | foo 94 | FOO 95 | % 96 | foo( 97 | <<~FOO, 98 | foo 99 | FOO 100 | foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo: 101 | :bar 102 | ) 103 | % 104 | foo(<<~FOO 105 | foo 106 | FOO 107 | ) { "foo" } 108 | - 109 | foo(<<~FOO) { "foo" } 110 | foo 111 | FOO 112 | -------------------------------------------------------------------------------- /test/plugin/trailing_comma_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | module SyntaxTree 6 | class TrailingCommaTest < Minitest::Test 7 | def test_arg_paren_flat 8 | assert_format("foo(a)\n") 9 | end 10 | 11 | def test_arg_paren_break 12 | assert_format(<<~EXPECTED, <<~SOURCE) 13 | foo( 14 | #{"a" * 80}, 15 | ) 16 | EXPECTED 17 | foo(#{"a" * 80}) 18 | SOURCE 19 | end 20 | 21 | def test_arg_paren_block 22 | assert_format(<<~EXPECTED, <<~SOURCE) 23 | foo( 24 | &#{"a" * 80} 25 | ) 26 | EXPECTED 27 | foo(&#{"a" * 80}) 28 | SOURCE 29 | end 30 | 31 | def test_arg_paren_command 32 | assert_format(<<~EXPECTED, <<~SOURCE) 33 | foo( 34 | bar #{"a" * 80} 35 | ) 36 | EXPECTED 37 | foo(bar #{"a" * 80}) 38 | SOURCE 39 | end 40 | 41 | def test_arg_paren_command_call 42 | assert_format(<<~EXPECTED, <<~SOURCE) 43 | foo( 44 | bar.baz #{"a" * 80} 45 | ) 46 | EXPECTED 47 | foo(bar.baz #{"a" * 80}) 48 | SOURCE 49 | end 50 | 51 | def test_array_literal_flat 52 | assert_format("[a]\n") 53 | end 54 | 55 | def test_array_literal_break 56 | assert_format(<<~EXPECTED, <<~SOURCE) 57 | [ 58 | #{"a" * 80}, 59 | ] 60 | EXPECTED 61 | [#{"a" * 80}] 62 | SOURCE 63 | end 64 | 65 | def test_hash_literal_flat 66 | assert_format("{ a: a }\n") 67 | end 68 | 69 | def test_hash_literal_break 70 | assert_format(<<~EXPECTED, <<~SOURCE) 71 | { 72 | a: 73 | #{"a" * 80}, 74 | } 75 | EXPECTED 76 | { a: #{"a" * 80} } 77 | SOURCE 78 | end 79 | 80 | private 81 | 82 | def assert_format(expected, source = expected) 83 | options = Formatter::Options.new(trailing_comma: true) 84 | formatter = Formatter.new(source, [], options: options) 85 | SyntaxTree.parse(source).format(formatter) 86 | 87 | formatter.flush 88 | assert_equal(expected, formatter.output.join) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /config/rubocop.yml: -------------------------------------------------------------------------------- 1 | # Disabling all Layout/* rules, as they're unnecessary when the user is using 2 | # Syntax Tree to handle all of the formatting. 3 | Layout: 4 | Enabled: false 5 | 6 | # Re-enable Layout/LineLength because certain cops that most projects use 7 | # (e.g. Style/IfUnlessModifier) require Layout/LineLength to be enabled. 8 | # By leaving it disabled, those rules will mis-fire. 9 | # 10 | # Users can always override these defaults in their own rubocop.yml files. 11 | # https://github.com/prettier/plugin-ruby/issues/825 12 | Layout/LineLength: 13 | Enabled: true 14 | 15 | Style/MultilineIfModifier: 16 | Enabled: false 17 | 18 | # Syntax Tree will expand empty methods to put the end keyword on the subsequent 19 | # line to reduce git diff noise. 20 | Style/EmptyMethod: 21 | EnforcedStyle: expanded 22 | 23 | # lambdas that are constructed with the lambda method call cannot be safely 24 | # turned into lambda literals without removing a method call. 25 | Style/Lambda: 26 | Enabled: false 27 | 28 | # When method chains with multiple blocks are chained together, rubocop will let 29 | # them pass if they're using braces but not if they're using do and end 30 | # keywords. Because we will break individual blocks down to using keywords if 31 | # they are multiline, this conflicts with rubocop. 32 | Style/MultilineBlockChain: 33 | Enabled: false 34 | 35 | # Syntax Tree by default uses double quotes, so changing the configuration here 36 | # to match that. 37 | Style/StringLiterals: 38 | EnforcedStyle: double_quotes 39 | 40 | Style/StringLiteralsInInterpolation: 41 | EnforcedStyle: double_quotes 42 | 43 | Style/QuotedSymbols: 44 | EnforcedStyle: double_quotes 45 | 46 | # We let users have a little more freedom with symbol and words arrays. If the 47 | # user only has an individual item like ["value"] then we don't bother 48 | # converting it because it ends up being just noise. 49 | Style/SymbolArray: 50 | Enabled: false 51 | 52 | Style/WordArray: 53 | Enabled: false 54 | 55 | # We don't support trailing commas in Syntax Tree by default, so just turning 56 | # these off for now. 57 | Style/TrailingCommaInArguments: 58 | Enabled: false 59 | 60 | Style/TrailingCommaInArrayLiteral: 61 | Enabled: false 62 | 63 | Style/TrailingCommaInHashLiteral: 64 | Enabled: false 65 | -------------------------------------------------------------------------------- /test/fixtures/command_call.rb: -------------------------------------------------------------------------------- 1 | % 2 | foo.bar baz 3 | % 4 | foo.bar barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr, bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 5 | - 6 | foo.bar barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr, 7 | bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 8 | % 9 | expect(foo).to receive(fooooooooooooooooooooooooooooooooooooooooooooooooooooooooo) 10 | - 11 | expect(foo).to receive( 12 | fooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 13 | ) 14 | % 15 | expect(foo).not_to receive(fooooooooooooooooooooooooooooooooooooooooooooooooooooooooo) 16 | - 17 | expect(foo).not_to receive( 18 | fooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 19 | ) 20 | % 21 | expect(foo).to_not receive(fooooooooooooooooooooooooooooooooooooooooooooooooooooooooo) 22 | - 23 | expect(foo).to_not receive( 24 | fooooooooooooooooooooooooooooooooooooooooooooooooooooooooo 25 | ) 26 | % 27 | foo.bar baz {} 28 | % 29 | foo.bar baz do 30 | end 31 | % 32 | foo. 33 | # comment 34 | bar baz 35 | % 36 | foo.bar baz ? qux : qaz 37 | % 38 | expect foo, bar.map { |i| { quux: bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz } } 39 | - 40 | expect foo, 41 | bar.map { |i| 42 | { 43 | quux: 44 | bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 45 | } 46 | } 47 | % 48 | expect(foo, bar.map { |i| {quux: bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz} }) 49 | - 50 | expect( 51 | foo, 52 | bar.map do |i| 53 | { 54 | quux: 55 | bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 56 | } 57 | end 58 | ) 59 | % 60 | expect(foo.map { |i| { bar: i.bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz } } ).to match(baz.map { |i| { bar: i.bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz } }) 61 | - 62 | expect( 63 | foo.map do |i| 64 | { 65 | bar: 66 | i.bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 67 | } 68 | end 69 | ).to match( 70 | baz.map do |i| 71 | { 72 | bar: 73 | i.bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz 74 | } 75 | end 76 | ) 77 | -------------------------------------------------------------------------------- /lib/syntax_tree/rake/task.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rake" 4 | require "rake/tasklib" 5 | 6 | require "syntax_tree" 7 | require "syntax_tree/cli" 8 | 9 | module SyntaxTree 10 | module Rake 11 | # A parent Rake task that runs a command on a set of source files. 12 | class Task < ::Rake::TaskLib 13 | # Name of the task. 14 | attr_accessor :name 15 | 16 | # Glob pattern to match source files. 17 | # Defaults to 'lib/**/*.rb'. 18 | attr_accessor :source_files 19 | 20 | # The set of plugins to require. 21 | # Defaults to []. 22 | attr_accessor :plugins 23 | 24 | # Max line length. 25 | # Defaults to 80. 26 | attr_accessor :print_width 27 | 28 | # The target Ruby version to use for formatting. 29 | # Defaults to Gem::Version.new(RUBY_VERSION). 30 | attr_accessor :target_ruby_version 31 | 32 | # Glob pattern to ignore source files. 33 | # Defaults to ''. 34 | attr_accessor :ignore_files 35 | 36 | def initialize( 37 | name = :"stree:#{command}", 38 | source_files = ::Rake::FileList["lib/**/*.rb"], 39 | plugins = [], 40 | print_width = DEFAULT_PRINT_WIDTH, 41 | target_ruby_version = Gem::Version.new(RUBY_VERSION), 42 | ignore_files = "" 43 | ) 44 | @name = name 45 | @source_files = source_files 46 | @plugins = plugins 47 | @print_width = print_width 48 | @target_ruby_version = target_ruby_version 49 | @ignore_files = ignore_files 50 | 51 | yield self if block_given? 52 | define_task 53 | end 54 | 55 | private 56 | 57 | # This method needs to be overridden in the child tasks. 58 | def command 59 | raise NotImplementedError 60 | end 61 | 62 | def define_task 63 | desc "Runs `stree #{command}` over source files" 64 | task(name) { run_task } 65 | end 66 | 67 | def run_task 68 | arguments = [command] 69 | arguments << "--plugins=#{plugins.join(",")}" if plugins.any? 70 | 71 | if print_width != DEFAULT_PRINT_WIDTH 72 | arguments << "--print-width=#{print_width}" 73 | end 74 | 75 | if target_ruby_version != Gem::Version.new(RUBY_VERSION) 76 | arguments << "--target-ruby-version=#{target_ruby_version}" 77 | end 78 | 79 | arguments << "--ignore-files=#{ignore_files}" if ignore_files != "" 80 | 81 | abort if SyntaxTree::CLI.run(arguments + Array(source_files)) != 0 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/syntax_tree/match_visitor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | # This visitor transforms the AST into a Ruby pattern matching expression that 5 | # would match correctly against the AST. 6 | class MatchVisitor < FieldVisitor 7 | attr_reader :q 8 | 9 | def initialize(q) 10 | @q = q 11 | end 12 | 13 | def visit(node) 14 | case node 15 | when Node 16 | super 17 | when String 18 | # pp will split up a string on newlines and concat them together using a 19 | # "+" operator. This breaks the pattern matching expression. So instead 20 | # we're going to check here for strings and manually put the entire 21 | # value into the output buffer. 22 | q.text(node.inspect) 23 | else 24 | node.pretty_print(q) 25 | end 26 | end 27 | 28 | private 29 | 30 | def comments(node) 31 | return if node.comments.empty? 32 | 33 | q.nest(0) do 34 | q.text("comments: [") 35 | q.indent do 36 | q.breakable("") 37 | q.seplist(node.comments) { |comment| visit(comment) } 38 | end 39 | q.breakable("") 40 | q.text("]") 41 | end 42 | end 43 | 44 | def field(name, value) 45 | q.nest(0) do 46 | q.text(name) 47 | q.text(": ") 48 | visit(value) 49 | end 50 | end 51 | 52 | def list(name, values) 53 | q.group do 54 | q.text(name) 55 | q.text(": [") 56 | q.indent do 57 | q.breakable("") 58 | q.seplist(values) { |value| visit(value) } 59 | end 60 | q.breakable("") 61 | q.text("]") 62 | end 63 | end 64 | 65 | def node(node, _type) 66 | items = [] 67 | q.with_target(items) { yield } 68 | 69 | if items.empty? 70 | q.text(node.class.name) 71 | return 72 | end 73 | 74 | q.group do 75 | q.text(node.class.name) 76 | q.text("[") 77 | q.indent do 78 | q.breakable("") 79 | q.seplist(items) { |item| q.target << item } 80 | end 81 | q.breakable("") 82 | q.text("]") 83 | end 84 | end 85 | 86 | def pairs(name, values) 87 | q.group do 88 | q.text(name) 89 | q.text(": [") 90 | q.indent do 91 | q.breakable("") 92 | q.seplist(values) do |(key, value)| 93 | q.group do 94 | q.text("[") 95 | q.indent do 96 | q.breakable("") 97 | visit(key) 98 | q.text(",") 99 | q.breakable 100 | visit(value || nil) 101 | end 102 | q.breakable("") 103 | q.text("]") 104 | end 105 | end 106 | end 107 | q.breakable("") 108 | q.text("]") 109 | end 110 | end 111 | 112 | def text(name, value) 113 | q.nest(0) do 114 | q.text(name) 115 | q.text(": ") 116 | value.pretty_print(q) 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /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 kddnewton@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 [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: config/rubocop.yml 2 | 3 | AllCops: 4 | DisplayCopNames: true 5 | DisplayStyleGuide: true 6 | NewCops: enable 7 | SuggestExtensions: false 8 | TargetRubyVersion: 2.7 9 | Exclude: 10 | - '{.git,.github,.ruby-lsp,bin,coverage,doc,pkg,sorbet,spec,test/fixtures,vendor,tmp}/**/*' 11 | - test.rb 12 | 13 | Gemspec/DevelopmentDependencies: 14 | Enabled: false 15 | 16 | Layout/LineLength: 17 | Max: 80 18 | 19 | Lint/AmbiguousBlockAssociation: 20 | Enabled: false 21 | 22 | Lint/AmbiguousOperatorPrecedence: 23 | Enabled: false 24 | 25 | Lint/AmbiguousRange: 26 | Enabled: false 27 | 28 | Lint/BooleanSymbol: 29 | Enabled: false 30 | 31 | Lint/Debugger: 32 | Enabled: false 33 | 34 | Lint/DuplicateBranch: 35 | Enabled: false 36 | 37 | Lint/EmptyBlock: 38 | Enabled: false 39 | 40 | Lint/InterpolationCheck: 41 | Enabled: false 42 | 43 | Lint/MissingSuper: 44 | Enabled: false 45 | 46 | Lint/NonLocalExitFromIterator: 47 | Enabled: false 48 | 49 | Lint/RedundantRequireStatement: 50 | Enabled: false 51 | 52 | Lint/RescueException: 53 | Enabled: false 54 | 55 | Lint/SuppressedException: 56 | Enabled: false 57 | 58 | Lint/UnderscorePrefixedVariableName: 59 | Enabled: false 60 | 61 | Lint/UnusedMethodArgument: 62 | AllowUnusedKeywordArguments: true 63 | 64 | Metrics: 65 | Enabled: false 66 | 67 | Naming/MethodName: 68 | Enabled: false 69 | 70 | Naming/MethodParameterName: 71 | Enabled: false 72 | 73 | Naming/RescuedExceptionsVariableName: 74 | PreferredName: error 75 | 76 | Naming/VariableNumber: 77 | Enabled: false 78 | 79 | Security/Eval: 80 | Enabled: false 81 | 82 | Style/AccessorGrouping: 83 | Enabled: false 84 | 85 | Style/Alias: 86 | Enabled: false 87 | 88 | Style/CaseEquality: 89 | Enabled: false 90 | 91 | Style/CaseLikeIf: 92 | Enabled: false 93 | 94 | Style/ClassVars: 95 | Enabled: false 96 | 97 | Style/CombinableLoops: 98 | Enabled: false 99 | 100 | Style/DocumentDynamicEvalDefinition: 101 | Enabled: false 102 | 103 | Style/Documentation: 104 | Enabled: false 105 | 106 | Style/EndBlock: 107 | Enabled: false 108 | 109 | Style/ExplicitBlockArgument: 110 | Enabled: false 111 | 112 | Style/FormatString: 113 | Enabled: false 114 | 115 | Style/FormatStringToken: 116 | Enabled: false 117 | 118 | Style/GuardClause: 119 | Enabled: false 120 | 121 | Style/HashLikeCase: 122 | Enabled: false 123 | 124 | Style/IdenticalConditionalBranches: 125 | Enabled: false 126 | 127 | Style/IfInsideElse: 128 | Enabled: false 129 | 130 | Style/IfWithBooleanLiteralBranches: 131 | Enabled: false 132 | 133 | Style/KeywordParametersOrder: 134 | Enabled: false 135 | 136 | Style/MissingRespondToMissing: 137 | Enabled: false 138 | 139 | Style/MultipleComparison: 140 | Enabled: false 141 | 142 | Style/MutableConstant: 143 | Enabled: false 144 | 145 | Style/NegatedIfElseCondition: 146 | Enabled: false 147 | 148 | Style/Next: 149 | Enabled: false 150 | 151 | Style/NumericPredicate: 152 | Enabled: false 153 | 154 | Style/ParallelAssignment: 155 | Enabled: false 156 | 157 | Style/PerlBackrefs: 158 | Enabled: false 159 | 160 | Style/RedundantArrayConstructor: 161 | Enabled: false 162 | 163 | Style/RedundantParentheses: 164 | Enabled: false 165 | 166 | Style/SafeNavigation: 167 | Enabled: false 168 | 169 | Style/SpecialGlobalVars: 170 | Enabled: false 171 | 172 | Style/StructInheritance: 173 | Enabled: false 174 | 175 | Style/YodaExpression: 176 | Enabled: false 177 | -------------------------------------------------------------------------------- /test/search_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | module SyntaxTree 6 | class SearchTest < Minitest::Test 7 | def test_search_invalid_syntax 8 | assert_raises(Pattern::CompilationError) { search("", "<>") } 9 | end 10 | 11 | def test_search_invalid_constant 12 | assert_raises(Pattern::CompilationError) { search("", "Foo") } 13 | end 14 | 15 | def test_search_invalid_nested_constant 16 | assert_raises(Pattern::CompilationError) { search("", "Foo::Bar") } 17 | end 18 | 19 | def test_search_regexp_with_interpolation 20 | assert_raises(Pattern::CompilationError) { search("", "/\#{foo}/") } 21 | end 22 | 23 | def test_search_string_with_interpolation 24 | assert_raises(Pattern::CompilationError) { search("", '"#{foo}"') } 25 | end 26 | 27 | def test_search_symbol_with_interpolation 28 | assert_raises(Pattern::CompilationError) { search("", ":\"\#{foo}\"") } 29 | end 30 | 31 | def test_search_invalid_node 32 | assert_raises(Pattern::CompilationError) { search("", "Int[^foo]") } 33 | end 34 | 35 | def test_search_self 36 | assert_raises(Pattern::CompilationError) { search("", "self") } 37 | end 38 | 39 | def test_search_array_pattern_no_constant 40 | results = search("1 + 2", "[Int, Int]") 41 | 42 | assert_equal 1, results.length 43 | end 44 | 45 | def test_search_array_pattern 46 | results = search("1 + 2", "Binary[Int, Int]") 47 | 48 | assert_equal 1, results.length 49 | end 50 | 51 | def test_search_binary_or 52 | results = search("Foo + Bar + 1", "VarRef | Int") 53 | 54 | assert_equal 3, results.length 55 | assert_equal "1", results.min_by { |node| node.class.name }.value 56 | end 57 | 58 | def test_search_const 59 | results = search("Foo + Bar + Baz", "VarRef") 60 | 61 | assert_equal 3, results.length 62 | assert_equal %w[Bar Baz Foo], results.map { |node| node.value.value }.sort 63 | end 64 | 65 | def test_search_object_const 66 | results = search("1 + 2 + 3", "Int[value: String]") 67 | 68 | assert_equal 3, results.length 69 | end 70 | 71 | def test_search_syntax_tree_const 72 | results = search("Foo + Bar + Baz", "SyntaxTree::VarRef") 73 | 74 | assert_equal 3, results.length 75 | end 76 | 77 | def test_search_hash_pattern_no_constant 78 | results = search("Foo + Bar + Baz", "{ value: Const }") 79 | 80 | assert_equal 3, results.length 81 | end 82 | 83 | def test_search_hash_pattern_string 84 | results = search("Foo + Bar + Baz", "VarRef[value: Const[value: 'Foo']]") 85 | 86 | assert_equal 1, results.length 87 | assert_equal "Foo", results.first.value.value 88 | end 89 | 90 | def test_search_hash_pattern_regexp 91 | results = search("Foo + Bar + Baz", "VarRef[value: Const[value: /^Ba/]]") 92 | 93 | assert_equal 2, results.length 94 | assert_equal %w[Bar Baz], results.map { |node| node.value.value }.sort 95 | end 96 | 97 | def test_search_string_empty 98 | results = search("", "''") 99 | 100 | assert_empty results 101 | end 102 | 103 | def test_search_symbol_empty 104 | results = search("", ":''") 105 | 106 | assert_empty results 107 | end 108 | 109 | def test_search_symbol_plain 110 | results = search("1 + 2", "Binary[operator: :'+']") 111 | 112 | assert_equal 1, results.length 113 | end 114 | 115 | def test_search_symbol 116 | results = search("1 + 2", "Binary[operator: :+]") 117 | 118 | assert_equal 1, results.length 119 | end 120 | 121 | private 122 | 123 | def search(source, query) 124 | SyntaxTree.search(source, query).to_a 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /test/parser_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | module SyntaxTree 6 | class ParserTest < Minitest::Test 7 | def test_parses_ripper_methods 8 | # First, get a list of all of the dispatched events from ripper. 9 | events = Ripper::EVENTS 10 | 11 | # Next, subtract all of the events that we have explicitly defined. 12 | events -= 13 | Parser.private_instance_methods(false).grep(/^on_(\w+)/) { $1.to_sym } 14 | 15 | # Next, subtract the list of events that we purposefully skipped. 16 | events -= %i[ 17 | arg_ambiguous 18 | assoclist_from_args 19 | ignored_nl 20 | ignored_sp 21 | magic_comment 22 | nl 23 | nokw_param 24 | operator_ambiguous 25 | semicolon 26 | sp 27 | words_sep 28 | ] 29 | 30 | # Finally, assert that we have no remaining events. 31 | assert_empty(events) 32 | end 33 | 34 | def test_errors_on_missing_token_with_location 35 | error = assert_raises(Parser::ParseError) { SyntaxTree.parse("f+\"foo") } 36 | assert_equal(3, error.column) 37 | end 38 | 39 | def test_errors_on_missing_end_with_location 40 | error = assert_raises(Parser::ParseError) { SyntaxTree.parse("foo do 1") } 41 | assert_equal(4, error.column) 42 | end 43 | 44 | def test_errors_on_missing_regexp_ending 45 | error = 46 | assert_raises(Parser::ParseError) { SyntaxTree.parse("a =~ /foo") } 47 | 48 | assert_equal(6, error.column) 49 | end 50 | 51 | def test_errors_on_missing_token_without_location 52 | assert_raises(Parser::ParseError) { SyntaxTree.parse(":\"foo") } 53 | end 54 | 55 | def test_handles_strings_with_non_terminated_embedded_expressions 56 | assert_raises(Parser::ParseError) { SyntaxTree.parse('"#{"') } 57 | end 58 | 59 | def test_errors_on_else_missing_two_ends 60 | assert_raises(Parser::ParseError) { SyntaxTree.parse(<<~RUBY) } 61 | def foo 62 | if something 63 | else 64 | call do 65 | end 66 | RUBY 67 | end 68 | 69 | def test_does_not_choke_on_invalid_characters_in_source_string 70 | SyntaxTree.parse(<<~RUBY) 71 | # comment 72 | # comment 73 | __END__ 74 | \xC5 75 | RUBY 76 | end 77 | 78 | def test_lambda_vars_with_parameters_location 79 | tree = SyntaxTree.parse(<<~RUBY) 80 | # comment 81 | # comment 82 | ->(_i; a) { a } 83 | RUBY 84 | 85 | local_location = 86 | tree.statements.body.last.params.contents.locals.first.location 87 | 88 | assert_equal(3, local_location.start_line) 89 | assert_equal(3, local_location.end_line) 90 | assert_equal(7, local_location.start_column) 91 | assert_equal(8, local_location.end_column) 92 | end 93 | 94 | def test_lambda_vars_location 95 | tree = SyntaxTree.parse(<<~RUBY) 96 | # comment 97 | # comment 98 | ->(; a) { a } 99 | RUBY 100 | 101 | local_location = 102 | tree.statements.body.last.params.contents.locals.first.location 103 | 104 | assert_equal(3, local_location.start_line) 105 | assert_equal(3, local_location.end_line) 106 | assert_equal(5, local_location.start_column) 107 | assert_equal(6, local_location.end_column) 108 | end 109 | 110 | def test_multiple_lambda_vars_location 111 | tree = SyntaxTree.parse(<<~RUBY) 112 | # comment 113 | # comment 114 | ->(; a, b, c) { a } 115 | RUBY 116 | 117 | local_location = 118 | tree.statements.body.last.params.contents.locals.last.location 119 | 120 | assert_equal(3, local_location.start_line) 121 | assert_equal(3, local_location.end_line) 122 | assert_equal(11, local_location.start_column) 123 | assert_equal(12, local_location.end_column) 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/syntax_tree/basic_visitor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | # BasicVisitor is the parent class of the Visitor class that provides the 5 | # ability to walk down the tree. It does not define any handlers, so you 6 | # should extend this class if you want your visitor to raise an error if you 7 | # attempt to visit a node that you don't handle. 8 | class BasicVisitor 9 | # This is raised when you use the Visitor.visit_method method and it fails. 10 | # It is correctable to through DidYouMean. 11 | class VisitMethodError < StandardError 12 | attr_reader :visit_method 13 | 14 | def initialize(visit_method) 15 | @visit_method = visit_method 16 | super("Invalid visit method: #{visit_method}") 17 | end 18 | end 19 | 20 | # This class is used by DidYouMean to offer corrections to invalid visit 21 | # method names. 22 | class VisitMethodChecker 23 | attr_reader :visit_method 24 | 25 | def initialize(error) 26 | @visit_method = error.visit_method 27 | end 28 | 29 | def corrections 30 | @corrections ||= 31 | DidYouMean::SpellChecker.new( 32 | dictionary: BasicVisitor.valid_visit_methods 33 | ).correct(visit_method) 34 | end 35 | 36 | # In some setups with Ruby you can turn off DidYouMean, so we're going to 37 | # respect that setting here. 38 | if defined?(DidYouMean.correct_error) 39 | DidYouMean.correct_error(VisitMethodError, self) 40 | end 41 | end 42 | 43 | # This module is responsible for checking all of the methods defined within 44 | # a given block to ensure that they are valid visit methods. 45 | class VisitMethodsChecker < Module 46 | Status = Struct.new(:checking) 47 | 48 | # This is the status of the checker. It's used to determine whether or not 49 | # we should be checking the methods that are defined. It is kept as an 50 | # instance variable so that it can be disabled later. 51 | attr_reader :status 52 | 53 | def initialize 54 | # We need the status to be an instance variable so that it can be 55 | # accessed by the disable! method, but also a local variable so that it 56 | # can be captured by the define_method block. 57 | status = @status = Status.new(true) 58 | 59 | define_method(:method_added) do |name| 60 | BasicVisitor.visit_method(name) if status.checking 61 | super(name) 62 | end 63 | end 64 | 65 | def disable! 66 | status.checking = false 67 | end 68 | end 69 | 70 | class << self 71 | # This is the list of all of the valid visit methods. 72 | def valid_visit_methods 73 | @valid_visit_methods ||= 74 | Visitor.instance_methods.grep(/^visit_(?!child_nodes)/) 75 | end 76 | 77 | # This method is here to help folks write visitors. 78 | # 79 | # It's not always easy to ensure you're writing the correct method name in 80 | # the visitor since it's perfectly valid to define methods that don't 81 | # override these parent methods. 82 | # 83 | # If you use this method, you can ensure you're writing the correct method 84 | # name. It will raise an error if the visit method you're defining isn't 85 | # actually a method on the parent visitor. 86 | def visit_method(method_name) 87 | return if valid_visit_methods.include?(method_name) 88 | 89 | raise VisitMethodError, method_name 90 | end 91 | 92 | # This method is here to help folks write visitors. 93 | # 94 | # Within the given block, every method that is defined will be checked to 95 | # ensure it's a valid visit method using the BasicVisitor::visit_method 96 | # method defined above. 97 | def visit_methods 98 | checker = VisitMethodsChecker.new 99 | extend(checker) 100 | yield 101 | checker.disable! 102 | end 103 | end 104 | 105 | def visit(node) 106 | node&.accept(self) 107 | end 108 | 109 | def visit_all(nodes) 110 | nodes.map { |node| visit(node) } 111 | end 112 | 113 | def visit_child_nodes(node) 114 | visit_all(node.child_nodes) 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/syntax_tree/mermaid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cgi" 4 | require "stringio" 5 | 6 | module SyntaxTree 7 | # This module is responsible for rendering mermaid (https://mermaid.js.org/) 8 | # flow charts. 9 | module Mermaid 10 | # This is the main class that handles rendering a flowchart. It keeps track 11 | # of its nodes and links and renders them according to the mermaid syntax. 12 | class FlowChart 13 | attr_reader :output, :prefix, :nodes, :links 14 | 15 | def initialize 16 | @output = StringIO.new 17 | @output.puts("flowchart TD") 18 | @prefix = " " 19 | 20 | @nodes = {} 21 | @links = [] 22 | end 23 | 24 | # Retrieve a node that has already been added to the flowchart by its id. 25 | def fetch(id) 26 | nodes.fetch(id) 27 | end 28 | 29 | # Add a link to the flowchart between two nodes with an optional label. 30 | def link(from, to, label = nil, type: :directed, color: nil) 31 | link = Link.new(from, to, label, type, color) 32 | links << link 33 | 34 | output.puts("#{prefix}#{link.render}") 35 | link 36 | end 37 | 38 | # Add a node to the flowchart with an optional label. 39 | def node(id, label = " ", shape: :rectangle) 40 | node = Node.new(id, label, shape) 41 | nodes[id] = node 42 | 43 | output.puts("#{prefix}#{nodes[id].render}") 44 | node 45 | end 46 | 47 | # Add a subgraph to the flowchart. Within the given block, all of the 48 | # nodes will be rendered within the subgraph. 49 | def subgraph(label) 50 | output.puts("#{prefix}subgraph #{Mermaid.escape(label)}") 51 | 52 | previous = prefix 53 | @prefix = "#{prefix} " 54 | 55 | begin 56 | yield 57 | ensure 58 | @prefix = previous 59 | output.puts("#{prefix}end") 60 | end 61 | end 62 | 63 | # Return the rendered flowchart. 64 | def render 65 | links.each_with_index do |link, index| 66 | if link.color 67 | output.puts("#{prefix}linkStyle #{index} stroke:#{link.color}") 68 | end 69 | end 70 | 71 | output.string 72 | end 73 | end 74 | 75 | # This class represents a link between two nodes in a flowchart. It is not 76 | # meant to be interacted with directly, but rather used as a data structure 77 | # by the FlowChart class. 78 | class Link 79 | TYPES = %i[directed dotted].freeze 80 | COLORS = %i[green red].freeze 81 | 82 | attr_reader :from, :to, :label, :type, :color 83 | 84 | def initialize(from, to, label, type, color) 85 | raise unless TYPES.include?(type) 86 | raise if color && !COLORS.include?(color) 87 | 88 | @from = from 89 | @to = to 90 | @label = label 91 | @type = type 92 | @color = color 93 | end 94 | 95 | def render 96 | left_side, right_side, full_side = sides 97 | 98 | if label 99 | escaped = Mermaid.escape(label) 100 | "#{from.id} #{left_side} #{escaped} #{right_side} #{to.id}" 101 | else 102 | "#{from.id} #{full_side} #{to.id}" 103 | end 104 | end 105 | 106 | private 107 | 108 | def sides 109 | case type 110 | when :directed 111 | %w[-- --> -->] 112 | when :dotted 113 | %w[-. .-> -.->] 114 | end 115 | end 116 | end 117 | 118 | # This class represents a node in a flowchart. Unlike the Link class, it can 119 | # be used directly. It is the return value of the #node method, and is meant 120 | # to be passed around to #link methods to create links between nodes. 121 | class Node 122 | SHAPES = %i[circle rectangle rounded stadium].freeze 123 | 124 | attr_reader :id, :label, :shape 125 | 126 | def initialize(id, label, shape) 127 | raise unless SHAPES.include?(shape) 128 | 129 | @id = id 130 | @label = label 131 | @shape = shape 132 | end 133 | 134 | def render 135 | left_bound, right_bound = bounds 136 | "#{id}#{left_bound}#{Mermaid.escape(label)}#{right_bound}" 137 | end 138 | 139 | private 140 | 141 | def bounds 142 | case shape 143 | when :circle 144 | %w[(( ))] 145 | when :rectangle 146 | ["[", "]"] 147 | when :rounded 148 | %w[( )] 149 | when :stadium 150 | ["([", "])"] 151 | end 152 | end 153 | end 154 | 155 | class << self 156 | # Escape a label to be used in the mermaid syntax. This is used to escape 157 | # HTML entities such that they render properly within the quotes. 158 | def escape(label) 159 | "\"#{CGI.escapeHTML(label)}\"" 160 | end 161 | 162 | # Create a new flowchart. If a block is given, it will be yielded to and 163 | # the flowchart will be rendered. Otherwise, the flowchart will be 164 | # returned. 165 | def flowchart 166 | flowchart = FlowChart.new 167 | 168 | if block_given? 169 | yield flowchart 170 | flowchart.render 171 | else 172 | flowchart 173 | end 174 | end 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /test/index_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | module SyntaxTree 6 | class IndexTest < Minitest::Test 7 | def test_module 8 | index_each("module Foo; end") do |entry| 9 | assert_equal :Foo, entry.name 10 | assert_equal [[:Foo]], entry.nesting 11 | end 12 | end 13 | 14 | def test_module_nested 15 | index_each("module Foo; module Bar; end; end") do |entry| 16 | assert_equal :Bar, entry.name 17 | assert_equal [[:Foo], [:Bar]], entry.nesting 18 | end 19 | end 20 | 21 | def test_module_comments 22 | index_each("# comment1\n# comment2\nmodule Foo; end") do |entry| 23 | assert_equal :Foo, entry.name 24 | assert_equal ["# comment1", "# comment2"], entry.comments.to_a 25 | end 26 | end 27 | 28 | def test_class 29 | index_each("class Foo; end") do |entry| 30 | assert_equal :Foo, entry.name 31 | assert_equal [[:Foo]], entry.nesting 32 | end 33 | end 34 | 35 | def test_class_paths_2 36 | index_each("class Foo::Bar; end") do |entry| 37 | assert_equal :Bar, entry.name 38 | assert_equal [%i[Foo Bar]], entry.nesting 39 | end 40 | end 41 | 42 | def test_class_paths_3 43 | index_each("class Foo::Bar::Baz; end") do |entry| 44 | assert_equal :Baz, entry.name 45 | assert_equal [%i[Foo Bar Baz]], entry.nesting 46 | end 47 | end 48 | 49 | def test_class_nested 50 | index_each("class Foo; class Bar; end; end") do |entry| 51 | assert_equal :Bar, entry.name 52 | assert_equal [[:Foo], [:Bar]], entry.nesting 53 | end 54 | end 55 | 56 | def test_class_paths_nested 57 | index_each("class Foo; class Bar::Baz::Qux; end; end") do |entry| 58 | assert_equal :Qux, entry.name 59 | assert_equal [[:Foo], %i[Bar Baz Qux]], entry.nesting 60 | end 61 | end 62 | 63 | def test_class_superclass 64 | index_each("class Foo < Bar; end") do |entry| 65 | assert_equal :Foo, entry.name 66 | assert_equal [[:Foo]], entry.nesting 67 | assert_equal [:Bar], entry.superclass 68 | end 69 | end 70 | 71 | def test_class_path_superclass 72 | index_each("class Foo::Bar < Baz::Qux; end") do |entry| 73 | assert_equal :Bar, entry.name 74 | assert_equal [%i[Foo Bar]], entry.nesting 75 | assert_equal %i[Baz Qux], entry.superclass 76 | end 77 | end 78 | 79 | def test_class_comments 80 | index_each("# comment1\n# comment2\nclass Foo; end") do |entry| 81 | assert_equal :Foo, entry.name 82 | assert_equal ["# comment1", "# comment2"], entry.comments.to_a 83 | end 84 | end 85 | 86 | def test_method 87 | index_each("def foo; end") do |entry| 88 | assert_equal :foo, entry.name 89 | assert_empty entry.nesting 90 | end 91 | end 92 | 93 | def test_method_nested 94 | index_each("class Foo; def foo; end; end") do |entry| 95 | assert_equal :foo, entry.name 96 | assert_equal [[:Foo]], entry.nesting 97 | end 98 | end 99 | 100 | def test_method_comments 101 | index_each("# comment1\n# comment2\ndef foo; end") do |entry| 102 | assert_equal :foo, entry.name 103 | assert_equal ["# comment1", "# comment2"], entry.comments.to_a 104 | end 105 | end 106 | 107 | def test_singleton_method 108 | index_each("def self.foo; end") do |entry| 109 | assert_equal :foo, entry.name 110 | assert_empty entry.nesting 111 | end 112 | end 113 | 114 | def test_singleton_method_nested 115 | index_each("class Foo; def self.foo; end; end") do |entry| 116 | assert_equal :foo, entry.name 117 | assert_equal [[:Foo]], entry.nesting 118 | end 119 | end 120 | 121 | def test_singleton_method_comments 122 | index_each("# comment1\n# comment2\ndef self.foo; end") do |entry| 123 | assert_equal :foo, entry.name 124 | assert_equal ["# comment1", "# comment2"], entry.comments.to_a 125 | end 126 | end 127 | 128 | def test_alias_method 129 | index_each("alias foo bar") do |entry| 130 | assert_equal :foo, entry.name 131 | assert_empty entry.nesting 132 | end 133 | end 134 | 135 | def test_attr_reader 136 | index_each("attr_reader :foo") do |entry| 137 | assert_equal :foo, entry.name 138 | assert_empty entry.nesting 139 | end 140 | end 141 | 142 | def test_attr_writer 143 | index_each("attr_writer :foo") do |entry| 144 | assert_equal :foo=, entry.name 145 | assert_empty entry.nesting 146 | end 147 | end 148 | 149 | def test_attr_accessor 150 | index_each("attr_accessor :foo") do |entry| 151 | assert_equal :foo=, entry.name 152 | assert_empty entry.nesting 153 | end 154 | end 155 | 156 | def test_constant 157 | index_each("FOO = 1") do |entry| 158 | assert_equal :FOO, entry.name 159 | assert_empty entry.nesting 160 | end 161 | end 162 | 163 | def test_this_file 164 | entries = Index.index_file(__FILE__, backend: Index::ParserBackend.new) 165 | 166 | if defined?(RubyVM::InstructionSequence) 167 | entries += Index.index_file(__FILE__, backend: Index::ISeqBackend.new) 168 | end 169 | 170 | entries.map { |entry| entry.comments.to_a } 171 | end 172 | 173 | private 174 | 175 | def index_each(source) 176 | yield Index.index(source, backend: Index::ParserBackend.new).last 177 | 178 | if defined?(RubyVM::InstructionSequence) 179 | yield Index.index(source, backend: Index::ISeqBackend.new).last 180 | end 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /lib/syntax_tree.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "prettier_print" 4 | require "pp" 5 | require "ripper" 6 | 7 | require_relative "syntax_tree/node" 8 | require_relative "syntax_tree/basic_visitor" 9 | require_relative "syntax_tree/visitor" 10 | 11 | require_relative "syntax_tree/formatter" 12 | require_relative "syntax_tree/parser" 13 | require_relative "syntax_tree/version" 14 | 15 | # Syntax Tree is a suite of tools built on top of the internal CRuby parser. It 16 | # provides the ability to generate a syntax tree from source, as well as the 17 | # tools necessary to inspect and manipulate that syntax tree. It can be used to 18 | # build formatters, linters, language servers, and more. 19 | module SyntaxTree 20 | # Syntax Tree the library has many features that aren't always used by the 21 | # CLI. Requiring those features takes time, so we autoload as many constants 22 | # as possible in order to keep the CLI as fast as possible. 23 | 24 | autoload :Database, "syntax_tree/database" 25 | autoload :DSL, "syntax_tree/dsl" 26 | autoload :FieldVisitor, "syntax_tree/field_visitor" 27 | autoload :Index, "syntax_tree/index" 28 | autoload :JSONVisitor, "syntax_tree/json_visitor" 29 | autoload :LanguageServer, "syntax_tree/language_server" 30 | autoload :MatchVisitor, "syntax_tree/match_visitor" 31 | autoload :Mermaid, "syntax_tree/mermaid" 32 | autoload :MermaidVisitor, "syntax_tree/mermaid_visitor" 33 | autoload :MutationVisitor, "syntax_tree/mutation_visitor" 34 | autoload :Pattern, "syntax_tree/pattern" 35 | autoload :PrettyPrintVisitor, "syntax_tree/pretty_print_visitor" 36 | autoload :Search, "syntax_tree/search" 37 | autoload :WithScope, "syntax_tree/with_scope" 38 | 39 | # This holds references to objects that respond to both #parse and #format 40 | # so that we can use them in the CLI. 41 | HANDLERS = {} 42 | HANDLERS.default = SyntaxTree 43 | 44 | # This is the default print width when formatting. It can be overridden in the 45 | # CLI by passing the --print-width option or here in the API by passing the 46 | # optional second argument to ::format. 47 | DEFAULT_PRINT_WIDTH = 80 48 | 49 | # This is the default ruby version that we're going to target for formatting. 50 | # It shouldn't really be changed except in very niche circumstances. 51 | DEFAULT_RUBY_VERSION = Formatter::SemanticVersion.new(RUBY_VERSION).freeze 52 | 53 | # The default indentation level for formatting. We allow changing this so 54 | # that Syntax Tree can format arbitrary parts of a document. 55 | DEFAULT_INDENTATION = 0 56 | 57 | # Parses the given source and returns the formatted source. 58 | def self.format( 59 | source, 60 | maxwidth = DEFAULT_PRINT_WIDTH, 61 | base_indentation = DEFAULT_INDENTATION, 62 | options: Formatter::Options.new 63 | ) 64 | format_node( 65 | source, 66 | parse(source), 67 | maxwidth, 68 | base_indentation, 69 | options: options 70 | ) 71 | end 72 | 73 | # Parses the given file and returns the formatted source. 74 | def self.format_file( 75 | filepath, 76 | maxwidth = DEFAULT_PRINT_WIDTH, 77 | base_indentation = DEFAULT_INDENTATION, 78 | options: Formatter::Options.new 79 | ) 80 | format(read(filepath), maxwidth, base_indentation, options: options) 81 | end 82 | 83 | # Accepts a node in the tree and returns the formatted source. 84 | def self.format_node( 85 | source, 86 | node, 87 | maxwidth = DEFAULT_PRINT_WIDTH, 88 | base_indentation = DEFAULT_INDENTATION, 89 | options: Formatter::Options.new 90 | ) 91 | formatter = Formatter.new(source, [], maxwidth, options: options) 92 | node.format(formatter) 93 | 94 | formatter.flush(base_indentation) 95 | formatter.output.join 96 | end 97 | 98 | # Indexes the given source code to return a list of all class, module, and 99 | # method definitions. Used to quickly provide indexing capability for IDEs or 100 | # documentation generation. 101 | def self.index(source) 102 | Index.index(source) 103 | end 104 | 105 | # Indexes the given file to return a list of all class, module, and method 106 | # definitions. Used to quickly provide indexing capability for IDEs or 107 | # documentation generation. 108 | def self.index_file(filepath) 109 | Index.index_file(filepath) 110 | end 111 | 112 | # A convenience method for creating a new mutation visitor. 113 | def self.mutation 114 | visitor = MutationVisitor.new 115 | yield visitor 116 | visitor 117 | end 118 | 119 | # Parses the given source and returns the syntax tree. 120 | def self.parse(source) 121 | parser = Parser.new(source) 122 | response = parser.parse 123 | response unless parser.error? 124 | end 125 | 126 | # Parses the given file and returns the syntax tree. 127 | def self.parse_file(filepath) 128 | parse(read(filepath)) 129 | end 130 | 131 | # Returns the source from the given filepath taking into account any potential 132 | # magic encoding comments. 133 | def self.read(filepath) 134 | encoding = 135 | File.open(filepath, "r") do |file| 136 | break Encoding.default_external if file.eof? 137 | 138 | header = file.readline 139 | header += file.readline if !file.eof? && header.start_with?("#!") 140 | Ripper.new(header).tap(&:parse).encoding 141 | end 142 | 143 | File.read(filepath, encoding: encoding) 144 | end 145 | 146 | # This is a hook provided so that plugins can register themselves as the 147 | # handler for a particular file type. 148 | def self.register_handler(extension, handler) 149 | HANDLERS[extension] = handler 150 | end 151 | 152 | # Searches through the given source using the given pattern and yields each 153 | # node in the tree that matches the pattern to the given block. 154 | def self.search(source, query, &block) 155 | pattern = Pattern.new(query).compile 156 | program = parse(source) 157 | 158 | Search.new(pattern).scan(program, &block) 159 | end 160 | 161 | # Searches through the given file using the given pattern and yields each 162 | # node in the tree that matches the pattern to the given block. 163 | def self.search_file(filepath, query, &block) 164 | search(read(filepath), query, &block) 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | unless RUBY_ENGINE == "truffleruby" 4 | require "simplecov" 5 | SimpleCov.start do 6 | add_filter("idempotency_test.rb") unless ENV["CI"] 7 | add_group("lib", "lib") 8 | add_group("test", "test") 9 | end 10 | end 11 | 12 | $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) 13 | require "syntax_tree" 14 | require "syntax_tree/cli" 15 | 16 | unless RUBY_ENGINE == "truffleruby" 17 | # Here we are going to establish type verification whenever a new node is 18 | # created. We do this through the reflection module, which in turn parses the 19 | # source code of the node classes. 20 | require "syntax_tree/reflection" 21 | SyntaxTree::Reflection.nodes.each do |name, node| 22 | next if name == :Statements 23 | 24 | clazz = SyntaxTree.const_get(name) 25 | parameters = clazz.instance_method(:initialize).parameters 26 | 27 | # First, verify that all of the parameters listed in the list of attributes. 28 | # If there are any parameters that aren't listed in the attributes, then 29 | # something went wrong with the parsing in the reflection module. 30 | raise unless (parameters.map(&:last) - node.attributes.keys).empty? 31 | 32 | # Now we're going to use an alias chain to redefine the initialize method to 33 | # include type checking. 34 | clazz.alias_method(:initialize_without_verify, :initialize) 35 | clazz.define_method(:initialize) do |**kwargs| 36 | kwargs.each do |kwarg, value| 37 | attribute = node.attributes.fetch(kwarg) 38 | 39 | unless attribute.type === value 40 | raise TypeError, 41 | "invalid type for #{name}##{kwarg}, expected " \ 42 | "#{attribute.type.inspect}, got #{value.inspect}" 43 | end 44 | end 45 | 46 | initialize_without_verify(**kwargs) 47 | end 48 | end 49 | end 50 | 51 | require "json" 52 | require "tempfile" 53 | require "pp" 54 | require "minitest/autorun" 55 | 56 | module SyntaxTree 57 | module Assertions 58 | class Recorder 59 | attr_reader :called 60 | 61 | def initialize 62 | @called = nil 63 | end 64 | 65 | def method_missing(called, *, **) 66 | @called = called 67 | end 68 | end 69 | 70 | private 71 | 72 | # This is a special kind of assertion that is going to get loaded into all 73 | # of test cases. It asserts against a whole bunch of stuff that every node 74 | # type should be able to handle. It's here so that we can use it in a bunch 75 | # of tests. 76 | def assert_syntax_tree(node) 77 | # First, get the visit method name. 78 | recorder = Recorder.new 79 | node.accept(recorder) 80 | 81 | # Next, get the "type" which is effectively an underscored version of 82 | # the name of the class. 83 | type = recorder.called[/^visit_(.+)$/, 1] 84 | 85 | # Test that the method that is called when you call accept is a valid 86 | # visit method on the visitor. 87 | assert_respond_to(Visitor.new, recorder.called) 88 | 89 | # Test that you can call child_nodes and the pattern matching methods on 90 | # this class. 91 | assert_kind_of(Array, node.child_nodes) 92 | assert_kind_of(Array, node.deconstruct) 93 | assert_kind_of(Hash, node.deconstruct_keys([])) 94 | 95 | # Assert that it can be pretty printed to a string. 96 | pretty = PP.singleline_pp(node, +"") 97 | refute_includes(pretty, "#<") 98 | assert_includes(pretty, type) 99 | 100 | # Assert that we can get back a new tree by using the mutation visitor. 101 | assert_operator node, :===, node.accept(MutationVisitor.new) 102 | 103 | # Serialize the node to JSON, parse it back out, and assert that we have 104 | # found the expected type. 105 | json = node.to_json 106 | refute_includes(json, "#<") 107 | assert_equal(type, JSON.parse(json)["type"]) 108 | 109 | # Get a match expression from the node, then assert that it can in fact 110 | # match the node. 111 | # rubocop:disable all 112 | assert(eval(<<~RUBY)) 113 | case node 114 | in #{node.construct_keys} 115 | true 116 | end 117 | RUBY 118 | end 119 | 120 | Minitest::Test.include(self) 121 | end 122 | end 123 | 124 | # There are a bunch of fixtures defined in test/fixtures. They exercise every 125 | # possible combination of syntax that leads to variations in the types of nodes. 126 | # They are used for testing various parts of Syntax Tree, including formatting, 127 | # serialization, and parsing. This module provides a single each_fixture method 128 | # that can be used to drive tests on each fixture. 129 | module Fixtures 130 | FIXTURES_3_0_0 = %w[ 131 | command_def_endless 132 | def_endless 133 | fndptn 134 | rassign 135 | rassign_rocket 136 | ].freeze 137 | 138 | FIXTURES_3_1_0 = %w[pinned_begin var_field_rassign].freeze 139 | 140 | Fixture = Struct.new(:name, :source, :formatted, keyword_init: true) 141 | 142 | def self.each_fixture 143 | ruby_version = Gem::Version.new(RUBY_VERSION) 144 | 145 | # First, get a list of the basenames of all of the fixture files. 146 | fixtures = 147 | Dir[File.expand_path("fixtures/*.rb", __dir__)].map do |filepath| 148 | File.basename(filepath, ".rb") 149 | end 150 | 151 | # Next, subtract out any fixtures that aren't supported by the current Ruby 152 | # version. 153 | fixtures -= FIXTURES_3_1_0 if ruby_version < Gem::Version.new("3.1.0") 154 | fixtures -= FIXTURES_3_0_0 if ruby_version < Gem::Version.new("3.0.0") 155 | 156 | delimiter = /%(?: # (.+?))?\n/ 157 | fixtures.each do |fixture| 158 | filepath = File.expand_path("fixtures/#{fixture}.rb", __dir__) 159 | 160 | # For each fixture in the fixture file yield a Fixture object. 161 | File 162 | .readlines(filepath) 163 | .slice_before(delimiter) 164 | .each_with_index do |source, index| 165 | comment = source.shift.match(delimiter)[1] 166 | source, formatted = source.join.split("-\n") 167 | 168 | # If there's a comment starting with >= that starts after the % that 169 | # delineates the test, then we're going to check if the version 170 | # satisfies that constraint. 171 | if comment&.start_with?(">=") 172 | next if ruby_version < Gem::Version.new(comment.split[1]) 173 | end 174 | 175 | name = :"#{fixture}_#{index}" 176 | yield( 177 | Fixture.new( 178 | name: name, 179 | source: source, 180 | formatted: formatted || source 181 | ) 182 | ) 183 | end 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/syntax_tree/formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | # A slightly enhanced PP that knows how to format recursively including 5 | # comments. 6 | class Formatter < PrettierPrint 7 | # Unfortunately, Gem::Version.new is not ractor-safe because it performs 8 | # global caching using a class variable. This works around that by just 9 | # setting the instance variables directly. 10 | class SemanticVersion < ::Gem::Version 11 | def initialize(version) 12 | @version = version 13 | @segments = nil 14 | end 15 | end 16 | 17 | # We want to minimize as much as possible the number of options that are 18 | # available in syntax tree. For the most part, if users want non-default 19 | # formatting, they should override the format methods on the specific nodes 20 | # themselves. However, because of some history with prettier and the fact 21 | # that folks have become entrenched in their ways, we decided to provide a 22 | # small amount of configurability. 23 | class Options 24 | attr_reader :quote, 25 | :trailing_comma, 26 | :disable_auto_ternary, 27 | :target_ruby_version 28 | 29 | def initialize( 30 | quote: :default, 31 | trailing_comma: :default, 32 | disable_auto_ternary: :default, 33 | target_ruby_version: :default 34 | ) 35 | @quote = 36 | if quote == :default 37 | # We ship with a single quotes plugin that will define this 38 | # constant. That constant is responsible for determining the default 39 | # quote style. If it's defined, we default to single quotes, 40 | # otherwise we default to double quotes. 41 | defined?(SINGLE_QUOTES) ? "'" : "\"" 42 | else 43 | quote 44 | end 45 | 46 | @trailing_comma = 47 | if trailing_comma == :default 48 | # We ship with a trailing comma plugin that will define this 49 | # constant. That constant is responsible for determining the default 50 | # trailing comma value. If it's defined, then we default to true. 51 | # Otherwise we default to false. 52 | defined?(TRAILING_COMMA) 53 | else 54 | trailing_comma 55 | end 56 | 57 | @disable_auto_ternary = 58 | if disable_auto_ternary == :default 59 | # We ship with a disable ternary plugin that will define this 60 | # constant. That constant is responsible for determining the default 61 | # disable ternary value. If it's defined, then we default to true. 62 | # Otherwise we default to false. 63 | defined?(DISABLE_AUTO_TERNARY) 64 | else 65 | disable_auto_ternary 66 | end 67 | 68 | @target_ruby_version = 69 | if target_ruby_version == :default 70 | # The default target Ruby version is the current version of Ruby. 71 | # This is really only used for very niche cases, and it shouldn't be 72 | # used by most users. 73 | SemanticVersion.new(RUBY_VERSION) 74 | else 75 | target_ruby_version 76 | end 77 | end 78 | end 79 | 80 | COMMENT_PRIORITY = 1 81 | HEREDOC_PRIORITY = 2 82 | 83 | attr_reader :source, :stack 84 | 85 | # These options are overridden in plugins to we need to make sure they are 86 | # available here. 87 | attr_reader :quote, 88 | :trailing_comma, 89 | :disable_auto_ternary, 90 | :target_ruby_version 91 | 92 | alias trailing_comma? trailing_comma 93 | alias disable_auto_ternary? disable_auto_ternary 94 | 95 | def initialize(source, *args, options: Options.new) 96 | super(*args) 97 | 98 | @source = source 99 | @stack = [] 100 | 101 | # Memoizing these values to make access faster. 102 | @quote = options.quote 103 | @trailing_comma = options.trailing_comma 104 | @disable_auto_ternary = options.disable_auto_ternary 105 | @target_ruby_version = options.target_ruby_version 106 | end 107 | 108 | def self.format(source, node, base_indentation = 0) 109 | q = new(source, []) 110 | q.format(node) 111 | q.flush(base_indentation) 112 | q.output.join 113 | end 114 | 115 | def format(node, stackable: true) 116 | stack << node if stackable 117 | doc = nil 118 | 119 | # If there are comments, then we're going to format them around the node 120 | # so that they get printed properly. 121 | if node.comments.any? 122 | trailing = [] 123 | last_leading = nil 124 | 125 | # First, we're going to print all of the comments that were found before 126 | # the node. We'll also gather up any trailing comments that we find. 127 | node.comments.each do |comment| 128 | if comment.leading? 129 | comment.format(self) 130 | breakable(force: true) 131 | last_leading = comment 132 | else 133 | trailing << comment 134 | end 135 | end 136 | 137 | # If the node has a stree-ignore comment right before it, then we're 138 | # going to just print out the node as it was seen in the source. 139 | doc = 140 | if last_leading&.ignore? 141 | range = source[node.start_char...node.end_char] 142 | first = true 143 | 144 | range.each_line(chomp: true) do |line| 145 | if first 146 | first = false 147 | else 148 | breakable_return 149 | end 150 | 151 | text(line) 152 | end 153 | 154 | breakable_return if range.end_with?("\n") 155 | else 156 | node.format(self) 157 | end 158 | 159 | # Print all comments that were found after the node. 160 | trailing.each do |comment| 161 | line_suffix(priority: COMMENT_PRIORITY) do 162 | comment.inline? ? text(" ") : breakable 163 | comment.format(self) 164 | break_parent 165 | end 166 | end 167 | else 168 | doc = node.format(self) 169 | end 170 | 171 | stack.pop if stackable 172 | doc 173 | end 174 | 175 | def format_each(nodes) 176 | nodes.each { |node| format(node) } 177 | end 178 | 179 | def grandparent 180 | stack[-3] 181 | end 182 | 183 | def parent 184 | stack[-2] 185 | end 186 | 187 | def parents 188 | stack[0...-1].reverse_each 189 | end 190 | 191 | # This is a simplified version of prettyprint's group. It doesn't provide 192 | # any of the more advanced options because we don't need them and they take 193 | # up expensive computation time. 194 | def group 195 | contents = [] 196 | doc = Group.new(0, contents: contents) 197 | 198 | groups << doc 199 | target << doc 200 | 201 | with_target(contents) { yield } 202 | groups.pop 203 | doc 204 | end 205 | 206 | # A similar version to the super, except that it calls back into the 207 | # separator proc with the instance of `self`. 208 | def seplist(list, sep = nil, iter_method = :each) 209 | first = true 210 | list.__send__(iter_method) do |*v| 211 | if first 212 | first = false 213 | elsif sep 214 | sep.call(self) 215 | else 216 | comma_breakable 217 | end 218 | yield(*v) 219 | end 220 | end 221 | 222 | # This is a much simplified version of prettyprint's text. It avoids 223 | # calculating width by pushing the string directly onto the target. 224 | def text(string) 225 | target << string 226 | end 227 | end 228 | end 229 | -------------------------------------------------------------------------------- /lib/syntax_tree/reflection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | # This module is used to provide some reflection on the various types of nodes 5 | # and their attributes. As soon as it is required it collects all of its 6 | # information. 7 | module Reflection 8 | # This module represents the type of the values being passed to attributes 9 | # of nodes. It is used as part of the documentation of the attributes. 10 | module Type 11 | CONSTANTS = SyntaxTree.constants.to_h { [_1, SyntaxTree.const_get(_1)] } 12 | 13 | # Represents an array type that holds another type. 14 | class ArrayType 15 | attr_reader :type 16 | 17 | def initialize(type) 18 | @type = type 19 | end 20 | 21 | def ===(value) 22 | value.is_a?(Array) && value.all? { type === _1 } 23 | end 24 | 25 | def inspect 26 | "Array<#{type.inspect}>" 27 | end 28 | end 29 | 30 | # Represents a tuple type that holds a number of types in order. 31 | class TupleType 32 | attr_reader :types 33 | 34 | def initialize(types) 35 | @types = types 36 | end 37 | 38 | def ===(value) 39 | value.is_a?(Array) && value.length == types.length && 40 | value.zip(types).all? { |item, type| type === item } 41 | end 42 | 43 | def inspect 44 | "[#{types.map(&:inspect).join(", ")}]" 45 | end 46 | end 47 | 48 | # Represents a union type that can be one of a number of types. 49 | class UnionType 50 | attr_reader :types 51 | 52 | def initialize(types) 53 | @types = types 54 | end 55 | 56 | def ===(value) 57 | types.any? { _1 === value } 58 | end 59 | 60 | def inspect 61 | types.map(&:inspect).join(" | ") 62 | end 63 | end 64 | 65 | class << self 66 | def parse(comment) 67 | comment = comment.gsub("\n", " ") 68 | 69 | unless comment.start_with?("[") 70 | raise "Comment does not start with a bracket: #{comment.inspect}" 71 | end 72 | 73 | count = 1 74 | found = 75 | comment.chars[1..] 76 | .find 77 | .with_index(1) do |char, index| 78 | count += { "[" => 1, "]" => -1 }.fetch(char, 0) 79 | break index if count == 0 80 | end 81 | 82 | # If we weren't able to find the end of the balanced brackets, then 83 | # the comment is malformed. 84 | if found.nil? 85 | raise "Comment does not have balanced brackets: #{comment.inspect}" 86 | end 87 | 88 | parse_type(comment[1...found].strip) 89 | end 90 | 91 | private 92 | 93 | def parse_type(value) 94 | case value 95 | when "Integer" 96 | Integer 97 | when "String" 98 | String 99 | when "Symbol" 100 | Symbol 101 | when "boolean" 102 | UnionType.new([TrueClass, FalseClass]) 103 | when "nil" 104 | NilClass 105 | when ":\"::\"" 106 | :"::" 107 | when ":call" 108 | :call 109 | when ":nil" 110 | :nil 111 | when /\AArray\[(.+)\]\z/ 112 | ArrayType.new(parse_type($1.strip)) 113 | when /\A\[(.+)\]\z/ 114 | TupleType.new($1.strip.split(/\s*,\s*/).map { parse_type(_1) }) 115 | else 116 | if value.include?("|") 117 | UnionType.new(value.split(/\s*\|\s*/).map { parse_type(_1) }) 118 | else 119 | CONSTANTS.fetch(value.to_sym) 120 | end 121 | end 122 | end 123 | end 124 | end 125 | 126 | # This class represents one of the attributes on a node in the tree. 127 | class Attribute 128 | attr_reader :name, :comment, :type 129 | 130 | def initialize(name, comment) 131 | @name = name 132 | @comment = comment 133 | @type = Type.parse(comment) 134 | end 135 | end 136 | 137 | # This class represents one of our nodes in the tree. We're going to use it 138 | # as a placeholder for collecting all of the various places that nodes are 139 | # used. 140 | class Node 141 | attr_reader :name, :comment, :attributes, :visitor_method 142 | 143 | def initialize(name, comment, attributes, visitor_method) 144 | @name = name 145 | @comment = comment 146 | @attributes = attributes 147 | @visitor_method = visitor_method 148 | end 149 | end 150 | 151 | class << self 152 | # This is going to hold a hash of all of the nodes in the tree. The keys 153 | # are the names of the nodes as symbols. 154 | attr_reader :nodes 155 | 156 | # This expects a node name as a symbol and returns the node object for 157 | # that node. 158 | def node(name) 159 | nodes.fetch(name) 160 | end 161 | 162 | private 163 | 164 | def parse_comments(statements, index) 165 | statements[0...index] 166 | .reverse_each 167 | .take_while { _1.is_a?(SyntaxTree::Comment) } 168 | .reverse_each 169 | .map { _1.value[2..] } 170 | end 171 | end 172 | 173 | @nodes = {} 174 | 175 | # For each node, we're going to parse out its attributes and other metadata. 176 | # We'll use this as the basis for our report. 177 | program = 178 | SyntaxTree.parse(SyntaxTree.read(File.expand_path("node.rb", __dir__))) 179 | 180 | program_statements = program.statements 181 | main_statements = program_statements.body.last.bodystmt.statements.body 182 | main_statements.each_with_index do |main_statement, main_statement_index| 183 | # Ensure we are only looking at class declarations. 184 | next unless main_statement.is_a?(SyntaxTree::ClassDeclaration) 185 | 186 | # Ensure we're looking at class declarations with superclasses. 187 | superclass = main_statement.superclass 188 | next unless superclass.is_a?(SyntaxTree::VarRef) 189 | 190 | # Ensure we're looking at class declarations that inherit from Node. 191 | next unless superclass.value.value == "Node" 192 | 193 | # All child nodes inherit the location attr_reader from Node, so we'll add 194 | # that to the list of attributes first. 195 | attributes = { 196 | location: 197 | Attribute.new(:location, "[Location] the location of this node") 198 | } 199 | 200 | # This is the name of the method tha gets called on the given visitor when 201 | # the accept method is called on this node. 202 | visitor_method = nil 203 | 204 | statements = main_statement.bodystmt.statements.body 205 | statements.each_with_index do |statement, statement_index| 206 | case statement 207 | when SyntaxTree::Command 208 | # We only use commands in node classes to define attributes. So, we 209 | # can safely assume that we're looking at an attribute definition. 210 | unless %w[attr_reader attr_accessor].include?(statement.message.value) 211 | raise "Unexpected command: #{statement.message.value.inspect}" 212 | end 213 | 214 | # The arguments to the command are the attributes that we're defining. 215 | # We want to ensure that we're only defining one at a time. 216 | if statement.arguments.parts.length != 1 217 | raise "Declaring more than one attribute at a time is not permitted" 218 | end 219 | 220 | attribute = 221 | Attribute.new( 222 | statement.arguments.parts.first.value.value.to_sym, 223 | "#{parse_comments(statements, statement_index).join("\n")}\n" 224 | ) 225 | 226 | # Ensure that we don't already have an attribute named the same as 227 | # this one, and then add it to the list of attributes. 228 | if attributes.key?(attribute.name) 229 | raise "Duplicate attribute: #{attribute.name}" 230 | end 231 | 232 | attributes[attribute.name] = attribute 233 | when SyntaxTree::DefNode 234 | if statement.name.value == "accept" 235 | call_node = statement.bodystmt.statements.body.first 236 | visitor_method = call_node.message.value.to_sym 237 | end 238 | end 239 | end 240 | 241 | # If we never found a visitor method, then we have an error. 242 | raise if visitor_method.nil? 243 | 244 | # Finally, set it up in the hash of nodes so that we can use it later. 245 | comments = parse_comments(main_statements, main_statement_index) 246 | node = 247 | Node.new( 248 | main_statement.constant.constant.value.to_sym, 249 | "#{comments.join("\n")}\n", 250 | attributes, 251 | visitor_method 252 | ) 253 | 254 | @nodes[node.name] = node 255 | end 256 | end 257 | end 258 | -------------------------------------------------------------------------------- /lib/syntax_tree/pattern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | # A pattern is an object that wraps a Ruby pattern matching expression. The 5 | # expression would normally be passed to an `in` clause within a `case` 6 | # expression or a rightward assignment expression. For example, in the 7 | # following snippet: 8 | # 9 | # case node 10 | # in Const[value: "SyntaxTree"] 11 | # end 12 | # 13 | # the pattern is the `Const[value: "SyntaxTree"]` expression. Within Syntax 14 | # Tree, every node generates these kinds of expressions using the 15 | # #construct_keys method. 16 | # 17 | # The pattern gets compiled into an object that responds to call by running 18 | # the #compile method. This method itself will run back through Syntax Tree to 19 | # parse the expression into a tree, then walk the tree to generate the 20 | # necessary callable objects. For example, if you wanted to compile the 21 | # expression above into a callable, you would: 22 | # 23 | # callable = SyntaxTree::Pattern.new("Const[value: 'SyntaxTree']").compile 24 | # callable.call(node) 25 | # 26 | # The callable object returned by #compile is guaranteed to respond to #call 27 | # with a single argument, which is the node to match against. It also is 28 | # guaranteed to respond to #===, which means it itself can be used in a `case` 29 | # expression, as in: 30 | # 31 | # case node 32 | # when callable 33 | # end 34 | # 35 | # If the query given to the initializer cannot be compiled into a valid 36 | # matcher (either because of a syntax error or because it is using syntax we 37 | # do not yet support) then a SyntaxTree::Pattern::CompilationError will be 38 | # raised. 39 | class Pattern 40 | # Raised when the query given to a pattern is either invalid Ruby syntax or 41 | # is using syntax that we don't yet support. 42 | class CompilationError < StandardError 43 | def initialize(repr) 44 | super(<<~ERROR) 45 | Syntax Tree was unable to compile the pattern you provided to search 46 | into a usable expression. It failed on to understand the node 47 | represented by: 48 | 49 | #{repr} 50 | 51 | Note that not all syntax supported by Ruby's pattern matching syntax 52 | is also supported by Syntax Tree's code search. If you're using some 53 | syntax that you believe should be supported, please open an issue on 54 | GitHub at https://github.com/ruby-syntax-tree/syntax_tree/issues/new. 55 | ERROR 56 | end 57 | end 58 | 59 | attr_reader :query 60 | 61 | def initialize(query) 62 | @query = query 63 | end 64 | 65 | def compile 66 | program = 67 | begin 68 | SyntaxTree.parse("case nil\nin #{query}\nend") 69 | rescue Parser::ParseError 70 | raise CompilationError, query 71 | end 72 | 73 | raise CompilationError, query if program.nil? 74 | compile_node(program.statements.body.first.consequent.pattern) 75 | end 76 | 77 | private 78 | 79 | # Shortcut for combining two procs into one that returns true if both return 80 | # true. 81 | def combine_and(left, right) 82 | ->(other) { left.call(other) && right.call(other) } 83 | end 84 | 85 | # Shortcut for combining two procs into one that returns true if either 86 | # returns true. 87 | def combine_or(left, right) 88 | ->(other) { left.call(other) || right.call(other) } 89 | end 90 | 91 | # Raise an error because the given node is not supported. 92 | def compile_error(node) 93 | raise CompilationError, PP.pp(node, +"").chomp 94 | end 95 | 96 | # There are a couple of nodes (string literals, dynamic symbols, and regexp) 97 | # that contain list of parts. This can include plain string content, 98 | # interpolated expressions, and interpolated variables. We only support 99 | # plain string content, so this method will extract out the plain string 100 | # content if it is the only element in the list. 101 | def extract_string(node) 102 | parts = node.parts 103 | 104 | if parts.length == 1 && (part = parts.first) && part.is_a?(TStringContent) 105 | part.value 106 | end 107 | end 108 | 109 | # in [foo, bar, baz] 110 | def compile_aryptn(node) 111 | compile_error(node) if !node.rest.nil? || node.posts.any? 112 | 113 | constant = node.constant 114 | compiled_constant = compile_node(constant) if constant 115 | 116 | preprocessed = node.requireds.map { |required| compile_node(required) } 117 | 118 | compiled_requireds = ->(other) do 119 | deconstructed = other.deconstruct 120 | 121 | deconstructed.length == preprocessed.length && 122 | preprocessed 123 | .zip(deconstructed) 124 | .all? { |(matcher, value)| matcher.call(value) } 125 | end 126 | 127 | if compiled_constant 128 | combine_and(compiled_constant, compiled_requireds) 129 | else 130 | compiled_requireds 131 | end 132 | end 133 | 134 | # in foo | bar 135 | def compile_binary(node) 136 | compile_error(node) if node.operator != :| 137 | 138 | combine_or(compile_node(node.left), compile_node(node.right)) 139 | end 140 | 141 | # in Ident 142 | # in String 143 | def compile_const(node) 144 | value = node.value 145 | 146 | if SyntaxTree.const_defined?(value, false) 147 | clazz = SyntaxTree.const_get(value) 148 | 149 | ->(other) { clazz === other } 150 | elsif Object.const_defined?(value, false) 151 | clazz = Object.const_get(value) 152 | 153 | ->(other) { clazz === other } 154 | else 155 | compile_error(node) 156 | end 157 | end 158 | 159 | # in SyntaxTree::Ident 160 | def compile_const_path_ref(node) 161 | parent = node.parent 162 | compile_error(node) if !parent.is_a?(VarRef) || !parent.value.is_a?(Const) 163 | 164 | if parent.value.value == "SyntaxTree" 165 | compile_node(node.constant) 166 | else 167 | compile_error(node) 168 | end 169 | end 170 | 171 | # in :"" 172 | # in :"foo" 173 | def compile_dyna_symbol(node) 174 | if node.parts.empty? 175 | symbol = :"" 176 | 177 | ->(other) { symbol === other } 178 | elsif (value = extract_string(node)) 179 | symbol = value.to_sym 180 | 181 | ->(other) { symbol === other } 182 | else 183 | compile_error(node) 184 | end 185 | end 186 | 187 | # in Ident[value: String] 188 | # in { value: String } 189 | def compile_hshptn(node) 190 | compile_error(node) unless node.keyword_rest.nil? 191 | compiled_constant = compile_node(node.constant) if node.constant 192 | 193 | preprocessed = 194 | node.keywords.to_h do |keyword, value| 195 | compile_error(node) unless keyword.is_a?(Label) 196 | [keyword.value.chomp(":").to_sym, compile_node(value)] 197 | end 198 | 199 | compiled_keywords = ->(other) do 200 | deconstructed = other.deconstruct_keys(preprocessed.keys) 201 | 202 | preprocessed.all? do |keyword, matcher| 203 | matcher.call(deconstructed[keyword]) 204 | end 205 | end 206 | 207 | if compiled_constant 208 | combine_and(compiled_constant, compiled_keywords) 209 | else 210 | compiled_keywords 211 | end 212 | end 213 | 214 | # in /foo/ 215 | def compile_regexp_literal(node) 216 | if (value = extract_string(node)) 217 | regexp = /#{value}/ 218 | 219 | ->(attribute) { regexp === attribute } 220 | else 221 | compile_error(node) 222 | end 223 | end 224 | 225 | # in "" 226 | # in "foo" 227 | def compile_string_literal(node) 228 | if node.parts.empty? 229 | ->(attribute) { "" === attribute } 230 | elsif (value = extract_string(node)) 231 | ->(attribute) { value === attribute } 232 | else 233 | compile_error(node) 234 | end 235 | end 236 | 237 | # in :+ 238 | # in :foo 239 | def compile_symbol_literal(node) 240 | symbol = node.value.value.to_sym 241 | 242 | ->(attribute) { symbol === attribute } 243 | end 244 | 245 | # in Foo 246 | # in nil 247 | def compile_var_ref(node) 248 | value = node.value 249 | 250 | if value.is_a?(Const) 251 | compile_node(value) 252 | elsif value.is_a?(Kw) && value.value.nil? 253 | ->(attribute) { nil === attribute } 254 | else 255 | compile_error(node) 256 | end 257 | end 258 | 259 | # Compile any kind of node. Dispatch out to the individual compilation 260 | # methods based on the type of node. 261 | def compile_node(node) 262 | case node 263 | when AryPtn 264 | compile_aryptn(node) 265 | when Binary 266 | compile_binary(node) 267 | when Const 268 | compile_const(node) 269 | when ConstPathRef 270 | compile_const_path_ref(node) 271 | when DynaSymbol 272 | compile_dyna_symbol(node) 273 | when HshPtn 274 | compile_hshptn(node) 275 | when RegexpLiteral 276 | compile_regexp_literal(node) 277 | when StringLiteral 278 | compile_string_literal(node) 279 | when SymbolLiteral 280 | compile_symbol_literal(node) 281 | when VarRef 282 | compile_var_ref(node) 283 | else 284 | compile_error(node) 285 | end 286 | end 287 | end 288 | end 289 | -------------------------------------------------------------------------------- /lib/syntax_tree/database.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | # Provides the ability to index source files into a database, then query for 5 | # the nodes. 6 | module Database 7 | class IndexingVisitor < SyntaxTree::FieldVisitor 8 | attr_reader :database, :filepath, :node_id 9 | 10 | def initialize(database, filepath) 11 | @database = database 12 | @filepath = filepath 13 | @node_id = nil 14 | end 15 | 16 | private 17 | 18 | def comments(node) 19 | end 20 | 21 | def field(name, value) 22 | return unless value.is_a?(SyntaxTree::Node) 23 | 24 | binds = [node_id, visit(value), name] 25 | database.execute(<<~SQL, binds) 26 | INSERT INTO edges (from_id, to_id, name) 27 | VALUES (?, ?, ?) 28 | SQL 29 | end 30 | 31 | def list(name, values) 32 | values.each_with_index do |value, index| 33 | binds = [node_id, visit(value), name, index] 34 | database.execute(<<~SQL, binds) 35 | INSERT INTO edges (from_id, to_id, name, list_index) 36 | VALUES (?, ?, ?, ?) 37 | SQL 38 | end 39 | end 40 | 41 | def node(node, _name) 42 | previous = node_id 43 | binds = [ 44 | node.class.name.delete_prefix("SyntaxTree::"), 45 | filepath, 46 | node.location.start_line, 47 | node.location.start_column 48 | ] 49 | 50 | database.execute(<<~SQL, binds) 51 | INSERT INTO nodes (type, path, line, column) 52 | VALUES (?, ?, ?, ?) 53 | SQL 54 | 55 | begin 56 | @node_id = database.last_insert_row_id 57 | yield 58 | @node_id 59 | ensure 60 | @node_id = previous 61 | end 62 | end 63 | 64 | def text(name, value) 65 | end 66 | 67 | def pairs(name, values) 68 | values.each_with_index do |(key, value), index| 69 | binds = [node_id, visit(key), "#{name}[0]", index] 70 | database.execute(<<~SQL, binds) 71 | INSERT INTO edges (from_id, to_id, name, list_index) 72 | VALUES (?, ?, ?, ?) 73 | SQL 74 | 75 | binds = [node_id, visit(value), "#{name}[1]", index] 76 | database.execute(<<~SQL, binds) 77 | INSERT INTO edges (from_id, to_id, name, list_index) 78 | VALUES (?, ?, ?, ?) 79 | SQL 80 | end 81 | end 82 | end 83 | 84 | # Query for a specific type of node. 85 | class TypeQuery 86 | attr_reader :type 87 | 88 | def initialize(type) 89 | @type = type 90 | end 91 | 92 | def each(database, &block) 93 | sql = "SELECT * FROM nodes WHERE type = ?" 94 | database.execute(sql, type).each(&block) 95 | end 96 | end 97 | 98 | # Query for the attributes of a node, optionally also filtering by type. 99 | class AttrQuery 100 | attr_reader :type, :attrs 101 | 102 | def initialize(type, attrs) 103 | @type = type 104 | @attrs = attrs 105 | end 106 | 107 | def each(database, &block) 108 | joins = [] 109 | binds = [] 110 | 111 | attrs.each do |name, query| 112 | ids = query.each(database).map { |row| row[0] } 113 | joins << <<~SQL 114 | JOIN edges AS #{name} 115 | ON #{name}.from_id = nodes.id 116 | AND #{name}.name = ? 117 | AND #{name}.to_id IN (#{(["?"] * ids.size).join(", ")}) 118 | SQL 119 | 120 | binds.push(name).concat(ids) 121 | end 122 | 123 | sql = +"SELECT nodes.* FROM nodes, edges #{joins.join(" ")}" 124 | 125 | if type 126 | sql << " WHERE nodes.type = ?" 127 | binds << type 128 | end 129 | 130 | sql << " GROUP BY nodes.id" 131 | database.execute(sql, binds).each(&block) 132 | end 133 | end 134 | 135 | # Query for the results of either query. 136 | class OrQuery 137 | attr_reader :left, :right 138 | 139 | def initialize(left, right) 140 | @left = left 141 | @right = right 142 | end 143 | 144 | def each(database, &block) 145 | left.each(database, &block) 146 | right.each(database, &block) 147 | end 148 | end 149 | 150 | # A lazy query result. 151 | class QueryResult 152 | attr_reader :database, :query 153 | 154 | def initialize(database, query) 155 | @database = database 156 | @query = query 157 | end 158 | 159 | def each(&block) 160 | return enum_for(__method__) unless block_given? 161 | query.each(database, &block) 162 | end 163 | end 164 | 165 | # A pattern matching expression that will be compiled into a query. 166 | class Pattern 167 | class CompilationError < StandardError 168 | end 169 | 170 | attr_reader :query 171 | 172 | def initialize(query) 173 | @query = query 174 | end 175 | 176 | def compile 177 | program = 178 | begin 179 | SyntaxTree.parse("case nil\nin #{query}\nend") 180 | rescue Parser::ParseError 181 | raise CompilationError, query 182 | end 183 | 184 | compile_node(program.statements.body.first.consequent.pattern) 185 | end 186 | 187 | private 188 | 189 | def compile_error(node) 190 | raise CompilationError, PP.pp(node, +"").chomp 191 | end 192 | 193 | # Shortcut for combining two queries into one that returns the results of 194 | # if either query matches. 195 | def combine_or(left, right) 196 | OrQuery.new(left, right) 197 | end 198 | 199 | # in foo | bar 200 | def compile_binary(node) 201 | compile_error(node) if node.operator != :| 202 | 203 | combine_or(compile_node(node.left), compile_node(node.right)) 204 | end 205 | 206 | # in Ident 207 | def compile_const(node) 208 | value = node.value 209 | 210 | if SyntaxTree.const_defined?(value, false) 211 | clazz = SyntaxTree.const_get(value) 212 | TypeQuery.new(clazz.name.delete_prefix("SyntaxTree::")) 213 | else 214 | compile_error(node) 215 | end 216 | end 217 | 218 | # in SyntaxTree::Ident 219 | def compile_const_path_ref(node) 220 | parent = node.parent 221 | if !parent.is_a?(SyntaxTree::VarRef) || 222 | !parent.value.is_a?(SyntaxTree::Const) 223 | compile_error(node) 224 | end 225 | 226 | if parent.value.value == "SyntaxTree" 227 | compile_node(node.constant) 228 | else 229 | compile_error(node) 230 | end 231 | end 232 | 233 | # in Ident[value: String] 234 | def compile_hshptn(node) 235 | compile_error(node) unless node.keyword_rest.nil? 236 | 237 | attrs = {} 238 | node.keywords.each do |keyword, value| 239 | compile_error(node) unless keyword.is_a?(SyntaxTree::Label) 240 | attrs[keyword.value.chomp(":")] = compile_node(value) 241 | end 242 | 243 | type = node.constant ? compile_node(node.constant).type : nil 244 | AttrQuery.new(type, attrs) 245 | end 246 | 247 | # in Foo 248 | def compile_var_ref(node) 249 | value = node.value 250 | 251 | if value.is_a?(SyntaxTree::Const) 252 | compile_node(value) 253 | else 254 | compile_error(node) 255 | end 256 | end 257 | 258 | def compile_node(node) 259 | case node 260 | when SyntaxTree::Binary 261 | compile_binary(node) 262 | when SyntaxTree::Const 263 | compile_const(node) 264 | when SyntaxTree::ConstPathRef 265 | compile_const_path_ref(node) 266 | when SyntaxTree::HshPtn 267 | compile_hshptn(node) 268 | when SyntaxTree::VarRef 269 | compile_var_ref(node) 270 | else 271 | compile_error(node) 272 | end 273 | end 274 | end 275 | 276 | class Connection 277 | attr_reader :raw_connection 278 | 279 | def initialize(raw_connection) 280 | @raw_connection = raw_connection 281 | end 282 | 283 | def execute(query, binds = []) 284 | raw_connection.execute(query, binds) 285 | end 286 | 287 | def index_file(filepath) 288 | program = SyntaxTree.parse(SyntaxTree.read(filepath)) 289 | program.accept(IndexingVisitor.new(self, filepath)) 290 | end 291 | 292 | def last_insert_row_id 293 | raw_connection.last_insert_row_id 294 | end 295 | 296 | def prepare 297 | raw_connection.execute(<<~SQL) 298 | CREATE TABLE nodes ( 299 | id integer primary key, 300 | type varchar(20), 301 | path varchar(200), 302 | line integer, 303 | column integer 304 | ); 305 | SQL 306 | 307 | raw_connection.execute(<<~SQL) 308 | CREATE INDEX nodes_type ON nodes (type); 309 | SQL 310 | 311 | raw_connection.execute(<<~SQL) 312 | CREATE TABLE edges ( 313 | id integer primary key, 314 | from_id integer, 315 | to_id integer, 316 | name varchar(20), 317 | list_index integer 318 | ); 319 | SQL 320 | 321 | raw_connection.execute(<<~SQL) 322 | CREATE INDEX edges_name ON edges (name); 323 | SQL 324 | end 325 | 326 | def search(query) 327 | QueryResult.new(self, Pattern.new(query).compile) 328 | end 329 | end 330 | end 331 | end 332 | -------------------------------------------------------------------------------- /lib/syntax_tree/with_scope.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | # WithScope is a module intended to be included in classes inheriting from 5 | # Visitor. The module overrides a few visit methods to automatically keep 6 | # track of local variables and arguments defined in the current scope. 7 | # Example usage: 8 | # 9 | # class MyVisitor < Visitor 10 | # include WithScope 11 | # 12 | # def visit_ident(node) 13 | # # Check if we're visiting an identifier for an argument, a local 14 | # # variable or something else 15 | # local = current_scope.find_local(node) 16 | # 17 | # if local.type == :argument 18 | # # handle identifiers for arguments 19 | # elsif local.type == :variable 20 | # # handle identifiers for variables 21 | # else 22 | # # handle other identifiers, such as method names 23 | # end 24 | # end 25 | # end 26 | # 27 | module WithScope 28 | # The scope class is used to keep track of local variables and arguments 29 | # inside a particular scope. 30 | class Scope 31 | # This class tracks the occurrences of a local variable or argument. 32 | class Local 33 | # [Symbol] The type of the local (e.g. :argument, :variable) 34 | attr_reader :type 35 | 36 | # [Array[Location]] The locations of all definitions and assignments of 37 | # this local 38 | attr_reader :definitions 39 | 40 | # [Array[Location]] The locations of all usages of this local 41 | attr_reader :usages 42 | 43 | def initialize(type) 44 | @type = type 45 | @definitions = [] 46 | @usages = [] 47 | end 48 | 49 | def add_definition(location) 50 | @definitions << location 51 | end 52 | 53 | def add_usage(location) 54 | @usages << location 55 | end 56 | end 57 | 58 | # [Integer] a unique identifier for this scope 59 | attr_reader :id 60 | 61 | # [scope | nil] The parent scope 62 | attr_reader :parent 63 | 64 | # [Hash[String, Local]] The local variables and arguments defined in this 65 | # scope 66 | attr_reader :locals 67 | 68 | def initialize(id, parent = nil) 69 | @id = id 70 | @parent = parent 71 | @locals = {} 72 | end 73 | 74 | # Adding a local definition will either insert a new entry in the locals 75 | # hash or append a new definition location to an existing local. Notice 76 | # that it's not possible to change the type of a local after it has been 77 | # registered. 78 | def add_local_definition(identifier, type) 79 | name = identifier.value.delete_suffix(":") 80 | 81 | local = 82 | if type == :argument 83 | locals[name] ||= Local.new(type) 84 | else 85 | resolve_local(name, type) 86 | end 87 | 88 | local.add_definition(identifier.location) 89 | end 90 | 91 | # Adding a local usage will either insert a new entry in the locals 92 | # hash or append a new usage location to an existing local. Notice that 93 | # it's not possible to change the type of a local after it has been 94 | # registered. 95 | def add_local_usage(identifier, type) 96 | name = identifier.value.delete_suffix(":") 97 | resolve_local(name, type).add_usage(identifier.location) 98 | end 99 | 100 | # Try to find the local given its name in this scope or any of its 101 | # parents. 102 | def find_local(name) 103 | locals[name] || parent&.find_local(name) 104 | end 105 | 106 | private 107 | 108 | def resolve_local(name, type) 109 | local = find_local(name) 110 | 111 | unless local 112 | local = Local.new(type) 113 | locals[name] = local 114 | end 115 | 116 | local 117 | end 118 | end 119 | 120 | attr_reader :current_scope 121 | 122 | def initialize(*args, **kwargs, &block) 123 | super 124 | 125 | @current_scope = Scope.new(0) 126 | @next_scope_id = 0 127 | end 128 | 129 | # Visits for nodes that create new scopes, such as classes, modules 130 | # and method definitions. 131 | def visit_class(node) 132 | with_scope { super } 133 | end 134 | 135 | def visit_module(node) 136 | with_scope { super } 137 | end 138 | 139 | # When we find a method invocation with a block, only the code that happens 140 | # inside of the block needs a fresh scope. The method invocation 141 | # itself happens in the same scope. 142 | def visit_method_add_block(node) 143 | visit(node.call) 144 | with_scope(current_scope) { visit(node.block) } 145 | end 146 | 147 | def visit_def(node) 148 | with_scope { super } 149 | end 150 | 151 | # Visit for keeping track of local arguments, such as method and block 152 | # arguments. 153 | def visit_params(node) 154 | add_argument_definitions(node.requireds) 155 | add_argument_definitions(node.posts) 156 | 157 | node.keywords.each do |param| 158 | current_scope.add_local_definition(param.first, :argument) 159 | end 160 | 161 | node.optionals.each do |param| 162 | current_scope.add_local_definition(param.first, :argument) 163 | end 164 | 165 | super 166 | end 167 | 168 | def visit_rest_param(node) 169 | name = node.name 170 | current_scope.add_local_definition(name, :argument) if name 171 | 172 | super 173 | end 174 | 175 | def visit_kwrest_param(node) 176 | name = node.name 177 | current_scope.add_local_definition(name, :argument) if name 178 | 179 | super 180 | end 181 | 182 | def visit_blockarg(node) 183 | name = node.name 184 | current_scope.add_local_definition(name, :argument) if name 185 | 186 | super 187 | end 188 | 189 | def visit_block_var(node) 190 | node.locals.each do |local| 191 | current_scope.add_local_definition(local, :variable) 192 | end 193 | 194 | super 195 | end 196 | alias visit_lambda_var visit_block_var 197 | 198 | # Visit for keeping track of local variable definitions 199 | def visit_var_field(node) 200 | value = node.value 201 | current_scope.add_local_definition(value, :variable) if value.is_a?(Ident) 202 | 203 | super 204 | end 205 | 206 | # Visit for keeping track of local variable definitions 207 | def visit_pinned_var_ref(node) 208 | value = node.value 209 | current_scope.add_local_usage(value, :variable) if value.is_a?(Ident) 210 | 211 | super 212 | end 213 | 214 | # Visits for keeping track of variable and argument usages 215 | def visit_var_ref(node) 216 | value = node.value 217 | 218 | if value.is_a?(Ident) 219 | definition = current_scope.find_local(value.value) 220 | current_scope.add_local_usage(value, definition.type) if definition 221 | end 222 | 223 | super 224 | end 225 | 226 | # When using regex named capture groups, vcalls might actually be a variable 227 | def visit_vcall(node) 228 | value = node.value 229 | definition = current_scope.find_local(value.value) 230 | current_scope.add_local_usage(value, definition.type) if definition 231 | 232 | super 233 | end 234 | 235 | # Visit for capturing local variables defined in regex named capture groups 236 | def visit_binary(node) 237 | if node.operator == :=~ 238 | left = node.left 239 | 240 | if left.is_a?(RegexpLiteral) && left.parts.length == 1 && 241 | left.parts.first.is_a?(TStringContent) 242 | content = left.parts.first 243 | 244 | value = content.value 245 | location = content.location 246 | start_line = location.start_line 247 | 248 | Regexp 249 | .new(value, Regexp::FIXEDENCODING) 250 | .names 251 | .each do |name| 252 | offset = value.index(/\(\?<#{Regexp.escape(name)}>/) 253 | line = start_line + value[0...offset].count("\n") 254 | 255 | # We need to add 3 to account for these three characters 256 | # prefixing a named capture (?< 257 | column = location.start_column + offset + 3 258 | if value[0...offset].include?("\n") 259 | column = 260 | value[0...offset].length - value[0...offset].rindex("\n") + 261 | 3 - 1 262 | end 263 | 264 | ident_location = 265 | Location.new( 266 | start_line: line, 267 | start_char: location.start_char + offset, 268 | start_column: column, 269 | end_line: line, 270 | end_char: location.start_char + offset + name.length, 271 | end_column: column + name.length 272 | ) 273 | 274 | identifier = Ident.new(value: name, location: ident_location) 275 | current_scope.add_local_definition(identifier, :variable) 276 | end 277 | end 278 | end 279 | 280 | super 281 | end 282 | 283 | private 284 | 285 | def add_argument_definitions(list) 286 | list.each do |param| 287 | case param 288 | when ArgStar 289 | value = param.value 290 | current_scope.add_local_definition(value, :argument) if value 291 | when MLHSParen 292 | add_argument_definitions(param.contents.parts) 293 | else 294 | current_scope.add_local_definition(param, :argument) 295 | end 296 | end 297 | end 298 | 299 | def next_scope_id 300 | @next_scope_id += 1 301 | end 302 | 303 | def with_scope(parent_scope = nil) 304 | previous_scope = @current_scope 305 | @current_scope = Scope.new(next_scope_id, parent_scope) 306 | yield 307 | ensure 308 | @current_scope = previous_scope 309 | end 310 | end 311 | end 312 | -------------------------------------------------------------------------------- /test/language_server_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | require "syntax_tree/language_server" 5 | 6 | module SyntaxTree 7 | # stree-ignore 8 | class LanguageServerTest < Minitest::Test 9 | class Initialize 10 | attr_reader :id 11 | 12 | def initialize(id) 13 | @id = id 14 | end 15 | 16 | def to_hash 17 | { method: "initialize", id: id } 18 | end 19 | end 20 | 21 | class Shutdown 22 | attr_reader :id 23 | 24 | def initialize(id) 25 | @id = id 26 | end 27 | 28 | def to_hash 29 | { method: "shutdown", id: id } 30 | end 31 | end 32 | 33 | class TextDocumentDidOpen 34 | attr_reader :uri, :text 35 | 36 | def initialize(uri, text) 37 | @uri = uri 38 | @text = text 39 | end 40 | 41 | def to_hash 42 | { 43 | method: "textDocument/didOpen", 44 | params: { textDocument: { uri: uri, text: text } } 45 | } 46 | end 47 | end 48 | 49 | class TextDocumentDidChange 50 | attr_reader :uri, :text 51 | 52 | def initialize(uri, text) 53 | @uri = uri 54 | @text = text 55 | end 56 | 57 | def to_hash 58 | { 59 | method: "textDocument/didChange", 60 | params: { 61 | textDocument: { uri: uri }, 62 | contentChanges: [{ text: text }] 63 | } 64 | } 65 | end 66 | end 67 | 68 | class TextDocumentDidClose 69 | attr_reader :uri 70 | 71 | def initialize(uri) 72 | @uri = uri 73 | end 74 | 75 | def to_hash 76 | { 77 | method: "textDocument/didClose", 78 | params: { textDocument: { uri: uri } } 79 | } 80 | end 81 | end 82 | 83 | class TextDocumentFormatting 84 | attr_reader :id, :uri 85 | 86 | def initialize(id, uri) 87 | @id = id 88 | @uri = uri 89 | end 90 | 91 | def to_hash 92 | { 93 | method: "textDocument/formatting", 94 | id: id, 95 | params: { textDocument: { uri: uri } } 96 | } 97 | end 98 | end 99 | 100 | class TextDocumentInlayHint 101 | attr_reader :id, :uri 102 | 103 | def initialize(id, uri) 104 | @id = id 105 | @uri = uri 106 | end 107 | 108 | def to_hash 109 | { 110 | method: "textDocument/inlayHint", 111 | id: id, 112 | params: { textDocument: { uri: uri } } 113 | } 114 | end 115 | end 116 | 117 | class SyntaxTreeVisualizing 118 | attr_reader :id, :uri 119 | 120 | def initialize(id, uri) 121 | @id = id 122 | @uri = uri 123 | end 124 | 125 | def to_hash 126 | { 127 | method: "syntaxTree/visualizing", 128 | id: id, 129 | params: { textDocument: { uri: uri } } 130 | } 131 | end 132 | end 133 | 134 | def test_formatting 135 | responses = run_server([ 136 | Initialize.new(1), 137 | TextDocumentDidOpen.new("file:///path/to/file.rb", "class Foo; end"), 138 | TextDocumentDidChange.new("file:///path/to/file.rb", "class Bar; end"), 139 | TextDocumentFormatting.new(2, "file:///path/to/file.rb"), 140 | TextDocumentDidClose.new("file:///path/to/file.rb"), 141 | Shutdown.new(3) 142 | ]) 143 | 144 | shape = LanguageServer::Request[[ 145 | { id: 1, result: { capabilities: Hash } }, 146 | { id: 2, result: [{ newText: :any }] }, 147 | { id: 3, result: {} } 148 | ]] 149 | 150 | assert_operator(shape, :===, responses) 151 | assert_equal("class Bar\nend\n", responses.dig(1, :result, 0, :newText)) 152 | end 153 | 154 | def test_formatting_ignore 155 | responses = run_server([ 156 | Initialize.new(1), 157 | TextDocumentDidOpen.new("file:///path/to/file.rb", "class Foo; end"), 158 | TextDocumentFormatting.new(2, "file:///path/to/file.rb"), 159 | Shutdown.new(3) 160 | ], ignore_files: ["path/**/*.rb"]) 161 | 162 | shape = LanguageServer::Request[[ 163 | { id: 1, result: { capabilities: Hash } }, 164 | { id: 2, result: :any }, 165 | { id: 3, result: {} } 166 | ]] 167 | 168 | assert_operator(shape, :===, responses) 169 | assert_nil(responses.dig(1, :result)) 170 | end 171 | 172 | def test_formatting_failure 173 | responses = run_server([ 174 | Initialize.new(1), 175 | TextDocumentDidOpen.new("file:///path/to/file.rb", "<>"), 176 | TextDocumentFormatting.new(2, "file:///path/to/file.rb"), 177 | Shutdown.new(3) 178 | ]) 179 | 180 | shape = LanguageServer::Request[[ 181 | { id: 1, result: { capabilities: Hash } }, 182 | { id: 2, result: :any }, 183 | { id: 3, result: {} } 184 | ]] 185 | 186 | assert_operator(shape, :===, responses) 187 | assert_nil(responses.dig(1, :result)) 188 | end 189 | 190 | def test_formatting_print_width 191 | contents = "#{"a" * 40} + #{"b" * 40}\n" 192 | responses = run_server([ 193 | Initialize.new(1), 194 | TextDocumentDidOpen.new("file:///path/to/file.rb", contents), 195 | TextDocumentFormatting.new(2, "file:///path/to/file.rb"), 196 | TextDocumentDidClose.new("file:///path/to/file.rb"), 197 | Shutdown.new(3) 198 | ], print_width: 100) 199 | 200 | shape = LanguageServer::Request[[ 201 | { id: 1, result: { capabilities: Hash } }, 202 | { id: 2, result: [{ newText: :any }] }, 203 | { id: 3, result: {} } 204 | ]] 205 | 206 | assert_operator(shape, :===, responses) 207 | assert_equal(contents, responses.dig(1, :result, 0, :newText)) 208 | end 209 | 210 | def test_inlay_hint 211 | responses = run_server([ 212 | Initialize.new(1), 213 | TextDocumentDidOpen.new("file:///path/to/file.rb", <<~RUBY), 214 | begin 215 | 1 + 2 * 3 216 | rescue 217 | end 218 | RUBY 219 | TextDocumentInlayHint.new(2, "file:///path/to/file.rb"), 220 | Shutdown.new(3) 221 | ]) 222 | 223 | shape = LanguageServer::Request[[ 224 | { id: 1, result: { capabilities: Hash } }, 225 | { id: 2, result: :any }, 226 | { id: 3, result: {} } 227 | ]] 228 | 229 | assert_operator(shape, :===, responses) 230 | assert_equal(3, responses.dig(1, :result).size) 231 | end 232 | 233 | def test_inlay_hint_invalid 234 | responses = run_server([ 235 | Initialize.new(1), 236 | TextDocumentDidOpen.new("file:///path/to/file.rb", "<>"), 237 | TextDocumentInlayHint.new(2, "file:///path/to/file.rb"), 238 | Shutdown.new(3) 239 | ]) 240 | 241 | shape = LanguageServer::Request[[ 242 | { id: 1, result: { capabilities: Hash } }, 243 | { id: 2, result: :any }, 244 | { id: 3, result: {} } 245 | ]] 246 | 247 | assert_operator(shape, :===, responses) 248 | assert_equal(0, responses.dig(1, :result).size) 249 | end 250 | 251 | def test_visualizing 252 | responses = run_server([ 253 | Initialize.new(1), 254 | TextDocumentDidOpen.new("file:///path/to/file.rb", "1 + 2"), 255 | SyntaxTreeVisualizing.new(2, "file:///path/to/file.rb"), 256 | Shutdown.new(3) 257 | ]) 258 | 259 | shape = LanguageServer::Request[[ 260 | { id: 1, result: { capabilities: Hash } }, 261 | { id: 2, result: :any }, 262 | { id: 3, result: {} } 263 | ]] 264 | 265 | assert_operator(shape, :===, responses) 266 | assert_equal( 267 | "(program (statements ((binary (int \"1\") + (int \"2\")))))\n", 268 | responses.dig(1, :result) 269 | ) 270 | end 271 | 272 | def test_reading_file 273 | Tempfile.open(%w[test- .rb]) do |file| 274 | file.write("class Foo; end") 275 | file.rewind 276 | 277 | responses = run_server([ 278 | Initialize.new(1), 279 | TextDocumentFormatting.new(2, "file://#{file.path}"), 280 | Shutdown.new(3) 281 | ]) 282 | 283 | shape = LanguageServer::Request[[ 284 | { id: 1, result: { capabilities: Hash } }, 285 | { id: 2, result: [{ newText: :any }] }, 286 | { id: 3, result: {} } 287 | ]] 288 | 289 | assert_operator(shape, :===, responses) 290 | assert_equal("class Foo\nend\n", responses.dig(1, :result, 0, :newText)) 291 | end 292 | end 293 | 294 | def test_bogus_request 295 | assert_raises(ArgumentError) do 296 | run_server([{ method: "textDocument/bogus" }]) 297 | end 298 | end 299 | 300 | def test_clean_shutdown 301 | responses = run_server([Initialize.new(1), Shutdown.new(2)]) 302 | 303 | shape = LanguageServer::Request[[ 304 | { id: 1, result: { capabilities: Hash } }, 305 | { id: 2, result: {} } 306 | ]] 307 | 308 | assert_operator(shape, :===, responses) 309 | end 310 | 311 | def test_file_that_does_not_exist 312 | responses = run_server([ 313 | Initialize.new(1), 314 | TextDocumentFormatting.new(2, "file:///path/to/file.rb"), 315 | Shutdown.new(3) 316 | ]) 317 | 318 | shape = LanguageServer::Request[[ 319 | { id: 1, result: { capabilities: Hash } }, 320 | { id: 2, result: :any }, 321 | { id: 3, result: {} } 322 | ]] 323 | 324 | assert_operator(shape, :===, responses) 325 | end 326 | 327 | private 328 | 329 | def write(content) 330 | request = content.to_hash.merge(jsonrpc: "2.0").to_json 331 | "Content-Length: #{request.bytesize}\r\n\r\n#{request}" 332 | end 333 | 334 | def read(content) 335 | [].tap do |messages| 336 | while (headers = content.gets("\r\n\r\n")) 337 | source = content.read(headers[/Content-Length: (\d+)/i, 1].to_i) 338 | messages << JSON.parse(source, symbolize_names: true) 339 | end 340 | end 341 | end 342 | 343 | def run_server(messages, print_width: DEFAULT_PRINT_WIDTH, ignore_files: []) 344 | input = StringIO.new(messages.map { |message| write(message) }.join) 345 | output = StringIO.new 346 | 347 | LanguageServer.new( 348 | input: input, 349 | output: output, 350 | print_width: print_width, 351 | ignore_files: ignore_files 352 | ).run 353 | 354 | read(output.tap(&:rewind)) 355 | end 356 | end 357 | end 358 | --------------------------------------------------------------------------------