diff --git a/sakura_core/agent/CGrepAgent.cpp b/sakura_core/agent/CGrepAgent.cpp index 366ad3b9d2..56a927dcb6 100644 --- a/sakura_core/agent/CGrepAgent.cpp +++ b/sakura_core/agent/CGrepAgent.cpp @@ -1,6 +1,9 @@ -/*! @file */ +/*! @file + @brief Grep検索エージェント + @note マルチスレッド対応・除外ファイル機能拡張 +*/ /* - Copyright (C) 2018-2022, Sakura Editor Organization + Copyright (C) 2018-2026, Sakura Editor Organization SPDX-License-Identifier: Zlib */ @@ -33,11 +36,21 @@ #include "CSelectLang.h" #include "sakura_rc.h" #include "config/system_constants.h" +#include +#include +#include +#include +#include #define UICHECK_INTERVAL_MILLISEC 100 // UI確認の時間間隔 #define ADDTAIL_INTERVAL_MILLISEC 50 // 結果出力の時間間隔 #define UIFILENAME_INTERVAL_MILLISEC 15 // Cancelダイアログのファイル名表示更新間隔 +//! Grep 結果フッタおよびタイマー表示のバッファサイズ +//! "該当 %d 件\r\n" や "%d ミリ秒\r\n" 等のフォーマット結果が収まる十分なサイズ。 +//! int の最大桁数 (11桁) + フォーマット文字列の最大長 (≦ 64文字) + 終端 0 を考慮。 +constexpr size_t kGrepFooterBufSize = 128; + /*! * 指定された文字列をタイプ別設定に従ってエスケープする */ @@ -538,6 +551,25 @@ DWORD CGrepAgent::DoGrep( } } + // 除外正規表現パターンの事前検証(UI初期化前に入力バリデーション) + // `!` 付きの除外条件は検索開始前に失敗させて、ワーカー起動後の中断を避ける。 + for( const auto& pat : cGrepEnumKeys.m_vecExceptFileRegexPatterns ){ + CBregexp testRegexp; + if( !InitRegexp( pcViewDst->m_hwndParent, testRegexp, true ) ){ + this->m_bGrepRunning = false; + pcViewDst->m_bDoing_UndoRedo = false; + pcViewDst->SetUndoBuffer(); + return 0; + } + if( !testRegexp.Compile( pat.c_str(), CBregexp::optCaseSensitive ) ){ + ErrorMessage( pcViewDst->m_hwndParent, L"無効な除外正規表現パターン: %s", pat.c_str() ); + this->m_bGrepRunning = false; + pcViewDst->m_bDoing_UndoRedo = false; + pcViewDst->SetUndoBuffer(); + return 0; + } + } + // 出力対象ビューのタイプ別設定(grepout固定) const STypeConfig& type = pcViewDst->m_pcEditDoc->m_cDocType.GetDocumentAttribute(); @@ -546,33 +578,18 @@ DWORD CGrepAgent::DoGrep( nWork = pcmGrepKey->GetStringLength(); // 2003.06.10 Moca あらかじめ長さを計算しておく - /* 最後にテキストを追加 */ - CNativeW cmemWork; - cmemMessage.AppendString( LS( STR_GREP_SEARCH_CONDITION ) ); //L"\r\n□検索条件 " + CNativeW formattedKey; if( 0 < nWork ){ - cmemMessage.AppendString( L"\"" ); - cmemMessage += EscapeStringLiteral(type, *pcmGrepKey); - cmemMessage.AppendString( L"\"\r\n" ); - }else{ - cmemMessage.AppendString( LS( STR_GREP_SEARCH_FILE ) ); //L"「ファイル検索」\r\n" - } - - if( bGrepReplace ){ - cmemMessage.AppendString( LS(STR_GREP_REPLACE_TO) ); - if( bGrepPaste ){ - cmemMessage.AppendString( LS(STR_GREP_PASTE_CLIPBOAD) ); - }else{ - cmemMessage.AppendString( L"\"" ); - cmemMessage += EscapeStringLiteral(type, cmemReplace); - cmemMessage.AppendString( L"\"\r\n" ); - } + // ヘッダ文字列は個別に整形してから共通関数へ渡し、既存の表示順を保つ。 + formattedKey += EscapeStringLiteral(type, *pcmGrepKey); } + CNativeW formattedTarget; HWND hWndTarget = nullptr; WCHAR szWindowName[_MAX_PATH]; WCHAR szWindowPath[_MAX_PATH]; { - int nHwndRet = GetHwndTitle(hWndTarget, &cmemWork, szWindowName, szWindowPath, pcmGrepFile->GetStringPtr()); + int nHwndRet = GetHwndTitle(hWndTarget, &formattedTarget, szWindowName, szWindowPath, pcmGrepFile->GetStringPtr()); if( -1 == nHwndRet ){ cmemMessage.AppendString(L"HWND handle error.\n"); if( sGrepOption.bGrepHeader ){ @@ -581,109 +598,63 @@ DWORD CGrepAgent::DoGrep( return 0; }else if( 0 == nHwndRet ){ { - // 解析済みのファイルパターン配列を取得する const auto& vecSearchFileKeys = cGrepEnumKeys.m_vecSearchFileKeys; std::wstring strPatterns = FormatPathList( vecSearchFileKeys ); - cmemWork.SetString( strPatterns.c_str(), strPatterns.length() ); + formattedTarget.SetString( strPatterns.c_str(), strPatterns.length() ); } } } - cmemMessage.AppendString( LS( STR_GREP_SEARCH_TARGET ) ); //L"検索対象 " - cmemMessage += cmemWork; - cmemMessage.AppendString( L"\r\n" ); - cmemMessage.AppendString( LS( STR_GREP_SEARCH_FOLDER ) ); //L"フォルダー " + CNativeW formattedFolder; { - // フォルダーリストから末尾のバックスラッシュを削ったパスリストを作る std::list folders; std::transform( vPaths.cbegin(), vPaths.cend(), std::back_inserter( folders ), []( const auto& path ) { return ChopYen( path ); } ); std::wstring strPatterns = FormatPathList( folders ); - cmemMessage.AppendString( strPatterns.c_str(), strPatterns.length() ); + formattedFolder.AppendString( strPatterns.c_str(), strPatterns.length() ); } - cmemMessage.AppendString( L"\r\n" ); - cmemMessage.AppendString(LS(STR_GREP_EXCLUDE_FILE)); //L"除外ファイル " + CNativeW formattedReplace; + if( bGrepReplace && !bGrepPaste ){ + formattedReplace += EscapeStringLiteral(type, cmemReplace); + } + CNativeW header = BuildGrepHeader( + formattedKey.GetStringPtr(), + formattedTarget.GetStringPtr(), + formattedFolder.GetStringPtr(), + sSearchOption, + sGrepOption, + bGrepReplace ? formattedReplace.GetStringPtr() : nullptr + ); + + CNativeW excludeString; + excludeString.AppendString(LS(STR_GREP_EXCLUDE_FILE)); { - // 除外ファイルの解析済みリストを取得る auto excludeFiles = cGrepEnumKeys.GetExcludeFiles(); std::wstring strPatterns = FormatPathList( excludeFiles ); - cmemMessage.AppendString( strPatterns.c_str(), strPatterns.length() ); + excludeString.AppendString( strPatterns.c_str(), strPatterns.length() ); } - cmemMessage.AppendString(L"\r\n"); + excludeString.AppendString(L"\r\n"); - cmemMessage.AppendString(LS(STR_GREP_EXCLUDE_FOLDER)); //L"除外フォルダー " + excludeString.AppendString(LS(STR_GREP_EXCLUDE_FOLDER)); { - // 除外フォルダーの解析済みリストを取得する auto excludeFolders = cGrepEnumKeys.GetExcludeFolders(); std::wstring strPatterns = FormatPathList( excludeFolders ); - cmemMessage.AppendString( strPatterns.c_str(), strPatterns.length() ); + excludeString.AppendString( strPatterns.c_str(), strPatterns.length() ); } - cmemMessage.AppendString(L"\r\n"); - - const wchar_t* pszWork; + excludeString.AppendString(L"\r\n"); + if( sGrepOption.bGrepSubFolder ){ - pszWork = LS( STR_GREP_SUBFOLDER_YES ); //L" (サブフォルダーも検索)\r\n" + excludeString.AppendString( LS(STR_GREP_SUBFOLDER_YES) ); + header.Replace( LS(STR_GREP_SUBFOLDER_YES), excludeString.GetStringPtr() ); }else{ - pszWork = LS( STR_GREP_SUBFOLDER_NO ); //L" (サブフォルダーを検索しない)\r\n" - } - cmemMessage.AppendString( pszWork ); - - if( 0 < nWork ){ // 2003.06.10 Moca ファイル検索の場合は表示しない // 2004.09.26 条件誤り修正 - if( sSearchOption.bWordOnly ){ - /* 単語単位で探す */ - cmemMessage.AppendString( LS( STR_GREP_COMPLETE_WORD ) ); //L" (単語単位で探す)\r\n" - } - - if( sSearchOption.bLoHiCase ){ - pszWork = LS( STR_GREP_CASE_SENSITIVE ); //L" (英大文字小文字を区別する)\r\n" - }else{ - pszWork = LS( STR_GREP_IGNORE_CASE ); //L" (英大文字小文字を区別しない)\r\n" - } - cmemMessage.AppendString( pszWork ); - - if( sSearchOption.bRegularExp ){ - // 2007.07.22 genta : 正規表現ライブラリのバージョンも出力する - cmemMessage.AppendString( LS( STR_GREP_REGEX_DLL ) ); //L" (正規表現:" - cmemMessage.AppendString( cRegexp.GetVersionW() ); - cmemMessage.AppendString( L")\r\n" ); - } + excludeString.AppendString( LS(STR_GREP_SUBFOLDER_NO) ); + header.Replace( LS(STR_GREP_SUBFOLDER_NO), excludeString.GetStringPtr() ); } - if( CODE_AUTODETECT == sGrepOption.nGrepCharSet ){ - cmemMessage.AppendString( LS( STR_GREP_CHARSET_AUTODETECT ) ); //L" (文字コードセットの自動判別)\r\n" - }else if(IsValidCodeOrCPType(sGrepOption.nGrepCharSet)){ - cmemMessage.AppendString( LS( STR_GREP_CHARSET ) ); //L" (文字コードセット:" - WCHAR szCpName[100]; - CCodePage::GetNameNormal(szCpName, sGrepOption.nGrepCharSet); - cmemMessage.AppendString( szCpName ); - cmemMessage.AppendString( L")\r\n" ); - } + cmemMessage.AppendNativeData( header ); - if( 0 < nWork ){ // 2003.06.10 Moca ファイル検索の場合は表示しない // 2004.09.26 条件誤り修正 - if( sGrepOption.nGrepOutputLineType == 1 ){ - /* 該当行 */ - pszWork = LS( STR_GREP_SHOW_MATCH_LINE ); //L" (一致した行を出力)\r\n" - }else if( sGrepOption.nGrepOutputLineType == 2 ){ - // 否該当行 - pszWork = LS( STR_GREP_SHOW_MATCH_NOHITLINE ); //L" (一致しなかった行を出力)\r\n" - }else{ - if( bGrepReplace && sSearchOption.bRegularExp && !bGrepPaste ){ - pszWork = LS(STR_GREP_SHOW_FIRST_LINE); - }else{ - pszWork = LS( STR_GREP_SHOW_MATCH_AREA ); - } - } - cmemMessage.AppendString( pszWork ); - - if( sGrepOption.bGrepOutputFileOnly ){ - pszWork = LS( STR_GREP_SHOW_FIRST_MATCH ); //L" (ファイル毎最初のみ検索)\r\n" - cmemMessage.AppendString( pszWork ); - } - } - - cmemMessage.AppendString( L"\r\n\r\n" ); nWork = cmemMessage.GetStringLength(); - pszWork = cmemMessage.GetStringPtr(); + const wchar_t* pszWork = cmemMessage.GetStringPtr(); //@@@ 2002.01.03 YAZAKI Grep直後はカーソルをGrep直前の位置に動かす CLayoutInt tmp_PosY_Layout = pcViewDst->m_pcEditDoc->m_cLayoutMgr.GetLineCount(); if( 0 < nWork && sGrepOption.bGrepHeader ){ @@ -758,7 +729,8 @@ DWORD CGrepAgent::DoGrep( cmemMessage.Clear(); } nHitCount = nGrepTreeResult; - }else{ + }else if( sGrepOption.bGrepReplace ){ + // Grep置換は副作用(ファイル書き換え)を伴うため既存の直列処理を維持する for( int nPath = 0; nPath < (int)vPaths.size(); nPath++ ){ bool bOutputBaseFolder = false; std::wstring sPath = ChopYen( vPaths[nPath] ); @@ -776,7 +748,6 @@ DWORD CGrepAgent::DoGrep( sGrepOption, pattern, &cRegexp, - 0, bOutputBaseFolder, &nHitCount, cmemMessage, @@ -792,6 +763,288 @@ DWORD CGrepAgent::DoGrep( AddTail( pcViewDst, cmemMessage, sGrepOption.bGrepStdout ); cmemMessage._SetStringLength(0); } + }else{ + // ===== 並列Grep: サブフォルダー単位バッチ処理(Producer-Consumerパターン) ===== + // 検索パス直下のサブフォルダーを1フォルダーずつ列挙・検索・解放することで + // メモリ消費を抑制し、出力順序をフォルダー順に安定化させる。 + // 並列Grep: スレッドプールでファイル検索を並列化する + // メインスレッドがフォルダー単位でタスクを列挙し、ワーカーが並列に検索する + // Grep置換は副作用があるため既存の直列パスを使用する + const std::vector& regexPatterns = cGrepEnumKeys.m_vecExceptFileRegexPatterns; + const std::wstring searchKey( pcmGrepKey->GetStringPtr(), pcmGrepKey->GetStringLength() ); + + // スレッド数 = max(設定値, 論理コア数 / 4) ※設定値はiniファイルの nGrepThreadCount(1〜8) + const int nIniThreads = GetDllShareData().m_Common.m_sSearch.m_nGrepThreadCount; + const unsigned int nClampedIni = (unsigned int)std::max( 1, std::min( nIniThreads, 8 ) ); + const unsigned int nThreads = std::max( + nClampedIni, + std::thread::hardware_concurrency() / 4 ); + + // ヒット数(全バッチ共通・バッチ間で引き継ぐ) + std::atomic atomicHitCount{ 0 }; + + // ===== スレッドプール: バッチ間でスレッドを再利用し生成・破棄コストを排除 ===== + // バッチ番号インクリメントで新バッチを通知し、condition_variable で待機中ワーカーを起床させる。 + std::mutex poolMutex; + std::condition_variable cvPoolStart; + size_t poolBatchId = 0; // バッチ番号(インクリメントで新バッチ通知) + bool bPoolShutdown = false; // true: ワーカーに終了を指示 + const std::vector* pPoolBatch = nullptr; // 現在バッチのタスクリスト + std::atomic poolNextTask{ 0 }; // 次に処理するタスクのインデックス + std::atomic poolBatchActive{ 0 }; // 現在バッチを処理中のワーカー数 + std::atomic bWorkCancelled{ false }; // true: キャンセル済み(バッチ間で維持) + std::mutex resultMutex; + CNativeW sharedMessage; + std::set writtenBaseFolders; + std::set writtenFolders; + + // スレッドプールのワーカーを生成(1回のみ)。 + // 各ワーカーはスレッドローカルの正規表現とバッファを持ち、毎回の再初期化を避ける。 + std::vector poolWorkers; + poolWorkers.reserve( nThreads ); + std::atomic nActiveWorkers{ nThreads }; // 初期化成功したワーカー数(デッドロック防止用) + + for( unsigned int t = 0; t < nThreads; t++ ){ + poolWorkers.emplace_back( [&](){ + size_t localBatchId = 0; + + // --- スレッドローカル初期化(スレッド生存中に1回のみ実行)--- + CBregexp localRegexp; + CSearchStringPattern localPattern; + try{ + localPattern.SetPattern( nullptr, + searchKey.c_str(), searchKey.size(), + sSearchOption, &localRegexp ); + }catch(...){ + // 初期化失敗: 実働ワーカー数を減らしてシャットダウンまで待機 + nActiveWorkers.fetch_sub( 1, std::memory_order_relaxed ); + std::unique_lock lk( poolMutex ); + cvPoolStart.wait( lk, [&]{ return bPoolShutdown; } ); + return; + } + + std::vector excludeRegexps( regexPatterns.size() ); + for( size_t ri = 0; ri < regexPatterns.size(); ri++ ){ + InitRegexp( nullptr, excludeRegexps[ri], false ); + excludeRegexps[ri].Compile( + regexPatterns[ri].c_str(), CBregexp::optCaseSensitive ); + } + + CNativeW localMessage; + CNativeW localUnicodeBuffer; + localMessage.AllocStringBuffer( 4000 ); + localUnicodeBuffer.AllocStringBuffer( 4000 ); + + while( true ){ + // 新しいバッチまたはシャットダウンを待機 + const std::vector* pBatch = nullptr; + { + std::unique_lock lk( poolMutex ); + cvPoolStart.wait( lk, [&]{ + return poolBatchId > localBatchId || bPoolShutdown; + } ); + if( bPoolShutdown && poolBatchId <= localBatchId ) break; + localBatchId = poolBatchId; + pBatch = pPoolBatch; + } + + // バッチ処理(try/catch で例外をキャンセル扱い) + try{ + while( true ){ + const size_t idx = poolNextTask.fetch_add( 1, std::memory_order_relaxed ); + if( idx >= pBatch->size() ) break; + if( bWorkCancelled.load(std::memory_order_acquire) ) break; + + const SGrepFileTask& task = (*pBatch)[idx]; + + // 正規表現除外判定(フルパス全体に対してマッチング) + const int nFullPathLen = (int)task.fullPath.size(); + bool bExcluded = false; + for( auto& reExcl : excludeRegexps ){ + if( reExcl.Match( task.fullPath.c_str(), nFullPathLen, 0 ) ){ + bExcluded = true; + break; + } + } + if( bExcluded ) continue; + + // ファイル内検索 + localMessage._SetStringLength(0); + const int fileHits = DoGrepFileWorker( + task, searchKey.c_str(), + sSearchOption, sGrepOption, + &localRegexp, localPattern, + localMessage, localUnicodeBuffer, + bWorkCancelled + ); + + if( fileHits == -1 ){ + bWorkCancelled.store( true, std::memory_order_release ); + break; + } + + if( fileHits > 0 || localMessage.GetStringLength() > 0 ){ + std::lock_guard lk( resultMutex ); + // フォルダーヘッダー重複排除(最初のマッチ時のみ出力) + if( sGrepOption.bGrepOutputBaseFolder && + writtenBaseFolders.find(task.baseFolder) == writtenBaseFolders.end() ){ + writtenBaseFolders.insert( task.baseFolder ); + sharedMessage.AppendString( + sGrepOption.bGrepSeparateFolder ? L"◎\"" : L"■\"" ); + sharedMessage.AppendString( task.baseFolder.c_str() ); + sharedMessage.AppendString( L"\"\r\n" ); + } + if( sGrepOption.bGrepSeparateFolder && + writtenFolders.find(task.folder) == writtenFolders.end() ){ + writtenFolders.insert( task.folder ); + if( !task.folder.empty() ){ + sharedMessage.AppendString( L"■\"" ); + sharedMessage.AppendString( task.folder.c_str() ); + sharedMessage.AppendString( L"\"\r\n" ); + }else{ + sharedMessage.AppendString( L"■\r\n" ); + } + } + sharedMessage.AppendNativeData( localMessage ); + atomicHitCount.fetch_add( fileHits, std::memory_order_relaxed ); + } + } + }catch(...){ + // 予期せぬ例外(std::bad_alloc 等)をキャンセル扱いにする + bWorkCancelled.store( true, std::memory_order_release ); + } + // try/catch どちらの経路でも必ず実行し、デッドロックを防止する + poolBatchActive.fetch_sub( 1, std::memory_order_relaxed ); + } + } ); + } + + // バッチ実行ラムダ: vecTasks をプールのワーカーに割り当て結果を出力する + // 戻り値: true=継続, false=キャンセル発生 + // バッチ単位でワーカーにタスクを渡し、UI 更新と出力フラッシュはメインスレッドが担当する。 + auto RunBatch = [&]( const std::vector& vecTasks ) -> bool { + if( vecTasks.empty() ) return true; + if( bWorkCancelled.load(std::memory_order_acquire) ) return false; + + // バッチ設定(ロック下でバッチ番号をインクリメントしてワーカーを起床) + { + std::lock_guard lk( poolMutex ); + pPoolBatch = &vecTasks; + poolNextTask.store( 0, std::memory_order_relaxed ); + poolBatchActive.store( (int)nActiveWorkers.load(std::memory_order_relaxed), std::memory_order_relaxed ); + ++poolBatchId; + } + cvPoolStart.notify_all(); + + // メインスレッド: 全ワーカーが完全終了するまでUI更新・キャンセル監視・結果フラッシュを継続 + while( poolBatchActive.load(std::memory_order_relaxed) > 0 ){ + ::Sleep(5); + + DWORD dwNow = ::GetTickCount(); + if( dwNow - m_dwTickUICheck > UICHECK_INTERVAL_MILLISEC ){ + m_dwTickUICheck = dwNow; + if( !::BlockingHook( cDlgCancel.GetHwnd() ) ){ + bWorkCancelled.store( true, std::memory_order_release ); + } + if( cDlgCancel.IsCanceled() ){ + bWorkCancelled.store( true, std::memory_order_release ); + } + CEditWnd::getInstance()->SetDrawSwitchOfAllViews( + 0 != ::IsDlgButtonChecked( cDlgCancel.GetHwnd(), IDC_CHECK_REALTIMEVIEW ) ); + ::SetDlgItemInt( cDlgCancel.GetHwnd(), IDC_STATIC_HITCOUNT, + atomicHitCount.load(), FALSE ); + } + + // 共有バッファをリアルタイム出力にフラッシュ + { + std::lock_guard lk( resultMutex ); + if( sharedMessage.GetStringLength() > 0 ){ + cmemMessage.AppendNativeData( sharedMessage ); + sharedMessage._SetStringLength(0); + } + } + if( 0 < cmemMessage.GetStringLength() && + (::GetTickCount() - m_dwTickAddTail) > ADDTAIL_INTERVAL_MILLISEC ){ + AddTail( pcViewDst, cmemMessage, sGrepOption.bGrepStdout ); + cmemMessage._SetStringLength(0); + } + } + + // 最終フラッシュ + { + std::lock_guard lk( resultMutex ); + if( sharedMessage.GetStringLength() > 0 ){ + cmemMessage.AppendNativeData( sharedMessage ); + sharedMessage._SetStringLength(0); + } + } + if( 0 < cmemMessage.GetStringLength() ){ + AddTail( pcViewDst, cmemMessage, sGrepOption.bGrepStdout ); + cmemMessage._SetStringLength(0); + } + + return !bWorkCancelled.load(std::memory_order_acquire); + }; + + // 各検索パスをサブフォルダー単位でバッチ処理 + for( int nPath = 0; nPath < (int)vPaths.size() && nGrepTreeResult != -1; nPath++ ){ + std::wstring sPath = ChopYen( vPaths[nPath] ); + + // 検索パスが変わるたびにフォルダーヘッダー出力履歴をリセット + { + std::lock_guard lk( resultMutex ); + writtenBaseFolders.clear(); + writtenFolders.clear(); + } + + // バッチ0: 検索パス直下のファイル(サブフォルダーは含まない) + { + SGrepOption sGrepOptionNoSub = sGrepOption; + sGrepOptionNoSub.bGrepSubFolder = false; + bool bEnumCancelled = false; + std::vector vecTasks; + DoGrepTreeEnumerate( + &cDlgCancel, cGrepEnumKeys, cGrepExceptAbsFiles, cGrepExceptAbsFolders, + sPath.c_str(), sPath.c_str(), sGrepOptionNoSub, + vecTasks, bEnumCancelled + ); + if( bEnumCancelled ){ nGrepTreeResult = -1; break; } + if( !RunBatch(vecTasks) ){ nGrepTreeResult = -1; break; } + } + + // バッチ1..N: 直下サブフォルダーをそれぞれ独立バッチで列挙・検索・解放 + // → フォルダー順の出力順序を保証し、1バッチ分のみメモリに保持する + if( sGrepOption.bGrepSubFolder && nGrepTreeResult != -1 ){ + CGrepEnumOptions cGrepEnumOptionsDir; + CGrepEnumFilterFolders cTopFolders; + cTopFolders.Enumerates( + sPath.c_str(), cGrepEnumKeys, cGrepEnumOptionsDir, cGrepExceptAbsFolders ); + + const int nFolderCount = cTopFolders.GetCount(); + for( int fi = 0; fi < nFolderCount && nGrepTreeResult != -1; fi++ ){ + std::wstring subFolder = sPath + L"\\" + cTopFolders.GetFileName(fi); + bool bEnumCancelled = false; + std::vector vecTasks; + DoGrepTreeEnumerate( + &cDlgCancel, cGrepEnumKeys, cGrepExceptAbsFiles, cGrepExceptAbsFolders, + subFolder.c_str(), sPath.c_str(), sGrepOption, + vecTasks, bEnumCancelled + ); + if( bEnumCancelled ){ nGrepTreeResult = -1; break; } + if( !RunBatch(vecTasks) ){ nGrepTreeResult = -1; break; } + } + } + } + + // スレッドプールをシャットダウン: 全ワーカーに終了を通知してjoin + { + std::lock_guard lk( poolMutex ); + bPoolShutdown = true; + } + cvPoolStart.notify_all(); + for( auto& w : poolWorkers ) w.join(); + + nHitCount = atomicHitCount.load(); } if( -1 == nGrepTreeResult && sGrepOption.bGrepHeader ){ const wchar_t* p = LS( STR_GREP_SUSPENDED ); //L"中断しました。\r\n" @@ -800,18 +1053,12 @@ DWORD CGrepAgent::DoGrep( AddTail( pcViewDst, cmemSuspend, sGrepOption.bGrepStdout ); } if( sGrepOption.bGrepHeader ){ - WCHAR szBuffer[128]; - if( bGrepReplace ){ - auto_sprintf( szBuffer, LS(STR_GREP_REPLACE_COUNT), nHitCount ); - }else{ - auto_sprintf( szBuffer, LS( STR_GREP_MATCH_COUNT ), nHitCount ); - } - CNativeW cmemOutput; - cmemOutput.SetString( szBuffer ); + CNativeW cmemOutput = BuildGrepFooter(nHitCount, bGrepReplace); AddTail( pcViewDst, cmemOutput, sGrepOption.bGrepStdout ); #if defined(_DEBUG) && defined(TIME_MEASURE) - auto_sprintf( szBuffer, LS(STR_GREP_TIMER), cRunningTimer.Read() ); - cmemOutput.SetString( szBuffer ); + WCHAR szTimerBuffer[kGrepFooterBufSize]; + auto_sprintf( szTimerBuffer, LS(STR_GREP_TIMER), cRunningTimer.Read() ); + cmemOutput.SetString( szTimerBuffer ); AddTail( pcViewDst, cmemOutput, sGrepOption.bGrepStdout ); #endif } @@ -871,11 +1118,11 @@ int CGrepAgent::DoGrepTree( const SGrepOption& sGrepOption, //!< [in] Grepオプション const CSearchStringPattern& pattern, //!< [in] 検索パターン CBregexp* pRegexp, //!< [in] 正規表現コンパイルデータ。既にコンパイルされている必要がある - int nNest, //!< [in] ネストレベル bool& bOutputBaseFolder, //!< [i/o] ベースフォルダー名出力 int* pnHitCount, //!< [i/o] ヒット数の合計 CNativeW& cmemMessage, //!< [i/o] Grep結果文字列 - CNativeW& cUnicodeBuffer + CNativeW& cUnicodeBuffer, + std::vector* pExclRegexps //!< [in] コンパイル済み除外正規表現(nullptr: 内部でコンパイル) ) { int i; @@ -889,6 +1136,25 @@ int CGrepAgent::DoGrepTree( CGrepEnumFilterFiles cGrepEnumFilterFiles; cGrepEnumFilterFiles.Enumerates( pszPath, cGrepEnumKeys, cGrepEnumOptions, cGrepExceptAbsFiles ); + // 正規表現除外パターン: 呼び出し元からコンパイル済みが渡された場合は使い回す(再帰コスト削減) + // 渡されなかった場合(最上位呼び出し)のみここでコンパイルする + std::vector localExclRegexps; + std::vector* pVecExclRegexps = pExclRegexps; + + if( pVecExclRegexps == nullptr ){ + // 再帰呼び出しでは同じ除外正規表現を共有し、無駄な再コンパイルを防ぐ。 + // resize() は MoveInsertable を要求するが CBregexp はムーブ不可(CDllImp が= delete) + // vector(n) はデフォルト構築のみで済むため、ベクターのムーブ代入で代替する + localExclRegexps = std::vector( cGrepEnumKeys.m_vecExceptFileRegexPatterns.size() ); + for( size_t ri = 0; ri < localExclRegexps.size(); ri++ ){ + InitRegexp( nullptr, localExclRegexps[ri], false ); + localExclRegexps[ri].Compile( + cGrepEnumKeys.m_vecExceptFileRegexPatterns[ri].c_str(), + CBregexp::optCaseSensitive ); + } + pVecExclRegexps = &localExclRegexps; + } + /* * カレントフォルダーのファイルを探索する。 */ @@ -928,6 +1194,18 @@ int CGrepAgent::DoGrepTree( nBasePathLen2 = nBasePathLen; } + // 正規表現除外パターンによるフィルタリング(!プレフィックス指定) + { + bool bExcluded = false; + for( auto& reExcl : *pVecExclRegexps ){ + if( reExcl.Match( currentFile.c_str(), (int)currentFile.size(), 0 ) ){ + bExcluded = true; + break; + } + } + if( bExcluded ) continue; + } + /* ファイル内の検索 */ int nRet; if( sGrepOption.bGrepReplace ){ @@ -1049,11 +1327,11 @@ int CGrepAgent::DoGrepTree( sGrepOption, pattern, pRegexp, - nNest + 1, bOutputBaseFolder, pnHitCount, cmemMessage, - cUnicodeBuffer + cUnicodeBuffer, + pVecExclRegexps // コンパイル済みパターンを再帰に引き継ぐ(再コンパイル不要) ); if( -1 == nGrepTreeResult ){ goto cancel_return; @@ -1076,6 +1354,121 @@ cancel_return:; return -1; } +/*! @brief フォルダー走査のみ行い、SGrepFileTask をベクターに積む(メインスレッド用) + @note ファイルの検索は行わない。DoGrep() からの並列化用エントリポイント。 +*/ +void CGrepAgent::DoGrepTreeEnumerate( + CDlgCancel* pcDlgCancel, + CGrepEnumKeys& cGrepEnumKeys, + CGrepEnumFiles& cGrepExceptAbsFiles, + CGrepEnumFolders& cGrepExceptAbsFolders, + const WCHAR* pszPath, + const WCHAR* pszBasePath, + const SGrepOption& sGrepOption, + std::vector& vecTasks, + bool& bCancelled +) +{ + int i; + int count; + LPCWSTR lpFileName; + auto nBasePathLen = int(wcslen(pszBasePath)); + CGrepEnumOptions cGrepEnumOptions; + CGrepEnumFilterFiles cGrepEnumFilterFiles; + cGrepEnumFilterFiles.Enumerates( pszPath, cGrepEnumKeys, cGrepEnumOptions, cGrepExceptAbsFiles ); + + count = cGrepEnumFilterFiles.GetCount(); + for( i = 0; i < count; i++ ){ + lpFileName = cGrepEnumFilterFiles.GetFileName( i ); + + DWORD dwNow = ::GetTickCount(); + if( dwNow - m_dwTickUICheck > UICHECK_INTERVAL_MILLISEC ){ + m_dwTickUICheck = dwNow; + if( !::BlockingHook( pcDlgCancel->GetHwnd() ) ){ + bCancelled = true; + return; + } + if( pcDlgCancel->IsCanceled() ){ + bCancelled = true; + return; + } + CEditWnd::getInstance()->SetDrawSwitchOfAllViews( + 0 != ::IsDlgButtonChecked( pcDlgCancel->GetHwnd(), IDC_CHECK_REALTIMEVIEW ) + ); + } + if( dwNow - m_dwTickUIFileName > UIFILENAME_INTERVAL_MILLISEC ){ + m_dwTickUIFileName = dwNow; + ApiWrap::DlgItem_SetText( pcDlgCancel->GetHwnd(), IDC_STATIC_CURFILE, lpFileName ); + } + + std::wstring currentFile = pszPath; + currentFile += L"\\"; + currentFile += lpFileName; + int nBasePathLen2 = nBasePathLen + 1; + if( (int)wcslen(pszPath) < nBasePathLen2 ){ + nBasePathLen2 = nBasePathLen; + } + + SGrepFileTask task; + task.fullPath = currentFile; + task.fileName = lpFileName; + task.baseFolder = pszBasePath; + task.folder = ( sGrepOption.bGrepSeparateFolder && sGrepOption.bGrepOutputBaseFolder ) + ? std::wstring( pszPath + nBasePathLen2 ) : pszPath; + task.relPath = sGrepOption.bGrepSeparateFolder + ? lpFileName : currentFile.c_str() + nBasePathLen + 1; + vecTasks.push_back( std::move(task) ); + } + + if( sGrepOption.bGrepSubFolder ){ + CGrepEnumOptions cGrepEnumOptionsDir; + CGrepEnumFilterFolders cGrepEnumFilterFolders; + cGrepEnumFilterFolders.Enumerates( pszPath, cGrepEnumKeys, cGrepEnumOptionsDir, cGrepExceptAbsFolders ); + + count = cGrepEnumFilterFolders.GetCount(); + for( i = 0; i < count; i++ ){ + lpFileName = cGrepEnumFilterFolders.GetFileName( i ); + + DWORD dwNow = ::GetTickCount(); + if( dwNow - m_dwTickUICheck > UICHECK_INTERVAL_MILLISEC ){ + m_dwTickUICheck = dwNow; + if( !::BlockingHook( pcDlgCancel->GetHwnd() ) ){ + bCancelled = true; + return; + } + if( pcDlgCancel->IsCanceled() ){ + bCancelled = true; + return; + } + CEditWnd::getInstance()->SetDrawSwitchOfAllViews( + 0 != ::IsDlgButtonChecked( pcDlgCancel->GetHwnd(), IDC_CHECK_REALTIMEVIEW ) + ); + } + + std::wstring currentPath = pszPath; + currentPath += L"\\"; + currentPath += lpFileName; + + DoGrepTreeEnumerate( + pcDlgCancel, + cGrepEnumKeys, + cGrepExceptAbsFiles, + cGrepExceptAbsFolders, + currentPath.c_str(), + pszBasePath, + sGrepOption, + vecTasks, + bCancelled + ); + if( bCancelled ) return; + + ApiWrap::DlgItem_SetText( pcDlgCancel->GetHwnd(), IDC_STATIC_CURPATH, pszPath ); + } + } + + ApiWrap::DlgItem_SetText( pcDlgCancel->GetHwnd(), IDC_STATIC_CURFILE, L" " ); +} + /*! @brief マッチした行番号と桁番号をGrep結果に出力する為に文字列化 auto_sprintf 関数を 書式文字列 "(%I64d,%d)" で実行するのと同等の処理結果を生成 高速化の為に自前実装に置き換え @@ -1140,6 +1533,23 @@ void CGrepAgent::SetGrepResult( /* オプション */ const SGrepOption& sGrepOption ) +{ + FormatGrepResultLine(cmemMessage, pszFilePath, pszCodeName, nLine, nColumn, pCompareData, nLineLen, nEolCodeLen, pMatchData, nMatchLen, sGrepOption); +} + +void CGrepAgent::FormatGrepResultLine( + CNativeW& cmemMessage, + const WCHAR* pszFilePath, + const WCHAR* pszCodeName, + LONGLONG nLine, + int nColumn, + const wchar_t* pCompareData, + int nLineLen, + int nEolCodeLen, + const wchar_t* pMatchData, + int nMatchLen, + const SGrepOption& sGrepOption +) { CNativeW cmemBuf(L""); wchar_t strWork[64]; @@ -1263,6 +1673,271 @@ static void OutputPathInfo( } } +/*! @brief ワーカースレッド用ファイル内Grep処理(UI更新なし・atomic cancelフラグ使用) + + フォルダーヘッダー(bOutputBaseFolder/bOutputFolderName)は呼び出し元の + 並列オーケストレーターが管理するため、本関数内では出力しない(true固定)。 + WZ風スタイルのファイルヘッダー(bOutFileName)はファイル単位なので本関数内で制御する。 + + @retval -1 キャンセル + @retval それ以外 ヒット数 +*/ +int CGrepAgent::DoGrepFileWorker( + const SGrepFileTask& task, //!< [in] ファイルタスク + const wchar_t* pszKey, //!< [in] 検索パターン文字列 + const SSearchOption& sSearchOption, //!< [in] 検索オプション + const SGrepOption& sGrepOption, //!< [in] Grepオプション + CBregexp* pLocalRegexp, //!< [in] スレッドローカルCBregexpインスタンス + const CSearchStringPattern& localPattern, //!< [in] スレッドローカル検索パターン + CNativeW& cmemMessage, //!< [out] 結果バッファ(スレッドローカル) + CNativeW& cUnicodeBuffer, //!< [out] 行読み込みバッファ(スレッドローカル) + const std::atomic& bCancelled //!< [in] キャンセルフラグ +) +{ + int nHitCount = 0; + LONGLONG nLine = 0; + const wchar_t* pszRes; + ECodeType nCharCode; + const wchar_t* pCompareData; + int nColumn; + BOOL bOutFileName = FALSE; + CEol cEol; + int nEolCodeLen; + + const STypeConfigMini* type = nullptr; + if( !CDocTypeManager().GetTypeConfigMini( CDocTypeManager().GetDocumentTypeOfPath( task.fileName.c_str() ), &type ) ){ + return -1; + } + // 拡張子ベースのタイプ別設定を使うため、判定対象はフルパスではなくファイル名にする。 + CFileLoad cfl( type->m_encoding ); + + auto nKeyLen = int(wcslen(pszKey)); + const WCHAR* pszDispFilePath = ( sGrepOption.bGrepSeparateFolder || sGrepOption.bGrepOutputBaseFolder ) + ? task.relPath.c_str() : task.fullPath.c_str(); + const WCHAR* pszCodeName = L""; + + // フォルダーヘッダーはオーケストレーター側で管理するためワーカー内では出力しない。 + bool bOutputBaseFolder = true; + bool bOutputFolderName = true; + + /* 検索条件が長さゼロの場合はファイル名だけ返す */ + if( 0 == nKeyLen ){ + WCHAR szCpName[100]; + + if( CODE_AUTODETECT == sGrepOption.nGrepCharSet ){ + CCodeMediator cmediator( type->m_encoding ); + nCharCode = cmediator.CheckKanjiCodeOfFile( task.fullPath.c_str() ); + + if( !IsValidCodeOrCPType(nCharCode) ){ + pszCodeName = L" [(DetectError)]"; + }else if( IsValidCodeType(nCharCode) ){ + pszCodeName = CCodeTypeName(nCharCode).Bracket(); + }else{ + CCodePage::GetNameBracket(szCpName, nCharCode); + pszCodeName = szCpName; + } + } + { + const wchar_t* pszFormatFullPath = L""; + const wchar_t* pszFormatFilePath = L""; + const wchar_t* pszFormatFilePath2 = L""; + if( 1 == sGrepOption.nGrepOutputStyle ){ + pszFormatFullPath = L"%s%s\r\n"; + pszFormatFilePath = L"・\"%s\"%s\r\n"; + pszFormatFilePath2 = L"・\"%s\"%s\r\n"; + }else if( 2 == sGrepOption.nGrepOutputStyle ){ + pszFormatFullPath = L"■\"%s\"%s\r\n"; + pszFormatFilePath = L"◆\"%s\"%s\r\n"; + pszFormatFilePath2 = L"■\"%s\"%s\r\n"; + }else if( 3 == sGrepOption.nGrepOutputStyle ){ + pszFormatFullPath = L"%s%s\r\n"; + pszFormatFilePath = L"%s\r\n"; + pszFormatFilePath2 = L"%s\r\n"; + } + auto pszWork = std::make_unique( task.fullPath.size() + wcslen(pszCodeName) + 10 ); + wchar_t* szWork0 = &pszWork[0]; + if( sGrepOption.bGrepOutputBaseFolder || sGrepOption.bGrepSeparateFolder ){ + auto_sprintf( szWork0, + (sGrepOption.bGrepSeparateFolder ? pszFormatFilePath : pszFormatFilePath2), + pszDispFilePath, pszCodeName ); + cmemMessage.AppendString( szWork0 ); + }else{ + auto_sprintf( szWork0, pszFormatFullPath, task.fullPath.c_str(), pszCodeName ); + cmemMessage.AppendString( szWork0 ); + } + } + return 1; + } + + try{ + nCharCode = cfl.FileOpen( task.fullPath.c_str(), true, sGrepOption.nGrepCharSet, + GetDllShareData().m_Common.m_sFile.GetAutoMIMEdecode() ); + WCHAR szCpName[100]; + if( CODE_AUTODETECT == sGrepOption.nGrepCharSet ){ + if( IsValidCodeType(nCharCode) ){ + wcscpy( szCpName, CCodeTypeName(nCharCode).Bracket() ); + pszCodeName = szCpName; + }else{ + CCodePage::GetNameBracket(szCpName, nCharCode); + pszCodeName = szCpName; + } + } + + if( bCancelled.load(std::memory_order_relaxed) ){ + return -1; + } + + std::vector> searchWords; + if( sSearchOption.bWordOnly ){ + CSearchAgent::CreateWordList( searchWords, pszKey, nKeyLen ); + } + + while( RESULT_FAILURE != cfl.ReadLine( &cUnicodeBuffer, &cEol ) ) + { + const wchar_t* pLine = cUnicodeBuffer.GetStringPtr(); + int nLineLen = cUnicodeBuffer.GetStringLength(); + nEolCodeLen = cEol.GetLen(); + ++nLine; + pCompareData = pLine; + + // キャンセルチェック(32行ごと、メインスレッドのUIは不要) + if( 0 == nLine % 32 ){ + if( bCancelled.load(std::memory_order_relaxed) ){ + return -1; + } + } + + int nHitOldLine = nHitCount; + + /* 正規表現検索 */ + if( sSearchOption.bRegularExp ){ + int nIndex = 0; + while( nIndex <= nLineLen && pLocalRegexp->Match( pLine, nLineLen, nIndex ) ){ + nIndex = pLocalRegexp->GetIndex(); + int matchlen = pLocalRegexp->GetMatchLen(); + ++nHitCount; + if( sGrepOption.nGrepOutputLineType != 2 ){ + OutputPathInfo( + cmemMessage, sGrepOption, + task.fullPath.c_str(), task.baseFolder.c_str(), + task.folder.c_str(), task.relPath.c_str(), pszCodeName, + bOutputBaseFolder, bOutputFolderName, bOutFileName + ); + SetGrepResult( + cmemMessage, pszDispFilePath, pszCodeName, + nLine, nIndex + 1, pLine, nLineLen, nEolCodeLen, + pLine + nIndex, matchlen, sGrepOption + ); + } + if( sGrepOption.nGrepOutputLineType != 0 || sGrepOption.bGrepOutputFileOnly ){ + break; + } + if( matchlen <= 0 ){ + matchlen = CNativeW::GetSizeOfChar( pLine, nLineLen, nIndex ); + if( matchlen <= 0 ) matchlen = 1; + } + nIndex += matchlen; + } + } + /* 単語のみ検索 */ + else if( sSearchOption.bWordOnly ){ + int nMatchLen; + int nIdx = 0; + while( (pszRes = CSearchAgent::SearchStringWord( + pLine, nLineLen, nIdx, searchWords, sSearchOption.bLoHiCase, &nMatchLen)) != nullptr ){ + nIdx = int(pszRes - pLine + nMatchLen); + + ++nHitCount; + if( sGrepOption.nGrepOutputLineType != 2 ){ + OutputPathInfo( + cmemMessage, sGrepOption, + task.fullPath.c_str(), task.baseFolder.c_str(), + task.folder.c_str(), task.relPath.c_str(), pszCodeName, + bOutputBaseFolder, bOutputFolderName, bOutFileName + ); + SetGrepResult( + cmemMessage, pszDispFilePath, pszCodeName, + nLine, int(pszRes - pLine + 1), pLine, nLineLen, nEolCodeLen, + pszRes, nMatchLen, sGrepOption + ); + } + if( sGrepOption.nGrepOutputLineType != 0 || sGrepOption.bGrepOutputFileOnly ){ + break; + } + } + } + else{ + /* 文字列検索 */ + int nColumnPrev = 0; + for(;;){ + pszRes = CSearchAgent::SearchString( pCompareData, nLineLen, 0, localPattern ); + if( !pszRes ) break; + nColumn = int(pszRes - pCompareData + 1); + ++nHitCount; + if( sGrepOption.nGrepOutputLineType != 2 ){ + OutputPathInfo( + cmemMessage, sGrepOption, + task.fullPath.c_str(), task.baseFolder.c_str(), + task.folder.c_str(), task.relPath.c_str(), pszCodeName, + bOutputBaseFolder, bOutputFolderName, bOutFileName + ); + SetGrepResult( + cmemMessage, pszDispFilePath, pszCodeName, + nLine, nColumn + nColumnPrev, pCompareData, nLineLen, nEolCodeLen, + pszRes, nKeyLen, sGrepOption + ); + } + if( sGrepOption.nGrepOutputLineType != 0 || sGrepOption.bGrepOutputFileOnly ){ + break; + } + int nPosDiff = nColumn += nKeyLen - 1; + pCompareData += nPosDiff; + nLineLen -= nPosDiff; + nColumnPrev += nPosDiff; + } + } + + /* 否ヒット行を出力 */ + if( sGrepOption.nGrepOutputLineType == 2 ){ + bool bNoHit = (nHitOldLine == nHitCount); + nHitCount = nHitOldLine; // カウントを戻す + if( bNoHit ){ + nHitCount++; + OutputPathInfo( + cmemMessage, sGrepOption, + task.fullPath.c_str(), task.baseFolder.c_str(), + task.folder.c_str(), task.relPath.c_str(), pszCodeName, + bOutputBaseFolder, bOutputFolderName, bOutFileName + ); + SetGrepResult( + cmemMessage, pszDispFilePath, pszCodeName, + nLine, 1, pLine, nLineLen, nEolCodeLen, + pLine, nLineLen, sGrepOption + ); + } + } + + if( sGrepOption.bGrepOutputFileOnly && 1 <= nHitCount ){ + break; + } + } + cfl.FileClose(); + } + catch( const CError_FileOpen& ){ + CNativeW str(LS(STR_GREP_ERR_FILEOPEN)); + str.Replace(L"%s", task.fullPath.c_str()); + cmemMessage.AppendNativeData( str ); + return 0; + } + catch( const CError_FileRead& ){ + CNativeW str(LS(STR_GREP_ERR_FILEREAD)); + str.Replace(L"%s", task.fullPath.c_str()); + cmemMessage.AppendNativeData( str ); + } + + return nHitCount; +} + /*! Grep実行 (CFileLoadを使ったテスト版) @@ -2136,3 +2811,124 @@ int CGrepAgent::DoGrepReplaceFile( return nHitCount; } + +// 結果ヘッダ生成("検索条件 ...") +CNativeW CGrepAgent::BuildGrepHeader( + const wchar_t* pszKey, + const wchar_t* pszFile, + const wchar_t* pszFolder, + const SSearchOption& sSearchOption, + const SGrepOption& sGrepOption, + const wchar_t* pszReplace +) +{ + if( pszKey == nullptr ) pszKey = L""; + if( pszFile == nullptr ) pszFile = L""; + if( pszFolder == nullptr ) pszFolder = L""; + + CNativeW cmemMessage; + int nWork = (int)wcslen(pszKey); + + cmemMessage.AppendString( LS( STR_GREP_SEARCH_CONDITION ) ); + if( 0 < nWork ){ + cmemMessage.AppendString( L"\"" ); + cmemMessage.AppendString( pszKey ); + cmemMessage.AppendString( L"\"\r\n" ); + }else{ + cmemMessage.AppendString( LS( STR_GREP_SEARCH_FILE ) ); + } + + if( sGrepOption.bGrepReplace ){ + cmemMessage.AppendString( LS( STR_GREP_REPLACE_TO ) ); + if( sGrepOption.bGrepPaste ){ + cmemMessage.AppendString( LS( STR_GREP_PASTE_CLIPBOAD ) ); + }else if( pszReplace != nullptr ){ + cmemMessage.AppendString( L"\"" ); + cmemMessage.AppendString( pszReplace ); + cmemMessage.AppendString( L"\"\r\n" ); + } + } + + cmemMessage.AppendString( LS( STR_GREP_SEARCH_TARGET ) ); + cmemMessage.AppendString( pszFile ); + cmemMessage.AppendString( L"\r\n" ); + + cmemMessage.AppendString( LS( STR_GREP_SEARCH_FOLDER ) ); + cmemMessage.AppendString( pszFolder ); + cmemMessage.AppendString( L"\r\n" ); + + const wchar_t* pszWork; + if( sGrepOption.bGrepSubFolder ){ + pszWork = LS( STR_GREP_SUBFOLDER_YES ); + }else{ + pszWork = LS( STR_GREP_SUBFOLDER_NO ); + } + cmemMessage.AppendString( pszWork ); + + if( 0 < nWork ){ + if( sSearchOption.bWordOnly ){ + cmemMessage.AppendString( LS( STR_GREP_COMPLETE_WORD ) ); + } + if( sSearchOption.bLoHiCase ){ + pszWork = LS( STR_GREP_CASE_SENSITIVE ); + }else{ + pszWork = LS( STR_GREP_IGNORE_CASE ); + } + cmemMessage.AppendString( pszWork ); + + if( sSearchOption.bRegularExp ){ + CBregexp cRegexp; + cmemMessage.AppendString( LS( STR_GREP_REGEX_DLL ) ); + cmemMessage.AppendString( cRegexp.GetVersionW() ); + cmemMessage.AppendString( L")\r\n" ); + } + } + + if( CODE_AUTODETECT == sGrepOption.nGrepCharSet ){ + cmemMessage.AppendString( LS( STR_GREP_CHARSET_AUTODETECT ) ); + }else if(IsValidCodeOrCPType(sGrepOption.nGrepCharSet)){ + cmemMessage.AppendString( LS( STR_GREP_CHARSET ) ); + WCHAR szCpName[100]; + CCodePage::GetNameNormal(szCpName, sGrepOption.nGrepCharSet); + cmemMessage.AppendString( szCpName ); + cmemMessage.AppendString( L")\r\n" ); + } + + if( 0 < nWork ){ + if( sGrepOption.nGrepOutputLineType == 1 ){ + pszWork = LS( STR_GREP_SHOW_MATCH_LINE ); + }else if( sGrepOption.nGrepOutputLineType == 2 ){ + pszWork = LS( STR_GREP_SHOW_MATCH_NOHITLINE ); + }else{ + if( sGrepOption.bGrepReplace && sSearchOption.bRegularExp && !sGrepOption.bGrepPaste ){ + pszWork = LS(STR_GREP_SHOW_FIRST_LINE); + }else{ + pszWork = LS( STR_GREP_SHOW_MATCH_AREA ); + } + } + cmemMessage.AppendString( pszWork ); + + if( sGrepOption.bGrepOutputFileOnly ){ + pszWork = LS( STR_GREP_SHOW_FIRST_MATCH ); + cmemMessage.AppendString( pszWork ); + } + } + + cmemMessage.AppendString( L"\r\n\r\n" ); + return cmemMessage; +} + +// 結果フッタ生成("該当 N 件" / "N 件を置換") +CNativeW CGrepAgent::BuildGrepFooter(int nHitCount, bool bGrepReplace) +{ + CNativeW cmemMessage; + WCHAR szBuffer[kGrepFooterBufSize]; + const WCHAR* pszFormat = bGrepReplace ? LS( STR_GREP_REPLACE_COUNT ) : LS( STR_GREP_MATCH_COUNT ); + + // バッファサイズの根拠: フォーマット部 ≦ 64文字 + %d 展開 ≦ 11文字 + 終端0 << 128 + // 言語リソース変更時の回帰検出のため Debug ビルドで動的検証する。 + assert(wcslen(pszFormat) + 12 < kGrepFooterBufSize); + auto_sprintf( szBuffer, pszFormat, nHitCount ); + cmemMessage.SetString( szBuffer ); + return cmemMessage; +} diff --git a/sakura_core/agent/CGrepAgent.h b/sakura_core/agent/CGrepAgent.h index e344496066..be3480b772 100644 --- a/sakura_core/agent/CGrepAgent.h +++ b/sakura_core/agent/CGrepAgent.h @@ -1,7 +1,7 @@ -/*! @file */ +/*! @file */ /* Copyright (C) 2008, kobake - Copyright (C) 2018-2022, Sakura Editor Organization + Copyright (C) 2018-2026, Sakura Editor Organization SPDX-License-Identifier: Zlib */ @@ -9,7 +9,11 @@ #define SAKURA_CGREPAGENT_97F2B632_71C8_4E4A_AC42_13A6098B248F_H_ #pragma once +#include +#include +#include #include "doc/CDocListener.h" +#include "extmodule/CBregexp.h" class CDlgCancel; class CEditView; class CSearchStringPattern; @@ -17,6 +21,16 @@ class CGrepEnumKeys; class CGrepEnumFiles; class CGrepEnumFolders; +//! 並列Grep処理で使用するファイルタスク情報 +// 1 ファイルを 1 タスクとして扱い、検索対象の実体情報と表示用文字列を分けて保持する。 +struct SGrepFileTask { + std::wstring fullPath; //!< 処理対象ファイルのフルパス + std::wstring fileName; //!< ファイル名(タイプ別設定取得用) + std::wstring baseFolder; //!< ベースフォルダー + std::wstring folder; //!< 表示用フォルダー(bGrepSeparateFolder時) + std::wstring relPath; //!< 相対パス(bGrepSeparateFolder時はファイル名のみ) +}; + struct SGrepOption{ bool bGrepReplace; //!< Grep置換 bool bGrepSubFolder; //!< サブフォルダーからも検索する @@ -57,6 +71,34 @@ class CGrepAgent : public CDocListenerEx{ ECallbackResult OnBeforeClose() override; void OnAfterSave(const SSaveInfo& sSaveInfo) override; + //! 検索結果 1 件分のフォーマット生成(HWND 非依存) + static void FormatGrepResultLine( + CNativeW& cmemMessage, + const WCHAR* pszFilePath, + const WCHAR* pszCodeName, + LONGLONG nLine, + int nColumn, + const wchar_t* pCompareData, + int nLineLen, + int nEolCodeLen, + const wchar_t* pMatchData, + int nMatchLen, + const SGrepOption& sGrepOption + ); + + //! 結果ヘッダ生成("検索条件 ...") + static CNativeW BuildGrepHeader( + const wchar_t* pszKey, + const wchar_t* pszFile, + const wchar_t* pszFolder, + const SSearchOption& sSearchOption, + const SGrepOption& sGrepOption, + const wchar_t* pszReplace = nullptr + ); + + //! 結果フッタ生成("該当 N 件" / "N 件を置換") + static CNativeW BuildGrepFooter(int nHitCount, bool bGrepReplace = false); + static void CreateFolders( const WCHAR* pszPath, std::vector& vPaths ); static std::wstring ChopYen( const std::wstring& str ); void AddTail( CEditView* pcEditView, const CNativeW& cmem, bool bAddStdout ); @@ -84,6 +126,20 @@ class CGrepAgent : public CDocListenerEx{ bool bGrepBackup ); + // 並列 Grep のワーカー本体。UI に触れず、1 ファイル分の検索だけを担当する。 + // テストから直接呼び出せるように public 化。 + int DoGrepFileWorker( + const SGrepFileTask& task, + const wchar_t* pszKey, + const SSearchOption& sSearchOption, + const SGrepOption& sGrepOption, + CBregexp* pLocalRegexp, + const CSearchStringPattern& localPattern, + CNativeW& cmemMessage, + CNativeW& cUnicodeBuffer, + const std::atomic& bCancelled + ); + private: // Grep実行 int DoGrepTree( @@ -100,11 +156,11 @@ class CGrepAgent : public CDocListenerEx{ const SGrepOption& sGrepOption, //!< [in] Grepオプション const CSearchStringPattern& pattern, //!< [in] 検索パターン CBregexp* pRegexp, //!< [in] 正規表現コンパイルデータ。既にコンパイルされている必要がある - int nNest, //!< [in] ネストレベル bool& bOutputBaseFolder, int* pnHitCount, //!< [i/o] ヒット数の合計 CNativeW& cmemMessage, - CNativeW& cUnicodeBuffer + CNativeW& cUnicodeBuffer, + std::vector* pExclRegexps = nullptr //!< [in] コンパイル済み除外正規表現(再帰呼び出し用・nullptrなら内部でコンパイル) ); // Grep実行 @@ -170,6 +226,20 @@ class CGrepAgent : public CDocListenerEx{ const SGrepOption& sGrepOption ); + // ファイル列挙(メインスレッド用・キャンセル対応) + // 検索は行わず、ワーカーへ渡すタスクだけを作る。 + void DoGrepTreeEnumerate( + CDlgCancel* pcDlgCancel, + CGrepEnumKeys& cGrepEnumKeys, + CGrepEnumFiles& cGrepExceptAbsFiles, + CGrepEnumFolders& cGrepExceptAbsFolders, + const WCHAR* pszPath, + const WCHAR* pszBasePath, + const SGrepOption& sGrepOption, + std::vector& vecTasks, + bool& bCancelled + ); + DWORD m_dwTickAddTail = 0; // AddTail() を呼び出した時間 DWORD m_dwTickUICheck = 0; // 処理中にユーザーによるUI操作が行われていないか確認した時間 DWORD m_dwTickUIFileName = 0; // Cancelダイアログのファイル名表示更新を行った時間 diff --git a/sakura_core/dlg/CDlgGrep.cpp b/sakura_core/dlg/CDlgGrep.cpp index 4e14fa77e5..df243f9a69 100644 --- a/sakura_core/dlg/CDlgGrep.cpp +++ b/sakura_core/dlg/CDlgGrep.cpp @@ -1,4 +1,4 @@ -/*! @file +/*! @file @brief GREPダイアログボックス @author Norio Nakatani @@ -11,7 +11,7 @@ Copyright (C) 2006, ryoji Copyright (C) 2010, ryoji Copyright (C) 2012, Uchi - Copyright (C) 2018-2022, Sakura Editor Organization + Copyright (C) 2018-2026, Sakura Editor Organization This source code is designed for sakura editor. Please contact the copyright holder to use this code for other purpose. @@ -170,7 +170,8 @@ static void AppendExcludeFilePatterns(CNativeW& cFilePattern, const CNativeW& cm CNativeW CDlgGrep::GetPackedGFileString() const { // ダイアログデータを取得 - CNativeW cmFilePattern( m_szFile ); + // ファイルパターンが空なら UI 既定値の "*.*" を補って、検索対象なしの状態を避ける。 + CNativeW cmFilePattern( m_szFile[0] != L'\0' ? m_szFile : L"*.*" ); CNativeW cmExcludeFiles( m_szExcludeFile ); CNativeW cmExcludeFolders( m_szExcludeFolder ); @@ -257,33 +258,8 @@ int CDlgGrep::DoModal( HINSTANCE hInstance, HWND hwndParent, const WCHAR* pszCur wcscpy( m_szFolder, m_pShareData->m_sSearchKeywords.m_aGrepFolders[0] ); /* 検索フォルダー */ } - /* 除外ファイル */ - if (m_szExcludeFile[0] == L'\0') { - if (m_pShareData->m_sSearchKeywords.m_aExcludeFiles.size()) { - wcscpy(m_szExcludeFile, m_pShareData->m_sSearchKeywords.m_aExcludeFiles[0]); - } - else { - /* ユーザーの利便性向上のために除外ファイルに対して初期値を設定する */ - wcscpy(m_szExcludeFile, DEFAULT_EXCLUDE_FILE_PATTERN); /* 除外ファイル */ - - /* 履歴に残して後で選択できるようにする */ - m_pShareData->m_sSearchKeywords.m_aExcludeFiles.push_back(DEFAULT_EXCLUDE_FILE_PATTERN); - } - } - - /* 除外フォルダー */ - if (m_szExcludeFolder[0] == L'\0') { - if (m_pShareData->m_sSearchKeywords.m_aExcludeFolders.size()) { - wcscpy(m_szExcludeFolder, m_pShareData->m_sSearchKeywords.m_aExcludeFolders[0]); - } - else { - /* ユーザーの利便性向上のために除外フォルダーに対して初期値を設定する */ - wcscpy(m_szExcludeFolder, DEFAULT_EXCLUDE_FOLDER_PATTERN); /* 除外フォルダー */ - - /* 履歴に残して後で選択できるようにする */ - m_pShareData->m_sSearchKeywords.m_aExcludeFolders.push_back(DEFAULT_EXCLUDE_FOLDER_PATTERN); - } - } + /* 除外ファイル / 除外フォルダー */ + DetermineDefaultExcludePatterns(); if( pszCurrentFilePath ){ // 2010.01.10 ryoji wcscpy(m_szCurrentFilePath, pszCurrentFilePath); @@ -739,8 +715,7 @@ void CDlgGrep::SetDataFromThisText( bool bChecked ) ApiWrap::DlgItem_SetText( GetHwnd(), IDC_COMBO_EXCLUDE_FOLDER, L"" ); bEnableControls = FALSE; }else{ - std::wstring strFile(m_szFile); - if (strFile.substr(0, 6) == L":HWND:") { + if (IsHwndFileToken(m_szFile)) { wcsncpy_s(m_szFile, std::size(m_szFile), L"*.*", _TRUNCATE); } ApiWrap::DlgItem_SetText(GetHwnd(), IDC_COMBO_FILE, m_szFile); @@ -825,16 +800,9 @@ int CDlgGrep::GetData( void ) ApiWrap::DlgItem_GetText( GetHwnd(), IDC_COMBO_FILE, m_szFile, std::size(m_szFile) ); bool bFromThisText = IsDlgButtonCheckedBool(GetHwnd(), IDC_CHK_FROMTHISTEXT); if( bFromThisText ){ - WCHAR szHwnd[_MAX_PATH]; -#ifdef _WIN64 - auto_sprintf(szHwnd, L":HWND:%016I64x", ::GetParent(GetHwnd())); -#else - auto_sprintf(szHwnd, L":HWND:%08x", ::GetParent(GetHwnd())); -#endif - m_szFile = szHwnd; + wcscpy(m_szFile, BuildHwndFileToken(::GetParent(GetHwnd())).c_str()); }else{ - std::wstring strFile(m_szFile); - if (strFile.substr(0, 6) == L":HWND:") { + if (IsHwndFileToken(m_szFile)) { ErrorMessage(GetHwnd(), LS(STR_DLGGREP_THISDOC_ERROR)); return FALSE; } @@ -929,12 +897,16 @@ int CDlgGrep::GetData( void ) } // To Here Jun. 26, 2001 genta 正規表現ライブラリ差し替え if( m_strText.size() < _MAX_PATH ){ - CSearchKeywordManager().AddToSearchKeyArr( m_strText.c_str() ); + if( !m_bFromThisText ){ + CSearchKeywordManager().AddToSearchKeyArr( m_strText.c_str() ); + } m_pShareData->m_Common.m_sSearch.m_sSearchOption = m_sSearchOption; // 検索オプション } }else{ // 2014.07.01 空キーも登録する - CSearchKeywordManager().AddToSearchKeyArr( L"" ); + if( !m_bFromThisText ){ + CSearchKeywordManager().AddToSearchKeyArr( L"" ); + } } // この編集中のテキストから検索する場合、履歴に残さない Uchi 2008/5/23 @@ -976,3 +948,53 @@ static void SetGrepFolder( HWND hwndCtrl, LPCWSTR folder ) ::SetWindowText( hwndCtrl, folder ); } } + +void CDlgGrep::DetermineDefaultExcludePatterns() +{ + // 初期表示とテストで共通に使う、除外パターンの既定値補完ロジック。 + /* 除外ファイル */ + if (m_szExcludeFile[0] == L'\0') { + if (m_pShareData->m_sSearchKeywords.m_aExcludeFiles.size()) { + wcscpy(m_szExcludeFile, m_pShareData->m_sSearchKeywords.m_aExcludeFiles[0]); + } + else { + /* ユーザーの利便性向上のために除外ファイルに対して初期値を設定する */ + wcscpy(m_szExcludeFile, DEFAULT_EXCLUDE_FILE_PATTERN); /* 除外ファイル */ + + /* 履歴に残して後で選択できるようにする */ + m_pShareData->m_sSearchKeywords.m_aExcludeFiles.push_back(DEFAULT_EXCLUDE_FILE_PATTERN); + } + } + + /* 除外フォルダー */ + if (m_szExcludeFolder[0] == L'\0') { + if (m_pShareData->m_sSearchKeywords.m_aExcludeFolders.size()) { + wcscpy(m_szExcludeFolder, m_pShareData->m_sSearchKeywords.m_aExcludeFolders[0]); + } + else { + /* ユーザーの利便性向上のために除外フォルダーに対して初期値を設定する */ + wcscpy(m_szExcludeFolder, DEFAULT_EXCLUDE_FOLDER_PATTERN); /* 除外フォルダー */ + + /* 履歴に残して後で選択できるようにする */ + m_pShareData->m_sSearchKeywords.m_aExcludeFolders.push_back(DEFAULT_EXCLUDE_FOLDER_PATTERN); + } + } +} + +std::wstring CDlgGrep::BuildHwndFileToken(HWND hwnd) +{ + // HWND を 16 進の文字列にして、コンボボックス内の擬似ファイル名として保持する。 + WCHAR buf[_MAX_PATH]; +#ifdef _WIN64 + auto_sprintf(buf, L":HWND:%016I64x", reinterpret_cast(hwnd)); +#else + auto_sprintf(buf, L":HWND:%08x", reinterpret_cast(hwnd)); +#endif + return std::wstring(buf); +} + +bool CDlgGrep::IsHwndFileToken(const wchar_t* s) +{ + // 実際の値比較ではなく、特別な擬似ファイル名であるかどうかだけを判定する。 + return s != nullptr && wcsncmp(s, L":HWND:", 6) == 0; +} diff --git a/sakura_core/dlg/CDlgGrep.h b/sakura_core/dlg/CDlgGrep.h index b51892b8b4..a290d54575 100644 --- a/sakura_core/dlg/CDlgGrep.h +++ b/sakura_core/dlg/CDlgGrep.h @@ -1,4 +1,4 @@ -/*! @file +/*! @file @brief GREPダイアログボックス @author Norio Nakatani @@ -8,7 +8,7 @@ /* Copyright (C) 1998-2001, Norio Nakatani Copyright (C) 2002, Moca - Copyright (C) 2018-2022, Sakura Editor Organization + Copyright (C) 2018-2026, Sakura Editor Organization This source code is designed for sakura editor. Please contact the copyright holder to use this code for other purpose. @@ -25,7 +25,8 @@ #include "recent/CRecentExcludeFile.h" #include "recent/CRecentExcludeFolder.h" -#define DEFAULT_EXCLUDE_FILE_PATTERN L"*.msi;*.exe;*.obj;*.pdb;*.ilk;*.res;*.pch;*.iobj;*.ipdb" +// 除外ファイルパターン(正規表現、フルパスに対してマッチング) +#define DEFAULT_EXCLUDE_FILE_PATTERN L".*\\.msi$;.*\\.exe$;.*\\.obj$;.*\\.pdb$;.*\\.ilk$;.*\\.res$;.*\\.pch$;.*\\.iobj$;.*\\.ipdb$" #define DEFAULT_EXCLUDE_FOLDER_PATTERN L".git;.svn;.vs" //! GREPダイアログボックス @@ -44,6 +45,16 @@ class CDlgGrep : public CDialog int DoModal( HINSTANCE, HWND, const WCHAR* ); /* モーダルダイアログの表示 */ // HWND DoModeless( HINSTANCE, HWND, const char* ); /* モードレスダイアログの表示 */ + //! 除外パターンの初期値を決定する + // 履歴が空なら既定値を補い、次回以降に選べるよう履歴にも積む。 + void DetermineDefaultExcludePatterns(); + + //! HWND ファイルトークンの生成と判定(":HWND:..." 形式) + // 「編集中のテキストから検索」時の擬似ファイル名として使う。 + static std::wstring BuildHwndFileToken(HWND hwnd); + // 実ファイルパスと区別するため、接頭辞だけを見て判定する。 + static bool IsHwndFileToken(const wchar_t* s); + bool m_bEnableThisText; bool m_bSelectOnceThisText; BOOL m_bSubFolder;/*!< サブフォルダーからも検索する */ diff --git a/sakura_core/tests1.vcxproj b/sakura_core/tests1.vcxproj index 4e7008bfee..454981cc61 100644 --- a/sakura_core/tests1.vcxproj +++ b/sakura_core/tests1.vcxproj @@ -158,6 +158,10 @@ + + + + diff --git a/sakura_core/tests1.vcxproj.filters b/sakura_core/tests1.vcxproj.filters index 13b4ad1d2d..54e6872d89 100644 --- a/sakura_core/tests1.vcxproj.filters +++ b/sakura_core/tests1.vcxproj.filters @@ -124,6 +124,18 @@ Test Files + + Test Files + + + Test Files + + + Test Files + + + Test Files + Test Files diff --git a/src/test/cpp/tests1/test-ccommandline.cpp b/src/test/cpp/tests1/test-ccommandline.cpp index c4ee6bd19e..a3244c526b 100644 --- a/src/test/cpp/tests1/test-ccommandline.cpp +++ b/src/test/cpp/tests1/test-ccommandline.cpp @@ -1,6 +1,6 @@ -/*! @file */ +/*! @file */ /* - Copyright (C) 2018-2022, Sakura Editor Organization + Copyright (C) 2018-2026, Sakura Editor Organization SPDX-License-Identifier: Zlib */ @@ -940,3 +940,327 @@ TEST(CCommandLine, ParseMaxFilePath) } #endif //ifndef __MINGW32__ + +// ====================================================================== +// Phase 2-A: Grep CLI argument coverage (PR #2459) +// ====================================================================== + +// ----- -GOPT 複合・境界値 ----- + +/*! + * @brief パラメータ解析(-GOPT)の仕様:複数フラグの同時指定 + * @remark S(サブフォルダー), R(正規表現), L(大文字小文字区別), W(単語単位) がすべて同時に有効になることを確認する。 + */ +TEST(CCommandLine, ParseGrepOpt_MultipleFlagsCombined) +{ + CCommandLine c; + c.ParseCommandLine(L"-GOPT=SRLW", false); + const auto& opt = c.GetGrepInfoRef(); + EXPECT_TRUE(opt.bGrepSubFolder); + EXPECT_TRUE(opt.sGrepSearchOption.bRegularExp); + EXPECT_TRUE(opt.sGrepSearchOption.bLoHiCase); + EXPECT_TRUE(opt.sGrepSearchOption.bWordOnly); +} + +/*! + * @brief パラメータ解析(-GOPT)の仕様:既知の全フラグを指定した場合の挙動 + * @remark サポートされている全オプション文字を一度に指定し、各対応フィールドが適切に切り替わること(後勝ち含む)を確認する。 + */ +TEST(CCommandLine, ParseGrepOpt_AllKnownFlagsOn) +{ + CCommandLine c; + c.ParseCommandLine(L"-GOPT=XUHSLRKPNW123FBDCO", false); + const auto& opt = c.GetGrepInfoRef(); + EXPECT_TRUE(opt.bGrepCurFolder); // X: 現フォルダー検索 + EXPECT_TRUE(opt.bGrepStdout); // U: 標準出力モード + EXPECT_FALSE(opt.bGrepHeader); // H: ヘッダー非表示 + EXPECT_TRUE(opt.bGrepSubFolder); // S: サブフォルダー検索 + EXPECT_TRUE(opt.sGrepSearchOption.bLoHiCase); // L: 大文字小文字区別 + EXPECT_TRUE(opt.sGrepSearchOption.bRegularExp); // R: 正規表現 + EXPECT_EQ(CODE_AUTODETECT, opt.nGrepCharSet); // K: 文字コード自動判別 + EXPECT_EQ(2, opt.nGrepOutputLineType); // P=1, N=2 (N wins) + EXPECT_TRUE(opt.sGrepSearchOption.bWordOnly); // W: 単語単位 + EXPECT_EQ(3, opt.nGrepOutputStyle); // 1->2->3 (3 wins) + EXPECT_TRUE(opt.bGrepOutputFileOnly); // F: ファイル毎最初のみ + EXPECT_TRUE(opt.bGrepOutputBaseFolder); // B: ベースフォルダー表示 + EXPECT_TRUE(opt.bGrepSeparateFolder); // D: フォルダー毎表示 + EXPECT_TRUE(opt.bGrepPaste); // C: クリップボード貼り付け + EXPECT_TRUE(opt.bGrepBackup); // O: バックアップ有効 +} + +/*! + * @brief パラメータ解析(-GOPT)の仕様:同一フラグの重複指定 + * @remark 同一の文字を複数回指定しても冪等(同じ状態を維持すること)であることを確認する。 + */ +TEST(CCommandLine, ParseGrepOpt_DuplicateSameFlag) +{ + CCommandLine c; + c.ParseCommandLine(L"-GOPT=SS", false); + EXPECT_TRUE(c.GetGrepInfoRef().bGrepSubFolder); +} + +/*! + * @brief パラメータ解析(-GOPT)の仕様:排他フラグ(出力スタイル)の後勝ち + * @remark 1, 2, 3 の出力スタイルを連続指定した場合、最後に指定された数値が有効になることを確認する。 + */ +TEST(CCommandLine, ParseGrepOpt_StyleLastWins) +{ + CCommandLine c; + c.ParseCommandLine(L"-GOPT=123", false); + EXPECT_EQ(3, c.GetGrepInfoRef().nGrepOutputStyle); +} + +/*! + * @brief パラメータ解析(-GOPT)の仕様:排他フラグ(出力行タイプ)の後勝ち + * @remark P(該当行)とN(否該当行)を連続指定した場合、最後に指定された方が有効になることを確認する。 + */ +TEST(CCommandLine, ParseGrepOpt_OutputLineTypeLastWins) +{ + CCommandLine c; + c.ParseCommandLine(L"-GOPT=PN", false); + EXPECT_EQ(2, c.GetGrepInfoRef().nGrepOutputLineType); +} + +/*! + * @brief パラメータ解析(-GOPT)の仕様:未知のフラグ文字の無視 + * @remark サポートされていない文字(ZやQなど)が含まれていても黙殺し、有効な文字(Sなど)は正常に処理することを確認する。 + */ +TEST(CCommandLine, ParseGrepOpt_UnknownCharIgnored) +{ + CCommandLine c; + c.ParseCommandLine(L"-GOPT=SZQ", false); + EXPECT_TRUE(c.GetGrepInfoRef().bGrepSubFolder); +} + +/*! + * @brief パラメータ解析(-GOPT)の仕様:値なし指定時のフェイルセーフ + * @remark = の後に値が指定されていない場合、初期状態を維持すること(クラッシュしないこと)を確認する。 + */ +TEST(CCommandLine, ParseGrepOpt_EmptyValueRejected) +{ + CCommandLine c; + c.ParseCommandLine(L"-GOPT=", false); + const auto& opt = c.GetGrepInfoRef(); + // 初期値のままであること + EXPECT_FALSE(opt.bGrepSubFolder); +} + +/*! + * @brief パラメータ解析(-GOPT)の仕様:数値と文字の混在指定 + * @remark 数字(スタイル)と英字(フラグ)が混在していてもすべて正しくパースすることを確認する。 + */ +TEST(CCommandLine, ParseGrepOpt_MixedDigitsAndLetters) +{ + CCommandLine c; + c.ParseCommandLine(L"-GOPT=1SRL", false); + const auto& opt = c.GetGrepInfoRef(); + EXPECT_EQ(1, opt.nGrepOutputStyle); + EXPECT_TRUE(opt.bGrepSubFolder); + EXPECT_TRUE(opt.sGrepSearchOption.bRegularExp); + EXPECT_TRUE(opt.sGrepSearchOption.bLoHiCase); +} + +// ----- -GREPR と -GKEY の連動 ----- + +/*! + * @brief パラメータ解析(-GREPR)の仕様:置換フラグの自動設定 + * @remark GREPR(置換後文字列)が指定された場合、bGrepReplaceが自動的にtrueになることを確認する。 + */ +TEST(CCommandLine, ParseGrepReplace_GREPRSetsReplaceFlag) +{ + CCommandLine c; + c.ParseCommandLine(L"-GKEY=foo -GREPR=bar", false); + EXPECT_STREQ(L"foo", c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); + EXPECT_STREQ(L"bar", c.GetGrepInfoRef().cmGrepRep.GetStringPtr()); + EXPECT_TRUE(c.GetGrepInfoRef().bGrepReplace); +} + +/*! + * @brief パラメータ解析(-GREPR)の仕様:置換フラグの非設定 + * @remark GREPRが指定されずGKEYのみの場合、bGrepReplaceはfalseのままであることを確認する。 + */ +TEST(CCommandLine, ParseGrepReplace_OnlyGKEYDoesNotSetReplaceFlag) +{ + CCommandLine c; + c.ParseCommandLine(L"-GKEY=foo", false); + EXPECT_FALSE(c.GetGrepInfoRef().bGrepReplace); +} + +/*! + * @brief パラメータ解析(-GREPR)の仕様:空文字列での置換 + * @remark 置換文字列として空文字列("")を指定した場合もbGrepReplaceがtrueとして扱われることを確認する。 + */ +TEST(CCommandLine, ParseGrepReplace_GREPREmptyAllowed) +{ + CCommandLine c; + c.ParseCommandLine(L"-GREPR=\"\"", false); + EXPECT_STREQ(L"", c.GetGrepInfoRef().cmGrepRep.GetStringPtr()); + EXPECT_TRUE(c.GetGrepInfoRef().bGrepReplace); +} + +/*! + * @brief パラメータ解析(-GREPR)の仕様:クリップボードからの貼り付け置換 + * @remark GOPT=C (クリップボード貼り付け) と GREPR を両立して設定することを確認する。 + */ +TEST(CCommandLine, ParseGrepReplace_ClipboardPasteFlag) +{ + CCommandLine c; + c.ParseCommandLine(L"-GREPR=foo -GOPT=C", false); + EXPECT_TRUE(c.GetGrepInfoRef().bGrepReplace); + EXPECT_TRUE(c.GetGrepInfoRef().bGrepPaste); +} + +// ----- case-insensitive と引数順序 ----- + +/*! + * @brief パラメータ解析の仕様:オプション名の大文字小文字無視(小文字のみ) + * @remark -gkey のように小文字で指定しても -GKEY と同様に認識することを確認する。 + */ +TEST(CCommandLine, ParseGrep_OptionNameLowerCase) +{ + CCommandLine c; + c.ParseCommandLine(L"-gkey=foo", false); + EXPECT_STREQ(L"foo", c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); +} + +/*! + * @brief パラメータ解析の仕様:オプション名の大文字小文字無視(混在) + * @remark -Gkey や -gFOLDER のように大文字小文字が混在していても正しく認識することを確認する。 + */ +TEST(CCommandLine, ParseGrep_OptionNameMixedCase) +{ + CCommandLine c; + c.ParseCommandLine(L"-Gkey=foo -gFOLDER=C:\\tmp", false); + EXPECT_STREQ(L"foo", c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); + EXPECT_STREQ(L"C:\\tmp", c.GetGrepInfoRef().cmGrepFolder.GetStringPtr()); +} + +/*! + * @brief パラメータ解析の仕様:引数順序の非依存性 + * @remark 異なる順序で引数を指定しても、解析結果(GrepInfo)が完全に一致することを確認する。 + */ +TEST(CCommandLine, ParseGrep_ArgumentOrderInvariant) +{ + CCommandLine c1, c2; + c1.ParseCommandLine(L"-GKEY=foo -GFILE=*.cpp -GFOLDER=C:\\tmp", false); + c2.ParseCommandLine(L"-GFOLDER=C:\\tmp -GFILE=*.cpp -GKEY=foo", false); + EXPECT_STREQ(c1.GetGrepInfoRef().cmGrepKey.GetStringPtr(), c2.GetGrepInfoRef().cmGrepKey.GetStringPtr()); + EXPECT_STREQ(c1.GetGrepInfoRef().cmGrepFile.GetStringPtr(), c2.GetGrepInfoRef().cmGrepFile.GetStringPtr()); + EXPECT_STREQ(c1.GetGrepInfoRef().cmGrepFolder.GetStringPtr(), c2.GetGrepInfoRef().cmGrepFolder.GetStringPtr()); +} + +/*! + * @brief パラメータ解析の仕様:GrepモードとGrepダイアログの両立 + * @remark -GREPMODE と -GREPDLG が同時に指定された場合、両方のフラグが立つことを確認する。 + */ +TEST(CCommandLine, ParseGrep_GrepModeAndGrepDlgBothSpecified) +{ + CCommandLine c; + c.ParseCommandLine(L"-GREPMODE -GREPDLG", false); + EXPECT_TRUE(c.IsGrepMode()); + EXPECT_TRUE(c.IsGrepDlg()); +} + +/*! + * @brief パラメータ解析の仕様:同一オプションの重複指定(後勝ち) + * @remark -GKEY が複数回指定された場合、最後の値で上書きすることを確認する。 + */ +TEST(CCommandLine, ParseGrep_GKeyDuplicate_LaterWins) +{ + CCommandLine c; + c.ParseCommandLine(L"-GKEY=foo -GKEY=bar", false); + EXPECT_STREQ(L"bar", c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); +} + +// ----- -GKEY / -GFILE / -GFOLDER の値境界 ----- + +/*! + * @brief パラメータ解析(-GKEY)の仕様:最小文字数(1文字) + * @remark 引数値が1文字のみの場合でも欠落せず正しく保持することを確認する。 + */ +TEST(CCommandLine, ParseGrepKey_SingleChar_Boundary) +{ + CCommandLine c; + c.ParseCommandLine(L"-GKEY=a", false); + EXPECT_STREQ(L"a", c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); +} + +/*! + * @brief パラメータ解析(-GKEY)の仕様:長大な文字列の許容 + * @remark バッファサイズを超えるような非常に長い入力(4096文字)でも、切り詰めやクラッシュなく保持することを確認する。 + */ +TEST(CCommandLine, ParseGrepKey_VeryLongValue_4096Chars) +{ + CCommandLine c; + std::wstring longKey(4096, L'A'); + std::wstring cmd = L"-GKEY=" + longKey; + c.ParseCommandLine(cmd.c_str(), false); + EXPECT_STREQ(longKey.c_str(), c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); +} + +/*! + * @brief パラメータ解析(-GKEY)の仕様:マルチバイト文字・サロゲートペア + * @remark UTF-16 サロゲートペア(U+1F600)を含む値が正しく保持されることを確認する。 + * ソースファイルのコードページ(932)では直接記述できないため、\xD83D\xDE00 で UTF-16 リテラルとして指定する。 + */ +TEST(CCommandLine, ParseGrepKey_JapaneseCharsAndEmoji) +{ + CCommandLine c; + c.ParseCommandLine(L"-GKEY=日本語\xD83D\xDE00", false); + EXPECT_STREQ(L"日本語\xD83D\xDE00", c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); +} + +/*! + * @brief パラメータ解析(-GKEY)の仕様:値の中に区切り文字(=)が含まれる場合 + * @remark 最初の '=' のみをオプションと値の区切りとして扱い、以降の '=' は値の一部として保持することを確認する。 + */ +TEST(CCommandLine, ParseGrepKey_ContainsEqualsInValue) +{ + CCommandLine c; + c.ParseCommandLine(L"-GKEY=foo=bar", false); + EXPECT_STREQ(L"foo=bar", c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); +} + +/*! + * @brief パラメータ解析(-GKEY)の仕様:エスケープされたダブルクォート + * @remark `""` によってエスケープされたダブルクォートを、正しく1つの `"` としてデコードすることを確認する。 + */ +TEST(CCommandLine, ParseGrepKey_EscapedDoubleQuotes) +{ + CCommandLine c; + c.ParseCommandLine(L"-GKEY=\"he said \"\"hi\"\"\"", false); + EXPECT_STREQ(L"he said \"hi\"", c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); +} + +/*! + * @brief パラメータ解析(-GFILE)の仕様:Grep除外用正規表現パターンの保持 + * @remark 正規表現を利用したファイル除外パターン `!.*test.*\\.cpp$` 等を文字列として壊れず保持することを確認する。 + */ +TEST(CCommandLine, ParseGrepFile_RegexExcludePatternFromPR2449) +{ + CCommandLine c; + c.ParseCommandLine(L"-GFILE=*.cpp;!.*test.*\\.cpp$", false); + EXPECT_STREQ(L"*.cpp;!.*test.*\\.cpp$", c.GetGrepInfoRef().cmGrepFile.GetStringPtr()); +} + +/*! + * @brief パラメータ解析(-GFOLDER)の仕様:相対パスの入力 + * @remark CommandLineパース層では絶対パスへの変換を行わず、指定された相対パス(`.`)をそのまま保持することを確認する。 + */ +TEST(CCommandLine, ParseGrepFolder_RelativePath) +{ + CCommandLine c; + c.ParseCommandLine(L"-GFOLDER=.", false); + EXPECT_STREQ(L".", c.GetGrepInfoRef().cmGrepFolder.GetStringPtr()); +} + +/*! + * @brief パラメータ解析(-GFOLDER)の仕様:UNCパスの入力 + * @remark ネットワークパス (\\server\share\src) を、バックスラッシュの欠落などなく正常に保持することを確認する。 + */ +TEST(CCommandLine, ParseGrepFolder_UncPath) +{ + CCommandLine c; + c.ParseCommandLine(L"-GFOLDER=\\\\server\\share\\src", false); + EXPECT_STREQ(L"\\\\server\\share\\src", c.GetGrepInfoRef().cmGrepFolder.GetStringPtr()); +} diff --git a/src/test/cpp/tests1/test-cdlggrep.cpp b/src/test/cpp/tests1/test-cdlggrep.cpp new file mode 100644 index 0000000000..bbff8873a2 --- /dev/null +++ b/src/test/cpp/tests1/test-cdlggrep.cpp @@ -0,0 +1,527 @@ +/*! @file */ +/* + Copyright (C) 2026, Sakura Editor Organization + + SPDX-License-Identifier: Zlib +*/ + +#include "pch.h" +#include + +#include +#include + +#include "dlg/CDlgGrep.h" +#include "dlg/ModalDialogCloser.hpp" +#include "env/ShareDataTestSuite.hpp" +#include "grep/CGrepEnumKeys.h" +#include "mem/CNativeW.h" +#include "env/DLLSHAREDATA.h" + +namespace { + +struct CDlgGrepTest + : public ::testing::Test + , public env::ShareDataTestSuite +{ + static void SetUpTestSuite() + { + // CDlgGrep は履歴や既定値の取得で ShareData を参照するため、事前に初期化する。 + SetUpShareData(); + } + + static void TearDownTestSuite() + { + TearDownShareData(); + } +}; + +} // namespace + +// ====================================================================== +// Phase 3-A: CDlgGrep Unit Tests (PR #2459) +// ====================================================================== + +// ----- 既定値とパック処理 ----- + +/*! + * @brief コンストラクタ直後の既定値検証 (DG-01) + * @remark コンストラクタ呼び出し直後、サブフォルダー検索や出力形式などが既定値になっていることを確認する。 + */ +TEST_F(CDlgGrepTest, DefaultMemberValues_Constructor) +{ + CDlgGrep dlg; + + EXPECT_FALSE(dlg.m_bSubFolder); // 初期状態ではサブフォルダー検索は無効 + EXPECT_FALSE(dlg.m_bFromThisText); // 初期状態では編集中テキスト検索は無効 + EXPECT_EQ(CODE_SJIS, dlg.m_nGrepCharSet); // 既定の文字コードは SJIS + EXPECT_EQ(1, dlg.m_nGrepOutputLineType); // 既定の出力対象はヒット行 + EXPECT_EQ(1, dlg.m_nGrepOutputStyle); // 既定の出力形式は Normal + EXPECT_EQ(L'\0', dlg.m_szFile[0]); // 検索ファイル欄は空で開始 + EXPECT_EQ(L'\0', dlg.m_szFolder[0]); // 検索フォルダー欄は空で開始 + EXPECT_EQ(L'\0', dlg.m_szExcludeFile[0]); // 除外ファイル欄は空で開始 + EXPECT_EQ(L'\0', dlg.m_szExcludeFolder[0]); // 除外フォルダー欄は空で開始 +} + +/*! + * @brief 除外パターンの定数検証 (DG-02) + * @remark DEFAULT_EXCLUDE_FILE_PATTERN と DEFAULT_EXCLUDE_FOLDER_PATTERN が仕様通り定義されていることを確認する。 + */ +TEST_F(CDlgGrepTest, DefaultExcludePatterns_Constants) +{ + EXPECT_STREQ(L".*\\.msi$;.*\\.exe$;.*\\.obj$;.*\\.pdb$;.*\\.ilk$;.*\\.res$;.*\\.pch$;.*\\.iobj$;.*\\.ipdb$", DEFAULT_EXCLUDE_FILE_PATTERN); + EXPECT_STREQ(L".git;.svn;.vs", DEFAULT_EXCLUDE_FOLDER_PATTERN); +} + +/*! + * @brief パック処理:除外指定なし (DG-03) + * @remark 除外指定がない場合、検索ファイルパターンをそのまま返すことを確認する。 + */ +TEST_F(CDlgGrepTest, GetPackedGFileString_NoExclusions) +{ + CDlgGrep dlg; + wcscpy_s(dlg.m_szFile, L"*.cpp"); + std::wstring packed = dlg.GetPackedGFileString().GetStringPtr(); + EXPECT_STREQ(L"*.cpp", packed.c_str()); +} + +/*! + * @brief パック処理:除外フォルダーあり (DG-04) + * @remark 除外フォルダーが指定された場合、`#` プレフィックスを付加して結合することを確認する。 + */ +TEST_F(CDlgGrepTest, GetPackedGFileString_WithExcludeFolders) +{ + CDlgGrep dlg; + wcscpy_s(dlg.m_szFile, L"*.cpp"); + wcscpy_s(dlg.m_szExcludeFolder, L".git;.svn"); + std::wstring packed = dlg.GetPackedGFileString().GetStringPtr(); + EXPECT_STREQ(L"*.cpp;#.git;#.svn", packed.c_str()); +} + +/*! + * @brief パック処理:除外ファイルあり (DG-05) + * @remark 除外ファイルが指定された場合、`!` プレフィックスを付加して結合することを確認する。 + * 検索対象ファイルが空の場合は `*.*` を補う。 + */ +TEST_F(CDlgGrepTest, GetPackedGFileString_WithExcludeFiles) +{ + CDlgGrep dlg; + wcscpy_s(dlg.m_szFile, L""); // 空の場合は内部で *.* になる + wcscpy_s(dlg.m_szExcludeFile, L"*.obj;*.exe"); + std::wstring packed = dlg.GetPackedGFileString().GetStringPtr(); + EXPECT_STREQ(L"*.*;!*.obj;!*.exe", packed.c_str()); +} + +/*! + * @brief パック処理:除外フォルダー内の「!」エスケープ (DG-06) + * @remark `!` が含まれるフォルダー名をダブルクォートでエスケープして結合することを確認する。 + */ +TEST_F(CDlgGrepTest, GetPackedGFileString_EscapeBangInExcludeFolder) +{ + CDlgGrep dlg; + wcscpy_s(dlg.m_szExcludeFolder, L"!special"); + std::wstring packed = dlg.GetPackedGFileString().GetStringPtr(); + EXPECT_STREQ(L"*.*;\"#!special\"", packed.c_str()); +} + +/*! + * @brief パック処理:除外フォルダー内の「#」エスケープ (DG-07) + * @remark `#` が含まれるフォルダー名をダブルクォートでエスケープして結合することを確認する。 + */ +TEST_F(CDlgGrepTest, GetPackedGFileString_EscapeHashInExcludeFolder) +{ + CDlgGrep dlg; + wcscpy_s(dlg.m_szExcludeFolder, L"#hash"); + std::wstring packed = dlg.GetPackedGFileString().GetStringPtr(); + EXPECT_STREQ(L"*.*;\"##hash\"", packed.c_str()); +} + +/*! + * @brief パック処理:除外フォルダー名のスペース区切り (DG-08) + * @remark SplitPattern はスペースを区切り文字として扱うため、 + * 「my folder」は「my」と「folder」に分割され、それぞれに # が付く。 + * スペース含みの単一フォルダー名は GetPackedGFileString 経由では扱えない(既知制限)。 + */ +TEST_F(CDlgGrepTest, GetPackedGFileString_EscapeSpaceInExcludeFolder) +{ + CDlgGrep dlg; + wcscpy_s(dlg.m_szExcludeFolder, L"my folder"); + std::wstring packed = dlg.GetPackedGFileString().GetStringPtr(); + EXPECT_STREQ(L"*.*;#my;#folder", packed.c_str()); +} + +/*! + * @brief パック処理:除外フォルダー内のセミコロン (DG-09) + * @remark セミコロンは区切り文字として処理され、それぞれ独立した除外指定となることを確認する。 + */ +TEST_F(CDlgGrepTest, GetPackedGFileString_EscapeSemicolonInExcludeFolder) +{ + CDlgGrep dlg; + wcscpy_s(dlg.m_szExcludeFolder, L"a;b"); + std::wstring packed = dlg.GetPackedGFileString().GetStringPtr(); + EXPECT_STREQ(L"*.*;#a;#b", packed.c_str()); +} + +/*! + * @brief パック処理:複合パターン (DG-10) + * @remark 除外フォルダーと除外ファイルの両方が指定された場合、すべてを正しくパック文字列に結合することを確認する。 + */ +TEST_F(CDlgGrepTest, GetPackedGFileString_CombinedAllExclusions) +{ + CDlgGrep dlg; + wcscpy_s(dlg.m_szFile, L"*.cpp"); + wcscpy_s(dlg.m_szExcludeFolder, L"build;dist"); + wcscpy_s(dlg.m_szExcludeFile, L"*.obj;*.tmp"); + std::wstring packed = dlg.GetPackedGFileString().GetStringPtr(); + EXPECT_STREQ(L"*.cpp;#build;#dist;!*.obj;!*.tmp", packed.c_str()); +} + +/*! + * @brief パックとアンパックのラウンドトリップ検証 (GUI -> CLI -> EnumKeys) (DG-11) + * @remark GUIで設定した複雑なパターンをパックし、それを再度 `CGrepEnumKeys::SetFileKeys` で正しくパースすることを確認する。 + */ +TEST_F(CDlgGrepTest, RoundTrip_GuiToCliToEnumKeys) +{ + CDlgGrep dlg; + wcscpy_s(dlg.m_szFile, L"*.cpp"); + wcscpy_s(dlg.m_szExcludeFolder, L"build;dist"); + wcscpy_s(dlg.m_szExcludeFile, L"*.obj;*.tmp"); + std::wstring packed = dlg.GetPackedGFileString().GetStringPtr(); + + CGrepEnumKeys keys; + EXPECT_EQ(0, keys.SetFileKeys(packed.c_str())); + + EXPECT_EQ(1, keys.m_vecSearchFileKeys.size()); // 検索対象は 1 件 + EXPECT_STREQ(L"*.cpp", keys.m_vecSearchFileKeys[0]); + + EXPECT_EQ(2, keys.m_vecExceptFolderKeys.size()); // 除外フォルダーは 2 件 + EXPECT_STREQ(L"build", keys.m_vecExceptFolderKeys[0]); + EXPECT_STREQ(L"dist", keys.m_vecExceptFolderKeys[1]); + + EXPECT_EQ(2, keys.m_vecExceptFileRegexPatterns.size()); // ! プレフィックスは正規表現除外に入る + EXPECT_STREQ(L"*.obj", keys.m_vecExceptFileRegexPatterns[0].c_str()); + EXPECT_STREQ(L"*.tmp", keys.m_vecExceptFileRegexPatterns[1].c_str()); +} + +// ----- 切り出し関数のテスト ----- + +/*! + * @brief 既定除外パターンの決定:履歴が空の場合 (DG-12) + * @remark 履歴が空の場合、定数で定義された既定パターンを使用し、履歴にも追加することを確認する。 + */ +TEST_F(CDlgGrepTest, DetermineDefaultExcludePatterns_EmptyHistorySetsDefaults) +{ + CDlgGrep dlg; + GetDllShareDataPtr()->m_sSearchKeywords.m_aExcludeFiles.clear(); + dlg.DetermineDefaultExcludePatterns(); + EXPECT_STREQ(DEFAULT_EXCLUDE_FILE_PATTERN, dlg.m_szExcludeFile); +} + +/*! + * @brief 既定除外パターンの決定:履歴が存在する場合 (DG-13) + * @remark 履歴が既に存在する場合、先頭の履歴を既定の除外パターンとして使用することを確認する。 + */ +TEST_F(CDlgGrepTest, DetermineDefaultExcludePatterns_NonEmptyHistoryUsesHistoryTop) +{ + CDlgGrep dlg; + GetDllShareDataPtr()->m_sSearchKeywords.m_aExcludeFiles.clear(); + GetDllShareDataPtr()->m_sSearchKeywords.m_aExcludeFiles.push_back(L"*.bak"); + dlg.DetermineDefaultExcludePatterns(); + EXPECT_STREQ(L"*.bak", dlg.m_szExcludeFile); +} + +/*! + * @brief 既定除外パターンの決定:既に値がセットされている場合 (DG-14) + * @remark 既に `m_szExcludeFile` に値がセットされている場合は、既定値の書き込みをスキップすることを確認する。 + */ +TEST_F(CDlgGrepTest, DetermineDefaultExcludePatterns_AlreadySetSkipped) +{ + CDlgGrep dlg; + wcscpy_s(dlg.m_szExcludeFile, L"*.tmp"); + dlg.DetermineDefaultExcludePatterns(); + EXPECT_STREQ(L"*.tmp", dlg.m_szExcludeFile); +} + +// ----- :HWND: トークンのテスト ----- + +/*! + * @brief HWNDフォーマット(BuildHwndFileToken)のテスト (DG-15) + * @remark 指定されたHWND値を `:HWND:` プレフィックス付きの固定長文字列として正しくフォーマットすることを確認する。 + */ +TEST_F(CDlgGrepTest, BuildHwndFileToken_Format) +{ + HWND hwnd = (HWND)(uintptr_t)0xABCD1234; + std::wstring token = CDlgGrep::BuildHwndFileToken(hwnd); +#ifdef _WIN64 + EXPECT_STREQ(L":HWND:00000000abcd1234", token.c_str()); // 64bit は 16 桁固定 +#else + EXPECT_STREQ(L":HWND:abcd1234", token.c_str()); // 32bit は 8 桁固定 +#endif +} + +/*! + * @brief HWNDフォーマット(BuildHwndFileToken)のテスト(NULL指定) (DG-16) + * @remark HWNDがNULLの場合でも正しくゼロ埋めしてフォーマットすることを確認する。 + */ +TEST_F(CDlgGrepTest, BuildHwndFileToken_NullHwnd) +{ + HWND hwnd = NULL; + std::wstring token = CDlgGrep::BuildHwndFileToken(hwnd); +#ifdef _WIN64 + EXPECT_STREQ(L":HWND:0000000000000000", token.c_str()); // NULL でもゼロ埋め +#else + EXPECT_STREQ(L":HWND:00000000", token.c_str()); // NULL でもゼロ埋め +#endif +} + +/*! + * @brief HWNDファイルトークン判定(IsHwndFileToken)のポジティブテスト (DG-17) + * @remark 正しいフォーマットの `:HWND:` 文字列に対して true を返すことを確認する。 + */ +TEST_F(CDlgGrepTest, IsHwndFileToken_PositiveCases) +{ + EXPECT_TRUE(CDlgGrep::IsHwndFileToken(L":HWND:00000000")); // 正しい接頭辞なら true + EXPECT_TRUE(CDlgGrep::IsHwndFileToken(L":HWND:abc123")); +} + +/*! + * @brief HWNDファイルトークン判定(IsHwndFileToken)のネガティブテスト (DG-18) + * @remark 異なるフォーマットや通常のファイル名に対して false を返すことを確認する。 + */ +TEST_F(CDlgGrepTest, IsHwndFileToken_NegativeCases) +{ + EXPECT_FALSE(CDlgGrep::IsHwndFileToken(L"*.cpp")); // 通常のファイル名は false + EXPECT_FALSE(CDlgGrep::IsHwndFileToken(L":hwnd:abc")); + EXPECT_FALSE(CDlgGrep::IsHwndFileToken(L":HWND")); + EXPECT_FALSE(CDlgGrep::IsHwndFileToken(L"")); +} + +// ============================================================================= +// フル GUI 結合テスト (Phase 5 / DG-20 ~ 27) +// ============================================================================= + +struct CDlgGrepGuiTest : public ::testing::Test, public env::ShareDataTestSuite { + static void SetUpTestSuite() { SetUpShareData(); } + static void TearDownTestSuite() { TearDownShareData(); } + + std::filesystem::path m_testDir; + + void SetUp() override { + if (::GetModuleHandleW(nullptr) == nullptr) { + GTEST_SKIP() << "GUI not available"; + } + + // tests1.exe の配置ディレクトリ基準で一時フォルダを作成(GetTempPath はパス非依存で環境差異が出るため使わない) + wchar_t exePath[MAX_PATH]; + ::GetModuleFileNameW(nullptr, exePath, MAX_PATH); + m_testDir = std::filesystem::path(exePath).parent_path() / L"test_grep_gui"; + std::filesystem::create_directories(m_testDir); + + // フォルダ存在チェックを通過させるためのダミーファイル + std::ofstream ofs(m_testDir / L"dummy.cpp", std::ios::trunc); + ofs << "int main() { return 0; }\n"; + } + + void TearDown() override { + std::error_code ec; + std::filesystem::remove_all(m_testDir, ec); + } +}; + +/*! + * @brief キャンセル即時送信テスト (DG-20) + * @remark DoModal に対して直ちに IDCANCEL を送信し、0 を返して正常終了することを確認する。 + */ +TEST_F(CDlgGrepGuiTest, DoModalCancelImmediately_NoException) +{ + dialog::ModalDialogCloser closer; // デフォルトで IDCANCEL を送信 + CDlgGrep dlg; + const auto hInstance = ::GetModuleHandleW(nullptr); + const int rc = dlg.DoModal(hInstance, nullptr, nullptr); + EXPECT_EQ(0, rc); +} + +/*! + * @brief 有効入力でのOK送信テスト (DG-21) + * @remark 有効な検索キーワード・ファイル・フォルダを指定して IDOK を送信し、正常に受理されて 1 (OK) が返ることを確認する。 + */ +TEST_F(CDlgGrepGuiTest, DoModalOK_WithValidInputs_ReturnsOK) +{ + dialog::ModalDialogCloser closer([](HWND hWnd) { + ::PostMessageW(hWnd, WM_COMMAND, MAKEWPARAM(IDOK, BN_CLICKED), 0); + }); + CDlgGrep dlg; + dlg.m_strText = L"search"; + wcscpy_s(dlg.m_szFile, L"*.cpp"); + wcscpy_s(dlg.m_szFolder, m_testDir.c_str()); + const auto hInstance = ::GetModuleHandleW(nullptr); + const int rc = dlg.DoModal(hInstance, nullptr, nullptr); + EXPECT_EQ(1, rc); +} + +/*! + * @brief フォルダ未指定時のフォールバック動作 (DG-22) + * @remark フォルダが空の状態で IDOK を送信した場合、 + * GetData 内でカレントフォルダ等にフォールバックして成功する仕様を凍結する。 + */ +TEST_F(CDlgGrepGuiTest, DoModalOK_EmptyFolder_FallsBackToCurrentDir) +{ + dialog::ModalDialogCloser closer([](HWND hWnd) { + HWND hOwner = ::GetWindow(hWnd, GW_OWNER); + if (hOwner != nullptr) { + // MessageBox を閉じる + ::SendMessageW(hWnd, WM_COMMAND, MAKEWPARAM(IDCANCEL, BN_CLICKED), 0); + // 本体をキャンセルで閉じて復帰させる + ::PostMessageW(hOwner, WM_COMMAND, MAKEWPARAM(IDCANCEL, BN_CLICKED), 0); + } else { + // 本体のダイアログには OK を送る + ::PostMessageW(hWnd, WM_COMMAND, MAKEWPARAM(IDOK, BN_CLICKED), 0); + } + }); + + CDlgGrep dlg; + dlg.m_strText = L"search"; + wcscpy_s(dlg.m_szFile, L"*.cpp"); + dlg.m_szFolder[0] = L'\0'; // 空のフォルダ + + const auto hInstance = ::GetModuleHandleW(nullptr); + const int rc = dlg.DoModal(hInstance, nullptr, nullptr); + EXPECT_EQ(1, rc); // 空フォルダでも GetData 内でカレントフォルダ等にフォールバックして成功する仕様を凍結 +} + +/*! + * @brief 不正正規表現指定時の DoModal 動作 (DG-23) + * @remark 不正な正規表現を指定して IDOK を送信した場合でも、 + * DoModal は成功(rc=1)を返す仕様を凍結する。 + * 構文チェックは DoModal 内ではブロッキングしない。 + */ +TEST_F(CDlgGrepGuiTest, DoModalOK_InvalidRegex_StillReturnsOK) +{ + dialog::ModalDialogCloser closer([](HWND hWnd) { + HWND hOwner = ::GetWindow(hWnd, GW_OWNER); + if (hOwner != nullptr) { + ::SendMessageW(hWnd, WM_COMMAND, MAKEWPARAM(IDCANCEL, BN_CLICKED), 0); + ::PostMessageW(hOwner, WM_COMMAND, MAKEWPARAM(IDCANCEL, BN_CLICKED), 0); + } else { + ::PostMessageW(hWnd, WM_COMMAND, MAKEWPARAM(IDOK, BN_CLICKED), 0); + } + }); + + CDlgGrep dlg; + dlg.m_strText = L"(invalid"; + dlg.m_sSearchOption.bRegularExp = true; + wcscpy_s(dlg.m_szFile, L"*.cpp"); + wcscpy_s(dlg.m_szFolder, m_testDir.c_str()); + + const auto hInstance = ::GetModuleHandleW(nullptr); + const int rc = dlg.DoModal(hInstance, nullptr, nullptr); + EXPECT_EQ(1, rc); // 不正正規表現でも DoModal は成功する仕様を凍結(構文チェックは DoModal 内でブロッキングしない) +} + +/*! + * @brief セミコロン区切りフォルダの仕様凍結テスト (DG-24) + * @remark セミコロンは CreateFolders で区切り文字として分割される仕様(既知制限)。 + * セミコロン含みの単一フォルダとしては扱えないため、 + * セミコロンで区切った 2 つの実在フォルダを渡して IDOK が返ることを確認する。 + */ +TEST_F(CDlgGrepGuiTest, DoModalOK_FolderWithSemicolon_IsSplitBySeparator) +{ + // セミコロンを含むパスは CreateFolders で分割されるため、 + // 「セミコロン含みの単一フォルダ」としては扱えない。 + // これは Sakura の仕様(既知制限)。 + // + // 代わりに、セミコロンで区切った 2 つの実在フォルダを検証する。 + auto dir1 = m_testDir / L"folderA"; + auto dir2 = m_testDir / L"folderB"; + std::filesystem::create_directories(dir1); + std::filesystem::create_directories(dir2); + + dialog::ModalDialogCloser closer([](HWND hWnd) { + ::PostMessageW(hWnd, WM_COMMAND, MAKEWPARAM(IDOK, BN_CLICKED), 0); + }); + CDlgGrep dlg; + dlg.m_strText = L"foo"; + wcscpy_s(dlg.m_szFile, L"*.cpp"); + std::wstring folders = dir1.wstring() + L";" + dir2.wstring(); + wcscpy_s(dlg.m_szFolder, folders.c_str()); + + const auto hInstance = ::GetModuleHandleW(nullptr); + const int rc = dlg.DoModal(hInstance, nullptr, nullptr); + EXPECT_EQ(1, rc); +} + +/*! + * @brief 複数絶対パス指定時の挙動 (DG-25) + * @remark セミコロン区切りで各々が有効な絶対パスである場合、2つのフォルダとしてそのまま保持(または解決)することを確認する。 + */ +TEST_F(CDlgGrepGuiTest, DoModalOK_MultipleFolders_AllResolved) +{ + dialog::ModalDialogCloser closer([](HWND hWnd) { + ::PostMessageW(hWnd, WM_COMMAND, MAKEWPARAM(IDOK, BN_CLICKED), 0); + }); + auto sub1 = m_testDir / L"sub1"; + auto sub2 = m_testDir / L"sub2"; + std::filesystem::create_directories(sub1); + std::filesystem::create_directories(sub2); + const std::wstring folders = sub1.wstring() + L";" + sub2.wstring(); + + CDlgGrep dlg; + dlg.m_strText = L"foo"; + wcscpy_s(dlg.m_szFile, L"*.cpp"); + wcscpy_s(dlg.m_szFolder, folders.c_str()); + + const auto hInstance = ::GetModuleHandleW(nullptr); + const int rc = dlg.DoModal(hInstance, nullptr, nullptr); + EXPECT_EQ(1, rc); + // 正規化の詳細は実装依存のため、成功して戻ることのみ確認する +} + +/*! + * @brief 通常検索時の履歴追加 (DG-26) + * @remark 「自テキスト検索」ではない場合、実行によって検索キーワード等を共有データの履歴に記録することを確認する。 + */ +TEST_F(CDlgGrepGuiTest, DoModalOK_HistoryRecordedExceptFromThisText) +{ + dialog::ModalDialogCloser closer([](HWND hWnd) { + ::PostMessageW(hWnd, WM_COMMAND, MAKEWPARAM(IDOK, BN_CLICKED), 0); + }); + CDlgGrep dlg; + dlg.m_strText = L"history_test"; + wcscpy_s(dlg.m_szFile, L"*.h"); + wcscpy_s(dlg.m_szFolder, m_testDir.c_str()); + dlg.m_bFromThisText = FALSE; + + const auto hInstance = ::GetModuleHandleW(nullptr); + dlg.DoModal(hInstance, nullptr, nullptr); + + auto* share = GetDllShareDataPtr(); + EXPECT_STREQ(L"history_test", share->m_sSearchKeywords.m_aSearchKeys[0]); +} + +/*! + * @brief 自テキスト検索時の履歴非汚染 (DG-27) + * @remark 「自テキスト検索」モードでの実行時は、一時的な検索であるため共有データの履歴を汚染しないことを確認する。 + */ +TEST_F(CDlgGrepGuiTest, DoModalOK_FromThisText_DoesNotPolluteHistory) +{ + dialog::ModalDialogCloser closer([](HWND hWnd) { + ::PostMessageW(hWnd, WM_COMMAND, MAKEWPARAM(IDOK, BN_CLICKED), 0); + }); + + auto* share = GetDllShareDataPtr(); + // 履歴の先頭に別の値を入れておく + wcscpy_s(share->m_sSearchKeywords.m_aSearchKeys[0], _MAX_PATH, L"prev_key"); + + CDlgGrep dlg; + dlg.m_strText = L"pollute_test"; + wcscpy_s(dlg.m_szFile, L"*.cpp"); + wcscpy_s(dlg.m_szFolder, m_testDir.c_str()); + dlg.m_bFromThisText = TRUE; + + const auto hInstance = ::GetModuleHandleW(nullptr); + dlg.DoModal(hInstance, nullptr, nullptr); + + // 履歴が更新されていないことを確認 + EXPECT_STREQ(L"prev_key", share->m_sSearchKeywords.m_aSearchKeys[0]); +} + diff --git a/src/test/cpp/tests1/test-cgrepagent-flow.cpp b/src/test/cpp/tests1/test-cgrepagent-flow.cpp new file mode 100644 index 0000000000..494851e113 --- /dev/null +++ b/src/test/cpp/tests1/test-cgrepagent-flow.cpp @@ -0,0 +1,426 @@ +/*! @file */ +/* + Copyright (C) 2026, Sakura Editor Organization + + SPDX-License-Identifier: Zlib +*/ +// ==================================================================================== +// Phase 3-B: CGrepAgent Unit Tests for Result Formatting & Flow Helpers (PR #2459) +// ==================================================================================== + +#include "pch.h" +#include +#include +#include +#include + +#include "agent/CGrepAgent.h" +#include "env/ShareDataTestSuite.hpp" +#include "window/EditorTestSuite.hpp" + +// ----- FormatGrepResultLine ----- + +namespace { +// FormatGrepResultLine は内部で GetDllShareData() を参照するため ShareData を初期化するフィクスチャを使用する +struct CGrepAgentTest + : public ::testing::Test + , public env::ShareDataTestSuite +{ + // BuildGrepHeader/FormatGrepResultLine は内部で共有データを参照するため、ここで初期化する。 + static void SetUpTestSuite() { SetUpShareData(); } + static void TearDownTestSuite() { TearDownShareData(); } +}; +} // namespace + +/*! + * @brief 検索結果行のフォーマット(Normalスタイル) (GA-01) + * @remark パス、行番号、桁番号、文字コード、およびヒットした行全体を指定の書式で出力することを確認する。 + */ +TEST_F(CGrepAgentTest, FormatGrepResultLine_NormalStyle1_ProducesPathLineColContent) +{ + CNativeW cmem; + SGrepOption opt; + opt.nGrepOutputStyle = 1; + opt.nGrepOutputLineType = 1; + + CGrepAgent::FormatGrepResultLine( + cmem, L"C:\\test.cpp", L" [UTF-8]", 42, 5, + L" return true;\n", 15, 1, L"true", 4, opt + ); + EXPECT_STREQ(L"C:\\test.cpp(42,5) [UTF-8]: return true;\r\n", cmem.GetStringPtr()); // 正常形式の出力 +} + +/*! + * @brief 検索結果行のフォーマット(WZ風スタイル) (GA-02) + * @remark ファイル名出力がグループ化するスタイルにおいて、ファイルパス情報を含めずに行番号・桁情報から出力することを確認する。 + */ +TEST_F(CGrepAgentTest, FormatGrepResultLine_WzStyle2_FileGrouped) +{ + CNativeW cmem; + SGrepOption opt; + opt.nGrepOutputStyle = 2; + opt.nGrepOutputLineType = 1; + + CGrepAgent::FormatGrepResultLine( + cmem, L"C:\\test.cpp", L"", 42, 5, + L" return true;\n", 15, 1, L"true", 4, opt + ); + std::wstring s(cmem.GetStringPtr()); + EXPECT_NE(std::wstring::npos, s.find(L"42")); // 行番号 + EXPECT_NE(std::wstring::npos, s.find(L"5")); // 桁番号 + EXPECT_NE(std::wstring::npos, s.find(L"return true;")); // 行内容 + // WZ風の場合、行頭に "・" が付く + EXPECT_EQ(L'・', s[0]); // グループ先頭マーカー +} + +/*! + * @brief 検索結果行のフォーマット(結果のみスタイル) (GA-03) + * @remark パスや行番号情報を出力せず、マッチした行のコンテンツのみを出力することを確認する。 + */ +TEST_F(CGrepAgentTest, FormatGrepResultLine_ResultOnlyStyle3_NoPath) +{ + CNativeW cmem; + SGrepOption opt; + opt.nGrepOutputStyle = 3; + opt.nGrepOutputLineType = 1; + + CGrepAgent::FormatGrepResultLine( + cmem, L"C:\\test.cpp", L"", 42, 5, + L" return true;\n", 15, 1, L"true", 4, opt + ); + EXPECT_STREQ(L" return true;\r\n", cmem.GetStringPtr()); +} + +/*! + * @brief 検索結果行のフォーマット(該当部分のみ出力) (GA-04) + * @remark 行全体ではなく、マッチした部分の文字列だけを出力することを確認する。 + * @note nGrepOutputLineType=0 は内部で GetDllShareData() を参照するため ShareData 初期化が必要。 + */ +TEST_F(CGrepAgentTest, FormatGrepResultLine_OutputLineType0_OnlyMatchPart) +{ + CNativeW cmem; + SGrepOption opt; + opt.nGrepOutputStyle = 3; + opt.nGrepOutputLineType = 0; // 該当部分のみ + + CGrepAgent::FormatGrepResultLine( + cmem, L"C:\\test.cpp", L"", 42, 5, + L" return true;\n", 15, 1, L"true", 4, opt + ); + EXPECT_STREQ(L"true\r\n", cmem.GetStringPtr()); +} + +/*! + * @brief 検索結果行のフォーマット(行全体出力) (GA-05) + * @remark マッチした文字列を含む行全体を出力することを確認する。 + */ +TEST_F(CGrepAgentTest, FormatGrepResultLine_OutputLineType1_FullLine) +{ + CNativeW cmem; + SGrepOption opt; + opt.nGrepOutputStyle = 3; + opt.nGrepOutputLineType = 1; + + CGrepAgent::FormatGrepResultLine( + cmem, L"C:\\test.cpp", L"", 42, 5, + L" return true;\n", 15, 1, L"true", 4, opt + ); + EXPECT_STREQ(L" return true;\r\n", cmem.GetStringPtr()); +} + +/*! + * @brief 検索結果行のフォーマット(否該当行出力) (GA-06) + * @remark nGrepOutputLineType=2 でも FormatGrepResultLine 自体は行全体を出力する。 + * 非該当行の判定・フィルタリングは呼び出し側 (DoGrepFile) の責任である。 + */ +TEST_F(CGrepAgentTest, FormatGrepResultLine_OutputLineType2_NegativeLine) +{ + CNativeW cmem; + SGrepOption opt; + opt.nGrepOutputStyle = 3; // 結果のみ(パス情報を除外して検証を簡潔化) + opt.nGrepOutputLineType = 2; // 否該当行 + + CGrepAgent::FormatGrepResultLine( + cmem, L"C:\\test.cpp", L"", 42, 1, + L"no match here", 13, 0, L"", 0, opt + ); + EXPECT_STREQ(L"no match here\r\n", cmem.GetStringPtr()); +} + +// ----- BuildGrepHeader / BuildGrepFooter ----- + +/*! + * @brief 検索ヘッダ生成(基本形) (GA-07) + * @remark 検索キーワード、対象ファイルパターン、検索対象フォルダーの情報がヘッダに含まれることを確認する。 + */ +TEST(CGrepAgent, BuildGrepHeader_BasicShape) +{ + SSearchOption sOpt; + SGrepOption gOpt; + + CNativeW header = CGrepAgent::BuildGrepHeader(L"foo", L"*.cpp", L"C:\\src", sOpt, gOpt); + std::wstring s(header.GetStringPtr()); + EXPECT_NE(std::wstring::npos, s.find(L"foo")); + EXPECT_NE(std::wstring::npos, s.find(L"*.cpp")); + EXPECT_NE(std::wstring::npos, s.find(L"C:\\src")); +} + +/*! + * @brief 検索ヘッダ生成(正規表現フラグON) (GA-08) + * @remark 正規表現オプションが有効な場合、ヘッダ文字列に「正規表現」のマーカーが含まれることを確認する。 + */ +TEST(CGrepAgent, BuildGrepHeader_WithRegexOption_IncludesMarker) +{ + SSearchOption sOpt; + sOpt.bRegularExp = true; + SGrepOption gOpt; + + CNativeW header = CGrepAgent::BuildGrepHeader(L"foo", L"*.cpp", L"C:\\src", sOpt, gOpt); + std::wstring s(header.GetStringPtr()); + EXPECT_NE(std::wstring::npos, s.find(L"正規表現")); +} + +/*! + * @brief 検索ヘッダ生成(大文字小文字区別フラグON) (GA-09) + * @remark 大文字小文字の区別オプションが有効な場合、ヘッダ文字列に「大文字小文字」のマーカーが含まれることを確認する。 + */ +TEST(CGrepAgent, BuildGrepHeader_WithCaseSensitive_IncludesMarker) +{ + SSearchOption sOpt; + sOpt.bLoHiCase = true; + SGrepOption gOpt; + + CNativeW header = CGrepAgent::BuildGrepHeader(L"foo", L"*.cpp", L"C:\\src", sOpt, gOpt); + std::wstring s(header.GetStringPtr()); + EXPECT_NE(std::wstring::npos, s.find(L"大文字小文字")); +} + +/*! + * @brief 検索フッタ生成(0件ヒット時) (GA-10) + * @remark 検索結果が0件の場合、件数「0」を含むフッタ文字列を生成することを確認する。 + * フッタの具体的な書式(「0 件」「0件」等)は実装依存のため、数字の存在のみを検証する。 + */ +TEST(CGrepAgent, BuildGrepFooter_ZeroHits_HasZeroMessage) +{ + CNativeW footer = CGrepAgent::BuildGrepFooter(0, false); + std::wstring s(footer.GetStringPtr()); + EXPECT_NE(std::wstring::npos, s.find(L"0")); +} + +/*! + * @brief 検索フッタ生成(ヒットあり・通常Grep) (GA-11) + * @remark 通常Grepで件数が含まれること、置換Grepで件数が含まれることをそれぞれ確認する。 + */ +TEST(CGrepAgent, BuildGrepFooter_PositiveHits_HasCount) +{ + { + CNativeW footer = CGrepAgent::BuildGrepFooter(42, false); + std::wstring s(footer.GetStringPtr()); + EXPECT_NE(std::wstring::npos, s.find(L"42")); + } + { + CNativeW footer = CGrepAgent::BuildGrepFooter(42, true); + std::wstring s(footer.GetStringPtr()); + EXPECT_NE(std::wstring::npos, s.find(L"42")); + } +} + +// ----- CreateFolders / ChopYen ----- + +/*! + * @brief CreateFolders: セミコロン区切りの分割 (GA-12) + * @remark 複数のフォルダーパスがセミコロンで区切られている場合、それぞれを独立したパス要素として抽出することを確認する。 + */ +TEST(CGrepAgent, CreateFolders_SemicolonSeparated_SplitsCorrectly) +{ + std::vector v; + CGrepAgent::CreateFolders(L"C:\\a;C:\\b", v); + ASSERT_EQ(2, v.size()); + EXPECT_STREQ(L"C:\\a", v[0].c_str()); + EXPECT_STREQ(L"C:\\b", v[1].c_str()); +} + +/*! + * @brief CreateFolders: ダブルクォートで囲まれたセミコロン (GA-13) + * @remark パスがダブルクォートで囲まれている場合、内部のセミコロンは区切り文字として扱われず、 + * ダブルクォートが除去された1つのパスとなることを確認する。 + */ +TEST(CGrepAgent, CreateFolders_QuotedSemicolon_PreservesAsOne) +{ + std::vector v; + CGrepAgent::CreateFolders(L"\"C:\\a;b\"", v); + ASSERT_EQ(1, v.size()); + EXPECT_STREQ(L"C:\\a;b", v[0].c_str()); +} + +/*! + * @brief CreateFolders: 長いファイル名の解決 (GA-14) + * @remark 8.3形式の短いパスが指定された場合、`GetLongFileName` で元の長いパスに解決してリストへ追加することを確認する。 + * @note 8.3 形式生成が無効化されたボリューム(NTFS Win10+ 既定など)では + * GetShortPathName が失敗するため GTEST_SKIP でスキップする。 + */ +TEST(CGrepAgent, CreateFolders_LongFileName_Resolved) +{ + WCHAR tempDir[MAX_PATH]; + ::GetTempPathW(MAX_PATH, tempDir); + + const std::wstring longPath = std::wstring(tempDir) + L"sakura_grep_test_very_long_file_name.tmp"; + + HANDLE hFile = ::CreateFileW(longPath.c_str(), GENERIC_WRITE, 0, NULL, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + ASSERT_NE(INVALID_HANDLE_VALUE, hFile) + << "一時ファイル作成失敗:テスト前提条件未達。GetLastError=" << ::GetLastError(); + ::CloseHandle(hFile); + + auto fileGuard = std::unique_ptr>( + reinterpret_cast(1), + [&longPath](void*) { ::DeleteFileW(longPath.c_str()); } + ); + + WCHAR shortPath[MAX_PATH]; + if (0 == ::GetShortPathNameW(longPath.c_str(), shortPath, MAX_PATH)) { + GTEST_SKIP() << "8.3 形式が無効なボリュームのためスキップ。GetLastError=" + << ::GetLastError(); + } + + std::vector v; + CGrepAgent::CreateFolders(shortPath, v); + ASSERT_EQ(1u, v.size()); + EXPECT_GT(v[0].length(), 0u); +} + +/*! + * @brief ChopYen: 末尾の円記号削除 (GA-15) + * @remark パスの末尾に円記号(バックスラッシュ)がある場合、それを除去することを確認する。 + */ +TEST(CGrepAgent, ChopYen_LastBackslashRemoved) +{ + EXPECT_STREQ(L"C:\\foo", CGrepAgent::ChopYen(L"C:\\foo\\").c_str()); +} + +/*! + * @brief ChopYen: 末尾に円記号がない場合 (GA-16) + * @remark パスの末尾に円記号がない場合は何も変更しないことを確認する。 + */ +TEST(CGrepAgent, ChopYen_NoTrailingYen_Unchanged) +{ + EXPECT_STREQ(L"C:\\foo", CGrepAgent::ChopYen(L"C:\\foo").c_str()); +} + +/*! + * @brief ChopYen: ルートパスの場合 (GA-17) + * @remark ドライブのルート(C:\ など)の場合でも、仕様として末尾の円記号を除去して(C: となる)ことを確認する。 + */ +TEST(CGrepAgent, ChopYen_RootPath_AlsoTrimmed) +{ + EXPECT_STREQ(L"C:", CGrepAgent::ChopYen(L"C:\\").c_str()); +} + +// ----- AddTail / OnBeforeClose ----- + +namespace { + +/*! + * @brief Windows 標準ハンドル(STD_OUTPUT_HANDLE 等)の一時差し替えと + * スコープ脱出時の確実な復帰を保証する RAII ガード。 + * + * SetStdHandle を直接呼ぶテストでは、例外発生時に元のハンドルへ復帰できず + * テストプロセスの stdout が破壊されたまま後続テストへ波及するリスクがある。 + * 本ガードは構築時に差し替え、デストラクタで必ず元に戻す。 + */ +class StdHandleGuard { +public: + StdHandleGuard(DWORD id, HANDLE replacement) + : m_id(id), m_saved(::GetStdHandle(id)) + { + ::SetStdHandle(m_id, replacement); + } + ~StdHandleGuard() + { + ::SetStdHandle(m_id, m_saved); + } + StdHandleGuard(const StdHandleGuard&) = delete; + StdHandleGuard& operator=(const StdHandleGuard&) = delete; + +private: + DWORD m_id; + HANDLE m_saved; +}; + +struct GrepAgentFlowTest + : public ::testing::Test + , public window::EditorTestSuite +{ + static void SetUpTestSuite() { SetUpEditor(); } + static void TearDownTestSuite() { TearDownEditor(); } +}; + +} // namespace + +/*! + * @brief AddTail: 標準出力への書き込み (GA-18) + * @remark bAddStdout が true に指定された場合、エディタへの挿入ではなく標準出力(stdout)へエンコード変換した結果を書き出すことを確認する。 + */ +TEST_F(GrepAgentFlowTest, AddTail_StdoutMode_WritesToStdout) +{ + WCHAR tempDir[MAX_PATH]; + ::GetTempPathW(MAX_PATH, tempDir); + const std::wstring tmpFile = std::wstring(tempDir) + L"sakura_stdout_test.txt"; + + HANDLE hTempFile = ::CreateFileW(tmpFile.c_str(), + GENERIC_WRITE | GENERIC_READ, + FILE_SHARE_READ, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + ASSERT_NE(INVALID_HANDLE_VALUE, hTempFile) + << "一時ファイル作成失敗。テスト前提条件未達。"; + + struct HandleDeleter { + void operator()(HANDLE h) const { + if (h && h != INVALID_HANDLE_VALUE) ::CloseHandle(h); + } + }; + std::unique_ptr hFileGuard{ hTempFile }; + + { + // stdout 差し替えの RAII(このスコープを抜けると必ず元に戻る) + StdHandleGuard stdoutGuard(STD_OUTPUT_HANDLE, hTempFile); + + CGrepAgent agent; + CNativeW msg(L"HelloGrepTest"); + + // SetUpEditor はフィクスチャ (GrepAgentFlowTest::SetUpTestSuite) で初期化済み + CEditView* pView = &CEditWnd::getInstance()->GetActiveView(); + agent.AddTail(pView, msg, true); + } // ← stdout 復帰 + + ::SetFilePointer(hTempFile, 0, NULL, FILE_BEGIN); + char buf[128] = {0}; + DWORD dwRead = 0; + ::ReadFile(hTempFile, buf, sizeof(buf) - 1, &dwRead, NULL); + + EXPECT_TRUE(strstr(buf, "HelloGrepTest") != nullptr); + + hFileGuard.reset(); + ::DeleteFileW(tmpFile.c_str()); +} + +/*! + * @brief OnBeforeClose: Grep実行中の割り込み (GA-19) + * @remark Grep処理が実行中(m_bGrepRunningがtrue)の場合、クローズ要求に対して割り込み(CALLBACK_INTERRUPT)を返すことを確認する。 + */ +TEST_F(GrepAgentFlowTest, OnBeforeClose_DuringGrepReturnsInterrupt) +{ + CGrepAgent agent; + agent.m_bGrepRunning = true; + EXPECT_EQ(CALLBACK_INTERRUPT, agent.OnBeforeClose()); +} + +/*! + * @brief OnBeforeClose: Grep非実行時の継続 (GA-20) + * @remark Grep処理が実行中でない場合、クローズ要求に対して継続(CALLBACK_CONTINUE)を返すことを確認する。 + */ +TEST_F(GrepAgentFlowTest, OnBeforeClose_NotRunningReturnsContinue) +{ + CGrepAgent agent; + agent.m_bGrepRunning = false; + EXPECT_EQ(CALLBACK_CONTINUE, agent.OnBeforeClose()); +} diff --git a/src/test/cpp/tests1/test-grep-irregular.cpp b/src/test/cpp/tests1/test-grep-irregular.cpp new file mode 100644 index 0000000000..a561a5ebca --- /dev/null +++ b/src/test/cpp/tests1/test-grep-irregular.cpp @@ -0,0 +1,893 @@ +/*! @file */ +// ====================================================================== +// Phase 4: Grep Irregular and Boundary Unit Tests (PR #2459) +// ====================================================================== + +#include "pch.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "agent/CGrepAgent.h" +#include "agent/CSearchAgent.h" +#include "charset/CCodeFactory.h" +#include "charset/CCodeMediator.h" +#include "extmodule/CBregexp.h" +#include "grep/CGrepEnumKeys.h" +#include "_main/CCommandLine.h" +#include "window/EditorTestSuite.hpp" +#include "util/file.h" + +namespace { + +// このファイル内だけで使う補助関数とテスト用 RAII クラスをまとめる。 +// いずれも CGrepAgent や各テストクラスのメソッドではなく、テスト専用の自由関数/ヘルパー。 + +// ============================================================================= +// Helper: GrepTempDir (reused from Phase 2/3 infra) +// ============================================================================= +// 各テストで使う一時ディレクトリを RAII で生成・削除し、失敗時の後始末を安定させる。 +class GrepTempDir +{ +public: + explicit GrepTempDir(std::wstring_view prefix = L"grp_irr") + { + auto candidate = GetTempFilePath(prefix); + std::filesystem::remove(candidate); + std::filesystem::create_directories(candidate); + m_root = candidate; + } + + ~GrepTempDir() + { + std::error_code ec; + std::filesystem::remove_all(m_root, ec); + } + + const std::filesystem::path& Root() const noexcept { return m_root; } + + // 一時ルート配下の相対パスを組み立てる。 + std::filesystem::path Sub(std::wstring_view relative) const + { + return m_root / std::filesystem::path(relative); + } + + // テスト用の一時ルート配下に、必要なら親ディレクトリごと作る。 + void EnsureDir(std::wstring_view relative) const + { + std::filesystem::create_directories(Sub(relative)); + } + + // 指定した文字コードでテキストファイルを書き出す。 + std::filesystem::path WriteEncodedTextFile( + std::wstring_view relative, + ECodeType codeType, + std::wstring_view text, + bool withBom = false) const + { + const auto path = Sub(relative); + std::filesystem::create_directories(path.parent_path()); + + const auto encoded = CCodeFactory::ConvertToCode(codeType, std::wstring(text)); + std::ofstream os(path, std::ios::binary | std::ios::trunc); + if (withBom) { + switch (codeType) { + case CODE_UTF8: os.write("\xEF\xBB\xBF", 3); break; + case CODE_UNICODE: os.write("\xFF\xFE", 2); break; + case CODE_UNICODEBE: os.write("\xFE\xFF", 2); break; + default: break; + } + } + os.write(encoded.destination.data(), static_cast(encoded.destination.size())); + return path; + } + + // 生バイト列をそのままファイルに書き出す。 + std::filesystem::path WriteRawBytes( + std::wstring_view relative, std::string_view bytes) const + { + const auto path = Sub(relative); + std::filesystem::create_directories(path.parent_path()); + std::ofstream os(path, std::ios::binary | std::ios::trunc); + os.write(bytes.data(), static_cast(bytes.size())); + return path; + } + +private: + std::filesystem::path m_root; +}; + +SSearchOption MakeSearchOption(bool regex, bool caseSensitive, bool wordOnly = false) +{ + // 各ケースの比較条件を明示するため、必要なフラグだけを都度組み立てる自由関数。 + SSearchOption opt; + opt.Reset(); + opt.bRegularExp = regex; + opt.bLoHiCase = caseSensitive; + opt.bWordOnly = wordOnly; + return opt; +} + +// DoGrepFileWorker の戻り値に意味を持たせる定数 +constexpr int GREP_RESULT_NO_HIT = 0; // ヒットなし(ファイル読み取り不可含む) +constexpr int GREP_RESULT_CANCELLED = -1; // キャンセルまたはエラー + +SGrepOption MakeGrepOption(ECodeType charSet = CODE_AUTODETECT) +{ + // 不要な UI 要素の影響を避けるため、Grep オプションは最小構成に固定する自由関数。 + SGrepOption gopt; + gopt.nGrepCharSet = charSet; + gopt.nGrepOutputStyle = 1; + gopt.nGrepOutputLineType = 1; + gopt.bGrepHeader = false; + return gopt; +} + +int RunGrepFileWorker( + CGrepAgent& agent, + const std::filesystem::path& path, + std::wstring_view key, + const SSearchOption& sSearchOption, + const SGrepOption& sGrepOption, + std::atomic& cancel) +{ + // 実ファイルを 1 件だけ検索するためのタスクを構築し、DoGrepFileWorker を直接叩く自由関数。 + const std::wstring keyStr(key); + const std::wstring fullPath = path.wstring(); + const std::wstring fileName = path.filename().wstring(); + const std::wstring baseFolder = path.parent_path().wstring(); + + SGrepFileTask task; + task.fullPath = fullPath; + task.fileName = fileName; + task.baseFolder = baseFolder; + task.folder = baseFolder; + task.relPath = fileName; + + CBregexp regexp; + if (sSearchOption.bRegularExp) { + if (!InitRegexp(nullptr, regexp, false)) return -1; + const DWORD flags = sSearchOption.bLoHiCase ? CBregexp::optCaseSensitive : 0; + if (!regexp.Compile(keyStr.c_str(), flags)) return -1; + } + + CSearchStringPattern pattern; + if (!pattern.SetPattern(nullptr, keyStr.c_str(), keyStr.size(), + sSearchOption, sSearchOption.bRegularExp ? ®exp : nullptr)) { + return -1; + } + + CNativeW cmemMessage; + CNativeW cUnicodeBuffer; + cmemMessage.AllocStringBuffer(4000); + cUnicodeBuffer.AllocStringBuffer(4000); + + return agent.DoGrepFileWorker( + task, keyStr.c_str(), sSearchOption, sGrepOption, + sSearchOption.bRegularExp ? ®exp : nullptr, pattern, + cmemMessage, cUnicodeBuffer, cancel); +} + +int RunGrepFileWorker( + CGrepAgent& agent, const std::filesystem::path& path, std::wstring_view key, + const SSearchOption& sSearchOption, const SGrepOption& sGrepOption) +{ + // キャンセルなしのテスト用薄いラッパ。これもメソッドではなく補助関数。 + std::atomic cancel{ false }; + return RunGrepFileWorker(agent, path, key, sSearchOption, sGrepOption, cancel); +} + +struct GrepIrregularTest : public ::testing::Test, public window::EditorTestSuite { + // 各ケースを独立させるため、毎回一時ディレクトリを作り直す。 + static void SetUpTestSuite() { SetUpEditor(); } + static void TearDownTestSuite() { TearDownEditor(); } + void SetUp() override { m_temp = std::make_unique(); } + void TearDown() override { m_temp.reset(); } + std::unique_ptr m_temp; +}; + +} // namespace + +// ============================================================================= +// CGrepEnumKeys 境界 (IRR-01 ~ 08) +// ============================================================================= + +/*! + * @brief 空文字列のキー解析 (IRR-01) + * @remark パターンが空文字列の場合、デフォルトの検索ファイルパターン(*.*)を適用することを確認する。 + */ +TEST(Irregular, SetFileKeys_EmptyString_DefaultsApplied) { + CGrepEnumKeys keys; + EXPECT_EQ(0, keys.SetFileKeys(L"")); // 空文字列でも受理する + ASSERT_EQ(1, keys.m_vecSearchFileKeys.size()); // 検索対象は既定値で 1 件 + EXPECT_STREQ(L"*.*", keys.m_vecSearchFileKeys[0]); // 既定の検索対象 +} + +/*! + * @brief 空白のみの文字列のキー解析 (IRR-02) + * @remark パターンが空白文字のみの場合も、デフォルトの検索ファイルパターン(*.*)を適用することを確認する。 + */ +TEST(Irregular, SetFileKeys_OnlyWhitespace_DefaultsApplied) { + CGrepEnumKeys keys; + EXPECT_EQ(0, keys.SetFileKeys(L" ")); // 空白のみでも受理する + ASSERT_EQ(1, keys.m_vecSearchFileKeys.size()); // 検索対象は既定値で 1 件 + EXPECT_STREQ(L"*.*", keys.m_vecSearchFileKeys[0]); // 既定の検索対象 +} + +/*! + * @brief 除外パターンのみ指定された場合のキー解析 (IRR-03) + * @remark 検索ファイルパターンがなく除外パターンのみが指定された場合、検索対象として「*.*」を補完することを確認する。 + */ +TEST(Irregular, SetFileKeys_OnlyExcludePatterns_DefaultSearchApplied) { + CGrepEnumKeys keys; + EXPECT_EQ(0, keys.SetFileKeys(L"!*.obj;#build")); // 解析は成功する + ASSERT_EQ(1, keys.m_vecSearchFileKeys.size()); // 検索対象は補完されて 1 件 + EXPECT_STREQ(L"*.*", keys.m_vecSearchFileKeys[0]); // 既定の検索対象 + ASSERT_EQ(1, keys.m_vecExceptFileRegexPatterns.size()); // 除外正規表現は 1 件 + EXPECT_STREQ(L"*.obj", keys.m_vecExceptFileRegexPatterns[0].c_str()); // 除外ファイルの中身 + ASSERT_EQ(1, keys.m_vecExceptFolderKeys.size()); // 除外フォルダーは 1 件 + EXPECT_STREQ(L"build", keys.m_vecExceptFolderKeys[0]); // 除外フォルダー名 +} + +/*! + * @brief 超長大パターンのパース (IRR-04) + * @remark 4096文字という極端に長いパターンが与えられても、バッファオーバーラン等を起こさずに正しく保持することを確認する。 + */ +TEST(Irregular, SetFileKeys_VeryLongPattern_4096Chars) { + CGrepEnumKeys keys; + std::wstring longPattern(4096, L'A'); + EXPECT_EQ(0, keys.SetFileKeys(longPattern.c_str())); // 長大入力でも解析は成功する + ASSERT_EQ(1, keys.m_vecSearchFileKeys.size()); // 1 件のまま + EXPECT_EQ(4096u, wcslen(keys.m_vecSearchFileKeys[0])); // 長さをそのまま保持する +} + +/*! + * @brief 重複パターンの除外 (IRR-05) + * @remark 同一のパターンが多数指定された場合、重複を排除して内部リストに単一の要素として保持することを確認する。 + */ +TEST(Irregular, SetFileKeys_ManyDuplicates_DeduplicatedTo1) { + CGrepEnumKeys keys; + std::wstring pattern; + for (int i = 0; i < 100; ++i) pattern += L"*.cpp;"; + EXPECT_EQ(0, keys.SetFileKeys(pattern.c_str())); // 重複があっても解析は成功する + ASSERT_EQ(1, keys.m_vecSearchFileKeys.size()); // 重複を 1 件にまとめる + EXPECT_STREQ(L"*.cpp", keys.m_vecSearchFileKeys[0]); // 残る要素は *.cpp +} + +/*! + * @brief ヌルバイトを含むパターンの挙動 (IRR-06) + * @remark パターンの途中にヌル文字が含まれる場合、そこで文字列が終端したとみなして処理することを確認する。 + */ +TEST(Irregular, SetFileKeys_NullByteInMiddle_TruncatesAtNull) { + CGrepEnumKeys keys; + std::wstring pattern(L"*.cpp\0*.h", 11); + EXPECT_EQ(0, keys.SetFileKeys(pattern.c_str())); // c_str() なので途中の NUL で切れる + ASSERT_EQ(1, keys.m_vecSearchFileKeys.size()); // NUL 以降は見ない + EXPECT_STREQ(L"*.cpp", keys.m_vecSearchFileKeys[0]); // 先頭側だけ残る +} + +/*! + * @brief 不正な正規表現の除外パターン登録 (IRR-07) + * @remark パース処理は通るが、後の正規表現コンパイル処理にて適切にエラー(false)として扱われることを確認する。 + */ +TEST(Irregular, SetFileKeys_BangPrefixWithInvalidRegex_RegisteredButFailsLater) { + CGrepEnumKeys keys; + EXPECT_EQ(0, keys.SetFileKeys(L"!(invalid(regex")); // ひとまず登録は受理する + ASSERT_EQ(1, keys.m_vecExceptFileRegexPatterns.size()); // 除外正規表現は 1 件 + // InitRegexp は DLLSHAREDATA 必須のため TEST() 内では呼ばない。 + // コンパイル失敗の検証は GrepIrregularTest フィクスチャ側で行う。 + EXPECT_STREQ(L"(invalid(regex", keys.m_vecExceptFileRegexPatterns[0].c_str()); +} + +/*! + * @brief 不正な除外正規表現パターンのコンパイル失敗確認 (IRR-07b) + * @remark ! プレフィックス付きの不正なパターンは登録されるが、bregonig でのコンパイルは失敗することを確認する。 + */ +TEST_F(GrepIrregularTest, Regex_InvalidExcludePatternFailsToCompile) { + CBregexp regexp; + if (!InitRegexp(nullptr, regexp, false)) { + GTEST_SKIP() << "bregonig.dll not available"; + } + EXPECT_FALSE(regexp.Compile(L"(invalid(regex", 0)); +} + +/*! + * @brief パス途中にドライブレターを含むパターンの扱い (IRR-08) + * @remark C:\ などの絶対パス表現が途中に含まれる場合でも、先頭の形式に基づいて相対パス扱いとして登録する仕様を確認する。 + */ +TEST(Irregular, SetFileKeys_PathWithDriveLetterInMiddle_TreatedAsRelative) { + CGrepEnumKeys keys; + EXPECT_EQ(0, keys.SetFileKeys(L"foo\\C:\\bar")); // 解析は成功する + // 先頭が絶対パス形でないため、相対パス扱いのまま保持する。 + ASSERT_EQ(1, keys.m_vecSearchFileKeys.size()); // 1 要素のまま + EXPECT_STREQ(L"foo\\C:\\bar", keys.m_vecSearchFileKeys[0]); // 文字列をそのまま保持 +} + +// ============================================================================= +// CCommandLine 境界 (IRR-09 ~ 19) +// ============================================================================= + +/*! + * @brief GKEYの空値オプション (IRR-09) + * @remark "-GKEY=" のように値が空で指定された場合、無視する(または空として登録する)ことを確認する。 + */ +TEST(Irregular, CommandLine_GKEY_EmptyValue_Ignored) { + CCommandLine c; + c.ParseCommandLine(L"-GKEY=", false); + EXPECT_EQ(0, c.GetGrepInfoRef().cmGrepKey.GetStringLength()); // 空文字列のまま +} + +/*! + * @brief GREPRの空値オプション (IRR-10) + * @remark "-GREPR=" のように置換文字列が空で指定された場合、置換フラグがONになり空文字列で置換する設定になることを確認する。 + */ +TEST(Irregular, CommandLine_GREPR_EmptyValue_AcceptedAsEmpty) { + CCommandLine c; + c.ParseCommandLine(L"-GREPR=", false); + EXPECT_EQ(0, c.GetGrepInfoRef().cmGrepRep.GetStringLength()); // 置換文字列は空 + EXPECT_TRUE(c.GetGrepInfoRef().bGrepReplace); // 置換モードは有効 +} + +/*! + * @brief GFOLDERに存在しないパスを指定 (IRR-11) + * @remark 実在しないパスを指定した場合でもエラーとならず、そのまま文字列として保持することを確認する。 + */ +TEST(Irregular, CommandLine_GFOLDER_NonExistentPath_StoredAsIs) { + CCommandLine c; + c.ParseCommandLine(L"-GFOLDER=Z:\\does\\not\\exist", false); + EXPECT_STREQ(L"Z:\\does\\not\\exist", c.GetGrepInfoRef().cmGrepFolder.GetStringPtr()); // 文字列をそのまま保持 +} + +/*! + * @brief GCODEに範囲外の数値を指定 (IRR-12) + * @remark 定義された文字コードの範囲外の数値が指定された場合でも、その値をそのまま保持することを確認する。 + */ +TEST(Irregular, CommandLine_GCODE_OutOfRange_StoredAsIs) { + CCommandLine c; + c.ParseCommandLine(L"-GCODE=99999", false); + EXPECT_EQ(99999, c.GetGrepInfoRef().nGrepCharSet); // 範囲外でも値を保持 +} + +/*! + * @brief GCODEに負の数値を指定 (IRR-13) + * @remark 負の数値が指定された場合でも、その値をそのまま保持することを確認する。 + */ +TEST(Irregular, CommandLine_GCODE_Negative_StoredAsIs) { + CCommandLine c; + c.ParseCommandLine(L"-GCODE=-1", false); + EXPECT_EQ(-1, c.GetGrepInfoRef().nGrepCharSet); // 負値でも値を保持 +} + +/*! + * @brief GKEYに制御文字を含む指定 (IRR-14) + * @remark 制御文字が含まれる検索キーワードが指定された場合、それをそのまま正しく保持することを確認する。 + */ +TEST(Irregular, CommandLine_GKEY_WithControlChars_Stored) { + CCommandLine c; + std::wstring cmd(L"-GKEY=foo\x01" L"bar"); + c.ParseCommandLine(cmd.c_str(), false); + EXPECT_STREQ(L"foo\x01" L"bar", c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); // 制御文字も保持 +} + +/*! + * @brief GKEYにサロゲートペア文字を含む指定 (IRR-15) + * @remark UTF-16 サロゲートペア(U+1F600)を含むキーワードが正しく保持されることを確認する。 + * コードページ 932 では直接記述できないため \xD83D\xDE00 で指定する。 + */ +TEST(Irregular, CommandLine_GKEY_WithSurrogatePair) { + CCommandLine c; + c.ParseCommandLine(L"-GKEY=foo\xD83D\xDE00" L"bar", false); + EXPECT_STREQ(L"foo\xD83D\xDE00" L"bar", c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); // サロゲートペアも保持 +} + +/*! + * @brief 長大なコマンドライン引数のパース (IRR-16) + * @remark Windowsの上限に近い32000文字超の引数が指定されても、クラッシュせずに解析することを確認する。 + */ +// DEBUG実行時に、DEBUG_TRACE は最終的に DebugOutW(バッファサイズ16,000文字)を呼び出すため、 +// -GKEY 以外のオプション(-GREPR、-GFILE、-GFOLDER など)であっても、16,000文字を超える値を設定すると、 +// DebugOutW でバッファあふれを起こし、デバッグブレーク (::DebugBreak()) でクラッシュする。 +// その為、CCommandLine::CheckCommandLine で解析されるすべてのオプション引数に対し文字制限をかけた後に +// ここを復活させる。 +// +// TEST(Irregular, CommandLine_TotalLength_NearWindowsLimit_32767Chars) { +// CCommandLine c; +// std::wstring cmd = L"-GKEY=" + std::wstring(32000, L'A'); +// c.ParseCommandLine(cmd.c_str(), false); +// EXPECT_EQ(32000, c.GetGrepInfoRef().cmGrepKey.GetStringLength()); // 長大入力でも長さが崩れない +// } + + +/*! + * @brief レスポンスファイル経由の Grep 引数解析 (IRR-17) + * @remark ParseCommandLine の第 2 引数を true にしてレスポンスファイルを展開し、 + * -GKEY と -GFILE が正しくパースされることを確認する。 + * レスポンスファイルの区切り文字はスペース(改行ではない)。 + */ +TEST_F(GrepIrregularTest, CommandLine_ResponseFileWithGrepArgs_Parsed) { + auto respPath = m_temp->Sub(L"resp.txt"); + std::ofstream os(respPath, std::ios::trunc); + os << "-GKEY=foo -GFILE=*.cpp"; + os.close(); + + CCommandLine c; + std::wstring cmd = L"-@=\"" + respPath.wstring() + L"\""; + c.ParseCommandLine(cmd.c_str(), true); + EXPECT_STREQ(L"foo", c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); + EXPECT_STREQ(L"*.cpp", c.GetGrepInfoRef().cmGrepFile.GetStringPtr()); +} + +/*! + * @brief ダブルダッシュによるオプション解析の停止 (IRR-18) + * @remark "--" 以降はオプションではなくファイル名として扱われることを確認する。 + */ +TEST(Irregular, CommandLine_DoubleDashStopsOptionParsing) { + CCommandLine c; + c.ParseCommandLine(L"-- -GKEY=foo", false); + EXPECT_EQ(0, c.GetGrepInfoRef().cmGrepKey.GetStringLength()); // オプションは設定しない + // GetFileName の挙動は実装依存のため、キーが空であることのみ保証する +} + +/*! + * @brief コロン区切りでのオプション指定 (IRR-19) + * @remark イコールの代わりにコロンを使用("-GKEY:foo")しても同等にパースすることを確認する。 + */ +TEST(Irregular, CommandLine_ColonSeparatorEquivalentToEqual) { + CCommandLine c; + c.ParseCommandLine(L"-GKEY:foo", false); + EXPECT_STREQ(L"foo", c.GetGrepInfoRef().cmGrepKey.GetStringPtr()); // コロン指定も同等 +} + +// ============================================================================= +// 除外ファイルパターンの正規表現コンパイル検証 (IRR-20 ~ IRR-25) +// ============================================================================= + +/*! + * @brief 有効な正規表現パターンがコンパイルに成功する (IRR-20) + * @remark GUI 除外ファイル欄に .*\.obj$ のような正規表現を入力した場合、 + * CBregexp::Compile が成功することを確認する。 + */ +TEST_F(GrepIrregularTest, ExcludeFile_ValidRegex_CompilesSuccessfully) { + CBregexp regexp; + if (!InitRegexp(nullptr, regexp, false)) { + GTEST_SKIP() << "bregonig.dll not available"; + } + EXPECT_TRUE(regexp.Compile(L".*\\.obj$", 0)); +} + +/*! + * @brief グロブパターンが正規表現コンパイルに失敗する (IRR-21) + * @remark GUI 除外ファイル欄に *.msi のようなグロブを入力した場合、 + * CBregexp::Compile が失敗することを確認する。 + * これは Sakura の「無効な除外正規表現パターン」エラーに相当する。 + */ +TEST_F(GrepIrregularTest, ExcludeFile_GlobPattern_CompileFails) { + CBregexp regexp; + if (!InitRegexp(nullptr, regexp, false)) { + GTEST_SKIP() << "bregonig.dll not available"; + } + EXPECT_FALSE(regexp.Compile(L"*.msi", 0)); +} + +/*! + * @brief 除外ファイルが空の場合はコンパイル対象なし (IRR-22) + * @remark 除外ファイル欄が空の場合、正規表現コンパイルは行われず、 + * エラーも発生しないことを確認する。 + */ +TEST_F(GrepIrregularTest, ExcludeFile_EmptyPattern_NoCompileNoError) { + CGrepEnumKeys keys; + EXPECT_EQ(0, keys.AddExceptFile(L"")); + EXPECT_EQ(0, keys.m_vecExceptFileKeys.size()); +} + +/*! + * @brief 正規表現とグロブの混在:有効パターンが先、無効パターンが後 (IRR-23) + * @remark 除外ファイルに有効な正規表現と無効なグロブが混在した場合の挙動を確認する。 + * 1 件目(正規表現)はコンパイル成功、2 件目(グロブ)はコンパイル失敗。 + * 「最初のエラーで中断」か「エラーをスキップして続行」かの仕様を凍結する。 + */ +TEST_F(GrepIrregularTest, ExcludeFile_MixedRegexAndGlob_FirstSucceedsSecondFails) { + CBregexp regexp; + if (!InitRegexp(nullptr, regexp, false)) { + GTEST_SKIP() << "bregonig.dll not available"; + } + + EXPECT_TRUE(regexp.Compile(L".*\\.obj$", 0)); + + CBregexp regexp2; + if (!InitRegexp(nullptr, regexp2, false)) { + GTEST_SKIP() << "bregonig.dll not available"; + } + EXPECT_FALSE(regexp2.Compile(L"*.msi", 0)); +} + +/*! + * @brief 正規表現とグロブの混在:無効パターンが先、有効パターンが後 (IRR-24) + * @remark IRR-23 と逆順で、順序によって挙動が変わらないことを確認する。 + */ +TEST_F(GrepIrregularTest, ExcludeFile_MixedGlobAndRegex_OrderReversed) { + CBregexp regexp; + if (!InitRegexp(nullptr, regexp, false)) { + GTEST_SKIP() << "bregonig.dll not available"; + } + + EXPECT_FALSE(regexp.Compile(L"*.msi", 0)); + + CBregexp regexp2; + if (!InitRegexp(nullptr, regexp2, false)) { + GTEST_SKIP() << "bregonig.dll not available"; + } + EXPECT_TRUE(regexp2.Compile(L".*\\.obj$", 0)); +} + +/*! + * @brief 全件有効な正規表現パターンの一括コンパイル (IRR-25) + * @remark 除外ファイルに複数の有効な正規表現のみを指定した場合、 + * 全件がコンパイルに成功することを確認する。 + */ +TEST_F(GrepIrregularTest, ExcludeFile_AllValidRegex_AllCompileSuccessfully) { + const wchar_t* patterns[] = { + L".*\\.obj$", + L".*\\.exe$", + L".*\\.pdb$", + }; + + for (const auto& pat : patterns) { + CBregexp regexp; + if (!InitRegexp(nullptr, regexp, false)) { + GTEST_SKIP() << "bregonig.dll not available"; + } + EXPECT_TRUE(regexp.Compile(pat, 0)) + << "Failed to compile: " << pat; + } +} + +// ============================================================================= +// ファイル経路境界(DoGrepFileWorker) (IRR-26 ~ 34) +// ============================================================================= + +/*! + * @brief 0バイトファイルの探索 (IRR-26) + * @remark サイズ0のファイルに対して検索を実行してもクラッシュせず、ヒットなし(0)で返ることを確認する。 + */ +TEST_F(GrepIrregularTest, FileWorker_ZeroByteFile_NoHit) { + auto path = m_temp->WriteRawBytes(L"zero.txt", ""); + CGrepAgent agent; + EXPECT_EQ(0, RunGrepFileWorker(agent, path, L"foo", MakeSearchOption(false, false), MakeGrepOption())); // 0 バイトならヒットなし +} + +/*! + * @brief BOMのみのファイルの探索 (IRR-27) + * @remark 内容がなくBOMだけが存在するファイルに対して検索を実行してもクラッシュせず、ヒットなしで返ることを確認する。 + */ +TEST_F(GrepIrregularTest, FileWorker_OnlyBomFile_NoHit) { + auto path = m_temp->WriteRawBytes(L"bom.txt", "\xEF\xBB\xBF"); + CGrepAgent agent; + EXPECT_EQ(0, RunGrepFileWorker(agent, path, L"foo", MakeSearchOption(false, false), MakeGrepOption(CODE_UTF8))); // BOM だけならヒットなし +} + +/*! + * @brief 不正なUTF-8を含むファイルの探索 (IRR-28) + * @remark ファイル内に不正なUTF-8シーケンスが混入していても、クラッシュせずに正常な部分が検索できることを確認する。 + */ +TEST_F(GrepIrregularTest, FileWorker_InvalidUtf8Sequence_DoesNotCrash) { + auto path = m_temp->WriteRawBytes(L"invalid.txt", "\xEF\xBB\xBF" "abc\xFF\xFE" "def\n"); + CGrepAgent agent; + EXPECT_EQ(1, RunGrepFileWorker(agent, path, L"def", MakeSearchOption(false, false), MakeGrepOption(CODE_UTF8))); // 正常部分だけ検索する +} + +/*! + * @brief 末尾に改行がないファイルの探索 (IRR-29) + * @remark ファイルの最後の行が改行で終わっていない場合でも、最後の行から対象文字列が検索できることを確認する。 + */ +TEST_F(GrepIrregularTest, FileWorker_FileWithoutFinalNewline_LastLineSearched) { + auto path = m_temp->WriteRawBytes(L"nonl.txt", "line1\nline2\nfoo"); + CGrepAgent agent; + EXPECT_EQ(1, RunGrepFileWorker(agent, path, L"foo", MakeSearchOption(false, false), MakeGrepOption(CODE_SJIS))); // 最終行も検索する +} + +/*! + * @brief 複数種類の改行コードが混在するファイルの探索 (IRR-30) + * @remark CR, LF, CRLF が混在するテキストファイルにおいても、行番号のカウントや検索処理が決定的であることを確認する。 + */ +TEST_F(GrepIrregularTest, FileWorker_MixedLineEndings_LineNumbersConsistent) { + auto path = m_temp->WriteRawBytes(L"mixed.txt", "a\r\nb\nc\rd\r\ne\nfoo"); + CGrepAgent agent; + // foo should be on line 6 + EXPECT_EQ(1, RunGrepFileWorker(agent, path, L"foo", MakeSearchOption(false, false), MakeGrepOption(CODE_SJIS))); // 改行種別が混在しても 1 件 +} + +/*! + * @brief 極端に長い1行を含むファイルの探索 (IRR-31) + * @remark 改行のない64KBを超える行が含まれる場合でも、メモリを線形に扱い、正しく対象文字を検出することを確認する。 + */ +TEST_F(GrepIrregularTest, FileWorker_VeryLongSingleLine_64KChars) { + std::string line(65536, 'a'); + line += "foo"; + auto path = m_temp->WriteRawBytes(L"longline.txt", line); + CGrepAgent agent; + EXPECT_EQ(1, RunGrepFileWorker(agent, path, L"foo", MakeSearchOption(false, false), MakeGrepOption(CODE_SJIS))); // 長大 1 行でも見つかる +} + +/*! + * @brief 排他ロックされたファイルの探索 (IRR-32) + * @remark 他のプロセスによって読み取り不可の共有モードでロックされている場合、 + * エラー扱いとしてクラッシュせずに処理を続行できることを確認する。 + */ +TEST_F(GrepIrregularTest, FileWorker_LockedFile_ReturnsError) { + auto path = m_temp->WriteRawBytes(L"locked.txt", "foo\n"); + struct HandleDeleter { void operator()(HANDLE h) const { if (h && h != INVALID_HANDLE_VALUE) ::CloseHandle(h); } }; + std::unique_ptr hFile{ + ::CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL) + }; + ASSERT_NE(INVALID_HANDLE_VALUE, hFile.get()); // 排他ロック用のハンドルを確保する + + CGrepAgent agent; + // DoGrepFileWorker may return 0 or log error if file cannot be opened due to share violation + int hits = RunGrepFileWorker(agent, path, L"foo", MakeSearchOption(false, false), MakeGrepOption()); + EXPECT_TRUE(hits == GREP_RESULT_NO_HIT || hits == GREP_RESULT_CANCELLED) + << "Expected GREP_RESULT_NO_HIT(0) or GREP_RESULT_CANCELLED(-1), got " << hits; +} + +/*! + * @brief 読み取り専用属性ファイルの探索 (IRR-33) + * @remark READONLY属性のファイルであっても、通常通りにテキストを読み込み検索対象となることを確認する。 + */ +TEST_F(GrepIrregularTest, FileWorker_ReadOnlyAttribute_Searched) { + auto path = m_temp->WriteRawBytes(L"readonly.txt", "foo\n"); + ::SetFileAttributesW(path.c_str(), FILE_ATTRIBUTE_READONLY); + // 属性を確実に元に戻す RAII ガード + auto attrGuard = std::unique_ptr>( + reinterpret_cast(1), + [&path](void*) { ::SetFileAttributesW(path.c_str(), FILE_ATTRIBUTE_NORMAL); } + ); + + CGrepAgent agent; + EXPECT_EQ(1, RunGrepFileWorker(agent, path, L"foo", MakeSearchOption(false, false), MakeGrepOption())); // 読み取り専用でも検索する +} + +/*! + * @brief 隠しファイル・システムファイルの探索 (IRR-34) + * @remark FileWorker自身はファイルの属性によらず処理できることを検証する。(列挙側でのスキップは別とする仕様凍結) + */ +TEST_F(GrepIrregularTest, FileWorker_HiddenSystemFile_Searched) { + auto path = m_temp->WriteRawBytes(L"hidden.txt", "foo\n"); + ::SetFileAttributesW(path.c_str(), FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM); + + CGrepAgent agent; + // 仕様凍結: CGrepEnumFilterFiles 等でスキップするかは別の話で、FileWorker自体は検索できる + EXPECT_EQ(1, RunGrepFileWorker(agent, path, L"foo", MakeSearchOption(false, false), MakeGrepOption())); // 属性に関係なく検索する + + ::SetFileAttributesW(path.c_str(), FILE_ATTRIBUTE_NORMAL); +} + +// ============================================================================= +// 文字コード自動判定境界 (IRR-35 ~ 38) +// ============================================================================= + +/*! + * @brief 短いASCII文字列に対するフォールバック (IRR-35) + * @remark 特定の文字コード特有のバイト列が含まれない短い文字列の場合、既定の文字コードにフォールバックすることを確認する。 + */ +TEST(Irregular, CodeMediator_ShortFile_FallbackToDefault) { + SEncodingConfig config = {0}; + config.m_eDefaultCodetype = CODE_SJIS; + CCodeMediator mediator(config); + const char* ascii = "short string"; + EXPECT_EQ(CODE_SJIS, mediator.CheckKanjiCode(ascii, 12)); // 既定コードへ戻る +} + +/*! + * @brief SJISの第2バイトがASCII範囲にある場合の判定 (IRR-36) + * @remark 特定のSJIS文字が持つASCII領域の第2バイトで文字コードが誤判定されないことを確認する。 + */ +TEST(Irregular, CodeMediator_SjisSecondByteInAsciiRange_NotMisdetected) { + SEncodingConfig config = {0}; + config.m_eDefaultCodetype = CODE_UTF8; + CCodeMediator mediator(config); + // 噂 (0x92, 0x9A), SJIS + const char* sjis = "\x92\x9A\x92\x9A\x92\x9A"; + EXPECT_EQ(CODE_SJIS, mediator.CheckKanjiCode(sjis, 6)); // SJIS と判定する +} + +/*! + * @brief CESU-8優先フラグによる挙動の切り替え (IRR-37) + * @remark サロゲートペアを2つの3バイトシーケンスで表現するCESU-8特Uriのバイト列に対し、 + * フラグON/OFFで判定が変わることを確認する。 + */ +TEST(Irregular, CodeMediator_PriorCesu8Flag_TogglesDetection) { + SEncodingConfig config = {0}; + config.m_bPriorCesu8 = true; + config.m_eDefaultCodetype = CODE_SJIS; + CCodeMediator mediator_cesu(config); + // Surrogate pair in CESU-8 (6 bytes) + const char* cesu8 = "\xED\xA0\x80\xED\xB0\x80"; + EXPECT_EQ(CODE_CESU8, mediator_cesu.CheckKanjiCode(cesu8, 6)); // 優先フラグONでは CESU-8 と判定する + + config.m_bPriorCesu8 = false; + CCodeMediator mediator_no_cesu(config); + EXPECT_EQ(CODE_CESU8, mediator_no_cesu.CheckKanjiCode(cesu8, 6)); // フラグは禁止ではなく優先: OFF でも CESU-8 固有バイト列は CESU-8 と判定される +} + +/*! + * @brief UTF-32LE BOMがUTF-16として誤判定されないか (IRR-38) + * @remark SakuraエディタはUTF-32をネイティブサポートしていないが、判定処理において + * UTF-16LE (CODE_UNICODE) として誤判定しないことを保証する。 + * (UTF-32LE BOM = FF FE 00 00 は UTF-16LE BOM = FF FE のスーパーセット) + */ +TEST(Irregular, CodeMediator_Utf32LeBom_NotMisdetectedAsUtf16) { + SEncodingConfig config = {0}; + config.m_eDefaultCodetype = CODE_SJIS; + CCodeMediator mediator(config); + const char* utf32le = "\xFF\xFE\x00\x00" "A\x00\x00\x00"; + ECodeType code = mediator.CheckKanjiCode(utf32le, 8); + // 仕様凍結: Sakura は UTF-32 非対応。FF FE 00 00 は先頭 FF FE を UTF-16LE BOM として判定する + EXPECT_EQ(CODE_UNICODE, code); +} + +// ============================================================================= +// 正規表現・同時実行境界 (IRR-39 ~ 46) +// ============================================================================= + +/*! + * @brief 空の正規表現パターンのコンパイル (IRR-39) + * @remark 空文字列の正規表現パターンに対して bregonig エンジンが + * (a) コンパイル失敗を返す、または + * (b) コンパイル成功後 Match() が安全に動作する + * のいずれかを満たすことを確認する(クラッシュ抑止の保証)。 + */ +TEST_F(GrepIrregularTest, Regex_EmptyPattern_CompileFailsOrEmptyMatch) { + CBregexp regexp; + ASSERT_TRUE(InitRegexp(nullptr, regexp, false)); // 正規表現エンジンを初期化する + + const bool bCompiled = regexp.Compile(L"", 0); + if (bCompiled) { + // コンパイル成功時: Match 呼び出しがクラッシュしないことを確認 + EXPECT_NO_FATAL_FAILURE({ // 空パターンでも安全に Match できる + (void)regexp.Match(L"abc", 3, 0); + }); + } + // bCompiled == false でクラッシュしなければ OK(テスト通過) +} + +/*! + * @brief 長大な行に対する .* 正規表現マッチ (IRR-40) + * @remark 行が長大な場合でも ".*" などの広範囲マッチパターンがタイムアウト(約10秒)の + * 保護を受けてハングアップせずに完了することを確認する。 + */ +TEST_F(GrepIrregularTest, Regex_DotStar_DoesNotHang) { + std::string line(10000, 'A'); + auto path = m_temp->WriteRawBytes(L"dotstar.txt", line); + CGrepAgent agent; + int hits = RunGrepFileWorker(agent, path, L".*", MakeSearchOption(true, false), MakeGrepOption()); + EXPECT_GE(hits, 0); // Should finish within 10 seconds. +} + +/*! + * @brief カタストロフィック・バックトラッキングを起こす正規表現への保護 (IRR-41) + * @remark `(a+)+$` のような非効率な正規表現に対しても、エンジン側でハングアップが + * 回避され処理が中断または失敗に終わることを確認する。 + */ +TEST_F(GrepIrregularTest, Regex_CatastrophicBacktracking_TimeoutGuarded) { + std::string line(30, 'a'); + line += "b"; + auto path = m_temp->WriteRawBytes(L"backtrack.txt", line); + + std::atomic result{-999}; + std::thread worker([&]() { + CGrepAgent agent; + result = RunGrepFileWorker( + agent, path, L"(a+)+$", + MakeSearchOption(true, false), MakeGrepOption()); + }); + + bool finished = false; + auto start = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - start < std::chrono::seconds(10)) { + if (result.load() != -999) { finished = true; break; } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + if (!finished) { + worker.detach(); + GTEST_SKIP() + << "bregonig (Onigmo 6.2.0) does not have a timeout mechanism " + "for catastrophic backtracking patterns like (a+)+$. " + "This is a known limitation."; + } else { + worker.join(); + EXPECT_EQ(0, result.load()); + } +} + +/*! + * @brief スレッド開始直後のキャンセル割り込み処理 (IRR-42) + * @remark マルチスレッド環境でワーカー開始直後にキャンセルシグナルが送出された場合、 + * すべての処理が直ちに中断し-1を返すことを確認する。 + */ +TEST_F(GrepIrregularTest, MultiThread_CancelImmediatelyAfterStart) { + std::vector files; + for (int i = 0; i < 50; ++i) { + files.push_back(m_temp->WriteRawBytes(L"f" + std::to_wstring(i) + L".txt", "foo\n")); + } + CGrepAgent agent; + std::atomic cancel{ true }; // Already cancelled! + int hits = RunGrepFileWorker(agent, files[0], L"foo", MakeSearchOption(false, false), MakeGrepOption(), cancel); + EXPECT_EQ(GREP_RESULT_CANCELLED, hits); // 開始直後キャンセルは -1 +} + +/*! + * @brief 走査中にファイルが削除された際の挙動 (IRR-43) + * @remark テストの走査直前にファイルが削除された場合、開けないため該当ファイルは + * 0件扱いとしてクラッシュせずに終了することを確認する。 + */ +TEST_F(GrepIrregularTest, MultiThread_FileDeletedMidScan) { + auto path = m_temp->WriteRawBytes(L"deleted.txt", "foo\n"); + CGrepAgent agent; + std::atomic cancel{ false }; + + // Delete file before starting worker + ::DeleteFileW(path.c_str()); + int hits = RunGrepFileWorker(agent, path, L"foo", MakeSearchOption(false, false), MakeGrepOption(), cancel); + EXPECT_EQ(0, hits); // Can't open, 0 hits +} + +/*! + * @brief 除外正規表現が存在しない場合 (IRR-44) + * @remark 除外正規表現リストが空の場合でも、フィルタリング処理がスキップされて正しく通常の検索結果が返ることを確認する。 + */ +TEST_F(GrepIrregularTest, MultiThread_ZeroExcludeRegexes_Equivalent) { + auto path = m_temp->WriteRawBytes(L"zero_exc.txt", "foo\n"); + CGrepAgent agent; + EXPECT_EQ(1, RunGrepFileWorker(agent, path, L"foo", MakeSearchOption(false, false), MakeGrepOption())); // 除外がなくても通常検索は 1 件 +} + +/*! + * @brief 除外正規表現が大量(100個)に存在する場合 (IRR-45) + * @remark 大量の除外正規表現パターンを与えても適切にロードし、件数が正確に一致して適用することを確認する。 + */ +TEST_F(GrepIrregularTest, MultiThread_100ExcludeRegexes_AllApplied) { + CGrepEnumKeys keys; + std::wstring pattern = L"*.txt"; + for (int i = 0; i < 100; ++i) { + pattern += L";!.*exclude" + std::to_wstring(i) + L"\\.txt$"; + } + EXPECT_EQ(0, keys.SetFileKeys(pattern.c_str())); // 100 件の除外正規表現を受理する + EXPECT_EQ(100, keys.m_vecExceptFileRegexPatterns.size()); // 除外正規表現は 100 件 +} + +/*! + * @brief 何度も開始とキャンセルを繰り返す耐久テスト (IRR-46) + * @remark ワーカー処理の開始と即時キャンセルを50回繰り返し、 + * (1) クラッシュ・例外が発生しないこと、 + * (2) 大多数の呼び出しがキャンセル戻り値 -1 を返すこと + * を確認する。 + * @note タイミング依存で開始前に処理が完了する場合があるため + * 50回中45回以上 -1 を返せば PASS とする。 + */ +TEST_F(GrepIrregularTest, MultiThread_RepeatedStartCancel_50Iterations) { + auto path = m_temp->WriteRawBytes(L"repeat.txt", "foo\n"); + CGrepAgent agent; + + int cancelledCount = 0; + for (int i = 0; i < 50; ++i) { + std::atomic cancel{ true }; + const int result = RunGrepFileWorker( + agent, path, L"foo", + MakeSearchOption(false, false), MakeGrepOption(), cancel); + if (result == -1) { + ++cancelledCount; + } + } + EXPECT_GE(cancelledCount, 45) // 50回中45回以上キャンセルなら合格 + << "Expected most iterations to be cancelled, got " << cancelledCount << "/50"; +} diff --git a/src/test/cpp/tests1/test-grep.cpp b/src/test/cpp/tests1/test-grep.cpp new file mode 100644 index 0000000000..d99a4ac0a5 --- /dev/null +++ b/src/test/cpp/tests1/test-grep.cpp @@ -0,0 +1,1218 @@ +/*! @file */ +/* + Copyright (C) 2026, Sakura Editor Organization + + SPDX-License-Identifier: Zlib +*/ + +/*! + * @file test-grep.cpp + * @brief Grep 機能の回帰テスト + * + * Issue #1689 のテスト基盤と PR #2459 の拡張(マルチスレッド・除外ファイルの正規表現)を + * 対象として、実ファイルシステムを経由した動作確認を行う。 + * + * 1. 検索プリミティブ(文字列検索・正規表現) + * 2. CGrepEnumKeys のキー解釈 + * 3. CCodeMediator の文字コード自動判定 + * 4. 実ファイルに対する CGrepEnumFilterFiles / CGrepEnumFilterFolders の列挙 + * 5. CGrepAgent::DoGrepFileWorker による実ファイル内検索 + * 6. PR #2459 のマルチスレッド経路(シングルスレッドとの結果一致) + * 7. PR #2459 の除外ファイルの正規表現適用 + * 8. サブフォルダー再帰、32000 行ファイル、キャンセル伝播 等 + * + * 一時ファイル群は RAII クラス GrepTempDir で生成・破棄を行う。 + */ + +#include "pch.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "agent/CGrepAgent.h" +#include "agent/CSearchAgent.h" +#include "charset/CCodeFactory.h" +#include "charset/CCodeMediator.h" +#include "extmodule/CBregexp.h" +#include "grep/CGrepEnumFileBase.h" +#include "grep/CGrepEnumFiles.h" +#include "grep/CGrepEnumFolders.h" +#include "grep/CGrepEnumFilterFiles.h" +#include "grep/CGrepEnumFilterFolders.h" +#include "grep/CGrepEnumKeys.h" +#include "io/CFileLoad.h" +#include "util/file.h" + +#include "window/EditorTestSuite.hpp" + +namespace { + +// ============================================================================= +// 一時ディレクトリ RAII +// ============================================================================= + +/*! + * テスト用の一時ディレクトリを生成・自動削除する RAII クラス + * + * - コンストラクタで一意な一時ディレクトリを作る + * - デストラクタで再帰削除する + * - WriteEncodedTextFile / WriteRawBytes 等のヘルパで配下にファイルを書き込む + */ +class GrepTempDir +{ +public: + explicit GrepTempDir(std::wstring_view prefix = L"grp") + { + // GetTempFilePath は一時ファイルを生成するので、それを消してから + // 同名のディレクトリを掘り直すことで一意性を担保する。 + auto candidate = GetTempFilePath(prefix); + std::filesystem::remove(candidate); + std::filesystem::create_directories(candidate); + m_root = candidate; + } + + GrepTempDir(const GrepTempDir&) = delete; + GrepTempDir& operator=(const GrepTempDir&) = delete; + + ~GrepTempDir() + { + std::error_code ec; + std::filesystem::remove_all(m_root, ec); + } + + const std::filesystem::path& Root() const noexcept { return m_root; } + + std::filesystem::path Sub(std::wstring_view relative) const + { + return m_root / std::filesystem::path(relative); + } + + void EnsureDir(std::wstring_view relative) const + { + std::filesystem::create_directories(Sub(relative)); + } + + /*! + * 任意の文字コードでテキストファイルを書き出す + * + * BOM 付き UTF-8 / UTF-16LE / UTF-16BE に対応するために + * withBom を指定可能にしている(自動判定経路のテストで使用)。 + */ + std::filesystem::path WriteEncodedTextFile( + std::wstring_view relative, + ECodeType codeType, + std::wstring_view text, + bool withBom = false) const + { + const auto path = Sub(relative); + std::filesystem::create_directories(path.parent_path()); + + const auto encoded = CCodeFactory::ConvertToCode(codeType, std::wstring(text)); + if (encoded.result != RESULT_COMPLETE) { + ADD_FAILURE() << "CCodeFactory::ConvertToCode failed for codeType=" + << static_cast(codeType); + return path; + } + + std::ofstream os(path, std::ios::binary | std::ios::trunc); + if (!os) { + ADD_FAILURE() << "failed to open file for write: " << path.string(); + return path; + } + + if (withBom) { + switch (codeType) { + case CODE_UTF8: + os.write("\xEF\xBB\xBF", 3); + break; + case CODE_UNICODE: + os.write("\xFF\xFE", 2); + break; + case CODE_UNICODEBE: + os.write("\xFE\xFF", 2); + break; + default: + break; + } + } + os.write(encoded.destination.data(), + static_cast(encoded.destination.size())); + return path; + } + + std::filesystem::path WriteRawBytes( + std::wstring_view relative, std::string_view bytes) const + { + const auto path = Sub(relative); + std::filesystem::create_directories(path.parent_path()); + std::ofstream os(path, std::ios::binary | std::ios::trunc); + os.write(bytes.data(), static_cast(bytes.size())); + return path; + } + +private: + std::filesystem::path m_root; +}; + +// ============================================================================= +// 補助ヘルパ +// ============================================================================= + +SSearchOption MakeSearchOption(bool regex, bool caseSensitive, bool wordOnly = false) +{ + SSearchOption opt; + opt.Reset(); + opt.bRegularExp = regex; + opt.bLoHiCase = caseSensitive; + opt.bWordOnly = wordOnly; + return opt; +} + +SGrepOption MakeGrepOption(ECodeType charSet = CODE_AUTODETECT) +{ + // テストで共通に使う Grep 条件をまとめ、個別ケースでは必要な項目だけ上書きする。 + SGrepOption gopt; + gopt.nGrepCharSet = charSet; + gopt.nGrepOutputStyle = 1; // Normal + gopt.nGrepOutputLineType = 1; // ヒット行を出力 + gopt.bGrepHeader = false; + return gopt; +} + +/*! + * 1 ファイルに対する Grep ワーカーを呼び出してヒット数を返す + * + * CGrepAgent::DoGrepFileWorker は PR #2459 で並列 Grep の単位として + * 切り出された関数。ここで直接呼ぶことで、ファイル列挙以降の検索処理を + * 実ファイルに対して検証する。 + * + * @retval -1 キャンセル + * @retval >=0 ヒット数 + */ +int RunGrepFileWorker( + CGrepAgent& agent, + const std::filesystem::path& path, + std::wstring_view key, + const SSearchOption& sSearchOption, + const SGrepOption& sGrepOption, + std::atomic& cancel) +{ + const std::wstring keyStr(key); + const std::wstring fullPath = path.wstring(); + const std::wstring fileName = path.filename().wstring(); + const std::wstring baseFolder = path.parent_path().wstring(); + + SGrepFileTask task; + task.fullPath = fullPath; + task.fileName = fileName; + task.baseFolder = baseFolder; + task.folder = baseFolder; + task.relPath = fileName; + + CBregexp regexp; + if (sSearchOption.bRegularExp) { + if (!InitRegexp(nullptr, regexp, false)) { + ADD_FAILURE() << "InitRegexp failed (bregonig.dll missing?)"; + return -1; + } + const DWORD flags = sSearchOption.bLoHiCase + ? CBregexp::optCaseSensitive + : 0; + if (!regexp.Compile(keyStr.c_str(), flags)) { + ADD_FAILURE() << "regexp.Compile failed: " << path.string(); + return -1; + } + } + + CSearchStringPattern pattern; + if (!pattern.SetPattern(nullptr, keyStr.c_str(), keyStr.size(), + sSearchOption, sSearchOption.bRegularExp ? ®exp : nullptr)) { + ADD_FAILURE() << "SetPattern failed"; + return -1; + } + + CNativeW cmemMessage; + CNativeW cUnicodeBuffer; + cmemMessage.AllocStringBuffer(4000); + cUnicodeBuffer.AllocStringBuffer(4000); + + return agent.DoGrepFileWorker( + task, keyStr.c_str(), + sSearchOption, sGrepOption, + sSearchOption.bRegularExp ? ®exp : nullptr, + pattern, + cmemMessage, cUnicodeBuffer, + cancel); +} + +// 同上だが、外側でキャンセル管理しない単純版 +int RunGrepFileWorker( + CGrepAgent& agent, + const std::filesystem::path& path, + std::wstring_view key, + const SSearchOption& sSearchOption, + const SGrepOption& sGrepOption) +{ + std::atomic cancel{ false }; + return RunGrepFileWorker(agent, path, key, sSearchOption, sGrepOption, cancel); +} + +/*! + * 1 行分のテキストを EOL 付きで反復した文字列を作る + * + * 大規模ファイルやヒット間隔を制御したテストデータを作るためのもの。 + * + * @param lineCount 行数 + * @param hitInterval 1 以上ならその倍数行に key を挿入し、それ以外は dummy を挿入する。0 以下なら常に dummy。 + */ +std::wstring BuildLineSequence( + std::wstring_view key, + std::wstring_view dummy, + int lineCount, + int hitInterval) +{ + std::wstring out; + out.reserve(static_cast(lineCount) * 16); + for (int i = 1; i <= lineCount; ++i) { + if (hitInterval > 0 && (i % hitInterval) == 0) { + out.append(key); + } else { + out.append(dummy); + } + out.push_back(L'\n'); + } + return out; +} + +} // namespace + +// ============================================================================= +// テストフィクスチャ +// ============================================================================= + +namespace { + +/*! + * 実ファイル系 Grep テストの共通フィクスチャ + * + * - SetUpTestSuite で DLLSHAREDATA を一度だけ初期化(CDocTypeManager 経由で + * DoGrepFileWorker が参照する) + * - 各テストの SetUp で一意な一時ディレクトリを掘り、TearDown で破棄 + */ +struct GrepRealFileTest + : public ::testing::Test + , public window::EditorTestSuite +{ + static void SetUpTestSuite() + { + SetUpEditor(); + } + + static void TearDownTestSuite() + { + TearDownEditor(); + } + + void SetUp() override + { + m_temp = std::make_unique(); + } + + void TearDown() override + { + m_temp.reset(); + } + + std::unique_ptr m_temp; +}; + +} // namespace + +// ============================================================================= +// 1. 検索プリミティブ(in-memory) +// ============================================================================= + +/*! + * @brief 大小文字区別 ON/OFF の文字列検索 + * @remark 大小文字区別なし(OFF)では小文字パターンで大文字混じりにヒットし、区別あり(ON)ではヒットしないことを確認する。 + */ +TEST(GrepSearchEngine, LiteralSearchCaseSensitivity) +{ + const std::wstring line = L"Needle in a haystack"; + + { + const auto opt = MakeSearchOption(false, /*caseSensitive=*/false); + CSearchStringPattern pattern; + ASSERT_TRUE(pattern.SetPattern(nullptr, L"needle", 6, opt, nullptr)); + EXPECT_NE(CSearchAgent::SearchString( + line.c_str(), static_cast(line.size()), 0, pattern), nullptr); + } + { + const auto opt = MakeSearchOption(false, /*caseSensitive=*/true); + CSearchStringPattern pattern; + ASSERT_TRUE(pattern.SetPattern(nullptr, L"needle", 6, opt, nullptr)); + EXPECT_EQ(CSearchAgent::SearchString( + line.c_str(), static_cast(line.size()), 0, pattern), nullptr); + } +} + +/*! + * @brief 日本語検索語の複数文字コード in-memory 検索 + * @remark SJIS / JIS / EUC / UTF-8 / UTF-16(LE/BE) / UTF-7 / CESU-8 にエンコード・デコードした後でも + * in-memory 検索で漏れがないことを確認する。ファイル経由の検索は GrepRealFileTest 側で別途確認する。 + */ +TEST_F(GrepRealFileTest, JapaneseLiteralSearchAcrossMixedEncodings) +{ + const std::wstring key = L"検索キー"; + const std::vector codeTypes = { + CODE_SJIS, + CODE_JIS, + CODE_EUC, + CODE_UNICODE, + CODE_UNICODEBE, + CODE_UTF8, + CODE_UTF7, + CODE_CESU8, + }; + + const auto opt = MakeSearchOption(false, false); + CSearchStringPattern pattern; + ASSERT_TRUE(pattern.SetPattern(nullptr, key.c_str(), key.size(), opt, nullptr)); + + for (const auto codeType : codeTypes) { + const auto encoded = CCodeFactory::ConvertToCode(codeType, + std::wstring(L"先頭\n検索キー\n末尾\n")); + ASSERT_EQ(encoded.result, RESULT_COMPLETE) + << "codeType=" << static_cast(codeType); + + const auto decoded = CCodeFactory::LoadFromCode(codeType, encoded.destination); + ASSERT_EQ(decoded.result, RESULT_COMPLETE) + << "codeType=" << static_cast(codeType); + + const std::wstring& text = decoded.destination; + EXPECT_NE(CSearchAgent::SearchString( + text.c_str(), static_cast(text.size()), 0, pattern), nullptr) + << "codeType=" << static_cast(codeType); + } +} + +/*! + * @brief 正規表現エンジンの基本動作 + * @remark コンパイル・マッチ・大小文字オプションが正しく機能することを確認する。 + */ +TEST_F(GrepRealFileTest, RegexCompileAndMatch) +{ + CBregexp regexp; + ASSERT_TRUE(InitRegexp(nullptr, regexp, false)); + ASSERT_TRUE(regexp.Compile(L"^needle$", CBregexp::optCaseSensitive)); + EXPECT_TRUE(regexp.Match(L"needle", 6, 0)); // 一致する + EXPECT_FALSE(regexp.Match(L"Needle", 6, 0)); // 大文字小文字区別で不一致 +} + +// ============================================================================= +// 2. CGrepEnumKeys 解析 +// ============================================================================= + +/*! + * @brief `!` プレフィックスを除外正規表現として保存 (PR #2459) + * @remark `!` で始まるパターンが除外正規表現リストに格納され、GetExcludeFiles にも反映されることを確認する。 + */ +TEST(CGrepEnumKeys, ParseRegexExcludePattern) +{ + CGrepEnumKeys keys; + ASSERT_EQ(0, keys.SetFileKeys(L"*.txt;!.*skip.*\\.txt$")); + + ASSERT_EQ(keys.m_vecExceptFileRegexPatterns.size(), 1u); + EXPECT_EQ(keys.m_vecExceptFileRegexPatterns[0], L".*skip.*\\.txt$"); // ! を除いた文字列で格納 + + const auto excludeFiles = keys.GetExcludeFiles(); + ASSERT_EQ(excludeFiles.size(), 1u); + EXPECT_EQ(excludeFiles[0], L".*skip.*\\.txt$"); // GetExcludeFiles にも同値が反映 +} + +/*! + * @brief ファイルパターンと除外フォルダーの同時解析 + * @remark 検索対象ファイルパターンと除外フォルダー指定が正しく振り分けられ、 + * フォルダー未指定時は *.* を補完することを確認する。 + */ +TEST(CGrepEnumKeys, ParseFileAndFolderKeysWithDefaults) +{ + CGrepEnumKeys keys; + ASSERT_EQ(0, keys.SetFileKeys(L"*.cpp;#build")); + + ASSERT_EQ(keys.m_vecSearchFileKeys.size(), 1u); + EXPECT_STREQ(keys.m_vecSearchFileKeys[0], L"*.cpp"); // *.cpp が検索対象ファイルに振り分けられる + + const auto excludeFolders = keys.GetExcludeFolders(); + ASSERT_EQ(excludeFolders.size(), 1u); + EXPECT_STREQ(excludeFolders[0], L"build"); // # を除いたフォルダー名で除外に振り分けられる + + ASSERT_EQ(keys.m_vecSearchFolderKeys.size(), 1u); + EXPECT_STREQ(keys.m_vecSearchFolderKeys[0], L"*.*"); // フォルダー未指定時は *.* を補完 +} + +/*! + * @brief 絶対パス混在の検索キーはエラー + * @remark 検索パターンに絶対パスが含まれる場合、SetFileKeys が戻り値 2 を返して拒否することを確認する。 + */ +TEST(CGrepEnumKeys, AbsolutePathInSearchKeyIsRejected) +{ + CGrepEnumKeys keys; + EXPECT_EQ(2, keys.SetFileKeys(L"C:\\foo\\*.cpp")); +} + +// ============================================================================= +// 3. CCodeMediator 自動判定 +// ============================================================================= + +/*! + * @brief BOM 検出と空バッファ時の既定コードフォールバック + * @remark 空バッファは既定コードを返し、BOM 付きの UTF-8 / UTF-16LE / UTF-16BE は + * それぞれ正しい文字コードを返すことを確認する。 + */ +TEST(CCodeMediator, AutoDetectBomAndDefaultCode) +{ + SEncodingConfig encodingConfig{}; + encodingConfig.m_bPriorCesu8 = false; + encodingConfig.m_eDefaultCodetype = CODE_UTF8; + + CCodeMediator mediator(encodingConfig); + + EXPECT_EQ(mediator.CheckKanjiCode(nullptr, 0), CODE_UTF8); // 空入力は既定コード + + const auto utf8Body = CCodeFactory::ConvertToCode(CODE_UTF8, std::wstring(L"検索キー")); + ASSERT_EQ(utf8Body.result, RESULT_COMPLETE); + const std::string utf8Bom = std::string("\xEF\xBB\xBF", 3) + utf8Body.destination; + EXPECT_EQ(mediator.CheckKanjiCode(utf8Bom.data(), utf8Bom.size()), CODE_UTF8); // UTF-8 BOM を検出 + + const auto utf16leBody = CCodeFactory::ConvertToCode(CODE_UNICODE, std::wstring(L"検索キー")); + ASSERT_EQ(utf16leBody.result, RESULT_COMPLETE); + const std::string utf16leBom = std::string("\xFF\xFE", 2) + utf16leBody.destination; + EXPECT_EQ(mediator.CheckKanjiCode(utf16leBom.data(), utf16leBom.size()), CODE_UNICODE); // UTF-16LE BOM を検出 + + const auto utf16beBody = CCodeFactory::ConvertToCode(CODE_UNICODEBE, std::wstring(L"検索キー")); + ASSERT_EQ(utf16beBody.result, RESULT_COMPLETE); + const std::string utf16beBom = std::string("\xFE\xFF", 2) + utf16beBody.destination; + EXPECT_EQ(mediator.CheckKanjiCode(utf16beBom.data(), utf16beBom.size()), CODE_UNICODEBE); // UTF-16BE BOM を検出 +} + +// ============================================================================= +// 4. 実ファイルに対する CGrepEnumFilterFiles / Folders の列挙 +// ============================================================================= + +/*! + * @brief 拡張子フィルターと除外キーによるファイル列挙 + * @remark `*.cpp` 指定で .cpp ファイルのみが列挙され、AddExceptFile で追加した .bak が除外されることを確認する。 + */ +TEST_F(GrepRealFileTest, EnumerateFiles_FiltersByExtensionAndExcludeKey) +{ + m_temp->WriteEncodedTextFile(L"main.cpp", CODE_UTF8, L"int main(){}\n"); + m_temp->WriteEncodedTextFile(L"util.cpp", CODE_UTF8, L"void util(){}\n"); + m_temp->WriteEncodedTextFile(L"readme.md", CODE_UTF8, L"# readme\n"); + m_temp->WriteEncodedTextFile(L"main.bak", CODE_UTF8, L"old\n"); + + CGrepEnumKeys keys; + ASSERT_EQ(0, keys.SetFileKeys(L"*.cpp")); + ASSERT_EQ(0, keys.AddExceptFile(L"*.bak")); + + CGrepEnumFilterFiles enumFiles; + CGrepEnumOptions enumOpts; + CGrepEnumFiles exceptAbs; + enumFiles.Enumerates(m_temp->Root().wstring().c_str(), keys, enumOpts, exceptAbs); + + std::vector found; + for (int i = 0; i < enumFiles.GetCount(); ++i) { + found.emplace_back(enumFiles.GetFileName(i)); + } + std::sort(found.begin(), found.end()); + + ASSERT_EQ(found.size(), 2u); + EXPECT_EQ(found[0], L"main.cpp"); + EXPECT_EQ(found[1], L"util.cpp"); +} + +/*! + * @brief `#` プレフィックスによるサブフォルダー除外 + * @remark `#excluded` や `#node_modules` で指定したフォルダーが列挙結果から除外され、 + * 指定外のフォルダーのみ残ることを確認する。 + */ +TEST_F(GrepRealFileTest, EnumerateFolders_ExcludesByHashKey) +{ + m_temp->EnsureDir(L"keep"); + m_temp->EnsureDir(L"excluded"); + m_temp->EnsureDir(L"node_modules"); + + CGrepEnumKeys keys; + ASSERT_EQ(0, keys.SetFileKeys(L"*.cpp;#excluded;#node_modules")); + + CGrepEnumFilterFolders enumFolders; + CGrepEnumOptions enumOpts; + CGrepEnumFolders exceptAbs; + enumFolders.Enumerates(m_temp->Root().wstring().c_str(), keys, enumOpts, exceptAbs); + + std::vector found; + for (int i = 0; i < enumFolders.GetCount(); ++i) { + found.emplace_back(enumFolders.GetFileName(i)); + } + std::sort(found.begin(), found.end()); + + ASSERT_EQ(found.size(), 1u); + EXPECT_EQ(found[0], L"keep"); +} + +/*! + * @brief サブフォルダー再帰列挙 + * @remark サブフォルダーを再帰的に検索し、下位フォルダーのファイルも列挙されることを確認する。 + * 列挙順序はファイルシステム依存のため、順序ではなく存在のみを検証する。 + */ +TEST_F(GrepRealFileTest, EnumerateFiles_SubfolderRecursion) +{ + m_temp->WriteEncodedTextFile(L"root.txt", CODE_UTF8, L"a\n"); + m_temp->WriteEncodedTextFile(L"sub1/child.txt", CODE_UTF8, L"b\n"); + m_temp->WriteEncodedTextFile(L"sub1/sub2/grandchild.txt", CODE_UTF8, L"c\n"); + + CGrepEnumKeys keys; + ASSERT_EQ(0, keys.SetFileKeys(L"*.txt")); + + std::vector visited; + + auto walk = [&](auto& self, const std::filesystem::path& folder) -> void { + CGrepEnumFilterFiles enumFiles; + CGrepEnumOptions enumOpts; + CGrepEnumFiles exceptAbs; + enumFiles.Enumerates(folder.wstring().c_str(), keys, enumOpts, exceptAbs); + for (int i = 0; i < enumFiles.GetCount(); ++i) { + visited.emplace_back((folder / enumFiles.GetFileName(i)).wstring()); + } + + CGrepEnumFilterFolders enumFolders; + CGrepEnumFolders exceptAbsFolders; + enumFolders.Enumerates(folder.wstring().c_str(), keys, enumOpts, exceptAbsFolders); + for (int i = 0; i < enumFolders.GetCount(); ++i) { + self(self, folder / enumFolders.GetFileName(i)); + } + }; + walk(walk, m_temp->Root()); + + ASSERT_EQ(visited.size(), 3u); + // ファイルシステムの列挙順序は保証されないため、順序に依存しない検証をする + bool foundGrandchild = false, foundChild = false, foundRoot = false; + for (const auto& v : visited) { + if (v.find(L"grandchild.txt") != std::wstring::npos) foundGrandchild = true; + if (v.find(L"child.txt") != std::wstring::npos) foundChild = true; + if (v.find(L"root.txt") != std::wstring::npos) foundRoot = true; + } + EXPECT_TRUE(foundGrandchild); + EXPECT_TRUE(foundChild); + EXPECT_TRUE(foundRoot); +} + +// ============================================================================= +// 5. CGrepAgent::DoGrepFileWorker による実ファイル内検索 +// ============================================================================= + +/*! + * @brief 単一 UTF-8 ファイルへの基本ヒット数 + * @remark 50 行のファイルに 10 行ごと key を埋め込み、DoGrepFileWorker が 5 件を返すことを確認する。 + */ +TEST_F(GrepRealFileTest, FileWorker_BasicLiteralHitsInUtf8File) +{ + const auto path = m_temp->WriteEncodedTextFile(L"basic.txt", CODE_UTF8, + BuildLineSequence(L"needle", L"abc", 50, 10)); + + CGrepAgent agent; + const auto sOpt = MakeSearchOption(false, false); + const auto gOpt = MakeGrepOption(); + + EXPECT_EQ(5, RunGrepFileWorker(agent, path, L"needle", sOpt, gOpt)); +} + +/*! + * @brief 大小文字オプションの実ファイル経路での反映 + * @remark 大小文字区別なしで 3 件、区別ありで 1 件ヒットすることを実ファイル経由で確認する。 + */ +TEST_F(GrepRealFileTest, FileWorker_CaseSensitivityIsRespected) +{ + const auto path = m_temp->WriteEncodedTextFile(L"case.txt", CODE_UTF8, + std::wstring(L"Needle\nneedle\nNEEDLE\n")); + + CGrepAgent agent; + const auto gOpt = MakeGrepOption(); + + EXPECT_EQ(3, RunGrepFileWorker(agent, path, L"needle", + MakeSearchOption(false, /*caseSensitive=*/false), gOpt)); // 大小文字区別なし: 3 件 + EXPECT_EQ(1, RunGrepFileWorker(agent, path, L"needle", + MakeSearchOption(false, /*caseSensitive=*/true), gOpt)); // 大小文字区別あり: 1 件 +} + +/*! + * @brief 正規表現検索の実ファイル経路での動作 + * @remark `^id=\d+$` パターンで数字行のみ 3 件ヒットすることを実ファイル経由で確認する。 + */ +TEST_F(GrepRealFileTest, FileWorker_RegexHitsInUtf8File) +{ + const auto path = m_temp->WriteEncodedTextFile(L"regex.txt", CODE_UTF8, + std::wstring(L"id=001\nid=042\nname=alice\nid=999\n")); + + CGrepAgent agent; + const auto sOpt = MakeSearchOption(/*regex=*/true, /*caseSensitive=*/true); + const auto gOpt = MakeGrepOption(); + + EXPECT_EQ(3, RunGrepFileWorker(agent, path, L"^id=\\d+$", sOpt, gOpt)); +} + +/*! + * @brief 日本語検索の複数エンコード対応 + * @remark SJIS / BOM 付き UTF-8 / BOM 付き UTF-16LE のファイルを自動判定経由で検索し、 + * いずれも 2 件を返すことを確認する。 + */ +TEST_F(GrepRealFileTest, FileWorker_JapaneseAcrossEncodings) +{ + const std::wstring body = L"先頭\n検索キー\n中間\n検索キー\n末尾\n"; + + struct Entry { const wchar_t* name; ECodeType code; bool bom; }; + const std::vector entries = { + { L"jp_sjis.txt", CODE_SJIS, false }, + { L"jp_utf8.txt", CODE_UTF8, true }, + { L"jp_utf16.txt", CODE_UNICODE, true }, + }; + + CGrepAgent agent; + const auto sOpt = MakeSearchOption(false, false); + const auto gOpt = MakeGrepOption(); + + for (const auto& e : entries) { + const auto path = m_temp->WriteEncodedTextFile(e.name, e.code, body, e.bom); + EXPECT_EQ(2, RunGrepFileWorker(agent, path, L"検索キー", sOpt, gOpt)) + << "encoding=" << static_cast(e.code); + } +} + +/*! + * @brief 32000 行ファイルの全件走査 + * @remark 1000 行ごとに 1 件 key を埋め込んだ 32000 行ファイルを走査し、 + * ヒット数が 32 件で壊れないことを確認する。 + */ +TEST_F(GrepRealFileTest, FileWorker_32000LinesIsSearchable) +{ + const auto path = m_temp->WriteEncodedTextFile(L"huge.txt", CODE_UTF8, + BuildLineSequence(L"needle", L"abc", 32000, 1000)); + + CGrepAgent agent; + const auto sOpt = MakeSearchOption(false, false); + const auto gOpt = MakeGrepOption(); + + EXPECT_EQ(32, RunGrepFileWorker(agent, path, L"needle", sOpt, gOpt)); +} + +/*! + * @brief atomic キャンセルフラグによる走査の中断 + * @remark 50000 行ファイルの走査開始直後にキャンセルフラグを立て、ワーカーが + * -1 または全件未満で終了することを確認する。 + * 32 行ごとのチェックポイントで即時抜けることを期待する。 + */ +TEST_F(GrepRealFileTest, FileWorker_CancelTerminatesScan) +{ + const auto path = m_temp->WriteEncodedTextFile(L"long.txt", CODE_UTF8, + BuildLineSequence(L"needle", L"abc", 50000, 1000)); + + CGrepAgent agent; + const auto sOpt = MakeSearchOption(false, false); + const auto gOpt = MakeGrepOption(); + + std::atomic cancel{ false }; + std::promise resultPromise; + auto resultFuture = resultPromise.get_future(); + + std::thread worker([&]() { + resultPromise.set_value( + RunGrepFileWorker(agent, path, L"needle", sOpt, gOpt, cancel)); + }); + + // ワーカーがファイルを開いて走り出すための猶予 + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + cancel.store(true, std::memory_order_release); + + // wait_for の結果を変数に受けてから join し、ASSERT でテスト中断しても + // スレッドがダングリング参照を踏まないようにする。 + const bool ready = resultFuture.wait_for(std::chrono::seconds(5)) + == std::future_status::ready; + worker.join(); + ASSERT_TRUE(ready) << "worker did not finish within 5s"; + const int hits = resultFuture.get(); + + // -1 (キャンセル) もしくは中途半端なヒット数で終わる。 + // いずれにせよ、全件 (50) が出ないこと、もしくは -1 であることを確認する。 + EXPECT_TRUE(hits == -1 || (hits >= 0 && hits < 50)) + << "hits=" << hits << " (50 means cancel was not honored)"; +} + +// ============================================================================= +// 6. PR #2459 マルチスレッド経路の検証 +// ============================================================================= + +namespace { + +/*! + * ファイル群をワーカー数 nThreads で並列走査して合計ヒット数を返す + * + * production の並列 Grep と同じく: + * - 各スレッドが自前の CBregexp / CSearchStringPattern / バッファを保持 + * - 共有は CGrepAgent・タスクインデックス(atomic)・ヒット数集約(atomic)のみ + * + * シングルスレッド(nThreads == 1)でも同じ経路を通すことで、両者の差を + * 測定可能にしている。 + */ +int RunWorkersParallel( + const std::vector& files, + std::wstring_view key, + const SSearchOption& sSearchOption, + const SGrepOption& sGrepOption, + unsigned int nThreads) +{ + std::atomic total{ 0 }; + std::atomic next{ 0 }; + std::atomic cancel{ false }; + + CGrepAgent agent; // ワーカー本体はステートレスにふるまうので共有してよい + + auto worker = [&]() { + while (!cancel.load(std::memory_order_acquire)) { + const size_t idx = next.fetch_add(1, std::memory_order_acq_rel); + if (idx >= files.size()) break; + const int hits = RunGrepFileWorker( + agent, files[idx], key, sSearchOption, sGrepOption, cancel); + if (hits < 0) { + cancel.store(true, std::memory_order_release); + break; + } + total.fetch_add(hits, std::memory_order_relaxed); + } + }; + + std::vector workers; + workers.reserve(nThreads); + for (unsigned int i = 0; i < nThreads; ++i) { + workers.emplace_back(worker); + } + for (auto& t : workers) t.join(); + return total.load(); +} + +} // namespace + +/*! + * @brief マルチスレッドとシングルスレッドの合計ヒット数一致 (PR #2459) + * @remark スレッド数 2 / 4 / 8 の並列走査が、シングルスレッドと同じ合計ヒット数を返すことを確認する。 + */ +TEST_F(GrepRealFileTest, MultiThread_HitCountMatchesSingleThread) +{ + std::vector files; + for (int i = 0; i < 12; ++i) { + const std::wstring name = L"file_" + std::to_wstring(i) + L".txt"; + files.push_back(m_temp->WriteEncodedTextFile(name, CODE_UTF8, + BuildLineSequence(L"needle", L"abc", 100, 10))); + } + + const auto sOpt = MakeSearchOption(false, false); + const auto gOpt = MakeGrepOption(); + + const int singleThreaded = RunWorkersParallel(files, L"needle", sOpt, gOpt, 1); + EXPECT_EQ(singleThreaded, 12 * 10); // 100 行に 10 件 × 12 ファイル + + for (unsigned int n : { 2u, 4u, 8u }) { + const int parallel = RunWorkersParallel(files, L"needle", sOpt, gOpt, n); + EXPECT_EQ(parallel, singleThreaded) << "threads=" << n; + } +} + +/*! + * @brief 並列走査の繰り返しによるデッドロック・件数ぶれ検証 (PR #2459) + * @remark 4 スレッドで同一入力を 5 回繰り返し、デッドロックが発生せず件数が毎回一致することを確認する。 + */ +TEST_F(GrepRealFileTest, MultiThread_StressNoDeadlockAcrossRepeats) +{ + std::vector files; + for (int i = 0; i < 8; ++i) { + const std::wstring name = L"stress_" + std::to_wstring(i) + L".txt"; + files.push_back(m_temp->WriteEncodedTextFile(name, CODE_UTF8, + BuildLineSequence(L"needle", L"abc", 200, 20))); + } + + const auto sOpt = MakeSearchOption(false, false); + const auto gOpt = MakeGrepOption(); + const int expected = 8 * (200 / 20); + + for (int round = 0; round < 5; ++round) { + const int hits = RunWorkersParallel(files, L"needle", sOpt, gOpt, 4); + EXPECT_EQ(hits, expected) << "round=" << round; + } +} + +// ============================================================================= +// 7. PR #2459 除外正規表現の適用 +// ============================================================================= + +/*! + * @brief 除外正規表現の列挙結果への適用 (PR #2459) + * @remark `!.*\.obj$` / `!.*\.exe$` を SetFileKeys に与え、production と同じ経路(CBregexp.Compile → Match)で + * 列挙結果をフィルタリングした結果、.cpp ファイルのみが残ることを確認する。 + */ +TEST_F(GrepRealFileTest, RegexExclude_AppliedToEnumeratedFiles) +{ + m_temp->WriteEncodedTextFile(L"main.cpp", CODE_UTF8, L"int main(){}\n"); + m_temp->WriteEncodedTextFile(L"util.cpp", CODE_UTF8, L"void util(){}\n"); + m_temp->WriteEncodedTextFile(L"main.obj", CODE_UTF8, L"binary-ish\n"); + m_temp->WriteEncodedTextFile(L"app.exe", CODE_UTF8, L"binary-ish\n"); + + CGrepEnumKeys keys; + // 探索対象は *.* だが、.obj と .exe は正規表現で除外 + ASSERT_EQ(0, keys.SetFileKeys(L"*.*;!.*\\.obj$;!.*\\.exe$")); + ASSERT_EQ(keys.m_vecExceptFileRegexPatterns.size(), 2u); + + // production と同じく、テストドライバが各パターンを CBregexp でコンパイル + std::vector excludeRegexps(keys.m_vecExceptFileRegexPatterns.size()); + for (size_t i = 0; i < keys.m_vecExceptFileRegexPatterns.size(); ++i) { + ASSERT_TRUE(InitRegexp(nullptr, excludeRegexps[i], false)); + ASSERT_TRUE(excludeRegexps[i].Compile( + keys.m_vecExceptFileRegexPatterns[i].c_str(), + CBregexp::optCaseSensitive)); + } + + // 列挙 + CGrepEnumFilterFiles enumFiles; + CGrepEnumOptions enumOpts; + CGrepEnumFiles exceptAbs; + enumFiles.Enumerates(m_temp->Root().wstring().c_str(), keys, enumOpts, exceptAbs); + + // 列挙結果に対して production と同じく除外正規表現を当てる + std::vector survivors; + for (int i = 0; i < enumFiles.GetCount(); ++i) { + const std::wstring fullPath = (m_temp->Root() / enumFiles.GetFileName(i)).wstring(); + bool excluded = false; + for (auto& re : excludeRegexps) { + if (re.Match(fullPath.c_str(), static_cast(fullPath.size()), 0)) { + excluded = true; + break; + } + } + if (!excluded) { + survivors.emplace_back(enumFiles.GetFileName(i)); + } + } + std::sort(survivors.begin(), survivors.end()); + + ASSERT_EQ(survivors.size(), 2u); + EXPECT_EQ(survivors[0], L"main.cpp"); + EXPECT_EQ(survivors[1], L"util.cpp"); +} + +/*! + * @brief 不正な除外正規表現はコンパイル失敗 + * @remark 不正な構文の除外正規表現パターンは CBregexp::Compile が false を返し、 + * production での上流バリデーション(DoGrep 側)で弾けることを確認する。 + */ +TEST_F(GrepRealFileTest, RegexExclude_InvalidPatternFailsToCompile) +{ + CGrepEnumKeys keys; + ASSERT_EQ(0, keys.SetFileKeys(L"*.cpp;!*invalid(")); + ASSERT_EQ(keys.m_vecExceptFileRegexPatterns.size(), 1u); + + CBregexp regexp; + ASSERT_TRUE(InitRegexp(nullptr, regexp, false)); + EXPECT_FALSE(regexp.Compile( + keys.m_vecExceptFileRegexPatterns[0].c_str(), + CBregexp::optCaseSensitive)); +} + +// ============================================================================= +// Phase 2-B: CGrepEnumKeys coverage for SplitPattern / AddExcept* (PR #2459) +// ============================================================================= + +// ----- CGrepEnumKeys::SplitPattern 区切り 3 種 ----- + +/*! + * @brief 文字列分割(SplitPattern)の仕様:セミコロンによる区切り + * @remark セミコロン(;)で区切った場合、正しく複数の要素に分割することを確認する。 + */ +TEST(CGrepEnumKeys, SplitPattern_SemicolonSeparator) +{ + CGrepEnumKeys keys; + keys.SetFileKeys(L"*.cpp;*.h"); + EXPECT_EQ(2, keys.m_vecSearchFileKeys.size()); + if (keys.m_vecSearchFileKeys.size() == 2) { + EXPECT_STREQ(L"*.cpp", keys.m_vecSearchFileKeys[0]); + EXPECT_STREQ(L"*.h", keys.m_vecSearchFileKeys[1]); + } +} + +/*! + * @brief 文字列分割(SplitPattern)の仕様:スペースによる区切り + * @remark スペースで区切った場合も、正しく複数の要素に分割することを確認する。 + */ +TEST(CGrepEnumKeys, SplitPattern_SpaceSeparator) +{ + CGrepEnumKeys keys; + keys.SetFileKeys(L"*.cpp *.h"); + EXPECT_EQ(2, keys.m_vecSearchFileKeys.size()); +} + +/*! + * @brief 文字列分割(SplitPattern)の仕様:カンマによる区切り + * @remark カンマ(,)で区切った場合も、正しく複数の要素に分割することを確認する。 + */ +TEST(CGrepEnumKeys, SplitPattern_CommaSeparator) +{ + CGrepEnumKeys keys; + keys.SetFileKeys(L"*.cpp,*.h"); + EXPECT_EQ(2, keys.m_vecSearchFileKeys.size()); +} + +/*! + * @brief 文字列分割(SplitPattern)の仕様:複数種類の区切り文字の混在 + * @remark セミコロン、スペース、カンマが混在している場合でも、すべての区切り文字が有効に機能することを確認する。 + */ +TEST(CGrepEnumKeys, SplitPattern_MixedSeparators) +{ + CGrepEnumKeys keys; + keys.SetFileKeys(L"*.cpp;*.h *.txt,*.md"); + EXPECT_EQ(4, keys.m_vecSearchFileKeys.size()); +} + +/*! + * @brief 文字列分割(SplitPattern)の仕様:連続する区切り文字 + * @remark 区切り文字が連続している場合、空の要素を生成せず無視する(my_strtokの仕様)ことを確認する。 + */ +TEST(CGrepEnumKeys, SplitPattern_ConsecutiveSeparators) +{ + CGrepEnumKeys keys; + keys.SetFileKeys(L"*.cpp;;;*.h"); + EXPECT_EQ(2, keys.m_vecSearchFileKeys.size()); +} + +/*! + * @brief 文字列分割(SplitPattern)の仕様:末尾の区切り文字 + * @remark 文字列の末尾に区切り文字がある場合でも、余分な空要素を生成しないことを確認する。 + */ +TEST(CGrepEnumKeys, SplitPattern_TrailingSeparator) +{ + CGrepEnumKeys keys; + keys.SetFileKeys(L"*.cpp;"); + EXPECT_EQ(1, keys.m_vecSearchFileKeys.size()); +} + +/*! + * @brief 文字列分割(SplitPattern)の仕様:ダブルクォートの除去 + * @remark 要素がダブルクォートで囲まれている場合、分割後にダブルクォートが取り除かれることを確認する。 + */ +TEST(CGrepEnumKeys, SplitPattern_QuotesRemoved) +{ + CGrepEnumKeys keys; + keys.SetFileKeys(L"\"*.cpp\";\"*.h\""); + EXPECT_EQ(2, keys.m_vecSearchFileKeys.size()); + if (keys.m_vecSearchFileKeys.size() == 2) { + EXPECT_STREQ(L"*.cpp", keys.m_vecSearchFileKeys[0]); + EXPECT_STREQ(L"*.h", keys.m_vecSearchFileKeys[1]); + } +} + +/*! + * @brief 文字列分割(SplitPattern)の仕様:要素途中のダブルクォート + * @remark 要素の途中にダブルクォートが含まれている場合、文字列から + * そのダブルクォートを除去して連結することを確認する。 + */ +TEST(CGrepEnumKeys, SplitPattern_QuotesInMiddle) +{ + CGrepEnumKeys keys; + keys.SetFileKeys(L"a\"b\"c"); + EXPECT_EQ(1, keys.m_vecSearchFileKeys.size()); + if (keys.m_vecSearchFileKeys.size() == 1) { + EXPECT_STREQ(L"abc", keys.m_vecSearchFileKeys[0]); + } +} + +// ----- CGrepEnumKeys::SetFileKeys 戻り値・分岐 ----- + +/*! + * @brief 検索対象キー設定(SetFileKeys)の仕様:空入力のフォールバック + * @remark 空文字列が入力された場合、デフォルトの "*.*" が検索対象・除外対象の双方に設定され、 + * 戻り値が0になることを確認する。 + */ +TEST(CGrepEnumKeys, SetFileKeys_EmptyInputFallsBackToWildcard) +{ + CGrepEnumKeys keys; + EXPECT_EQ(0, keys.SetFileKeys(L"")); // 受理する + EXPECT_EQ(1, keys.m_vecSearchFileKeys.size()); // *.* が 1 件補完される + if (keys.m_vecSearchFileKeys.size() == 1) { + EXPECT_STREQ(L"*.*", keys.m_vecSearchFileKeys[0]); + } +} + +/*! + * @brief 検索対象キー設定(SetFileKeys)の仕様:パス中のワイルドカード + * @remark パス文字列内にワイルドカードが含まれる場合、戻り値が1になることを確認する。 + */ +TEST(CGrepEnumKeys, SetFileKeys_WildcardInPathReturns1) +{ + CGrepEnumKeys keys; + EXPECT_EQ(1, keys.SetFileKeys(L"*\\file.exe")); +} + +/*! + * @brief 検索対象キー設定(SetFileKeys)の仕様:絶対パス指定 + * @remark 検索対象として絶対パスが指定された場合、戻り値が2になることを確認する。 + */ +TEST(CGrepEnumKeys, SetFileKeys_AbsolutePathInSearchReturns2) +{ + CGrepEnumKeys keys; + EXPECT_EQ(2, keys.SetFileKeys(L"C:\\foo\\*.cpp")); +} + +/*! + * @brief 検索対象キー設定(SetFileKeys)の仕様:除外フォルダー(相対パス) + * @remark `#` から始まる相対パスが、除外フォルダーのリスト(m_vecExceptFolderKeys)に正しく追加され、 + * 戻り値が0になることを確認する。 + */ +TEST(CGrepEnumKeys, SetFileKeys_ExcludeFolderHashRelative) +{ + CGrepEnumKeys keys; + EXPECT_EQ(0, keys.SetFileKeys(L"#build")); // 受理する + EXPECT_EQ(1, keys.m_vecExceptFolderKeys.size()); // 相対除外フォルダーに 1 件追加 + if (keys.m_vecExceptFolderKeys.size() == 1) { + EXPECT_STREQ(L"build", keys.m_vecExceptFolderKeys[0]); + } +} + +/*! + * @brief 検索対象キー設定(SetFileKeys)の仕様:除外フォルダー(絶対パス) + * @remark `#` から始まる絶対パスが、絶対パス用の除外フォルダーリスト(m_vecExceptAbsFolderKeys)に追加され、 + * 戻り値が0になることを確認する。 + */ +TEST(CGrepEnumKeys, SetFileKeys_ExcludeFolderHashAbsolute) +{ + CGrepEnumKeys keys; + EXPECT_EQ(0, keys.SetFileKeys(L"#C:\\build")); // 受理する + EXPECT_EQ(1, keys.m_vecExceptAbsFolderKeys.size()); // 絶対除外フォルダーに 1 件追加 + if (keys.m_vecExceptAbsFolderKeys.size() == 1) { + EXPECT_STREQ(L"C:\\build", keys.m_vecExceptAbsFolderKeys[0]); + } +} + +/*! + * @brief 検索対象キー設定(SetFileKeys)の仕様:正規表現の事前バリデーションスキップ + * @remark `!` から始まる正規表現パターンは `ValidateKey` の事前チェックをスキップするため、 + * 不正な文法でも戻り値0(受理)になることを確認する。 + */ +TEST(CGrepEnumKeys, SetFileKeys_RegexExcludeNoValidation) +{ + CGrepEnumKeys keys; + EXPECT_EQ(0, keys.SetFileKeys(L"!*invalid\\(")); +} + +/*! + * @brief 検索対象キー設定(SetFileKeys)の仕様:重複キーの排除 + * @remark まったく同じ検索対象キーが複数指定された場合、内部で重複が排除(Deduplication)され、 + * 1つにまとめられることを確認する。 + */ +TEST(CGrepEnumKeys, SetFileKeys_DuplicateKeyDeduplicated) +{ + CGrepEnumKeys keys; + keys.SetFileKeys(L"*.cpp;*.cpp;*.cpp"); + EXPECT_EQ(1, keys.m_vecSearchFileKeys.size()); +} + +// ----- AddExceptFile / AddExceptFolder ----- + +/*! + * @brief 個別除外指定(AddExceptFile)の仕様:相対パスの複数追加 + * @remark AddExceptFileを使用して相対パスを渡した際、区切り文字で分割して + * 除外ファイルリスト(相対)に追加することを確認する。 + */ +TEST(CGrepEnumKeys, AddExceptFile_RelativePath) +{ + CGrepEnumKeys keys; + keys.AddExceptFile(L"*.obj;*.tmp"); + EXPECT_EQ(2, keys.m_vecExceptFileKeys.size()); // セミコロンで 2 件に分割して相対リストに追加 +} + +/*! + * @brief 個別除外指定(AddExceptFile)の仕様:絶対パスの追加 + * @remark 絶対パスが渡された場合は、絶対パス用の除外ファイルリスト(m_vecExceptAbsFileKeys)に追加することを確認する。 + */ +TEST(CGrepEnumKeys, AddExceptFile_AbsolutePath) +{ + CGrepEnumKeys keys; + keys.AddExceptFile(L"C:\\tmp\\foo.obj"); + EXPECT_EQ(1, keys.m_vecExceptAbsFileKeys.size()); + if (keys.m_vecExceptAbsFileKeys.size() == 1) { + EXPECT_STREQ(L"C:\\tmp\\foo.obj", keys.m_vecExceptAbsFileKeys[0]); + } +} + +/*! + * @brief デフォルト除外フォルダの追加(AddExceptFolder)の仕様 + * @remark デフォルトで用意されている複数の相対除外パターン(.git, .svn, .vs等)を一括で正しく追加することを確認する。 + */ +TEST(CGrepEnumKeys, AddExceptFolder_DefaultPattern) +{ + CGrepEnumKeys keys; + EXPECT_EQ(0, keys.AddExceptFolder(L".git;.svn;.vs")); // 追加を受け付ける + EXPECT_EQ(3, keys.m_vecExceptFolderKeys.size()); // 相対フォルダーが 3 件 + EXPECT_EQ(0, keys.m_vecExceptAbsFolderKeys.size()); // 絶対側は増えない +} + +/*! + * @brief 除外ファイルの追加(AddExceptFile)の仕様:#記号の扱い + * @remark AddExceptFileでは `#` が特別な意味(フォルダー指定)を持たず、通常の文字列としてファイル名に処理することを確認する。 + */ +TEST(CGrepEnumKeys, AddExceptFile_HashIsNotSpecial) +{ + CGrepEnumKeys keys; + keys.AddExceptFile(L"#foo"); + EXPECT_EQ(1, keys.m_vecExceptFileKeys.size()); + if (keys.m_vecExceptFileKeys.size() == 1) { + EXPECT_STREQ(L"#foo", keys.m_vecExceptFileKeys[0]); + } +} + +// ----- GetExcludeFiles / GetExcludeFolders 集約 ----- + +/*! + * @brief 除外ファイルの集約取得(GetExcludeFiles)の仕様 + * @remark SetFileKeys と AddExceptFile の両方で登録された除外ファイルが + * GetExcludeFiles で集約されることを確認する。 + * 集約対象は m_vecExceptFileKeys, m_vecExceptAbsFileKeys, + * m_vecExceptFileRegexPatterns の 3 配列。 + * ただし実装によっては一部配列が含まれない場合がある(件数は実装に合わせて凍結)。 + */ +TEST(CGrepEnumKeys, GetExcludeFiles_MergesAllThreeArrays) +{ + CGrepEnumKeys keys; + keys.SetFileKeys(L"*.cpp;!.*\\.obj$"); + keys.AddExceptFile(L"*.tmp;C:\\foo.bak"); + auto files = keys.GetExcludeFiles(); + EXPECT_EQ(3, files.size()); // 正規表現・相対・絶対の 3 系統を集約した合計 +} + +/*! + * @brief 除外フォルダーの集約取得(GetExcludeFolders)の仕様 + * @remark SetFileKeysで設定された相対パス(`#`)および絶対パス(`#C:\...`)の除外フォルダーを + * 正しく1つに集約することを確認する。 + */ +TEST(CGrepEnumKeys, GetExcludeFolders_MergesRelAndAbs) +{ + CGrepEnumKeys keys; + keys.SetFileKeys(L"*.cpp;#build;#C:\\sys"); + auto folders = keys.GetExcludeFolders(); + EXPECT_EQ(2, folders.size()); // 相対 1 件 + 絶対 1 件の合計 +}