diff --git a/README.md b/README.md index 7be809371ed..509aef83906 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ Options: --pyi Format all input files like typing stubs regardless of file extension (useful when piping source on standard input). + -s, --single-quotes Use single quotes during string + normalization. -S, --skip-string-normalization Don't normalize string quotes or prefixes. -N, --skip-numeric-underscore-normalization @@ -670,6 +672,7 @@ Commands and shortcuts: Configuration: * `g:black_fast` (defaults to `0`) * `g:black_linelength` (defaults to `88`) +* `g:black_single_quotes` (defaults to `0`) * `g:black_skip_string_normalization` (defaults to `0`) * `g:black_virtualenv` (defaults to `~/.vim/black`) @@ -805,6 +808,7 @@ request is rejected with `HTTP 501` (Not Implemented). The headers controlling how code is formatted are: - `X-Line-Length`: corresponds to the `--line-length` command line flag. + - `X-Single-Quotes`: corresponds to the `--single-quotes` command line flag. - `X-Skip-String-Normalization`: corresponds to the `--skip-string-normalization` command line flag. If present and its value is not the empty string, no string normalization will be performed. diff --git a/black.py b/black.py index c1cc08021f3..49275b3df79 100644 --- a/black.py +++ b/black.py @@ -117,6 +117,7 @@ class FileMode(Flag): PYI = 2 NO_STRING_NORMALIZATION = 4 NO_NUMERIC_UNDERSCORE_NORMALIZATION = 8 + SINGLE_QUOTES = 16 @classmethod def from_configuration( @@ -124,6 +125,7 @@ def from_configuration( *, py36: bool, pyi: bool, + single_quotes: bool, skip_string_normalization: bool, skip_numeric_underscore_normalization: bool, ) -> "FileMode": @@ -132,6 +134,8 @@ def from_configuration( mode |= cls.PYTHON36 if pyi: mode |= cls.PYI + if single_quotes: + mode |= cls.SINGLE_QUOTES if skip_string_normalization: mode |= cls.NO_STRING_NORMALIZATION if skip_numeric_underscore_normalization: @@ -199,6 +203,12 @@ def read_pyproject_toml( "(useful when piping source on standard input)." ), ) +@click.option( + "-s", + "--single-quotes", + is_flag=True, + help="Use single quotes during string normalization.", +) @click.option( "-S", "--skip-string-normalization", @@ -300,6 +310,7 @@ def main( fast: bool, pyi: bool, py36: bool, + single_quotes: bool, skip_string_normalization: bool, skip_numeric_underscore_normalization: bool, quiet: bool, @@ -314,6 +325,7 @@ def main( mode = FileMode.from_configuration( py36=py36, pyi=pyi, + single_quotes=single_quotes, skip_string_normalization=skip_string_normalization, skip_numeric_underscore_normalization=skip_numeric_underscore_normalization, ) @@ -631,11 +643,13 @@ def format_str( future_imports = get_future_imports(src_node) is_pyi = bool(mode & FileMode.PYI) py36 = bool(mode & FileMode.PYTHON36) or is_python36(src_node) + single_quotes = bool(mode & FileMode.SINGLE_QUOTES) normalize_strings = not bool(mode & FileMode.NO_STRING_NORMALIZATION) normalize_fmt_off(src_node) lines = LineGenerator( remove_u_prefix=py36 or "unicode_literals" in future_imports, is_pyi=is_pyi, + single_quotes=single_quotes, normalize_strings=normalize_strings, allow_underscores=py36 and not bool(mode & FileMode.NO_NUMERIC_UNDERSCORE_NORMALIZATION), @@ -1425,6 +1439,7 @@ class LineGenerator(Visitor[Line]): is_pyi: bool = False normalize_strings: bool = True + single_quotes: bool = False current_line: Line = Factory(Line) remove_u_prefix: bool = False allow_underscores: bool = False @@ -1468,7 +1483,7 @@ def visit_default(self, node: LN) -> Iterator[Line]: normalize_prefix(node, inside_brackets=any_open_brackets) if self.normalize_strings and node.type == token.STRING: normalize_string_prefix(node, remove_u_prefix=self.remove_u_prefix) - normalize_string_quotes(node) + normalize_string_quotes(node, self.single_quotes) if node.type == token.NUMBER: normalize_numeric_literal(node, self.allow_underscores) if node.type not in WHITESPACE: @@ -2494,27 +2509,28 @@ def normalize_string_prefix(leaf: Leaf, remove_u_prefix: bool = False) -> None: leaf.value = f"{new_prefix}{match.group(2)}" -def normalize_string_quotes(leaf: Leaf) -> None: - """Prefer double quotes but only if it doesn't cause more escaping. +def normalize_string_quotes(leaf: Leaf, single_quotes: bool) -> None: + """Normalize quotes but only if it doesn't cause more escaping. Adds or removes backslashes as appropriate. Doesn't parse and fix strings nested in f-strings (yet). Note: Mutates its argument. """ + preferred_quote = "'" if single_quotes else '"' + other_quote = '"' if single_quotes else "'" value = leaf.value.lstrip("furbFURB") if value[:3] == '"""': return - elif value[:3] == "'''": orig_quote = "'''" new_quote = '"""' - elif value[0] == '"': - orig_quote = '"' - new_quote = "'" + elif value[0] == preferred_quote: + orig_quote = preferred_quote + new_quote = other_quote else: - orig_quote = "'" - new_quote = '"' + orig_quote = other_quote + new_quote = preferred_quote first_quote_pos = leaf.value.find(orig_quote) if first_quote_pos == -1: return # There's an internal error @@ -2555,8 +2571,8 @@ def normalize_string_quotes(leaf: Leaf) -> None: if new_escape_count > orig_escape_count: return # Do not introduce more escaping - if new_escape_count == orig_escape_count and orig_quote == '"': - return # Prefer double quotes + if new_escape_count == orig_escape_count and orig_quote == preferred_quote: + return # Quote is already as desired -> nothing to do leaf.value = f"{prefix}{new_quote}{new_body}{new_quote}" diff --git a/blackd.py b/blackd.py index e1006a1942b..165b6b8b332 100644 --- a/blackd.py +++ b/blackd.py @@ -13,6 +13,7 @@ VERSION_HEADER = "X-Protocol-Version" LINE_LENGTH_HEADER = "X-Line-Length" PYTHON_VARIANT_HEADER = "X-Python-Variant" +SINGLE_QUOTES_HEADER = "X-Single-Quotes" SKIP_STRING_NORMALIZATION_HEADER = "X-Skip-String-Normalization" SKIP_NUMERIC_UNDERSCORE_NORMALIZATION_HEADER = "X-Skip-Numeric-Underscore-Normalization" FAST_OR_SAFE_HEADER = "X-Fast-Or-Safe" @@ -67,6 +68,7 @@ async def handle(request: web.Request, executor: Executor) -> web.Response: return web.Response( status=400, text=f"Invalid value for {PYTHON_VARIANT_HEADER}" ) + single_quotes = bool(request.headers.get(SINGLE_QUOTES_HEADER, False)) skip_string_normalization = bool( request.headers.get(SKIP_STRING_NORMALIZATION_HEADER, False) ) @@ -79,6 +81,7 @@ async def handle(request: web.Request, executor: Executor) -> web.Response: mode = black.FileMode.from_configuration( py36=py36, pyi=pyi, + single_quotes=single_quotes, skip_string_normalization=skip_string_normalization, skip_numeric_underscore_normalization=skip_numeric_underscore_normalization, ) diff --git a/plugin/black.vim b/plugin/black.vim index 0a26aa0eabe..32b8c513c23 100644 --- a/plugin/black.vim +++ b/plugin/black.vim @@ -32,6 +32,9 @@ endif if !exists("g:black_linelength") let g:black_linelength = 88 endif +if !exists("g:black_single_quotes") + let g:black_single_quotes = 0 +endif if !exists("g:black_skip_string_normalization") let g:black_skip_string_normalization = 0 endif @@ -100,6 +103,8 @@ def Black(): fast = bool(int(vim.eval("g:black_fast"))) line_length = int(vim.eval("g:black_linelength")) mode = black.FileMode.AUTO_DETECT + if bool(int(vim.eval("g:black_single_quotes"))): + mode |= black.FileMode.SINGLE_QUOTES if bool(int(vim.eval("g:black_skip_string_normalization"))): mode |= black.FileMode.NO_STRING_NORMALIZATION buffer_str = '\n'.join(vim.current.buffer) + '\n' diff --git a/tests/data/string_single_quotes.py b/tests/data/string_single_quotes.py new file mode 100644 index 00000000000..ea6a519897e --- /dev/null +++ b/tests/data/string_single_quotes.py @@ -0,0 +1,97 @@ +'''''' +'\'' +'"' +"'" +"\"" +"Hello" +"Don't do that" +'Here is a "' +'What\'s the deal here?' +"What's the deal \"here\"?" +"And \"here\"?" +"""Strings with "" in them""" +'''Strings with "" in them''' +'''Here's a "''' +'''Here's a " ''' +'''Just a normal triple +quote''' +f"just a normal {f} string" +f'''This is a triple-quoted {f}-string''' +f'MOAR {" ".join([])}' +f"MOAR {' '.join([])}" +r"raw string ftw" +r'Date d\'expiration:(.*)' +r'Tricky "quote' +r'Not-so-tricky \"quote' +r'Not-so-tricky \'single-quote' +rf'{yay}' +'\n\ +The \"quick\"\n\ +brown fox\n\ +jumps over\n\ +the \'lazy\' dog.\n\ +' +re.compile(r'[\\"]') +"x = ''; y = \"\"" +"x = '''; y = \"\"" +"x = ''''; y = \"\"" +"x = '' ''; y = \"\"" +"x = ''; y = \"\"\"" +"x = '''; y = \"\"\"\"" +"x = ''''; y = \"\"\"\"\"" +"x = '' ''; y = \"\"\"\"\"" +'unnecessary \"\"escaping' +"unnecessary \'\'escaping" +'\\""' +"\\''" +'Lots of \\\\\\\\\'quotes\'' + +# output + +"""""" +"'" +'"' +"'" +'"' +'Hello' +"Don't do that" +'Here is a "' +"What's the deal here?" +'What\'s the deal "here"?' +'And "here"?' +"""Strings with "" in them""" +"""Strings with "" in them""" +'''Here's a "''' +"""Here's a " """ +"""Just a normal triple +quote""" +f'just a normal {f} string' +f"""This is a triple-quoted {f}-string""" +f'MOAR {" ".join([])}' +f"MOAR {' '.join([])}" +r'raw string ftw' +r'Date d\'expiration:(.*)' +r'Tricky "quote' +r'Not-so-tricky \"quote' +r'Not-so-tricky \'single-quote' +rf'{yay}' +'\n\ +The "quick"\n\ +brown fox\n\ +jumps over\n\ +the \'lazy\' dog.\n\ +' +re.compile(r'[\\"]') +'x = \'\'; y = ""' +"x = '''; y = \"\"" +"x = ''''; y = \"\"" +"x = '' ''; y = \"\"" +'x = \'\'; y = """' +'x = \'\'\'; y = """"' +'x = \'\'\'\'; y = """""' +'x = \'\' \'\'; y = """""' +'unnecessary ""escaping' +"unnecessary ''escaping" +'\\""' +"\\''" +"Lots of \\\\\\\\'quotes'" diff --git a/tests/test_black.py b/tests/test_black.py index 1d759f2537e..d57d1e05963 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -314,6 +314,15 @@ def test_string_quotes(self) -> None: black.assert_equivalent(source, not_normalized) black.assert_stable(source, not_normalized, line_length=ll, mode=mode) + @patch("black.dump_to_file", dump_to_stderr) + def test_string_single_quotes(self) -> None: + source, expected = read_data("string_single_quotes") + mode = black.FileMode.SINGLE_QUOTES + actual = fs(source, mode=mode) + self.assertFormatEqual(expected, actual) + black.assert_equivalent(source, actual) + black.assert_stable(source, actual, line_length=ll, mode=mode) + @patch("black.dump_to_file", dump_to_stderr) def test_slices(self) -> None: source, expected = read_data("slices")