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

-

Known issues

- -

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

- -

Update default list movement keys

- - -

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

- - -

Other changes

- - """.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")