@@ -174,3 +174,263 @@ func TestFishFormat_ParensNotWordbreak(t *testing.T) {
174174 t .Errorf ("fish parens: Words = %v, want [echo (echo test)]" , words )
175175 }
176176}
177+
178+ func TestFishFormat_AndAnd (t * testing.T ) {
179+ tokens , err := SplitWith ("echo foo && echo bar" , FishFormat ())
180+ if err != nil {
181+ t .Fatal (err )
182+ }
183+ pipelines := tokens .Pipelines ()
184+ if len (pipelines ) != 2 {
185+ t .Errorf ("fish &&: %d pipelines, want 2" , len (pipelines ))
186+ }
187+ last := tokens [len (tokens )- 3 ]
188+ if last .Type != WORDBREAK_TOKEN || last .WordbreakType != WORDBREAK_LIST_AND {
189+ t .Errorf ("fish &&: Type=%v WordbreakType=%v, want WORDBREAK_TOKEN/LIST_AND" , last .Type , last .WordbreakType )
190+ }
191+ }
192+
193+ func TestFishFormat_OrOr (t * testing.T ) {
194+ tokens , err := SplitWith ("echo foo || echo bar" , FishFormat ())
195+ if err != nil {
196+ t .Fatal (err )
197+ }
198+ pipelines := tokens .Pipelines ()
199+ if len (pipelines ) != 2 {
200+ t .Errorf ("fish ||: %d pipelines, want 2" , len (pipelines ))
201+ }
202+ }
203+
204+ func TestFishFormat_Background (t * testing.T ) {
205+ tokens , err := SplitWith ("echo foo & echo bar" , FishFormat ())
206+ if err != nil {
207+ t .Fatal (err )
208+ }
209+ pipelines := tokens .Pipelines ()
210+ if len (pipelines ) != 2 {
211+ t .Errorf ("fish &: %d pipelines, want 2" , len (pipelines ))
212+ }
213+ }
214+
215+ func TestFishFormat_PipeWithStderrMerge (t * testing.T ) {
216+ tokens , err := SplitWith ("echo foo |& cat bar" , FishFormat ())
217+ if err != nil {
218+ t .Fatal (err )
219+ }
220+ pipelines := tokens .Pipelines ()
221+ if len (pipelines ) != 2 {
222+ t .Errorf ("fish |&: %d pipelines, want 2" , len (pipelines ))
223+ }
224+ wb := tokens [len (tokens )- 3 ]
225+ if wb .Type != WORDBREAK_TOKEN || wb .WordbreakType != WORDBREAK_PIPE_WITH_STDERR {
226+ t .Errorf ("fish |&: Type=%v WordbreakType=%v, want WORDBREAK_TOKEN/PIPE_WITH_STDERR" , wb .Type , wb .WordbreakType )
227+ }
228+ }
229+
230+ func TestFishFormat_AmpPipe (t * testing.T ) {
231+ tokens , err := SplitWith ("echo foo &| cat bar" , FishFormat ())
232+ if err != nil {
233+ t .Fatal (err )
234+ }
235+ pipelines := tokens .Pipelines ()
236+ if len (pipelines ) != 2 {
237+ t .Errorf ("fish &|: %d pipelines, want 2" , len (pipelines ))
238+ }
239+ }
240+
241+ func TestFishFormat_ExplicitFdPipe (t * testing.T ) {
242+ // >| is a pipe with explicit fd in fish (e.g. echo foo >| bar)
243+ tokens , err := SplitWith ("echo foo >| cat bar" , FishFormat ())
244+ if err != nil {
245+ t .Fatal (err )
246+ }
247+ pipelines := tokens .Pipelines ()
248+ if len (pipelines ) != 2 {
249+ t .Errorf ("fish >|: %d pipelines, want 2" , len (pipelines ))
250+ }
251+ wb := tokens [len (tokens )- 3 ]
252+ if wb .Type != WORDBREAK_TOKEN || wb .WordbreakType != WORDBREAK_PIPE {
253+ t .Errorf ("fish >|: Type=%v WordbreakType=%v, want WORDBREAK_TOKEN/PIPE" , wb .Type , wb .WordbreakType )
254+ }
255+ }
256+
257+ func TestFishFormat_AmpRedirect (t * testing.T ) {
258+ _ , err := SplitWith ("echo foo &> file.txt" , FishFormat ())
259+ if err != nil {
260+ t .Fatal (err )
261+ }
262+ ctx := SplitForCompletion ("echo foo &> file.txt" , FishFormat ())
263+ if ! ctx .IsRedirect {
264+ t .Errorf ("fish &>: IsRedirect = false, want true" )
265+ }
266+ }
267+
268+ func TestFishFormat_AmpRedirectAppend (t * testing.T ) {
269+ _ , err := SplitWith ("echo foo &>> file.txt" , FishFormat ())
270+ if err != nil {
271+ t .Fatal (err )
272+ }
273+ ctx := SplitForCompletion ("echo foo &>> file.txt" , FishFormat ())
274+ if ! ctx .IsRedirect {
275+ t .Errorf ("fish &>>: IsRedirect = false, want true" )
276+ }
277+ }
278+
279+ func TestFishFormat_FdRedirect (t * testing.T ) {
280+ // >&2 is a fd redirect: >& is the operator, 2 is the fd number.
281+ // After >&2, the cursor is at a new word (not a redirect target).
282+ // Test that >& is classified as a redirect operator.
283+ tokens , err := SplitWith ("echo foo >&2" , FishFormat ())
284+ if err != nil {
285+ t .Fatal (err )
286+ }
287+ found := false
288+ for _ , tok := range tokens {
289+ if tok .Type == WORDBREAK_TOKEN && tok .WordbreakType == WORDBREAK_REDIRECT_INPUT_DUPLICATE {
290+ found = true
291+ break
292+ }
293+ }
294+ if ! found {
295+ t .Errorf ("fish >&2: no WORDBREAK_REDIRECT_INPUT_DUPLICATE token found" )
296+ }
297+ }
298+
299+ func TestFishFormat_InputOutputRedirect (t * testing.T ) {
300+ ctx := SplitForCompletion ("echo foo <> " , FishFormat ())
301+ if ! ctx .IsRedirect {
302+ t .Errorf ("fish <>: IsRedirect = false, want true" )
303+ }
304+ }
305+
306+ func TestFishFormat_NoclobberRedirect (t * testing.T ) {
307+ ctx := SplitForCompletion ("echo foo >? " , FishFormat ())
308+ if ! ctx .IsRedirect {
309+ t .Errorf ("fish >?: IsRedirect = false, want true" )
310+ }
311+ }
312+
313+ func TestFishFormat_NoclobberAppendRedirect (t * testing.T ) {
314+ ctx := SplitForCompletion ("echo foo >>? " , FishFormat ())
315+ if ! ctx .IsRedirect {
316+ t .Errorf ("fish >>?: IsRedirect = false, want true" )
317+ }
318+ }
319+
320+ func TestFishFormat_TryInputRedirect (t * testing.T ) {
321+ ctx := SplitForCompletion ("echo foo <? " , FishFormat ())
322+ if ! ctx .IsRedirect {
323+ t .Errorf ("fish <?: IsRedirect = false, want true" )
324+ }
325+ }
326+
327+ func TestFishFormat_DoubleQuoteEscapedQuote (t * testing.T ) {
328+ tokens , err := SplitWith (`echo "say \"hello\""` , FishFormat ())
329+ if err != nil {
330+ t .Fatal (err )
331+ }
332+ words := tokens .Words ().Strings ()
333+ if len (words ) != 2 || words [1 ] != `say "hello"` {
334+ t .Errorf ("fish \\ \" in double: Words = %v, want [echo say \" hello\" ]" , words )
335+ }
336+ }
337+
338+ func TestFishFormat_DoubleQuoteEscapedDollar (t * testing.T ) {
339+ tokens , err := SplitWith (`echo "cost: \$5"` , FishFormat ())
340+ if err != nil {
341+ t .Fatal (err )
342+ }
343+ words := tokens .Words ().Strings ()
344+ if len (words ) != 2 || words [1 ] != `cost: $5` {
345+ t .Errorf ("fish \\ $ in double: Words = %v, want [echo cost: $5]" , words )
346+ }
347+ }
348+
349+ func TestFishFormat_DoubleQuoteEscapedBackslash (t * testing.T ) {
350+ tokens , err := SplitWith (`echo "C:\\path"` , FishFormat ())
351+ if err != nil {
352+ t .Fatal (err )
353+ }
354+ words := tokens .Words ().Strings ()
355+ if len (words ) != 2 || words [1 ] != `C:\path` {
356+ t .Errorf ("fish \\ \\ in double: Words = %v, want [echo C:\\ path]" , words )
357+ }
358+ }
359+
360+ func TestFishFormat_DoubleQuoteNonEscapeBackslash (t * testing.T ) {
361+ // \n inside fish double quotes is NOT an escape — both \ and n are literal
362+ tokens , err := SplitWith (`echo "hello\nworld"` , FishFormat ())
363+ if err != nil {
364+ t .Fatal (err )
365+ }
366+ words := tokens .Words ().Strings ()
367+ if len (words ) != 2 || words [1 ] != `hello\nworld` {
368+ t .Errorf ("fish \\ n in double: Words = %v, want [echo hello\\ nworld]" , words )
369+ }
370+ }
371+
372+ func TestFishFormat_DoubleQuoteNonEscapeBackslashOther (t * testing.T ) {
373+ // \t inside fish double quotes is NOT an escape — both \ and t are literal
374+ tokens , err := SplitWith (`echo "a\tb"` , FishFormat ())
375+ if err != nil {
376+ t .Fatal (err )
377+ }
378+ words := tokens .Words ().Strings ()
379+ if len (words ) != 2 || words [1 ] != `a\tb` {
380+ t .Errorf ("fish \\ t in double: Words = %v, want [echo a\\ tb]" , words )
381+ }
382+ }
383+
384+ func TestFishFormat_DoubleQuoteEscapedNewline (t * testing.T ) {
385+ // \<newline> is a line continuation escape inside fish double quotes
386+ tokens , err := SplitWith ("echo \" hello\\ \n world\" " , FishFormat ())
387+ if err != nil {
388+ t .Fatal (err )
389+ }
390+ words := tokens .Words ().Strings ()
391+ if len (words ) != 2 || words [1 ] != "hello\n world" {
392+ t .Errorf ("fish \\ <newline> in double: Words = %v, want [echo hello\\ nworld]" , words )
393+ }
394+ }
395+
396+ func TestFishFormat_QuoteWordBacktick (t * testing.T ) {
397+ // Backtick is a regular character in fish — should not trigger quoting
398+ q := fishQuoteWord ("hello`world" )
399+ if q != "hello`world" {
400+ t .Errorf ("fishQuoteWord backtick: got %q, want %q" , q , "hello`world" )
401+ }
402+ }
403+
404+ func TestFishFormat_QuoteWordDollar (t * testing.T ) {
405+ q := fishQuoteWord ("hello$world" )
406+ if q != `"hello\$world"` {
407+ t .Errorf ("fishQuoteWord dollar: got %q, want %q" , q , `"hello\$world"` )
408+ }
409+ }
410+
411+ func TestFishFormat_QuoteWordSafe (t * testing.T ) {
412+ q := fishQuoteWord ("hello-world" )
413+ if q != "hello-world" {
414+ t .Errorf ("fishQuoteWord safe: got %q, want %q" , q , "hello-world" )
415+ }
416+ }
417+
418+ func TestFishFormat_CompletionAndAnd (t * testing.T ) {
419+ ctx := SplitForCompletion ("echo foo && echo bar hel" , FishFormat ())
420+ if ctx .CurrentWord != "hel" {
421+ t .Errorf ("fish && completion: CurrentWord = %q, want %q" , ctx .CurrentWord , "hel" )
422+ }
423+ if len (ctx .Words ) != 3 {
424+ t .Errorf ("fish && completion: Words = %v, want 3 (echo bar hel)" , ctx .Words )
425+ }
426+ }
427+
428+ func TestFishFormat_CompletionBackground (t * testing.T ) {
429+ ctx := SplitForCompletion ("echo foo & echo bar hel" , FishFormat ())
430+ if ctx .CurrentWord != "hel" {
431+ t .Errorf ("fish & completion: CurrentWord = %q, want %q" , ctx .CurrentWord , "hel" )
432+ }
433+ if len (ctx .Words ) != 3 {
434+ t .Errorf ("fish & completion: Words = %v, want 3 (echo bar hel)" , ctx .Words )
435+ }
436+ }
0 commit comments