Skip to content

Commit bafd669

Browse files
olehermanseclaude
andcommitted
cfengine format: Fixed handling of macros inside attributes / lists
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Ole Herman Schumacher Elgesem <ole@northern.tech>
1 parent 7ff8f6a commit bafd669

1 file changed

Lines changed: 86 additions & 14 deletions

File tree

src/cfengine_cli/format.py

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@
3333
PROMISER_PARTS = {"promiser", "->", "stakeholder"}
3434

3535

36+
def _contains_macro(nodes: Node | list[Node]) -> bool:
37+
"""Check if a node (or any node in a list) is a macro or has a child which is a macro.
38+
39+
This is useful when we want to for example always split something across multiple
40+
lines if macros are involved.
41+
"""
42+
if isinstance(nodes, list):
43+
return any(_contains_macro(node) for node in nodes)
44+
if nodes.type == "macro":
45+
return True
46+
return _contains_macro(nodes.children)
47+
48+
3649
def format_json_file(filename: str, check: bool) -> int:
3750
"""Reformat a JSON file in place using cfbs pretty-printer.
3851
@@ -192,42 +205,62 @@ def split_generic_value(node: Node, indent: int, line_length: int) -> list[str]:
192205
return [stringify_single_line_node(node)]
193206

194207

208+
def _set_trailing_comma(line: str, add: bool) -> str:
209+
"""Add or remove a trailing comma from a formatted line."""
210+
if add and not line.endswith(","):
211+
return line + ","
212+
if not add and line.endswith(","):
213+
return line[:-1]
214+
return line
215+
216+
195217
def split_generic_list(
196218
middle: list[Node], indent: int, line_length: int, trailing_comma: bool = True
197219
) -> list[str]:
198220
"""Split list elements into one-per-line strings, each pre-indented."""
221+
has_macros = _contains_macro(middle)
199222
elements: list[str] = []
200223
for element in middle:
201224
if elements and element.type == ",":
202225
elements[-1] = elements[-1] + ","
203226
continue
227+
if element.type == "macro":
228+
elements.append(text(element))
229+
continue
204230
line = " " * indent + stringify_single_line_node(element)
205231
if len(line) < line_length:
206232
elements.append(line)
207233
else:
208234
lines = split_generic_value(element, indent, line_length)
209235
elements.append(" " * indent + lines[0])
210236
elements.extend(lines[1:])
211-
# Ensure trailing comma state matches the desired setting, on the last
212-
# non-comment element (so it doesn't end up after a trailing comment).
213-
for i in range(len(elements) - 1, -1, -1):
214-
if elements[i].lstrip().startswith("#"):
215-
continue
216-
if trailing_comma and not elements[i].endswith(","):
217-
elements[i] = elements[i] + ","
218-
elif not trailing_comma and elements[i].endswith(","):
219-
elements[i] = elements[i][:-1]
220-
break
237+
238+
# Optionally add trailing commas (based on trailing comma boolean arg):
239+
# When there are no macros, we just want to find the last element
240+
# which is not a comment, and add a trailing comma if needed.
241+
# If there are macros, there might be 2 elements to fix (1 in each branch).
242+
# Note that syntax errors for missing commas between elements
243+
# should be handled elsewhere.
244+
if has_macros:
245+
for i, e in enumerate(elements):
246+
if not e.lstrip().startswith(("@", "#")):
247+
elements[i] = _set_trailing_comma(e, trailing_comma)
248+
else:
249+
for i in reversed(range(len(elements))):
250+
if not elements[i].lstrip().startswith("#"):
251+
elements[i] = _set_trailing_comma(elements[i], trailing_comma)
252+
break
221253
return elements
222254

223255

224256
def maybe_split_generic_list(
225257
nodes: list[Node], indent: int, line_length: int, trailing_comma: bool = True
226258
) -> list[str]:
227259
"""Try a single-line rendering; fall back to split_generic_list if too long."""
228-
string = " " * indent + stringify_single_line_nodes(nodes)
229-
if len(string) < line_length:
230-
return [string]
260+
if not any(n.type == "macro" for n in nodes):
261+
string = " " * indent + stringify_single_line_nodes(nodes)
262+
if len(string) < line_length:
263+
return [string]
231264
return split_generic_list(nodes, indent, line_length, trailing_comma)
232265

233266

@@ -269,6 +302,8 @@ def maybe_split_rval(
269302
node: Node, indent: int, offset: int, line_length: int
270303
) -> list[str]:
271304
"""Return single-line rval if it fits at offset, otherwise split it."""
305+
if _contains_macro(node):
306+
return split_rval(node, indent, line_length)
272307
line = stringify_single_line_node(node)
273308
if len(line) + offset < line_length:
274309
return [line]
@@ -280,8 +315,36 @@ def maybe_split_rval(
280315
# ---------------------------------------------------------------------------
281316

282317

318+
def _format_attribute_with_macros(
319+
node: Node, indent: int, line_length: int
320+
) -> list[str]:
321+
"""Format an attribute whose direct children include macro nodes."""
322+
lines: list[str] = []
323+
children = list(node.children)
324+
325+
# First two children are lval and arrow (=>)
326+
lval = children[0]
327+
arrow = children[1]
328+
lines.append(" " * indent + text(lval) + " " + text(arrow))
329+
330+
for child in children[2:]:
331+
if child.type == "macro":
332+
lines.append(text(child))
333+
elif child.type == "comment":
334+
lines.append(" " * (indent + 2) + text(child))
335+
else:
336+
lines.append(" " * (indent + 2) + stringify_single_line_node(child))
337+
338+
return lines
339+
340+
283341
def _attempt_split_attribute(node: Node, indent: int, line_length: int) -> list[str]:
284342
"""Split an attribute node, wrapping the rval if it's a list or call."""
343+
# When macros appear as direct children of the attribute, use
344+
# the dedicated macro-aware formatter.
345+
if any(child.type == "macro" for child in node.children):
346+
return _format_attribute_with_macros(node, indent, line_length)
347+
285348
assert len(node.children) >= 3 # lval + arrow + rval + optionally comments
286349

