001/*
002 * jPOS Project [http://jpos.org]
003 * Copyright (C) 2000-2026 jPOS Software SRL
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jpos.q2;
020
021import org.jline.reader.*;
022import org.jline.reader.impl.history.DefaultHistory;
023import org.jline.terminal.Attributes;
024import org.jline.terminal.Terminal;
025import org.jline.terminal.TerminalBuilder;
026
027import java.io.*;
028import java.util.*;
029import java.util.logging.Level;
030import java.util.logging.Logger;
031
032/** Interactive command-line interface for a running Q2 instance. */
033public class CLI implements Runnable {
034    final private static String DEFAULT_PROMPT = "q2> ";
035    final private static String ESCAPED_SEMICOLON = "__semicolon__";
036    private Thread t;
037    private String line = null;
038    private boolean keepRunning = false;
039    private boolean interactive = false;
040    /** The context for this CLI session. */
041    protected CLIContext ctx;
042    private CLICommandInterface cmdInterface;
043    private Terminal terminal;
044    private LineReader reader;
045    private Q2 q2;
046    private String prompt = DEFAULT_PROMPT;
047    private History mainHistory;
048
049    /**
050     * Creates a simple CLI with a single command and no streams.
051     * @param q2 the Q2 instance
052     * @param line the initial command line
053     * @param keepRunning true to keep running after the first command
054     * @throws IOException on I/O failure
055     */
056    public CLI(Q2 q2, String line, boolean keepRunning) throws IOException {
057        this(q2, System.in, System.out, line, keepRunning, true);
058    }
059
060    /**
061     * Creates a full CLI with explicit I/O streams.
062     * @param q2 the Q2 instance
063     * @param in input stream
064     * @param rawout output stream
065     * @param line initial command (may be null for interactive mode)
066     * @param keepRunning true to keep running after commands
067     * @param interactive true for interactive (line-edit) mode
068     * @throws IOException on I/O failure
069     */
070    public CLI(Q2 q2, InputStream in, OutputStream rawout, String line, boolean keepRunning, boolean interactive) throws IOException {
071        Logger.getLogger("org.jline").setLevel(Level.SEVERE);
072        this.q2 = q2;
073        PrintStream out = rawout instanceof PrintStream ? (PrintStream) rawout : new PrintStream(rawout);
074        ctx = buildCLIContext(in, out);
075        this.line = line;
076        this.keepRunning = keepRunning;
077        this.interactive = interactive;
078        this.mainHistory = new DefaultHistory();
079        if (interactive) {
080            terminal = terminal = buildTerminal(in, out);
081        }
082        initCmdInterface(getCompletionPrefixes(), mainHistory);
083    }
084
085    /**
086     * Returns true if this CLI is still running.
087     * @return true if running
088     */
089    protected boolean running() {
090        return getQ2() == null || getQ2().running();
091    }
092
093    /** Called when the CLI is stopping; subclasses may override. */
094    protected void markStopped() { }
095
096    /** Called when the CLI is starting; subclasses may override. */
097    protected void markStarted() { }
098
099    /**
100     * Returns command prefixes registered for tab-completion.
101     * @return array of command prefixes
102     */
103    protected String[] getCompletionPrefixes() {
104        return new String[] {"org.jpos.q2.cli." };
105    }
106
107    /** Called on normal exit; subclasses may override. */
108    protected void handleExit() { }
109
110    void setPrompt(String prompt, String[] completionPrefixes) throws IOException {
111        this.prompt = prompt != null ? prompt : DEFAULT_PROMPT;
112        initCmdInterface(completionPrefixes, completionPrefixes == null ? mainHistory : new DefaultHistory());
113    }
114
115    private void initCmdInterface(String[] completionPrefixes, History history) throws IOException {
116        completionPrefixes = completionPrefixes == null ? getCompletionPrefixes() : completionPrefixes;
117        cmdInterface = new CLICommandInterface(ctx);
118        for (String s : completionPrefixes) {
119            cmdInterface.addPrefix(s);
120        }
121        cmdInterface.addPrefix("org.jpos.q2.cli.builtin.");
122        if (terminal != null) {
123            reader = buildReader(terminal, completionPrefixes, history);
124            ctx.setReader(reader);
125        }
126    }
127
128    /** Starts the CLI session.
129     * @throws Exception on startup failure
130     */
131    public void start() throws Exception {
132        markStarted();
133        t = new Thread(this);
134        t.setName("Q2-CLI");
135        t.start();
136    }
137
138    /** Stops the CLI session. */
139    public void stop() {
140        markStopped();
141        try {
142            t.join();
143        }
144        catch (InterruptedException ignored) { }
145    }
146
147    public void run() {
148        while (running()) {
149            try {
150                LineReader reader = getReader();
151                String p = prompt;
152                if (line == null) {
153                    String s;
154                    while ((s = reader.readLine(p)) != null) {
155                        if (s.endsWith("\\")) {
156                            s = s.substring(0, s.length() -1);
157                            p = "";
158                            line = line == null ? s : line + s;
159                            continue;
160                        }
161                        line = line == null ? s : line + s;
162                        break;
163                    }
164                }
165                if (line != null) {
166                    line = line.replace("\\;", ESCAPED_SEMICOLON);
167                    StringTokenizer st = new StringTokenizer(line, ";");
168                    boolean exit = false;
169                    while (st.hasMoreTokens()) {
170                        String n = st.nextToken().replace (ESCAPED_SEMICOLON, ";");
171                        try {
172                            String[] args = cmdInterface.parseCommand(n);
173                            if (args.length > 0 && args[0].contains(":")) {
174                                String prefixCommand = args[0].substring(0, args[0].indexOf(":"));
175                                cmdInterface.execCommand(prefixCommand);
176                                n = n.substring(prefixCommand.length() + 1);
177                                exit = true;
178                            }
179                            cmdInterface.execCommand(n);
180                        } catch (IOException e) {
181                            ctx.printThrowable(e);
182                        }
183                    }
184                    line = null;
185                    if (exit) {
186                        try {
187                            cmdInterface.execCommand("exit");
188                        } catch (IOException e) {
189                            ctx.printThrowable(e);
190                        }
191                    }
192                }
193                if (!keepRunning) {
194                    break;
195                }
196
197            } catch (UserInterruptException | EndOfFileException e) {
198                break;
199            }
200        }
201        try {
202            if (keepRunning)
203                getReader().getTerminal().close();
204        } catch (IOException e) {
205            ctx.printThrowable(e);
206        }
207        handleExit();
208    }
209
210    /**
211     * Returns the Q2 instance this CLI is attached to.
212     * @return the Q2 instance
213     */
214    public Q2 getQ2() {
215        return q2;
216    }
217
218    /**
219     * Returns true if this CLI session is interactive.
220     * @return true if interactive
221     */
222    public boolean isInteractive() {
223        return interactive;
224    }
225
226    /**
227     * Returns the JLine3 LineReader for this session.
228     * @return the LineReader
229     */
230    public LineReader getReader() {
231        return reader;
232    }
233
234    /**
235     * Executes a CLI command with the given I/O streams.
236     * @param in input stream
237     * @param out output stream
238     * @param command command to execute
239     * @throws Exception on execution failure
240     */
241    public static void exec (InputStream in, OutputStream out, String command) throws Exception {
242        CLI cli = new CLI(Q2.getQ2(), in, out, command, false, false);
243        cli.start();
244        cli.stop();
245    }
246
247    /**
248     * Executes a CLI command and captures its output as a string.
249     * @param command command string to execute
250     * @return captured output
251     * @throws Exception on execution failure
252     */
253    public static String exec (String command) throws Exception {
254        ByteArrayOutputStream out = new ByteArrayOutputStream();
255        exec (null, out, command);
256        return out.toString();
257    }
258
259    /**
260     * Builds a JLine3 Terminal for this session.
261     * @param in input stream
262     * @param out output stream
263     * @return the Terminal
264     * @throws IOException on I/O failure
265     */
266    protected Terminal buildTerminal (InputStream in, OutputStream out) throws IOException {
267        TerminalBuilder builder = TerminalBuilder.builder()
268            .streams(in,out)
269            .system(System.in == in);
270        Terminal t = builder.build();
271        Attributes attr = t.getAttributes();
272        attr.getOutputFlags().addAll(
273            EnumSet.of(Attributes.OutputFlag.ONLCR, Attributes.OutputFlag.OPOST)
274        );
275        t.setAttributes(attr);
276         return t;
277    }
278
279
280    private LineReader buildReader(Terminal terminal, String[] completionPrefixes, History history) throws IOException {
281        LineReader reader = LineReaderBuilder.builder()
282          .terminal(terminal)
283          .history(history)
284          .completer(new CLIPrefixedClassNameCompleter(Arrays.asList(completionPrefixes)))
285          .build();
286        reader.unsetOpt(LineReader.Option.INSERT_TAB);
287        reader.setOpt(LineReader.Option.DISABLE_EVENT_EXPANSION);
288        return reader;
289    }
290
291    private CLIContext buildCLIContext (InputStream in, OutputStream out) {
292        return CLIContext.builder()
293                .cli(this)
294                .in(in)
295                .out(out)
296                .build();
297    }
298}