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.
-- v5: 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 | 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);
OK, latest changes have been pushed. Code now uses ReadConsole/CONSOLE_READCONSOLE_CONTROL for editing.
eric pouech (@epo) commented about programs/cmd/lineedit.c:
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';
that could write past the end of buffer
eric pouech (@epo) commented about programs/cmd/lineedit.c:
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);
isn't len == *numRead?
eric pouech (@epo) commented about programs/cmd/lineedit.c:
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);
'\t' instead of 0x09 is more readable IMO
On Sun Apr 20 12:20:40 2025 +0000, eric pouech wrote:
'\t' instead of 0x09 is more readable IMO
that function is way too large and should be split with helpers to improve readability
eric pouech (@epo) commented about programs/cmd/lineedit.c:
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 &&
shouldn't you loop with *numRead as end marker instead of inputBufferLength?
eric pouech (@epo) commented about programs/cmd/lineedit.c:
}
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:
there's likely a way with helpers not to have a label
eric pouech (@epo) commented about programs/cmd/lineedit.c:
/* 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]));
this doesn't make any sense... rest of code uses maxlen as number of (w)chars not bytes
note also that native has several modes concerning line wrapping and wine doesn't implement the one you have in mind (ie a single line in "screen buffer" but that is displayed on several lines in output... line wrap changes when you change the width of the console).
wine implements the "old" fashion, ie if line is too long, it's spread across several screen buffer lines, but won't change when console width changes
On Sun Apr 20 12:18:27 2025 +0000, eric pouech wrote:
that could write past the end of buffer
Thanks, will fix.
On Sun Apr 20 12:18:43 2025 +0000, eric pouech wrote:
isn't len == *numRead?
Ah, yes, thanks. Cruft left from refactoring.
On Sun Apr 20 12:23:31 2025 +0000, eric pouech wrote:
that function is way too large and should be split with helpers to improve readability
Will look into.
On Sun Apr 20 12:26:01 2025 +0000, eric pouech wrote:
shouldn't you loop with *numRead as end marker instead of inputBufferLength?
Yes.
On Sun Apr 20 12:58:07 2025 +0000, eric pouech wrote:
there's likely a way with helpers not to have a label
Will look into.
On Sun Apr 20 13:03:31 2025 +0000, eric pouech wrote:
this doesn't make any sense... rest of code uses maxlen as number of (w)chars not bytes note also that native has several modes concerning line wrapping and wine doesn't implement the one you have in mind (ie a single line in "screen buffer" but that is displayed on several lines in output... line wrap changes when you change the width of the console). wine implements the "old" fashion, ie if line is too long, it's spread across several screen buffer lines, but won't change when console width changes
Ah, mistake, good catch. Arithmetic should be * instead of /. Had conversion backward.
I tested with multi-line text but I did not resize console window. Will look into.
On Sun Apr 20 16:23:31 2025 +0000, Joe Souza wrote:
Ah, mistake, good catch. Arithmetic should be * instead of /. Had conversion backward. I tested with multi-line text but I did not resize console window. Will look into.
And of course, memset is not the correct function to use here.