diff --git a/build.gradle.kts b/build.gradle.kts
index a91f4df4..1b47f552 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -27,7 +27,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
// Use the same version and group for the jar and the plugin
-val currentVersion = "2.0.1"
+val currentVersion = "2.1.0"
val myGroup = "com.mituuz"
version = currentVersion
group = myGroup
@@ -40,78 +40,19 @@ intellijPlatform {
changeNotes = """
Version $currentVersion
- - Fix incorrect
grep command
- - Partially fix
findstr command
- - Re-add the backend name to the popup title
-
- Known issues
-
- findstr does not work with currently open tabs
+ - Add a fallback solution for file path handling on Windows
- - To reduce the maintenance burden, I may remove support later
- - Performance is poor enough that I thought that the command wasn't returning any results
+ - Thanks to s0ders!
-
- Version 2.0.0
- This version contains larger refactors and multiple new actions enabled by them.
- I'm updating the existing package structure to keep things nicer and not supporting the old actions to avoid possible problems in the future.
-
- Breaking changes
- Rename existing actions
-
- com.mituuz.fuzzier.FuzzyGrepCaseInsensitive to com.mituuz.fuzzier.grep.FuzzyGrepCI
- com.mituuz.fuzzier.FuzzyGrep to com.mituuz.fuzzier.grep.FuzzyGrep
- com.mituuz.fuzzier.Fuzzier to com.mituuz.fuzzier.search.Fuzzier
- com.mituuz.fuzzier.FuzzierVCS to com.mituuz.fuzzier.search.FuzzierVCS
- com.mituuz.fuzzier.FuzzyMover to com.mituuz.fuzzier.operation.FuzzyMover
-
- Update default list movement keys
-
- - From
CTRL + j and CTRL + k to CTRL + n and CTRL + p
-
-
- New actions
- Added some new grep variations
- com.mituuz.fuzzier.grep.FuzzyGrepOpenTabsCI
- com.mituuz.fuzzier.grep.FuzzyGrepOpenTabs
- com.mituuz.fuzzier.grep.FuzzyGrepCurrentBufferCI
- com.mituuz.fuzzier.grep.FuzzyGrepCurrentBuffer
-
- Example mappings
- " File search
- nmap <Leader>sf <action>(com.mituuz.fuzzier.search.Fuzzier)
- nmap <Leader>sg <action>(com.mituuz.fuzzier.search.FuzzierVCS)
-
- " Mover
- nmap <Leader>fm <action>(com.mituuz.fuzzier.operation.FuzzyMover)
-
- " Grepping
- nmap <Leader>ss <action>(com.mituuz.fuzzier.grep.FuzzyGrepCI)
- nmap <Leader>sS <action>(com.mituuz.fuzzier.grep.FuzzyGrep)
- nmap <Leader>st <action>(com.mituuz.fuzzier.grep.FuzzyGrepOpenTabsCI)
- nmap <Leader>sT <action>(com.mituuz.fuzzier.grep.FuzzyGrepOpenTabs)
- nmap <Leader>sb <action>(com.mituuz.fuzzier.grep.FuzzyGrepCurrentBufferCI)
- nmap <Leader>sB <action>(com.mituuz.fuzzier.grep.FuzzyGrepCurrentBuffer)
-
- New features
-
- - Popup now defaults to auto-sized, which scales with the current window
- - You can revert this from the settings
-
-
- Other changes
-
- - Refactor file search to use coroutines
+
- Add a built-in Fuzzier grep backend
- - Handle list size limiting during processing instead of doing them separately
+ - Replaces
grep and findstr fallback implementations
+ - Add setting to choose between Dynamic (uses
rg if available, otherwise Fuzzier) and Fuzzier backends
- - Add debouncing for file preview using
SingleAlarm
- - Refactor everything
- - Remove manual handling of the divider location (use JBSplitter instead) and unify styling
+ - Migrate from
Timer to coroutines for debouncing
-
""".trimIndent()
ideaVersion {
diff --git a/changelog.md b/changelog.md
index 1ba754e5..9e14a29e 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,14 @@
# Changelog
+## Version 2.1.0
+
+- Add a fallback solution for file path handling on Windows
+ - Thanks to [s0ders](https://github.com/s0ders)!
+- Add a built-in Fuzzier grep backend
+ - Replaces `grep` and `findstr` fallback implementations
+ - Add setting to choose between Dynamic (uses `rg` if available, otherwise Fuzzier) and Fuzzier backends
+- Migrate from `Timer` to coroutines for debouncing
+
## Version 2.0.1
- Fix incorrect `grep` command
diff --git a/src/main/kotlin/com/mituuz/fuzzier/actions/FuzzyAction.kt b/src/main/kotlin/com/mituuz/fuzzier/actions/FuzzyAction.kt
index 482da7ad..ac4d0b73 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/actions/FuzzyAction.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/actions/FuzzyAction.kt
@@ -53,18 +53,15 @@ import kotlinx.coroutines.*
import java.awt.Component
import java.awt.Font
import java.awt.event.ActionEvent
-import java.util.*
-import java.util.Timer
import java.util.concurrent.ConcurrentHashMap
import javax.swing.*
-import kotlin.concurrent.schedule
abstract class FuzzyAction : AnAction() {
lateinit var component: FuzzyComponent
lateinit var popup: JBPopup
- private lateinit var originalDownHandler: EditorActionHandler
- private lateinit var originalUpHandler: EditorActionHandler
- private var debounceTimer: TimerTask? = null
+ private var originalDownHandler: EditorActionHandler? = null
+ private var originalUpHandler: EditorActionHandler? = null
+ private var debounceJob: Job? = null
protected lateinit var projectState: FuzzierSettingsService.State
protected val globalState = service().state
protected var defaultDoc: Document? = null
@@ -138,9 +135,10 @@ abstract class FuzzyAction : AnAction() {
val document = component.searchField.document
val listener: DocumentListener = object : DocumentListener {
override fun documentChanged(event: DocumentEvent) {
- debounceTimer?.cancel()
+ debounceJob?.cancel()
val debouncePeriod = globalState.debouncePeriod
- debounceTimer = Timer().schedule(debouncePeriod.toLong()) {
+ debounceJob = actionScope?.launch {
+ delay(debouncePeriod.toLong())
updateListContents(project, component.searchField.text)
}
}
@@ -161,6 +159,9 @@ abstract class FuzzyAction : AnAction() {
fun cleanupPopup() {
resetOriginalHandlers()
+ debounceJob?.cancel()
+ debounceJob = null
+
currentUpdateListContentJob?.cancel()
currentUpdateListContentJob = null
@@ -180,8 +181,12 @@ abstract class FuzzyAction : AnAction() {
fun resetOriginalHandlers() {
val actionManager = EditorActionManager.getInstance()
- actionManager.setActionHandler(IdeActions.ACTION_EDITOR_MOVE_CARET_DOWN, originalDownHandler)
- actionManager.setActionHandler(IdeActions.ACTION_EDITOR_MOVE_CARET_UP, originalUpHandler)
+ originalDownHandler?.let {
+ actionManager.setActionHandler(IdeActions.ACTION_EDITOR_MOVE_CARET_DOWN, it)
+ }
+ originalUpHandler?.let {
+ actionManager.setActionHandler(IdeActions.ACTION_EDITOR_MOVE_CARET_UP, it)
+ }
}
class FuzzyListActionHandler(private val fuzzyAction: FuzzyAction, private val isUp: Boolean) :
diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt
index a9376ca1..593355ff 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt
@@ -85,16 +85,26 @@ class FuzzierGlobalSettingsComponent(
JBCheckBox(), "Fuzzy Grep: Preview entire file",
"""
Toggles showing the full file in the preview.
-
+
If set, preview will use full syntax highlighting.
Otherwise, preview will only use limited syntax highlighting and show a slice around the match.
-
- Disabling this option may improve performance on very large files,
+
+ Disabling this option may improve performance on very large files,
for small-to-medium files the performance impact is negligible.
""".trimIndent(),
false
)
+ val grepBackendSelector = SettingsComponent(
+ ComboBox(), "Grep backend",
+ """
+ Select which backend to use for Fuzzy Grep.
+ Dynamic: Uses ripgrep (rg) if available, otherwise falls back to Fuzzier.
+ Fuzzier: Uses the built-in Fuzzier backend.
+ """.trimIndent(),
+ false
+ )
+
val globalExclusionTextArea: JBTextArea = JBTextArea().apply {
rows = 5
lineWrap = true
@@ -328,6 +338,7 @@ class FuzzierGlobalSettingsComponent(
.addComponent(debounceTimerValue)
.addComponent(fileListLimit)
.addComponent(fuzzyGrepShowFullFile)
+ .addComponent(grepBackendSelector)
.addComponent(globalExclusionSet)
.addSeparator()
@@ -430,6 +441,25 @@ class FuzzierGlobalSettingsComponent(
popupSizingSelector.getPopupSizingComboBox().addItem(s)
}
+ grepBackendSelector.getGrepBackendComboBox().renderer = object : DefaultListCellRenderer() {
+ override fun getListCellRendererComponent(
+ list: JList<*>?,
+ value: Any?,
+ index: Int,
+ isSelected: Boolean,
+ cellHasFocus: Boolean
+ ): Component {
+ val renderer =
+ super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) as JLabel
+ val backend = value as GrepBackend
+ renderer.text = backend.text
+ return renderer
+ }
+ }
+ for (backend in GrepBackend.entries) {
+ grepBackendSelector.getGrepBackendComboBox().addItem(backend)
+ }
+
filenameTypeSelector.getFilenameTypeComboBox().renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/SettingsComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/SettingsComponent.kt
index 594e4367..b7065487 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/components/SettingsComponent.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/components/SettingsComponent.kt
@@ -104,6 +104,11 @@ class SettingsComponent {
return component as ComboBox
}
+ fun getGrepBackendComboBox(): ComboBox {
+ @Suppress("UNCHECKED_CAST")
+ return component as ComboBox
+ }
+
fun getIntSpinner(index: Int): JBIntSpinner {
return (component as JPanel).getComponent(index) as JBIntSpinner
}
diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt
index c98798bc..9103ea8f 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt
@@ -54,14 +54,13 @@ import java.util.concurrent.Future
import javax.swing.DefaultListModel
import javax.swing.JPanel
import javax.swing.table.DefaultTableModel
-import kotlin.concurrent.schedule
class TestBenchComponent : JPanel(), Disposable {
private val columnNames =
arrayOf("Filename", "Filepath", "Streak", "MultiMatch", "PartialPath", "Filename", "Total")
private val table = JBTable()
private var searchField = EditorTextField()
- private var debounceTimer: TimerTask? = null
+ private var debounceJob: Job? = null
@Volatile
var currentTask: Future<*>? = null
@@ -122,9 +121,10 @@ class TestBenchComponent : JPanel(), Disposable {
val document = searchField.document
val listener: DocumentListener = object : DocumentListener {
override fun documentChanged(event: DocumentEvent) {
- debounceTimer?.cancel()
+ debounceJob?.cancel()
val debouncePeriod = liveSettingsComponent.debounceTimerValue.getIntSpinner().value as Int
- debounceTimer = Timer().schedule(debouncePeriod.toLong()) {
+ debounceJob = actionScope.launch {
+ delay(debouncePeriod.toLong())
updateListContents(project, searchField.text)
}
}
@@ -199,14 +199,16 @@ class TestBenchComponent : JPanel(), Disposable {
}
override fun dispose() {
- debounceTimer?.cancel()
- debounceTimer = null
+ debounceJob?.cancel()
+ debounceJob = null
currentTask?.let { task ->
if (!task.isDone) task.cancel(true)
}
currentTask = null
+ actionScope.cancel()
+
ApplicationManager.getApplication().invokeLater {
try {
table.setPaintBusy(false)
diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyContainer.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyContainer.kt
index 527ab0c8..34ed42a6 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyContainer.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyContainer.kt
@@ -1,31 +1,37 @@
/*
-MIT License
-
-Copyright (c) 2025 Mitja Leino
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-*/
+ * MIT License
+ *
+ * Copyright (c) 2025 Mitja Leino
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
package com.mituuz.fuzzier.entities
+import com.intellij.openapi.vfs.VirtualFile
import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService
-abstract class FuzzyContainer(val filePath: String, val basePath: String, val filename: String) {
+abstract class FuzzyContainer(
+ val filePath: String,
+ val basePath: String,
+ val filename: String,
+ val virtualFile: VirtualFile? = null,
+) {
/**
* Get display string for the popup
*/
@@ -34,7 +40,7 @@ abstract class FuzzyContainer(val filePath: String, val basePath: String, val fi
/**
* Get the complete URI for the file
*/
- fun getFileUri() : String {
+ fun getFileUri(): String {
return "$basePath$filePath"
}
diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/GrepConfig.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/GrepConfig.kt
index 481910d5..d320632b 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/entities/GrepConfig.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/entities/GrepConfig.kt
@@ -24,13 +24,15 @@
package com.mituuz.fuzzier.entities
+import com.intellij.openapi.vfs.VirtualFile
+
enum class CaseMode {
SENSITIVE,
INSENSITIVE,
}
class GrepConfig(
- val targets: List,
+ val targets: Set?,
val caseMode: CaseMode,
val title: String = "",
val supportsSecondaryField: Boolean = true,
diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/RowContainer.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/RowContainer.kt
index 72b5871c..3ca03da9 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/entities/RowContainer.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/entities/RowContainer.kt
@@ -23,6 +23,7 @@
*/
package com.mituuz.fuzzier.entities
+import com.intellij.openapi.vfs.VirtualFile
import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService
import java.io.File
@@ -32,8 +33,9 @@ class RowContainer(
filename: String,
val rowNumber: Int,
val trimmedRow: String,
- val columnNumber: Int = 0
-) : FuzzyContainer(filePath, basePath, filename) {
+ val columnNumber: Int = 0,
+ virtualFile: VirtualFile? = null
+) : FuzzyContainer(filePath, basePath, filename, virtualFile) {
companion object {
private val FILE_SEPARATOR: String = File.separator
private val RG_PATTERN: Regex = Regex("""^.+:\d+:\d+:\s*.+$""")
diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrep.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrep.kt
index 88a7ea37..8159b247 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrep.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrep.kt
@@ -35,6 +35,8 @@ import com.intellij.openapi.editor.LogicalPosition
import com.intellij.openapi.editor.ScrollType
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.project.Project
+import com.intellij.openapi.vcs.changes.ChangeListManager
+import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.util.SingleAlarm
import com.mituuz.fuzzier.actions.FuzzyAction
@@ -46,7 +48,7 @@ import com.mituuz.fuzzier.entities.RowContainer
import com.mituuz.fuzzier.grep.backend.BackendResolver
import com.mituuz.fuzzier.grep.backend.BackendStrategy
import com.mituuz.fuzzier.intellij.files.FileOpeningUtil
-import com.mituuz.fuzzier.runner.DefaultCommandRunner
+import com.mituuz.fuzzier.runner.CommandRunner
import com.mituuz.fuzzier.ui.bindings.ActivationBindings
import com.mituuz.fuzzier.ui.popup.PopupConfig
import com.mituuz.fuzzier.ui.preview.CoroutinePreviewAlarmProvider
@@ -63,7 +65,7 @@ open class FuzzyGrep : FuzzyAction() {
val isWindows = System.getProperty("os.name").lowercase().contains("win")
private val backendResolver = BackendResolver(isWindows)
- private val commandRunner = DefaultCommandRunner()
+ private val commandRunner = CommandRunner()
private var currentLaunchJob: Job? = null
private var backend: BackendStrategy? = null
private var previewAlarm: SingleAlarm? = null
@@ -72,7 +74,7 @@ open class FuzzyGrep : FuzzyAction() {
open fun getGrepConfig(project: Project): GrepConfig {
return GrepConfig(
- targets = listOf("."),
+ targets = null,
caseMode = CaseMode.SENSITIVE,
title = "Fuzzy Grep",
)
@@ -100,10 +102,9 @@ open class FuzzyGrep : FuzzyAction() {
yield()
defaultDoc = EditorFactory.getInstance().createDocument("")
- val showSecondaryField = backend!!.supportsSecondaryField() && grepConfig.supportsSecondaryField
+ val showSecondaryField = grepConfig.supportsSecondaryField
component = FuzzyFinderComponent(
- project = project,
- showSecondaryField = showSecondaryField
+ project = project, showSecondaryField = showSecondaryField
)
previewAlarmProvider = CoroutinePreviewAlarmProvider(actionScope)
previewAlarm = previewAlarmProvider?.getPreviewAlarm(component, defaultDoc)
@@ -155,8 +156,7 @@ open class FuzzyGrep : FuzzyAction() {
try {
val results = withContext(Dispatchers.IO) {
findInFiles(
- searchString,
- project
+ searchString, project
)
}
coroutineContext.ensureActive()
@@ -172,17 +172,35 @@ open class FuzzyGrep : FuzzyAction() {
project: Project,
): ListModel {
val listModel = DefaultListModel()
- val projectBasePath = project.basePath.toString()
+ val projectBasePath = project.basePath
- if (backend != null) {
+ if (backend != null && projectBasePath != null) {
val secondaryFieldText = (component as FuzzyFinderComponent).getSecondaryText()
- val commands = backend!!.buildCommand(grepConfig, searchString, secondaryFieldText)
- commandRunner.runCommandPopulateListModel(commands, listModel, projectBasePath, backend!!)
+ backend!!.handleSearch(
+ grepConfig, searchString, secondaryFieldText, commandRunner, listModel, projectBasePath, project
+ ) { vf -> validVf(vf, secondaryFieldText, ChangeListManager.getInstance(project)) }
}
return listModel
}
+ private fun validVf(
+ virtualFile: VirtualFile, secondaryFieldText: String? = null, clm: ChangeListManager
+ ): Boolean {
+ if (virtualFile.isDirectory) return false
+ if (virtualFile.fileType.isBinary) return false
+
+ if (clm.isIgnoredFile(virtualFile)) return false
+
+ if (secondaryFieldText.isNullOrBlank()) {
+ return true
+ } else if (virtualFile.extension.equals(secondaryFieldText, ignoreCase = true)) {
+ return true
+ }
+
+ return false
+ }
+
private fun createListeners(project: Project) {
// Add a listener that updates the contents of the preview pane
component.fileList.addListSelectionListener { event ->
@@ -199,15 +217,13 @@ open class FuzzyGrep : FuzzyAction() {
private fun handleInput(project: Project) {
val selectedValue = component.fileList.selectedValue
- val virtualFile =
- VirtualFileManager.getInstance().findFileByUrl("file://${selectedValue?.getFileUri()}")
+ val virtualFile = selectedValue?.virtualFile
+ ?: VirtualFileManager.getInstance().findFileByUrl("file://${selectedValue?.getFileUri()}")
virtualFile?.let {
val fileEditorManager = FileEditorManager.getInstance(project)
FileOpeningUtil.openFile(
- fileEditorManager,
- virtualFile,
- globalState.newTab
+ fileEditorManager, virtualFile, globalState.newTab
) {
popup.cancel()
ApplicationManager.getApplication().invokeLater {
diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrepVariants.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrepVariants.kt
index f12788e2..89a28a11 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrepVariants.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrepVariants.kt
@@ -40,7 +40,7 @@ class FuzzyGrepOpenTabsCI : FuzzyGrep() {
override fun getGrepConfig(project: Project): GrepConfig {
val fileEditorManager = FileEditorManager.getInstance(project)
val openFiles: Array = fileEditorManager.openFiles
- val targets = openFiles.map { it.path }
+ val targets = openFiles.toSet()
return GrepConfig(
targets = targets,
@@ -54,7 +54,7 @@ class FuzzyGrepOpenTabs : FuzzyGrep() {
override fun getGrepConfig(project: Project): GrepConfig {
val fileEditorManager = FileEditorManager.getInstance(project)
val openFiles: Array = fileEditorManager.openFiles
- val targets = openFiles.map { it.path }
+ val targets = openFiles.toSet()
return GrepConfig(
targets = targets,
@@ -69,7 +69,7 @@ class FuzzyGrepCurrentBufferCI : FuzzyGrep() {
val editor = FileEditorManager.getInstance(project).selectedTextEditor
val virtualFile: VirtualFile? =
editor?.let { FileEditorManager.getInstance(project).selectedFiles.firstOrNull() }
- val targets = virtualFile?.path?.let { listOf(it) } ?: emptyList()
+ val targets = virtualFile?.let { setOf(it) } ?: emptySet()
return GrepConfig(
targets = targets,
@@ -85,7 +85,7 @@ class FuzzyGrepCurrentBuffer : FuzzyGrep() {
val editor = FileEditorManager.getInstance(project).selectedTextEditor
val virtualFile: VirtualFile? =
editor?.let { FileEditorManager.getInstance(project).selectedFiles.firstOrNull() }
- val targets = virtualFile?.path?.let { listOf(it) } ?: emptyList()
+ val targets = virtualFile?.let { setOf(it) } ?: emptySet()
return GrepConfig(
targets = targets,
@@ -99,7 +99,7 @@ class FuzzyGrepCurrentBuffer : FuzzyGrep() {
class FuzzyGrepCI : FuzzyGrep() {
override fun getGrepConfig(project: Project): GrepConfig {
return GrepConfig(
- targets = listOf("."),
+ targets = null,
caseMode = CaseMode.INSENSITIVE,
title = FuzzyGrepTitles.DEFAULT,
)
diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendResolver.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendResolver.kt
index f8f01570..c7a5a17d 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendResolver.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendResolver.kt
@@ -24,23 +24,21 @@
package com.mituuz.fuzzier.grep.backend
+import com.intellij.openapi.components.service
import com.mituuz.fuzzier.runner.CommandRunner
+import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService
+import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService.GrepBackend
class BackendResolver(val isWindows: Boolean) {
suspend fun resolveBackend(commandRunner: CommandRunner, projectBasePath: String): Result {
- return when {
- isInstalled(commandRunner, "rg", projectBasePath) -> Result.success(BackendStrategy.Ripgrep)
- isWindows && isInstalled(
- commandRunner,
- "findstr",
- projectBasePath
- ) -> Result.success(BackendStrategy.Findstr)
-
- !isWindows && isInstalled(commandRunner, "grep", projectBasePath) -> Result.success(
- BackendStrategy.Grep
- )
-
- else -> Result.failure(Exception("No suitable grep command found"))
+ val grepBackendSetting = service().state.grepBackend
+
+ return when (grepBackendSetting) {
+ GrepBackend.FUZZIER -> Result.success(FuzzierGrep)
+ GrepBackend.DYNAMIC -> when {
+ isInstalled(commandRunner, "rg", projectBasePath) -> Result.success(Ripgrep)
+ else -> Result.success(FuzzierGrep)
+ }
}
}
diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendStrategy.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendStrategy.kt
index 3cf53d48..95b55d01 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendStrategy.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendStrategy.kt
@@ -24,116 +24,24 @@
package com.mituuz.fuzzier.grep.backend
-import com.mituuz.fuzzier.entities.CaseMode
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.VirtualFile
+import com.mituuz.fuzzier.entities.FuzzyContainer
import com.mituuz.fuzzier.entities.GrepConfig
-import com.mituuz.fuzzier.entities.RowContainer
+import com.mituuz.fuzzier.runner.CommandRunner
+import javax.swing.DefaultListModel
sealed interface BackendStrategy {
val name: String
- fun buildCommand(grepConfig: GrepConfig, searchString: String, secondarySearchString: String?): List
- fun parseOutputLine(line: String, projectBasePath: String): RowContainer? {
- val line = line.replace(projectBasePath, ".")
- return RowContainer.rowContainerFromString(line, projectBasePath)
- }
- fun supportsSecondaryField(): Boolean = false
-
- object Ripgrep : BackendStrategy {
- override val name = "ripgrep"
-
- override fun buildCommand(
- grepConfig: GrepConfig,
- searchString: String,
- secondarySearchString: String?
- ): List {
- val commands = mutableListOf("rg")
-
- if (grepConfig.caseMode == CaseMode.INSENSITIVE) {
- commands.add("--smart-case")
- commands.add("-F")
- }
-
- commands.addAll(
- mutableListOf(
- "--no-heading",
- "--color=never",
- "-n",
- "--with-filename",
- "--column"
- )
- )
- secondarySearchString?.removePrefix(".").takeIf { it?.isNotEmpty() == true }?.let { ext ->
- val glob = "*.${ext}"
- commands.addAll(listOf("-g", glob))
- }
- commands.add(searchString)
- commands.addAll(grepConfig.targets)
- return commands
- }
-
- override fun parseOutputLine(line: String, projectBasePath: String): RowContainer? {
- val line = line.replace(projectBasePath, ".")
- return RowContainer.rgRowContainerFromString(line, projectBasePath)
- }
-
- override fun supportsSecondaryField(): Boolean {
- return true
- }
- }
-
- object Findstr : BackendStrategy {
- override val name = "findstr"
-
- override fun buildCommand(
- grepConfig: GrepConfig,
- searchString: String,
- secondarySearchString: String?
- ): List {
- val commands = mutableListOf("findstr")
-
- if (grepConfig.caseMode == CaseMode.INSENSITIVE) {
- commands.add("/I")
- }
-
- commands.addAll(
- mutableListOf(
- "/p",
- "/s",
- "/n",
- "/C:$searchString"
- )
- )
- commands.addAll(grepConfig.targets.map { if (it == ".") "*" else it })
- return commands
- }
- }
-
- object Grep : BackendStrategy {
- override val name = "grep"
-
- override fun buildCommand(
- grepConfig: GrepConfig,
- searchString: String,
- secondarySearchString: String?
- ): List {
- val commands = mutableListOf("grep")
-
- if (grepConfig.caseMode == CaseMode.INSENSITIVE) {
- commands.add("-i")
- }
-
- commands.addAll(
- mutableListOf(
- "--color=none",
- "-r",
- "-H",
- "-n",
- searchString
- )
- )
- commands.addAll(grepConfig.targets)
- return commands
- }
- }
+ suspend fun handleSearch(
+ grepConfig: GrepConfig,
+ searchString: String,
+ secondarySearchString: String?,
+ commandRunner: CommandRunner,
+ listModel: DefaultListModel,
+ projectBasePath: String,
+ project: Project,
+ fileFilter: (VirtualFile) -> Boolean = { true }
+ )
}
-
diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/FuzzierGrep.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/FuzzierGrep.kt
new file mode 100644
index 00000000..332d3fe6
--- /dev/null
+++ b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/FuzzierGrep.kt
@@ -0,0 +1,194 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2025 Mitja Leino
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package com.mituuz.fuzzier.grep.backend
+
+import com.intellij.openapi.application.ReadAction
+import com.intellij.openapi.components.service
+import com.intellij.openapi.module.ModuleManager
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.project.rootManager
+import com.intellij.openapi.roots.FileIndex
+import com.intellij.openapi.roots.ProjectFileIndex
+import com.intellij.openapi.vfs.VfsUtil
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.psi.search.GlobalSearchScope
+import com.intellij.psi.search.PsiSearchHelper
+import com.intellij.openapi.components.service
+import com.intellij.util.Processor
+import com.mituuz.fuzzier.entities.CaseMode
+import com.mituuz.fuzzier.entities.FuzzyContainer
+import com.mituuz.fuzzier.entities.GrepConfig
+import com.mituuz.fuzzier.entities.RowContainer
+import com.mituuz.fuzzier.runner.CommandRunner
+import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService
+import com.mituuz.fuzzier.settings.FuzzierSettingsService
+import com.mituuz.fuzzier.util.FuzzierUtil
+import kotlinx.coroutines.*
+import javax.swing.DefaultListModel
+
+object FuzzierGrep : BackendStrategy {
+ override val name = "fuzzier"
+ private val fuzzierUtil = FuzzierUtil()
+ private val searchMatcher = SearchMatcher()
+
+ override suspend fun handleSearch(
+ grepConfig: GrepConfig,
+ searchString: String,
+ secondarySearchString: String?,
+ commandRunner: CommandRunner,
+ listModel: DefaultListModel,
+ projectBasePath: String,
+ project: Project,
+ fileFilter: (VirtualFile) -> Boolean
+ ) {
+ val files = grepConfig.targets ?: collectFiles(searchString, fileFilter, project, grepConfig)
+
+ val maxResults = service().state.fileListLimit
+ val batcher = ResultBatcher()
+
+ for (file in files) {
+ currentCoroutineContext().ensureActive()
+
+ val matches = withContext(Dispatchers.IO) {
+ try {
+ val content = VfsUtil.loadText(file)
+ val lines = content.lines()
+ val fileMatches = mutableListOf()
+
+ for ((index, line) in lines.withIndex()) {
+ currentCoroutineContext().ensureActive()
+
+ val found = searchMatcher.matchesLine(line, searchString, grepConfig.caseMode)
+
+ if (found) {
+ val (filePath, basePath) = fuzzierUtil.extractModulePath(file.path, project)
+ fileMatches.add(
+ RowContainer(
+ filePath,
+ basePath,
+ file.name,
+ index,
+ line.trim(),
+ virtualFile = file
+ )
+ )
+ }
+ }
+ fileMatches
+ } catch (_: Exception) {
+ // Skip files that cannot be read (permissions, encoding issues, etc.)
+ emptyList()
+ }
+ }
+
+ for (match in matches) {
+ currentCoroutineContext().ensureActive()
+
+ val batch = batcher.add(match)
+ if (batch != null) {
+ withContext(Dispatchers.Main) {
+ listModel.addAll(batch)
+ }
+ }
+
+ if (batcher.getCount() >= maxResults) break
+ }
+
+ if (batcher.getCount() >= maxResults) break
+ }
+
+ if (batcher.hasRemaining()) {
+ withContext(Dispatchers.Main) {
+ listModel.addAll(batcher.getRemaining())
+ }
+ }
+ }
+
+ private suspend fun collectFiles(
+ searchString: String,
+ fileFilter: (VirtualFile) -> Boolean,
+ project: Project,
+ grepConfig: GrepConfig
+ ): List {
+ val files = mutableListOf()
+ val firstCompleteWord = searchMatcher.extractFirstCompleteWord(searchString)
+
+ if (firstCompleteWord != null) {
+ ReadAction.run {
+ val helper = PsiSearchHelper.getInstance(project)
+ helper.processAllFilesWithWord(
+ firstCompleteWord,
+ GlobalSearchScope.projectScope(project),
+ Processor { psiFile ->
+ psiFile.virtualFile?.let { vf ->
+ if (fileFilter(vf)) {
+ files.add(vf)
+ }
+ }
+ true
+ },
+ grepConfig.caseMode == CaseMode.SENSITIVE
+ )
+ }
+ } else {
+ val ctx = currentCoroutineContext()
+ val job = ctx.job
+ val projectState = project.service().state
+
+ val indexTargets = if (projectState.isProject) {
+ listOf(ProjectFileIndex.getInstance(project) to project.name)
+ } else {
+ val moduleManager = ModuleManager.getInstance(project)
+ moduleManager.modules.map { it.rootManager.fileIndex to it.name }
+ }
+
+ return collectFiles(
+ targets = indexTargets,
+ shouldContinue = { job.isActive },
+ fileFilter = fileFilter
+ )
+ }
+
+ return files
+ }
+
+ private fun collectFiles(
+ targets: List>,
+ shouldContinue: () -> Boolean,
+ fileFilter: (VirtualFile) -> Boolean
+ ): List = buildList {
+ for ((fileIndex, _) in targets) {
+ fileIndex.iterateContent { vf ->
+ if (!shouldContinue()) return@iterateContent false
+
+ if (fileFilter(vf)) {
+ add(vf)
+ }
+
+ true
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/ResultBatcher.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/ResultBatcher.kt
new file mode 100644
index 00000000..7f749edc
--- /dev/null
+++ b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/ResultBatcher.kt
@@ -0,0 +1,55 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2025 Mitja Leino
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package com.mituuz.fuzzier.grep.backend
+
+class ResultBatcher(
+ private val batchSize: Int = 20
+) {
+ private val currentBatch = mutableListOf()
+ private var count = 0
+
+ fun add(item: T): List? {
+ currentBatch.add(item)
+ count++
+
+ if (currentBatch.size >= batchSize) {
+ val batch = currentBatch.toList()
+ currentBatch.clear()
+ return batch
+ }
+
+ return null
+ }
+
+ fun getRemaining(): List {
+ val batch = currentBatch.toList()
+ currentBatch.clear()
+ return batch
+ }
+
+ fun getCount(): Int = count
+
+ fun hasRemaining(): Boolean = currentBatch.isNotEmpty()
+}
diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/Ripgrep.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/Ripgrep.kt
new file mode 100644
index 00000000..7061937e
--- /dev/null
+++ b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/Ripgrep.kt
@@ -0,0 +1,90 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2025 Mitja Leino
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package com.mituuz.fuzzier.grep.backend
+
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.VirtualFile
+import com.mituuz.fuzzier.entities.CaseMode
+import com.mituuz.fuzzier.entities.FuzzyContainer
+import com.mituuz.fuzzier.entities.GrepConfig
+import com.mituuz.fuzzier.entities.RowContainer
+import com.mituuz.fuzzier.runner.CommandRunner
+import javax.swing.DefaultListModel
+
+object Ripgrep : BackendStrategy {
+ override val name = "ripgrep"
+
+ internal fun buildCommand(
+ grepConfig: GrepConfig,
+ searchString: String,
+ secondarySearchString: String?
+ ): List {
+ val commands = mutableListOf("rg")
+
+ if (grepConfig.caseMode == CaseMode.INSENSITIVE) {
+ commands.add("--smart-case")
+ commands.add("-F")
+ }
+
+ commands.addAll(
+ mutableListOf(
+ "--no-heading",
+ "--color=never",
+ "-n",
+ "--with-filename",
+ "--column"
+ )
+ )
+ secondarySearchString?.removePrefix(".").takeIf { it?.isNotEmpty() == true }?.let { ext ->
+ val glob = "*.${ext}"
+ commands.addAll(listOf("-g", glob))
+ }
+ commands.add(searchString)
+
+ // Convert VirtualFiles to paths, or use "." if targets is null
+ val targetPaths = grepConfig.targets?.map { it.path } ?: listOf(".")
+ commands.addAll(targetPaths)
+ return commands
+ }
+
+ private fun parseOutputLine(line: String, projectBasePath: String): RowContainer? {
+ val line = line.replace(projectBasePath, ".")
+ return RowContainer.rgRowContainerFromString(line, projectBasePath)
+ }
+
+ override suspend fun handleSearch(
+ grepConfig: GrepConfig,
+ searchString: String,
+ secondarySearchString: String?,
+ commandRunner: CommandRunner,
+ listModel: DefaultListModel,
+ projectBasePath: String,
+ project: Project,
+ fileFilter: (VirtualFile) -> Boolean
+ ) {
+ val commands = buildCommand(grepConfig, searchString, secondarySearchString)
+ commandRunner.runCommandPopulateListModel(commands, listModel, projectBasePath, this::parseOutputLine)
+ }
+}
diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/SearchMatcher.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/SearchMatcher.kt
new file mode 100644
index 00000000..62df0b93
--- /dev/null
+++ b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/SearchMatcher.kt
@@ -0,0 +1,52 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2025 Mitja Leino
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package com.mituuz.fuzzier.grep.backend
+
+import com.mituuz.fuzzier.entities.CaseMode
+
+class SearchMatcher {
+ fun matchesLine(line: String, searchString: String, caseMode: CaseMode): Boolean {
+ return if (caseMode == CaseMode.INSENSITIVE) {
+ line.contains(searchString, ignoreCase = true)
+ } else {
+ line.contains(searchString)
+ }
+ }
+
+ fun parseSearchWords(searchString: String): List {
+ return searchString.trim().split(" ")
+ }
+
+ fun extractFirstCompleteWord(searchString: String): String? {
+ val trimmedSearch = searchString.trim()
+ val words = parseSearchWords(trimmedSearch)
+
+ if (words.size > 1 && words[1].isNotEmpty() && trimmedSearch.count { it == ' ' } >= 2) {
+ return words[1]
+ }
+
+ return null
+ }
+}
diff --git a/src/main/kotlin/com/mituuz/fuzzier/runner/CommandRunner.kt b/src/main/kotlin/com/mituuz/fuzzier/runner/CommandRunner.kt
index 40523c14..1f05e5b8 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/runner/CommandRunner.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/runner/CommandRunner.kt
@@ -24,20 +24,104 @@
package com.mituuz.fuzzier.runner
+import com.intellij.execution.configurations.GeneralCommandLine
+import com.intellij.execution.process.OSProcessHandler
+import com.intellij.execution.process.ProcessEvent
+import com.intellij.execution.process.ProcessListener
+import com.intellij.openapi.util.Key
import com.mituuz.fuzzier.entities.FuzzyContainer
-import com.mituuz.fuzzier.grep.backend.BackendStrategy
+import com.mituuz.fuzzier.entities.RowContainer
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import javax.swing.DefaultListModel
-interface CommandRunner {
+class CommandRunner {
+ companion object {
+ const val MAX_OUTPUT_SIZE = 10000
+ const val MAX_NUMBER_OR_RESULTS = 1000
+ }
+
suspend fun runCommandForOutput(
commands: List,
projectBasePath: String
- ): String?
+ ): String? {
+ return try {
+ val commandLine = GeneralCommandLine(commands)
+ .withWorkDirectory(projectBasePath)
+ .withRedirectErrorStream(true)
+ val output = StringBuilder()
+ val processHandler = OSProcessHandler(commandLine)
+
+ processHandler.addProcessListener(object : ProcessListener {
+ override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) {
+ if (output.length < MAX_OUTPUT_SIZE) {
+ output.appendLine(event.text.replace("\n", ""))
+ }
+ }
+ })
+ try {
+ withContext(Dispatchers.IO) {
+ processHandler.startNotify()
+ processHandler.waitFor(2000)
+ }
+ } finally {
+ if (!processHandler.isProcessTerminated) {
+ processHandler.destroyProcess()
+ }
+ }
+ output.toString()
+ } catch (_: InterruptedException) {
+ throw InterruptedException()
+ }
+ }
+
+ /**
+ * Run the command and stream a limited number of results to the list model
+ */
suspend fun runCommandPopulateListModel(
commands: List,
listModel: DefaultListModel,
projectBasePath: String,
- backend: BackendStrategy
- )
+ parseOutputLine: (String, String) -> RowContainer?
+ ) {
+ try {
+ val commandLine = GeneralCommandLine(commands)
+ .withWorkDirectory(projectBasePath)
+ .withRedirectErrorStream(true)
+
+ val processHandler = OSProcessHandler(commandLine)
+ var count = 0
+
+ processHandler.addProcessListener(object : ProcessListener {
+ override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) {
+ if (count >= MAX_NUMBER_OR_RESULTS) return
+
+ event.text.lines().forEach { line ->
+ if (count >= MAX_NUMBER_OR_RESULTS) return@forEach
+ if (line.isNotBlank()) {
+ val rowContainer = parseOutputLine(line, projectBasePath)
+ if (rowContainer != null) {
+ listModel.addElement(rowContainer)
+ count++
+ }
+ }
+ }
+ }
+ })
+
+ try {
+ withContext(Dispatchers.IO) {
+ processHandler.startNotify()
+ processHandler.waitFor(2000)
+ }
+ } finally {
+ if (!processHandler.isProcessTerminated) {
+ processHandler.destroyProcess()
+ }
+ }
+ } catch (_: InterruptedException) {
+ throw InterruptedException()
+ }
+ }
}
\ No newline at end of file
diff --git a/src/main/kotlin/com/mituuz/fuzzier/runner/DefaultCommandRunner.kt b/src/main/kotlin/com/mituuz/fuzzier/runner/DefaultCommandRunner.kt
deleted file mode 100644
index 283c2d91..00000000
--- a/src/main/kotlin/com/mituuz/fuzzier/runner/DefaultCommandRunner.kt
+++ /dev/null
@@ -1,115 +0,0 @@
-/*
- * MIT License
- *
- * Copyright (c) 2025 Mitja Leino
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- * SOFTWARE.
- */
-
-package com.mituuz.fuzzier.runner
-
-import com.intellij.execution.configurations.GeneralCommandLine
-import com.intellij.execution.process.OSProcessHandler
-import com.intellij.execution.process.ProcessEvent
-import com.intellij.execution.process.ProcessListener
-import com.intellij.openapi.util.Key
-import com.mituuz.fuzzier.entities.FuzzyContainer
-import com.mituuz.fuzzier.grep.backend.BackendStrategy
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import javax.swing.DefaultListModel
-
-class DefaultCommandRunner : CommandRunner {
- companion object {
- const val MAX_OUTPUT_SIZE = 10000
- const val MAX_NUMBER_OR_RESULTS = 1000
- }
-
- override suspend fun runCommandForOutput(
- commands: List,
- projectBasePath: String
- ): String? {
- return try {
- val commandLine = GeneralCommandLine(commands)
- .withWorkDirectory(projectBasePath)
- .withRedirectErrorStream(true)
- val output = StringBuilder()
- val processHandler = OSProcessHandler(commandLine)
-
- processHandler.addProcessListener(object : ProcessListener {
- override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) {
- if (output.length < MAX_OUTPUT_SIZE) {
- output.appendLine(event.text.replace("\n", ""))
- }
- }
- })
-
- withContext(Dispatchers.IO) {
- processHandler.startNotify()
- processHandler.waitFor(2000)
- }
- output.toString()
- } catch (_: InterruptedException) {
- throw InterruptedException()
- }
- }
-
- /**
- * Run the command and stream a limited number of results to the list model
- */
- override suspend fun runCommandPopulateListModel(
- commands: List,
- listModel: DefaultListModel,
- projectBasePath: String,
- backend: BackendStrategy
- ) {
- try {
- val commandLine = GeneralCommandLine(commands)
- .withWorkDirectory(projectBasePath)
- .withRedirectErrorStream(true)
-
- val processHandler = OSProcessHandler(commandLine)
- var count = 0
-
- processHandler.addProcessListener(object : ProcessListener {
- override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) {
- if (count >= MAX_NUMBER_OR_RESULTS) return
-
- event.text.lines().forEach { line ->
- if (count >= MAX_NUMBER_OR_RESULTS) return@forEach
- if (line.isNotBlank()) {
- val rowContainer = backend.parseOutputLine(line, projectBasePath)
- if (rowContainer != null) {
- listModel.addElement(rowContainer)
- count++
- }
- }
- }
- }
- })
-
- withContext(Dispatchers.IO) {
- processHandler.startNotify()
- processHandler.waitFor(2000)
- }
- } catch (_: InterruptedException) {
- throw InterruptedException()
- }
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt
index bcdceec0..25b42249 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt
@@ -68,6 +68,7 @@ class FuzzierGlobalSettingsConfigurable : Configurable {
component.previewFontSize.getIntSpinner().value = state.previewFontSize
component.fileListSpacing.getIntSpinner().value = state.fileListSpacing
component.fuzzyGrepShowFullFile.getCheckBox().isSelected = state.fuzzyGrepShowFullFile
+ component.grepBackendSelector.getGrepBackendComboBox().selectedIndex = state.grepBackend.ordinal
// Hide dimension settings when Auto size is selected
updateDimensionVisibility(state.popupSizing)
@@ -129,6 +130,7 @@ class FuzzierGlobalSettingsConfigurable : Configurable {
|| state.matchWeightStreakModifier != component.matchWeightStreakModifier.getIntSpinner().value
|| state.matchWeightFilename != component.matchWeightFilename.getIntSpinner().value
|| state.globalExclusionSet != newGlobalSet
+ || state.grepBackend != component.grepBackendSelector.getGrepBackendComboBox().selectedItem
}
override fun apply() {
@@ -181,6 +183,9 @@ class FuzzierGlobalSettingsConfigurable : Configurable {
.filter { it.isNotEmpty() }
.toSet()
state.globalExclusionSet = newGlobalSet
+
+ state.grepBackend =
+ FuzzierGlobalSettingsService.GrepBackend.entries.toTypedArray()[component.grepBackendSelector.getGrepBackendComboBox().selectedIndex]
}
override fun disposeUIResources() {
diff --git a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt
index c8d08862..e8fac6d5 100644
--- a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt
+++ b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt
@@ -70,6 +70,8 @@ class FuzzierGlobalSettingsService : PersistentStateComponent(batchSize = 3)
+ assertNull(batcher.add("item1"))
+ assertNull(batcher.add("item2"))
+ }
+
+ @Test
+ fun `add should return batch when batch size reached`() {
+ val batcher = ResultBatcher(batchSize = 3)
+ batcher.add("item1")
+ batcher.add("item2")
+ val result = batcher.add("item3")
+
+ assertEquals(listOf("item1", "item2", "item3"), result)
+ }
+
+ @Test
+ fun `add should clear batch after returning`() {
+ val batcher = ResultBatcher(batchSize = 2)
+ batcher.add("item1")
+ val result = batcher.add("item2")
+
+ assertNotNull(result)
+ assertFalse(batcher.hasRemaining())
+ }
+
+ @Test
+ fun `getRemaining should return items that have not been batched`() {
+ val batcher = ResultBatcher(batchSize = 3)
+ batcher.add("item1")
+ batcher.add("item2")
+
+ val remaining = batcher.getRemaining()
+ assertEquals(listOf("item1", "item2"), remaining)
+ }
+
+ @Test
+ fun `getRemaining should clear internal batch`() {
+ val batcher = ResultBatcher(batchSize = 3)
+ batcher.add("item1")
+ batcher.getRemaining()
+
+ assertFalse(batcher.hasRemaining())
+ }
+
+ @Test
+ fun `getRemaining should return empty list when no items`() {
+ val batcher = ResultBatcher(batchSize = 3)
+ assertTrue(batcher.getRemaining().isEmpty())
+ }
+
+ @Test
+ fun `getCount should track total items added`() {
+ val batcher = ResultBatcher(batchSize = 2)
+ batcher.add("item1")
+ assertEquals(1, batcher.getCount())
+
+ batcher.add("item2")
+ assertEquals(2, batcher.getCount())
+
+ batcher.add("item3")
+ assertEquals(3, batcher.getCount())
+ }
+
+ @Test
+ fun `hasRemaining should return true when items in batch`() {
+ val batcher = ResultBatcher(batchSize = 3)
+ batcher.add("item1")
+ assertTrue(batcher.hasRemaining())
+ }
+
+ @Test
+ fun `hasRemaining should return false when batch is empty`() {
+ val batcher = ResultBatcher(batchSize = 3)
+ assertFalse(batcher.hasRemaining())
+ }
+
+ @Test
+ fun `hasRemaining should return false after batch is returned`() {
+ val batcher = ResultBatcher(batchSize = 2)
+ batcher.add("item1")
+ batcher.add("item2")
+ assertFalse(batcher.hasRemaining())
+ }
+
+ @Test
+ fun `should handle default batch size of 20`() {
+ val batcher = ResultBatcher()
+
+ for (i in 1..19) {
+ assertNull(batcher.add("item$i"))
+ }
+
+ val result = batcher.add("item20")
+ assertNotNull(result)
+ assertEquals(20, result?.size)
+ }
+
+ @Test
+ fun `should work with different types`() {
+ val batcher = ResultBatcher(batchSize = 2)
+ batcher.add(1)
+ val result = batcher.add(2)
+
+ assertEquals(listOf(1, 2), result)
+ }
+
+ @Test
+ fun `multiple batches should work correctly`() {
+ val batcher = ResultBatcher(batchSize = 2)
+
+ val batch1 = batcher.add("item1")
+ assertNull(batch1)
+
+ val batch2 = batcher.add("item2")
+ assertEquals(listOf("item1", "item2"), batch2)
+
+ val batch3 = batcher.add("item3")
+ assertNull(batch3)
+
+ val batch4 = batcher.add("item4")
+ assertEquals(listOf("item3", "item4"), batch4)
+
+ assertEquals(4, batcher.getCount())
+ }
+}
diff --git a/src/test/kotlin/com/mituuz/fuzzier/grep/backend/RipgrepTest.kt b/src/test/kotlin/com/mituuz/fuzzier/grep/backend/RipgrepTest.kt
new file mode 100644
index 00000000..bcda71a2
--- /dev/null
+++ b/src/test/kotlin/com/mituuz/fuzzier/grep/backend/RipgrepTest.kt
@@ -0,0 +1,140 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2025 Mitja Leino
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package com.mituuz.fuzzier.grep.backend
+
+import com.mituuz.fuzzier.entities.CaseMode
+import com.mituuz.fuzzier.entities.GrepConfig
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class RipgrepTest {
+ @Test
+ fun `buildCommand should include basic ripgrep flags`() {
+ val config = GrepConfig(
+ targets = null,
+ caseMode = CaseMode.SENSITIVE,
+ )
+
+ val result = Ripgrep.buildCommand(config, "test", null)
+
+ assertEquals(
+ listOf(
+ "rg",
+ "--no-heading",
+ "--color=never",
+ "-n",
+ "--with-filename",
+ "--column",
+ "test",
+ "."
+ ),
+ result
+ )
+ }
+
+ @Test
+ fun `buildCommand should include file extension glob flag without dot`() {
+ val config = GrepConfig(
+ targets = null,
+ caseMode = CaseMode.SENSITIVE,
+ )
+
+ val result = Ripgrep.buildCommand(config, "test", "java")
+
+ assertEquals(
+ listOf(
+ "rg",
+ "--no-heading",
+ "--color=never",
+ "-n",
+ "--with-filename",
+ "--column",
+ "-g",
+ "*.java",
+ "test",
+ "."
+ ),
+ result
+ )
+ }
+
+ @Test
+ fun `buildCommand should include file extension glob flag with dot`() {
+ val config = GrepConfig(
+ targets = null,
+ caseMode = CaseMode.SENSITIVE,
+ )
+
+ val result = Ripgrep.buildCommand(config, "test", ".java")
+
+ assertEquals(
+ listOf(
+ "rg",
+ "--no-heading",
+ "--color=never",
+ "-n",
+ "--with-filename",
+ "--column",
+ "-g",
+ "*.java",
+ "test",
+ "."
+ ),
+ result
+ )
+ }
+
+ @Test
+ fun `buildCommand should add smart-case and -F for insensitive mode`() {
+ val config = GrepConfig(
+ targets = null,
+ caseMode = CaseMode.INSENSITIVE,
+ )
+
+ val result = Ripgrep.buildCommand(config, "test", null)
+
+ assertTrue(result.contains("--smart-case"))
+ assertTrue(result.contains("-F"))
+ }
+
+ @Test
+ fun `buildCommand should not add smart-case for sensitive mode`() {
+ val config = GrepConfig(
+ targets = null,
+ caseMode = CaseMode.SENSITIVE,
+ )
+
+ val result = Ripgrep.buildCommand(config, "test", null)
+
+ assertTrue(!result.contains("--smart-case"))
+ assertTrue(!result.contains("-F"))
+ }
+
+ @Test
+ fun `name should return ripgrep`() {
+ assertEquals("ripgrep", Ripgrep.name)
+ }
+}
diff --git a/src/test/kotlin/com/mituuz/fuzzier/grep/backend/SearchMatcherTest.kt b/src/test/kotlin/com/mituuz/fuzzier/grep/backend/SearchMatcherTest.kt
new file mode 100644
index 00000000..5d3132bb
--- /dev/null
+++ b/src/test/kotlin/com/mituuz/fuzzier/grep/backend/SearchMatcherTest.kt
@@ -0,0 +1,123 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2025 Mitja Leino
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package com.mituuz.fuzzier.grep.backend
+
+import com.mituuz.fuzzier.entities.CaseMode
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+
+class SearchMatcherTest {
+ private val matcher = SearchMatcher()
+
+ @Test
+ fun `matchesLine should return true for exact match with case sensitive`() {
+ assertTrue(matcher.matchesLine("hello world", "hello", CaseMode.SENSITIVE))
+ }
+
+ @Test
+ fun `matchesLine should return false for case mismatch with case sensitive`() {
+ assertFalse(matcher.matchesLine("hello world", "HELLO", CaseMode.SENSITIVE))
+ }
+
+ @Test
+ fun `matchesLine should return true for case mismatch with case insensitive`() {
+ assertTrue(matcher.matchesLine("hello world", "HELLO", CaseMode.INSENSITIVE))
+ }
+
+ @Test
+ fun `matchesLine should return true for lowercase match with case insensitive`() {
+ assertTrue(matcher.matchesLine("HELLO WORLD", "hello", CaseMode.INSENSITIVE))
+ }
+
+ @Test
+ fun `matchesLine should return false when substring not found`() {
+ assertFalse(matcher.matchesLine("hello world", "xyz", CaseMode.SENSITIVE))
+ }
+
+ @Test
+ fun `matchesLine should handle empty string`() {
+ assertTrue(matcher.matchesLine("hello world", "", CaseMode.SENSITIVE))
+ }
+
+ @Test
+ fun `parseSearchWords should split by spaces`() {
+ val result = matcher.parseSearchWords("hello world test")
+ assertEquals(listOf("hello", "world", "test"), result)
+ }
+
+ @Test
+ fun `parseSearchWords should trim input`() {
+ val result = matcher.parseSearchWords(" hello world ")
+ assertEquals(listOf("hello", "world"), result)
+ }
+
+ @Test
+ fun `parseSearchWords should handle single word`() {
+ val result = matcher.parseSearchWords("hello")
+ assertEquals(listOf("hello"), result)
+ }
+
+ @Test
+ fun `parseSearchWords should handle empty string`() {
+ val result = matcher.parseSearchWords("")
+ assertEquals(listOf(""), result)
+ }
+
+ @Test
+ fun `extractFirstCompleteWord should return second word when enough spaces`() {
+ val result = matcher.extractFirstCompleteWord("hello world test")
+ assertEquals("world", result)
+ }
+
+ @Test
+ fun `extractFirstCompleteWord should return null when less than 2 spaces`() {
+ val result = matcher.extractFirstCompleteWord("hello world")
+ assertNull(result)
+ }
+
+ @Test
+ fun `extractFirstCompleteWord should return null when only one word`() {
+ val result = matcher.extractFirstCompleteWord("hello")
+ assertNull(result)
+ }
+
+ @Test
+ fun `extractFirstCompleteWord should return null when second word is empty`() {
+ val result = matcher.extractFirstCompleteWord("hello test")
+ assertNull(result)
+ }
+
+ @Test
+ fun `extractFirstCompleteWord should handle multiple spaces`() {
+ val result = matcher.extractFirstCompleteWord("hello world test foo")
+ assertEquals("world", result)
+ }
+
+ @Test
+ fun `extractFirstCompleteWord should trim input`() {
+ val result = matcher.extractFirstCompleteWord(" hello world test ")
+ assertEquals("world", result)
+ }
+}
diff --git a/src/test/kotlin/com/mituuz/fuzzier/search/BackendResolverTest.kt b/src/test/kotlin/com/mituuz/fuzzier/search/BackendResolverTest.kt
index c6ff1894..818b32d6 100644
--- a/src/test/kotlin/com/mituuz/fuzzier/search/BackendResolverTest.kt
+++ b/src/test/kotlin/com/mituuz/fuzzier/search/BackendResolverTest.kt
@@ -24,9 +24,14 @@
package com.mituuz.fuzzier.search
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.testFramework.TestApplicationManager
+import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.mituuz.fuzzier.grep.backend.BackendResolver
-import com.mituuz.fuzzier.grep.backend.BackendStrategy
+import com.mituuz.fuzzier.grep.backend.FuzzierGrep
+import com.mituuz.fuzzier.grep.backend.Ripgrep
import com.mituuz.fuzzier.runner.CommandRunner
+import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
@@ -37,123 +42,55 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class BackendResolverTest {
+ @Suppress("unused")
+ private val testApplicationManager: TestApplicationManager = TestApplicationManager.getInstance()
private lateinit var commandRunner: CommandRunner
private val projectBasePath = "/test/project"
@BeforeEach
fun setUp() {
- commandRunner = mockk()
+ commandRunner = mockk(relaxed = true)
}
@Test
- fun `resolveBackend returns Ripgrep when rg is available on Windows`() = runBlocking {
- val resolver = BackendResolver(isWindows = true)
- coEvery { commandRunner.runCommandForOutput(listOf("where", "rg"), projectBasePath) } returns "/usr/bin/rg"
+ fun `resolveBackend returns FuzzierGrep when setting is FUZZIER`() = runBlocking {
+ val settings = ApplicationManager.getApplication().getService(FuzzierGlobalSettingsService::class.java)
+ settings.state.grepBackend = FuzzierGlobalSettingsService.GrepBackend.FUZZIER
+ val resolver = BackendResolver(isWindows = false)
val result = resolver.resolveBackend(commandRunner, projectBasePath)
assertTrue(result.isSuccess)
- assertEquals(BackendStrategy.Ripgrep, result.getOrNull())
- coVerify { commandRunner.runCommandForOutput(listOf("where", "rg"), projectBasePath) }
+ assertEquals(FuzzierGrep, result.getOrNull())
}
@Test
- fun `resolveBackend returns Ripgrep when rg is available on non-Windows`() = runBlocking {
+ fun `resolveBackend returns Ripgrep when setting is DYNAMIC and rg is available`() = runBlocking {
+ val settings = ApplicationManager.getApplication().getService(FuzzierGlobalSettingsService::class.java)
+ settings.state.grepBackend = FuzzierGlobalSettingsService.GrepBackend.DYNAMIC
+
val resolver = BackendResolver(isWindows = false)
coEvery { commandRunner.runCommandForOutput(listOf("which", "rg"), projectBasePath) } returns "/usr/bin/rg"
val result = resolver.resolveBackend(commandRunner, projectBasePath)
assertTrue(result.isSuccess)
- assertEquals(BackendStrategy.Ripgrep, result.getOrNull())
+ assertEquals(Ripgrep, result.getOrNull())
coVerify { commandRunner.runCommandForOutput(listOf("which", "rg"), projectBasePath) }
}
@Test
- fun `resolveBackend returns Findstr when rg is not available on Windows`() = runBlocking {
- val resolver = BackendResolver(isWindows = true)
- coEvery { commandRunner.runCommandForOutput(listOf("where", "rg"), projectBasePath) } returns null
- coEvery {
- commandRunner.runCommandForOutput(
- listOf("where", "findstr"), projectBasePath
- )
- } returns "C:\\Windows\\System32\\findstr.exe"
-
- val result = resolver.resolveBackend(commandRunner, projectBasePath)
-
- assertTrue(result.isSuccess)
- assertEquals(BackendStrategy.Findstr, result.getOrNull())
- coVerify { commandRunner.runCommandForOutput(listOf("where", "rg"), projectBasePath) }
- coVerify { commandRunner.runCommandForOutput(listOf("where", "findstr"), projectBasePath) }
- }
+ fun `resolveBackend returns FuzzierGrep when setting is DYNAMIC and rg is not available`() = runBlocking {
+ val settings = ApplicationManager.getApplication().getService(FuzzierGlobalSettingsService::class.java)
+ settings.state.grepBackend = FuzzierGlobalSettingsService.GrepBackend.DYNAMIC
- @Test
- fun `resolveBackend returns Grep when rg is not available on non-Windows`() = runBlocking {
val resolver = BackendResolver(isWindows = false)
coEvery { commandRunner.runCommandForOutput(listOf("which", "rg"), projectBasePath) } returns ""
- coEvery {
- commandRunner.runCommandForOutput(
- listOf("which", "grep"),
- projectBasePath
- )
- } returns "/usr/bin/grep"
val result = resolver.resolveBackend(commandRunner, projectBasePath)
assertTrue(result.isSuccess)
- assertEquals(BackendStrategy.Grep, result.getOrNull())
+ assertEquals(FuzzierGrep, result.getOrNull())
coVerify { commandRunner.runCommandForOutput(listOf("which", "rg"), projectBasePath) }
- coVerify { commandRunner.runCommandForOutput(listOf("which", "grep"), projectBasePath) }
- }
-
- @Test
- fun `resolveBackend returns failure when no backend is available on Windows`() = runBlocking {
- val resolver = BackendResolver(isWindows = true)
- coEvery {
- commandRunner.runCommandForOutput(
- listOf("where", "rg"), projectBasePath
- )
- } returns "Could not find files"
- coEvery { commandRunner.runCommandForOutput(listOf("where", "findstr"), projectBasePath) } returns null
-
- val result = resolver.resolveBackend(commandRunner, projectBasePath)
-
- assertTrue(result.isFailure)
- assertEquals("No suitable grep command found", result.exceptionOrNull()?.message)
- }
-
- @Test
- fun `resolveBackend returns failure when no backend is available on non-Windows`() = runBlocking {
- val resolver = BackendResolver(isWindows = false)
- coEvery { commandRunner.runCommandForOutput(listOf("which", "rg"), projectBasePath) } returns " "
- coEvery {
- commandRunner.runCommandForOutput(
- listOf("which", "grep"),
- projectBasePath
- )
- } returns ""
-
- val result = resolver.resolveBackend(commandRunner, projectBasePath)
-
- assertTrue(result.isFailure)
- assertEquals("No suitable grep command found", result.exceptionOrNull()?.message)
- }
-
- @Test
- fun `resolveBackend prioritizes Ripgrep over Findstr on Windows`() = runBlocking {
- val resolver = BackendResolver(isWindows = true)
- coEvery { commandRunner.runCommandForOutput(listOf("where", "rg"), projectBasePath) } returns "/usr/bin/rg"
- coEvery {
- commandRunner.runCommandForOutput(
- listOf("where", "findstr"), projectBasePath
- )
- } returns "C:\\Windows\\System32\\findstr.exe"
-
- val result = resolver.resolveBackend(commandRunner, projectBasePath)
-
- assertTrue(result.isSuccess)
- assertEquals(BackendStrategy.Ripgrep, result.getOrNull())
- coVerify { commandRunner.runCommandForOutput(listOf("where", "rg"), projectBasePath) }
- coVerify(exactly = 0) { commandRunner.runCommandForOutput(listOf("where", "findstr"), projectBasePath) }
}
}
\ No newline at end of file
diff --git a/src/test/kotlin/com/mituuz/fuzzier/search/BackendStrategyTest.kt b/src/test/kotlin/com/mituuz/fuzzier/search/BackendStrategyTest.kt
deleted file mode 100644
index e75463be..00000000
--- a/src/test/kotlin/com/mituuz/fuzzier/search/BackendStrategyTest.kt
+++ /dev/null
@@ -1,244 +0,0 @@
-/*
- * MIT License
- *
- * Copyright (c) 2025 Mitja Leino
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- * SOFTWARE.
- */
-
-package com.mituuz.fuzzier.search
-
-import com.mituuz.fuzzier.entities.CaseMode
-import com.mituuz.fuzzier.entities.GrepConfig
-import com.mituuz.fuzzier.grep.backend.BackendStrategy
-import org.junit.jupiter.api.Assertions.assertEquals
-import org.junit.jupiter.api.Assertions.assertTrue
-import org.junit.jupiter.api.Nested
-import org.junit.jupiter.api.Test
-
-class BackendStrategyTest {
-
- @Nested
- inner class RipgrepTest {
- @Test
- fun `buildCommand should include basic ripgrep flags`() {
- val config = GrepConfig(
- targets = listOf("/path/to/search"),
- caseMode = CaseMode.SENSITIVE,
- )
-
- val result = BackendStrategy.Ripgrep.buildCommand(config, "test", null)
-
- assertEquals(
- listOf(
- "rg",
- "--no-heading",
- "--color=never",
- "-n",
- "--with-filename",
- "--column",
- "test",
- "/path/to/search"
- ),
- result
- )
- }
-
- @Test
- fun `buildCommand should include file extension glob flag without dot`() {
- val config = GrepConfig(
- targets = listOf("/path/to/search"),
- caseMode = CaseMode.SENSITIVE,
- )
-
- val result = BackendStrategy.Ripgrep.buildCommand(config, "test", "java")
-
- assertEquals(
- listOf(
- "rg",
- "--no-heading",
- "--color=never",
- "-n",
- "--with-filename",
- "--column",
- "-g",
- "*.java",
- "test",
- "/path/to/search"
- ),
- result
- )
- }
-
- @Test
- fun `buildCommand should include file extension glob flag with dot`() {
- val config = GrepConfig(
- targets = listOf("/path/to/search"),
- caseMode = CaseMode.SENSITIVE,
- )
-
- val result = BackendStrategy.Ripgrep.buildCommand(config, "test", ".java")
-
- assertEquals(
- listOf(
- "rg",
- "--no-heading",
- "--color=never",
- "-n",
- "--with-filename",
- "--column",
- "-g",
- "*.java",
- "test",
- "/path/to/search"
- ),
- result
- )
- }
-
- @Test
- fun `buildCommand should add smart-case and -F for insensitive mode`() {
- val config = GrepConfig(
- targets = listOf("/path"),
- caseMode = CaseMode.INSENSITIVE,
- )
-
- val result = BackendStrategy.Ripgrep.buildCommand(config, "test", null)
-
- assertTrue(result.contains("--smart-case"))
- assertTrue(result.contains("-F"))
- }
-
- @Test
- fun `buildCommand should not add smart-case for sensitive mode`() {
- val config = GrepConfig(
- targets = listOf("/path"),
- caseMode = CaseMode.SENSITIVE,
- )
-
- val result = BackendStrategy.Ripgrep.buildCommand(config, "test", null)
-
- assertTrue(!result.contains("--smart-case"))
- assertTrue(!result.contains("-F"))
- }
-
- @Test
- fun `name should return ripgrep`() {
- assertEquals("ripgrep", BackendStrategy.Ripgrep.name)
- }
- }
-
- @Nested
- inner class FindstrTest {
- @Test
- fun `buildCommand should include basic findstr flags`() {
- val config = GrepConfig(
- targets = listOf("C:\\path\\to\\search"),
- caseMode = CaseMode.SENSITIVE,
- )
-
- val result = BackendStrategy.Findstr.buildCommand(config, "test", null)
-
- assertTrue(result.contains("findstr"))
- assertTrue(result.contains("/p"))
- assertTrue(result.contains("/s"))
- assertTrue(result.contains("/n"))
- assertTrue(result.contains("/C:test"))
- assertTrue(result.contains("C:\\path\\to\\search"))
- }
-
- @Test
- fun `buildCommand should add case insensitive flag when needed`() {
- val config = GrepConfig(
- targets = listOf("C:\\path"),
- caseMode = CaseMode.INSENSITIVE,
- )
-
- val result = BackendStrategy.Findstr.buildCommand(config, "test", null)
-
- assertTrue(result.contains("/I"))
- }
-
- @Test
- fun `buildCommand should not add case insensitive flag for sensitive mode`() {
- val config = GrepConfig(
- targets = listOf("C:\\path"),
- caseMode = CaseMode.SENSITIVE,
- )
-
- val result = BackendStrategy.Findstr.buildCommand(config, "test", null)
-
- assertTrue(!result.contains("/I"))
- }
-
- @Test
- fun `name should return findstr`() {
- assertEquals("findstr", BackendStrategy.Findstr.name)
- }
- }
-
- @Nested
- inner class GrepTest {
- @Test
- fun `buildCommand should include basic grep flags`() {
- val config = GrepConfig(
- targets = listOf("/path/to/search"),
- caseMode = CaseMode.SENSITIVE,
- )
-
- val result = BackendStrategy.Grep.buildCommand(config, "test", null)
-
- assertTrue(result.contains("grep"))
- assertTrue(result.contains("--color=none"))
- assertTrue(result.contains("-r"))
- assertTrue(result.contains("-n"))
- assertTrue(result.contains("test"))
- assertTrue(result.contains("/path/to/search"))
- }
-
- @Test
- fun `buildCommand should add case insensitive flag when needed`() {
- val config = GrepConfig(
- targets = listOf("/path"),
- caseMode = CaseMode.INSENSITIVE,
- )
-
- val result = BackendStrategy.Grep.buildCommand(config, "test", null)
-
- assertTrue(result.contains("-i"))
- }
-
- @Test
- fun `buildCommand should not add case insensitive flag for sensitive mode`() {
- val config = GrepConfig(
- targets = listOf("/path"),
- caseMode = CaseMode.SENSITIVE,
- )
-
- val result = BackendStrategy.Grep.buildCommand(config, "test", null)
-
- assertTrue(!result.contains("-i"))
- }
-
- @Test
- fun `name should return grep`() {
- assertEquals("grep", BackendStrategy.Grep.name)
- }
- }
-}
\ No newline at end of file
diff --git a/src/test/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurableTest.kt b/src/test/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurableTest.kt
index 97d2e29c..39a42cf2 100644
--- a/src/test/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurableTest.kt
+++ b/src/test/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurableTest.kt
@@ -270,6 +270,46 @@ class FuzzierGlobalSettingsConfigurableTest {
assertTrue(settingsConfigurable.isModified)
}
+ @Test
+ fun grepBackend_isPopulatedFromState() {
+ state.grepBackend = FuzzierGlobalSettingsService.GrepBackend.FUZZIER
+ settingsConfigurable.createComponent()
+ val selected = settingsConfigurable.component.grepBackendSelector
+ .getGrepBackendComboBox().selectedItem as FuzzierGlobalSettingsService.GrepBackend
+ assertEquals(FuzzierGlobalSettingsService.GrepBackend.FUZZIER, selected)
+ }
+
+ @Test
+ fun grepBackend_isModifiedAndApply() {
+ settingsConfigurable.createComponent()
+ assertFalse(settingsConfigurable.isModified)
+
+ val newValue = if (state.grepBackend == FuzzierGlobalSettingsService.GrepBackend.DYNAMIC)
+ FuzzierGlobalSettingsService.GrepBackend.FUZZIER else FuzzierGlobalSettingsService.GrepBackend.DYNAMIC
+ state.grepBackend = newValue
+ assertTrue(settingsConfigurable.isModified)
+
+ val uiSelected = settingsConfigurable.component.grepBackendSelector
+ .getGrepBackendComboBox().selectedItem as FuzzierGlobalSettingsService.GrepBackend
+ settingsConfigurable.apply()
+ assertEquals(uiSelected, state.grepBackend)
+ assertFalse(settingsConfigurable.isModified)
+ }
+
+ @Test
+ fun grepBackend_persistsAcrossRecreate() {
+ settingsConfigurable.createComponent()
+ val combo = settingsConfigurable.component.grepBackendSelector.getGrepBackendComboBox()
+ combo.selectedItem = FuzzierGlobalSettingsService.GrepBackend.FUZZIER
+ settingsConfigurable.apply()
+
+ val recreated = FuzzierGlobalSettingsConfigurable()
+ recreated.createComponent()
+ val selected = recreated.component.grepBackendSelector.getGrepBackendComboBox().selectedItem
+ as FuzzierGlobalSettingsService.GrepBackend
+ assertEquals(FuzzierGlobalSettingsService.GrepBackend.FUZZIER, selected)
+ }
+
@Test
fun globalExclusionSet_textIsPopulatedFromState() {
state.globalExclusionSet = setOf("/foo", "/bar")