Skip to content

Commit 430141e

Browse files
committed
Consolidate test files and add newBaseClassifier helper
Test file reorganization: - Delete scattered test files (format_posix_test.go, format_fixes_test.go, format_phase4_test.go, edge_cases_test.go) - Create one format_<shell>_test.go per format (10 files) - Move TestClassifier from shlex_test.go to format_bash_test.go - Merge edge case tests into their format's test file - Each format now has a dedicated test file with all its tests Code cleanup: - Add newBaseClassifier(escapeChar) helper in shlex.go that creates a classifier with standard POSIX rune classes (space, quotes, escape, comment) without wordbreaks - Add addWordbreaks(runes) method on tokenClassifier that filters out already-classified runes - Update all 7 format Classifier() methods to use the helpers, reducing boilerplate from ~15 lines to ~5 lines each 109 test cases pass, 0 gofmt diffs. Assisted-by: Crush:glm-5.2
1 parent 9fdd47d commit 430141e

18 files changed

Lines changed: 766 additions & 813 deletions

edge_cases_test.go

Lines changed: 0 additions & 608 deletions
This file was deleted.

format_bash.go

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,13 @@ type bashFormat struct{}
1111
func BashFormat() Format { return bashFormat{} }
1212

1313
func (bashFormat) Classifier() tokenClassifier {
14-
t := tokenClassifier{}
15-
t.addRuneClass(spaceRunes, spaceRuneClass)
16-
t.addRuneClass(escapingQuoteRunes, escapingQuoteRuneClass)
17-
t.addRuneClass(nonEscapingQuoteRunes, nonEscapingQuoteRuneClass)
18-
t.addRuneClass(escapeRunes, escapeRuneClass)
19-
t.addRuneClass(commentRunes, commentRuneClass)
14+
t := newBaseClassifier(escapeRunes)
2015

2116
wordbreakRunes := BASH_WORDBREAKS
2217
if wordbreaks := os.Getenv("COMP_WORDBREAKS"); wordbreaks != "" {
2318
wordbreakRunes = wordbreaks
2419
}
25-
filtered := make([]rune, 0)
26-
for _, r := range wordbreakRunes {
27-
if t.ClassifyRune(r) == unknownRuneClass {
28-
filtered = append(filtered, r)
29-
}
30-
}
31-
t.addRuneClass(string(filtered), wordbreakRuneClass)
20+
t.addWordbreaks(wordbreakRunes)
3221

3322
return t
3423
}