287350
# Separate comments from the 3 structural children (lval, arrow, rval).
@@ -313,6 +376,10 @@ def _attempt_split_attribute(node: Node, indent: int, line_length: int) -> list[
313376

314377
def _stringify(node: Node, indent: int, line_length: int) -> list[str]:
315378
"""Return a node as pre-indented line(s), splitting if it exceeds line_length."""
379+
# Attributes containing macros must always be split — macros cannot
380+
# appear inline on a single line.
381+
if node.type == "attribute" and _contains_macro(node):
382+
return _attempt_split_attribute(node, indent, line_length - 1)
316383
single_line = " " * indent + stringify_single_line_node(node)
317384
# Reserve 1 char for trailing ; or , after attributes
318385
effective_length = line_length - 1 if node.type == "attribute" else line_length
@@ -455,6 +522,8 @@ def _can_single_line_promise(node: Node, indent: int, line_length: int) -> bool:
455522
"""
456523
assert node.type == "promise"
457524
children = node.children
525+
if _contains_macro(children):
526+
return False
458527
attrs = [c for c in children if c.type == "attribute"]
459528
next_sib = node.next_named_sibling
460529
while next_sib and next_sib.type == "macro":
@@ -762,7 +831,10 @@ def _autoformat(
762831

763832
# Leaf nodes
764833
if node.type in {",", ";"}:
765-
fmt.print_same_line(node)
834+
if previous and previous.type == "macro":
835+
fmt.print(node, indent + 2)
836+
else:
837+
fmt.print_same_line(node)
766838
elif node.type == "comment":
767839
if not _is_empty_comment(node):
768840
fmt.print(node, _comment_indent(node, indent))

0 commit comments

Comments
 (0)