GROOVY-11742: posix commands should support variable assignment (amendments for /tail)
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
index 3d07e07..84a8be0 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
@@ -82,7 +82,7 @@
 class Main {
     private static final MessageSource messages = new MessageSource(Main)
     public static final String INTERPRETER_MODE_PREFERENCE_KEY = 'interpreterMode'
-    private static POSIX_FILE_CMDS = ['/tail', '/wc', '/sort']
+    private static POSIX_FILE_CMDS = ['/wc', '/sort']
 
     @SuppressWarnings("resource")
     protected static class ExtraConsoleCommands extends JlineCommandRegistry implements CommandRegistry {
@@ -114,6 +114,7 @@
                 '/ls'   : new CommandMethods((Function) this::ls, this::optFileCompleter),
                 '/grep' : new CommandMethods((Function) this::grepcmd, this::optFileCompleter),
                 '/head' : new CommandMethods((Function) this::headcmd, this::optFileCompleter),
+                '/tail' : new CommandMethods((Function) this::tailcmd, this::optFileCompleter),
                 '/cat'  : new CommandMethods((Function) this::cat, this::optFileCompleter),
                 "/!"    : new CommandMethods((Function) this::shell, this::defaultCompleter)
             ]
@@ -199,6 +200,14 @@
             }
         }
 
+        private void tailcmd(CommandInput input) {
+            try {
+                GroovyPosixCommands.tail(context(input), ['/tail', *input.xargs()] as Object[])
+            } catch (Exception e) {
+                saveException(e)
+            }
+        }
+
         private GroovyPosixContext context(CommandInput input) {
             GroovyPosixContext ctx = new GroovyPosixContext(input.in(), input.out(), input.err(),
                 posix.context.currentDir(), input.terminal(), scriptEngine::get)
diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyPosixCommands.java b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyPosixCommands.java
index 5cde0fc..dcf56c2 100644
--- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyPosixCommands.java
+++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyPosixCommands.java
@@ -31,6 +31,7 @@
 
 import java.io.BufferedReader;
 import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -115,15 +116,17 @@
     public static void head(Context context, Object[] argv) throws Exception {
         final String[] usage = {
             "/head - display first lines of files or variables",
-            "Usage: /head [-n lines | -c bytes] [file|variable ...]",
+            "Usage: /head [-n lines | -c bytes | -q | -v] [file|variable ...]",
             "  -? --help                    Show help",
             "  -n --lines=LINES             Print line counts",
             "  -c --bytes=BYTES             Print byte counts",
+            "  -q --quiet                   Never output filename headers",
+            "  -v --verbose                 Always output filename headers",
         };
         Options opt = parseOptions(context, usage, argv);
 
         if (opt.isSet("lines") && opt.isSet("bytes")) {
-            throw new IllegalArgumentException("usage: head [-n # | -c #] [file ...]");
+            throw new IllegalArgumentException("usage: /head [-n # | -c # | -q | -v] [file|variable ...]");
         }
 
         int nbLines = Integer.MAX_VALUE;
@@ -144,35 +147,110 @@
         boolean first = true;
         List<NamedInputStream> sources = getSources(context, argv, args);
         for (NamedInputStream nis : sources) {
-            if (!first && args.size() > 1) {
-                context.out().println();
+            boolean filenameHeader = sources.size() > 1;
+            if (opt.isSet("verbose")) {
+                filenameHeader = true;
+            } else if (opt.isSet("quiet")) {
+                filenameHeader = false;
             }
-            if (args.size() > 1) {
+            if (filenameHeader) {
+                if (!first) {
+                    context.out().println();
+                }
                 context.out().println("==> " + nis.getName() + " <==");
             }
-
-            InputStream is = nis.getInputStream();
-            if (nbLines != Integer.MAX_VALUE) {
-                try (BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
-                    String line;
-                    int count = 0;
-                    while ((line = reader.readLine()) != null && count < nbLines) {
-                        context.out().println(line);
-                        count++;
-                    }
-                }
-            } else {
-                byte[] buffer = new byte[nbBytes];
-                int bytesRead = is.read(buffer);
-                if (bytesRead > 0) {
-                    context.out().write(buffer, 0, bytesRead);
-                }
-                is.close();
-            }
+            doHead(context, nis.getInputStream(), nbLines, nbBytes);
             first = false;
         }
     }
 
+    private static void doHead(Context context, InputStream is, final int nbLines, final int nbBytes) throws IOException {
+        if (nbLines != Integer.MAX_VALUE) {
+            try (BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
+                String line;
+                int count = 0;
+                while ((line = reader.readLine()) != null && count < nbLines) {
+                    context.out().println(line);
+                    count++;
+                }
+            }
+        } else {
+            byte[] buffer = new byte[nbBytes];
+            int bytesRead = is.read(buffer);
+            if (bytesRead > 0) {
+                context.out().write(buffer, 0, bytesRead);
+            }
+            is.close();
+        }
+    }
+
+    public static void tail(Context context, Object[] argv) throws Exception {
+        final String[] usage = {
+            "/tail - display last lines of files or variables",
+            "Usage: /tail [-n lines | -c bytes | -q | -v] [file|variable ...]",
+            "  -? --help                    Show help",
+            "  -n --lines=LINES             Number of lines to print",
+            "  -c --bytes=BYTES             Number of bytes to print",
+            "  -q --quiet                   Never output filename headers",
+            "  -v --verbose                 Always output filename headers",
+        };
+        Options opt = parseOptions(context, usage, argv);
+
+        if (opt.isSet("lines") && opt.isSet("bytes")) {
+            throw new IllegalArgumentException("usage: /tail [-c # | -n # | -q | -v] [file|variable ...]");
+        }
+
+        int lines = opt.isSet("lines") ? opt.getNumber("lines") : 10;
+        int bytes = opt.isSet("bytes") ? opt.getNumber("bytes") : -1;
+
+        List<String> args = opt.args();
+        if (args.isEmpty()) {
+            args = Collections.singletonList("-");
+        }
+
+        List<NamedInputStream> sources = getSources(context, argv, args);
+        boolean filenameHeader = sources.size() > 1;
+        if (opt.isSet("verbose")) {
+            filenameHeader = true;
+        } else if (opt.isSet("quiet")) {
+            filenameHeader = false;
+        }
+        for (NamedInputStream nis : sources) {
+            if (filenameHeader) {
+                context.out().println("==> " + nis.getName() + " <==");
+            }
+            tailInputStream(context, nis.getInputStream(), lines, bytes);
+        }
+    }
+
+    private static void tailInputStream(Context context, InputStream is, int lines, int bytes) throws IOException {
+        if (bytes > 0) {
+            // Read all and keep last bytes
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            byte[] buffer = new byte[8192];
+            int n;
+            while ((n = is.read(buffer)) != -1) {
+                baos.write(buffer, 0, n);
+            }
+            byte[] data = baos.toByteArray();
+            int start = Math.max(0, data.length - bytes);
+            context.out().write(data, start, data.length - start);
+        } else {
+            // Read all and keep last lines
+            List<String> allLines = new ArrayList<>();
+            try (BufferedReader reader = new BufferedReader(new java.io.InputStreamReader(is))) {
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    allLines.add(line);
+                }
+            }
+            int start = Math.max(0, allLines.size() - lines);
+            for (int i = start; i < allLines.size(); i++) {
+                context.out().println(allLines.get(i));
+            }
+        }
+    }
+
     private static InputStream newInputStream(Path p) {
         try {
             return Files.newInputStream(p);