Skip to content

Commit

Permalink
black: add a single-quotes option to prefer single quotes
Browse files Browse the repository at this point in the history
Add support for `black -s|--single-quotes`.

Fixes #594
  • Loading branch information
davvid committed Dec 8, 2018
1 parent 5512119 commit ccd0797
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 11 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -805,6 +807,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.
Expand Down
38 changes: 27 additions & 11 deletions black.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,15 @@ class FileMode(Flag):
PYI = 2
NO_STRING_NORMALIZATION = 4
NO_NUMERIC_UNDERSCORE_NORMALIZATION = 8
SINGLE_QUOTES = 16

@classmethod
def from_configuration(
cls,
*,
py36: bool,
pyi: bool,
single_quotes: bool,
skip_string_normalization: bool,
skip_numeric_underscore_normalization: bool,
) -> "FileMode":
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"

Expand Down
3 changes: 3 additions & 0 deletions blackd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
)
Expand All @@ -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,
)
Expand Down
5 changes: 5 additions & 0 deletions plugin/black.vim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
97 changes: 97 additions & 0 deletions tests/data/string_single_quotes.py
Original file line number Diff line number Diff line change
@@ -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'"
9 changes: 9 additions & 0 deletions tests/test_black.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down

0 comments on commit ccd0797

Please sign in to comment.