Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ jobs:

- name: Build
run: make build

- name: Test
run: make test
17 changes: 11 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ BINARY_NAME = mac-menu
SRC_DIR = src
DIST_DIR = .build
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "unknown")
SOURCES = $(SRC_DIR)/FuzzyMatch.swift $(SRC_DIR)/main.swift $(SRC_DIR)/Version.swift

.PHONY: all clean install uninstall test
.PHONY: all clean install uninstall test run
RUN_CMD = $(DIST_DIR)/$(BINARY_NAME)

all: $(DIST_DIR)/$(BINARY_NAME)

build: all

$(DIST_DIR)/$(BINARY_NAME): $(SRC_DIR)/main.swift $(SRC_DIR)/Version.swift
$(DIST_DIR)/$(BINARY_NAME): $(SOURCES)
@mkdir -p $(DIST_DIR)
swiftc -O -o $(DIST_DIR)/$(BINARY_NAME) $(SRC_DIR)/main.swift $(SRC_DIR)/Version.swift -framework Cocoa
swiftc -O -o $(DIST_DIR)/$(BINARY_NAME) $(SOURCES) -framework Cocoa

$(SRC_DIR)/Version.swift:
@echo 'let appVersion = "$(VERSION)"' > $(SRC_DIR)/Version.swift
Expand All @@ -31,6 +33,9 @@ clean:
@echo "Cleaning build files..."
@rm -rf $(DIST_DIR)

test: $(DIST_DIR)/$(BINARY_NAME)
@echo "Testing with Yes/No input..."
@echo "Yes\nNo" | $(DIST_DIR)/$(BINARY_NAME)
test:
@echo "Running Swift Testing suite..."
@swift test --enable-swift-testing

run: build
@$(RUN_CMD)
24 changes: 24 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// swift-tools-version: 6.0

import PackageDescription

let package = Package(
name: "mac-menu",
platforms: [
.macOS(.v13)
],
dependencies: [
.package(url: "https://github.com/apple/swift-testing.git", from: "6.2.0")
],
targets: [
.target(
name: "FuzzyMatchModule",
path: "src",
exclude: ["main.swift", "Version.swift"],
sources: ["FuzzyMatch.swift"]
),
.testTarget(
name: "FuzzyMatchTests",
dependencies: [
"FuzzyMatchModule",
.product(name: "Testing", package: "swift-testing")
],
path: "tests"
)
]
)
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,17 @@ make clean

## Testing

A simple test is included to verify the application with basic input. This test will run the app with "Yes" and "No" as options:
Run the Swift Testing suite:

```bash
make test
```

This will build the application (if needed) and launch it with "Yes" and "No" as selectable options. You can interact with the UI as usual (keyboard/mouse), and the selected value will be printed to stdout.
For a quick manual check of the UI, pipe input into the app and launch it via the convenience target:

```bash
echo -e "Yes\nNo" | make run
```

## License

Expand Down
99 changes: 99 additions & 0 deletions src/FuzzyMatch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Foundation

/// Result of a fuzzy match attempt including match positions for scoring.
struct FuzzyMatchResult {
let string: String
let score: Int
let positions: [Int]
}

/// Constants used to calculate fuzzy match scores.
private enum ScoreConfig {
static let bonusMatch: Int = 16
static let bonusBoundary: Int = 16
static let bonusConsecutive: Int = 16
static let penaltyGapStart: Int = -3
static let penaltyGapExtension: Int = -1
static let penaltyNonContiguous: Int = -5
}

/// Fuzzy search function implementing fzf's algorithm.
/// - Parameters:
/// - pattern: The search pattern to match
/// - string: The string to search in
/// - Returns: A tuple containing whether there's a match and the match result
func fuzzyMatch(pattern: String, string: String) -> (Bool, FuzzyMatchResult?) {
let pattern = pattern.lowercased()
let string = string.lowercased()

// Empty pattern matches everything
if pattern.isEmpty {
return (true, FuzzyMatchResult(string: string, score: 0, positions: []))
}

let patternLength = pattern.count
let stringLength = string.count

// If pattern is longer than string, no match possible
if patternLength > stringLength {
return (false, nil)
}

// Initialize score matrix
var scores = Array(repeating: Array(repeating: 0, count: stringLength + 1), count: patternLength + 1)
var positions = Array(repeating: Array(repeating: [Int](), count: stringLength + 1), count: patternLength + 1)

// Fill score matrix
for i in 1...patternLength {
for j in 1...stringLength {
let patternChar = pattern[pattern.index(pattern.startIndex, offsetBy: i - 1)]
let stringChar = string[string.index(string.startIndex, offsetBy: j - 1)]

if patternChar == stringChar {
var score = ScoreConfig.bonusMatch

// Bonus for boundary
if j == 1 || string[string.index(string.startIndex, offsetBy: j - 2)] == " " {
score += ScoreConfig.bonusBoundary
}

// Bonus for consecutive matches
if i > 1 && j > 1 && pattern[pattern.index(pattern.startIndex, offsetBy: i - 2)] == string[string.index(string.startIndex, offsetBy: j - 2)] {
score += ScoreConfig.bonusConsecutive
}

let prevScore = scores[i - 1][j - 1]
let newScore = prevScore + score

// Check if we should extend previous match or start new one
if newScore > scores[i - 1][j] + ScoreConfig.penaltyGapStart {
scores[i][j] = newScore
positions[i][j] = positions[i - 1][j - 1] + [j - 1]
} else {
scores[i][j] = scores[i - 1][j] + ScoreConfig.penaltyGapStart
positions[i][j] = positions[i - 1][j]
}
} else {
// Penalty for gaps
let gapScore = max(
scores[i][j - 1] + ScoreConfig.penaltyGapExtension,
scores[i - 1][j] + ScoreConfig.penaltyGapStart
)
scores[i][j] = gapScore
positions[i][j] = positions[i][j - 1]
}
}
}

// Check if we found a match
let finalScore = scores[patternLength][stringLength]
if finalScore > 0 {
return (true, FuzzyMatchResult(
string: string,
score: finalScore,
positions: positions[patternLength][stringLength]
))
}

return (false, nil)
}
98 changes: 0 additions & 98 deletions src/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,104 +74,6 @@ class MenuApp: NSObject, NSApplicationDelegate, NSTableViewDataSource, NSTableVi
/// Items filtered by search query
var filteredItems: [String] = []

