Repository: ansible/ansible-lint (language: python)

Issue:
Tasks with attributes formatted as key=value pairs get reformatted incorrectly
<!--- Verify first that your issue is not already reported on GitHub -->
<!--- Also test if the latest release and main branch are affected too -->

##### Summary

`ansible-lint --fix` incorrectly reformats tasks with attributes originally formatted as `key=value` pairs by splitting the fact's value and adding the second part to an invalid `cmd` attribute.

##### Issue Type

- Bug Report

##### OS / ENVIRONMENT

<!--- Paste verbatim output between triple backticks -->

```console (paste below)
ansible-lint 6.22.2 using ansible-core:2.14.9 ansible-compat:4.1.11 ruamel-yaml:None ruamel-yaml-clib:None
```

<!--- Provide all relevant information below, e.g. target OS versions, network
 device firmware, etc. -->

- ansible installation method: pip
- ansible-lint installation method: pip

##### STEPS TO REPRODUCE

<!--- Describe exactly how to reproduce the problem, using a minimal test case -->

<!--- Paste example playbooks or commands between triple backticks below -->

Run `ansible-lint --fix` on the following test playbook:

```yaml
---
- name: Test set_fact
  hosts: all
  tasks:
    - name: Set boolean fact
      ansible.builtin.set_fact: foo=true

    - name: Set string fact with spaces
      ansible.builtin.set_fact: foo="String with spaces"

    - name: File task
      ansible.builtin.file: path=/tmp/config state=directory mode=0755

```

<!--- HINT: You can paste gist.github.com links for larger files -->

##### Desired Behavior

<!--- Describe what you expected to happen when running the steps above -->

Resulting formatted playbook should be:

```yaml
- name: Test set_fact
  hosts: all
  tasks:
    - name: Set boolean fact
      ansible.builtin.set_fact:
        foo: "True"

    - name: Set string fact with spaces
      ansible.builtin.set_fact:
        foo: "String with spaces"

    - name: File task
      ansible.builtin.file:
        path: /tmp/config
        state: directory
        mode: "0755"
```

##### Actual Behavior

<!--- Describe what happened. If possible run with extra verbosity (-vvvv) -->
Actual resulting formatted playbook:

```yaml
---
- name: Test set_fact
  hosts: all
  tasks:
    - name: Set boolean fact
      ansible.builtin.set_fact:

        foo: "True"
        cmd: ""
    - name: Set string fact with spaces
      ansible.builtin.set_fact:

        foo: '"String'
        cmd: with spaces"
    - name: File task
      ansible.builtin.file:

        path: /tmp/config
        state: directory
        mode: "0755"
        cmd: ""

```
Problems:
- The boolean `set_fact` and `file` tasks have an empty `cmd` attribute added to them
- The string `set_fact` has everything in the fact string after the first space put in a `cmd` attribute



Discussion / hints:

Fix patch:
diff --git a/examples/playbooks/transform-no-free-form.transformed.yml b/examples/playbooks/transform-no-free-form.transformed.yml
index d9e238e249..e947c345af 100644
--- a/examples/playbooks/transform-no-free-form.transformed.yml
+++ b/examples/playbooks/transform-no-free-form.transformed.yml
@@ -8,6 +8,12 @@
         cmd: touch foo
       changed_when: false
 
+    - name: Create a placefolder file
+      ansible.builtin.command: # <-- command can also go first
+        chdir: /tmp
+        cmd: touch bar
+      changed_when: false
+
     - name: Use raw to echo
       ansible.builtin.raw: echo foo # <-- don't use executable=
       args:
@@ -17,3 +23,8 @@
     - name: Example task with usage for '=' as module params
       ansible.builtin.debug:
         msg: "'Hello there world'"
