packages feed

plexus-synapse-0.3.0.0: src/Synapse/CLI/Similarity.hs

{-# LANGUAGE OverloadedStrings #-}

-- | String similarity utilities for typo suggestions
--
-- Provides Levenshtein distance computation and correction suggestions
-- for CLI parameter names and variant values.
module Synapse.CLI.Similarity
  ( levenshteinDistance
  , suggestCorrections
  ) where

import Data.Text (Text)
import qualified Data.Text as T
import Data.List (sortOn)
import Data.Array

-- | Compute Levenshtein distance between two strings
--
-- Uses the Wagner-Fischer dynamic programming algorithm.
-- Time complexity: O(m*n) where m, n are string lengths.
--
-- Examples:
-- >>> levenshteinDistance "kitten" "sitting"
-- 3
-- >>> levenshteinDistance "message" "mesage"
-- 1
levenshteinDistance :: Text -> Text -> Int
levenshteinDistance s1 s2 = dp ! (m, n)
  where
    m = T.length s1
    n = T.length s2

    -- Dynamic programming array bounds: (0,0) to (m,n)
    bounds = ((0, 0), (m, n))

    -- DP table: dp!(i,j) = distance between first i chars of s1 and first j chars of s2
    dp = array bounds [(ij, dist ij) | ij <- range bounds]

    dist (0, j) = j  -- s1 is empty, need j insertions
    dist (i, 0) = i  -- s2 is empty, need i deletions
    dist (i, j) = minimum
      [ dp ! (i-1, j) + 1        -- deletion
      , dp ! (i, j-1) + 1        -- insertion
      , dp ! (i-1, j-1) + cost   -- substitution
      ]
      where
        cost = if T.index s1 (i-1) == T.index s2 (j-1) then 0 else 1

-- | Find similar strings from a list of valid options
--
-- Returns up to 3 suggestions sorted by edit distance.
-- Only suggests strings within a reasonable edit distance threshold:
-- - Threshold = max(2, length/3)
-- - This filters out dissimilar strings
--
-- Examples:
-- >>> suggestCorrections "mesage" ["message", "massage", "passage"]
-- ["message"]
-- >>> suggestCorrections "cnt" ["count", "content", "constant"]
-- ["count", "content"]
suggestCorrections :: Text -> [Text] -> [Text]
suggestCorrections typo validOptions =
  let threshold = max 2 (T.length typo `div` 3)
      withDist = [(opt, levenshteinDistance typo opt) | opt <- validOptions]
      -- Filter: distance > 0 (not exact match) and <= threshold
      filtered = filter (\(_, d) -> d <= threshold && d > 0) withDist
      sorted = sortOn snd filtered
  in take 3 (map fst sorted)