Fixing bug where comment added to multiline attribute was added as suffix (#1434)

- Also implementing tests for cmdComment

Co-authored-by: Tim Malmström <oreflow@google.com>
diff --git a/build/BUILD.bazel b/build/BUILD.bazel
index 2b7fcf0..e1d3f6f 100644
--- a/build/BUILD.bazel
+++ b/build/BUILD.bazel
@@ -39,6 +39,7 @@
         "quote_test.go",
         "rewrite_test.go",
         "rule_test.go",
+        "utils_test.go",
         "walk_test.go",
     ],
     data = glob(["testdata/*"]) + [
diff --git a/build/utils.go b/build/utils.go
index 1e7c2b9..c0b2bd3 100644
--- a/build/utils.go
+++ b/build/utils.go
@@ -79,3 +79,9 @@
 		return []string{}
 	}
 }
+
+// IsMultiLine returns whether an Expr is multiline or not.
+func IsMultiLine(param Expr) bool {
+	start, end := param.Span()
+	return start.Line != end.Line
+}
diff --git a/build/utils_test.go b/build/utils_test.go
new file mode 100644
index 0000000..8edba18
--- /dev/null
+++ b/build/utils_test.go
@@ -0,0 +1,122 @@
+/*
+Copyright 2025 Google LLC
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package build
+
+import (
+	"testing"
+)
+
+func TestIsMultiline(t *testing.T) {
+	var tests = []struct {
+		name            string
+		buildFile       string
+		exprToCheck     func(Expr) bool
+		wantIsMultiline bool
+	}{
+		{
+			name: "single_line_call",
+			exprToCheck: func(expr Expr) bool {
+				if call, ok := expr.(*CallExpr); ok {
+					if calledIdent, ok := call.X.(*Ident); ok && calledIdent.Name == "foo" {
+						return true
+					}
+				}
+				return false
+			},
+			buildFile: `
+	foo(name = "bar")`,
+			wantIsMultiline: false,
+		},
+		{
+			name: "multiline_call",
+			exprToCheck: func(expr Expr) bool {
+				if call, ok := expr.(*CallExpr); ok {
+					if calledIdent, ok := call.X.(*Ident); ok && calledIdent.Name == "foo" {
+						return true
+					}
+				}
+				return false
+			},
+			buildFile: `
+	foo(
+		name = "bar",
+	)`,
+			wantIsMultiline: true,
+		},
+		{
+			name: "single_line_attribute",
+			exprToCheck: func(expr Expr) bool {
+				if assign, ok := expr.(*AssignExpr); ok {
+					if calledIdent, ok := assign.LHS.(*Ident); ok && calledIdent.Name == "foo_attr" {
+						return true
+					}
+				}
+				return false
+			},
+			buildFile: `
+	foo(
+		name = "bar",
+		foo_attr = "on_a_single_line",
+	)`,
+			wantIsMultiline: false,
+		},
+		{
+			name: "multi_line_attribute",
+			exprToCheck: func(expr Expr) bool {
+				if assign, ok := expr.(*AssignExpr); ok {
+					if calledIdent, ok := assign.LHS.(*Ident); ok && calledIdent.Name == "foo_attr" {
+						return true
+					}
+				}
+				return false
+			},
+			buildFile: `
+	foo(
+		name = "bar",
+		foo_attr = [
+		    "attribute",
+			"which spans",
+			"multiple lines",
+		],
+	)`,
+			wantIsMultiline: true,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			bld, err := Parse("BUILD", []byte(tc.buildFile))
+			if err != nil {
+				t.Fatal(err)
+			}
+			var testedExpr *Expr
+			Walk(bld, func(x Expr, _ []Expr) {
+				if tc.exprToCheck(x) {
+					testedExpr = &x
+				}
+			})
+			if testedExpr == nil {
+				t.Fatal("Unable to find expression to test")
+			}
+
+			got := IsMultiLine(*testedExpr)
+			if got != tc.wantIsMultiline {
+				t.Fatalf("IsMultiline returned incorrect value, got: %t, expected: %t", got, tc.wantIsMultiline)
+			}
+		})
+	}
+}
diff --git a/edit/buildozer.go b/edit/buildozer.go
index 5527d65..d6eb110 100644
--- a/edit/buildozer.go
+++ b/edit/buildozer.go
@@ -117,7 +117,7 @@
 		env.Rule.Call.Comments.Before = comment
 	case 2: // Attach to an attribute
 		if attr := env.Rule.AttrDefn(env.Args[0]); attr != nil {
-			if fullLine {
+			if fullLine || build.IsMultiLine(attr) {
 				attr.LHS.Comment().Before = comment
 			} else {
 				attr.RHS.Comment().Suffix = comment
diff --git a/edit/buildozer_test.go b/edit/buildozer_test.go
index c1814a4..f80a4a0 100644
--- a/edit/buildozer_test.go
+++ b/edit/buildozer_test.go
@@ -123,6 +123,124 @@
 	}
 }
 
+func TestCmdComment(t *testing.T) {
+	var tests = []struct {
+		name      string
+		args      []string
+		buildFile string
+		want      string
+	}{
+		{
+			name: "adds_comment_to_rule_call",
+			args: []string{"New Comment"},
+			buildFile: `foo(
+    name = "foo",
+)`,
+			want: `# New Comment
+foo(
+    name = "foo",
+)`,
+		}, {
+			name: "adds_comment_to_single_line_attribute",
+			args: []string{"deps", "New Comment"},
+			buildFile: `foo(
+    name = "foo",
+    deps = ["//some/dep"],
+)`,
+			want: `foo(
+    name = "foo",
+    deps = ["//some/dep"],  # New Comment
+)`,
+		}, {
+			name: "adds_multiline_comment_to_single_line_attribute",
+			args: []string{"deps", "New Comment\nWith Multiple Lines"},
+			buildFile: `foo(
+    name = "foo",
+    deps = ["//some/dep"],
+)`,
+			want: `foo(
+    name = "foo",
+    # New Comment
+    # With Multiple Lines
+    deps = ["//some/dep"],
+)`,
+		}, {
+			name: "adds_comment_to_multiline_attribute",
+			args: []string{"deps", "New Comment"},
+			buildFile: `foo(
+    name = "foo",
+    deps = [
+	    "//some/dep",
+	    "//some/other/dep",
+    ],
+)`,
+			want: `foo(
+    name = "foo",
+    # New Comment
+    deps = [
+        "//some/dep",
+        "//some/other/dep",
+    ],
+)`,
+		}, {
+			name: "adds_comment_to_element_in_list",
+			args: []string{"deps", "//some/other/dep", "New Comment"},
+			buildFile: `foo(
+    name = "foo",
+    deps = [
+	    "//some/dep",
+	    "//some/other/dep",
+    ],
+)`,
+			want: `foo(
+    name = "foo",
+    deps = [
+        "//some/dep",
+        "//some/other/dep",  # New Comment
+    ],
+)`,
+		}, {
+			name: "adds_multiline_comment_to_element_in_list",
+			args: []string{"deps", "//some/other/dep", "New Comment\nWith Multiple Lines"},
+			buildFile: `foo(
+    name = "foo",
+    deps = [
+	    "//some/dep",
+	    "//some/other/dep",
+    ],
+)`,
+			want: `foo(
+    name = "foo",
+    deps = [
+        "//some/dep",
+        # New Comment
+        # With Multiple Lines
+        "//some/other/dep",
+    ],
+)`,
+		},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			bld, err := build.Parse("BUILD", []byte(tc.buildFile))
+			if err != nil {
+				t.Error(err)
+			}
+			rl := bld.Rules("foo")[0]
+			env := CmdEnvironment{
+				File: bld,
+				Rule: rl,
+				Args: tc.args,
+			}
+			bld, _ = cmdComment(NewOpts(), env)
+			got := strings.TrimSpace(string(build.Format(bld)))
+			if diff := cmp.Diff(got, tc.want); diff != "" {
+				t.Fatalf("cmdComment returned unexpected diff %s", diff)
+			}
+		})
+	}
+}
+
 type targetExpressionToBuildFilesTestCase struct {
 	rootDir, target string
 	buildFiles      []string