Skip to content

Commit aac3d5a

Browse files
committed
[#55] Account for Markdown flavor type
Problem: it turned out that GitHub and GitLab render anchors for headers differently. Solution: make it possible to specify flavor in config, adjust headers conversion respectively. Added some fixes for GitHub as well. Flavor field is mandatory, so this change is breaking, but I think it's a worthy thing since otherwise other subtle bugs are possible, it should be good if users specify the flavor as soon as possible.
1 parent 52e2acc commit aac3d5a

10 files changed

Lines changed: 192 additions & 51 deletions

File tree

exec/Main.hs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ import Xrefcheck.Scanners
1919
import Xrefcheck.System
2020
import Xrefcheck.Verify
2121

22-
formats :: FormatsSupport
23-
formats = specificFormatsSupport
24-
[ markdownSupport
22+
formats :: ScannersConfig -> FormatsSupport
23+
formats ScannersConfig{..} = specificFormatsSupport
24+
[ markdownSupport scMarkdown
2525
]
2626

2727
defaultAction :: Options -> IO ()
@@ -45,7 +45,7 @@ defaultAction Options{..} = do
4545

4646
repoInfo <- allowRewrite showProgressBar $ \rw -> do
4747
let fullConfig = addTraversalOptions (cTraversal config) oTraversalOptions
48-
gatherRepoInfo rw formats fullConfig root
48+
gatherRepoInfo rw (formats $ cScanners config) fullConfig root
4949

5050
when oVerbose $
5151
fmtLn $ "=== Repository data ===\n\n" <> indentF 2 (build repoInfo)

src-files/def-config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,12 @@ verification:
5656
# It is an optional parameter, so it can be omitted.
5757
ignoreRefs:
5858
[]
59+
60+
# Parameters of scanners for various file types.
61+
scanners:
62+
63+
markdown:
64+
# Flavor of markdown, e.g. GitHub-flavor.
65+
#
66+
# This affects which anchors are generated for headers.
67+
flavor: GitHub

src/Xrefcheck/CLI.hs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ module Xrefcheck.CLI
1919

2020
import qualified Data.List as L
2121
import Data.Version (showVersion)
22-
import Options.Applicative (Parser, ReadM, command, eitherReader, execParser, flag', footerDoc, fullDesc,
23-
help, helper, hsubparser, info, infoOption, long, metavar, option, progDesc,
24-
short, strOption, switch, value)
22+
import Options.Applicative
23+
(Parser, ReadM, command, eitherReader, execParser, flag', footerDoc, fullDesc, help, helper,
24+
hsubparser, info, infoOption, long, metavar, option, progDesc, short, strOption, switch, value)
2525
import Options.Applicative.Help.Pretty (Doc, displayS, fill, fillSep, indent, renderPretty, text)
2626

2727
import Paths_xrefcheck (version)
28-
import Xrefcheck.Config
2928
import Xrefcheck.Core
29+
import Xrefcheck.Scan
3030

3131
modeReadM :: ReadM VerifyMode
3232
modeReadM = eitherReader $ \s ->

src/Xrefcheck/Config.hs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,22 @@ import Instances.TH.Lift ()
1414
import Text.Regex.TDFA (CompOption (..), ExecOption (..), Regex)
1515
import Text.Regex.TDFA.Text (compile)
1616

17-
import Data.FileEmbed (embedFile)
1817
-- FIXME: Use </> from System.FilePath
1918
-- </> from Posix is used only because we cross-compile to Windows and \ doesn't work on Linux
19+
import Data.FileEmbed (embedFile)
2020
import System.FilePath.Posix ((</>))
2121
import Time (KnownRatName, Second, Time, unitsP)
2222

23+
import Xrefcheck.Scan
24+
import Xrefcheck.Scanners.Markdown
2325
import Xrefcheck.System (RelGlobPattern)
2426
import Xrefcheck.Util (aesonConfigOption, postfixFields)
2527

2628
-- | Overall config.
2729
data Config = Config
2830
{ cTraversal :: TraversalConfig
2931
, cVerification :: VerifyConfig
30-
}
31-
32-
-- | Config of repositry traversal.
33-
data TraversalConfig = TraversalConfig
34-
{ tcIgnored :: [FilePath]
35-
-- ^ Files and folders, files in which we completely ignore.
32+
, cScanners :: ScannersConfig
3633
}
3734

3835
-- | Config of verification.
@@ -47,6 +44,11 @@ data VerifyConfig = VerifyConfig
4744
-- ^ Regular expressions that match external references we should not verify.
4845
}
4946

47+
-- | Configs for all the supported scanners.
48+
data ScannersConfig = ScannersConfig
49+
{ scMarkdown :: MarkdownConfig
50+
}
51+
5052
makeLensesWith postfixFields ''Config
5153
makeLensesWith postfixFields ''VerifyConfig
5254

@@ -72,7 +74,7 @@ defConfig =
7274
-----------------------------------------------------------
7375

7476
deriveFromJSON aesonConfigOption ''Config
75-
deriveFromJSON aesonConfigOption ''TraversalConfig
77+
deriveFromJSON aesonConfigOption ''ScannersConfig
7678
deriveFromJSON aesonConfigOption ''VerifyConfig
7779

7880
instance KnownRatName unit => FromJSON (Time unit) where

src/Xrefcheck/Core.hs

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
module Xrefcheck.Core where
1111

1212
import Control.Lens (makeLenses, (%=))
13+
import Data.Aeson (FromJSON (..), withText)
1314
import Data.Char (isAlphaNum)
1415
import qualified Data.Char as C
1516
import Data.Default (Default (..))
@@ -28,6 +29,22 @@ import Xrefcheck.Util
2829
-- Types
2930
-----------------------------------------------------------
3031

32+
-- | Markdown flavor.
33+
--
34+
-- Unfortunatelly, CMark renderers used on different sites slightly differ,
35+
-- we have to account for that.
36+
data Flavor
37+
= GitHub
38+
| GitLab
39+
deriving (Show)
40+
41+
instance FromJSON Flavor where
42+
parseJSON = withText "flavor" $ \txt ->
43+
case T.toLower txt of
44+
"github" -> pure GitHub
45+
"gitlab" -> pure GitLab
46+
_ -> fail $ "Unknown flavor " <> show txt
47+
3148
-- | Description of element position in source file.
3249
-- We keep this in text because scanners for different formats use different
3350
-- representation of this thing, and it actually appears in reports only.
@@ -210,19 +227,33 @@ shouldCheckExternal = \case
210227

211228
-- | Convert section header name to an anchor refering it.
212229
-- Conversion rules: https://docs.gitlab.com/ee/user/markdown.html#header-ids-and-links
213-
headerToAnchor :: Text -> Text
214-
headerToAnchor t = t
230+
headerToAnchor :: Flavor -> Text -> Text
231+
headerToAnchor flavor = \t -> t
215232
& T.toLower
216-
& T.replace "+" tmp
217-
& T.replace " " tmp
218-
& joinSyms tmp
219-
& T.replace (tmp <> "-") "-"
220-
& T.replace ("-" <> tmp) "-"
221-
& T.replace tmp "-"
222-
& T.filter (\c -> isAlphaNum c || c == '_' || c == '-')
233+
& mergeSpecialSymbols
223234
where
224-
joinSyms sym = T.intercalate sym . filter (not . null) . T.splitOn sym
225-
tmp = "\0"
235+
joinSubsequentChars sym = toText . go . toString
236+
where
237+
go = \case
238+
(c1 : c2 : s)
239+
| c1 == c2 && c1 == sym -> go (c1 : s)
240+
(c : s) -> c : go s
241+
[] -> []
242+
243+
mergeSpecialSymbols = case flavor of
244+
GitLab -> \t -> t
245+
& T.replace " " "-"
246+
& T.filter (\c -> isAlphaNum c || c == '_' || c == '-')
247+
& joinSubsequentChars '-'
248+
GitHub ->
249+
-- GitHub case is tricky, it can produce many hythens in a row, e.g.
250+
-- "A - B" -> "a---b"
251+
let tmp = '\0'; tmpT = T.singleton tmp
252+
in \t -> t
253+
& T.replace " " tmpT
254+
& joinSubsequentChars tmp
255+
& T.replace tmpT "-"
256+
& T.filter (\c -> isAlphaNum c || c == '_' || c == '-')
226257

227258
-- | When there are several anchors with the same name, github automatically attaches
228259
-- "-<number>" suffixes to duplications to make them referable unambiguously.

src/Xrefcheck/Scan.hs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
-- | Generalised repo scanner and analyser.
77

88
module Xrefcheck.Scan
9-
( Extension
9+
( TraversalConfig (..)
10+
, Extension
1011
, ScanAction
1112
, FormatsSupport
1213
, RepoInfo (..)
@@ -15,16 +16,24 @@ module Xrefcheck.Scan
1516
, specificFormatsSupport
1617
) where
1718

19+
import Data.Aeson.TH (deriveFromJSON)
1820
import qualified Data.Foldable as F
1921
import qualified Data.Map as M
2022
import GHC.Err (errorWithoutStackTrace)
2123
import qualified System.Directory.Tree as Tree
2224
import System.FilePath (takeDirectory, takeExtension, (</>))
2325

24-
import Xrefcheck.Config
2526
import Xrefcheck.Core
2627
import Xrefcheck.Progress
27-
import Xrefcheck.Util ()
28+
import Xrefcheck.Util (aesonConfigOption)
29+
30+
-- | Config of repositry traversal.
31+
data TraversalConfig = TraversalConfig
32+
{ tcIgnored :: [FilePath]
33+
-- ^ Files and folders, files in which we completely ignore.
34+
}
35+
36+
deriveFromJSON aesonConfigOption ''TraversalConfig
2837

2938
-- | File extension, dot included.
3039
type Extension = String

src/Xrefcheck/Scanners/Markdown.hs

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@
88
-- | Markdown documents markdownScanner.
99

1010
module Xrefcheck.Scanners.Markdown
11-
( markdownScanner
11+
( MarkdownConfig (..)
12+
, defGithubMdConfig
13+
, markdownScanner
1214
, markdownSupport
1315
, parseFileInfo
1416
) where
1517

1618
import CMarkGFM (Node (..), NodeType (..), PosInfo (..), commonmarkToNode)
1719
import Control.Lens ((%=))
1820
import Control.Monad.Trans.Except (Except, runExcept, throwE)
21+
import Data.Aeson.TH (deriveFromJSON)
1922
import qualified Data.ByteString.Lazy as BSL
2023
import Data.Char (isSpace)
2124
import Data.Default (Default (..))
@@ -25,6 +28,18 @@ import Fmt (Buildable (..), blockListF, nameF, (+|), (|+))
2528

2629
import Xrefcheck.Core
2730
import Xrefcheck.Scan
31+
import Xrefcheck.Util
32+
33+
data MarkdownConfig = MarkdownConfig
34+
{ mcFlavor :: Flavor
35+
}
36+
37+
deriveFromJSON aesonConfigOption ''MarkdownConfig
38+
39+
defGithubMdConfig :: MarkdownConfig
40+
defGithubMdConfig = MarkdownConfig
41+
{ mcFlavor = GitHub
42+
}
2843

2944
instance Buildable Node where
3045
build (Node _mpos ty subs) = nameF (show ty) $ blockListF subs
@@ -57,8 +72,8 @@ data IgnoreMode
5772
| File
5873
deriving Eq
5974

60-
nodeExtractInfo :: Node -> Except Text FileInfo
61-
nodeExtractInfo (Node _ _ docNodes) =
75+
nodeExtractInfo :: MarkdownConfig -> Node -> Except Text FileInfo
76+
nodeExtractInfo config (Node _ _ docNodes) =
6277
if checkIgnoreFile docNodes
6378
then return def
6479
else finaliseFileInfo <$> extractionResult
@@ -90,7 +105,8 @@ nodeExtractInfo (Node _ _ docNodes) =
90105
HTML_BLOCK _ -> processHtmlNode node pos nodes toIgnore
91106
HEADING lvl -> do
92107
let aType = HeaderAnchor lvl
93-
let aName = headerToAnchor $ nodeExtractText node
108+
let aName = headerToAnchor (mcFlavor config) $
109+
nodeExtractText node
94110
let aPos = toPosition pos
95111
fiAnchors %= (Anchor{..} :)
96112
loop nodes toIgnore
@@ -230,17 +246,17 @@ nodeExtractInfo (Node _ _ docNodes) =
230246
(loop nodes . pure) $ getIgnoreMode node
231247
Nothing -> loop nodes toIgnore
232248

233-
parseFileInfo :: LT.Text -> Either Text FileInfo
234-
parseFileInfo input = runExcept $ nodeExtractInfo $
249+
parseFileInfo :: MarkdownConfig -> LT.Text -> Either Text FileInfo
250+
parseFileInfo config input = runExcept $ nodeExtractInfo config $
235251
commonmarkToNode [] [] $ toStrict input
236252

237-
markdownScanner :: ScanAction
238-
markdownScanner path = do
239-
errOrInfo <- parseFileInfo . decodeUtf8 <$> BSL.readFile path
253+
markdownScanner :: MarkdownConfig -> ScanAction
254+
markdownScanner config path = do
255+
errOrInfo <- parseFileInfo config . decodeUtf8 <$> BSL.readFile path
240256
case errOrInfo of
241257
Left errTxt -> do
242258
die $ "Error when scanning " <> path <> ": " <> T.unpack errTxt
243259
Right fileInfo -> return fileInfo
244260

245-
markdownSupport :: ([Extension], ScanAction)
246-
markdownSupport = ([".md"], markdownScanner)
261+
markdownSupport :: MarkdownConfig -> ([Extension], ScanAction)
262+
markdownSupport config = ([".md"], markdownScanner config)

0 commit comments

Comments
 (0)