wordsetdiff (empty) → 0.0.1
raw patch · 4 files changed
+404/−0 lines, 4 filesdep +ansi-terminaldep +basedep +bytestringsetup-changed
Dependencies added: ansi-terminal, base, bytestring, containers, directory, filepath, process
Files
- LICENSE +31/−0
- Setup.hs +2/−0
- Text/WordSetDiff/Main.hs +332/−0
- wordsetdiff.cabal +39/−0
+ LICENSE view
@@ -0,0 +1,31 @@+NOTE: This License applies to all files in the "Haskell CnC"+distribution, irrespective of whether the license is appended at the+top of each file.++ BSD STANDARD THREE CLAUSE LICENSE ("New BSD License")++Copyright (c) 2010, Intel Corporation.+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:+ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.+ * Redistributions in binary form must reproduce the above copyright+ notice, this list of conditions and the following disclaimer in the+ documentation and/or other materials provided with the distribution.+ * Neither the name of the <organization> nor the+ names of its contributors may be used to endorse or promote products+ derived from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.+
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ Text/WordSetDiff/Main.hs view
@@ -0,0 +1,332 @@+#!/usr/bin/env runhaskell++------------------------------------------------------------------------------------------s+{-| + Is one text file a subset of the other?+ Or is there some bit of new text that needs to be salvaged?++ The basic unix diff tool is sometimes incredibly unsatisfactory for+ this purpose -- for example when text has been moved around, or when+ there are widespread whitespace differences.++ This program compares two files by treating them as unstructured+ sets of word sequences. By default words are defined by `Data.Char.isAlpha`.++ Run `wordsetdiff` with no arguments to print the help information.+ -}+------------------------------------------------------------------------------------------s++module Main where++import System.Environment+import System.FilePath+import System.IO+import System.Exit++import System.Console.GetOpt+import System.Console.ANSI+import System.FilePath++--import System.Console.GetOpt++import Prelude hiding (null, readFile)++import Debug.Trace+import Numeric+import Data.Int+import Data.Char+import Data.Function+import Data.Word as W+import qualified Data.Map as Map+import qualified Data.Set as Set+import qualified Data.List as List+import qualified Data.ByteString.Lazy as L+import Data.ByteString.Lazy.Char8 as B++-- How many consecutive words should we look at?+default_word_sequence_size = 3+clump_distance = 10+use_color = True++--------------------------------------------------------------------------------+-- | Command line option flags+data CmdFlag = + NoColor+ | NWords Int+ | WithPunc+ | AlphaOnly+ | CaseInsensitive++options :: [OptDescr CmdFlag]+options =+ [ Option ['n'] ["nwords"] (ReqArg (NWords . safeRead) "NUM") "use word tuples of length NUM"+ , Option [] ["nocolor"] (NoArg NoColor) "disable ANSI color output"++ , Option ['p'] ["withpunc"] (NoArg WithPunc) "use all non-whitespace chars as 'words'"+ , Option [ ] ["alpha"] (NoArg AlphaOnly) "ignore all but A-Z characters when forming words [default]"+ , Option ['i'] ["ignore-case"] (NoArg CaseInsensitive) "ignore case distinctions"+ ]++usage = "\nUsage: wordsetdiff [OPTION...] file1 file2 [file3 ...]\n\n"++++ "The wordsetdiff program subtracts the contents of file2, file3, ... from file1.\n\n"++++ "As output wordsetdiff will print any remaining regions of text in file1 after the\n"+++ "other files' contents are subtracted. Files are compared by treating them as\n"+++ "unordered sets of word sequences of length N-words.\n"++++ "\nOptions include:\n"+defaultErr errs = error $ "ERROR!\n" ++ (List.concat errs ++ usageInfo usage options)++safeRead :: String -> Int +safeRead s = + case readDec s of+ [(n,"")] -> n+ _ -> error$ "Could not read '"++ s ++"' as an integer."++--------------------------------------------------------------------------------++-- | Tracking simple source locations as (start,end) inclusive/exclusive character indices.+data Loc = Loc Int64 Int64+ deriving (Show, Ord, Eq)++left (Loc l _) = l+right (Loc _ r) = r++-- | Returns words satisfying whose characters satisfy a predicate along with their ZERO BASED locations.+words_wloc :: (Char -> Bool) -> ByteString -> [(ByteString, Loc)]+words_wloc isWordChar bs = + -- Convert each point location into a start/end Loc structure:+ List.map (\ (word,pos) -> (word,Loc pos (pos + B.length word))) filtered + where + filtered = List.filter (not . null . fst) withpos+ split = splitWith (not . isWordChar) bs+ withpos = List.scanl (\ (last,pos) chunk -> (chunk, pos + B.length last + 1)) (empty,-1) split +++-- | Cluster regions together if they are "almost touching".+-- Any regions within clump_distance characters of one another are joined.+-- The result should have no overlaps:+clump_regions :: [Loc] -> [Loc]+clump_regions [] = []+clump_regions [a] = [a]+clump_regions (Loc a1 a2 : Loc b1 b2 :c) | b1-a2 <= clump_distance = clump_regions (Loc a1 (max a2 b2) : c)+clump_regions (a:b) = a : clump_regions b+++-- | Take the "bounding box" of a list of locations.+combine_locs :: [Loc] -> Loc+combine_locs [] = error "No locs!"+combine_locs [a] = a+combine_locs ls = Loc (left$ List.head ls) (right$ List.last ls) ++-- | Form a map mapping words to a set of occurrence locations within the bytestring.+wordmap :: (Char -> Bool) -> ByteString -> Map.Map ByteString (Set.Set Loc)+wordmap isWordChar new = + Map.fromListWith Set.union $+ List.map (\ (a,b) -> (a, Set.singleton b)) $ + words_wloc isWordChar $ new++-- | Like wordmap, but this version forms a map using consecutive sequences of+-- | N words (represented as lists) as the keys instead of individual words.+wordmapN :: (Char -> Bool) -> Int -> ByteString -> Map.Map [ByteString] (Set.Set Loc)+wordmapN isWordChar n bs = + Map.fromListWith Set.union $+ List.map (\ ls -> (List.map fst ls, Set.singleton (combine_locs (List.map snd ls)))) $ + sliding_win n $ + words_wloc isWordChar $ bs+ where + loc_list ls = Loc (List.head ls) (List.last ls)+ -- Perhaps not the most efficient...+ sliding_win n ls = List.take (List.length ls - n + 1) $+ List.map (List.take n) $+ List.tails ls+++-- | The region of interest will end up bloated with separator+-- charactors around the edges. This will trim those down.+trim_separators :: (Char -> Bool) -> ByteString -> [Loc] -> [Loc]+trim_separators isWordChar bs [] = []+trim_separators isWordChar bs diffs@(Loc start end : _) = + -- Note: passing isWordChar as a function is probably less efficient than passing a boolean flag.+ -- (Unless GHC does MLton-style control flow analysis and transforming calling sites into switches.)+ loop 0 bs diffs+ where + -- This look keeps the head of the bytestring bs lined up with the region we're looking at.+ -- Invariant next start == offset (could get rid of offset)+ loop offset bs [] = []+ loop offset bs origd@(Loc start end : diffs) ++ -- Need to scroll the tape.+ | offset < start = loop start (B.drop (start - offset) bs) origd++ | isWordChar (B.head bs) = + -- Scroll past to the next (non-overlapping) segment:+ let tail = loop end (B.drop (end-offset) bs) diffs in + case trim_tail offset bs end of + Nothing -> tail + Just trimmed -> Loc start trimmed : tail++ + -- Otherwise scroll forward one character:+ | otherwise = loop (offset+1) (B.tail bs) (Loc (start+1) end : diffs)+ -- Trim from the other end.+ trim_tail offset bs end + | end == offset = Nothing -- The whole section is nixed is trimmed+ | isWordChar (B.index bs (end - offset - 1)) = Just end+ | otherwise = trim_tail offset bs (end - 1)++--baseline = [SetColor Background Dull Black]+withCol False viv col act = act+withCol True viv col act = + do setSGR [SetColor Foreground viv col]+ act + setSGR []++-- | Print out results, i.e. the distinct regions of text within one file and not the other.+print_diff_regions :: Bool -> ByteString -> [Loc] -> IO ()+print_diff_regions color bs ls = + loop 0 0 (B.lines bs) ls+ where + loop lnum pos lines [] = return ()+ loop lnum pos [] diff = error$ "difference regions beyond end of file: " ++ show diff+ loop lnum pos (ln:lines) origd@(Loc start end : diffs)+ -- NOTE: This adds ONE character for the newline, won't work with carriage-return/newline:+ | pos + B.length ln < start = + loop (lnum+1) (pos + B.length ln + 1) lines origd+ | otherwise = do + -- Make all these locations one-based? For now I make just the lines one-based.+ --withCol Dull Red $ Prelude.putStr$ "\n==== line "++ show (lnum+1) ++": " + withCol color Dull Red $ Prelude.putStr$ "\n==== "+ Prelude.putStr$ "Found distinct material, "++ show (end-start) ++" characters (chars "++ show start ++ " to " ++ show end + ++ ") " -- starting on+ withCol color Dull Red $ Prelude.putStrLn$ "line "++ show (lnum+1) ++"\n" + let snip = B.take (end-start) $ B.drop (start-pos) $ B.unlines (ln:lines)+ header = B.take (start-pos) $ B.repeat ' '++ withCol color Dull Green $ do+ B.putStr header -- Not sure if this helps readability+ B.putStrLn snip+ loop lnum pos (ln:lines) diffs -- Finished printing that diff, move to next++data Config = + Cfg { color :: Bool+ , word_sequence_size :: Int+ , case_insensitive :: Bool+ , with_punctuation :: Bool+ }++default_cfg = + Cfg { color = True+ , word_sequence_size = default_word_sequence_size+ , case_insensitive = False+ , with_punctuation = False+ }++main = + do args <- getArgs+ -- We could perhaps search for a value of word_sequence_size that+ -- gives us a "desirable" output according to some metric, which+ -- would probably be a combination of the number of distinct+ -- difference regions and the total length of characters in them.++ -- let (left, right, word_sequence_size) =+ -- case args of + -- [l,r] -> (l,r, default_word_sequence_size)+ -- [l,r,w] -> (l,r, read w)+ -- _ -> error "Expects two arguments!" ++ (opts,left,rights) <- + case getOpt Permute options args of+ (o,l:r:rest,[]) -> return (o,l,r:rest)+ (_,_,errs) -> defaultErr errs++ let + cfg = List.foldl process_opt default_cfg opts+ process_opt cfg opt = + case opt of + NoColor -> cfg { color = False }+ NWords n -> cfg { word_sequence_size = n }+ WithPunc -> cfg { with_punctuation = True }+ AlphaOnly -> cfg { with_punctuation = False }+ CaseInsensitive -> cfg { case_insensitive = True }++ _bs_left <- B.readFile left + _bs_rights <- mapM B.readFile rights++ let + -- This is a sloppy way to implement case insesitivity. Do it at the outset:+ isWordChar = if with_punctuation cfg then not . isSpace else isAlpha+ lower = if case_insensitive cfg then B.map toLower else id+ bs_left = lower _bs_left + bs_rights = List.map lower _bs_rights+ snips_left = wordmapN isWordChar (word_sequence_size cfg) bs_left ++ sub_one_file (remain,common) bs_right = + let+ snips_right = wordmapN isWordChar (word_sequence_size cfg) bs_right+ remain' = Map.difference remain snips_right+ common' = Map.intersection common snips_right+ in (remain',common')++ -- We go through each of the files to carve its contents out+ -- of the starting contents of file1 ('left')+ (remain,common) = List.foldl sub_one_file (snips_left, snips_left) bs_rights++ sorted_locs map = List.sort $ List.concat $ + List.map Set.toList $ Map.elems $ map+ diff_area = clump_regions $ sorted_locs remain+ common_area = clump_regions $ sorted_locs common++ -- Because of our N-word strategy we get sloppy edges, to+ -- prune the borders we subtract out the known common area:+ -- 'pruned' is the final, important differences.+ pruned = trim_separators isWordChar bs_left $+ loop diff_area common_area+ inbetween a1 a2 b = (a1 <= b && b <= a2) + loop a [] = a+ loop [] b = []+ loop a@(Loc a1 a2 :ar) b@(Loc b1 b2 :br) -- Subtract b from a+ | a2 < b1 = Loc a1 a2 : loop ar b -- Already in the clear.+ | b2 < a1 = loop a br -- Keep looking for disqualification+ | inbetween a1 a2 b1 && inbetween a1 a2 b2 = + -- It splits us in two:+ loop (add_loc a1 b1 $ add_loc b2 a2 ar) br+ | inbetween a1 a2 b1 = add_loc a1 b1 (loop ar b) -- Chop our right end+ | inbetween a1 a2 b2 = loop (add_loc b2 a2 ar) br -- Chop our left end++ -- In this case we instead fall entirely inside the prune range.+ | inbetween b1 b2 a1 && inbetween b1 b2 a2 = loop ar b++ | otherwise = error$ "Should not happen (a1 a2) (b1 b2): "++ show (a1,a2) ++" "++ show (b1,b2)+ add_loc start end ls | start == end = ls+ | otherwise = Loc start end : ls+ numpruned = List.length pruned+ + withCol (color cfg) Dull Red $ Prelude.putStr$ "==== "+ Prelude.putStr$ "Subtracting contents of file(s) "+ withCol (color cfg) Dull Red$ Prelude.putStr$ (List.concat$ List.intersperse ", " $ List.map takeBaseName rights)+ Prelude.putStr$ " from "+ withCol (color cfg) Dull Red$ Prelude.putStr$ takeBaseName left+ if List.null pruned+ then do Prelude.putStrLn$ "\nNo contents remaining after difference."; exitSuccess+ else do + print_diff_regions (color cfg) bs_left pruned+ withCol (color cfg) Dull Red$ Prelude.putStrLn$ "\n--------------------------------------------------------------------------------"+ Prelude.putStrLn$ " !!! Discovered "++ show numpruned ++" difference region(s) using word sequences of length "+ ++ show (word_sequence_size cfg) ++". Excerpts above."+ exitWith (ExitFailure numpruned)++{- ++ Performance notes [2010.10.13]:+ If I run this on a 378K file on my laptop it takes 1.7 seconds with under 50% productivity.++ Parallelism in the map/set operations could help a little here... not+ sure what else would other than using an imperative algorithm with+ different data structures.++Comparing against some stray versions of my large amorphous computing notes file it does indeed prove useful.+++ -}
+ wordsetdiff.cabal view
@@ -0,0 +1,39 @@+++Name: wordsetdiff+Version: 0.0.1+License: BSD3+License-file: LICENSE+Stability: Beta+Author: Ryan Newton <rrnewton@gmail.com>+Maintainer: Ryan Newton <rrnewton@gmail.com>++-- homepage: +-- Copyright: Copyright (c) 2009-2010 Ryan Newton+Synopsis: Compare two files as sets of words or sets of N-tuples of words.++Description: This utility is useful for finding out if some old,+ misplaced version of a file (say from your old laptop)+ has any new text in it that never got checked in, synced,+ or copied over to your newest version of the file.++ The basic unix diff tool is sometimes incredibly+ unsatisfactory for this purpose, for example when text has+ been moved around, or when there are widespread whitespace+ differences.++Category: system+Cabal-Version: >=1.6++build-type: Simple++-- source-repository head+-- type: darcs+-- location: ++Executable wordsetdiff+ Main-is: Text/WordSetDiff/Main.hs+ Build-Depends: base >= 3 && < 5, directory, process, filepath, ansi-terminal,+ bytestring >= 0.9.1, containers >= 0.3+ GHC-Options: -O2 +