diff --git a/src/Spago/Command/Build.purs b/src/Spago/Command/Build.purs index ac7ec4864..b8833fd69 100644 --- a/src/Spago/Command/Build.purs +++ b/src/Spago/Command/Build.purs @@ -115,15 +115,18 @@ run opts = do , psaCliFlags , censorLibWarnings: workspace.buildOptions.censorLibWarnings } - let - psaArgs = - { color: logOptions.color - , jsonErrors: opts.jsonErrors - , decisions: join pathDecisions - , statVerbosity: fromMaybe Psa.defaultStatVerbosity workspace.buildOptions.statVerbosity - } - Psa.psaCompile globs args psaArgs + Psa.psaCompile + { globs + , pursArgs: args + , shouldApplySuggestions: true + , psaArgs: + { color: logOptions.color + , jsonErrors: opts.jsonErrors + , decisions: join pathDecisions + , statVerbosity: fromMaybe Psa.defaultStatVerbosity workspace.buildOptions.statVerbosity + } + } case workspace.backend of Nothing -> pure unit diff --git a/src/Spago/Psa.purs b/src/Spago/Psa.purs index d173ed9b6..e307bce2b 100644 --- a/src/Spago/Psa.purs +++ b/src/Spago/Psa.purs @@ -4,7 +4,11 @@ -- To fullfil license requirements -- Copyright © Nathan Faubion -- https://opensource.org/license/mit/ -module Spago.Psa where +module Spago.Psa + ( psaCompile + , toPathDecisions + , defaultStatVerbosity + ) where import Spago.Prelude @@ -19,7 +23,7 @@ import Data.String as Str import Data.String as String import Data.Tuple as Tuple import Effect.Ref as Ref -import Foreign.Object as FO +import Foreign.Object as Object import Node.Encoding as Encoding import Node.FS.Aff as FSA import Node.Path as Path @@ -28,16 +32,24 @@ import Spago.Config (Package(..), PackageMap, WorkspacePackage) import Spago.Config as Config import Spago.Core.Config (CensorBuildWarnings(..), WarningCensorTest(..)) import Spago.Core.Config as Core -import Spago.Psa.Output (buildOutput) -import Spago.Psa.Printer (printDefaultOutputToErr, printJsonOutputToOut) +import Spago.Psa.Output as Psa.Output +import Spago.Psa.Printer as Psa.Printer +import Spago.Psa.Suggest as Suggest import Spago.Psa.Types (ErrorCode, PathDecision, PsaArgs, PsaOutputOptions, PsaPathType(..), psaResultCodec) import Spago.Purs as Purs defaultStatVerbosity :: Core.StatVerbosity defaultStatVerbosity = Core.CompactStats -psaCompile :: forall a. Set.Set FilePath -> Array String -> PsaArgs -> Spago (Purs.PursEnv a) Unit -psaCompile globs pursArgs psaArgs = do +type PsaCompileArgs = + { globs :: Set.Set FilePath + , pursArgs :: Array String + , psaArgs :: PsaArgs + , shouldApplySuggestions :: Boolean + } + +psaCompile :: forall a. PsaCompileArgs -> Spago (Purs.PursEnv a) Unit +psaCompile { globs, pursArgs, psaArgs, shouldApplySuggestions } = do result <- Purs.compile globs (Array.snoc pursArgs "--json-errors") let resultStdout = Cmd.getStdout result arrErrorsIsEmpty <- forWithIndex (Str.split (Str.Pattern "\n") resultStdout) \idx err -> @@ -51,12 +63,20 @@ psaCompile globs pursArgs psaArgs = do -- So, this shouldn't fail the build. pure true Right out -> do - files <- liftEffect $ Ref.new FO.empty - out' <- buildOutput (loadLines files) psaArgs out + files <- liftEffect $ Ref.new Object.empty + out' <- Psa.Output.buildOutput (loadLines files) psaArgs out + + liftEffect $ if psaArgs.jsonErrors then Psa.Printer.printJsonOutputToOut out' else Psa.Printer.printDefaultOutputToErr psaArgs out' - liftEffect $ if psaArgs.jsonErrors then printJsonOutputToOut out' else printDefaultOutputToErr psaArgs out' + case shouldApplySuggestions of + false -> do + suggestions <- Suggest.listSuggestions $ map _.error out'.warnings + logWarn suggestions + true -> do + logInfo "Automatically applying suggestions from the compiler..." + Suggest.applySuggestions $ map _.error out'.warnings - pure $ FO.isEmpty out'.stats.allErrors + pure $ Object.isEmpty out'.stats.allErrors if Array.all identity arrErrorsIsEmpty then do logSuccess "Build succeeded." @@ -75,13 +95,13 @@ psaCompile globs pursArgs psaArgs = do | isEmptySpan filename pos = pure Nothing | otherwise = do result <- try do - cache <- liftEffect $ FO.lookup filename <$> Ref.read files + cache <- liftEffect $ Object.lookup filename <$> Ref.read files contents <- case cache of Just lines -> pure lines Nothing -> do lines <- liftAff $ Str.split (Str.Pattern "\n") <$> FSA.readTextFile Encoding.UTF8 filename - liftEffect $ Ref.modify_ (FO.insert filename lines) files + liftEffect $ Ref.modify_ (Object.insert filename lines) files pure lines let source = Array.slice (pos.startLine - 1) (pos.endLine) contents pure $ Just source diff --git a/src/Spago/Psa/Suggest.purs b/src/Spago/Psa/Suggest.purs new file mode 100644 index 000000000..6d2ed6183 --- /dev/null +++ b/src/Spago/Psa/Suggest.purs @@ -0,0 +1,143 @@ +-- The majority of this code was lifted from +-- - https://github.com/nwolverson/purescript-suggest +-- +-- Original license is MIT: +-- Copyright © Nicholas Wolverson +-- https://opensource.org/license/mit/ +module Spago.Psa.Suggest (listSuggestions, applySuggestions) where + +import Spago.Prelude + +import Data.Array as Array +import Data.Array.NonEmpty as NEA +import Data.List (List(..)) +import Data.List as List +import Data.Map as Map +import Data.String as Str +import Data.String.Regex (regex, test, replace) as Regex +import Data.String.Regex.Flags (noFlags, global) as Regex +import Dodo as Log +import Effect.Ref as Ref +import Spago.FS as FS +import Spago.Psa.Output as Psa.Output +import Spago.Psa.Types (Position, PsaError, PsaPath(..)) +import Spago.Psa.Types as Psa.Types + +-------------------------------------------------------------------------------- +-- Entrypoints + +applySuggestions :: Array PsaError -> Spago (LogEnv _) Unit +applySuggestions warnings = do + { replacements, files } <- getSuggestions warnings + for_ replacements $ \group -> + case Array.head group of + Just { filename } -> do + logInfo $ "Applying suggestions to " <> filename + liftEffect (Ref.read files) >>= \res -> case Map.lookup filename res of + Just lines -> replaceFile lines filename group + _ -> pure unit + Nothing -> pure unit + +listSuggestions :: Array PsaError -> Spago (LogEnv _) (Array Docc) +listSuggestions warnings = do + { replacements } <- getSuggestions warnings + let totalCount = Array.length (Array.concat replacements) + pure + $ [ Log.break, toDoc $ "There are " <> show totalCount <> " warnings that could be fixed automatically:" ] + <> map (indent2 <<< toDoc) (Array.mapMaybe rep replacements) + <> [ Log.break, toDoc "Run `spago build --autofix-warnings` to apply them", Log.break ] + where + rep reps = case Array.head reps of + Just { filename } -> Just $ filename <> ": " <> show (Array.length reps) <> " replacements" + _ -> Nothing + +-------------------------------------------------------------------------------- +-- Implementation + +type Replacement = + { filename :: String + , position :: Position + , original :: String + , replacement :: String + } + +type Suggestions = { replacements :: Array (Array Replacement), files :: Ref (Map String (Array String)) } + +getSuggestions :: Array PsaError -> Spago (LogEnv _) Suggestions +getSuggestions warnings = do + files <- liftEffect $ Ref.new Map.empty + let loadLinesImpl = loadLines files + warnings' <- Array.sortBy Psa.Types.compareByLocation <$> Array.catMaybes <$> traverse (annotateError loadLinesImpl) warnings + let replacements = Array.mapMaybe getReplacement warnings' + pure { replacements: map NEA.toArray (Array.groupBy (\a b -> a.filename == b.filename) replacements), files } + where + loadLines files filename pos = do + contents <- (liftEffect $ Ref.read files) >>= \cache -> + case Map.lookup filename cache of + Just lines -> pure lines + Nothing -> do + lines <- Str.split (Str.Pattern "\n") <$> FS.readTextFile filename + liftEffect $ Ref.modify_ (Map.insert filename lines) files + pure lines + let source = Array.slice (pos.startLine - 1) (pos.endLine) contents + pure $ Just source + + getReplacement { source: Just s, error: { suggestion: Just { replacement, replaceRange }, filename: Just filename }, position: Just position } = + Just + { filename + , position: fromMaybe position replaceRange + , original: Str.joinWith "\n" s + , replacement + } + getReplacement _ = Nothing + + annotateError loadLinesF error = do + source <- fromMaybe (pure Nothing) (loadLinesF <$> error.filename <*> error.position) + pure $ Psa.Output.annotatedError <$> (Src <$> error.filename) <*> pure source <*> pure error + +replaceFile :: Array String -> String -> Array Replacement -> Spago (LogEnv _) Unit +replaceFile lines filename group = + case replaceFile' 1 1 (List.fromFoldable lines) (List.fromFoldable group) of + Left err -> logDebug err + Right outLines -> do + FS.writeTextFile filename $ List.intercalate "" outLines + logInfo $ filename <> ": Applied " <> show (Array.length group) <> " fixes" + +-- | This is where all the real work happens. +-- | Steps through the source file, outputting replacement text when the position +-- | matches otherwise the original text. Objects if replacements overlap or go past the file end. +replaceFile' :: Int -> Int -> List String -> List Replacement -> Either String (List String) +replaceFile' n _ lines reps@(Cons { position: { startLine } } _) | n < startLine && List.length lines >= startLine - n = + (withNewlines (List.take count lines) <> _) <$> replaceFile' startLine 1 (List.drop count lines) reps + where + count = startLine - n +replaceFile' n m lines (Cons { position: { startLine, startColumn, endLine, endColumn }, replacement } reps) | n == startLine = + let + initial = Str.take (startColumn - m) (fromMaybe "" $ List.head lines) + final = Str.drop (endColumn - (if startLine == endLine then m else 1)) (fromMaybe "" $ List.index lines (endLine - startLine)) + trailingNewline = either (const true) (\regex -> Regex.test regex replacement) (Regex.regex "\n\\s+$" Regex.noFlags) + addNewline = trailingNewline && (not $ Str.null final) + replace regex s text = either (const text) (\regex' -> Regex.replace regex' s text) (Regex.regex regex Regex.global) + tweak = replace "\\n(.)" ("\n" <> List.fold (Array.replicate (startColumn - 1) " ") <> "$1") + >>> replace "\\s+\\n" "\n" + >>> + Str.trim + newText = initial <> tweak replacement <> (if addNewline then "\n" else "") + replaceNewText = case newText of + "" -> identity + _ -> Cons newText + remainingLines = (List.drop (endLine - startLine + 1) lines) + in + if final == "" && newText == "" then + -- Avoid blank lines when replacing entire line(s) with blank + replaceNewText <$> replaceFile' (endLine + 1) 1 remainingLines reps + else + replaceNewText <$> replaceFile' endLine endColumn (Cons final remainingLines) reps + +replaceFile' n _ _ (Cons { position: { startLine } } _) | n > startLine = + Left $ "Found replacement starting before current position: " <> show startLine <> ", " <> show n +replaceFile' _ _ lines Nil = pure $ List.intercalate (Cons "\n" Nil) (List.singleton <$> lines) +replaceFile' _ _ _ _ = Left "Found replacement after end of file" + +withNewlines :: List String -> List String +withNewlines = List.concatMap (\x -> Cons x (Cons "\n" Nil)) diff --git a/src/Spago/Psa/Types.purs b/src/Spago/Psa/Types.purs index e09879310..fec3ab023 100644 --- a/src/Spago/Psa/Types.purs +++ b/src/Spago/Psa/Types.purs @@ -1,6 +1,6 @@ -- A majority of this code was copied from -- - https://github.com/natefaubion/purescript-psa-utils --- +-- -- To fullfil license requirements -- Copyright © Nathan Faubion -- https://opensource.org/license/mit/