Over the past several weeks I've been working on and off on this. I didn't track the hours but I'm sure that I've spent 80+ hours on the feature. I've tested and retested all known scenarios and it seems to be working as expected.
-- v14: cmd: Implement tab completion for command line entry.
From: Joe Souza jsouza@yahoo.com
--- programs/cmd/Makefile.in | 1 + programs/cmd/batch.c | 4 +- programs/cmd/lineedit.c | 431 +++++++++++++++++++++++++++++++++++++++ programs/cmd/wcmd.h | 1 + 4 files changed, 434 insertions(+), 3 deletions(-) create mode 100755 programs/cmd/lineedit.c
diff --git a/programs/cmd/Makefile.in b/programs/cmd/Makefile.in index cca40b84011..0c4c283505d 100644 --- a/programs/cmd/Makefile.in +++ b/programs/cmd/Makefile.in @@ -8,5 +8,6 @@ SOURCES = \ builtins.c \ cmd.rc \ directory.c \ + lineedit.c \ wcmd.svg \ wcmdmain.c diff --git a/programs/cmd/batch.c b/programs/cmd/batch.c index 20ae894dbfd..a06cc7bc495 100644 --- a/programs/cmd/batch.c +++ b/programs/cmd/batch.c @@ -227,9 +227,7 @@ WCHAR *WCMD_fgets(WCHAR *buf, DWORD noChars, HANDLE h) /* We can't use the native f* functions because of the filename syntax differences between DOS and Unix. Also need to lose the LF (or CRLF) from the line. */
- if (VerifyConsoleIoHandle(h) && ReadConsoleW(h, buf, noChars, &charsRead, NULL) && charsRead) { - if (!charsRead) return NULL; - + if (VerifyConsoleIoHandle(h) && WCMD_ReadConsole(h, buf, noChars, &charsRead) && charsRead) { /* Find first EOL */ for (i = 0; i < charsRead; i++) { if (buf[i] == '\n' || buf[i] == '\r') diff --git a/programs/cmd/lineedit.c b/programs/cmd/lineedit.c new file mode 100755 index 00000000000..b514484f2e9 --- /dev/null +++ b/programs/cmd/lineedit.c @@ -0,0 +1,431 @@ +/* + * CMD - Wine-compatible command line interface - Command line editing + * functions, including tab-completion support + * + * Copyright (C) 2025 Joe Souza + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + */ + +#define WIN32_LEAN_AND_MEAN + +#include "wcmd.h" +#include "wine/debug.h" + +WINE_DEFAULT_DEBUG_CHANNEL(cmd); + +typedef struct _SEARCH_CONTEXT +{ + HANDLE hSearch; + WIN32_FIND_DATAW findData; + BOOL haveQuotes; + BOOL ignoreTrailingQuote; + BOOL isDirSearch; + BOOL haveSearchResult; + BOOL hadOneMatch; + int lastStartQuote; + int searchPos; + int insertPos; +} SEARCH_CONTEXT, *PSEARCH_CONTEXT; + + +static BOOL IsDirectoryOperation(const WCHAR *inputBuffer, const ULONG inputBufferLength) +{ + int cc = 0; + BOOL ret = FALSE; + + while (cc < inputBufferLength && (inputBuffer[cc] == L' ' || inputBuffer[cc] == L'\t')) { + cc++; + } + + if ((cc + 2 < inputBufferLength && (inputBuffer[cc + 2] == L' ' || inputBuffer[cc + 2] == L'\t')) && + (!_wcsnicmp(&inputBuffer[cc], L"cd", 2) || + !_wcsnicmp(&inputBuffer[cc], L"rd", 2) || + !_wcsnicmp(&inputBuffer[cc], L"md", 2))) { + + ret = TRUE; + } else if ((cc + 5 < inputBufferLength && (inputBuffer[cc + 5] == L' ' || inputBuffer[cc + 5] == L'\t')) && + (!_wcsnicmp(&inputBuffer[cc], L"chdir", 5) || + !_wcsnicmp(&inputBuffer[cc], L"rmdir", 5) || + !_wcsnicmp(&inputBuffer[cc], L"mkdir", 5))) { + + ret = TRUE; + } + + return ret; +} + +static void ClearConsoleCharacters(const HANDLE hOutput, SHORT cCount, const SHORT width) +{ + CONSOLE_SCREEN_BUFFER_INFO csbi; + DWORD written; + SHORT chars; + + GetConsoleScreenBufferInfo(hOutput, &csbi); + + /* Need to handle clearing multiple lines, in case user resized console window. */ + while (cCount) { + chars = min(width - csbi.dwCursorPosition.X, cCount); + FillConsoleOutputCharacterW(hOutput, L' ', chars, csbi.dwCursorPosition, &written); + csbi.dwCursorPosition.Y++; /* Bump to next row. */ + csbi.dwCursorPosition.X = 0; /* First column in the row. */ + cCount -= chars; + } +} + +static void SetCursorVisible(const HANDLE hOutput, const BOOL visible) +{ + CONSOLE_CURSOR_INFO cursorInfo; + + if (GetConsoleCursorInfo(hOutput, &cursorInfo)) { + cursorInfo.bVisible = visible; + SetConsoleCursorInfo(hOutput, &cursorInfo); + } +} + +static void FindSearchPos(const WCHAR *inputBuffer, int len, PSEARCH_CONTEXT sc) +{ + int cc = 0; + + /* Handle spaces in directory names. Need to quote paths if they contain spaces. + * Also, there can be multiple quoted paths on a command line, so find the current + * one. + */ + while (cc < len) { + if (inputBuffer[cc] == L'"') { + sc->haveQuotes = !sc->haveQuotes; + TRACE("Flip haveQuotes, now: %u\n", sc->haveQuotes); + if (sc->haveQuotes) { + sc->lastStartQuote = cc; + } + } + cc++; + } + + /* Special case: If user entered a backslash after quotes, ignore the final quotes. */ + if (len > 2 && inputBuffer[len-2] == L'"' && inputBuffer[len-1] == L'\') { + TRACE("Ignore quote before trailing backslash\n"); + sc->ignoreTrailingQuote = sc->haveQuotes = TRUE; + } + + if (sc->haveQuotes) { + /* Tab was hit inside a quoted path. Search specification is start of the path. */ + cc = sc->lastStartQuote + 1; + } else { + /* Tab was hit outside of a quoted path. Find the start of this path, + * i.e. the first character after the previous spaces and tabs. + * This will be the search specification for tab-completion. + */ + cc = len; + while (cc > 0 && inputBuffer[cc] != L' ' && inputBuffer[cc] != L'\t') { + cc--; + } + while (cc < len && (inputBuffer[cc] == L' ' || inputBuffer[cc] == L'\t')) { + cc++; + } + } + + sc->searchPos = cc; +} + +static void FindInsertPos(const WCHAR *inputBuffer, int len, PSEARCH_CONTEXT sc) +{ + int cc = len; + + /* Handle paths here. Find last '\'. */ + /* Handle spaces in directory names. Look for '\' on first pass, then quotes on second pass + * if '\' isn't found, and finally whitespace on third pass. + */ + while (cc >= sc->searchPos && inputBuffer[cc] != L'\') { + cc--; + } + + if (cc == sc->searchPos && inputBuffer[sc->searchPos] != L'\') { + /* Handle dir "Program Files" */ + TRACE("haveQuotes: %u, lastStartQuote: %u, curPos: %u\n", sc->haveQuotes, sc->lastStartQuote, len); + if (sc->haveQuotes && sc->lastStartQuote) { + cc = sc->lastStartQuote; + } else { + cc = len; + while (cc > 0 && inputBuffer[cc] != L' ' && inputBuffer[cc] != L'\t') { + cc--; + } + } + } + + if (cc != sc->searchPos || inputBuffer[sc->searchPos] == L'\') { + cc++; + } + + sc->insertPos = cc; +} + +static void FindNextMatchingDirectoryEntry(const WCHAR *searchstr, PSEARCH_CONTEXT sc) +{ + BOOL search_restart = TRUE; + + while (search_restart) { + search_restart = FALSE; + + if (sc->hSearch == INVALID_HANDLE_VALUE) { + /* On Windows, the FindExSearchLimitToDirectories specification is merely a suggestion and an + * optimization apparently intended for network file systems. + */ + sc->hSearch = FindFirstFileExW(searchstr, FindExInfoStandard, &sc->findData, sc->isDirSearch ? FindExSearchLimitToDirectories : FindExSearchNameMatch, NULL, 0); + if (sc->hSearch != INVALID_HANDLE_VALUE) { + /* Always skip "." and ".." entries. */ + if (wcscmp(sc->findData.cFileName, L".") && wcscmp(sc->findData.cFileName, L"..")) { + if (!sc->isDirSearch || sc->findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + sc->haveSearchResult = TRUE; + } + } + } + } + /* Subsequent search */ + if (sc->hSearch != INVALID_HANDLE_VALUE && !sc->haveSearchResult) { + while (!sc->haveSearchResult && FindNextFileW(sc->hSearch, &sc->findData)) { + /* Always skip "." and ".." entries. */ + if (wcscmp(sc->findData.cFileName, L".") && wcscmp(sc->findData.cFileName, L"..")) { + if (!sc->isDirSearch || sc->findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + sc->haveSearchResult = TRUE; + } + } + } + + if (!sc->haveSearchResult && sc->hadOneMatch) { + sc->hadOneMatch = FALSE; + /* Reached the last match, start over. */ + FindClose(sc->hSearch); + sc->hSearch = INVALID_HANDLE_VALUE; + search_restart = TRUE; + } + } + } + + if (sc->haveSearchResult) { + sc->hadOneMatch = TRUE; + } +} + +static void UpdateInputBuffer(WCHAR *inputBuffer, const DWORD inputBufferLength, PSEARCH_CONTEXT sc) +{ + BOOL needQuotes = FALSE; + int len; + + /* We have found the insert position for the results. Terminate the string here. */ + inputBuffer[sc->insertPos] = L'\0'; + + needQuotes = wcschr(sc->findData.cFileName, L' ') || sc->findData.cAlternateFileName[0]; + /* Add starting quotes if needed. */ + if (needQuotes && !sc->haveQuotes) { + len = lstrlenW(inputBuffer); + if (len < inputBufferLength - 1) { + if (sc->searchPos <= len) { + memmove(&inputBuffer[sc->searchPos+1], &inputBuffer[sc->searchPos], (len - sc->searchPos + 1) * sizeof(WCHAR)); + inputBuffer[sc->searchPos] = L'"'; + sc->haveQuotes = TRUE; + sc->lastStartQuote = sc->searchPos; + sc->insertPos++; + } + } + } + lstrcatW(inputBuffer, sc->findData.cFileName); + /* Add closing quotes if needed. */ + if (needQuotes || sc->haveQuotes) { + len = lstrlenW(inputBuffer); + if (len < inputBufferLength - 1) { + inputBuffer[len] = L'"'; + inputBuffer[len+1] = L'\0'; + } + } +} + + +/* Intended as a mostly drop-in replacement for ReadConsole, but with tab-completion support. + */ +BOOL WCMD_ReadConsole(const HANDLE hInput, WCHAR *inputBuffer, const DWORD inputBufferLength, LPDWORD numRead) +{ + HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE); + SEARCH_CONTEXT sc = {0}; + WCHAR searchstr[MAX_PATH]; + CONSOLE_SCREEN_BUFFER_INFO startConsoleInfo, lastConsoleInfo; + DWORD numWritten; + UINT oldCurPos, curPos; + BOOL done = FALSE; + BOOL ret = FALSE; + static int maxLen = 0; /* Track maximum length in case user fetches a long string from a previous iteration in history. */ + + if (hInput == INVALID_HANDLE_VALUE || hOutput == INVALID_HANDLE_VALUE || !inputBuffer || !inputBufferLength) { + return FALSE; + } + + *inputBuffer = L'\0'; + curPos = 0; + + sc.hSearch = INVALID_HANDLE_VALUE; + + /* Get starting cursor position and size */ + GetConsoleScreenBufferInfo(hOutput, &startConsoleInfo); + lastConsoleInfo = startConsoleInfo; + + while (!done) { + CONSOLE_READCONSOLE_CONTROL inputControl; + int len; + + len = lstrlenW(inputBuffer); + + /* Update current input display in console */ + SetCursorVisible(hOutput, FALSE); + SetConsoleCursorPosition(hOutput, startConsoleInfo.dwCursorPosition); + WriteConsoleW(hOutput, inputBuffer, len, &numWritten, NULL); + if (maxLen > len) { + ClearConsoleCharacters(hOutput, maxLen - len, lastConsoleInfo.dwSize.X); /* width at time of last console update */ + } + SetCursorVisible(hOutput, TRUE); + + /* Remember current dimensions in case user resizes console window. */ + GetConsoleScreenBufferInfo(hOutput, &lastConsoleInfo); + + inputControl.nLength = sizeof(inputControl); + inputControl.nInitialChars = len; + /* FIXME: 0x1B (ESC) does not work in the Linux console. Works in the Wine console, though. */ + inputControl.dwCtrlWakeupMask = (1 << '\t') | (1 << 0x1B); + /* FIXME: In the Windows SDK this is called dwControlKeyState */ + inputControl.dwConsoleKeyState = 0; + + /* Allow room for NULL terminator. inputBufferLength is at least 1 due to check above. */ + ret = ReadConsoleW(hInput, inputBuffer, inputBufferLength - 1, numRead, &inputControl); + + if (ret) { + inputBuffer[*numRead] = L'\0'; + TRACE("ReadConsole: [%lu][%s]\n", *numRead, wine_dbgstr_w(inputBuffer)); + len = *numRead; + if (len > maxLen) { + maxLen = len; + } + oldCurPos = curPos; + curPos = 0; + while (curPos < len && + inputBuffer[curPos] != L'\t' && + inputBuffer[curPos] != L'\x1B' + ) { + + curPos++; + } + /* curPos is often numRead - 1, but not always, as in the case where history is retrieved + * and then user backspaces to somewhere mid-string and then hits Tab. + */ + TRACE("numRead: %lu, curPos: %u\n", *numRead, curPos); + + switch (inputBuffer[curPos]) { + case L'\t': + TRACE("TAB: [%s]\n", wine_dbgstr_w(inputBuffer)); + inputBuffer[curPos] = L'\0'; + len = curPos; + sc.haveSearchResult = FALSE; + + /* See if we need to reset an existing search. */ + if (sc.hSearch != INVALID_HANDLE_VALUE) { + /* If tab key was hit at a different location than last time */ + if (curPos != oldCurPos) { + TRACE("***** Reset search, tab hit at different location, curPos: %u, oldCurPos: %u\n", curPos, oldCurPos); + FindClose(sc.hSearch); + sc.hSearch = INVALID_HANDLE_VALUE; + } + } + + /* If new search */ + if (sc.hSearch == INVALID_HANDLE_VALUE) { + sc.lastStartQuote = 0; + sc.haveQuotes = FALSE; + sc.ignoreTrailingQuote = FALSE; + sc.searchPos = 0; + sc.insertPos = 0; + + FindSearchPos(inputBuffer, curPos, &sc); + + /* Build search string. */ + lstrcpynW(searchstr, &inputBuffer[sc.searchPos], ARRAY_SIZE(searchstr) - 2); + if (sc.ignoreTrailingQuote) { + len = lstrlenW(searchstr); + searchstr[len-2] = L'\'; + searchstr[len-1] = L'\0'; + } + lstrcatW(searchstr, L"*"); + TRACE("New search: [%s]\n", wine_dbgstr_w(searchstr)); + + sc.isDirSearch = IsDirectoryOperation(inputBuffer, inputBufferLength); + } + + FindNextMatchingDirectoryEntry(searchstr, &sc); + + if (sc.haveSearchResult) { + /* If this is our first time through here for this search, we need to find the insert position + * for the results. Note that this is very likely not the same location as the search position. + */ + if (!sc.insertPos) { + /* If user entered a backslash after quotes, remove the quotes. */ + if (curPos > 2 && inputBuffer[curPos-2] == L'"' && inputBuffer[curPos-1] == L'\') { + TRACE("Remove quote before trailing backslash\n"); + inputBuffer[curPos-2] = L'\'; + inputBuffer[curPos-1] = L'\0'; + curPos--; + } + + FindInsertPos(inputBuffer, curPos, &sc); + } + + /* Copy search results to input buffer. */ + UpdateInputBuffer(inputBuffer, inputBufferLength, &sc); + + /* Update cursor position to end of buffer. */ + curPos = lstrlenW(inputBuffer); + if (curPos > maxLen) { + maxLen = curPos; + } + } + break; + + case L'\x1B': /* ESC */ + /* FIXME: We shouldn't need to handle the ESC key in this code. ReadConsole on Windows will clear the text + * from the screen and the buffer, but Wine does not, instead emitting '^]' at the console. + */ + TRACE("ESC: [%s], len: %u, maxLen: %u\n", wine_dbgstr_w(inputBuffer), len, maxLen); + /* Clear buffer. Screen will be cleaned at top of loop. */ + inputBuffer[0] = L'\0'; + curPos = 0; + break; + + default: + TRACE("RETURN: [%s]\n", wine_dbgstr_w(inputBuffer)); + done = TRUE; + break; + } + + } else { + /* ReadConsole failed */ + done = TRUE; + } + } + + /* Close any existing search before exiting. */ + if (sc.hSearch != INVALID_HANDLE_VALUE) { + FindClose(sc.hSearch); + sc.hSearch = INVALID_HANDLE_VALUE; + } + + return ret; +} + diff --git a/programs/cmd/wcmd.h b/programs/cmd/wcmd.h index d4adc7fd7c8..4f8486bbc9e 100644 --- a/programs/cmd/wcmd.h +++ b/programs/cmd/wcmd.h @@ -212,6 +212,7 @@ WCHAR *WCMD_LoadMessage(UINT id); WCHAR *WCMD_strsubstW(WCHAR *start, const WCHAR* next, const WCHAR* insert, int len); RETURN_CODE WCMD_wait_for_input(HANDLE hIn); BOOL WCMD_ReadFile(const HANDLE hIn, WCHAR *intoBuf, const DWORD maxChars, LPDWORD charsRead); +BOOL WCMD_ReadConsole(const HANDLE hInput, WCHAR *inputBuffer, const DWORD inputBufferLength, LPDWORD numRead);
enum read_parse_line {RPL_SUCCESS, RPL_EOF, RPL_SYNTAXERROR}; enum read_parse_line WCMD_ReadAndParseLine(const WCHAR *initialcmd, CMD_NODE **output);