Skip to content

Commit f179d82

Browse files
committed
format: treat backtick escapes like other quotes; expand quote tests
Address review on PR #164: - Backtick strings process the SAME escapes as single/double quotes (\\, \", \'); a backtick string is not literal. Its only special property is that the delimiter itself cannot be escaped, so a literal backtick can never appear inside one. Unified _decode_literal (drops its delim arg) and _encode_literal (now escapes backslashes for backticks too) accordingly. - Removed the unrequested backslash-newline line-continuation handling from _decode_literal. - Turned the literal-shape guard in _normalize_quotes into an assert, since callers always pass a real quoted_string literal. - Expanded the 012_quotes golden fixture with backslash cases (l/m/n/p) and an all-three-quotes case (o) to cover the unified escape behavior and the backtick encode branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4hEZuqiuEFH2zy8wWGwyD
1 parent e2d6aa2 commit f179d82

3 files changed

Lines changed: 37 additions & 30 deletions

File tree

src/cfengine_cli/format.py

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -95,40 +95,37 @@ def text(node: Node) -> str:
9595
return node.text.decode("utf-8")
9696

9797

98-
def _decode_literal(inner: str, delim: str) -> str:
98+
def _decode_literal(inner: str) -> str:
9999
"""Return the logical character content of a quoted_string's inner text.
100100
101-
Backtick strings are taken literally - CFEngine processes no escapes
102-
inside them. Single- and double-quoted strings recognize only the escapes
103-
for a backslash, a double quote and a single quote (plus a backslash-
104-
newline line continuation); any other backslash is kept literally, matching
105-
CFEngine's lexer.
101+
CFEngine processes the same escapes for all three quote styles: an escaped
102+
backslash, double quote, or single quote is unescaped, and any other
103+
backslash is kept as-is. (A backtick string still cannot contain a literal
104+
backtick, since the delimiter itself can't be escaped.)
106105
"""
107-
if delim == "`":
108-
return inner
109106
out = []
110107
i = 0
111108
while i < len(inner):
112109
c = inner[i]
113-
if c == "\\" and i + 1 < len(inner):
114-
nxt = inner[i + 1]
115-
if nxt in ("\\", '"', "'"):
116-
out.append(nxt)
117-
i += 2
118-
continue
119-
if nxt == "\n": # line continuation: drop both characters
120-
i += 2
121-
continue
110+
if c == "\\" and i + 1 < len(inner) and inner[i + 1] in ("\\", '"', "'"):
111+
out.append(inner[i + 1])
112+
i += 2
113+
continue
122114
out.append(c)
123115
i += 1
124116
return "".join(out)
125117

126118

127119
def _encode_literal(content: str, delim: str) -> str:
128-
"""Wrap logical content in delim, escaping as that quote style requires."""
129-
if delim == "`":
130-
return "`" + content + "`"
131-
escaped = content.replace("\\", "\\\\").replace(delim, "\\" + delim)
120+
"""Wrap content in delim, escaping as that quote style requires.
121+
122+
Backslashes are always escaped. Double- and single-quoted strings also
123+
escape their own delimiter; a backtick can't be escaped, so the caller must
124+
only choose backticks when the content contains no backtick.
125+
"""
126+
escaped = content.replace("\\", "\\\\")
127+
if delim != "`":
128+
escaped = escaped.replace(delim, "\\" + delim)
132129
return delim + escaped + delim
133130

134131

@@ -140,14 +137,13 @@ def _normalize_quotes(literal: str) -> str:
140137
need no escaping: no double quote -> double quotes; a double quote but no
141138
single quote -> single quotes; both -> backticks.
142139
"""
143-
if (
144-
len(literal) < 2
145-
or literal[0] != literal[-1]
146-
or literal[0] not in ("'", '"', "`")
147-
):
148-
return literal
140+
assert (
141+
len(literal) >= 2
142+
and literal[0] == literal[-1]
143+
and literal[0] in ("'", '"', "`")
144+
), f"expected a quoted string literal, got {literal!r}"
149145
delim = literal[0]
150-
content = _decode_literal(literal[1:-1], delim)
146+
content = _decode_literal(literal[1:-1])
151147
has_double = '"' in content
152148
has_single = "'" in content
153149
if not has_double:
@@ -157,8 +153,9 @@ def _normalize_quotes(literal: str) -> str:
157153
else:
158154
target = "`"
159155
if target == "`" and "`" in content:
160-
# Needs all three quote styles at once; a backtick string cannot
161-
# contain a backtick, so leave the literal as written.
156+
# A string containing a double quote, a single quote, and a backtick
157+
# can't use any style without escaping, and a backtick can't be
158+
# escaped, so leave the literal as the author wrote it.
162159
return literal
163160
if target == delim:
164161
return literal

tests/format/012_quotes.expected.cf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ bundle agent main
1515
"i" string => `it's "quoted"`;
1616
"j" string => 'say "hi"';
1717
"k" string => `he said "hi" it's`;
18+
"l" string => "a\\b";
19+
"m" string => "c\\\\d";
20+
"n" string => "e\\f";
21+
"o" string => 'mix "q" it\'s `tick`';
22+
"p" string => `a\\b "c" it's`;
1823

1924
reports:
2025
"a single-quoted promiser";

tests/format/012_quotes.input.cf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ vars:
1515
"i" string => `it's "quoted"`;
1616
"j" string => "say \"hi\"";
1717
"k" string => 'he said "hi" it\'s';
18+
"l" string => 'a\\b';
19+
"m" string => "c\\\\d";
20+
"n" string => `e\\f`;
21+
"o" string => 'mix "q" it\'s `tick`';
22+
"p" string => 'a\\b "c" it\'s';
1823

1924
reports:
2025
'a single-quoted promiser';

0 commit comments

Comments
 (0)