Repository: air-verse/air
Pull request #749: Fix: Properly handle paths with spaces in generated command
Author: login: pkashin
Created: 2025-03-03T09:03:51Z | Merged: 2025-03-05T14:54:23Z

- Fixes incorrect command generation when the binary path contains spaces (issue #666).
- Wraps paths with spaces in quotes to prevent shell splitting.
- Adds OS-specific handling for Windows (uses `& "..."` syntax).

**Changes:**
1. Added `formatPath()` function in `runner/util.go` to:
   - Quote absolute paths with spaces.
   - Apply Windows-specific syntax when needed.
2. Integrated `formatPath()` into command generation in `runner/engine.go`.
3. Added comprehensive tests in `runner/util_test.go` covering:
   - Windows/Unix absolute/relative paths with/without spaces.
   - Edge cases (empty path, simple filename).

**Testing:**
- Unit tests verify correct behavior across platforms.
- Manually tested on [Windows 11/macOS 15] with paths containing spaces.

**Notes:**
- This ensures commands like `C:\Program Files\app.exe` or `/usr/local/my app/main` execute correctly.

Closes #666

Linked issue #666: air doesn't read the spaces between names of directories
 #https://github.com/air-verse/air/issues/630#event-14693459937
check above thread for more info

Diff:
diff --git a/runner/engine.go b/runner/engine.go
index ce2fa843..ce5fdd1e 100644
--- a/runner/engine.go
+++ b/runner/engine.go
@@ -591,7 +591,8 @@ func (e *Engine) runBin() error {
 			case <-killCh:
 				return
 			default:
-				command := strings.Join(append([]string{e.config.Build.Bin}, e.runArgs...), " ")
+				formattedBin := formatPath(e.config.Build.Bin)
+				command := strings.Join(append([]string{formattedBin}, e.runArgs...), " ")
 				cmd, stdout, stderr, err := e.startCmd(command)
 				if err != nil {
 					e.mainLog("failed to start %s, error: %s", e.config.rel(e.config.binPath()), err.Error())
diff --git a/runner/util.go b/runner/util.go
index 03d32afe..79700b92 100644
--- a/runner/util.go
+++ b/runner/util.go
@@ -429,3 +429,17 @@ func joinPath(root, path string) string {
 
 	return filepath.Join(root, path)
 }
+
+func formatPath(path string) string {
+	if !filepath.IsAbs(path) || !strings.Contains(path, " ") {
+		return path
+	}
+
+	quotedPath := fmt.Sprintf(`"%s"`, path)
+
+	if runtime.GOOS == PlatformWindows {
+		return fmt.Sprintf(`& %s`, quotedPath)
+	}
+
+	return quotedPath
+}
diff --git a/runner/util_test.go b/runner/util_test.go
index 3c18cf36..b526f05c 100644
--- a/runner/util_test.go
+++ b/runner/util_test.go
@@ -318,3 +318,87 @@ func TestJoinPathAbsolute(t *testing.T) {
 
 	assert.Equal(t, result, path)
 }
+
+func TestFormatPath(t *testing.T) {
+	type testCase struct {
+		name     string
+		path     string
+		expected string
+	}
+
+	runTests := func(t *testing.T, tests []testCase) {
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				result := formatPath(tt.path)
+				if result != tt.expected {
+					t.Errorf("formatPath(%q) = %q, want %q", tt.path, result, tt.expected)
+				}
+			})
+		}
+	}
+
+	t.Run("PathPlatformSpecific", func(t *testing.T) {
+		if runtime.GOOS == PlatformWindows {
+			// Windows-specific tests
+			tests := []testCase{
+				{
+					name:     "Windows style absolute path with spaces",
+					path:     `C:\My Documents\My Project\tmp\app.exe`,
+					expected: `& "C:\My Documents\My Project\tmp\app.exe"`,
+				},
+				{
+					name:     "Windows style relative path with spaces",
+					path:     `My Project\tmp\app.exe`,
+					expected: `My Project\tmp\app.exe`,
+				},
+				{
+					name:     "Windows style absolute path without spaces",
+					path:     `C:\Documents\Project\tmp\app.exe`,
+					expected: `C:\Documents\Project\tmp\app.exe`,
+				},
+			}
+			runTests(t, tests)
+		} else {
+			// Unix-specific tests
+			tests := []testCase{
+				{
+					name:     "Unix style absolute path with spaces",
+					path:     `/usr/local/my project/tmp/main`,
+					expected: `"/usr/local/my project/tmp/main"`,
+				},
+				{
+					name:     "Unix style relative path with spaces",
+					path:     "./my project/tmp/main",
+					expected: "./my project/tmp/main",
+				},
+				{
+					name:     "Unix style absolute path without spaces",
+					path:     `/usr/local/project/tmp/main`,
+					expected: `/usr/local/project/tmp/main`,
+				},
+			}
+			runTests(t, tests)
+		}
+	})
+
+	t.Run("CommonCases", func(t *testing.T) {
+		tests := []testCase{
+			{
+				name:     "Empty path",
+				path:     "",
+				expected: "",
+			},
+			{
+				name:     "Simple path",
+				path:     "main.go",
+				expected: "main.go",
+			},
+			{
+				name:     "TestShouldIncludeIncludedFile",
+				path:     "sh main.sh",
+				expected: "sh main.sh",
+			},
+		}
+		runTests(t, tests)
+	})
+}


