From a0e22b1a7a06481a047bbe54783cc4f960796b13 Mon Sep 17 00:00:00 2001 From: Kevin Deisz Date: Tue, 5 Jan 2021 12:53:32 -0500 Subject: [PATCH] Incorporate HAML plugin --- .gitignore | 1 + .npmignore | 1 + .prettierignore | 2 + CHANGELOG.md | 1 + Gemfile | 2 + README.md | 12 +- bin/port | 14 +++ bin/print | 23 ++-- package.json | 2 +- src/haml/embed.js | 87 ++++++++++++++ src/haml/nodes/comment.js | 27 +++++ src/haml/nodes/doctype.js | 34 ++++++ src/haml/nodes/filter.js | 16 +++ src/haml/nodes/hamlComment.js | 21 ++++ src/haml/nodes/plain.js | 6 + src/haml/nodes/root.js | 8 ++ src/haml/nodes/script.js | 33 +++++ src/haml/nodes/silentScript.js | 59 +++++++++ src/haml/nodes/tag.js | 193 ++++++++++++++++++++++++++++++ src/haml/parser.js | 33 +++++ src/haml/parser.rb | 141 ++++++++++++++++++++++ src/haml/printer.js | 28 +++++ src/plugin.js | 15 ++- src/rbs/parser.rb | 19 +-- src/ruby/parser.rb | 14 ++- test/js/globalSetup.js | 5 +- test/js/haml/comment.test.js | 33 +++++ test/js/haml/doctype.test.js | 23 ++++ test/js/haml/filter.test.js | 103 ++++++++++++++++ test/js/haml/hamlComment.test.js | 21 ++++ test/js/haml/parser.test.js | 32 +++++ test/js/haml/plain.test.js | 12 ++ test/js/haml/script.test.js | 33 +++++ test/js/haml/silentScript.test.js | 70 +++++++++++ test/js/haml/tag.test.js | 109 +++++++++++++++++ test/js/parser.rb | 26 ++-- test/js/rbs/parser.test.js | 13 +- test/js/rbs/rbs.test.js | 147 ++++++++++------------- test/js/setupTests.js | 20 ++-- test/js/utils.js | 22 +++- 40 files changed, 1312 insertions(+), 149 deletions(-) create mode 100755 bin/port create mode 100644 src/haml/embed.js create mode 100644 src/haml/nodes/comment.js create mode 100644 src/haml/nodes/doctype.js create mode 100644 src/haml/nodes/filter.js create mode 100644 src/haml/nodes/hamlComment.js create mode 100644 src/haml/nodes/plain.js create mode 100644 src/haml/nodes/root.js create mode 100644 src/haml/nodes/script.js create mode 100644 src/haml/nodes/silentScript.js create mode 100644 src/haml/nodes/tag.js create mode 100644 src/haml/parser.js create mode 100644 src/haml/parser.rb create mode 100644 src/haml/printer.js create mode 100644 test/js/haml/comment.test.js create mode 100644 test/js/haml/doctype.test.js create mode 100644 test/js/haml/filter.test.js create mode 100644 test/js/haml/hamlComment.test.js create mode 100644 test/js/haml/parser.test.js create mode 100644 test/js/haml/plain.test.js create mode 100644 test/js/haml/script.test.js create mode 100644 test/js/haml/silentScript.test.js create mode 100644 test/js/haml/tag.test.js diff --git a/.gitignore b/.gitignore index 47fd297d..1a035cd8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /pkg/ /test.rb /test.rbs +/test.haml *.gem # This is to better support the GitHub actions checking - since bundler changes diff --git a/.npmignore b/.npmignore index ded90fa6..61ba492d 100644 --- a/.npmignore +++ b/.npmignore @@ -15,3 +15,4 @@ /yarn-error.log /test.rb /test.rbs +/test.haml diff --git a/.prettierignore b/.prettierignore index aa42508a..951790df 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,10 +7,12 @@ /.eslintcache /.*ignore +/bin/port /docs/logo.png /*.lock /LICENSE /test.rb /test.rbs +/test.haml *.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bfb41da..7e493c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Added - [@kddeisz] - Handling of the RBS language. +- [@kddeisz] - Incorporate the HAML plugin. ## [1.2.5] - 2021-01-04 diff --git a/Gemfile b/Gemfile index f365da50..c2d85b51 100644 --- a/Gemfile +++ b/Gemfile @@ -5,5 +5,7 @@ source 'https://rubygems.org' gemspec gem 'bundler', '~> 2.1' +gem 'haml', '~> 5.2' gem 'minitest', '~> 5.14' gem 'rake', '~> 13.0' +gem 'rbs', '~> 1.0' diff --git a/README.md b/README.md index ba9d4daf..1b281c58 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,12 @@ To run `prettier` with the Ruby plugin, you're going to need [`ruby`](https://ww Note that currently the editor integrations work best with the `npm` package, as most of the major editor plugins expect a `node_modules` directory. You can get them to work with the Ruby gem, but it requires manually configuring the paths. +This plugin currently supports formatting the following kinds of files: + +- All varieties of Ruby source files (e.g., `*.rb`, `*.gemspec`, `Gemfile`, etc.) +- [RBS type language](https://github.com/ruby/rbs) files - requires having the `rbs` gem in your gem path +- [HAML template language](https://haml.info/) files - requires having the `haml` gem in your gem path + ### Ruby gem Add this line to your application's Gemfile: @@ -95,7 +101,7 @@ gem install prettier The `rbprettier` executable is now installed and ready for use: ```bash -bundle exec rbprettier --write '**/*.{rb,rbs}' +bundle exec rbprettier --write '**/*' ``` ### `npm` package @@ -115,7 +121,7 @@ yarn add --dev prettier @prettier/plugin-ruby The `prettier` executable is now installed and ready for use: ```bash -./node_modules/.bin/prettier --write '**/*.{rb,rbs}' +./node_modules/.bin/prettier --write '**/*' ``` ## Configuration @@ -146,7 +152,7 @@ file](https://prettier.io/docs/en/configuration.html). For example: Or, they can be passed to `prettier` as arguments: ```bash -prettier --ruby-single-quote false --write '**/*.{rb,rbs}' +prettier --ruby-single-quote false --write '**/*' ``` ### Usage with RuboCop diff --git a/bin/port b/bin/port new file mode 100755 index 00000000..0e55c69f --- /dev/null +++ b/bin/port @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +LOW_BOUND=49152 +RANGE=16384 + +while true; do + CANDIDATE=$[$LOW_BOUND + ($RANDOM % $RANGE)] + (echo "" >/dev/tcp/127.0.0.1/${CANDIDATE}) >/dev/null 2>&1 + + if [ $? -ne 0 ]; then + echo $CANDIDATE + break + fi +done diff --git a/bin/print b/bin/print index 982e611d..4e38bc57 100755 --- a/bin/print +++ b/bin/print @@ -4,15 +4,22 @@ const fs = require("fs"); const prettier = require("prettier"); let parser = "ruby"; -let content = 2; +let contentIdx = 2; -if (process.argv[content] === "rbs") { - parser = "rbs"; - content = 3; +if (["rbs", "haml"].includes(process.argv[contentIdx])) { + parser = process.argv[contentIdx]; + contentIdx += 1; } -const code = fs.existsSync(process.argv[content]) - ? fs.readFileSync(process.argv[content], "utf-8") - : process.argv.slice(content).join(" ").replace(/\\n/g, "\n"); +let content; -console.log(prettier.format(code, { parser, plugins: ["."] })); +if (fs.existsSync(process.argv[contentIdx])) { + content = fs.readFileSync(process.argv[contentIdx], "utf-8"); +} else if (process.argv.length === contentIdx) { + const extension = parser === "ruby" ? "rb" : parser; + content = fs.readFileSync(`test.${extension}`, "utf-8"); +} else { + content = process.argv.slice(contentIdx).join(" ").replace(/\\n/g, "\n"); +} + +console.log(prettier.format(content, { parser, plugins: ["."] })); diff --git a/package.json b/package.json index 27af989f..55ff9f11 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "check-format": "prettier --check '**/*'", "lint": "eslint --cache .", - "test": "jest" + "test": "PORT=$(bin/port) jest" }, "repository": { "type": "git", diff --git a/src/haml/embed.js b/src/haml/embed.js new file mode 100644 index 00000000..249d95a0 --- /dev/null +++ b/src/haml/embed.js @@ -0,0 +1,87 @@ +const { + concat, + hardline, + indent, + literalline, + markAsRoot, + mapDoc, + stripTrailingHardline +} = require("../prettier"); + +// Get the name of the parser that is represented by the given element node, +// return null if a matching parser cannot be found +function getParser(name, opts) { + let parser = name; + + // We don't want to deal with some weird recursive parser situation, so we + // need to explicitly call out the HAML parser here and just return null + if (parser === "haml") { + return null; + } + + // In HAML the name of the JS filter is :javascript, whereas in prettier the + // name of the JS parser is babel. Here we explicitly handle that conversion. + if (parser === "javascript") { + parser = "babel"; + } + + // If there is a plugin that has a parser that matches the name of this + // element, then we're going to assume that's correct for embedding and go + // ahead and switch to that parser + if ( + opts.plugins.some( + (plugin) => + plugin.parsers && + Object.prototype.hasOwnProperty.call(plugin.parsers, parser) + ) + ) { + return parser; + } + + return null; +} + +// This function is in here because it handles embedded parser values. I don't +// have a test that exercises it because I'm not sure for which parser it is +// necessary, but since it's in prettier core I'm keeping it here. +/* istanbul ignore next */ +function replaceNewlines(doc) { + return mapDoc(doc, (currentDoc) => + typeof currentDoc === "string" && currentDoc.includes("\n") + ? concat( + currentDoc + .split(/(\n)/g) + .map((v, i) => (i % 2 === 0 ? v : literalline)) + ) + : currentDoc + ); +} + +function embed(path, _print, textToDoc, opts) { + const node = path.getValue(); + if (node.type !== "filter") { + return null; + } + + const parser = getParser(node.value.name, opts); + if (!parser) { + return null; + } + + return markAsRoot( + concat([ + ":", + node.value.name, + indent( + concat([ + hardline, + replaceNewlines( + stripTrailingHardline(textToDoc(node.value.text, { parser })) + ) + ]) + ) + ]) + ); +} + +module.exports = embed; diff --git a/src/haml/nodes/comment.js b/src/haml/nodes/comment.js new file mode 100644 index 00000000..78ec3d8f --- /dev/null +++ b/src/haml/nodes/comment.js @@ -0,0 +1,27 @@ +const { concat, group, hardline, indent, join } = require("../../prettier"); + +// https://haml.info/docs/yardoc/file.REFERENCE.html#html-comments- +function comment(path, _opts, print) { + const { children, value } = path.getValue(); + const parts = ["/"]; + + if (value.revealed) { + parts.push("!"); + } + + if (value.conditional) { + parts.push(value.conditional); + } else if (value.text) { + parts.push(" ", value.text); + } + + if (children.length > 0) { + parts.push( + indent(concat([hardline, join(hardline, path.map(print, "children"))])) + ); + } + + return group(concat(parts)); +} + +module.exports = comment; diff --git a/src/haml/nodes/doctype.js b/src/haml/nodes/doctype.js new file mode 100644 index 00000000..9f699dee --- /dev/null +++ b/src/haml/nodes/doctype.js @@ -0,0 +1,34 @@ +const { join } = require("../../prettier"); + +const types = { + basic: "Basic", + frameset: "Frameset", + mobile: "Mobile", + rdfa: "RDFa", + strict: "Strict", + xml: "XML" +}; + +const versions = ["1.1", "5"]; + +// https://haml.info/docs/yardoc/file.REFERENCE.html#doctype- +function doctype(path, _opts, _print) { + const { value } = path.getValue(); + const parts = ["!!!"]; + + if (value.type in types) { + parts.push(types[value.type]); + } else if (versions.includes(value.version)) { + parts.push(value.version); + } else { + parts.push(value.type); + } + + if (value.encoding) { + parts.push(value.encoding); + } + + return join(" ", parts); +} + +module.exports = doctype; diff --git a/src/haml/nodes/filter.js b/src/haml/nodes/filter.js new file mode 100644 index 00000000..aa126c90 --- /dev/null +++ b/src/haml/nodes/filter.js @@ -0,0 +1,16 @@ +const { concat, group, hardline, indent, join } = require("../../prettier"); + +// https://haml.info/docs/yardoc/file.REFERENCE.html#filters +function filter(path, _opts, _print) { + const { value } = path.getValue(); + + return group( + concat([ + ":", + value.name, + indent(concat([hardline, join(hardline, value.text.trim().split("\n"))])) + ]) + ); +} + +module.exports = filter; diff --git a/src/haml/nodes/hamlComment.js b/src/haml/nodes/hamlComment.js new file mode 100644 index 00000000..a30324e2 --- /dev/null +++ b/src/haml/nodes/hamlComment.js @@ -0,0 +1,21 @@ +const { concat, hardline, indent, join } = require("../../prettier"); + +// https://haml.info/docs/yardoc/file.REFERENCE.html#haml-comments-- +function hamlComment(path, opts, _print) { + const node = path.getValue(); + const parts = ["-#"]; + + if (node.value.text) { + if (opts.originalText.split("\n")[node.line - 1].trim() === "-#") { + const lines = node.value.text.trim().split("\n"); + + parts.push(indent(concat([hardline, join(hardline, lines)]))); + } else { + parts.push(" ", node.value.text.trim()); + } + } + + return concat(parts); +} + +module.exports = hamlComment; diff --git a/src/haml/nodes/plain.js b/src/haml/nodes/plain.js new file mode 100644 index 00000000..fd1c9301 --- /dev/null +++ b/src/haml/nodes/plain.js @@ -0,0 +1,6 @@ +// https://haml.info/docs/yardoc/file.REFERENCE.html#plain-text +function plain(path, _opts, _print) { + return path.getValue().value.text; +} + +module.exports = plain; diff --git a/src/haml/nodes/root.js b/src/haml/nodes/root.js new file mode 100644 index 00000000..deee0de0 --- /dev/null +++ b/src/haml/nodes/root.js @@ -0,0 +1,8 @@ +const { concat, hardline, join } = require("../../prettier"); + +// The root node in the AST +function root(path, _opts, print) { + return concat([join(hardline, path.map(print, "children")), hardline]); +} + +module.exports = root; diff --git a/src/haml/nodes/script.js b/src/haml/nodes/script.js new file mode 100644 index 00000000..dcb5f531 --- /dev/null +++ b/src/haml/nodes/script.js @@ -0,0 +1,33 @@ +const { concat, group, hardline, indent, join } = require("../../prettier"); + +// https://haml.info/docs/yardoc/file.REFERENCE.html#inserting_ruby +function script(path, opts, print) { + const { children, value } = path.getValue(); + const parts = []; + + if (value.escape_html) { + parts.unshift("&"); + } + + if (value.preserve) { + parts.push("~"); + } else if (!value.interpolate) { + parts.push("="); + } + + if (value.escape_html && !value.preserve && value.interpolate) { + parts.push(" ", value.text.trim().slice(1, -1)); + } else { + parts.push(" ", value.text.trim()); + } + + if (children.length > 0) { + parts.push( + indent(concat([hardline, join(hardline, path.map(print, "children"))])) + ); + } + + return group(concat(parts)); +} + +module.exports = script; diff --git a/src/haml/nodes/silentScript.js b/src/haml/nodes/silentScript.js new file mode 100644 index 00000000..7dac177d --- /dev/null +++ b/src/haml/nodes/silentScript.js @@ -0,0 +1,59 @@ +const { concat, group, hardline, indent, join } = require("../../prettier"); + +function findKeywordIndices(children, keywords) { + const indices = []; + + children.forEach((child, index) => { + if (child.type !== "silent_script") { + return; + } + + if (keywords.includes(child.value.keyword)) { + indices.push(index); + } + }); + + return indices; +} + +// https://haml.info/docs/yardoc/file.REFERENCE.html#running-ruby-- +function silentScript(path, _opts, print) { + const { children, value } = path.getValue(); + const parts = [`- ${value.text.trim()}`]; + + if (children.length > 0) { + const scripts = path.map(print, "children"); + + if (value.keyword === "case") { + const keywordIndices = findKeywordIndices(children, ["when", "else"]); + + parts.push( + concat( + scripts.map((script, index) => { + const concated = concat([hardline, script]); + + return keywordIndices.includes(index) ? concated : indent(concated); + }) + ) + ); + } else if (["if", "unless"].includes(value.keyword)) { + const keywordIndices = findKeywordIndices(children, ["elsif", "else"]); + + parts.push( + concat( + scripts.map((script, index) => { + const concated = concat([hardline, script]); + + return keywordIndices.includes(index) ? concated : indent(concated); + }) + ) + ); + } else { + parts.push(indent(concat([hardline, join(hardline, scripts)]))); + } + } + + return group(concat(parts)); +} + +module.exports = silentScript; diff --git a/src/haml/nodes/tag.js b/src/haml/nodes/tag.js new file mode 100644 index 00000000..2723c411 --- /dev/null +++ b/src/haml/nodes/tag.js @@ -0,0 +1,193 @@ +const { + align, + concat, + fill, + group, + hardline, + ifBreak, + indent, + join, + line, + softline +} = require("../../prettier"); + +function getDynamicAttributes(header, attributes) { + const pairs = attributes + .slice(1, -2) + .split(",") + .map((pair) => pair.slice(1).split('" => ')); + + const parts = [concat([pairs[0][0], "=", pairs[0][1]])]; + pairs.slice(1).forEach((pair) => { + parts.push(line, concat([pair[0], "=", pair[1]])); + }); + + return group(concat(["(", align(header + 1, fill(parts)), ")"])); +} + +function getHashValue(value, opts) { + if (typeof value !== "string") { + return value.toString(); + } + + // This is a very special syntax created by the parser to let us know that + // this should be printed literally instead of as a string. + if (value.startsWith("&")) { + return value.slice(1); + } + + const quote = opts.rubySingleQuote ? "'" : '"'; + return `${quote}${value}${quote}`; +} + +function getHashKey(key, opts) { + let quoted = key; + const joiner = opts.rubyHashLabel ? ":" : " =>"; + + if (key.includes(":") || key.includes("-")) { + const quote = opts.rubySingleQuote ? "'" : '"'; + quoted = `${quote}${key}${quote}`; + } + + return `${opts.rubyHashLabel ? "" : ":"}${quoted}${joiner}`; +} + +function getKeyValuePair(key, value, opts) { + return `${getHashKey(key, opts)} ${getHashValue(value, opts)}`; +} + +function getStaticAttributes(header, attributes, opts) { + const keys = Object.keys(attributes).filter( + (name) => !["class", "id"].includes(name) + ); + + const parts = [getKeyValuePair(keys[0], attributes[keys[0]], opts)]; + + keys.slice(1).forEach((key) => { + parts.push(",", line, getKeyValuePair(key, attributes[key], opts)); + }); + + return group(concat(["{", align(header + 1, fill(parts)), "}"])); +} + +function getAttributesObject(object, opts, level = 0) { + if (typeof object !== "object") { + return getHashValue(object, opts); + } + + const boundary = level === 0 ? softline : line; + const parts = Object.keys(object).map((key) => + concat([ + getHashKey(key, opts), + " ", + getAttributesObject(object[key], opts, level + 1) + ]) + ); + + return group( + concat([ + "{", + indent(group(concat([boundary, join(concat([",", line]), parts)]))), + boundary, + "}" + ]) + ); +} + +function getHeader(value, opts) { + const { attributes } = value; + const parts = []; + + if (value.name !== "div") { + parts.push(`%${value.name}`); + } + + if (attributes.class) { + parts.push(`.${attributes.class.replace(/ /g, ".")}`); + } + + if (attributes.id) { + parts.push(`#${attributes.id}`); + } + + if (value.dynamic_attributes.new) { + parts.push( + getDynamicAttributes(parts.join("").length, value.dynamic_attributes.new) + ); + } + + if ( + Object.keys(attributes).some((name) => name !== "class" && name !== "id") + ) { + parts.push(getStaticAttributes(parts.join("").length, attributes, opts)); + } + + if (value.dynamic_attributes.old) { + if (parts.length === 0) { + parts.push("%div"); + } + + if (typeof value.dynamic_attributes.old === "string") { + parts.push(value.dynamic_attributes.old); + } else { + parts.push(getAttributesObject(value.dynamic_attributes.old, opts)); + } + } + + if (value.object_ref) { + if (parts.length === 0) { + parts.push("%div"); + } + parts.push(value.object_ref); + } + + if (value.nuke_outer_whitespace) { + parts.push(">"); + } + + if (value.nuke_inner_whitespace) { + parts.push("<"); + } + + if (value.self_closing) { + parts.push("/"); + } + + if (value.value) { + const prefix = value.parse ? "= " : ifBreak("", " "); + + return group( + concat([ + group(concat(parts)), + indent(concat([softline, prefix, value.value])) + ]) + ); + } + + // In case none of the other if statements have matched and we're printing a + // div, we need to explicitly add it back into the array. + if (parts.length === 0 && value.name === "div") { + parts.push("%div"); + } + + return group(concat(parts)); +} + +// https://haml.info/docs/yardoc/file.REFERENCE.html#element-name- +function tag(path, opts, print) { + const { children, value } = path.getValue(); + const header = getHeader(value, opts); + + if (children.length === 0) { + return header; + } + + return group( + concat([ + header, + indent(concat([hardline, join(hardline, path.map(print, "children"))])) + ]) + ); +} + +module.exports = tag; diff --git a/src/haml/parser.js b/src/haml/parser.js new file mode 100644 index 00000000..fd7f4767 --- /dev/null +++ b/src/haml/parser.js @@ -0,0 +1,33 @@ +const { spawnSync } = require("child_process"); +const path = require("path"); + +const parser = path.join(__dirname, "./parser.rb"); + +const parse = (text, _parsers, _opts) => { + const child = spawnSync("ruby", [parser], { input: text }); + + const error = child.stderr.toString(); + if (error) { + throw new Error(error); + } + + const response = child.stdout.toString(); + return JSON.parse(response); +}; + +const pragmaPattern = /^\s*-#\s*@(prettier|format)/; +const hasPragma = (text) => pragmaPattern.test(text); + +// These functions are just placeholders until we can actually perform this +// properly. The functions are necessary otherwise the format with cursor +// functions break. +const locStart = (_node) => 0; +const locEnd = (_node) => 0; + +module.exports = { + parse, + astFormat: "haml", + hasPragma, + locStart, + locEnd +}; diff --git a/src/haml/parser.rb b/src/haml/parser.rb new file mode 100644 index 00000000..3cb4c3f3 --- /dev/null +++ b/src/haml/parser.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'bundler/setup' if ENV['CI'] +require 'haml' +require 'json' +require 'ripper' + +class Haml::Parser::ParseNode + class DeepAttributeParser + def parse(string) + Haml::AttributeParser.available? ? parse_value(string) : string + end + + private + + def literal(string, level) + level == 0 ? string : "&#{string}" + end + + def parse_value(string, level = 0) + response = Ripper.sexp(string) + return literal(string, level) unless response + + case response[1][0][0] + when :hash + hash = Haml::AttributeParser.parse(string) + + if hash + # Explicitly not using Enumerable#to_h here to support Ruby 2.5 + hash.each_with_object({}) do |(key, value), response| + response[key] = parse_value(value, level + 1) + end + else + literal(string, level) + end + when :string_literal + string[1...-1] + else + literal(string, level) + end + end + end + + ESCAPE = /Haml::Helpers.html_escape\(\((.+)\)\)/.freeze + + # If a node comes in as the plain type but starts with one of the special + # characters that haml parses, then we need to escape it with a \ when + # printing. So here we make a regexp pattern to check if the node needs to be + # escaped. + special_chars = + Haml::Parser::SPECIAL_CHARACTERS.map { |char| Regexp.escape(char) } + + SPECIAL_START = /\A(?:#{special_chars.join('|')})/ + + def as_json + case type + when :comment, :doctype, :silent_script + to_h.tap do |json| + json.delete(:parent) + json[:children] = children.map(&:as_json) + end + when :filter, :haml_comment + to_h.tap { |json| json.delete(:parent) } + when :plain + to_h.tap do |json| + json.delete(:parent) + json[:children] = children.map(&:as_json) + + text = json[:value][:text] + json[:value][:text] = "\\#{text}" if text.match?(SPECIAL_START) + end + when :root + to_h.tap { |json| json[:children] = children.map(&:as_json) } + when :script + to_h.tap do |json| + json.delete(:parent) + json[:children] = children.map(&:as_json) + + if json[:value][:text].match?(ESCAPE) + json[:value][:text].gsub!(ESCAPE) { $1 } + json[:value].merge!(escape_html: 'escape_html', interpolate: true) + end + end + when :tag + to_h.tap do |json| + json.delete(:parent) + + # For some reason this is actually using a symbol to represent a null + # object ref instead of nil itself, so just replacing it here for + # simplicity in the printer + json[:value][:object_ref] = nil if json[:value][:object_ref] == :nil + + # Get a reference to the dynamic attributes hash + dynamic_attributes = value[:dynamic_attributes].to_h + + # If we have any in the old style, then we're going to pass it through + # the deep attribute parser filter. + if dynamic_attributes[:old] + dynamic_attributes[:old] = + DeepAttributeParser.new.parse(dynamic_attributes[:old]) + end + + json.merge!( + children: children.map(&:as_json), + value: value.merge(dynamic_attributes: dynamic_attributes) + ) + end + else + raise ArgumentError, "Unsupported type: #{type}" + end + end +end + +module Prettier + class HAMLParser + def self.parse(source) + Haml::Parser.new({}).call(source).as_json + rescue StandardError + false + end + end +end + +# If this is the main file we're executing, then most likely this is being +# executed from the haml.js spawn. In that case, read the ruby source from +# stdin and report back the AST over stdout. +if $0 == __FILE__ + response = Prettier::HAMLParser.parse($stdin.read) + + if !response + warn( + '@prettier/plugin-ruby encountered an error when attempting to parse ' \ + 'the HAML source. This usually means there was a syntax error in the ' \ + 'file in question.' + ) + + exit 1 + end + + puts JSON.fast_generate(response) +end diff --git a/src/haml/printer.js b/src/haml/printer.js new file mode 100644 index 00000000..d109d0ac --- /dev/null +++ b/src/haml/printer.js @@ -0,0 +1,28 @@ +const embed = require("./embed"); +const nodes = { + comment: require("./nodes/comment"), + doctype: require("./nodes/doctype"), + filter: require("./nodes/filter"), + haml_comment: require("./nodes/hamlComment"), + plain: require("./nodes/plain"), + root: require("./nodes/root"), + script: require("./nodes/script"), + silent_script: require("./nodes/silentScript"), + tag: require("./nodes/tag") +}; + +const genericPrint = (path, opts, print) => { + const { type } = path.getValue(); + + /* istanbul ignore next */ + if (!(type in nodes)) { + throw new Error(`Unsupported node encountered: ${type}`); + } + + return nodes[type](path, opts, print); +}; + +module.exports = { + embed, + print: genericPrint +}; diff --git a/src/plugin.js b/src/plugin.js index db05fc42..b42ea486 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -4,6 +4,9 @@ const rubyParser = require("./ruby/parser"); const rbsPrinter = require("./rbs/printer"); const rbsParser = require("./rbs/parser"); +const hamlPrinter = require("./haml/printer"); +const hamlParser = require("./haml/parser"); + /* * metadata mostly pulled from linguist and rubocop: * https://github.com/github/linguist/blob/master/lib/linguist/languages.yml @@ -75,15 +78,23 @@ module.exports = { name: "RBS", parsers: ["rbs"], extensions: [".rbs"] + }, + { + name: "HAML", + parsers: ["haml"], + extensions: [".haml"], + vscodeLanguageIds: ["haml"] } ], parsers: { ruby: rubyParser, - rbs: rbsParser + rbs: rbsParser, + haml: hamlParser }, printers: { ruby: rubyPrinter, - rbs: rbsPrinter + rbs: rbsPrinter, + haml: hamlPrinter }, options: { rubyArrayLiteral: { diff --git a/src/rbs/parser.rb b/src/rbs/parser.rb index 62031cce..98bff3be 100644 --- a/src/rbs/parser.rb +++ b/src/rbs/parser.rb @@ -1,5 +1,6 @@ #!/usr/bin/env ruby +require 'bundler/setup' if ENV['CI'] require 'json' require 'rbs' @@ -43,15 +44,17 @@ def to_json(*a) # key-value pairs of the record. class RBS::Types::Record def to_json(*a) - fields_extra = - fields.to_h do |key, type| - if key.is_a?(Symbol) && key.match?(/\A[A-Za-z_][A-Za-z_]*\z/) && - !key.match?(RBS::Parser::KEYWORDS_RE) - [key, { type: type, joiner: :label }] - else - [key.inspect, { type: type, joiner: :rocket }] - end + fields_extra = {} + + # Explicitly not using Enumerable#to_h here to support Ruby 2.5 + fields.each do |key, type| + if key.is_a?(Symbol) && key.match?(/\A[A-Za-z_][A-Za-z_]*\z/) && + !key.match?(RBS::Parser::KEYWORDS_RE) + fields_extra[key] = { type: type, joiner: :label } + else + fields_extra[key.inspect] = { type: type, joiner: :rocket } end + end { class: :record, fields: fields_extra, location: location }.to_json(*a) end diff --git a/src/ruby/parser.rb b/src/ruby/parser.rb index babbde17..2c0503fc 100755 --- a/src/ruby/parser.rb +++ b/src/ruby/parser.rb @@ -14,7 +14,7 @@ end require 'delegate' -require 'json' unless defined?(JSON) +require 'json' require 'ripper' module Prettier; end @@ -40,6 +40,13 @@ def initialize(source, *args) @source.lines.each { |line| @line_counts << @line_counts.last + line.size } end + def self.parse(source) + builder = new(source) + + response = builder.parse + response unless builder.error? + end + private # This represents the current place in the source string that we've gotten to @@ -2548,10 +2555,9 @@ def on_zsuper # stdin and report back the AST over stdout. if $0 == __FILE__ - builder = Prettier::Parser.new($stdin.read) - response = builder.parse + response = Prettier::Parser.parse($stdin.read) - if !response || builder.error? + if !response warn( '@prettier/plugin-ruby encountered an error when attempting to parse ' \ 'the ruby source. This usually means there was a syntax error in the ' \ diff --git a/test/js/globalSetup.js b/test/js/globalSetup.js index ca84779d..7544736c 100644 --- a/test/js/globalSetup.js +++ b/test/js/globalSetup.js @@ -8,7 +8,10 @@ process.env.RUBY_VERSION = spawnSync("ruby", args).stdout.toString().trim(); function globalSetup() { // Spawn the async parser process so that tests can send their content over to // it to get back the AST. - global.__ASYNC_PARSER__ = spawn("ruby", ["./test/js/parser.rb"]); + global.__ASYNC_PARSER__ = spawn("ruby", [ + "./test/js/parser.rb", + process.env.PORT + ]); } module.exports = globalSetup; diff --git a/test/js/haml/comment.test.js b/test/js/haml/comment.test.js new file mode 100644 index 00000000..742fbb34 --- /dev/null +++ b/test/js/haml/comment.test.js @@ -0,0 +1,33 @@ +const { haml } = require("../utils"); + +describe("comment", () => { + test("single line", () => + expect(haml("/ This is the peanutbutterjelly element")).toMatchFormat()); + + test("multi line", () => { + const content = haml(` + / + %p This doesn't render, because it's commented out! + `); + + return expect(content).toMatchFormat(); + }); + + test("conditional", () => { + const content = haml(` + /[if IE] + %h1 Get Firefox + `); + + return expect(content).toMatchFormat(); + }); + + test("revealed", () => { + const content = haml(` + /![if !IE] + You are not using Internet Explorer, or are using version 10+. + `); + + return expect(content).toMatchFormat(); + }); +}); diff --git a/test/js/haml/doctype.test.js b/test/js/haml/doctype.test.js new file mode 100644 index 00000000..d8d67660 --- /dev/null +++ b/test/js/haml/doctype.test.js @@ -0,0 +1,23 @@ +const { haml } = require("../utils"); + +describe("doctype", () => { + test("basic", () => expect(haml("!!! Basic")).toMatchFormat()); + + test("frameset", () => expect(haml("!!! Frameset")).toMatchFormat()); + + test("mobile", () => expect(haml("!!! Mobile")).toMatchFormat()); + + test("rdfa", () => expect(haml("!!! RDFa")).toMatchFormat()); + + test("strict", () => expect(haml("!!! Strict")).toMatchFormat()); + + test("xml", () => expect(haml("!!! XML")).toMatchFormat()); + + test("encoding", () => expect(haml("!!! XML iso-8859-1")).toMatchFormat()); + + test("1.1", () => expect(haml("!!! 1.1")).toMatchFormat()); + + test("5", () => expect(haml("!!! 5")).toMatchFormat()); + + test("misc", () => expect(haml("!!! foo")).toMatchFormat()); +}); diff --git a/test/js/haml/filter.test.js b/test/js/haml/filter.test.js new file mode 100644 index 00000000..6f345aa1 --- /dev/null +++ b/test/js/haml/filter.test.js @@ -0,0 +1,103 @@ +const { haml } = require("../utils"); + +describe("filter", () => { + test("self", () => { + const content = haml(` + :haml + -# comment + `); + + return expect(content).toMatchFormat(); + }); + + test("custom", () => { + const content = haml(` + :python + def foo: + bar + `); + + return expect(content).toMatchFormat(); + }); + + test("css", () => { + const content = haml(` + :css + .foo { height: 100px; width: 100px; } + `); + + return expect(content).toChangeFormat( + haml(` + :css + .foo { + height: 100px; + width: 100px; + } + `) + ); + }); + + test("javascript", () => { + const content = haml(` + :javascript + 1+1 + `); + + return expect(content).toChangeFormat( + haml(` + :javascript + 1 + 1; + `) + ); + }); + + test("less", () => { + const content = haml(` + :less + .foo { .bar { height: 100px; } } + `); + + return expect(content).toChangeFormat( + haml(` + :less + .foo { + .bar { + height: 100px; + } + } + `) + ); + }); + + test("markdown", () => { + const content = haml(` + :markdown + *Hello, world!* + `); + + return expect(content).toChangeFormat( + haml(` + :markdown + _Hello, world!_ + `) + ); + }); + + test("scss", () => { + const content = haml(` + :scss + .foo { .bar { height: 100px; } } + `); + + return expect(content).toChangeFormat( + haml(` + :scss + .foo { + .bar { + height: 100px; + } + } + `) + ); + }); +}); diff --git a/test/js/haml/hamlComment.test.js b/test/js/haml/hamlComment.test.js new file mode 100644 index 00000000..a6c69fa4 --- /dev/null +++ b/test/js/haml/hamlComment.test.js @@ -0,0 +1,21 @@ +const { haml } = require("../utils"); + +describe("haml comment", () => { + test("empty", () => expect(haml("-#")).toMatchFormat()); + + test("same line", () => expect(haml("-# comment")).toMatchFormat()); + + test("multi line", () => { + const content = haml(` + -# + this is + a multi line + comment + `); + + return expect(content).toMatchFormat(); + }); + + test("weird spacing same line", () => + expect(haml("-# foobar ")).toChangeFormat("-# foobar")); +}); diff --git a/test/js/haml/parser.test.js b/test/js/haml/parser.test.js new file mode 100644 index 00000000..47c3404c --- /dev/null +++ b/test/js/haml/parser.test.js @@ -0,0 +1,32 @@ +const { + parse, + hasPragma, + locStart, + locEnd +} = require("../../../src/haml/parser"); + +describe("parser", () => { + test("parse", () => { + expect(parse("= foo").type).toEqual("root"); + }); + + test("parse failure", () => { + expect(() => parse(`%div("invalid ": 1)`)).toThrowError(); + }); + + test("hasPragma", () => { + const withPragma = "-# @prettier"; + const withoutPragma = "-# foo"; + + expect(hasPragma(withPragma)).toBe(true); + expect(hasPragma(withoutPragma)).toBe(false); + }); + + test("locStart", () => { + expect(locStart({})).toEqual(0); + }); + + test("locEnd", () => { + expect(locEnd({})).toEqual(0); + }); +}); diff --git a/test/js/haml/plain.test.js b/test/js/haml/plain.test.js new file mode 100644 index 00000000..4dd75614 --- /dev/null +++ b/test/js/haml/plain.test.js @@ -0,0 +1,12 @@ +const { haml } = require("../utils"); + +describe("plain", () => { + const specialChars = ["%", ".", "#", "/", "!", "=", "&", "~", "-", "\\", ":"]; + + test.each(specialChars)("escapes starting %s", (specialChar) => + expect(haml(`\\${specialChar}`)).toMatchFormat() + ); + + test("does not unnecessarily escape other characters", () => + expect(haml("foo")).toMatchFormat()); +}); diff --git a/test/js/haml/script.test.js b/test/js/haml/script.test.js new file mode 100644 index 00000000..f45a028d --- /dev/null +++ b/test/js/haml/script.test.js @@ -0,0 +1,33 @@ +const { haml } = require("../utils"); + +describe("script", () => { + test("single line", () => expect(haml('%p= "hello"')).toMatchFormat()); + + test("multi line", () => { + const content = haml(` + %p + = ['hi', 'there', 'reader!'].join " " + = "yo" + `); + + return expect(content).toMatchFormat(); + }); + + test("escape", () => + expect(haml(`& I like #{"cheese & crackers"}`)).toMatchFormat()); + + test("escape with interpolate", () => + expect(haml(`&= "I like cheese & crackers"`)).toMatchFormat()); + + test("children", () => { + const content = haml(` + = foo + = bar + `); + + return expect(content).toMatchFormat(); + }); + + test("preserve", () => + expect(haml('~ "Foo\\n
Bar\\nBaz
"')).toMatchFormat()); +}); diff --git a/test/js/haml/silentScript.test.js b/test/js/haml/silentScript.test.js new file mode 100644 index 00000000..437a5d16 --- /dev/null +++ b/test/js/haml/silentScript.test.js @@ -0,0 +1,70 @@ +const { haml } = require("../utils"); + +describe("silent script", () => { + test("single line", () => expect(haml('- foo = "hello"')).toMatchFormat()); + + test("multi-line", () => { + const content = haml(` + - foo + - bar + `); + + return expect(content).toMatchFormat(); + }); + + test("multi line with case", () => { + const content = haml(` + - case foo + - when 1 + = "1" + %span bar + - when 2 + = "2" + - else + = "3" + `); + + return expect(content).toMatchFormat(); + }); + + test("multi line with if/else", () => { + const content = haml(` + - if foo + %span bar + -# baz + - elsif qux + = "qax" + - else + -# qix + `); + + return expect(content).toMatchFormat(); + }); + + test("multi line with unless/else", () => { + const content = haml(` + - unless foo + %span bar + -# baz + - elsif qux + = "qax" + - else + -# qix + `); + + return expect(content).toMatchFormat(); + }); + + test("multi line with embedded", () => { + const content = haml(` + - if foo + %span foo + - if bar + %span bar + - elsif baz + %span baz + `); + + return expect(content).toMatchFormat(); + }); +}); diff --git a/test/js/haml/tag.test.js b/test/js/haml/tag.test.js new file mode 100644 index 00000000..078c2a67 --- /dev/null +++ b/test/js/haml/tag.test.js @@ -0,0 +1,109 @@ +const { long, haml } = require("../utils"); + +describe("tag", () => { + test("class", () => expect(haml("%p.foo")).toMatchFormat()); + + test("class multiple", () => expect(haml("%p.foo.bar.baz")).toMatchFormat()); + + test("id", () => expect(haml("%p#foo")).toMatchFormat()); + + test("classes and id", () => expect(haml("%p.foo.bar#baz")).toMatchFormat()); + + test("self closing", () => expect(haml("%br/")).toMatchFormat()); + + test("whitespace removal left single line", () => + expect(haml('%p>= "Foo\\nBar"')).toMatchFormat()); + + test("whitespace removal right single line", () => + expect(haml('%p<= "Foo\\nBar"')).toMatchFormat()); + + test("whitespace removal right multi line", () => { + const content = haml(` + %blockquote< + %div + Foo! + `); + + return expect(content).toMatchFormat(); + }); + + test("dynamic attribute", () => + expect(haml("%span{html_attrs('fr-fr')}")).toMatchFormat()); + + test("dynamic attributes (ruby hash)", () => { + const content = haml("%div{data: { controller: 'lesson-evaluation' }}"); + + return expect(content).toMatchFormat(); + }); + + test("dynamic attributes (html-style)", () => { + const content = haml("%img(title=@title alt=@alt)/"); + + return expect(content).toMatchFormat(); + }); + + test("static attributes", () => + expect(haml("%span(foo)")).toChangeFormat("%span{foo: true}")); + + test("static attributes (hash label, single quote)", () => { + const content = haml(`%section(xml:lang="en" title="title")`); + const expected = "%section{'xml:lang': 'en', title: 'title'}"; + + return expect(content).toChangeFormat(expected); + }); + + test("static attributes (hash label, double quote)", () => { + const content = haml(`%section(xml:lang="en" title="title")`); + const expected = `%section{"xml:lang": "en", title: "title"}`; + + return expect(content).toChangeFormat(expected, { rubySingleQuote: false }); + }); + + test("static attributes (hash rocket, single quote)", () => { + const content = haml(`%section(xml:lang="en" title="title")`); + const expected = `%section{:'xml:lang' => 'en', :title => 'title'}`; + + return expect(content).toChangeFormat(expected, { rubyHashLabel: false }); + }); + + test("static attributes (hash rocket, double quote)", () => { + const content = haml(`%section(xml:lang="en" title="title")`); + const expected = '%section{:"xml:lang" => "en", :title => "title"}'; + + return expect(content).toChangeFormat(expected, { + rubyHashLabel: false, + rubySingleQuote: false + }); + }); + + test("static attributes (non-strings)", () => { + const content = haml(`%section(foo=1 bar=2)`); + const expected = `%section(foo=1 bar=2)`; + + return expect(content).toChangeFormat(expected); + }); + + test("object reference", () => { + const content = haml(` + %div[@user, :greeting] + %bar[290]/ + Hello! + `); + + return expect(content).toMatchFormat(); + }); + + test("long declaration before text", () => { + const content = haml(`%button{ data: { current: ${long} } } foo`); + const expected = haml(` + %button{ + data: { + current: ${long} + } + } + foo + `); + + return expect(content).toChangeFormat(expected); + }); +}); diff --git a/test/js/parser.rb b/test/js/parser.rb index d53ac0f5..76694951 100644 --- a/test/js/parser.rb +++ b/test/js/parser.rb @@ -1,17 +1,10 @@ # frozen_string_literal: true require 'socket' -require_relative '../../src/ruby/parser' -if RUBY_VERSION >= '3.0.0' - require_relative '../../src/rbs/parser' -else - class Prettier::RBSParser - def self.parse(source) - false - end - end -end +require_relative '../../src/ruby/parser' +require_relative '../../src/haml/parser' +require_relative '../../src/rbs/parser' # Set the program name so that it's easy to find if we need it $PROGRAM_NAME = 'prettier-ruby-test-parser' @@ -23,7 +16,7 @@ def self.parse(source) trap(:INT) { quit = true } trap(:TERM) { quit = true } -server = TCPServer.new(22_020) +server = TCPServer.new(ARGV.first || 22_021) loop do break if quit @@ -34,12 +27,13 @@ def self.parse(source) parser, source = message.force_encoding('UTF-8').split('|', 2) response = - if parser == 'ruby' - builder = Prettier::Parser.new(source) - response = builder.parse - response unless builder.error? - elsif parser == 'rbs' + case parser + when 'ruby' + Prettier::Parser.parse(source) + when 'rbs' Prettier::RBSParser.parse(source) + when 'haml' + Prettier::HAMLParser.parse(source) end if !response diff --git a/test/js/rbs/parser.test.js b/test/js/rbs/parser.test.js index 87822e7c..638c3d3f 100644 --- a/test/js/rbs/parser.test.js +++ b/test/js/rbs/parser.test.js @@ -6,21 +6,12 @@ const { locEnd } = require("../../../src/rbs/parser"); -describe("printer", () => { - if (process.env.RUBY_VERSION <= "3.0") { - test("RBS did not exist before ruby 3.0", () => { - // this is here because test files must contain at least one test, so for - // earlier versions of ruby this is just going to chill here - }); - - return; - } - +describe("parser", () => { test("parse", () => { expect(parse("class Foo end").declarations).toHaveLength(1); }); - test("parse", () => { + test("parse failure", () => { expect(() => parse("<>")).toThrowError(); }); diff --git a/test/js/rbs/rbs.test.js b/test/js/rbs/rbs.test.js index 0da11916..2fe21c27 100644 --- a/test/js/rbs/rbs.test.js +++ b/test/js/rbs/rbs.test.js @@ -1,16 +1,14 @@ const fs = require("fs"); const path = require("path"); -const { ruby } = require("../utils"); +const { rbs } = require("../utils"); function testCases(name, transform) { const buffer = fs.readFileSync(path.resolve(__dirname, `${name}.txt`)); const sources = buffer.toString().slice(0, -1).split("\n"); sources.forEach((source) => { - test(source, () => - expect(transform(source)).toMatchFormat({ parser: "rbs" }) - ); + test(source, () => expect(rbs(transform(source))).toMatchFormat()); }); } @@ -21,15 +19,6 @@ function describeCases(name, transform) { } describe("rbs", () => { - if (process.env.RUBY_VERSION <= "3.0") { - test("RBS did not exist before ruby 3.0", () => { - // this is here because test files must contain at least one test, so for - // earlier versions of ruby this is just going to chill here - }); - - return; - } - describeCases("combination", (source) => `T: ${source}`); describeCases("constant", (source) => `T: ${source}`); @@ -38,118 +27,118 @@ describe("rbs", () => { testCases("declaration", (source) => source); test("interface", () => { - const content = ruby(` + const content = rbs(` interface _Foo end `); - return expect(content).toMatchFormat({ parser: "rbs" }); + return expect(content).toMatchFormat(); }); test("interface with type params", () => { - const content = ruby(` + const content = rbs(` interface _Foo[A, B] end `); - return expect(content).toMatchFormat({ parser: "rbs" }); + return expect(content).toMatchFormat(); }); test("class", () => { - const content = ruby(` + const content = rbs(` class Foo end `); - return expect(content).toMatchFormat({ parser: "rbs" }); + return expect(content).toMatchFormat(); }); test("class with type params", () => { - const content = ruby(` + const content = rbs(` class Foo[A, B] end `); - return expect(content).toMatchFormat({ parser: "rbs" }); + return expect(content).toMatchFormat(); }); test("class with complicated type params", () => { - const content = ruby(` + const content = rbs(` class Foo[unchecked in A, unchecked out B, in C, out D, unchecked E, unchecked F, G, H] end `); - return expect(content).toMatchFormat({ parser: "rbs" }); + return expect(content).toMatchFormat(); }); test("class with annotations", () => { - const content = ruby(` + const content = rbs(` %a{This is an annotation.} class Foo end `); - return expect(content).toMatchFormat({ parser: "rbs" }); + return expect(content).toMatchFormat(); }); test("class with annotations that cannot be switched to braces", () => { - const content = ruby(` + const content = rbs(` %a class Foo end `); - return expect(content).toMatchFormat({ parser: "rbs" }); + return expect(content).toMatchFormat(); }); test("class with comments", () => { - const content = ruby(` + const content = rbs(` # This is a comment. class Foo end `); - return expect(content).toMatchFormat({ parser: "rbs" }); + return expect(content).toMatchFormat(); }); test("class with superclass", () => { - const content = ruby(` + const content = rbs(` class Foo < Bar end `); - return expect(content).toMatchFormat({ parser: "rbs" }); + return expect(content).toMatchFormat(); }); test("module", () => { - const content = ruby(` + const content = rbs(` module Foo end `); - return expect(content).toMatchFormat({ parser: "rbs" }); + return expect(content).toMatchFormat(); }); test("module with type params", () => { - const content = ruby(` + const content = rbs(` module Foo[A, B] end `); - return expect(content).toMatchFormat({ parser: "rbs" }); + return expect(content).toMatchFormat(); }); test("module with self types", () => { - const content = ruby(` + const content = rbs(` module Foo : A end `); - return expect(content).toMatchFormat({ parser: "rbs" }); + return expect(content).toMatchFormat(); }); test("multiple empty lines", () => { - const content = ruby(` + const content = rbs(` class Foo A: 1 B: 2 @@ -159,7 +148,7 @@ describe("rbs", () => { end `); - const expected = ruby(` + const expected = rbs(` class Foo A: 1 B: 2 @@ -168,16 +157,17 @@ describe("rbs", () => { end `); - return expect(content).toChangeFormat(expected, { parser: "rbs" }); + return expect(content).toChangeFormat(expected); }); }); - describeCases("generic", (source) => - ruby(` - class T - def t: ${source} - end - `) + describeCases( + "generic", + (source) => ` + class T + def t: ${source} + end + ` ); describeCases("interface", (source) => `T: ${source}`); @@ -186,79 +176,72 @@ describe("rbs", () => { testCases("literal", (source) => `T: ${source}`); test("+1 drops the plus sign", () => - expect("T: +1").toChangeFormat("T: 1", { parser: "rbs" })); + expect(rbs("T: +1")).toChangeFormat("T: 1")); - test("uses default quotes", () => - expect("T: 'foo'").toMatchFormat({ parser: "rbs" })); + test("uses default quotes", () => expect(rbs("T: 'foo'")).toMatchFormat()); test("changes quotes to match", () => - expect("T: 'foo'").toChangeFormat(`T: "foo"`, { - rubySingleQuote: false, - parser: "rbs" + expect(rbs("T: 'foo'")).toChangeFormat(`T: "foo"`, { + rubySingleQuote: false })); test("keeps string the same when there is an escape sequence", () => - expect(`T: "super \\" duper"`).toMatchFormat({ parser: "rbs" })); + expect(rbs(`T: "super \\" duper"`)).toMatchFormat()); test("unescapes single quotes when using double quotes", () => - expect(`T: 'super \\' duper'`).toChangeFormat(`T: "super ' duper"`, { - rubySingleQuote: false, - parser: "rbs" + expect(rbs(`T: 'super \\' duper'`)).toChangeFormat(`T: "super ' duper"`, { + rubySingleQuote: false })); test("maintains escape sequences when using double quotes", () => - expect(`T: "escape sequences \\a\\b\\e\\f\\n\\r\\t\\v"`).toMatchFormat({ - parser: "rbs" - })); + expect(rbs(`T: "escape sequences \\a\\b\\e\\f\\n\\r"`)).toMatchFormat()); test("maintains not escape sequences when using single quotes", () => - expect(`T: 'escape sequences \\a\\b\\e\\f\\n\\r\\t\\v'`).toMatchFormat({ - parser: "rbs" - })); + expect(rbs(`T: 'escape sequences \\a\\b\\e\\f\\n\\r'`)).toMatchFormat()); }); - describeCases("member", (source) => - ruby(` - class T - ${source} - end - `) + describeCases( + "member", + (source) => ` + class T + ${source} + end + ` ); - describeCases("method", (source) => - ruby(` - class T - ${source} - end - `) + describeCases( + "method", + (source) => ` + class T + ${source} + end + ` ); describe("optional", () => { testCases("optional", (source) => `T: ${source}`); test("removes optional space before question mark", () => - expect("T: :foo ?").toChangeFormat("T: :foo?", { parser: "rbs" })); + expect(rbs("T: :foo ?")).toChangeFormat("T: :foo?")); }); describe("plain", () => { testCases("plain", (source) => `T: ${source}`); test("any gets transformed into untyped", () => - expect("T: any").toChangeFormat("T: untyped", { parser: "rbs" })); + expect(rbs("T: any")).toChangeFormat("T: untyped")); }); describe("proc", () => { testCases("proc", (source) => `T: ${source}`); test("drops optional parentheses when there are no params", () => - expect("T: ^() -> void").toChangeFormat("T: ^-> void", { - parser: "rbs" - })); + expect(rbs("T: ^() -> void")).toChangeFormat("T: ^-> void")); test("drops optional parentheses with block param when there are no params to the block", () => - expect( - "T: ^{ () -> void } -> void" - ).toChangeFormat("T: ^{ -> void } -> void", { parser: "rbs" })); + expect(rbs("T: ^{ () -> void } -> void")).toChangeFormat( + "T: ^{ -> void } -> void" + )); }); describeCases("record", (source) => `T: ${source}`); diff --git a/test/js/setupTests.js b/test/js/setupTests.js index 79ee938a..d443da28 100644 --- a/test/js/setupTests.js +++ b/test/js/setupTests.js @@ -31,31 +31,31 @@ function parseAsync(parser, text) { (response.error ? reject : resolve)(response); }); - client.connect({ port: 22020 }, () => { + client.connect({ port: process.env.PORT || 22021 }, () => { client.end(`${parser}|${text}`); }); }); } function checkFormat(before, after, config) { - const opts = Object.assign( - { parser: "ruby", plugins: ["."], originalText: before }, - config - ); + const parser = before.parser || "ruby"; + const originalText = before.code || before; + + const opts = Object.assign({ parser, plugins: ["."], originalText }, config); return new Promise((resolve, reject) => { if ( opts.parser === "ruby" && - (before.includes("#") || before.includes("=begin")) + (originalText.includes("#") || originalText.includes("=begin")) ) { // If the source includes an #, then this test has a comment in it. // Unfortunately, formatAST expects comments to already be attached, but // prettier doesn't export anything that allows you to hook into their // attachComments function. So in this case, we need to instead go through // the normal format function and spawn a process. - resolve(prettier.format(before, opts)); + resolve(prettier.format(originalText, opts)); } else { - parseAsync(opts.parser, before) + parseAsync(opts.parser, originalText) .then((ast) => resolve(formatAST(ast, opts).formatted)) .catch(reject); } @@ -72,10 +72,10 @@ function checkFormat(before, after, config) { expect.extend({ toChangeFormat(before, after, config = {}) { - return checkFormat(before, after, config); + return checkFormat(before, after.code || after, config); }, toMatchFormat(before, config = {}) { - return checkFormat(before, before, config); + return checkFormat(before, before.code || before, config); }, toFailFormat(before, message) { let pass = false; diff --git a/test/js/utils.js b/test/js/utils.js index b29efeb8..ebe161bf 100644 --- a/test/js/utils.js +++ b/test/js/utils.js @@ -1,12 +1,28 @@ const long = Array(80).fill("a").join(""); -const ruby = (code) => { +function stripLeadingWhitespace(code) { + if (!code.includes("\n")) { + return code; + } + const lines = code.split("\n"); const indent = lines[1].split("").findIndex((char) => /[^\s]/.test(char)); const content = lines.slice(1, lines.length - 1); return content.map((line) => line.slice(indent)).join("\n"); -}; +} + +function ruby(code) { + return stripLeadingWhitespace(code); +} + +function rbs(code) { + return { code: stripLeadingWhitespace(code), parser: "rbs" }; +} + +function haml(code) { + return { code: stripLeadingWhitespace(code), parser: "haml" }; +} -module.exports = { long, ruby }; +module.exports = { long, ruby, rbs, haml };