Skip to content

Commit 6b26526

Browse files
authored
Merge pull request #6597 from commercialhaskell/fix5925
Fix #5925 Add file-watch-hook for post-processing
2 parents 028fe53 + b721b81 commit 6b26526

File tree

9 files changed

+171
-26
lines changed

9 files changed

+171
-26
lines changed

ChangeLog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ Other enhancements:
6868
* Add the `ls globals` command to list all global packages for the version of
6969
GHC specified by the snapshot.
7070
* Add `stack -h` (equivalent to `stack --help`).
71+
* In YAML configuration files, the `file-watch-hook` key is introduced to allow
72+
`--file-watch` post-processing to be customised with a executable or `sh`
73+
shell script.
7174

7275
Bug fixes:
7376

doc/maintainers/stack_errors.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
In connection with considering Stack's support of the
66
[Haskell Error Index](https://errors.haskell.org/) initiative, this page seeks
77
to take stock of the errors that Stack itself can raise, by reference to the
8-
`master` branch of the Stack repository. Last updated: 2024-05-17.
8+
`master` branch of the Stack repository. Last updated: 2024-06-03.
99

1010
* `Stack.main`: catches exceptions from action `commandLineHandler`.
1111

@@ -403,6 +403,7 @@ to take stock of the errors that Stack itself can raise, by reference to the
403403
[S-2040] | UnableToExtractArchive Text (Path Abs File)
404404
[S-1641] | BadStackVersionException VersionRange
405405
[S-8773] | NoSuchDirectory FilePath
406+
[S-4335] | NoSuchFile FilePath
406407
[S-3938] | ParseGHCVariantException String
407408
[S-8530] | BadStackRoot (Path Abs Dir)
408409
[S-7613] | Won'tCreateStackRootInDirectoryOwnedByDifferentUser (Path Abs Dir) (Path Abs Dir)

doc/yaml_configuration.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,21 @@ Other paths added by Stack - things like the project's binary directory and the
11111111
compiler's binary directory - will take precedence over those specified here
11121112
(the automatic paths get prepended).
11131113

1114+
### file-watch-hook
1115+
1116+
:octicons-tag-24: UNRELEASED
1117+
1118+
Specifies the location of an executable or `sh` shell script to be run after
1119+
each attempted build with
1120+
[`build --file-watch`](build_command.md#-file-watch-flag). An absolute or
1121+
relative path can be specified. Changes to the configuration after the
1122+
initial `build --file-watch` command are ignored.
1123+
1124+
If the project-level configuration is provided in the `global-project` directory
1125+
in the [Stack root](stack_root.md), a relative path is assumed to be relative to
1126+
the current directory. Otherwise, it is assumed to be relative to the directory
1127+
of the project-level configuration file.
1128+
11141129
### ghc-build
11151130

11161131
[:octicons-tag-24: 1.3.0](https://github.com/commercialhaskell/stack/releases/tag/v1.3.0)
@@ -2138,3 +2153,34 @@ case $HOOK_GHC_TYPE in
21382153
;;
21392154
esac
21402155
~~~
2156+
2157+
### `--file-watch` post-processing
2158+
2159+
:octicons-tag-24: UNRELEASED
2160+
2161+
On Unix-like operating systems and Windows, Stack's `build --file-watch`
2162+
post-processing can be fully customised by specifying an executable or a `sh`
2163+
shell script (a 'hook') using the [`file-watch-hook`](#file-watch-hook)
2164+
configuration option. On Unix-like operating systems, the script file must be
2165+
made executable. A script is run by the `sh` application (which is provided by
2166+
MSYS2 on Windows).
2167+
2168+
The following environment variables are always available to the executable or script:
2169+
2170+
* `HOOK_FW_RESULT` (Equal to `""` if the build did not fail. Equal to the result
2171+
of `displayException e`, if exception `e` thown during the build.)
2172+
2173+
An example script is:
2174+
2175+
~~~sh
2176+
#!/bin/sh
2177+
2178+
set -eu
2179+
2180+
if [ -z "$HOOK_FW_RESULT" ]; then
2181+
echo "Success! Waiting for next file change."
2182+
else
2183+
echo "Build failed with exception:"
2184+
echo $HOOK_FW_RESULT
2185+
fi
2186+
~~~

src/Stack/Build.hs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,15 +112,20 @@ buildCmd opts = do
112112
prettyThrowIO GHCProfOptionInvalid
113113
local (over globalOptsL modifyGO) $
114114
case opts.fileWatch of
115-
FileWatchPoll -> fileWatchPoll (inner . Just)
116-
FileWatch -> fileWatch (inner . Just)
115+
FileWatchPoll -> withFileWatchHook fileWatchPoll
116+
FileWatch -> withFileWatchHook fileWatch
117117
NoFileWatch -> inner Nothing
118118
where
119+
withFileWatchHook fileWatchAction =
120+
-- This loads the full configuration in order to obtain the file-watch-hook
121+
-- setting. That is likely not the most efficient approach.
122+
withConfig YesReexec $ withEnvConfig NeedTargets opts $
123+
fileWatchAction (inner . Just)
119124
inner ::
120125
Maybe (Set (Path Abs File) -> IO ())
121126
-> RIO Runner ()
122127
inner setLocalFiles = withConfig YesReexec $ withEnvConfig NeedTargets opts $
123-
Stack.Build.build setLocalFiles
128+
Stack.Build.build setLocalFiles
124129
-- Read the build command from the CLI and enable it to run
125130
modifyGO =
126131
case opts.command of

src/Stack/Config.hs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ import Path.IO
7272
( XdgDirectory (..), canonicalizePath, doesFileExist
7373
, ensureDir, forgivingAbsence, getAppUserDataDir
7474
, getCurrentDir, getXdgDir, resolveDir, resolveDir'
75-
, resolveFile'
75+
, resolveFile, resolveFile'
7676
)
7777
import RIO.List ( unzip )
7878
import RIO.Process
@@ -381,6 +381,23 @@ configFromConfigMonoid
381381
-- resolveDirMaybe.
382382
`catchAny`
383383
const (throwIO (NoSuchDirectory userPath))
384+
fileWatchHook <-
385+
case getFirst configMonoid.fileWatchHook of
386+
Nothing -> pure Nothing
387+
Just userPath ->
388+
( case mproject of
389+
-- Not in a project
390+
Nothing -> Just <$> resolveFile' userPath
391+
-- Resolves to the project dir and appends the user path if it is
392+
-- relative
393+
Just (_, configYaml) ->
394+
Just <$> resolveFile (parent configYaml) userPath
395+
)
396+
-- TODO: Either catch specific exceptions or add a
397+
-- parseRelAsAbsFileMaybe utility and use it along with
398+
-- resolveFileMaybe.
399+
`catchAny`
400+
const (throwIO (NoSuchFile userPath))
384401
jobs <-
385402
case getFirst configMonoid.jobs of
386403
Nothing -> liftIO getNumProcessors
@@ -539,6 +556,7 @@ configFromConfigMonoid
539556
, compilerCheck
540557
, compilerRepository
541558
, localBin
559+
, fileWatchHook
542560
, requireStackVersion
543561
, jobs
544562
, overrideGccPath

src/Stack/FileWatch.hs

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
{-# LANGUAGE NoImplicitPrelude #-}
2-
{-# LANGUAGE OverloadedStrings #-}
1+
{-# LANGUAGE NoImplicitPrelude #-}
2+
{-# LANGUAGE OverloadedRecordDot #-}
3+
{-# LANGUAGE OverloadedStrings #-}
34

45
module Stack.FileWatch
56
( WatchMode (WatchModePoll)
@@ -11,25 +12,34 @@ import Control.Concurrent.STM ( check )
1112
import qualified Data.Map.Merge.Strict as Map
1213
import qualified Data.Map.Strict as Map
1314
import qualified Data.Set as Set
15+
import qualified Data.Text as T
1416
import GHC.IO.Exception
1517
( IOErrorType (InvalidArgument), IOException (..) )
16-
import Path ( parent )
18+
import Path ( fileExtension, parent )
19+
import Path.IO ( doesFileExist, executable, getPermissions )
20+
import RIO.Process
21+
( EnvVars, HasProcessContext (..), proc, runProcess
22+
, withModifyEnvVars
23+
)
24+
import System.Permissions ( osIsWindows )
1725
import Stack.Prelude
26+
import Stack.Types.Config ( Config (..), HasConfig (..) )
27+
import Stack.Types.Runner ( HasRunner (..), Runner (..) )
1828
import System.FSNotify
1929
( WatchConfig, WatchMode (..), confWatchMode, defaultConfig
2030
, eventPath, watchDir, withManagerConf
2131
)
2232
import System.IO ( getLine )
2333

2434
fileWatch ::
25-
HasTerm env
26-
=> ((Set (Path Abs File) -> IO ()) -> RIO env ())
35+
(HasConfig env, HasTerm env)
36+
=> ((Set (Path Abs File) -> IO ()) -> RIO Runner ())
2737
-> RIO env ()
2838
fileWatch = fileWatchConf defaultConfig
2939

3040
fileWatchPoll ::
31-
HasTerm env
32-
=> ((Set (Path Abs File) -> IO ()) -> RIO env ())
41+
(HasConfig env, HasTerm env)
42+
=> ((Set (Path Abs File) -> IO ()) -> RIO Runner ())
3343
-> RIO env ()
3444
fileWatchPoll =
3545
fileWatchConf $ defaultConfig { confWatchMode = WatchModePoll 1000000 }
@@ -39,11 +49,13 @@ fileWatchPoll =
3949
-- The action provided takes a callback that is used to set the files to be
4050
-- watched. When any of those files are changed, we rerun the action again.
4151
fileWatchConf ::
42-
HasTerm env
52+
(HasConfig env, HasTerm env)
4353
=> WatchConfig
44-
-> ((Set (Path Abs File) -> IO ()) -> RIO env ())
54+
-> ((Set (Path Abs File) -> IO ()) -> RIO Runner ())
4555
-> RIO env ()
46-
fileWatchConf cfg inner =
56+
fileWatchConf cfg inner = do
57+
runner <- view runnerL
58+
mHook <- view $ configL . to (.fileWatchHook)
4759
withRunInIO $ \run -> withManagerConf cfg $ \manager -> do
4860
allFiles <- newTVarIO Set.empty
4961
dirtyVar <- newTVarIO True
@@ -134,7 +146,7 @@ fileWatchConf cfg inner =
134146
dirty <- readTVar dirtyVar
135147
check dirty
136148

137-
eres <- tryAny $ inner setWatched
149+
eres <- tryAny $ runRIO runner (inner setWatched)
138150

139151
-- Clear dirtiness flag after the build to avoid an infinite loop caused
140152
-- by the build itself triggering dirtiness. This could be viewed as a
@@ -143,19 +155,63 @@ fileWatchConf cfg inner =
143155
-- https://github.com/commercialhaskell/stack/issues/822
144156
atomically $ writeTVar dirtyVar False
145157

146-
case eres of
147-
Left e ->
148-
case fromException e of
149-
Just ExitSuccess ->
150-
prettyInfo $ style Good $ fromString $ displayException e
151-
_ -> case fromException e :: Maybe PrettyException of
152-
Just pe -> prettyError $ pretty pe
153-
_ -> prettyInfo $ style Error $ fromString $ displayException e
154-
_ -> prettyInfo $
155-
style Good (flow "Success! Waiting for next file change.")
158+
let defaultAction = case eres of
159+
Left e ->
160+
case fromException e of
161+
Just ExitSuccess ->
162+
prettyInfo $ style Good $ fromString $ displayException e
163+
_ -> case fromException e :: Maybe PrettyException of
164+
Just pe -> prettyError $ pretty pe
165+
_ -> prettyInfo $ style Error $ fromString $ displayException e
166+
_ -> prettyInfo $
167+
style Good (flow "Success! Waiting for next file change.")
168+
169+
case mHook of
170+
Nothing -> defaultAction
171+
Just hook -> do
172+
hookIsExecutable <- handleIO (\_ -> pure False) $ if osIsWindows
173+
then
174+
-- can't really detect executable on windows, only file extension
175+
doesFileExist hook
176+
else executable <$> getPermissions hook
177+
if hookIsExecutable
178+
then runFileWatchHook eres hook
179+
else do
180+
prettyWarn $
181+
flow "File watch hook not executable. Falling back on default."
182+
defaultAction
156183

157184
prettyInfoL
158185
[ "Type"
159186
, style Shell "help"
160187
, flow "for the available commands. Press enter to force a rebuild."
161188
]
189+
190+
runFileWatchHook ::
191+
(HasProcessContext env, HasTerm env)
192+
=> Either SomeException ()
193+
-> Path Abs File
194+
-> RIO env ()
195+
runFileWatchHook buildResult hook =
196+
withModifyEnvVars insertBuildResultInEnv $ do
197+
let (cmd, args) = if osIsWindows && isShFile
198+
then ("sh", [toFilePath hook])
199+
else (toFilePath hook, [])
200+
menv <- view processContextL
201+
exit <- withProcessContext menv $ proc cmd args runProcess
202+
case exit of
203+
ExitSuccess -> pure ()
204+
ExitFailure i -> do
205+
prettyWarnL
206+
[ flow "File watch hook exited with code:"
207+
, style Error (fromString $ show i) <> "."
208+
]
209+
pure ()
210+
where
211+
insertBuildResultInEnv :: EnvVars -> EnvVars
212+
insertBuildResultInEnv = Map.insert "HOOK_FW_RESULT" $ case buildResult of
213+
Left e -> T.pack $ displayException e
214+
Right _ -> ""
215+
isShFile = case fileExtension hook of
216+
Just ".sh" -> True
217+
_ -> False

src/Stack/Types/Config.hs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ data Config = Config
110110
-- ^ Specifies the repository containing the compiler sources
111111
, localBin :: !(Path Abs Dir)
112112
-- ^ Directory we should install executables into
113+
, fileWatchHook :: !(Maybe (Path Abs File))
114+
-- ^ Optional path of executable used to override --file-watch
115+
-- post-processing.
113116
, requireStackVersion :: !VersionRange
114117
-- ^ Require a version of Stack within this range.
115118
, jobs :: !Int

src/Stack/Types/Config/Exception.hs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ data ConfigException
3333
| UnableToExtractArchive Text (Path Abs File)
3434
| BadStackVersionException VersionRange
3535
| NoSuchDirectory FilePath
36+
| NoSuchFile FilePath
3637
| ParseGHCVariantException String
3738
| BadStackRoot (Path Abs Dir)
3839
| Won'tCreateStackRootInDirectoryOwnedByDifferentUser
@@ -97,6 +98,11 @@ instance Exception ConfigException where
9798
, "No directory could be located matching the supplied path: "
9899
, dir
99100
]
101+
displayException (NoSuchFile file) = concat
102+
[ "Error: [S-4335]\n"
103+
, "No file could be located matching the supplied path: "
104+
, file
105+
]
100106
displayException (ParseGHCVariantException v) = concat
101107
[ "Error: [S-3938]\n"
102108
, "Invalid ghc-variant value: "

src/Stack/Types/ConfigMonoid.hs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ data ConfigMonoid = ConfigMonoid
119119
-- ^ See: 'configConcurrentTests'
120120
, localBinPath :: !(First FilePath)
121121
-- ^ Used to override the binary installation dir
122+
, fileWatchHook :: !(First FilePath)
123+
-- ^ Path to executable used to override --file-watch post-processing.
122124
, templateParameters :: !(Map Text Text)
123125
-- ^ Template parameters.
124126
, scmInit :: !(First SCM)
@@ -265,6 +267,7 @@ parseConfigMonoidObject rootDir obj = do
265267
hpackForce <- FirstFalse <$> obj ..:? configMonoidHpackForceName
266268
concurrentTests <- First <$> obj ..:? configMonoidConcurrentTestsName
267269
localBinPath <- First <$> obj ..:? configMonoidLocalBinPathName
270+
fileWatchHook <- First <$> obj ..:? configMonoidFileWatchHookName
268271
templates <- obj ..:? "templates"
269272
(scmInit, templateParameters) <-
270273
case templates of
@@ -373,6 +376,7 @@ parseConfigMonoidObject rootDir obj = do
373376
, hpackForce
374377
, concurrentTests
375378
, localBinPath
379+
, fileWatchHook
376380
, templateParameters
377381
, scmInit
378382
, ghcOptionsByName
@@ -495,6 +499,9 @@ configMonoidConcurrentTestsName = "concurrent-tests"
495499
configMonoidLocalBinPathName :: Text
496500
configMonoidLocalBinPathName = "local-bin-path"
497501

502+
configMonoidFileWatchHookName :: Text
503+
configMonoidFileWatchHookName = "file-watch-hook"
504+
498505
configMonoidScmInitName :: Text
499506
configMonoidScmInitName = "scm-init"
500507

0 commit comments

Comments
 (0)