From: Joe Souza jsouza@yahoo.com
--- programs/cmd/Makefile.in | 1 + programs/cmd/batch.c | 4 +- programs/cmd/lineedit.c | 362 +++++++++++++++++++++++++++++++++++++++ programs/cmd/wcmd.h | 1 + 4 files changed, 365 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..9a3ef7433a1 --- /dev/null +++ b/programs/cmd/lineedit.c @@ -0,0 +1,362 @@ +/* + * 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); + + +static BOOL IsDirectoryOperation(const WCHAR *inputBuffer, const ULONG inputBufferLength) +{ + int cc = 0; + BOOL ret = FALSE; + + while (inputBuffer[cc] == L' ' || inputBuffer[cc] == L'\t') { + cc++; + } + + if (!_wcsnicmp(&inputBuffer[cc], L"cd", 2) || + !_wcsnicmp(&inputBuffer[cc], L"chdir", 5) || + !_wcsnicmp(&inputBuffer[cc], L"rd", 2) || + !_wcsnicmp(&inputBuffer[cc], L"rmdir", 5) || + !_wcsnicmp(&inputBuffer[cc], L"md", 2) || + !_wcsnicmp(&inputBuffer[cc], L"mkdir", 5)) { + + ret = TRUE; + } + + return ret; +} + +static void ClearConsoleCharacters(const HANDLE hOutput, const SHORT cCount) +{ + CONSOLE_SCREEN_BUFFER_INFO csbi; + DWORD written; + + GetConsoleScreenBufferInfo(hOutput, &csbi); + FillConsoleOutputCharacterW(hOutput, L' ', cCount, csbi.dwCursorPosition, &written); +} + +static void SetCursorVisible(const HANDLE hOutput, const BOOL visible) +{ + CONSOLE_CURSOR_INFO cursorInfo; + + if (GetConsoleCursorInfo(hOutput, &cursorInfo)) { + cursorInfo.bVisible = visible; + SetConsoleCursorInfo(hOutput, &cursorInfo); + } +} + +/* 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); + WCHAR searchstr[MAX_PATH]; + BOOL haveSearchResult = FALSE; + BOOL hadOneMatch = FALSE; + HANDLE hSearch = INVALID_HANDLE_VALUE; + WIN32_FIND_DATAW findData = {0}; + CONSOLE_SCREEN_BUFFER_INFO startConsoleInfo; + DWORD numWritten; + UINT oldCurPos, curPos; + int searchPos, insertPos, lastStartQuote; + BOOL isDirSearch = FALSE; + BOOL haveQuotes = FALSE; + BOOL ignoreTrailingQuote = FALSE; + BOOL needQuotes = FALSE; + 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; + + /* Get starting cursor position and size */ + GetConsoleScreenBufferInfo(hOutput, &startConsoleInfo); + + while (!done) { + CONSOLE_READCONSOLE_CONTROL inputControl; + int cc, 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); + } + SetCursorVisible(hOutput, TRUE); + + inputControl.nLength = sizeof(inputControl); + inputControl.nInitialChars = len; + inputControl.dwCtrlWakeupMask = (1 << 0x09) | (1 << 0x1B); + /* FIXME: In the Windows SDK this is called dwControlKeyState */ + inputControl.dwConsoleKeyState = 0; + + ret = ReadConsoleW(hInput, inputBuffer, inputBufferLength, numRead, &inputControl); + + if (ret) { + inputBuffer[*numRead] = L'\0'; + TRACE("ReadConsole: [%lu][%s]\n", *numRead, wine_dbgstr_w(inputBuffer)); + len = lstrlenW(inputBuffer); + if (len > maxLen) { + maxLen = len; + } + oldCurPos = curPos; + curPos = 0; + while (curPos < inputBufferLength && + inputBuffer[curPos] && + inputBuffer[curPos] != L'\x09' && + 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'\x09': /* Tab */ + TRACE("TAB: [%s]\n", wine_dbgstr_w(inputBuffer)); + inputBuffer[curPos] = L'\0'; + haveSearchResult = needQuotes = FALSE; + + /* See if we need to reset an existing search */ + if (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(hSearch); + hSearch = INVALID_HANDLE_VALUE; + } + } + + /* If new search */ + if (hSearch == INVALID_HANDLE_VALUE) { + cc = searchPos = insertPos = lastStartQuote = 0; + haveQuotes = ignoreTrailingQuote = FALSE; + + /* 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 < curPos) { + if (inputBuffer[cc] == L'"') { + haveQuotes = !haveQuotes; + TRACE("Flip haveQuotes, now: %u\n", haveQuotes); + if (haveQuotes) { + lastStartQuote = cc; + } + } + cc++; + } + + /* Special case: If user entered a backslash after quotes, ignore the final quotes. */ + if (inputBuffer[curPos-2] == L'"' && inputBuffer[curPos-1] == L'\') { + TRACE("Ignore quote before trailing backslash\n"); + ignoreTrailingQuote = haveQuotes = TRUE; + } + + if (haveQuotes) { + /* Tab was hit inside a quoted path. Search specification is start of the path. */ + cc = 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 = curPos; + while (cc > 0 && inputBuffer[cc] != L' ' && inputBuffer[cc] != L'\t') { + cc--; + } + while (cc < len && (inputBuffer[cc] == L' ' || inputBuffer[cc] == L'\t')) { + cc++; + } + } + searchPos = cc; + lstrcpynW(searchstr, &inputBuffer[searchPos], ARRAY_SIZE(searchstr) - 2); + if (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)); + isDirSearch = IsDirectoryOperation(inputBuffer, inputBufferLength); +search_restart: + /* On Windows, the FindExSearchLimitToDirectories specification is merely a suggestion and an + * optimization apparently intended for network file systems. + */ + hSearch = FindFirstFileExW(searchstr, FindExInfoStandard, &findData, isDirSearch ? FindExSearchLimitToDirectories : FindExSearchNameMatch, NULL, 0); + if (hSearch != INVALID_HANDLE_VALUE) { + /* Always skip "." and ".." entries. */ + if (wcscmp(findData.cFileName, L".") && wcscmp(findData.cFileName, L"..")) { + if (!isDirSearch || findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + haveSearchResult = TRUE; + } + } + } + } + /* Subsequent search */ + if (hSearch != INVALID_HANDLE_VALUE && !haveSearchResult) { + while (!haveSearchResult && FindNextFileW(hSearch, &findData)) { + /* Always skip "." and ".." entries. */ + if (wcscmp(findData.cFileName, L".") && wcscmp(findData.cFileName, L"..")) { + if (!isDirSearch || findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + haveSearchResult = TRUE; + } + } + } + + if (!haveSearchResult && hadOneMatch) { + hadOneMatch = FALSE; + /* Reached the last match, start over. */ + FindClose(hSearch); + goto search_restart; + } + } + + if (haveSearchResult) { + hadOneMatch = TRUE; + + /* 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 (!insertPos) { + /* If user entered a backslash after quotes, remove the quotes. */ + if (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--; + } + + len = lstrlenW(inputBuffer); + /* Handle paths here. Find last '\'. */ + cc = len; + /* 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 >= searchPos && inputBuffer[cc] != L'\') { + cc--; + } + + if (cc == searchPos && inputBuffer[searchPos] != L'\') { + /* Handle dir "Program Files" */ + TRACE("haveQuotes: %u, lastStartQuote: %u, curPos: %u\n", haveQuotes, lastStartQuote, curPos); + if (haveQuotes && lastStartQuote) { + cc = lastStartQuote; + } else { + cc = len; + while (cc > 0 && inputBuffer[cc] != L' ' && inputBuffer[cc] != L'\t') { + cc--; + } + } + } + + if (cc != searchPos || inputBuffer[searchPos] == L'\') { + cc++; + } + + insertPos = cc; + } + + /* We have found the insert position for the results. Terminate the string here. */ + inputBuffer[insertPos] = L'\0'; + + needQuotes = wcschr(findData.cFileName, L' ') || findData.cAlternateFileName[0]; + /* Add starting quotes if needed. */ + if (needQuotes && !haveQuotes) { + len = lstrlenW(inputBuffer); + if (len < inputBufferLength - 1) { + if (searchPos <= len) { + memmove(&inputBuffer[searchPos+1], &inputBuffer[searchPos], (len - searchPos + 1) * sizeof(WCHAR)); + inputBuffer[searchPos] = L'"'; + haveQuotes = TRUE; + lastStartQuote = searchPos; + insertPos++; + } + } + } + lstrcatW(inputBuffer, findData.cFileName); + /* Add closing quotes if needed. */ + if (needQuotes || haveQuotes) { + len = lstrlenW(inputBuffer); + if (len < inputBufferLength - 1) { + inputBuffer[len] = L'"'; + inputBuffer[len+1] = L'\0'; + } + } + /* Update cursor position to end of buffer. */ + curPos = lstrlenW(inputBuffer); + } + 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 and text from screen. */ + if (len) { + /* In cases where text spans multiple console lines, FillConsoleOutputCharacter does not seem + * to be working properly so we just manually write out a string full of spaces here. + */ + memset(inputBuffer, L' ', maxLen / sizeof(inputBuffer[0])); + SetCursorVisible(hOutput, FALSE); + SetConsoleCursorPosition(hOutput, startConsoleInfo.dwCursorPosition); + WriteConsoleW(hOutput, inputBuffer, maxLen, &numWritten, NULL); + } + inputBuffer[0] = L'\0'; + curPos = 0; + break; + + default: + TRACE("RETURN: [%s]\n", wine_dbgstr_w(inputBuffer)); + if (hSearch != INVALID_HANDLE_VALUE) { + FindClose(hSearch); + hSearch = INVALID_HANDLE_VALUE; + } + done = TRUE; + break; + } + + } else { + /* ReadConsole failed */ + done = TRUE; + } + } + + 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);