Skip to content

Commit d8b4825

Browse files
Copilotpelikhan
andauthored
manualpathconcat: detect 2-operand embedded-slash literal shape
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent 3d707ab commit d8b4825

2 files changed

Lines changed: 85 additions & 20 deletions

File tree

pkg/linters/manualpathconcat/manualpathconcat.go

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func analyzeBinaryExpr(pass *analysis.Pass, n ast.Node, generatedFiles filecheck
5353
if !ok || reported[bin] {
5454
return
5555
}
56-
left, ok := matchSlashSeparator(bin)
56+
left, rightOverride, ok := matchSlashSeparator(bin)
5757
if !ok {
5858
return
5959
}
@@ -73,6 +73,9 @@ func analyzeBinaryExpr(pass *analysis.Pass, n ast.Node, generatedFiles filecheck
7373

7474
leftText := astutil.NodeText(pass.Fset, left)
7575
rightText := astutil.NodeText(pass.Fset, bin.Y)
76+
if rightOverride != "" {
77+
rightText = rightOverride
78+
}
7679
message := `manual "/" path concatenation; use filepath.Join (or path.Join) instead`
7780
if isShortOperandText(leftText) && isShortOperandText(rightText) && !containsSlashConcat(left) {
7881
message = fmt.Sprintf(`manual "/" path concatenation; use filepath.Join(%s, %s) (or path.Join) instead`, leftText, rightText)
@@ -84,15 +87,28 @@ func analyzeBinaryExpr(pass *analysis.Pass, n ast.Node, generatedFiles filecheck
8487
})
8588
}
8689

87-
// analyzeAssignStmt reports compound assignments of the shape X += "/" + Y.
90+
// analyzeAssignStmt reports compound assignments of the shape X += "/" + Y,
91+
// as well as the two-operand X += "/subpath" form.
8892
func analyzeAssignStmt(pass *analysis.Pass, n ast.Node, generatedFiles filecheck.GeneratedIndex, noLintIndex nolint.DirectiveIndex) {
8993
assign, ok := n.(*ast.AssignStmt)
9094
if !ok || assign.Tok != token.ADD_ASSIGN || len(assign.Lhs) != 1 || len(assign.Rhs) != 1 {
9195
return
9296
}
93-
bin, ok := assign.Rhs[0].(*ast.BinaryExpr)
94-
if !ok || bin.Op != token.ADD || !isSlashLiteral(bin.X) {
95-
return
97+
var rightExpr ast.Expr
98+
var rightOverride string
99+
switch rhs := assign.Rhs[0].(type) {
100+
case *ast.BinaryExpr:
101+
if rhs.Op != token.ADD || !isSlashLiteral(rhs.X) {
102+
return
103+
}
104+
rightExpr = rhs.Y
105+
default:
106+
trimmed, isEmbedded := embeddedSlashLiteral(assign.Rhs[0])
107+
if !isEmbedded {
108+
return
109+
}
110+
rightExpr = assign.Rhs[0]
111+
rightOverride = trimmed
96112
}
97113
pos := pass.Fset.PositionFor(assign.Pos(), false)
98114
if filecheck.ShouldSkipFilename(pos.Filename, generatedFiles) {
@@ -103,7 +119,10 @@ func analyzeAssignStmt(pass *analysis.Pass, n ast.Node, generatedFiles filecheck
103119
}
104120

105121
leftText := astutil.NodeText(pass.Fset, assign.Lhs[0])
106-
rightText := astutil.NodeText(pass.Fset, bin.Y)
122+
rightText := astutil.NodeText(pass.Fset, rightExpr)
123+
if rightOverride != "" {
124+
rightText = rightOverride
125+
}
107126
message := `manual "/" path concatenation; use filepath.Join (or path.Join) instead`
108127
if isShortOperandText(leftText) && isShortOperandText(rightText) && !containsSlashConcat(assign.Lhs[0]) {
109128
message = fmt.Sprintf(`manual "/" path concatenation; use filepath.Join(%s, %s) (or path.Join) instead`, leftText, rightText)
@@ -116,24 +135,28 @@ func analyzeAssignStmt(pass *analysis.Pass, n ast.Node, generatedFiles filecheck
116135
}
117136

118137
// matchSlashSeparator reports whether bin has the shape X + "/" + Y, which Go
119-
// parses as ((X + "/") + Y), and returns the X operand.
120-
func matchSlashSeparator(bin *ast.BinaryExpr) (left ast.Expr, ok bool) {
138+
// parses as ((X + "/") + Y), or the two-operand shape X + "/subpath", where
139+
// the leading slash is embedded in a longer literal. It returns the X operand
140+
// (left) and, for the two-operand shape, a quoted rightOverride text (the
141+
// literal with its leading slash stripped) suitable for the diagnostic
142+
// message; rightOverride is empty for the three-operand shape, where the
143+
// caller uses bin.Y's own source text instead.
144+
func matchSlashSeparator(bin *ast.BinaryExpr) (left ast.Expr, rightOverride string, ok bool) {
121145
if bin.Op != token.ADD {
122-
return nil, false
146+
return nil, "", false
123147
}
124-
inner, isBinary := bin.X.(*ast.BinaryExpr)
125-
if !isBinary || inner.Op != token.ADD {
126-
return nil, false
127-
}
128-
if !isSlashLiteral(inner.Y) {
129-
return nil, false
148+
if inner, isBinary := bin.X.(*ast.BinaryExpr); isBinary && inner.Op == token.ADD && isSlashLiteral(inner.Y) {
149+
// A left operand that is itself the separator (e.g. `"/" + "/" + name`)
150+
// carries no path segment to join, so it is not a manual join.
151+
if isSlashLiteral(inner.X) {
152+
return nil, "", false
153+
}
154+
return inner.X, "", true
130155
}
131-
// A left operand that is itself the separator (e.g. `"/" + "/" + name`)
132-
// carries no path segment to join, so it is not a manual join.
133-
if isSlashLiteral(inner.X) {
134-
return nil, false
156+
if trimmed, isEmbedded := embeddedSlashLiteral(bin.Y); isEmbedded {
157+
return bin.X, trimmed, true
135158
}
136-
return inner.X, true
159+
return nil, "", false
137160
}
138161

139162
// isSlashLiteral reports whether expr is the string literal "/".
@@ -146,6 +169,23 @@ func isSlashLiteral(expr ast.Expr) bool {
146169
return err == nil && val == "/"
147170
}
148171

172+
// embeddedSlashLiteral reports whether expr is a string literal that begins
173+
// with "/" and carries additional path text after it (e.g. "/config.yml"),
174+
// as opposed to the bare separator "/" alone. On success it returns the
175+
// literal's text quoted without the leading slash (e.g. `"config.yml"`),
176+
// suitable for a filepath.Join diagnostic argument.
177+
func embeddedSlashLiteral(expr ast.Expr) (trimmedQuoted string, ok bool) {
178+
lit, isLit := expr.(*ast.BasicLit)
179+
if !isLit || lit.Kind != token.STRING {
180+
return "", false
181+
}
182+
val, err := strconv.Unquote(lit.Value)
183+
if err != nil || !strings.HasPrefix(val, "/") || val == "/" {
184+
return "", false
185+
}
186+
return strconv.Quote(strings.TrimPrefix(val, "/")), true
187+
}
188+
149189
// maxOperandTextLen bounds the operand source text embedded in a diagnostic
150190
// message so that long or multi-line operands do not produce unreadable output.
151191
const maxOperandTextLen = 48

pkg/linters/manualpathconcat/testdata/src/manualpathconcat/manualpathconcat.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,24 @@ func badAddAssign(absPath, relPath string) string {
3838
return absPath
3939
}
4040

41+
// bad2OpEmbeddedSlash uses the two-operand shape where the leading slash is
42+
// embedded in a longer literal rather than being its own operand.
43+
func bad2OpEmbeddedSlash(dir string) string {
44+
return dir + "/config.yml" // want `manual "/" path concatenation; use filepath\.Join\(dir, "config\.yml"\) \(or path\.Join\) instead`
45+
}
46+
47+
// bad2OpEmbeddedSlashNested has a longer path after the leading slash.
48+
func bad2OpEmbeddedSlashNested(home string) string {
49+
return home + "/.config/foo" // want `manual "/" path concatenation; use filepath\.Join\(home, "\.config/foo"\) \(or path\.Join\) instead`
50+
}
51+
52+
// bad2OpEmbeddedSlashAddAssign builds a path with an embedded-slash literal
53+
// via +=.
54+
func bad2OpEmbeddedSlashAddAssign(home string) string {
55+
home += "/.config/foo" // want `manual "/" path concatenation; use filepath\.Join\(home, "\.config/foo"\) \(or path\.Join\) instead`
56+
return home
57+
}
58+
4159
// badLongOperand has an operand too long to quote, so the diagnostic falls back
4260
// to the generic message.
4361
func badLongOperand(name string) string {
@@ -69,6 +87,13 @@ func goodPrefixOnly(name string) string {
6987
return "/" + name
7088
}
7189

90+
// goodBareSeparatorLeftOfEmbedded is a compile-time constant (both operands
91+
// are literals), where filepath.Join is not valid — not flagged, matching
92+
// goodConstant below.
93+
func goodBareSeparatorLeftOfEmbedded() string {
94+
return "/" + "/config.yml"
95+
}
96+
7297
// badSuffixAfterJoinShape confirms the nested join shape is still reported.
7398
func badSuffixAfterJoinShape(dir, file string) string {
7499
return dir + "/" + file + ".tmp" // want `manual "/" path concatenation; use filepath\.Join\(dir, file\) \(or path\.Join\) instead`

0 commit comments

Comments
 (0)