+      changed_when: false
+
+    - name: Task that has a non-debug string with spaces
+      ansible.builtin.set_fact:
+        foo: '"String with spaces"'
diff --git a/examples/playbooks/transform-no-free-form.yml b/examples/playbooks/transform-no-free-form.yml
index 54cb266b12..c57da0ce3e 100644
--- a/examples/playbooks/transform-no-free-form.yml
+++ b/examples/playbooks/transform-no-free-form.yml
@@ -6,9 +6,17 @@
       ansible.builtin.command: chdir=/tmp touch foo # <-- don't use shorthand
       changed_when: false
 
+    - name: Create a placefolder file
+      ansible.builtin.command: touch bar chdir=/tmp # <-- command can also go first
+      changed_when: false
+
     - name: Use raw to echo
       ansible.builtin.raw: executable=/bin/bash echo foo # <-- don't use executable=
       changed_when: false
 
     - name: Example task with usage for '=' as module params
       ansible.builtin.debug: msg='Hello there world'
+      changed_when: false
+
+    - name: Task that has a non-debug string with spaces
+      ansible.builtin.set_fact: foo="String with spaces"
diff --git a/src/ansiblelint/rules/no_free_form.py b/src/ansiblelint/rules/no_free_form.py
index 8b3a3884af..13489efa3d 100644
--- a/src/ansiblelint/rules/no_free_form.py
+++ b/src/ansiblelint/rules/no_free_form.py
@@ -80,7 +80,7 @@ def matchtask(
                 "win_command",
                 "win_shell",
             ):
-                if self.cmd_shell_re.match(action_value):
+                if self.cmd_shell_re.search(action_value):
                     fail = True
             else:
                 fail = True
@@ -107,14 +107,36 @@ def filter_values(
                 val: str,
                 filter_key: str,
                 filter_dict: dict[str, Any],
-            ) -> bool:
-                """Return True if module option is not present in the string."""
+            ) -> str:
+                """Pull out key=value pairs from a string and set them in filter_dict.
+
+                Returns unmatched strings.
+                """
                 if filter_key not in val:
-                    return True
+                    return val
+
+                extra = ""
+                [k, v] = val.split(filter_key, 1)
+                if " " in k:
+                    extra, k = k.rsplit(" ", 1)
+
+                if v[0] in "\"'":
+                    # Keep quoted strings together
+                    quote = v[0]
+                    _, v, remainder = v.split(quote, 2)
+                    v = f"{quote}{v}{quote}"
+                else:
+                    try:
+                        v, remainder = v.split(" ", 1)
+                    except ValueError:
+                        remainder = ""
 
-                [k, v] = val.split(filter_key)
                 filter_dict[k] = v
-                return False
+
+                extra = " ".join(
+                    (extra, filter_values(remainder, filter_key, filter_dict)),
+                )
+                return extra.strip()
 
             if match.tag == "no-free-form":
                 module_opts: dict[str, Any] = {}
@@ -122,18 +144,9 @@ def filter_values(
                     k, v = task.popitem(False)
                     # identify module as key and process its value
                     if len(k.split(".")) == 3 and isinstance(v, str):
-                        # if it is a message
-                        if "msg" in v:
-                            filter_values(v, "=", module_opts)
-                        else:
-                            # Filter the module options and command
-                            module_opts["cmd"] = " ".join(
-                                [
-                                    item
-                                    for item in v.split(" ")
-                                    if filter_values(item, "=", module_opts)
-                                ],
-                            )
+                        cmd = filter_values(v, "=", module_opts)
+                        if cmd:
+                            module_opts["cmd"] = cmd
 
                         sorted_module_opts = {}
                         for key in sorted(


Test patch:
diff --git a/test/test_transformer.py b/test/test_transformer.py
index 0767f4b7e1..51e97d5f4a 100644
--- a/test/test_transformer.py
+++ b/test/test_transformer.py
@@ -143,7 +143,7 @@ def fixture_runner_result(
         ),
         pytest.param(
             "examples/playbooks/transform-no-free-form.yml",
-            3,
+            5,
             True,
             True,
             id="no_free_form_transform",