diff --git a/.gitignore b/.gitignore index 178253e..2395d9f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__ /tmp/ *.log *.output.cf +*.output.txt git_diff_exists commit_message.txt black_output.txt diff --git a/HACKING.md b/HACKING.md index f97bc94..df9a2bf 100644 --- a/HACKING.md +++ b/HACKING.md @@ -4,6 +4,53 @@ This document aims to have relevant information for people contributing to and m It is not necessary for users of the tool to know about these processes. For general user information, see the [README](./README.md). +## Code formatting + +We use automated code formatters to ensure consistent code style / indentation. +Please format Python code with [black](https://pypi.org/project/black/), and YAML and markdown files with [Prettier](https://prettier.io/). +For simplicity's sake, we don't have a custom configuration, we use the tool's defaults. + +If your editor does not do this automatically, you can run these tools from the command line: + +```bash +make format +``` + +## Installing from source: + +For developers working on CFEngine CLI, it is recommended to install an editable version of the tool: + +```bash +make install +``` + +Some of the tests require that you have the CLI installed (they run `cfengine` commands). + +## Running commands without installing + +You can also run commands without installing, using `uv`: + +```bash +uv run cfengine format +``` + +## Running tests + +Use the makefile command to run all linting and tests: + +```bash +make check +``` + +Running individual test suites: + +```bash +uv run pytest +bash tests/run-lint-tests.sh +bash tests/run-format-tests.sh +bash tests/run-shell-tests.sh +``` + ## Releasing new versions Releases are [automated using a GH Action](https://github.com/cfengine/cfengine-cli/blob/main/.github/workflows/pypi-publish.yml) @@ -79,62 +126,11 @@ Copy the token and paste it into the GitHub Secret named `PYPI_PASSWORD`. `PYPI_USERNAME` should be there already, you don't have to edit it, it is simply `__token__`. Don't store the token anywhere else - we generate new tokens if necessary. -## Code formatting - -We use automated code formatters to ensure consistent code style / indentation. -Please format Python code with [black](https://pypi.org/project/black/), and YAML and markdown files with [Prettier](https://prettier.io/). -For simplicity's sake, we don't have a custom configuration, we use the tool's defaults. - -If your editor does not do this automatically, you can run these tools from the command line: - -```bash -black . && prettier . --write -``` - -## Running commands during development - -This project uses `uv`. -This makes it easy to run commands without installing the project, for example: - -```bash -uv run cfengine format -``` - -## Installing from source: - -```bash -git fetch --all --tags -pip3 install . -``` - -## Running tests - -Unit tests: - -```bash -py.test -``` - -Shell tests (requires installing first): - -```bash -cat tests/shell/*.sh | bash -``` - ## Not implemented yet / TODOs - `cfengine run` - The command could automatically detect that you have CFEngine installed on a remote hub, and run it there instead (using `cf-remote`). - Handle when `cf-agent` is not installed, help users install. - Prompt / help users do what they meant (i.e. build and deploy and run). -- `cfengine format` - - Automatically break up and indent method calls, function calls, and nested function calls. - - Smarter placement of comments based on context. - - The command should be able to take a filename as an argument, and also operate using stdin and stdout. - (Receive file content on stdin, file type using command line arg, output formatted file to stdout). - - We can add a shortcut, `cfengine fmt`, since that matches other tools, like `deno`. -- `cfengine lint` - - The command should be able to take a filename as an argument, and also take file content from stdin. - - It would be nice if we refactored `validate_config()` in `cfbs` so it would take a simple dictionary (JSON) instead of a special CFBSConfig object. - Missing commands: - `cfengine install` - Install CFEngine packages / binaries (Wrapping `cf-remote install`). diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f955a47 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: check format lint install + +default: check + +format: + uv tool run black . + prettier . --write + +lint: + uv tool run black --check . + uv tool run flake8 src/ --ignore=E203,W503,E722,E731 --max-complexity=100 --max-line-length=160 + uv tool run pyflakes src/ + uv tool run pyright src/ + +check: format lint install + uv tool run black --check . + uv tool run flake8 src/ --ignore=E203,W503,E722,E731 --max-complexity=100 --max-line-length=160 + uv tool run pyflakes src/ + uv tool run pyright src/ + uv run pytest + bash tests/run-lint-tests.sh + bash tests/run-format-tests.sh + bash tests/run-shell-tests.sh + +install: + git fetch --all --tags + pipx install --force --editable . diff --git a/README.md b/README.md index 2b80121..48d5fb8 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ When it finds a mistake, it points out where the problem is like this; ifvarclass => "cfengine"; ^--------^ Deprecation: Use 'if' instead of 'ifvarclass' at main.cf:5:7 -FAIL: main.cf (1 errors) -Failure, 1 errors in total. +FAIL: main.cf (1 error) +Failure, 1 error in total. ``` Note that since we use a different parser than `cf-agent` / `cf-promises`, they are not 100% in sync. diff --git a/src/cfengine_cli/commands.py b/src/cfengine_cli/commands.py index ead79a1..6db55f2 100644 --- a/src/cfengine_cli/commands.py +++ b/src/cfengine_cli/commands.py @@ -4,7 +4,7 @@ import json from cfengine_cli.profile import profile_cfengine, generate_callstack from cfengine_cli.dev import dispatch_dev_subcommand -from cfengine_cli.lint import lint_folder, lint_single_arg +from cfengine_cli.lint import lint_args from cfengine_cli.shell import user_command from cfengine_cli.paths import bin from cfengine_cli.version import cfengine_cli_version_string @@ -96,20 +96,16 @@ def format(names, line_length) -> int: def _lint(files, strict) -> int: if not files: - return lint_folder(".", strict) - - errors = 0 - - for file in files: - errors += lint_single_arg(file, strict) - - return errors + return lint_args(["."], strict) + return lint_args(files, strict) def lint(files, strict) -> int: errors = _lint(files, strict) if errors == 0: print("Success, no errors found.") + elif errors == 1: + print("Failure, 1 error in total.") else: print(f"Failure, {errors} errors in total.") return errors diff --git a/src/cfengine_cli/docs.py b/src/cfengine_cli/docs.py index e2f0dcf..7f37c54 100644 --- a/src/cfengine_cli/docs.py +++ b/src/cfengine_cli/docs.py @@ -16,7 +16,7 @@ from cfbs.pretty import pretty_file from cfbs.utils import find -from cfengine_cli.lint import lint_folder, lint_policy_file +from cfengine_cli.lint import lint_args, lint_policy_file_snippet from cfengine_cli.utils import UserError IGNORED_DIRS = [".git"] @@ -138,7 +138,7 @@ def fn_check_syntax( match language: case "cf": - r = lint_policy_file( + r = lint_policy_file_snippet( snippet_abs_path, origin_path, first_line + 1, snippet_number, prefix ) if r != 0: @@ -409,7 +409,7 @@ def check_docs() -> int: Run by the command: cfengine dev lint-docs""" - r = lint_folder(".", strict=False) + r = lint_args(["."], strict=False) if r != 0: return r _process_markdown_code_blocks( diff --git a/src/cfengine_cli/format.py b/src/cfengine_cli/format.py index 033a2c2..d89735d 100644 --- a/src/cfengine_cli/format.py +++ b/src/cfengine_cli/format.py @@ -49,7 +49,18 @@ def update_previous(self, node): return tmp -def stringify_children_from_strings(parts): +def stringify_parameter_list(parts): + """Join pre-extracted string tokens into a formatted parameter list. + + Used when formatting bundle/body headers. Comments are + stripped from the parameter_list node before this function is called, + so `parts` contains only the structural tokens: "(", identifiers, "," + separators, and ")". The function removes any trailing comma before + ")", then joins the tokens with appropriate spacing (space after each + comma, no space after "(" or before ")"). + + Example: ["(", "a", ",", "b", ",", ")"] -> "(a, b)" + """ # Remove trailing comma before closing paren cleaned = [] for i, part in enumerate(parts): @@ -68,26 +79,39 @@ def stringify_children_from_strings(parts): return result -def stringify_children(children): +def stringify_single_line_nodes(nodes): + """Join a list of tree-sitter nodes into a single-line string. + + Operates on the direct child nodes of a CFEngine syntax construct + (e.g. a list, call, or attribute). Each child is recursively + flattened via stringify_single_line_node(). Spacing rules: + - A space is inserted after each "," separator. + - A space is inserted before and after "=>" (fat arrow). + - No extra space otherwise (e.g. no space after "(" or before ")"). + + Used by stringify_single_line_node() to recursively flatten any node with + children, and by maybe_split_generic_list() to attempt a single-line + rendering before falling back to multi-line splitting. + """ result = "" previous = None - for child in children: - string = stringify_single_line(child) + for node in nodes: + string = stringify_single_line_node(node) if previous and previous.type == ",": result += " " - if previous and child.type == "=>": + if previous and node.type == "=>": result += " " if previous and previous.type == "=>": result += " " result += string - previous = child + previous = node return result -def stringify_single_line(node): +def stringify_single_line_node(node): if not node.children: return text(node) - return stringify_children(node.children) + return stringify_single_line_nodes(node.children) def split_generic_value(node, indent, line_length): @@ -95,7 +119,7 @@ def split_generic_value(node, indent, line_length): return split_rval_call(node, indent, line_length) if node.type == "list": return split_rval_list(node, indent, line_length) - return [stringify_single_line(node)] + return [stringify_single_line_node(node)] def split_generic_list(middle, indent, line_length): @@ -104,7 +128,7 @@ def split_generic_list(middle, indent, line_length): if elements and element.type == ",": elements[-1] = elements[-1] + "," continue - line = " " * indent + stringify_single_line(element) + line = " " * indent + stringify_single_line_node(element) if len(line) < line_length: elements.append(line) else: @@ -115,7 +139,7 @@ def split_generic_list(middle, indent, line_length): def maybe_split_generic_list(nodes, indent, line_length): - string = " " * indent + stringify_children(nodes) + string = " " * indent + stringify_single_line_nodes(nodes) if len(string) < line_length: return [string] return split_generic_list(nodes, indent, line_length) @@ -147,11 +171,11 @@ def split_rval(node, indent, line_length): return split_rval_list(node, indent, line_length) if node.type == "call": return split_rval_call(node, indent, line_length) - return [stringify_single_line(node)] + return [stringify_single_line_node(node)] def maybe_split_rval(node, indent, offset, line_length): - line = stringify_single_line(node) + line = stringify_single_line_node(node) if len(line) + offset < line_length: return [line] return split_rval(node, indent, line_length) @@ -169,11 +193,11 @@ def attempt_split_attribute(node, indent, line_length): lines = maybe_split_rval(rval, indent, offset, line_length) lines[0] = prefix + lines[0] return lines - return [" " * indent + stringify_single_line(node)] + return [" " * indent + stringify_single_line_node(node)] def stringify(node, indent, line_length): - single_line = " " * indent + stringify_single_line(node) + single_line = " " * indent + stringify_single_line_node(node) # Reserve 1 char for trailing ; or , after attributes effective_length = line_length - 1 if node.type == "attribute" else line_length if len(single_line) < effective_length: @@ -209,9 +233,7 @@ def autoformat(node, fmt, line_length, macro_indent, indent=0): else: parts.append(text(p)) # Append directly to previous part (no space before parens) - header_parts[-1] = header_parts[-1] + stringify_children_from_strings( - parts - ) + header_parts[-1] = header_parts[-1] + stringify_parameter_list(parts) else: header_parts.append(text(x)) line = " ".join(header_parts) @@ -220,7 +242,15 @@ def autoformat(node, fmt, line_length, macro_indent, indent=0): if not (prev_sib and prev_sib.type == "comment"): fmt.print("", 0) fmt.print(line, 0) - for comment in header_comments: + for i, comment in enumerate(header_comments): + if comment.strip() == "#": + prev_is_comment = i > 0 and header_comments[i - 1].strip() != "#" + next_is_comment = ( + i + 1 < len(header_comments) + and header_comments[i + 1].strip() != "#" + ) + if not (prev_is_comment and next_is_comment): + continue fmt.print(comment, 0) children = node.children[-1].children if node.type in [ @@ -239,17 +269,23 @@ def autoformat(node, fmt, line_length, macro_indent, indent=0): return if node.type == "promise": # Single-line promise: if exactly 1 attribute, no half_promise continuation, - # and the whole line fits in line_length + # not inside a class guard, and the whole line fits in line_length attr_children = [c for c in children if c.type == "attribute"] next_sib = node.next_named_sibling has_continuation = next_sib and next_sib.type == "half_promise" - if len(attr_children) == 1 and not has_continuation: + parent = node.parent + in_class_guard = parent and parent.type in [ + "class_guarded_promises", + "class_guarded_body_attributes", + "class_guarded_promise_block_attributes", + ] + if len(attr_children) == 1 and not has_continuation and not in_class_guard: promiser_node = next((c for c in children if c.type == "promiser"), None) if promiser_node: line = ( text(promiser_node) + " " - + stringify_single_line(attr_children[0]) + + stringify_single_line_node(attr_children[0]) + ";" ) if indent + len(line) <= line_length: @@ -257,8 +293,13 @@ def autoformat(node, fmt, line_length, macro_indent, indent=0): return if children: for child in children: + # Blank line between bundle sections + if child.type == "bundle_section": + prev = child.prev_named_sibling + if prev and prev.type == "bundle_section": + fmt.print("", 0) # Blank line between promises in a section - if child.type == "promise": + elif child.type == "promise": prev = child.prev_named_sibling if prev and prev.type in ["promise", "half_promise"]: fmt.print("", 0) @@ -271,6 +312,7 @@ def autoformat(node, fmt, line_length, macro_indent, indent=0): if prev and prev.type in [ "promise", "half_promise", + "class_guarded_promises", ]: fmt.print("", 0) elif child.type == "comment": @@ -288,6 +330,11 @@ def autoformat(node, fmt, line_length, macro_indent, indent=0): fmt.print_same_line(node) return if node.type == "comment": + if text(node).strip() == "#": + prev = node.prev_named_sibling + nxt = node.next_named_sibling + if not (prev and prev.type == "comment" and nxt and nxt.type == "comment"): + return comment_indent = indent next_sib = node.next_named_sibling while next_sib and next_sib.type == "comment": diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index 38882c9..d60d38d 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -6,13 +6,24 @@ - cfbs.json (CFEngine Build project files) - *.json (basic JSON syntax checking) +Linting is performed in 3 steps: +1. Parsing - Read the .cf code and convert it into syntax trees +2. Discovery - Walk the syntax trees and record what is defined +3. Checking - Walk the syntax tree again and check for errors + +By default, linting is strict about bundles and bodies being +defined somewhere in the supplied files / folders. This +can be disabled using the `--strict=no` flag. + Usage: $ cfengine lint +$ cfengine lint ./core/ ./masterfiles/ +$ cfengine lint --strict=no main.cf """ +from enum import Enum import os import json -import itertools import tree_sitter_cfengine as tscfengine from dataclasses import dataclass from tree_sitter import Language, Parser @@ -27,95 +38,340 @@ ) +def _qualify(name: str, namespace: str) -> str: + """If name is already qualified (contains ':'), return as-is. Otherwise prepend namespace.""" + assert '"' not in namespace + assert '"' not in name + if ":" in name: + return name + return f"{namespace}:{name}" + + +class Mode(Enum): + NONE = None + SYNTAX = 1 + DISCOVER = 2 + LINT = 3 + + @dataclass -class _State: - block_type: str | None = None # "bundle" | "body" | "promise" | None +class State: + block_keyword: str | None = None # "bundle" | "body" | "promise" | None + block_type: str | None = None + block_name: str | None = None promise_type: str | None = None # "vars" | "files" | "classes" | ... | None attribute_name: str | None = None # "if" | "string" | "slist" | ... | None namespace: str = "default" # "ns" | "default" | ... | + mode: Mode = Mode.NONE + walking: bool = False + strict: bool = True + bundles = {} + bodies = {} + custom_promise_types = {} + policy_file = None + + def print_summary(self): + print("Bundles") + print(self.bundles) + print("Bodies") + print(self.bodies) + print("Custom promise types") + print(self.custom_promise_types) + + def block_string(self) -> str | None: + if not (self.block_keyword and self.block_type and self.block_name): + return None + + return " ".join((self.block_keyword, self.block_type, self.block_name)) + + def start_file(self, policy_file): + assert not self.walking + assert self.mode != Mode.NONE + self.policy_file = policy_file + self.namespace = "default" + self.walking = True + + def end_of_file(self): + assert self.walking + assert self.mode != Mode.NONE + assert self.block_keyword is None + assert self.promise_type is None + assert self.attribute_name is None + self.walking = False + self.policy_file = None + + def add_bundle(self, name: str): + name = _qualify(name, self.namespace) + # TODO: In the future we will record more information than True, like: + # - Can be a list / dict of all places a bundle with that + # qualified name is defined in cases there are duplicates. + # - Can record the location of each definition + # - Can record the parameters / signature + # - Can record whether the bundle is inside a macro + # - Can have a list of classes and vars defined inside + self.bundles[name] = True + + def add_body(self, name: str): + name = _qualify(name, self.namespace) + self.bodies[name] = True + + def add_promise_type(self, name: str): + self.custom_promise_types[name] = True + + def navigate(self, node): + """This function is called whenever we move to a node, to update the + state accordingly. + + For example: + - When we encounter a closing } for a bundle, we want to set + block_keyword from "bundle" to None + """ + assert self.mode != Mode.NONE + assert self.walking + + # Beginnings of blocks: + if node.type in ( + "bundle_block_keyword", + "body_block_keyword", + "promise_block_keyword", + ): + self.block_keyword = _text(node) + assert self.block_keyword in ("bundle", "body", "promise") + return + if node.type in ( + "bundle_block_type", + "body_block_type", + "promise_block_type", + ): + self.block_type = _text(node) + assert self.block_type + return + if node.type in ( + "bundle_block_name", + "body_block_name", + "promise_block_name", + ): + self.block_name = _text(node) + assert self.block_name + return - def update(self, node): - """Updates and returns the state that should apply to the children of `node`.""" - if node.type == "}": + # Update namespace inside body file control: + if ( + self.block_string() == "body file control" + and self.attribute_name == "namespace" + and node.type == "quoted_string" + ): + self.namespace = _text(node)[1:-1] + return + + # New promise type (bundle section) inside a bundle: + if node.type == "promise_guard": + self.promise_type = _text(node)[:-1] # strip trailing ':' + return + + if node.type == "attribute_name": + self.attribute_name = _text(node) + return + + # Attributes always end with ; in all 3 block types + if node.type == ";": + self.attribute_name = None + return + + # Clear things when ending a top level block: + if node.type == "}" and node.parent.type != "list": + assert self.attribute_name is None # Should already be cleared by ; assert node.parent assert node.parent.type in [ "bundle_block_body", "promise_block_body", "body_block_body", - "list", ] - if node.parent.type != "list": - # We just ended a block - self.block_type = None - self.promise_type = None - self.attribute_name = None - return - if node.type == ";": - self.attribute_name = None - return - if node.type == "bundle_block": - self.block_type = "bundle" - return - if node.type == "body_block": - self.block_type = "body" - return - if node.type == "promise_block": - self.block_type = "promise" + self.block_keyword = None + self.block_type = None + self.block_name = None + self.promise_type = None return - if node.type == "bundle_section": - # A bundle_section is always: promise_guard, [promises], [class_guarded_promises...] - # The promise_guard is guaranteed to exist by the grammar - guard = next((c for c in node.children if c.type == "promise_guard"), None) - if guard is None: # Should never happen - print("ERROR: Bundle section without a promise guard") - return - - self.promise_type = _text(guard)[:-1] # strip trailing ':' - return - if node.type == "attribute": - for child in node.children: - if child.type == "attribute_name": - self.attribute_name = _text(child) - if self.attribute_name == "namespace": - self.namespace = _text(child.next_named_sibling).strip("\"'") - return - return - @staticmethod - def qualify(name: str, namespace: str) -> str: - """If name is already qualified (contains ':'), return as-is. Otherwise prepend namespace.""" - return name if ":" in name else f"{namespace}:{name}" +class PolicyFile: + def __init__(self, filename): + self.filename = filename + tree, lines, original_data = _parse_policy_file(filename) + self.tree = tree + self.lines = lines + self.original_data = original_data -def lint_cfbs_json(filename) -> int: - assert os.path.isfile(filename) - assert filename.endswith("cfbs.json") + # Flatten tree so it is easier to iterate over: + self.nodes = [] - config = CFBSConfig.get_instance(filename=filename, non_interactive=True) - r = validate_config(config) + def visit(x): + self.nodes.append(x) + return 0 - if r == 0: - print(f"PASS: {filename}") + _walk_callback(tree.root_node, visit) + + +def _check_syntax(policy_file: PolicyFile) -> int: + assert state + assert state.mode == Mode.SYNTAX + filename = policy_file.filename + lines = policy_file.lines + errors = 0 + if not policy_file.tree.root_node.children: + print(f"Error: Empty policy file '{filename}'") + return 1 + + assert policy_file.tree.root_node.type == "source_file" + + state.start_file(policy_file) + for node in policy_file.nodes: + state.navigate(node) + _discover_node(node) + if node.type != "ERROR": + continue + line = node.range.start_point[0] + 1 + column = node.range.start_point[1] + 1 + _highlight_range(node, lines) + print(f"Error: Syntax error at {filename}:{line}:{column}") + errors += 1 + state.end_of_file() + return errors + + +def _discover_node(node) -> int: + assert state + # Define bodies: + if node.type == "body_block_name": + name = _text(node) + if name == "control": + return 0 # No need to define control blocks + state.add_body(name) + return 0 + + # Define bundles: + if node.type == "bundle_block_name": + name = _text(node) + state.add_bundle(name) return 0 - print(f"FAIL: {filename}") - return r + # Define custom promise types: + if node.type == "promise_block_name": + state.add_promise_type(_text(node)) + return 0 -def lint_json(filename) -> int: - assert os.path.isfile(filename) + return 0 - with open(filename, "r") as f: - data = f.read() - try: - data = json.loads(data) - except: - print(f"FAIL: {filename} (invalid JSON)") - return 1 - print(f"PASS: {filename}") +def _discover(policy_file: PolicyFile) -> int: + assert state + assert state.mode == Mode.DISCOVER + state.start_file(policy_file) + for node in policy_file.nodes: + state.navigate(node) + _discover_node(node) + state.end_of_file() return 0 +def _lint_node(node, policy_file): + return _node_checks(policy_file.filename, policy_file.lines, node) + + +def _lint(policy_file: PolicyFile) -> int: + assert state + assert state.mode == Mode.LINT + errors = 0 + state.start_file(policy_file) + for node in policy_file.nodes: + state.navigate(node) + errors += _lint_node(node, policy_file) + state.end_of_file() + if errors == 0: + print(f"PASS: {policy_file.filename}") + else: + print( + f"FAIL: {policy_file.filename} ({errors} error{'s' if errors > 1 else ''})" + ) + return errors + + +def _find_policy_files(args): + for arg in args: + if os.path.isdir(arg): + while arg.endswith(("/.", "/")): + arg = arg[0:-1] + for result in find(arg, extension=".cf"): + yield result + elif arg.endswith(".cf"): + yield arg + + +def _find_json_files(args): + for arg in args: + if os.path.isdir(arg): + for result in find(arg, extension=".json"): + yield result + elif arg.endswith(".json"): + yield arg + + +def filter_filenames(filenames): + for filename in filenames: + if "/out/" in filename or "/." in filename: + continue + if filename.startswith(".") and not filename.startswith("./"): + continue + if filename.startswith("out/"): + continue + yield filename + + +def _lint_main(args: list, strict: bool) -> int: + errors = 0 + + global state + state = State() + state.strict = strict + state.mode = Mode.SYNTAX + + json_filenames = filter_filenames(_find_json_files(args)) + policy_filenames = filter_filenames(_find_policy_files(args)) + + # TODO: JSON checking could be split into parse + # and additional checks for cfbs.json. + # The second step could happen after discovery for consistency. + for file in json_filenames: + errors += _lint_json(file) + + policy_files = [] + for filename in policy_filenames: + policy_file = PolicyFile(filename) + r = _check_syntax(policy_file) + errors += r + if r != 0: + print(f"FAIL: {filename} ({errors} error{'s' if errors > 1 else ''})") + continue + policy_files.append(policy_file) + if errors != 0: + return errors + state.mode = Mode.DISCOVER + + for policy_file in policy_files: + errors += _discover(policy_file) + + state.mode = Mode.LINT + + for policy_file in policy_files: + errors += _lint(policy_file) + + return errors + + +# TODO: Will remove this global in the future. +state = None + + def _highlight_range(node, lines): line = node.range.start_point[0] + 1 column = node.range.start_point[1] @@ -141,22 +397,21 @@ def _text(node): return node.text.decode() -def _walk_generic(filename, lines, node, visitor): - visitor(node) - for node in node.children: - _walk_generic(filename, lines, node, visitor) +def _walk_callback(node, callback) -> int: + assert node + assert callback - -def _find_node_type(filename, lines, node, node_type): - matches = [] - visitor = lambda x: matches.extend([x] if x.type == node_type else []) - _walk_generic(filename, lines, node, visitor) - return matches + errors = 0 + errors += callback(node) + for child in node.children: + _walk_callback(child, callback) + return errors -def _node_checks(filename, lines, node, user_definition, strict, state: _State): +def _node_checks(filename, lines, node): """Checks we run on each node in the syntax tree, utilizes state for checks which require context.""" + assert state line = node.range.start_point[0] + 1 column = node.range.start_point[1] + 1 if node.type == "attribute_name" and _text(node) == "ifvarclass": @@ -174,13 +429,10 @@ def _node_checks(filename, lines, node, user_definition, strict, state: _State): f"Deprecation: Promise type '{promise_type}' is deprecated at {filename}:{line}:{column}" ) return 1 - if strict and ( - ( - promise_type - not in BUILTIN_PROMISE_TYPES.union( - user_definition.get("custom_promise_types", set()) - ) - ) + if ( + state.strict + and promise_type not in BUILTIN_PROMISE_TYPES + and promise_type not in state.custom_promise_types ): _highlight_range(node, lines) print( @@ -209,113 +461,48 @@ def _node_checks(filename, lines, node, user_definition, strict, state: _State): ) return 1 if node.type == "calling_identifier": + name = _text(node) + qualified_name = _qualify(name, state.namespace) if ( - strict - and state.qualify(_text(node), state.namespace) - in user_definition.get("all_bundle_names", set()) - and state.promise_type in user_definition.get("custom_promise_types", set()) + state.strict + and qualified_name in state.bundles + and state.promise_type in state.custom_promise_types ): _highlight_range(node, lines) print( - f"Error: Call to bundle '{_text(node)}' inside custom promise: '{state.promise_type}' at {filename}:{line}:{column}" + f"Error: Call to bundle '{name}' inside custom promise: '{state.promise_type}' at {filename}:{line}:{column}" ) return 1 - if strict and ( - state.qualify(_text(node), state.namespace) - not in set.union( - user_definition.get("all_bundle_names", set()), - user_definition.get("all_body_names", set()), - ) - and _text(node) not in BUILTIN_FUNCTIONS + if state.strict and ( + qualified_name not in state.bundles + and qualified_name not in state.bodies + and name not in BUILTIN_FUNCTIONS ): _highlight_range(node, lines) print( - f"Error: Call to unknown function / bundle / body '{_text(node)}' at at {filename}:{line}:{column}" + f"Error: Call to unknown function / bundle / body '{name}' at at {filename}:{line}:{column}" ) return 1 return 0 -def _stateful_walk( - filename, lines, node, user_definition, strict, state: _State | None = None -) -> int: - if state is None: - state = _State() - - errors = _node_checks(filename, lines, node, user_definition, strict, state) - - state.update(node) - for child in node.children: - errors += _stateful_walk(filename, lines, child, user_definition, strict, state) - return errors - - -def _walk(filename, lines, node, user_definition=None, strict=True) -> int: - if user_definition is None: - user_definition = {} - - error_nodes = _find_node_type(filename, lines, node, "ERROR") - if error_nodes: - for node in error_nodes: - line = node.range.start_point[0] + 1 - column = node.range.start_point[1] + 1 - _highlight_range(node, lines) - print(f"Error: Syntax error at {filename}:{line}:{column}") - return len(error_nodes) - - line = node.range.start_point[0] + 1 - column = node.range.start_point[1] + 1 - - state = _State() - ret = _stateful_walk(filename, lines, node, user_definition, strict, state=state) - state = _State() # Clear state - return ret +def _stateful_walk(filename, lines, node) -> int: + assert state + errors = 0 + def visit(x): + nonlocal errors + assert state + state.navigate(node) + if state.mode == Mode.LINT: + errors += _node_checks(filename, lines, node) + return errors -def _parse_user_definition(filename, lines, root_node): - ns = "default" - promise_blocks = set() - bundle_blocks = set() - body_blocks = set() + return _walk_callback(node, visit) - for child in root_node.children: - if child.type == "body_block": - name_node = next( - (c for c in child.named_children if c.type == "body_block_name"), - None, - ) - ns_attr = next( - ( - c - for c in _find_node_type(filename, lines, child, "attribute_name") - if _text(c) == "namespace" - ), - None, - ) - if ns_attr is not None: - ns = _text(ns_attr.next_named_sibling).strip("\"'") - elif name_node is not None: - body_blocks.add(_State.qualify(_text(name_node), ns)) - elif child.type == "bundle_block": - name_node = next( - (c for c in child.named_children if c.type == "bundle_block_name"), - None, - ) - if name_node is not None: - bundle_blocks.add(_State.qualify(_text(name_node), ns)) - elif child.type == "promise_block": - name_node = next( - (c for c in child.named_children if c.type == "promise_block_name"), - None, - ) - if name_node is not None: - promise_blocks.add(_text(name_node)) - return { - "custom_promise_types": promise_blocks, - "all_bundle_names": bundle_blocks, - "all_body_names": body_blocks, - } +def _walk(filename, lines, node) -> int: + return _stateful_walk(filename, lines, node) def _parse_policy_file(filename): @@ -331,133 +518,166 @@ def _parse_policy_file(filename): return tree, lines, original_data -def lint_policy_file( +def _lint_policy_file_snippet( filename, - original_filename=None, - original_line=None, - snippet=None, - prefix=None, - user_definition=None, - strict=True, + original_filename, + original_line, + snippet, + prefix, ): - assert original_filename is None or type(original_filename) is str - assert original_line is None or type(original_line) is int - assert snippet is None or type(snippet) is int - if ( - original_filename is not None - or original_line is not None - or snippet is not None - ): - assert original_filename and os.path.isfile(original_filename) - assert original_line and original_line > 0 - assert snippet and snippet > 0 + assert state + assert prefix + assert type(original_filename) is str + assert type(original_line) is int + assert type(snippet) is int + assert type(prefix) is str + assert original_filename and os.path.isfile(original_filename) + assert original_line and original_line > 0 + assert snippet and snippet > 0 assert os.path.isfile(filename) assert filename.endswith((".cf", ".cfengine3", ".cf3", ".cf.sub")) - if user_definition is None: - user_definition = {} - tree, lines, original_data = _parse_policy_file(filename) root_node = tree.root_node - if root_node.type != "source_file": - if snippet: - assert original_filename and original_line - print( - f"Error: Failed to parse policy snippet {snippet} at '{original_filename}:{original_line}'" - ) - else: - print(f" Empty policy file '{filename}'") - print(" Is this valid CFEngine policy?") - print("") - lines = original_data.decode().split("\n") - if not len(lines) <= 5: - lines = lines[:4] + ["..."] - for line in lines: - print(" " + line) - print("") - return 1 assert root_node.type == "source_file" errors = 0 if not root_node.children: - if snippet: - assert original_filename and original_line - print( - f"Error: Empty policy snippet {snippet} at '{original_filename}:{original_line}'" - ) - else: - print(f"Error: Empty policy file '{filename}'") + assert original_filename and original_line + print( + f"Error: Empty policy snippet {snippet} at '{original_filename}:{original_line}'" + ) errors += 1 - errors += _walk(filename, lines, root_node, user_definition, strict) - if prefix: - print(prefix, end="") + state.start_file(filename) + errors += _walk(filename, lines, root_node) + state.end_of_file() + print(prefix, end="") if errors == 0: - if snippet: - assert original_filename and original_line - print( - f"PASS: Snippet {snippet} at '{original_filename}:{original_line}' (cf3)" - ) - else: - print(f"PASS: {filename}") + assert original_filename and original_line + print(f"PASS: Snippet {snippet} at '{original_filename}:{original_line}' (cf3)") return 0 - if snippet: - assert original_filename and original_line - print(f"FAIL: Snippet {snippet} at '{original_filename}:{original_line}' (cf3)") - else: - print(f"FAIL: {filename} ({errors} error{'s' if errors > 0 else ''})") + assert original_filename and original_line + print(f"FAIL: Snippet {snippet} at '{original_filename}:{original_line}' (cf3)") return errors -def lint_folder(folder, strict=True): +def _lint_policy_file( + filename, + prefix=None, +): + assert state + assert os.path.isfile(filename) + assert filename.endswith((".cf", ".cfengine3", ".cf3", ".cf.sub")) + + tree, lines, original_data = _parse_policy_file(filename) + root_node = tree.root_node + assert root_node.type == "source_file" errors = 0 - policy_files = [] - while folder.endswith(("/.", "/")): - folder = folder[0:-1] - for filename in itertools.chain( - find(folder, extension=".json"), find(folder, extension=".cf") - ): - if filename.startswith(("./.", "./out/", folder + "/.", folder + "/out/")): - continue - if filename.startswith(".") and not filename.startswith("./"): - continue + if not root_node.children: + print(f"Error: Empty policy file '{filename}'") + errors += 1 + state.start_file(filename) + errors += _walk(filename, lines, root_node) + state.end_of_file() + if prefix: + print(prefix, end="") + if errors == 0: + print(f"PASS: {filename}") + return 0 - if filename.endswith((".cf", ".cfengine3", ".cf3", ".cf.sub")): - policy_files.append(filename) - else: - errors += lint_single_file(filename) - - user_definition = {} - - # First pass: Gather custom types - for filename in policy_files if strict else []: - tree, lines, _ = _parse_policy_file(filename) - if tree.root_node.type == "source_file": - for key, val in _parse_user_definition( - filename, lines, tree.root_node - ).items(): - user_definition[key] = user_definition.get(key, set()).union(val) - - # Second pass: lint all policy files - for filename in policy_files: - errors += lint_policy_file( - filename, user_definition=user_definition, strict=strict - ) + print(f"FAIL: {filename} ({errors} error{'s' if errors > 1 else ''})") return errors -def lint_single_file(file, user_definition=None, strict=True): +def _lint_json(file): assert os.path.isfile(file) if file.endswith("/cfbs.json"): return lint_cfbs_json(file) - if file.endswith(".json"): - return lint_json(file) - assert file.endswith(".cf") - return lint_policy_file(file, user_definition=user_definition, strict=strict) + assert file.endswith(".json") + return lint_json(file) + + +def _discovery_file(filename): + assert state + assert state.mode == Mode.DISCOVER + + tree, lines, original_data = _parse_policy_file(filename) + root_node = tree.root_node + assert root_node.type == "source_file" + errors = 0 + errors += _lint_policy_file(filename) + # errors += _walk(filename, lines, root_node) + state.end_of_file() + return errors + + +# Interface: These are the functions we want to be called from outside +# They create State() and should not be called recursively inside lint.py + + +def lint_single_file(file, strict=True): + return _lint_main([file], strict) + + +def lint_args(args, strict=True) -> int: + return _lint_main(args, strict) + + +def lint_policy_file_snippet( + filename, + original_filename, + original_line, + snippet, + prefix, + strict=True, +): + global state + state = State() + state.strict = strict + state.mode = Mode.DISCOVER + errors = _discovery_file(filename) + if errors: + return errors + state.mode = Mode.LINT + if snippet: + return _lint_policy_file_snippet( + filename=filename, + original_filename=original_filename, + original_line=original_line, + snippet=snippet, + prefix=prefix, + ) + assert not snippet + assert not original_filename + assert not prefix + assert not original_line + return _lint_policy_file(filename=filename) + + +def lint_cfbs_json(filename) -> int: + assert os.path.isfile(filename) + assert filename.endswith("cfbs.json") + + config = CFBSConfig.get_instance(filename=filename, non_interactive=True) + r = validate_config(config) + if r == 0: + print(f"PASS: {filename}") + return 0 + print(f"FAIL: {filename}") + return r -def lint_single_arg(arg, strict=True): - if os.path.isdir(arg): - return lint_folder(arg, strict) - assert os.path.isfile(arg) - return lint_single_file(arg, strict=strict) +def lint_json(filename) -> int: + assert os.path.isfile(filename) + + with open(filename, "r") as f: + data = f.read() + + try: + data = json.loads(data) + except: + print(f"FAIL: {filename} (invalid JSON)") + return 1 + print(f"PASS: {filename}") + return 0 diff --git a/tests/format/002_basics.expected.cf b/tests/format/002_basics.expected.cf index 64a236f..9d0696d 100644 --- a/tests/format/002_basics.expected.cf +++ b/tests/format/002_basics.expected.cf @@ -29,4 +29,8 @@ bundle agent main if => "bar" # Comment at atttribute level string => "some_value"; + + classes: + # Comment before promise + "a" if => "b"; } diff --git a/tests/format/002_basics.input.cf b/tests/format/002_basics.input.cf index 6ac1977..54aafee 100644 --- a/tests/format/002_basics.input.cf +++ b/tests/format/002_basics.input.cf @@ -26,4 +26,7 @@ baz:: if => "bar" # Comment at atttribute level string => "some_value"; +classes: +# Comment before promise +"a" if => "b"; } diff --git a/tests/format/006_remove_empty_comments.expected.cf b/tests/format/006_remove_empty_comments.expected.cf new file mode 100644 index 0000000..3dd3d0a --- /dev/null +++ b/tests/format/006_remove_empty_comments.expected.cf @@ -0,0 +1,24 @@ +bundle agent main +{ + # comment + reports: + "hello"; +} + +bundle agent b +{ + # Some long comment here + # + # With more explanation here + reports: + "hello"; +} + +bundle agent c +# Some long comment here +# +# With more explanation here +{ + reports: + "hello"; +} diff --git a/tests/format/006_remove_empty_comments.input.cf b/tests/format/006_remove_empty_comments.input.cf new file mode 100644 index 0000000..43172d6 --- /dev/null +++ b/tests/format/006_remove_empty_comments.input.cf @@ -0,0 +1,30 @@ +bundle agent main +{ +# +# comment +reports: +"hello"; +} + + +bundle agent b +{ +# Some long comment here +# +# With more explanation here +# +reports: +"hello"; +} + + + +bundle agent c +# Some long comment here +# +# With more explanation here +# +{ +reports: +"hello"; +} diff --git a/tests/format/007_class_guarded_empty_lines.expected.cf b/tests/format/007_class_guarded_empty_lines.expected.cf new file mode 100644 index 0000000..d340c89 --- /dev/null +++ b/tests/format/007_class_guarded_empty_lines.expected.cf @@ -0,0 +1,11 @@ +bundle agent main +{ + vars: + hpux:: + "package_dir" + string => "$(sys.flavour)_$(sys.arch)"; + + !hpux:: + "package_dir" + string => "$(sys.class)_$(sys.arch)"; +} diff --git a/tests/format/007_class_guarded_empty_lines.input.cf b/tests/format/007_class_guarded_empty_lines.input.cf new file mode 100644 index 0000000..46487e2 --- /dev/null +++ b/tests/format/007_class_guarded_empty_lines.input.cf @@ -0,0 +1,10 @@ +bundle agent main +{ + vars: + hpux:: + "package_dir" + string => "$(sys.flavour)_$(sys.arch)"; + !hpux:: + "package_dir" + string => "$(sys.class)_$(sys.arch)"; +} diff --git a/tests/lint/002_ifvarclass.output.txt b/tests/lint/002_ifvarclass.expected.txt similarity index 67% rename from tests/lint/002_ifvarclass.output.txt rename to tests/lint/002_ifvarclass.expected.txt index af068bf..6ffa1cb 100644 --- a/tests/lint/002_ifvarclass.output.txt +++ b/tests/lint/002_ifvarclass.expected.txt @@ -3,5 +3,5 @@ ifvarclass => "cfengine"; ^--------^ Deprecation: Use 'if' instead of 'ifvarclass' at tests/lint/002_ifvarclass.x.cf:5:7 -FAIL: tests/lint/002_ifvarclass.x.cf (1 errors) -Failure, 1 errors in total. +FAIL: tests/lint/002_ifvarclass.x.cf (1 error) +Failure, 1 error in total. diff --git a/tests/lint/003_deprecated_promise_type.output.txt b/tests/lint/003_deprecated_promise_type.expected.txt similarity index 59% rename from tests/lint/003_deprecated_promise_type.output.txt rename to tests/lint/003_deprecated_promise_type.expected.txt index 94f2fbd..36f78b3 100644 --- a/tests/lint/003_deprecated_promise_type.output.txt +++ b/tests/lint/003_deprecated_promise_type.expected.txt @@ -3,5 +3,5 @@ defaults: ^-------^ Deprecation: Promise type 'defaults' is deprecated at tests/lint/003_deprecated_promise_type.x.cf:3:3 -FAIL: tests/lint/003_deprecated_promise_type.x.cf (1 errors) -Failure, 1 errors in total. +FAIL: tests/lint/003_deprecated_promise_type.x.cf (1 error) +Failure, 1 error in total. diff --git a/tests/lint/004_bundle_name_lowercase.output.txt b/tests/lint/004_bundle_name_lowercase.expected.txt similarity index 61% rename from tests/lint/004_bundle_name_lowercase.output.txt rename to tests/lint/004_bundle_name_lowercase.expected.txt index 7e5e3f5..484d04e 100644 --- a/tests/lint/004_bundle_name_lowercase.output.txt +++ b/tests/lint/004_bundle_name_lowercase.expected.txt @@ -2,5 +2,5 @@ bundle agent MyBundle ^------^ Convention: Bundle name should be lowercase at tests/lint/004_bundle_name_lowercase.x.cf:1:14 -FAIL: tests/lint/004_bundle_name_lowercase.x.cf (1 errors) -Failure, 1 errors in total. +FAIL: tests/lint/004_bundle_name_lowercase.x.cf (1 error) +Failure, 1 error in total. diff --git a/tests/lint/005_bundle_type.output.txt b/tests/lint/005_bundle_type.expected.txt similarity index 72% rename from tests/lint/005_bundle_type.output.txt rename to tests/lint/005_bundle_type.expected.txt index a1f4412..89b1b44 100644 --- a/tests/lint/005_bundle_type.output.txt +++ b/tests/lint/005_bundle_type.expected.txt @@ -2,5 +2,5 @@ bundle notavalidtype my_bundle ^-----------^ Error: Bundle type must be one of (agent, common, monitor, server, edit_line, edit_xml), not 'notavalidtype' at tests/lint/005_bundle_type.x.cf:1:8 -FAIL: tests/lint/005_bundle_type.x.cf (1 errors) -Failure, 1 errors in total. +FAIL: tests/lint/005_bundle_type.x.cf (1 error) +Failure, 1 error in total. diff --git a/tests/lint/006_syntax_error.output.txt b/tests/lint/006_syntax_error.expected.txt similarity index 51% rename from tests/lint/006_syntax_error.output.txt rename to tests/lint/006_syntax_error.expected.txt index 5e55fa8..de31969 100644 --- a/tests/lint/006_syntax_error.output.txt +++ b/tests/lint/006_syntax_error.expected.txt @@ -3,5 +3,5 @@ reports ^-----^ Error: Syntax error at tests/lint/006_syntax_error.x.cf:3:3 -FAIL: tests/lint/006_syntax_error.x.cf (1 errors) -Failure, 1 errors in total. +FAIL: tests/lint/006_syntax_error.x.cf (1 error) +Failure, 1 error in total. diff --git a/tests/lint/007_empty_file.expected.txt b/tests/lint/007_empty_file.expected.txt new file mode 100644 index 0000000..8d17cd1 --- /dev/null +++ b/tests/lint/007_empty_file.expected.txt @@ -0,0 +1,3 @@ +Error: Empty policy file 'tests/lint/007_empty_file.x.cf' +FAIL: tests/lint/007_empty_file.x.cf (1 error) +Failure, 1 error in total. diff --git a/tests/lint/007_empty_file.output.txt b/tests/lint/007_empty_file.output.txt deleted file mode 100644 index f4ab399..0000000 --- a/tests/lint/007_empty_file.output.txt +++ /dev/null @@ -1,3 +0,0 @@ -Error: Empty policy file 'tests/lint/007_empty_file.x.cf' -FAIL: tests/lint/007_empty_file.x.cf (1 errors) -Failure, 1 errors in total. diff --git a/tests/lint/008_namespace.cf b/tests/lint/008_namespace.cf new file mode 100644 index 0000000..301e8e1 --- /dev/null +++ b/tests/lint/008_namespace.cf @@ -0,0 +1,16 @@ +body file control +{ + namespace => "mylib"; +} + +bundle agent target(a) +{ + reports: + "Hello, $(a)"; +} + +bundle agent helper +{ + vars: + "x" string => mylib:target("arg"); +} diff --git a/tests/lint/008_namespace.expected.txt b/tests/lint/008_namespace.expected.txt new file mode 100644 index 0000000..c4ce8ac --- /dev/null +++ b/tests/lint/008_namespace.expected.txt @@ -0,0 +1,7 @@ + + vars: + "x" string => default:target("arg"); + ^------------^ +Error: Call to unknown function / bundle / body 'default:target' at at tests/lint/008_namespace.x.cf:15:19 +FAIL: tests/lint/008_namespace.x.cf (1 error) +Failure, 1 error in total. diff --git a/tests/lint/008_namespace.x.cf b/tests/lint/008_namespace.x.cf new file mode 100644 index 0000000..1876c0a --- /dev/null +++ b/tests/lint/008_namespace.x.cf @@ -0,0 +1,16 @@ +body file control +{ + namespace => "mylib"; +} + +bundle agent target(a) +{ + reports: + "Hello, $(a)"; +} + +bundle agent helper +{ + vars: + "x" string => default:target("arg"); +} diff --git a/tests/run-lint-tests.sh b/tests/run-lint-tests.sh index bf4deb4..624f6ff 100644 --- a/tests/run-lint-tests.sh +++ b/tests/run-lint-tests.sh @@ -21,12 +21,12 @@ for file in tests/lint/*.cf; do # - Fail (non-zero exit code) # - Output the correct error message - expected="$(echo $file | sed 's/\.x\.cf$/.output.txt/')" + expected="$(echo $file | sed 's/\.x\.cf$/.expected.txt/')" if [ ! -f "$expected" ]; then echo "FAIL: Missing expected output file: $expected" exit 1 fi - output="tmp/$(basename $file .x.cf).lint-output.txt" + output="tests/lint/$(basename $file .x.cf).output.txt" if cfengine lint "$file" > "$output" 2>&1; then echo "FAIL: $file - expected lint failure but got success" exit 1 diff --git a/tests/unit/test_format.py b/tests/unit/test_format.py new file mode 100644 index 0000000..b0b346b --- /dev/null +++ b/tests/unit/test_format.py @@ -0,0 +1,70 @@ +from cfengine_cli.format import stringify_parameter_list, stringify_single_line_nodes + + +class MockNode: + """Minimal stand-in for a tree-sitter Node used by stringify_single_line_nodes.""" + + def __init__(self, node_type, node_text=None, children=None): + self.type = node_type + self.text = node_text.encode("utf-8") if node_text is not None else None + self.children = children or [] + + +def _leaf(node_type, node_text=None): + return MockNode(node_type, node_text or node_type) + + +def test_stringify_parameter_list(): + assert stringify_parameter_list([]) == "" + assert stringify_parameter_list(["foo"]) == "foo" + assert stringify_parameter_list(["(", "a", ")"]) == "(a)" + assert stringify_parameter_list(["(", "a", ",", "b", ")"]) == "(a, b)" + assert stringify_parameter_list(["(", "a", ",", ")"]) == "(a)" + assert stringify_parameter_list(["(", "a", ",", "b", ",", ")"]) == "(a, b)" + assert stringify_parameter_list(["a", "b", "c"]) == "a b c" + assert stringify_parameter_list(["a", ",", "b"]) == "a, b" + assert stringify_parameter_list(["(", ")"]) == "()" + parts = ["(", "x", ",", "y", ",", "z", ")"] + assert stringify_parameter_list(parts) == "(x, y, z)" + + +def test_stringify_single_line_nodes(): + assert stringify_single_line_nodes([]) == "" + assert stringify_single_line_nodes([_leaf("identifier", "foo")]) == "foo" + + nodes = [_leaf("string", '"a"'), _leaf(","), _leaf("string", '"b"')] + assert stringify_single_line_nodes(nodes) == '"a", "b"' + + nodes = [_leaf("identifier", "lval"), _leaf("=>"), _leaf("string", '"rval"')] + assert stringify_single_line_nodes(nodes) == 'lval => "rval"' + + nodes = [_leaf("("), _leaf("identifier", "x"), _leaf(")")] + assert stringify_single_line_nodes(nodes) == "(x)" + + nodes = [ + _leaf("{"), + _leaf("string", '"a"'), + _leaf(","), + _leaf("string", '"b"'), + _leaf("}"), + ] + assert stringify_single_line_nodes(nodes) == '{"a", "b"}' + nodes = [ + _leaf("identifier", "package_name"), + _leaf("=>"), + _leaf("string", '"nginx"'), + ] + + assert stringify_single_line_nodes(nodes) == 'package_name => "nginx"' + inner = MockNode( + "call", + children=[ + _leaf("calling_identifier", "func"), + _leaf("("), + _leaf("string", '"arg"'), + _leaf(")"), + ], + ) + + nodes = [_leaf("identifier", "x"), _leaf("=>"), inner] + assert stringify_single_line_nodes(nodes) == 'x => func("arg")'