format_bash_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package shlex
2+
3+
import "testing"
4+
5+
func TestBashFormat_Classifier(t *testing.T) {
6+
classifier := BashFormat().Classifier()
7+
tests := map[rune]runeTokenClass{
8+
' ': spaceRuneClass,
9+
'"': escapingQuoteRuneClass,
10+
'\'': nonEscapingQuoteRuneClass,
11+
'#': commentRuneClass,
12+
}
13+
for runeChar, want := range tests {
14+
got := classifier.ClassifyRune(runeChar)
15+
if got != want {
16+
t.Errorf("ClassifyRune(%v) -> %v. Want: %v", runeChar, got, want)
17+
}
18+
}
19+
}
20+
21+
func TestBashFormat_CloseQuoteEscapeReopen(t *testing.T) {
22+
// The POSIX idiom for embedding a single quote: 'it'\''s
23+
tokens, err := SplitWith("echo 'it'\\''s", BashFormat())
24+
if err != nil {
25+
t.Fatal(err)
26+
}
27+
words := tokens.Words().Strings()
28+
if len(words) != 2 || words[1] != "it's" {
29+
t.Errorf("bash '\\'': Words = %v, want [echo it's]", words)
30+
}
31+
}
32+
33+
func TestBashFormat_EscapedSpace(t *testing.T) {
34+
tokens, err := SplitWith(`echo a\ b`, BashFormat())
35+
if err != nil {
36+
t.Fatal(err)
37+
}
38+
words := tokens.Words().Strings()
39+
if len(words) != 2 || words[1] != "a b" {
40+
t.Errorf("bash escaped space: Words = %v, want [echo a b]", words)
41+
}
42+
}
43+
44+
func TestBashFormat_BackslashNInDoubleQuotes(t *testing.T) {
45+
// In bash, \n inside "..." is literal (backslash not special before n)
46+
// The state machine consumes \ + next char, so Value = "hellonworld"
47+
// This is a known limitation — the lexer is not a full expander.
48+
tokens, err := SplitWith(`echo "hello\nworld"`, BashFormat())
49+
if err != nil {
50+
t.Fatal(err)
51+
}
52+
words := tokens.Words()
53+
last := words[len(words)-1]
54+
if last.State != IN_WORD_STATE {
55+
t.Errorf("bash \\n in double: State = %v, want IN_WORD_STATE", last.State)
56+
}
57+
}
58+
59+
func TestBashFormat_AdjacentQuotedSegments(t *testing.T) {
60+
tokens, err := SplitWith(`echo a"b"'c'`, BashFormat())
61+
if err != nil {
62+
t.Fatal(err)
63+
}
64+
words := tokens.Words().Strings()
65+
if len(words) != 2 || words[1] != "abc" {
66+
t.Errorf("bash adjacent: Words = %v, want [echo abc]", words)
67+
}
68+
}
69+
70+
func TestBashFormat_SingleQuoteLiteral(t *testing.T) {
71+
tokens, err := SplitWith(`echo '$HOME \n \t'`, BashFormat())
72+
if err != nil {
73+
t.Fatal(err)
74+
}
75+
words := tokens.Words().Strings()
76+
if len(words) != 2 || words[1] != `$HOME \n \t` {
77+
t.Errorf("bash single literal: Words = %v, want [echo $HOME \\n \\t]", words)
78+
}
79+
}
80+
81+
func TestBashFormat_AtWordbreakPrefix(t *testing.T) {
82+
// @ is a wordbreak but WordbreakPrefix skips it
83+
ctx := SplitForCompletion("echo foo@bar", BashFormat())
84+
// @ is a wordbreak, but Words() merges adjoining tokens, so CurrentWord is the full word
85+
if ctx.CurrentWord != "foo@bar" {
86+
t.Errorf("bash @: CurrentWord = %q, want %q", ctx.CurrentWord, "foo@bar")
87+
}
88+
// @ is skipped as a wordbreak boundary, so prefix should be "foo"
89+
if ctx.Prefix != "foo" {
90+
t.Errorf("bash @: Prefix = %q, want %q", ctx.Prefix, "foo")
91+
}
92+
}
93+
94+
func TestBashFormat_EscapeAtEOF(t *testing.T) {
95+
tokens, err := SplitWith(`echo foo\`, BashFormat())
96+
if err != nil {
97+
t.Fatal(err)
98+
}
99+
words := tokens.Words()
100+
last := words[len(words)-1]
101+
if last.State != ESCAPING_STATE {
102+
t.Errorf("bash escape EOF: State = %v, want ESCAPING_STATE", last.State)
103+
}
104+
if last.Value != "foo" {
105+
t.Errorf("bash escape EOF: Value = %q, want %q", last.Value, "foo")
106+
}
107+
}
108+
109+
func TestBashFormat_Comment(t *testing.T) {
110+
tokens, err := SplitWith("echo hello # comment", BashFormat())
111+
if err != nil {
112+
t.Fatal(err)
113+
}
114+
// Lexer skips comments, so only "echo" and "hello" are returned
115+
words := tokens.Words().Strings()
116+
if len(words) != 2 {
117+
t.Errorf("bash comment: Words = %v, want 2 words", words)
118+
}
119+
}

format_cmd_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,48 @@ func TestCmdFormat_PercentNotWordbreak(t *testing.T) {
131131
t.Errorf("cmd %%: Words = %v, want [echo %%PATH%%]", words)
132132
}
133133
}
134+
135+
func TestCmdFormat_DoubleOr(t *testing.T) {
136+
tokens, err := SplitWith("echo foo || echo bar", CmdFormat())
137+
if err != nil {
138+
t.Fatal(err)
139+
}
140+
if len(tokens.Pipelines()) != 2 {
141+
t.Errorf("cmd ||: %d pipelines, want 2", len(tokens.Pipelines()))
142+
}
143+
}
144+
145+
func TestCmdFormat_Redirect(t *testing.T) {
146+
ctx := SplitForCompletion("echo foo > bar", CmdFormat())
147+
if !ctx.IsRedirect {
148+
t.Errorf("cmd redirect: IsRedirect = false, want true")
149+
}
150+
if ctx.CurrentWord != "bar" {
151+
t.Errorf("cmd redirect: CurrentWord = %q, want %q", ctx.CurrentWord, "bar")
152+
}
153+
}
154+
155+
func TestCmdFormat_OpenDoubleQuote(t *testing.T) {
156+
tokens, err := SplitWith(`echo "hel`, CmdFormat())
157+
if err != nil {
158+
t.Fatal(err)
159+
}
160+
last := tokens.Words().CurrentToken()
161+
if last.State != QUOTING_ESCAPING_STATE {
162+
t.Errorf("cmd open double: State = %v, want QUOTING_ESCAPING_STATE", last.State)
163+
}
164+
}
165+
166+
func TestCmdFormat_CaretAtEOF(t *testing.T) {
167+
tokens, err := SplitWith("echo foo^", CmdFormat())
168+
if err != nil {
169+
t.Fatal(err)
170+
}
171+
last := tokens.Words().CurrentToken()
172+
if last.State != ESCAPING_STATE {
173+
t.Errorf("cmd caret EOF: State = %v, want ESCAPING_STATE", last.State)
174+
}
175+
if last.Value != "foo" {
176+
t.Errorf("cmd caret EOF: Value = %q, want %q", last.Value, "foo")
177+
}
178+
}

format_elvish.go

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,10 @@ type elvishFormat struct{}
1111
func ElvishFormat() Format { return elvishFormat{} }
1212

1313
func (elvishFormat) Classifier() tokenClassifier {
14-
t := tokenClassifier{}
15-
t.addRuneClass(spaceRunes, spaceRuneClass)
16-
t.addRuneClass(escapingQuoteRunes, escapingQuoteRuneClass)
17-
t.addRuneClass(nonEscapingQuoteRunes, nonEscapingQuoteRuneClass)
18-
// Elvish: \ is a bareword character, not an escape outside quotes.
19-
// It IS an escape inside double quotes. The EscapeNotBareword() flag
20-
// returns false, so the state machine treats \ as a regular word char
21-
// in IN_WORD_STATE and START_STATE, but still uses it as an escape
22-
// in QUOTING_ESCAPING_STATE (double quotes).
23-
t.addRuneClass(escapeRunes, escapeRuneClass)
24-
t.addRuneClass(commentRunes, commentRuneClass)
25-
14+
t := newBaseClassifier(escapeRunes)
2615
// Elvish operators: |, >, <, >>, >>?, <>>, ;
2716
// No &, &&, || — & is for map literals
28-
wordbreakRunes := "|><;"
29-
filtered := make([]rune, 0)
30-
for _, r := range wordbreakRunes {
31-
if t.ClassifyRune(r) == unknownRuneClass {
32-
filtered = append(filtered, r)
33-
}
34-
}
35-
t.addRuneClass(string(filtered), wordbreakRuneClass)
17+
t.addWordbreaks("|><;")
3618
return t
3719
}
3820

format_elvish_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package shlex
2+
3+
import "testing"
4+
5+
func TestElvishFormat_BarewordBackslash(t *testing.T) {
6+
// Elvish: \ is a bareword character outside quotes (not an escape)
7+
tokens, err := SplitWith(`echo C:\path`, ElvishFormat())
8+
if err != nil {
9+
t.Fatal(err)
10+
}
11+
words := tokens.Words().Strings()
12+
if len(words) != 2 || words[0] != "echo" || words[1] != `C:\path` {
13+
t.Errorf("elvish bareword \\: Words = %v, want [echo C:\\path]", words)
14+
}
15+
}
16+
17+
func TestElvishFormat_DoubleQuoteEscape(t *testing.T) {
18+
// Elvish: \ IS an escape inside double quotes
19+
tokens, err := SplitWith(`echo "hello\nworld"`, ElvishFormat())
20+
if err != nil {
21+
t.Fatal(err)
22+
}
23+
words := tokens.Words()
24+
last := words[len(words)-1]
25+
if last.State != IN_WORD_STATE {
26+
t.Errorf("elvish double-quote escape: State = %v, want IN_WORD_STATE", last.State)
27+
}
28+
}
29+
30+
func TestElvishFormat_DoubledQuoteSplit(t *testing.T) {
31+
tokens, err := SplitWith("echo 'it''s a test'", ElvishFormat())
32+
if err != nil {
33+
t.Fatal(err)
34+
}
35+
words := tokens.Words().Strings()
36+
if len(words) != 2 || words[1] != "it's a test" {
37+
t.Errorf("elvish '' split: Words = %v, want [echo it's a test]", words)
38+
}
39+
}
40+
41+
func TestElvishFormat_DoubleQuoteValue(t *testing.T) {
42+
tokens, err := SplitWith(`echo "say \"hello\""`, ElvishFormat())
43+
if err != nil {
44+
t.Fatal(err)
45+
}
46+
words := tokens.Words().Strings()
47+
if len(words) != 2 || words[1] != `say "hello"` {
48+
t.Errorf("elvish double value: Words = %v, want [echo say \"hello\"]", words)
49+
}
50+
}
51+
52+
func TestElvishFormat_AmpNotListOperator(t *testing.T) {
53+
// & is for map literals in elvish, not a list operator
54+
tokens, err := SplitWith("echo foo & echo bar", ElvishFormat())
55+
if err != nil {
56+
t.Fatal(err)
57+
}
58+
pipelines := tokens.Pipelines()
59+
if len(pipelines) != 1 {
60+
t.Errorf("elvish &: %d pipelines, want 1 (& is not a separator)", len(pipelines))
61+
}
62+
}
63+
64+
func TestElvishFormat_Pipe(t *testing.T) {
65+
tokens, err := SplitWith("echo foo | grep bar", ElvishFormat())
66+
if err != nil {
67+
t.Fatal(err)
68+
}
69+
if len(tokens.Pipelines()) != 2 {
70+
t.Errorf("elvish pipe: %d pipelines, want 2", len(tokens.Pipelines()))
71+
}
72+
}
73+
74+
func TestElvishFormat_Semicolon(t *testing.T) {
75+
tokens, err := SplitWith("echo foo ; echo bar", ElvishFormat())
76+
if err != nil {
77+
t.Fatal(err)
78+
}
79+
if len(tokens.Pipelines()) != 2 {
80+
t.Errorf("elvish semicolon: %d pipelines, want 2", len(tokens.Pipelines()))
81+
}
82+
}
83+
84+
func TestElvishFormat_OpenSingleQuote(t *testing.T) {
85+
tokens, err := SplitWith("echo 'hel", ElvishFormat())
86+
if err != nil {
87+
t.Fatal(err)
88+
}
89+
last := tokens.Words().CurrentToken()
90+
if last.State != QUOTING_STATE {
91+
t.Errorf("elvish open single: State = %v, want QUOTING_STATE", last.State)
92+
}
93+
}

format_fish.go

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,10 @@ type fishFormat struct{}
1313
func FishFormat() Format { return fishFormat{} }
1414

1515
func (fishFormat) Classifier() tokenClassifier {
16-
t := tokenClassifier{}
17-
t.addRuneClass(spaceRunes, spaceRuneClass)
18-
t.addRuneClass(escapingQuoteRunes, escapingQuoteRuneClass)
19-
t.addRuneClass(nonEscapingQuoteRunes, nonEscapingQuoteRuneClass)
20-
t.addRuneClass(escapeRunes, escapeRuneClass)
21-
t.addRuneClass(commentRunes, commentRuneClass)
22-
16+
t := newBaseClassifier(escapeRunes)
2317
// Fish operators: |, ;, <, >, >>, >>?, >?, <>&
2418
// No &&, ||, & — fish uses keyword operators (and, or, not) instead
25-
// No @, =, : as wordbreaks (different from bash)
26-
wordbreakRunes := "|;<>"
27-
filtered := make([]rune, 0)
28-
for _, r := range wordbreakRunes {
29-
if t.ClassifyRune(r) == unknownRuneClass {
30-
filtered = append(filtered, r)
31-
}
32-
}
33-
t.addRuneClass(string(filtered), wordbreakRuneClass)
19+
t.addWordbreaks("|;<>")
3420
return t
3521
}
3622

format_fish_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,39 @@ func TestFishFormat_CompletionContext(t *testing.T) {
138138
t.Errorf("fish completion: Words = %v, want 2 words (grep hel)", ctx.Words)
139139
}
140140
}
141+
142+
func TestFishFormat_DollarNotEscapeInSingleQuotes(t *testing.T) {
143+
// \$ is NOT an escape in fish single quotes — only \' and \\ are
144+
tokens, err := SplitWith(`echo 'cost: \$5'`, FishFormat())
145+
if err != nil {
146+
t.Fatal(err)
147+
}
148+
words := tokens.Words().Strings()
149+
if len(words) != 2 || words[1] != `cost: \$5` {
150+
t.Errorf("fish \\$ in single: Words = %v, want [echo cost: \\$5]", words)
151+
}
152+
}
153+
154+
func TestFishFormat_EscapedSpace(t *testing.T) {
155+
tokens, err := SplitWith(`echo a\ b`, FishFormat())
156+
if err != nil {
157+
t.Fatal(err)
158+
}
159+
words := tokens.Words().Strings()
160+
if len(words) != 2 || words[1] != "a b" {
161+
t.Errorf("fish escaped space: Words = %v, want [echo a b]", words)
162+
}
163+
}
164+
165+
func TestFishFormat_ParensNotWordbreak(t *testing.T) {
166+
// Fish: () are command substitution, not word breaks.
167+
// Spaces still split words, but parens are part of the words.
168+
tokens, err := SplitWith("echo (echo test)", FishFormat())
169+
if err != nil {
170+
t.Fatal(err)
171+
}
172+
words := tokens.Words().Strings()
173+
if len(words) != 3 || words[0] != "echo" || words[1] != "(echo" || words[2] != "test)" {
174+
t.Errorf("fish parens: Words = %v, want [echo (echo test)]", words)
175+
}
176+
}

0 commit comments

Comments
 (0)