/// Fuzzy search result containing the matched string and its score
private struct FuzzyMatchResult {
let string: String
let score: Int
let positions: [Int]
}

/// Constants for scoring
private struct ScoreConfig {
static let bonusMatch: Int = 16
static let bonusBoundary: Int = 16
static let bonusConsecutive: Int = 16
static let penaltyGapStart: Int = -3
static let penaltyGapExtension: Int = -1
static let penaltyNonContiguous: Int = -5
}

/// Fuzzy search function implementing fzf's algorithm
/// - Parameters:
/// - pattern: The search pattern to match
/// - string: The string to search in
/// - Returns: A tuple containing whether there's a match and the match result
private func fuzzyMatch(pattern: String, string: String) -> (Bool, FuzzyMatchResult?) {
let pattern = pattern.lowercased()
let string = string.lowercased()

// Empty pattern matches everything
if pattern.isEmpty {
return (true, FuzzyMatchResult(string: string, score: 0, positions: []))
}

let patternLength = pattern.count
let stringLength = string.count

// If pattern is longer than string, no match possible
if patternLength > stringLength {
return (false, nil)
}

// Initialize score matrix
var scores = Array(repeating: Array(repeating: 0, count: stringLength + 1), count: patternLength + 1)
var positions = Array(repeating: Array(repeating: [Int](), count: stringLength + 1), count: patternLength + 1)

// Fill score matrix
for i in 1...patternLength {
for j in 1...stringLength {
let patternChar = pattern[pattern.index(pattern.startIndex, offsetBy: i - 1)]
let stringChar = string[string.index(string.startIndex, offsetBy: j - 1)]

if patternChar == stringChar {
var score = ScoreConfig.bonusMatch

// Bonus for boundary
if j == 1 || string[string.index(string.startIndex, offsetBy: j - 2)] == " " {
score += ScoreConfig.bonusBoundary
}

// Bonus for consecutive matches
if i > 1 && j > 1 && pattern[pattern.index(pattern.startIndex, offsetBy: i - 2)] == string[string.index(string.startIndex, offsetBy: j - 2)] {
score += ScoreConfig.bonusConsecutive
}

let prevScore = scores[i - 1][j - 1]
let newScore = prevScore + score

// Check if we should extend previous match or start new one
if newScore > scores[i - 1][j] + ScoreConfig.penaltyGapStart {
scores[i][j] = newScore
positions[i][j] = positions[i - 1][j - 1] + [j - 1]
} else {
scores[i][j] = scores[i - 1][j] + ScoreConfig.penaltyGapStart
positions[i][j] = positions[i - 1][j]
}
} else {
// Penalty for gaps
let gapScore = max(
scores[i][j - 1] + ScoreConfig.penaltyGapExtension,
scores[i - 1][j] + ScoreConfig.penaltyGapStart
)
scores[i][j] = gapScore
positions[i][j] = positions[i][j - 1]
}
}
}

// Check if we found a match
let finalScore = scores[patternLength][stringLength]
if finalScore > 0 {
return (true, FuzzyMatchResult(
string: string,
score: finalScore,
positions: positions[patternLength][stringLength]
))
}

return (false, nil)
}

// MARK: - Application Lifecycle

/// Sets up the application window and UI components
Expand Down
30 changes: 30 additions & 0 deletions tests/FuzzyMatchTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Testing
@testable import FuzzyMatchModule

struct FuzzyMatchTests {
@Test func emptyPatternMatchesEverything() throws {
let (matched, result) = fuzzyMatch(pattern: "", string: "Hello World")
#expect(matched, "Empty pattern should match any string")
#expect(result != nil, "Result should not be nil for empty pattern")
#expect(result?.positions.isEmpty == true, "Empty pattern should have no positions")
}

@Test func patternLongerThanStringDoesNotMatch() throws {
let (matched, result) = fuzzyMatch(pattern: "toolong", string: "tiny")
#expect(!matched, "Longer pattern should not match shorter string")
#expect(result == nil, "Result should be nil when there is no match")
}

@Test func exactMatchProducesSequentialPositions() throws {
let (matched, result) = fuzzyMatch(pattern: "abc", string: "abc")
#expect(matched, "Exact match should succeed")
#expect(result?.positions == [0, 1, 2], "Exact match should return sequential positions")
#expect((result?.score ?? 0) > 0, "Exact match should have a positive score")
}

@Test func caseInsensitiveMatching() throws {
let (matched, result) = fuzzyMatch(pattern: "FZ", string: "fuzzy")
#expect(matched, "Matching should be case-insensitive")
#expect(result?.positions == [0, 3], "Expected positions for 'FZ' in 'fuzzy' to be [0, 3]")
}
}