From 473d6d20a0cfa3d44826c6cbc7c1af642be0f4a7 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 2 Oct 2023 16:44:11 +0200 Subject: [PATCH] Fix terminal width support on MINGW (fixes #233) (#264) --- .../org/fusesource/jansi/AnsiConsole.java | 27 +++- .../java/org/fusesource/jansi/AnsiMain.java | 34 ++++- .../jansi/internal/MingwSupport.java | 137 ++++++++++++++++++ 3 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/fusesource/jansi/internal/MingwSupport.java diff --git a/src/main/java/org/fusesource/jansi/AnsiConsole.java b/src/main/java/org/fusesource/jansi/AnsiConsole.java index 9ce1b9a3..7fe9271d 100644 --- a/src/main/java/org/fusesource/jansi/AnsiConsole.java +++ b/src/main/java/org/fusesource/jansi/AnsiConsole.java @@ -29,6 +29,7 @@ import org.fusesource.jansi.internal.CLibrary; import org.fusesource.jansi.internal.CLibrary.WinSize; import org.fusesource.jansi.internal.Kernel32.CONSOLE_SCREEN_BUFFER_INFO; +import org.fusesource.jansi.internal.MingwSupport; import org.fusesource.jansi.io.AnsiOutputStream; import org.fusesource.jansi.io.AnsiProcessor; import org.fusesource.jansi.io.FastBufferedOutputStream; @@ -280,6 +281,15 @@ private static AnsiPrintStream ansiStream(boolean stdout) { final long console = GetStdHandle(stdout ? STD_OUTPUT_HANDLE : STD_ERROR_HANDLE); final int[] mode = new int[1]; final boolean isConsole = GetConsoleMode(console, mode) != 0; + final AnsiOutputStream.WidthSupplier kernel32Width = new AnsiOutputStream.WidthSupplier() { + @Override + public int getTerminalWidth() { + CONSOLE_SCREEN_BUFFER_INFO info = new CONSOLE_SCREEN_BUFFER_INFO(); + GetConsoleScreenBufferInfo(console, info); + return info.windowWidth(); + } + }; + if (isConsole && SetConsoleMode(console, mode[0] | ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0) { SetConsoleMode(console, mode[0]); // set it back for now, but we know it works processor = null; @@ -299,11 +309,19 @@ public void run() throws IOException { } } }; + width = kernel32Width; } else if ((IS_CONEMU || IS_CYGWIN || IS_MSYSTEM) && !isConsole) { // ANSI-enabled ConEmu, Cygwin or MSYS(2) on Windows... processor = null; type = AnsiType.Native; installer = uninstaller = null; + MingwSupport mingw = new MingwSupport(); + String name = mingw.getConsoleName(stdout); + if (name != null && !name.isEmpty()) { + width = () -> mingw.getTerminalWidth(name); + } else { + width = () -> -1; + } } else { // On Windows, when no ANSI-capable terminal is used, we know the console does not natively interpret // ANSI @@ -322,15 +340,8 @@ public void run() throws IOException { processor = proc; type = ttype; installer = uninstaller = null; + width = kernel32Width; } - width = new AnsiOutputStream.WidthSupplier() { - @Override - public int getTerminalWidth() { - CONSOLE_SCREEN_BUFFER_INFO info = new CONSOLE_SCREEN_BUFFER_INFO(); - GetConsoleScreenBufferInfo(console, info); - return info.windowWidth(); - } - }; } // We must be on some Unix variant... diff --git a/src/main/java/org/fusesource/jansi/AnsiMain.java b/src/main/java/org/fusesource/jansi/AnsiMain.java index 197c7978..360d3c45 100644 --- a/src/main/java/org/fusesource/jansi/AnsiMain.java +++ b/src/main/java/org/fusesource/jansi/AnsiMain.java @@ -28,8 +28,11 @@ import org.fusesource.jansi.Ansi.Attribute; import org.fusesource.jansi.internal.CLibrary; import org.fusesource.jansi.internal.JansiLoader; +import org.fusesource.jansi.internal.Kernel32; +import org.fusesource.jansi.internal.MingwSupport; import static org.fusesource.jansi.Ansi.ansi; +import static org.fusesource.jansi.internal.Kernel32.GetConsoleScreenBufferInfo; /** * Main class for the library, providing executable jar to diagnose Jansi setup. @@ -192,11 +195,38 @@ private static String getJansiVersion() { } private static void diagnoseTty(boolean stderr) { - int fd = stderr ? CLibrary.STDERR_FILENO : CLibrary.STDOUT_FILENO; - int isatty = CLibrary.LOADED ? CLibrary.isatty(fd) : 0; + int isatty; + int width; + if (AnsiConsole.IS_WINDOWS) { + long console = Kernel32.GetStdHandle(stderr ? Kernel32.STD_ERROR_HANDLE : Kernel32.STD_OUTPUT_HANDLE); + int[] mode = new int[1]; + isatty = Kernel32.GetConsoleMode(console, mode); + if ((AnsiConsole.IS_CONEMU || AnsiConsole.IS_CYGWIN || AnsiConsole.IS_MSYSTEM) && isatty == 0) { + MingwSupport mingw = new MingwSupport(); + String name = mingw.getConsoleName(!stderr); + if (name != null && !name.isEmpty()) { + isatty = 1; + width = mingw.getTerminalWidth(name); + } else { + isatty = 0; + width = 0; + } + } else { + Kernel32.CONSOLE_SCREEN_BUFFER_INFO info = new Kernel32.CONSOLE_SCREEN_BUFFER_INFO(); + GetConsoleScreenBufferInfo(console, info); + width = info.windowWidth(); + } + } else { + int fd = stderr ? CLibrary.STDERR_FILENO : CLibrary.STDOUT_FILENO; + isatty = CLibrary.LOADED ? CLibrary.isatty(fd) : 0; + CLibrary.WinSize ws = new CLibrary.WinSize(); + CLibrary.ioctl(fd, CLibrary.TIOCGWINSZ, ws); + width = ws.ws_col; + } System.out.println("isatty(STD" + (stderr ? "ERR" : "OUT") + "_FILENO): " + isatty + ", System." + (stderr ? "err" : "out") + " " + ((isatty == 0) ? "is *NOT*" : "is") + " a terminal"); + System.out.println("width(STD" + (stderr ? "ERR" : "OUT") + "_FILENO): " + width); } private static void testAnsi(boolean stderr) { diff --git a/src/main/java/org/fusesource/jansi/internal/MingwSupport.java b/src/main/java/org/fusesource/jansi/internal/MingwSupport.java new file mode 100644 index 00000000..be0c54a2 --- /dev/null +++ b/src/main/java/org/fusesource/jansi/internal/MingwSupport.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2009-2023 the original author(s). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fusesource.jansi.internal; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileDescriptor; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Support for MINGW terminals. + * Those terminals do not use the underlying windows terminal and there's no CLibrary available + * in these environments. We have to rely on calling {@code stty.exe} and {@code tty.exe} to + * obtain the terminal name and width. + */ +public class MingwSupport { + + private final String sttyCommand; + private final String ttyCommand; + private final Pattern columnsPatterns; + + public MingwSupport() { + String tty = null; + String stty = null; + String path = System.getenv("PATH"); + if (path != null) { + String[] paths = path.split(File.pathSeparator); + for (String p : paths) { + File ttyFile = new File(p, "tty.exe"); + if (tty == null && ttyFile.canExecute()) { + tty = ttyFile.getAbsolutePath(); + } + File sttyFile = new File(p, "stty.exe"); + if (stty == null && sttyFile.canExecute()) { + stty = sttyFile.getAbsolutePath(); + } + } + } + if (tty == null) { + tty = "tty.exe"; + } + if (stty == null) { + stty = "stty.exe"; + } + ttyCommand = tty; + sttyCommand = stty; + // Compute patterns + columnsPatterns = Pattern.compile("\\b" + "columns" + "\\s+(\\d+)\\b"); + } + + public String getConsoleName(boolean stdout) { + try { + Process p = new ProcessBuilder(ttyCommand) + .redirectInput(getRedirect(stdout ? FileDescriptor.out : FileDescriptor.err)) + .start(); + String result = waitAndCapture(p); + if (p.exitValue() == 0) { + return result.trim(); + } + } catch (Throwable t) { + if ("java.lang.reflect.InaccessibleObjectException" + .equals(t.getClass().getName())) { + System.err.println("MINGW support requires --add-opens java.base/java.lang=ALL-UNNAMED"); + } + // ignore + } + return null; + } + + public int getTerminalWidth(String name) { + try { + Process p = new ProcessBuilder(sttyCommand, "-F", name, "-a").start(); + String result = waitAndCapture(p); + if (p.exitValue() != 0) { + throw new IOException("Error executing '" + sttyCommand + "': " + result); + } + Matcher matcher = columnsPatterns.matcher(result); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)); + } + throw new IOException("Unable to parse columns"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static String waitAndCapture(Process p) throws IOException, InterruptedException { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + try (InputStream in = p.getInputStream(); + InputStream err = p.getErrorStream()) { + int c; + while ((c = in.read()) != -1) { + bout.write(c); + } + while ((c = err.read()) != -1) { + bout.write(c); + } + p.waitFor(); + } + return bout.toString(); + } + + /** + * This requires --add-opens java.base/java.lang=ALL-UNNAMED + */ + private ProcessBuilder.Redirect getRedirect(FileDescriptor fd) throws ReflectiveOperationException { + // This is not really allowed, but this is the only way to redirect the output or error stream + // to the input. This is definitely not something you'd usually want to do, but in the case of + // the `tty` utility, it provides a way to get + Class rpi = Class.forName("java.lang.ProcessBuilder$RedirectPipeImpl"); + Constructor cns = rpi.getDeclaredConstructor(); + cns.setAccessible(true); + ProcessBuilder.Redirect input = (ProcessBuilder.Redirect) cns.newInstance(); + Field f = rpi.getDeclaredField("fd"); + f.setAccessible(true); + f.set(input, fd); + return input; + } +}