Skip to content

Commit 4ead8ea

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 4ead8ea

1 file changed

Lines changed: 91 additions & 14 deletions

File tree

src/cfengine_cli/format.py

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

3535

36+
def _has_direct_macro(node: Node) -> bool:
37+
"""Check if any direct child of a node is a macro (non-recursive)."""
38+
return any(child.type == "macro" for child in node.children)
39+
40+
41+
def _contains_macro(nodes: Node | list[Node]) -> bool:
42+
"""Check if a node (or any node in a list) is a macro or has a child which is a macro.
43+
44+
This is useful when we want to for example always split something across multiple
45+
lines if macros are involved.
46+
"""
47+
if isinstance(nodes, list):
48+
return any(_contains_macro(node) for node in nodes)
49+
if nodes.type == "macro":
50+
return True
51+
return _contains_macro(nodes.children)
52+
53+
3654
def format_json_file(filename: str, check: bool) -> int:
3755
"""Reformat a JSON file in place using cfbs pretty-printer.
3856
@@ -192,42 +210,62 @@ def split_generic_value(node: Node, indent: int, line_length: int) -> list[str]:
192210
return [stringify_single_line_node(node)]
193211

194212

213+
def _set_trailing_comma(line: str, add: bool) -> str:
214+
"""Add or remove a trailing comma from a formatted line."""
215+
if add and not line.endswith(","):
216+
return line + ","
217+
if not add and line.endswith(","):
218+
return line[:-1]
219+
return line
220+
221+
195222
def split_generic_list(
196223
middle: list[Node], indent: int, line_length: int, trailing_comma: bool = True
197224
) -> list[str]:
198225
"""Split list elements into one-per-line strings, each pre-indented."""
226+
has_macros = _contains_macro(middle)
199227
elements: list[str] = []
200228
for element in middle:
201229
if elements and element.type == ",":
202230
elements[-1] = elements[-1] + ","
203231
continue
232+
if element.type == "macro":
233+
elements.append(text(element))
234+
continue
204235
line = " " * indent + stringify_single_line_node(element)
205236
if len(line) < line_length:
206237
elements.append(line)
207238
else:
208239
lines = split_generic_value(element, indent, line_length)
209240
elements.append(" " * indent + lines[0])
210241
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
242+
243+
# Optionally add trailing commas (based on trailing comma boolean arg):
244+
# When there are no macros, we just want to find the last element
245+
# which is not a comment, and add a trailing comma if needed.
246+
# If there are macros, there might be 2 elements to fix (1 in each branch).
247+
# Note that syntax errors for missing commas between elements
248+
# should be handled elsewhere.
249+
if has_macros:
250+
for i, e in enumerate(elements):
251+
if not e.lstrip().startswith(("@", "#")):
252+
elements[i] = _set_trailing_comma(e, trailing_comma)
253+
else:
254+
for i in reversed(range(len(elements))):
255+
if not elements[i].lstrip().startswith("#"):
256+
elements[i] = _set_trailing_comma(elements[i], trailing_comma)
257+
break
221258
return elements
222259

223260

224261
def maybe_split_generic_list(
225262
nodes: list[Node], indent: int, line_length: int, trailing_comma: bool = True
226263
) -> list[str]:
227264
"""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]
265+
if not any(n.type == "macro" for n in nodes):
266+
string = " " * indent + stringify_single_line_nodes(nodes)
267+
if len(string) < line_length:
268+
return [string]
231269
return split_generic_list(nodes, indent, line_length, trailing_comma)
232270

233271

@@ -269,6 +307,8 @@ def maybe_split_rval(
269307
node: Node, indent: int, offset: int, line_length: int
270308
) -> list[str]:
271309
"""Return single-line rval if it fits at offset, otherwise split it."""
310+
if _contains_macro(node):
311+
return split_rval(node, indent, line_length)
272312
line = stringify_single_line_node(node)
273313
if len(line) + offset < line_length:
274314
return [line]
@@ -280,8 +320,36 @@ def maybe_split_rval(
280320
# ---------------------------------------------------------------------------
281321

282322

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

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

314382
def _stringify(node: Node, indent: int, line_length: int) -> list[str]:
315383
"""Return a node as pre-indented line(s), splitting if it exceeds line_length."""
384+
# Attributes containing macros must always be split — macros cannot
385+
# appear inline on a single line.
386+
if node.type == "attribute" and _contains_macro(node):
387+
return _attempt_split_attribute(node, indent, line_length - 1)
316388
single_line = " " * indent + stringify_single_line_node(node)
317389
# Reserve 1 char for trailing ; or , after attributes
318390
effective_length = line_length - 1 if node.type == "attribute" else line_length
@@ -455,6 +527,8 @@ def _can_single_line_promise(node: Node, indent: int, line_length: int) -> bool:
455527
"""
456528
assert node.type == "promise"
457529
children = node.children
530+
if _contains_macro(children):
531+
return False
458532
attrs = [c for c in children if c.type == "attribute"]
459533
next_sib = node.next_named_sibling
460534
while next_sib and next_sib.type == "macro":
@@ -762,7 +836,10 @@ def _autoformat(
762836

763837
# Leaf nodes
764838
if node.type in {",", ";"}:
765-
fmt.print_same_line(node)
839+
if previous and previous.type == "macro":
840+
fmt.print(node, indent + 2)
841+
else:
842+
fmt.print_same_line(node)
766843
elif node.type == "comment":
767844
if not _is_empty_comment(node):
768845
fmt.print(node, _comment_indent(node, indent))

0 commit comments

Comments
 (0)