Reviews:
user: login: xiantang
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-04T14:13:21Z
user: login: pkashin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T14:37:16Z
user: login: pkashin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T15:05:49Z
user: login: xiantang
body: LGTM
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-05T14:54:15Z

Review comments:
diff_hunk: @@ -429,3 +429,17 @@ func joinPath(root, path string) string {
 
 	return filepath.Join(root, path)
 }
+
+func formatPath(path string) string {
+	if !filepath.IsAbs(path) || !strings.Contains(path, " ") {
+		return path
+	}
+
+	quotedPath := fmt.Sprintf(`"%s"`, path)
+
+	if runtime.GOOS == PlatformWindows {
+		return fmt.Sprintf(`& %s`, quotedPath)
path: runner/util.go
user: login: xiantang
body: im not using Windows, what does `&` mean? 
created_at: 2025-03-04T14:13:21Z
author_association: COLLABORATOR
diff_hunk: @@ -429,3 +429,17 @@ func joinPath(root, path string) string {
 
 	return filepath.Join(root, path)
 }
+
+func formatPath(path string) string {
+	if !filepath.IsAbs(path) || !strings.Contains(path, " ") {
+		return path
+	}
+
+	quotedPath := fmt.Sprintf(`"%s"`, path)
+
+	if runtime.GOOS == PlatformWindows {
+		return fmt.Sprintf(`& %s`, quotedPath)
path: runner/util.go
user: login: pkashin
body: Adding `&` before the path in quotation marks ("path") is necessary for PowerShell or the Windows command prompt to correctly interpret the path with spaces as a command to execute. Without `&`, a path with spaces can be interpreted as a string, not as a command.
Usage examples:
1. Running an executable file with spaces in the path:
If the path to the executable file contains spaces, PowerShell will not be able to execute it without `&`. 
```powershell
& "C:\Program Files\MyApp\myapp.exe"
```
2. Running commands with arguments:
You can use & to run commands with arguments:
```powershell
& "C:\Program Files\MyApp\myapp.exe" --arg1 value1
```
created_at: 2025-03-04T14:37:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -429,3 +429,17 @@ func joinPath(root, path string) string {
 
 	return filepath.Join(root, path)
 }
+
+func formatPath(path string) string {
+	if !filepath.IsAbs(path) || !strings.Contains(path, " ") {
+		return path
+	}
+
+	quotedPath := fmt.Sprintf(`"%s"`, path)
+
+	if runtime.GOOS == PlatformWindows {
+		return fmt.Sprintf(`& %s`, quotedPath)
path: runner/util.go
user: login: pkashin
body: Notes:
1. If the path to an executable file or script is called without `&`, PowerShell interprets it as a string, not as a command. For example:
```powershell
"C:\Program Files\MyApp\myapp.exe"
```
Result:
It just returns the string `C:\Program Files\MyApp\myapp.exe` , but the command is not executed.

2. If the path contains spaces and is not enclosed in quotation marks, PowerShell or the command line will not be able to process it correctly. For example:
```powershell
C:\Program Files\MyApp\myapp.exe
```
Result:
PowerShell will try to interpret `C:\Program` As a command, eh `Files\MyApp\myapp.exe` as an argument, which will lead to an error.

3. I ran the tests manually locally on Windows, then the coverage reaches 100% for the `formatPath()` function.
created_at: 2025-03-04T15:05:49Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-03-03T12:13:30Z
body: ## [Codecov](https://app.codecov.io/gh/air-verse/air/pull/749?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
Attention: Patch coverage is `75.00000%` with `3 lines` in your changes missing coverage. Please review.

| [Files with missing lines](https://app.codecov.io/gh/air-verse/air/pull/749?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Patch % | Lines |
|---|---|---|
| [runner/util.go](https://app.codecov.io/gh/air-verse/air/pull/749?src=pr&el=tree&filepath=runner%2Futil.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cnVubmVyL3V0aWwuZ28=) | 70.00% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/air-verse/air/pull/749?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) |

| [Files with missing lines](https://app.codecov.io/gh/air-verse/air/pull/749?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [runner/engine.go](https://app.codecov.io/gh/air-verse/air/pull/749?src=pr&el=tree&filepath=runner%2Fengine.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cnVubmVyL2VuZ2luZS5nbw==) | `60.03% <100.00%> (+0.07%)` | :arrow_up: |
| [runner/util.go](https://app.codecov.io/gh/air-verse/air/pull/749?src=pr&el=tree&filepath=runner%2Futil.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cnVubmVyL3V0aWwuZ28=) | `70.45% <70.00%> (-0.02%)` | :arrow_down: |

author_association: NONE
user: login: pkashin
created_at: 2025-03-03T16:10:16Z
body: Hi @xiantang,

I noticed that the `Smoke test / smoke_test_ubuntu / Smoke test (pull_request)` check failed ❌ in this PR. However, the same test passes successfully ✅ in my fork (screenshot attached below). Could you please help me investigate this issue?

It would be great if you could:

1. Restart the failed check to see if it was a transient issue.

2. Let me know if there are any additional steps I should take to resolve this.

Thank you for your time and assistance!

<img width="627" alt="Снимок экрана 2025-03-03 в 22 44 48" src="https://github.com/user-attachments/assets/be3c5068-d32c-4e5a-8a9d-2317591df16b" />

author_association: CONTRIBUTOR
user: login: xiantang
created_at: 2025-03-04T14:11:34Z
body: looks pipeline is ok now.
author_association: COLLABORATOR