Skip to content

Commit 045f414

Browse files
committed
[#55] Generate config depending on the repo type
Problem: now when we include repository type into the config, it seems to make sense to generate config differently depending on the repository type. Especially taking into account that currently in some fields we mix GitHub and GitLab -specific contents. Solution: Leave placeholders in the default config and later fill them from the code depending on the required repository type. Add a mandatory repository type parameter to `dump-config` CLI command. Along with a test checking for config validity, add a golden test on the produced config so that we could assess how sane it looks like.
1 parent aac3d5a commit 045f414

11 files changed

Lines changed: 260 additions & 47 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ xrefcheck --help
153153
Configuration template (with all options explained) can be dumped with:
154154

155155
```sh
156-
xrefcheck dump-config
156+
xrefcheck dump-config -t GitHub
157157
```
158158

159159
Currently supported options include:

exec/Main.hs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import System.Directory (doesFileExist)
1313

1414
import Xrefcheck.CLI
1515
import Xrefcheck.Config
16+
import Xrefcheck.Core
1617
import Xrefcheck.Progress
1718
import Xrefcheck.Scan
1819
import Xrefcheck.Scanners
@@ -33,8 +34,10 @@ defaultAction Options{..} = do
3334
mConfigPath <- findFirstExistingFile defaultConfigPaths
3435
case mConfigPath of
3536
Nothing -> do
36-
hPutStrLn @Text stderr "Configuration file not found, using default config\n"
37-
pure defConfig
37+
hPutStrLn @Text stderr
38+
"Configuration file not found, using default config \
39+
\for GitHub repositories\n"
40+
pure $ defConfig GitHub
3841
Just configPath ->
3942
readConfig configPath
4043
Just configPath -> do
@@ -79,5 +82,5 @@ main = withUtf8 $ do
7982
case command of
8083
DefaultCommand options ->
8184
defaultAction options
82-
DumpConfig path ->
83-
BS.writeFile path defConfigText
85+
DumpConfig repoType path ->
86+
BS.writeFile path (defConfigText repoType)

package.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ dependencies:
8383
- optparse-applicative
8484
- regex-tdfa
8585
- req
86+
- regex-tdfa
8687
- roman-numerals
8788
- template-haskell
8889
- text

src-files/def-config.yaml

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# SPDX-FileCopyrightText: 2019 Serokell <https://serokell.io>
1+
# SPDX-FileCopyrightText: 2019-2021 Serokell <https://serokell.io>
22
#
33
# SPDX-License-Identifier: Unlicense
44

@@ -26,30 +26,12 @@ verification:
2626

2727
# Prefixes of files, references in which should not be analyzed.
2828
notScanned:
29-
# GitHub-specific files
30-
- .github/pull_request_template.md
31-
- .github/issue_template.md
32-
- .github/PULL_REQUEST_TEMPLATE
33-
- .github/ISSUE_TEMPLATE
34-
35-
# GitLab-specific files
36-
- .gitlab/merge_request_templates/
37-
- .gitlab/issue_templates/
29+
- :PLACEHOLDER:notScanned:
3830

3931
# Glob patterns describing the files which do not physically exist in the
4032
# repository but should be treated as existing nevertheless.
4133
virtualFiles:
42-
# GitHub pages
43-
- ../../../issues
44-
- ../../../issues/*
45-
- ../../../pulls
46-
- ../../../pulls/*
47-
48-
# GitLab pages
49-
- ../../issues
50-
- ../../issues/*
51-
- ../../merge_requests
52-
- ../../merge_requests/*
34+
- :PLACEHOLDER:virtualFiles:
5335

5436
# POSIX extended regular expressions that match external references
5537
# that have to be ignored (not verified).
@@ -64,4 +46,4 @@ scanners:
6446
# Flavor of markdown, e.g. GitHub-flavor.
6547
#
6648
# This affects which anchors are generated for headers.
67-
flavor: GitHub
49+
flavor: :PLACEHOLDER:flavor:

src/Xrefcheck/CLI.hs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ module Xrefcheck.CLI
1717
, getCommand
1818
) where
1919

20+
import qualified Data.Char as C
2021
import qualified Data.List as L
22+
import qualified Data.Text as T
2123
import Data.Version (showVersion)
2224
import Options.Applicative
2325
(Parser, ReadM, command, eitherReader, execParser, flag', footerDoc, fullDesc, help, helper,
@@ -45,7 +47,7 @@ modeReadM = eitherReader $ \s ->
4547

4648
data Command
4749
= DefaultCommand Options
48-
| DumpConfig FilePath
50+
| DumpConfig Flavor FilePath
4951

5052
data Options = Options
5153
{ oConfigPath :: Maybe FilePath
@@ -71,6 +73,24 @@ addTraversalOptions TraversalConfig{..} (TraversalOptions ignored) =
7173
defaultConfigPaths :: [FilePath]
7274
defaultConfigPaths = ["./xrefcheck.yaml", "./.xrefcheck.yaml"]
7375

76+
-- | Strictly speaking, what config we will dump depends on the repository type:
77+
-- this affects Markdown flavor, things excluded by default, e.t.c.
78+
--
79+
-- But at the moment there is one-to-one correspondence between repository types
80+
-- and flavors, so we write a type alias here.
81+
type RepoType = Flavor
82+
83+
repoTypeReadM :: ReadM RepoType
84+
repoTypeReadM = eitherReader $ \name ->
85+
maybeToRight (failureText name) $ L.lookup (map C.toLower name) allRepoTypesNamed
86+
where
87+
allRepoTypesNamed =
88+
allRepoTypes <&> \ty -> (toString $ T.toLower (show ty), ty)
89+
failureText name =
90+
"Unknown repository type: " <> show name <> "\n\
91+
\Expected one of: " <> mconcat (intersperse ", " $ map show allRepoTypes)
92+
allRepoTypes = allFlavors
93+
7494
optionsParser :: Parser Options
7595
optionsParser = do
7696
oConfigPath <- optional . strOption $
@@ -122,13 +142,23 @@ traversalOptionsParser = do
122142
help "Files and folders which we pretend do not exist."
123143
return TraversalOptions{..}
124144

125-
dumpConfigOptions :: Parser FilePath
145+
dumpConfigOptions :: Parser Command
126146
dumpConfigOptions = hsubparser $
127147
command "dump-config" $
128148
info parser $
129149
progDesc "Dump default configuration into a file."
130150
where
131-
parser = strOption $
151+
parser = DumpConfig <$> repoTypeOption <*> outputOption
152+
153+
repoTypeOption =
154+
option repoTypeReadM $
155+
short 't' <>
156+
long "type" <>
157+
metavar "REPOSITORY TYPE" <>
158+
help "Git repository type."
159+
160+
outputOption =
161+
strOption $
132162
short 'o' <>
133163
long "output" <>
134164
metavar "FILEPATH" <>
@@ -138,7 +168,7 @@ dumpConfigOptions = hsubparser $
138168
totalParser :: Parser Command
139169
totalParser = asum
140170
[ DefaultCommand <$> optionsParser
141-
, DumpConfig <$> dumpConfigOptions
171+
, dumpConfigOptions
142172
]
143173

144174
versionOption :: Parser (a -> a)

src/Xrefcheck/Config.hs

Lines changed: 119 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,31 @@
77

88
module Xrefcheck.Config where
99

10+
import qualified Unsafe
11+
12+
import Control.Exception (assert)
1013
import Control.Lens (makeLensesWith)
1114
import Data.Aeson.TH (deriveFromJSON)
15+
import qualified Data.ByteString as BS
16+
import qualified Data.Map as Map
1217
import Data.Yaml (FromJSON (..), decodeEither', prettyPrintParseException, withText)
1318
import Instances.TH.Lift ()
1419
import Text.Regex.TDFA (CompOption (..), ExecOption (..), Regex)
15-
import Text.Regex.TDFA.Text (compile)
20+
import qualified Text.Regex.TDFA as R
21+
import Text.Regex.TDFA.ByteString ()
22+
import qualified Text.Regex.TDFA.Text as R
1623

1724
-- FIXME: Use </> from System.FilePath
1825
-- </> from Posix is used only because we cross-compile to Windows and \ doesn't work on Linux
1926
import Data.FileEmbed (embedFile)
2027
import System.FilePath.Posix ((</>))
2128
import Time (KnownRatName, Second, Time, unitsP)
2229

30+
import Xrefcheck.Core
2331
import Xrefcheck.Scan
2432
import Xrefcheck.Scanners.Markdown
2533
import Xrefcheck.System (RelGlobPattern)
26-
import Xrefcheck.Util (aesonConfigOption, postfixFields)
34+
import Xrefcheck.Util (aesonConfigOption, postfixFields, (-:))
2735

2836
-- | Overall config.
2937
data Config = Config
@@ -56,18 +64,119 @@ makeLensesWith postfixFields ''VerifyConfig
5664
-- Default config
5765
-----------------------------------------------------------
5866

67+
defConfigUnfilled :: ByteString
68+
defConfigUnfilled =
69+
$(embedFile ("src-files" </> "def-config.yaml"))
70+
71+
-- | Picks raw config with @:PLACEHOLDER:<key>:@ and fills the specified fields
72+
-- in it, picking a replacement suitable for the given key. Only strings and lists
73+
-- of strings can be filled this way.
74+
--
75+
-- This will fail if any placeholder is left unreplaced, however extra keys in
76+
-- the provided replacement won't cause any warnings or failures.
77+
fillHoles
78+
:: HasCallStack
79+
=> [(ByteString, Either ByteString [ByteString])] -> ByteString -> ByteString
80+
fillHoles allReplacements rawConfig =
81+
let holesLocs = R.getAllMatches $ holeLineRegex `R.match` rawConfig
82+
in mconcat $ replaceHoles 0 holesLocs
83+
where
84+
showBs :: ByteString -> Text
85+
showBs = show @_ @Text . decodeUtf8
86+
87+
holeLineRegex :: R.Regex
88+
holeLineRegex = R.makeRegex ("[ -]+:PLACEHOLDER:[^:]+:" :: Text)
89+
90+
replacementsMap = Map.fromList allReplacements
91+
getReplacement key =
92+
Map.lookup key replacementsMap
93+
?: error ("Replacement for key " <> showBs key <> " is not specified")
94+
95+
pickConfigSubstring :: Int -> Int -> ByteString
96+
pickConfigSubstring from len = BS.take len $ BS.drop from rawConfig
97+
98+
replaceHoles :: Int -> [(R.MatchOffset, R.MatchLength)] -> [ByteString]
99+
replaceHoles processedLen [] = one $ BS.drop processedLen rawConfig
100+
replaceHoles processedLen ((off, len) : locs) =
101+
-- in our case matches here should not overlap
102+
assert (off > processedLen) $
103+
pickConfigSubstring processedLen (off - processedLen) :
104+
replaceHole (pickConfigSubstring off len) ++
105+
replaceHoles (off + len) locs
106+
107+
holeItemRegex :: R.Regex
108+
holeItemRegex = R.makeRegex ("(^|:)([ ]*):PLACEHOLDER:([^:]+):" :: Text)
109+
110+
holeListRegex :: R.Regex
111+
holeListRegex = R.makeRegex ("^([ ]*-[ ]*):PLACEHOLDER:([^:]+):" :: Text)
112+
113+
replaceHole :: ByteString -> [ByteString]
114+
replaceHole holeLine = if
115+
| Just [_wholeMatch, _beginning, leadingSpaces, key] <-
116+
R.getAllTextSubmatches <$> (holeItemRegex `R.matchM` holeLine) ->
117+
case getReplacement key of
118+
Left replacement -> [leadingSpaces, replacement]
119+
Right _ -> error $
120+
"Key " <> showBs key <> " requires replacement with an item, \
121+
\but list was given"
122+
123+
| Just [_wholeMatch, leadingChars, key] <-
124+
R.getAllTextSubmatches <$> (holeListRegex `R.matchM` holeLine) ->
125+
case getReplacement key of
126+
Left _ -> error $
127+
"Key " <> showBs key <> " requires replacement with a list, \
128+
\but an item was given"
129+
Right [] ->
130+
["[]"]
131+
Right replacements@(_ : _) ->
132+
Unsafe.init $ do
133+
replacement <- replacements
134+
[leadingChars, replacement, "\n"]
135+
136+
| otherwise ->
137+
error $ "Unrecognized placeholder pattern " <> showBs holeLine
138+
59139
-- | Default config in textual representation.
60140
--
61141
-- Sometimes you cannot just use 'defConfig' because clarifying comments
62142
-- would be lost.
63-
defConfigText :: ByteString
64-
defConfigText =
65-
$(embedFile ("src-files" </> "def-config.yaml"))
66-
67-
defConfig :: HasCallStack => Config
68-
defConfig =
143+
defConfigText :: Flavor -> ByteString
144+
defConfigText flavor =
145+
flip fillHoles defConfigUnfilled
146+
[
147+
"flavor" -: Left (show flavor)
148+
149+
, "notScanned" -: Right $ case flavor of
150+
GitHub ->
151+
[ ".github/pull_request_template.md"
152+
, ".github/issue_template.md"
153+
, ".github/PULL_REQUEST_TEMPLATE"
154+
, ".github/ISSUE_TEMPLATE"
155+
]
156+
GitLab ->
157+
[ ".gitlab/merge_request_templates/"
158+
, ".gitlab/issue_templates/"
159+
]
160+
161+
, "virtualFiles" -: Right $ case flavor of
162+
GitHub ->
163+
[ "../../../issues"
164+
, "../../../issues/*"
165+
, "../../../pulls"
166+
, "../../../pulls/*"
167+
]
168+
GitLab ->
169+
[ "../../issues"
170+
, "../../issues/*"
171+
, "../../merge_requests"
172+
, "../../merge_requests/*"
173+
]
174+
]
175+
176+
defConfig :: HasCallStack => Flavor -> Config
177+
defConfig flavor =
69178
either (error . toText . prettyPrintParseException) id $
70-
decodeEither' defConfigText
179+
decodeEither' (defConfigText flavor)
71180

72181
-----------------------------------------------------------
73182
-- Yaml instances
@@ -84,7 +193,7 @@ instance KnownRatName unit => FromJSON (Time unit) where
84193
instance FromJSON Regex where
85194
parseJSON = withText "regex" $ \val -> do
86195
let errOrRegex =
87-
compile defaultCompOption defaultExecOption val
196+
R.compile defaultCompOption defaultExecOption val
88197
either (error . show) return errOrRegex
89198

90199
-- Default boolean values according to

src/Xrefcheck/Core.hs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ data Flavor
3838
| GitLab
3939
deriving (Show)
4040

41+
allFlavors :: [Flavor]
42+
allFlavors = [GitHub, GitLab]
43+
where
44+
_exhaustivenessCheck = \case
45+
GitHub -> ()
46+
GitLab -> ()
47+
-- if you update this, also update the list above
48+
4149
instance FromJSON Flavor where
4250
parseJSON = withText "flavor" $ \txt ->
4351
case T.toLower txt of

src/Xrefcheck/Util.hs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ module Xrefcheck.Util
99
( nameF'
1010
, paren
1111
, postfixFields
12+
, (-:)
1213
, aesonConfigOption
1314
) where
1415

@@ -33,6 +34,10 @@ paren a
3334
postfixFields :: LensRules
3435
postfixFields = lensRules & lensField .~ mappingNamer (\n -> [n ++ "L"])
3536

37+
infixr 0 -:
38+
(-:) :: a -> b -> (a, b)
39+
(-:) = (,)
40+
3641
-- | Options that we use to derive JSON instances for config types.
3742
aesonConfigOption :: Aeson.Options
3843
aesonConfigOption = aesonPrefix camelCase

0 commit comments

Comments
 (0)