diff --git a/.rubocop.yml b/.rubocop.yml index 8c1bc99e..8cf5f209 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -78,3 +78,6 @@ Style/PerlBackrefs: Style/SpecialGlobalVars: Enabled: false + +Style/StructInheritance: + Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d80cfc3..aa928c0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## [Unreleased] +## [2.7.1] - 2022-05-25 + +### Added + +- [#92](https://github.com/ruby-syntax-tree/syntax_tree/pull/92) - (Internal) Drastically increase test coverage, including many more tests for the language server and the CLI. + +### Changed + +- [#87](https://github.com/ruby-syntax-tree/syntax_tree/pull/87) - Don't convert quotes on strings if it would result in more escapes. +- [#91](https://github.com/ruby-syntax-tree/syntax_tree/pull/91) - Always use `[]` with array patterns. There are just too many edge cases where you have to use them anyway. This simplifies the look and makes it more consistent. +- [#92](https://github.com/ruby-syntax-tree/syntax_tree/pull/92) - Remodel the currently shipped plugins such that they're modifying an options hash instead of overriding methods. This should make it easier for other plugins to reference the already loaded plugins, e.g., the RBS plugin referencing the quotes. +- [#92](https://github.com/ruby-syntax-tree/syntax_tree/pull/92) - Fix up the language server inlay hints to continue walking the tree once a pattern is found. This should increase useability. + ## [2.7.0] - 2022-05-19 ### Added @@ -246,7 +259,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - šŸŽ‰ Initial release! šŸŽ‰ -[unreleased]: https://github.com/ruby-syntax-tree/syntax_tree/compare/v2.7.0...HEAD +[unreleased]: https://github.com/ruby-syntax-tree/syntax_tree/compare/v2.7.1...HEAD +[2.7.1]: https://github.com/ruby-syntax-tree/syntax_tree/compare/v2.7.0...v2.7.1 [2.7.0]: https://github.com/ruby-syntax-tree/syntax_tree/compare/v2.6.0...v2.7.0 [2.6.0]: https://github.com/ruby-syntax-tree/syntax_tree/compare/v2.5.0...v2.6.0 [2.5.0]: https://github.com/ruby-syntax-tree/syntax_tree/compare/v2.4.1...v2.5.0 diff --git a/Gemfile.lock b/Gemfile.lock index 3f892652..d707afd5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - syntax_tree (2.7.0) + syntax_tree (2.7.1) prettier_print GEM diff --git a/lib/syntax_tree/formatter.rb b/lib/syntax_tree/formatter.rb index 5d362129..56de6a4a 100644 --- a/lib/syntax_tree/formatter.rb +++ b/lib/syntax_tree/formatter.rb @@ -4,6 +4,18 @@ module SyntaxTree # A slightly enhanced PP that knows how to format recursively including # comments. class Formatter < PrettierPrint + # We want to minimize as much as possible the number of options that are + # available in syntax tree. For the most part, if users want non-default + # formatting, they should override the format methods on the specific nodes + # themselves. However, because of some history with prettier and the fact + # that folks have become entrenched in their ways, we decided to provide a + # small amount of configurability. + # + # Note that we're keeping this in a global-ish hash instead of just + # overriding methods on classes so that other plugins can reference this if + # necessary. For example, the RBS plugin references the quote style. + OPTIONS = { quote: "\"", trailing_comma: false } + COMMENT_PRIORITY = 1 HEREDOC_PRIORITY = 2 @@ -14,13 +26,20 @@ class Formatter < PrettierPrint attr_reader :quote, :trailing_comma alias trailing_comma? trailing_comma - def initialize(source, ...) - super(...) + def initialize( + source, + *args, + quote: OPTIONS[:quote], + trailing_comma: OPTIONS[:trailing_comma] + ) + super(*args) @source = source @stack = [] - @quote = "\"" - @trailing_comma = false + + # Memoizing these values per formatter to make access faster. + @quote = quote + @trailing_comma = trailing_comma end def self.format(source, node) diff --git a/lib/syntax_tree/formatter/single_quotes.rb b/lib/syntax_tree/formatter/single_quotes.rb deleted file mode 100644 index 4d1f41b3..00000000 --- a/lib/syntax_tree/formatter/single_quotes.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - class Formatter - # This module overrides the quote method on the formatter to use single - # quotes for everything instead of double quotes. - module SingleQuotes - def quote - "'" - end - end - end -end diff --git a/lib/syntax_tree/formatter/trailing_comma.rb b/lib/syntax_tree/formatter/trailing_comma.rb deleted file mode 100644 index 63fe2e9a..00000000 --- a/lib/syntax_tree/formatter/trailing_comma.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - class Formatter - # This module overrides the trailing_comma? method on the formatter to - # return true. - module TrailingComma - def trailing_comma? - true - end - end - end -end diff --git a/lib/syntax_tree/language_server.rb b/lib/syntax_tree/language_server.rb index 1e305cca..3853ee18 100644 --- a/lib/syntax_tree/language_server.rb +++ b/lib/syntax_tree/language_server.rb @@ -70,13 +70,11 @@ def run id:, params: { textDocument: { uri: } } } - output = [] - PP.pp(SyntaxTree.parse(store[uri]), output) - write(id: id, result: output.join) + write(id: id, result: PP.pp(SyntaxTree.parse(store[uri]), +"")) in method: %r{\$/.+} # ignored else - raise "Unhandled: #{request}" + raise ArgumentError, "Unhandled: #{request}" end end end @@ -109,10 +107,6 @@ def format(source) } end - def log(message) - write(method: "window/logMessage", params: { type: 4, message: message }) - end - def inlay_hints(source) inlay_hints = InlayHints.find(SyntaxTree.parse(source)) serialize = ->(position, text) { { position: position, text: text } } diff --git a/lib/syntax_tree/language_server/inlay_hints.rb b/lib/syntax_tree/language_server/inlay_hints.rb index 69fc5ce4..089355a7 100644 --- a/lib/syntax_tree/language_server/inlay_hints.rb +++ b/lib/syntax_tree/language_server/inlay_hints.rb @@ -38,6 +38,7 @@ def visit(node) # def visit_assign(node) parentheses(node.location) if stack[-2].is_a?(Params) + super end # Adds parentheses around binary expressions to make it clear which @@ -57,6 +58,8 @@ def visit_binary(node) parentheses(node.location) else end + + super end # Adds parentheses around ternary operators contained within certain @@ -70,9 +73,13 @@ def visit_binary(node) # a ? b : ā‚c ? d : eā‚Ž # def visit_if_op(node) - if stack[-2] in Assign | Binary | IfOp | OpAssign + case stack[-2] + in Assign | Binary | IfOp | OpAssign parentheses(node.location) + else end + + super end # Adds the implicitly rescued StandardError into a bare rescue clause. For @@ -92,6 +99,8 @@ def visit_rescue(node) if node.exception.nil? after[node.location.start_char + "rescue".length] << " StandardError" end + + super end # Adds parentheses around unary statements using the - operator that are @@ -107,6 +116,8 @@ def visit_unary(node) if stack[-2].is_a?(Binary) && (node.operator == "-") parentheses(node.location) end + + super end def self.find(program) diff --git a/lib/syntax_tree/node.rb b/lib/syntax_tree/node.rb index 6c2617cc..85956c8c 100644 --- a/lib/syntax_tree/node.rb +++ b/lib/syntax_tree/node.rb @@ -1123,38 +1123,20 @@ def deconstruct_keys(_keys) end def format(q) - parts = [*requireds] - parts << RestFormatter.new(rest) if rest - parts += posts - - if constant - q.group do - q.format(constant) - q.text("[") - q.indent do - q.breakable("") - q.seplist(parts) { |part| q.format(part) } - end - q.breakable("") - q.text("]") - end - - return - end - - parent = q.parent - if parts.length == 1 || PATTERNS.include?(parent.class) + q.group do + q.format(constant) if constant q.text("[") q.indent do q.breakable("") + + parts = [*requireds] + parts << RestFormatter.new(rest) if rest + parts += posts + q.seplist(parts) { |part| q.format(part) } end q.breakable("") q.text("]") - elsif parts.empty? - q.text("[]") - else - q.group { q.seplist(parts) { |part| q.format(part) } } end end end @@ -2147,11 +2129,13 @@ def format(q) # # break # - in [Paren[ - contents: { - body: [ArrayLiteral[contents: { parts: [_, _, *] }] => array] - } - ]] + in [ + Paren[ + contents: { + body: [ArrayLiteral[contents: { parts: [_, _, *] }] => array] + } + ] + ] # Here we have a single argument that is a set of parentheses wrapping # an array literal that has at least 2 elements. We're going to print # the contents of the array directly. This would be like if we had: @@ -3879,9 +3863,9 @@ module Quotes # whichever quote the user chose. (If they chose single quotes, then double # quoting would activate the escape sequence, and if they chose double # quotes, then single quotes would deactivate it.) - def self.locked?(node) + def self.locked?(node, quote) node.parts.any? do |part| - !part.is_a?(TStringContent) || part.value.match?(/\\|#[@${]/) + !part.is_a?(TStringContent) || part.value.match?(/\\|#[@${]|#{quote}/) end end @@ -3996,12 +3980,12 @@ def quotes(q) if matched [quote, matching] - elsif Quotes.locked?(self) + elsif Quotes.locked?(self, q.quote) ["#{":" unless hash_key}'", "'"] else ["#{":" unless hash_key}#{q.quote}", q.quote] end - elsif Quotes.locked?(self) + elsif Quotes.locked?(self, q.quote) if quote.start_with?(":") [hash_key ? quote[1..] : quote, quote[1..]] else @@ -5490,12 +5474,14 @@ def format_flat(q) q.format(predicate) q.text(" ?") - q.breakable - q.format(truthy) - q.text(" :") + q.indent do + q.breakable + q.format(truthy) + q.text(" :") - q.breakable - q.format(falsy) + q.breakable + q.format(falsy) + end end end @@ -8429,7 +8415,7 @@ def format(q) end opening_quote, closing_quote = - if !Quotes.locked?(self) + if !Quotes.locked?(self, q.quote) [q.quote, q.quote] elsif quote.start_with?("%") [quote, Quotes.matching(quote[/%[qQ]?(.)/, 1])] diff --git a/lib/syntax_tree/parser.rb b/lib/syntax_tree/parser.rb index 6bff0838..fdffbeb9 100644 --- a/lib/syntax_tree/parser.rb +++ b/lib/syntax_tree/parser.rb @@ -548,13 +548,6 @@ def on_aryptn(constant, requireds, rest, posts) parts[0].location.to(parts[-1].location) end - # If there's the optional then keyword, then we'll delete that and use it - # as the end bounds of the location. - if (token = find_token(Kw, "then", consume: false)) - tokens.delete(token) - location = location.to(token.location) - end - # If there is a plain *, then we're going to fix up the location of it # here because it currently doesn't have anything to use for its precise # location. If we hit a comma, then we've gone too far. @@ -1698,12 +1691,6 @@ def on_hshptn(constant, keywords, keyword_rest) end end - # Delete the optional then keyword - if (token = find_token(Kw, "then", consume: false)) - parts << token - tokens.delete(token) - end - HshPtn.new( constant: constant, keywords: keywords || [], @@ -3013,6 +3000,11 @@ def on_stmts_new # (StringEmbExpr | StringDVar | TStringContent) part # ) -> StringContent def on_string_add(string, part) + # Due to some eccentricities in how ripper works, you need this here in + # case you have a syntax error with an embedded expression that doesn't + # finish, as in: "#{" + return string if part.is_a?(String) + location = string.parts.any? ? string.location.to(part.location) : part.location diff --git a/lib/syntax_tree/plugin/single_quotes.rb b/lib/syntax_tree/plugin/single_quotes.rb index d8034084..c6e829e0 100644 --- a/lib/syntax_tree/plugin/single_quotes.rb +++ b/lib/syntax_tree/plugin/single_quotes.rb @@ -1,4 +1,3 @@ # frozen_string_literal: true -require "syntax_tree/formatter/single_quotes" -SyntaxTree::Formatter.prepend(SyntaxTree::Formatter::SingleQuotes) +SyntaxTree::Formatter::OPTIONS[:quote] = "'" diff --git a/lib/syntax_tree/plugin/trailing_comma.rb b/lib/syntax_tree/plugin/trailing_comma.rb index eaa8cb6a..878703c3 100644 --- a/lib/syntax_tree/plugin/trailing_comma.rb +++ b/lib/syntax_tree/plugin/trailing_comma.rb @@ -1,4 +1,3 @@ # frozen_string_literal: true -require "syntax_tree/formatter/trailing_comma" -SyntaxTree::Formatter.prepend(SyntaxTree::Formatter::TrailingComma) +SyntaxTree::Formatter::OPTIONS[:trailing_comma] = true diff --git a/lib/syntax_tree/version.rb b/lib/syntax_tree/version.rb index 851d9565..7754cf7a 100644 --- a/lib/syntax_tree/version.rb +++ b/lib/syntax_tree/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module SyntaxTree - VERSION = "2.7.0" + VERSION = "2.7.1" end diff --git a/test/cli_test.rb b/test/cli_test.rb index ade1485c..7f2bcd26 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -142,11 +142,41 @@ def test_generic_error end end + def test_plugins + Dir.mktmpdir do |directory| + Dir.mkdir(File.join(directory, "syntax_tree")) + $:.unshift(directory) + + File.write( + File.join(directory, "syntax_tree", "plugin.rb"), + "puts 'Hello, world!'" + ) + result = run_cli("format", "--plugins=plugin") + + assert_equal("Hello, world!\ntest\n", result.stdio) + end + end + + def test_language_server + prev_stdin = $stdin + prev_stdout = $stdout + + request = { method: "shutdown" }.merge(jsonrpc: "2.0").to_json + $stdin = + StringIO.new("Content-Length: #{request.bytesize}\r\n\r\n#{request}") + $stdout = StringIO.new + + assert_equal(0, SyntaxTree::CLI.run(["lsp"])) + ensure + $stdin = prev_stdin + $stdout = prev_stdout + end + private Result = Struct.new(:status, :stdio, :stderr, keyword_init: true) - def run_cli(command, file: nil) + def run_cli(command, *args, file: nil) if file.nil? file = Tempfile.new(%w[test- .rb]) file.puts("test") @@ -156,7 +186,7 @@ def run_cli(command, file: nil) status = nil stdio, stderr = - capture_io { status = SyntaxTree::CLI.run([command, file.path]) } + capture_io { status = SyntaxTree::CLI.run([command, *args, file.path]) } Result.new(status: status, stdio: stdio, stderr: stderr) ensure diff --git a/test/fixtures/aryptn.rb b/test/fixtures/aryptn.rb index c5562305..64d5d9d0 100644 --- a/test/fixtures/aryptn.rb +++ b/test/fixtures/aryptn.rb @@ -4,53 +4,110 @@ end % case foo +in [] then +end +- +case foo +in [] +end +% +case foo +in * then +end +- +case foo +in [*] +end +% +case foo in _, _ end +- +case foo +in [_, _] +end % case foo in bar, baz end +- +case foo +in [bar, baz] +end % case foo in [bar] end % case foo -in [bar, baz] +in [bar] +in [baz] end -- +% case foo -in bar, baz +in [bar, baz] end % case foo in bar, *baz end +- +case foo +in [bar, *baz] +end % case foo in *bar, baz end +- +case foo +in [*bar, baz] +end % case foo in bar, *, baz end +- +case foo +in [bar, *, baz] +end % case foo in *, bar, baz end +- +case foo +in [*, bar, baz] +end % case foo in Constant[bar] end % case foo +in Constant(bar) +end +- +case foo +in Constant[bar] +end +% +case foo in Constant[bar, baz] end % case foo in bar, [baz, _] => qux end +- +case foo +in [bar, [baz, _] => qux] +end % case foo in bar, baz if bar == baz end +- +case foo +in [bar, baz] if bar == baz +end diff --git a/test/fixtures/call.rb b/test/fixtures/call.rb index f3333276..c41ee4ac 100644 --- a/test/fixtures/call.rb +++ b/test/fixtures/call.rb @@ -1,6 +1,8 @@ % foo.bar % +foo.bar(baz) +% foo.() % foo::() @@ -21,3 +23,40 @@ .barrrrrrrrrrrrrrrrrrr {} .bazzzzzzzzzzzzzzzzzzzzzzzzzz .quxxxxxxxxx +% +foo. # comment + bar +% +foo + .bar + .baz # comment + .qux + .quux +% +foo + .bar + .baz. + # comment + qux + .quux +% +{ a: 1, b: 2 }.fooooooooooooooooo.barrrrrrrrrrrrrrrrrrr.bazzzzzzzzzzzz.quxxxxxxxxxxxx +- +{ a: 1, b: 2 }.fooooooooooooooooo + .barrrrrrrrrrrrrrrrrrr + .bazzzzzzzzzzzz + .quxxxxxxxxxxxx +% +fooooooooooooooooo.barrrrrrrrrrrrrrrrrrr.bazzzzzzzzzzzz.quxxxxxxxxxxxx.each { block } +- +fooooooooooooooooo.barrrrrrrrrrrrrrrrrrr.bazzzzzzzzzzzz.quxxxxxxxxxxxx.each do + block +end +% +foo.bar.baz.each do + block1 + block2 +end +% +a b do +end.c d diff --git a/test/fixtures/command_call.rb b/test/fixtures/command_call.rb index fb0d084a..4a0f60f0 100644 --- a/test/fixtures/command_call.rb +++ b/test/fixtures/command_call.rb @@ -32,3 +32,5 @@ foo. # comment bar baz +% +foo.bar baz ? qux : qaz diff --git a/test/fixtures/do_block.rb b/test/fixtures/do_block.rb index 016f27b2..8ea4f75f 100644 --- a/test/fixtures/do_block.rb +++ b/test/fixtures/do_block.rb @@ -14,3 +14,15 @@ foo :bar do baz end +% +sig do + override.params(contacts: Contact::ActiveRecord_Relation).returns( + Customer::ActiveRecord_Relation + ) +end +- +sig do + override + .params(contacts: Contact::ActiveRecord_Relation) + .returns(Customer::ActiveRecord_Relation) +end diff --git a/test/fixtures/hshptn.rb b/test/fixtures/hshptn.rb index 7a35b4d0..505336b8 100644 --- a/test/fixtures/hshptn.rb +++ b/test/fixtures/hshptn.rb @@ -64,6 +64,14 @@ end % case foo +in {} then +end +- +case foo +in {} +end +% +case foo in **nil end % @@ -71,3 +79,8 @@ in bar, { baz:, **nil } in qux: end +- +case foo +in [bar, { baz:, **nil }] +in qux: +end diff --git a/test/fixtures/if.rb b/test/fixtures/if.rb index 9045e5bf..e5e88103 100644 --- a/test/fixtures/if.rb +++ b/test/fixtures/if.rb @@ -49,3 +49,13 @@ end - not(a) ? b : c +% +(if foo then bar else baz end) +- +( + if foo + bar + else + baz + end +) diff --git a/test/fixtures/ifop.rb b/test/fixtures/ifop.rb index 541e667e..e56eb987 100644 --- a/test/fixtures/ifop.rb +++ b/test/fixtures/ifop.rb @@ -10,3 +10,9 @@ end % foo bar ? 1 : 2 +% +foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo ? break : baz +- +foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo ? + break : + baz diff --git a/test/fixtures/in.rb b/test/fixtures/in.rb index 1e1b2282..59102505 100644 --- a/test/fixtures/in.rb +++ b/test/fixtures/in.rb @@ -14,8 +14,10 @@ end - case foo -in fooooooooooooooooooooooooooooooooooooo, - barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr +in [ + fooooooooooooooooooooooooooooooooooooo, + barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr + ] baz end % diff --git a/test/fixtures/string_literal.rb b/test/fixtures/string_literal.rb index ebe56a40..d8ee0cdb 100644 --- a/test/fixtures/string_literal.rb +++ b/test/fixtures/string_literal.rb @@ -41,4 +41,8 @@ % '"foo"' - -"\"foo\"" +'"foo"' +% +"'foo'" +- +"'foo'" diff --git a/test/formatter/single_quotes_test.rb b/test/formatter/single_quotes_test.rb deleted file mode 100644 index ac5103a1..00000000 --- a/test/formatter/single_quotes_test.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -require_relative "../test_helper" -require "syntax_tree/formatter/single_quotes" - -module SyntaxTree - class Formatter - class SingleQuotesTest < Minitest::Test - class TestFormatter < Formatter - prepend Formatter::SingleQuotes - end - - def test_empty_string_literal - assert_format("''\n", "\"\"") - end - - def test_string_literal - assert_format("'string'\n", "\"string\"") - end - - def test_string_literal_with_interpolation - assert_format("\"\#{foo}\"\n") - end - - def test_dyna_symbol - assert_format(":'symbol'\n", ":\"symbol\"") - end - - def test_label - assert_format( - "{ foo => foo, :'bar' => bar }\n", - "{ foo => foo, \"bar\": bar }" - ) - end - - private - - def assert_format(expected, source = expected) - formatter = TestFormatter.new(source, []) - SyntaxTree.parse(source).format(formatter) - - formatter.flush - assert_equal(expected, formatter.output.join) - end - end - end -end diff --git a/test/formatter/trailing_comma_test.rb b/test/formatter/trailing_comma_test.rb deleted file mode 100644 index f6585772..00000000 --- a/test/formatter/trailing_comma_test.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -require_relative "../test_helper" -require "syntax_tree/formatter/trailing_comma" - -module SyntaxTree - class Formatter - class TrailingCommaTest < Minitest::Test - class TestFormatter < Formatter - prepend Formatter::TrailingComma - end - - def test_arg_paren_flat - assert_format("foo(a)\n") - end - - def test_arg_paren_break - assert_format(<<~EXPECTED, <<~SOURCE) - foo( - #{"a" * 80}, - ) - EXPECTED - foo(#{"a" * 80}) - SOURCE - end - - def test_arg_paren_block - assert_format(<<~EXPECTED, <<~SOURCE) - foo( - &#{"a" * 80} - ) - EXPECTED - foo(&#{"a" * 80}) - SOURCE - end - - def test_arg_paren_command - assert_format(<<~EXPECTED, <<~SOURCE) - foo( - bar #{"a" * 80} - ) - EXPECTED - foo(bar #{"a" * 80}) - SOURCE - end - - def test_arg_paren_command_call - assert_format(<<~EXPECTED, <<~SOURCE) - foo( - bar.baz #{"a" * 80} - ) - EXPECTED - foo(bar.baz #{"a" * 80}) - SOURCE - end - - def test_array_literal_flat - assert_format("[a]\n") - end - - def test_array_literal_break - assert_format(<<~EXPECTED, <<~SOURCE) - [ - #{"a" * 80}, - ] - EXPECTED - [#{"a" * 80}] - SOURCE - end - - def test_hash_literal_flat - assert_format("{ a: a }\n") - end - - def test_hash_literal_break - assert_format(<<~EXPECTED, <<~SOURCE) - { - a: - #{"a" * 80}, - } - EXPECTED - { a: #{"a" * 80} } - SOURCE - end - - private - - def assert_format(expected, source = expected) - formatter = TestFormatter.new(source, []) - SyntaxTree.parse(source).format(formatter) - - formatter.flush - assert_equal(expected, formatter.output.join) - end - end - end -end diff --git a/test/language_server/inlay_hints_test.rb b/test/language_server/inlay_hints_test.rb new file mode 100644 index 00000000..f652f6d8 --- /dev/null +++ b/test/language_server/inlay_hints_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require "syntax_tree/language_server" + +module SyntaxTree + class LanguageServer + class InlayHintsTest < Minitest::Test + def test_assignments_in_parameters + hints = find("def foo(a = b = c); end") + + assert_equal(1, hints.before.length) + assert_equal(1, hints.after.length) + end + + def test_operators_in_binaries + hints = find("1 + 2 * 3") + + assert_equal(1, hints.before.length) + assert_equal(1, hints.after.length) + end + + def test_binaries_in_assignments + hints = find("a = 1 + 2") + + assert_equal(1, hints.before.length) + assert_equal(1, hints.after.length) + end + + def test_nested_ternaries + hints = find("a ? b : c ? d : e") + + assert_equal(1, hints.before.length) + assert_equal(1, hints.after.length) + end + + def test_bare_rescue + hints = find("begin; rescue; end") + + assert_equal(1, hints.after.length) + end + + def test_unary_in_binary + hints = find("-a + b") + + assert_equal(1, hints.before.length) + assert_equal(1, hints.after.length) + end + + private + + def find(source) + InlayHints.find(SyntaxTree.parse(source)) + end + end + end +end diff --git a/test/language_server_test.rb b/test/language_server_test.rb new file mode 100644 index 00000000..f8a61003 --- /dev/null +++ b/test/language_server_test.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "syntax_tree/language_server" + +module SyntaxTree + class LanguageServerTest < Minitest::Test + class Initialize < Struct.new(:id) + def to_hash + { method: "initialize", id: id } + end + end + + class Shutdown + def to_hash + { method: "shutdown" } + end + end + + class TextDocumentDidOpen < Struct.new(:uri, :text) + def to_hash + { + method: "textDocument/didOpen", + params: { + textDocument: { + uri: uri, + text: text + } + } + } + end + end + + class TextDocumentDidChange < Struct.new(:uri, :text) + def to_hash + { + method: "textDocument/didChange", + params: { + textDocument: { + uri: uri + }, + contentChanges: [{ text: text }] + } + } + end + end + + class TextDocumentDidClose < Struct.new(:uri) + def to_hash + { + method: "textDocument/didClose", + params: { + textDocument: { + uri: uri + } + } + } + end + end + + class TextDocumentFormatting < Struct.new(:id, :uri) + def to_hash + { + method: "textDocument/formatting", + id: id, + params: { + textDocument: { + uri: uri + } + } + } + end + end + + class TextDocumentInlayHints < Struct.new(:id, :uri) + def to_hash + { + method: "textDocument/inlayHints", + id: id, + params: { + textDocument: { + uri: uri + } + } + } + end + end + + class SyntaxTreeVisualizing < Struct.new(:id, :uri) + def to_hash + { + method: "syntaxTree/visualizing", + id: id, + params: { + textDocument: { + uri: uri + } + } + } + end + end + + def test_formatting + messages = [ + Initialize.new(1), + TextDocumentDidOpen.new("file:///path/to/file.rb", "class Foo; end"), + TextDocumentDidChange.new("file:///path/to/file.rb", "class Bar; end"), + TextDocumentFormatting.new(2, "file:///path/to/file.rb"), + TextDocumentDidClose.new("file:///path/to/file.rb"), + Shutdown.new + ] + + case run_server(messages) + in [ + { id: 1, result: { capabilities: Hash } }, + { id: 2, result: [{ newText: new_text }] } + ] + assert_equal("class Bar\nend\n", new_text) + end + end + + def test_inlay_hints + messages = [ + Initialize.new(1), + TextDocumentDidOpen.new("file:///path/to/file.rb", <<~RUBY), + begin + 1 + 2 * 3 + rescue + end + RUBY + TextDocumentInlayHints.new(2, "file:///path/to/file.rb"), + Shutdown.new + ] + + case run_server(messages) + in [ + { id: 1, result: { capabilities: Hash } }, + { id: 2, result: { before:, after: } } + ] + assert_equal(1, before.length) + assert_equal(2, after.length) + end + end + + def test_visualizing + messages = [ + Initialize.new(1), + TextDocumentDidOpen.new("file:///path/to/file.rb", "1 + 2"), + SyntaxTreeVisualizing.new(2, "file:///path/to/file.rb"), + Shutdown.new + ] + + case run_server(messages) + in [{ id: 1, result: { capabilities: Hash } }, { id: 2, result: }] + assert_equal( + "(program (statements ((binary (int \"1\") + (int \"2\")))))\n", + result + ) + end + end + + def test_reading_file + Tempfile.open(%w[test- .rb]) do |file| + file.write("class Foo; end") + file.rewind + + messages = [ + Initialize.new(1), + TextDocumentFormatting.new(2, "file://#{file.path}"), + Shutdown.new + ] + + case run_server(messages) + in [ + { id: 1, result: { capabilities: Hash } }, + { id: 2, result: [{ newText: new_text }] } + ] + assert_equal("class Foo\nend\n", new_text) + end + end + end + + def test_bogus_request + assert_raises(ArgumentError) do + run_server([{ method: "textDocument/bogus" }]) + end + end + + private + + def write(content) + request = content.to_hash.merge(jsonrpc: "2.0").to_json + "Content-Length: #{request.bytesize}\r\n\r\n#{request}" + end + + def read(content) + [].tap do |messages| + while (headers = content.gets("\r\n\r\n")) + source = content.read(headers[/Content-Length: (\d+)/i, 1].to_i) + messages << JSON.parse(source, symbolize_names: true) + end + end + end + + def run_server(messages) + input = StringIO.new(messages.map { |message| write(message) }.join) + output = StringIO.new + + LanguageServer.new(input: input, output: output).run + read(output.tap(&:rewind)) + end + end +end diff --git a/test/location_test.rb b/test/location_test.rb new file mode 100644 index 00000000..2a697281 --- /dev/null +++ b/test/location_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +module SyntaxTree + class LocationTest < Minitest::Test + def test_lines + location = Location.fixed(line: 1, char: 0, column: 0) + location = location.to(Location.fixed(line: 3, char: 3, column: 3)) + + assert_equal(1..3, location.lines) + end + + def test_deconstruct + location = Location.fixed(line: 1, char: 0, column: 0) + + case location + in [start_line, 0, 0, *] + assert_equal(1, start_line) + end + end + + def test_deconstruct_keys + location = Location.fixed(line: 1, char: 0, column: 0) + + case location + in start_line: + assert_equal(1, start_line) + end + end + end +end diff --git a/test/node_test.rb b/test/node_test.rb index 6bde39bc..ffd00fa5 100644 --- a/test/node_test.rb +++ b/test/node_test.rb @@ -1032,6 +1032,20 @@ def test_multibyte_column_positions assert_node(Command, source, at: at) end + def test_root_class_raises_not_implemented_errors + { + accept: [nil], + child_nodes: [], + deconstruct: [], + deconstruct_keys: [[]], + format: [nil] + }.each do |method, arguments| + assert_raises(NotImplementedError) do + Node.new.public_send(method, *arguments) + end + end + end + private def location(lines: 1..1, chars: 0..0, columns: 0..0) diff --git a/test/parser_test.rb b/test/parser_test.rb index 8aadbfc2..b36c1a5f 100644 --- a/test/parser_test.rb +++ b/test/parser_test.rb @@ -30,5 +30,17 @@ def test_parses_ripper_methods # Finally, assert that we have no remaining events. assert_empty(events) end + + def test_errors_on_missing_token_with_location + assert_raises(Parser::ParseError) { SyntaxTree.parse("\"foo") } + end + + def test_errors_on_missing_token_without_location + assert_raises(Parser::ParseError) { SyntaxTree.parse(":\"foo") } + end + + def test_handles_strings_with_non_terminated_embedded_expressions + assert_raises(Parser::ParseError) { SyntaxTree.parse('"#{"') } + end end end diff --git a/test/plugin/single_quotes_test.rb b/test/plugin/single_quotes_test.rb new file mode 100644 index 00000000..719f33c1 --- /dev/null +++ b/test/plugin/single_quotes_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +module SyntaxTree + class SingleQuotesTest < Minitest::Test + OPTIONS = Plugin.options("syntax_tree/plugin/single_quotes") + + def test_empty_string_literal + assert_format("''\n", "\"\"") + end + + def test_string_literal + assert_format("'string'\n", "\"string\"") + end + + def test_string_literal_with_interpolation + assert_format("\"\#{foo}\"\n") + end + + def test_dyna_symbol + assert_format(":'symbol'\n", ":\"symbol\"") + end + + def test_single_quote_in_string + assert_format("\"str'ing\"\n") + end + + def test_label + assert_format( + "{ foo => foo, :'bar' => bar }\n", + "{ foo => foo, \"bar\": bar }" + ) + end + + private + + def assert_format(expected, source = expected) + formatter = Formatter.new(source, [], **OPTIONS) + SyntaxTree.parse(source).format(formatter) + + formatter.flush + assert_equal(expected, formatter.output.join) + end + end +end diff --git a/test/plugin/trailing_comma_test.rb b/test/plugin/trailing_comma_test.rb new file mode 100644 index 00000000..ba9ad846 --- /dev/null +++ b/test/plugin/trailing_comma_test.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +module SyntaxTree + class TrailingCommaTest < Minitest::Test + OPTIONS = Plugin.options("syntax_tree/plugin/trailing_comma") + + def test_arg_paren_flat + assert_format("foo(a)\n") + end + + def test_arg_paren_break + assert_format(<<~EXPECTED, <<~SOURCE) + foo( + #{"a" * 80}, + ) + EXPECTED + foo(#{"a" * 80}) + SOURCE + end + + def test_arg_paren_block + assert_format(<<~EXPECTED, <<~SOURCE) + foo( + &#{"a" * 80} + ) + EXPECTED + foo(&#{"a" * 80}) + SOURCE + end + + def test_arg_paren_command + assert_format(<<~EXPECTED, <<~SOURCE) + foo( + bar #{"a" * 80} + ) + EXPECTED + foo(bar #{"a" * 80}) + SOURCE + end + + def test_arg_paren_command_call + assert_format(<<~EXPECTED, <<~SOURCE) + foo( + bar.baz #{"a" * 80} + ) + EXPECTED + foo(bar.baz #{"a" * 80}) + SOURCE + end + + def test_array_literal_flat + assert_format("[a]\n") + end + + def test_array_literal_break + assert_format(<<~EXPECTED, <<~SOURCE) + [ + #{"a" * 80}, + ] + EXPECTED + [#{"a" * 80}] + SOURCE + end + + def test_hash_literal_flat + assert_format("{ a: a }\n") + end + + def test_hash_literal_break + assert_format(<<~EXPECTED, <<~SOURCE) + { + a: + #{"a" * 80}, + } + EXPECTED + { a: #{"a" * 80} } + SOURCE + end + + private + + def assert_format(expected, source = expected) + formatter = Formatter.new(source, [], **OPTIONS) + SyntaxTree.parse(source).format(formatter) + + formatter.flush + assert_equal(expected, formatter.output.join) + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index bb3ea67f..895fbc82 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -77,10 +77,30 @@ def assert_syntax_tree(node) end RUBY end + + Minitest::Test.include(self) end end -Minitest::Test.include(SyntaxTree::Assertions) +module SyntaxTree + module Plugin + # A couple of plugins modify the options hash on the formatter. They're + # modeled as files that should be required so that it's simple for the CLI + # and the library to use the same code path. In this case we're going to + # require the file for the plugin but ensure it doesn't make any lasting + # changes. + def self.options(path) + previous_options = SyntaxTree::Formatter::OPTIONS.dup + + begin + require path + SyntaxTree::Formatter::OPTIONS.dup + ensure + SyntaxTree::Formatter::OPTIONS.merge!(previous_options) + end + end + end +end # There are a bunch of fixtures defined in test/fixtures. They exercise every # possible combination of syntax that leads to variations in the types of nodes.