summaryrefslogtreecommitdiff
path: root/lib/Properties.hs
blob: 0049d4ef760fed6bb6e19a2931edac62b6d43859 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
{-# LANGUAGE DataKinds         #-}
{-# LANGUAGE LambdaCase        #-}
{-# LANGUAGE MultiWayIf        #-}
{-# LANGUAGE NamedFieldPuns    #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards   #-}
{-# LANGUAGE TypeApplications  #-}

-- | Contains checks for custom ties of the map json
module Properties (checkMap, checkTileset, checkLayer) where


import           Control.Monad (forM_, unless, when)
import           Data.Text     (Text, intercalate, isPrefixOf)
import qualified Data.Text     as T
import qualified Data.Vector   as V
import           Tiled         (Layer (..), Object (..), Property (..),
                                PropertyValue (..), Tile (..), Tiledmap (..),
                                Tileset (..))
import           TiledAbstract (HasName (..), HasProperties (..),
                                HasTypeName (..), IsProperty (..))
import           Util          (layerIsEmpty, mkProxy, naiveEscapeHTML,
                                prettyprint, showText)

import           Badges        (Badge (Badge),
                                BadgeArea (BadgePoint, BadgeRect), BadgeToken,
                                parseToken)
import           Data.Data     (Proxy (Proxy))
import           Data.Functor  ((<&>))
import           Data.List     ((\\))
import           Data.Maybe    (fromMaybe, isJust)
import           Data.Set      (Set)
import qualified Data.Set      as S
import           GHC.TypeLits  (KnownSymbol)
import           LayerData     (Collision, layerOverlaps)
import           LintConfig    (LintConfig (..))
import           LintWriter    (LintWriter, adjust, askContext, askFileDepth,
                                complain, dependsOn, forbid, lintConfig,
                                offersBadge, offersEntrypoint, suggest, warn)
import           Paths         (PathResult (..), RelPath (..), getExtension,
                                isOldStyle, parsePath)
import           Types         (Dep (Link, Local, LocalMap, MapLink))
import           Uris          (SubstError (..), applySubst)


-- | Checks an entire map for "general" lints.
--
-- Note that it does /not/ check any tile layer/tileset properties;
-- these are handled seperately in CheckMap, since these lints go
-- into a different field of the output.
checkMap :: LintWriter Tiledmap
checkMap = do
  tiledmap <- askContext
  let layers = collectLayers tiledmap
  let unlessLayer = unlessElement layers

  -- test custom map properties
  mapM_ checkMapProperty (fromMaybe mempty $ tiledmapProperties tiledmap)

  -- can't have these with the rest of layer/tileset lints since they're
  -- not specific to any one of them
  refuseDoubledNames layers
  refuseDoubledNames (tiledmapTilesets tiledmap)
  refuseDoubledNames (getProperties tiledmap)

  -- some layers should exist
  unlessElementNamed layers "start"
    $ complain "The map must have one layer named \"start\"."
  unlessLayer (\l -> getName l == "floorLayer" && layerType l == "objectgroup")
    $ complain "The map must have one layer named \"floorLayer\" of type \"objectgroup\"."
  unlessLayer (flip containsProperty "exitUrl" . getProperties)
    $ complain "The map must contain at least one layer with the property \"exitUrl\" set."

  -- reject maps not suitable for workadventure
  unless (tiledmapOrientation tiledmap == "orthogonal")
    $ complain "The map's orientation must be set to \"orthogonal\"."
  unless (tiledmapTileheight tiledmap == 32 && tiledmapTilewidth tiledmap == 32)
    $ complain "The map's tile size must be 32 by 32 pixels."

  unlessHasProperty "mapCopyright"
    $ suggest "document the map's copyright via the \"mapCopyright\" property."

  -- TODO: this doesn't catch collisions with the default start layer!
  whenLayerCollisions layers (\(Property name _) -> name == "exitUrl" || name == "startLayer")
    $ \cols -> warn $ "collisions between entry and / or exit layers: " <> prettyprint cols

  let missingMetaInfo =
        ["mapName","mapDescription","mapLink","mapImage"]
        \\ fmap getName (getProperties tiledmap)

  unless (null missingMetaInfo)
   $ suggest $ "consider adding meta information to your map using the "
                <> prettyprint missingMetaInfo <> " properties."

  where
    -- recursively find all layers (to deal with nested group layers)
    collectLayers :: Tiledmap -> V.Vector Layer
    collectLayers tiledmap = tiledmapLayers tiledmap <>
      V.fromList (concatMap groupmembers (tiledmapLayers tiledmap))
     where groupmembers :: Layer -> [Layer]
           groupmembers layer = concatMap groupmembers layers <> layers
            where layers = fromMaybe [] $ layerLayers layer

-- | Checks a single property of a map.
checkMapProperty :: Property -> LintWriter Tiledmap
checkMapProperty p@(Property name _) = case name of
  "mapName" -> naiveEscapeProperty p
  "mapDescription" -> naiveEscapeProperty p
  "mapCopyright" -> naiveEscapeProperty p
  "mapLink" -> pure ()
  "mapImage" -> pure ()
  -- usually the linter will complain if names aren't in their
  -- "canonical" form, but allowing that here so that multiple
  -- scripts can be used by one map
  _ | T.toLower name == "script" ->
      unwrapString p $ \str ->
        unless ("https://static.rc3.world/scripts" `isPrefixOf` str)
        $ forbid "only scripts hosted on static.rc3.world are allowed."
    | otherwise
          -> complain $ "unknown map property " <> prettyprint name


-- | check an embedded tile set.
--
-- Important to collect dependency files
checkTileset ::  LintWriter Tileset
checkTileset = do
  tileset <- askContext
  -- TODO: can tilesets be non-local dependencies?
  unwrapPath (tilesetImage tileset) (dependsOn . Local)

  refuseDoubledNames (getProperties tileset)

  -- reject tilesets unsuitable for workadventure
  unless (tilesetTilewidth tileset == 32 && tilesetTileheight tileset == 32)
    $ complain "Tilesets must have tile size 32×32."

  unless (tilesetImageheight tileset < 4096 && tilesetImagewidth tileset < 4096)
    $ warn "Tilesets should not be larger than 4096x4096 pixels in total."

  when (isJust (tilesetSource tileset))
    $ complain "Tilesets must be embedded and cannot be loaded from external files."

  unlessHasProperty "tilesetCopyright"
    $ forbid "property \"tilesetCopyright\" for tilesets must be set."

  when (isJust (tilesetFileName tileset))
    $ complain "The \"filename\" property on tilesets was removed; use \"image\" instead (and perhaps a newer version of the Tiled Editor)."

  -- check individual tileset properties
  mapM_ checkTilesetProperty (fromMaybe mempty $ tilesetProperties tileset)

  -- check individual tile definitions
  mapM_ checkTile (fromMaybe mempty $ tilesetTiles tileset)
  where
    checkTilesetProperty :: Property -> LintWriter Tileset
    checkTilesetProperty p@(Property name _value) = case name of
      "copyright" -> naiveEscapeProperty p
      _           -> warn $ "unknown tileset property " <> prettyprint name

    checkTile :: Tile -> LintWriter Tileset
    checkTile tile = do
      -- TODO:  refused doubled IDs?
      mapM_ checkTileProperty (fromMaybe mempty $ tileProperties tile)
      where checkTileProperty :: Property -> LintWriter Tileset
            checkTileProperty p@(Property name _) = case name of
              "collides" -> isBool p
              _ -> warn $ "unknown tile property " <> prettyprint name
                        <> " in tile with global id "
                        <> showText (tileId tile)


-- | collect lints on a single map layer
checkLayer :: LintWriter Layer
checkLayer = do
  layer <- askContext

  refuseDoubledNames (getProperties layer)

  when (isJust (layerImage layer))
    $ complain "imagelayer are not supported."

  case layerType layer of
    "tilelayer" -> mapM_ checkTileLayerProperty (getProperties layer)
    "group" -> pure ()
    "objectgroup" -> do

      -- all objects which can't define badges, i.e. only texts
      publicObjects <- askContext <&>
        fmap (V.filter (\case {ObjectText {} -> True; _ -> False})) . layerObjects

      -- filter everything out that might define badges, but keep text
      -- objects, which workadventure apparently supports but doesn't
      -- really tell anyone about.
      adjust $ \l -> l { layerObjects = publicObjects
                       , layerProperties = Nothing }

      unless (layerName layer == "floorLayer") $
        unlessHasProperty "getBadge" $
          when (null publicObjects || publicObjects == Just mempty) $
            warn "objectgroup layer (which aren't the floor layer) \
                 \are useless if they do not contain the \"getBadge\" \
                 \property and define at least one area for this badge, \
                 \or do not contain at least one text element."

      -- individual objects can't have properties
      forM_ (fromMaybe mempty (layerObjects layer)) $ \object ->
        unless (null (objectProperties object))
          $ warn "Properties cannot be set on individual objects. For \
                 \setting badge tokens, use per-layer properties instead."

      forM_ (getProperties layer) checkObjectGroupProperty
    ty -> complain $ "unsupported layer type " <> prettyprint ty <> "."

  if layerType layer == "group"
    then when (null (layerLayers layer))
    $ warn "Empty group layers are pointless."
    else when (isJust (layerLayers layer))
    $ complain "Layer is not of type \"group\", but has sublayers."


-- | Checks a single (custom) property of an objectgroup layer
checkObjectGroupProperty :: Property -> LintWriter Layer
checkObjectGroupProperty p@(Property name _) = case name of
  "getBadge" ->
    unwrapString p $ \str ->
      unwrapBadgeToken str $ \token -> do
        layer <- askContext
        forM_ (fromMaybe (V.fromList []) $ layerObjects layer) $ \object -> do
          case object of
            ObjectPoint {..} ->
              offersBadge (Badge token (BadgePoint objectX objectY))
            ObjectRectangle {..} ->
              if isJust objectEllipse
              then offersBadge
                $ Badge token (BadgeRect objectX objectY objectWidth objectHeight)
              else complain "ellipses are not supported."
            ObjectPolygon {} -> complain "polygons are not supported."
            ObjectPolyline {} -> complain "polylines are not supported."
            ObjectText {} -> complain "cannot use texts to define badge areas."
  _ -> warn $ "unknown property " <> prettyprint name <> " for objectgroup layers"


-- | Checks a single (custom) property of a "normal" tile layer
checkTileLayerProperty :: Property -> LintWriter Layer
checkTileLayerProperty p@(Property name _value) = case name of
    "jitsiRoom" -> do
      lintConfig configAssemblyTag
        >>= setProperty "jitsiRoomAdminTag"
        . ("assembly-" <>) -- prepend "assembly-" to avoid namespace clashes
      uselessEmptyLayer
      unwrapString p $ \jitsiRoom -> do
        suggestProperty $ Property "jitsiTrigger" "onaction"

        -- prepend jitsi room names to avoid name clashes
        unless ("shared-" `isPrefixOf` jitsiRoom) $ do
          assemblyname <- lintConfig configAssemblyTag
          setProperty "jitsiRoom" (assemblyname <> "-" <> jitsiRoom)
    "jitsiTrigger" -> do
      isString p
      unlessHasProperty "jitsiTriggerMessage"
       $ suggest "set \"jitsiTriggerMessage\" to a custom message to overwrite\
                 \the default \"press SPACE to enter in jitsi meet room\"."
      requireProperty "jitsiRoom"
    "jitsiTriggerMessage" -> do
      isString p
      requireProperty "jitsiTrigger"
    "jitsiUrl" -> isForbidden
    "jitsiConfig" -> isForbidden
    "jitsiClientConfig" -> isForbidden
    "jitsiRoomAdminTag" -> isForbidden
    "jitsiInterfaceConfig" -> isForbidden
    "jitsiWidth" ->
      isIntInRange 0 100 p
    "bbbRoom" -> do
      removeProperty "bbbRoom"
      unwrapURI (Proxy @"bbb") p
        (\link -> do
            dependsOn (Link link)
            setProperty "openWebsite" link
            setProperty "silent" (BoolProp True)
            setProperty "openWebsitePolicy"
              ("fullscreen;camera;microphone;display-capture" :: Text)
        )
        (const $ complain "property \"bbbRoom\" cannot be used with local links.")
    "bbbTrigger" -> do
      removeProperty "bbbTrigger"
      requireProperty "bbbRoom"
      unwrapString p
        (setProperty "openWebsiteTrigger")
      unlessHasProperty "bbbTriggerMessage" $ do
       setProperty "openWebsiteTriggerMessage"
         ("press SPACE to enter bbb room" :: Text)
       suggest "set \"bbbTriggerMessage\" to a custom message to overwrite the\
               \default \"press SPACE to enter the bbb room\""
    "bbbTriggerMessage" -> do
      removeProperty "bbbTriggerMessage"
      requireProperty "bbbRoom"
      unwrapString p
        (setProperty "openWebsiteTriggerMessage")
    "playAudio" -> do
      uselessEmptyLayer
      unwrapURI (Proxy @"audio") p
        (dependsOn . Link)
        (dependsOn . Local)
    "audioLoop" -> do
      isBool p
      requireProperty "playAudio"
    "playAudioLoop" ->
      deprecatedUseInstead "audioLoop"
    "audioVolume" -> do
      isOrdInRange unwrapFloat 0 1 p
      requireProperty "playAudio"
    "openWebsite" -> do
      uselessEmptyLayer
      suggestProperty $ Property "openWebsiteTrigger" (StrProp "onaction")
      unwrapURI (Proxy @"website") p
        (dependsOn . Link)
        (dependsOn . Local)
    "openWebsiteTrigger" -> do
      isString p
      requireProperty "openWebsite"
      unlessHasProperty "openWebsiteTriggerMessage"
        $ suggest "set \"openWebsiteTriggerMessage\" to a custom message to\
                  \overwrite the default \"press SPACE to open Website\"."
    "openWebsiteTriggerMessage" -> do
      isString p
      requireProperty "openWebsiteTrigger"
    "openWebsitePolicy" -> isForbidden
    "openWebsiteAllowApi" -> isForbidden
    "openTab" -> do
      isString p
      requireProperty "openWebsite"
    "url" -> isForbidden
    "allowApi" -> isForbidden
    "exitUrl" -> do
      forbidEmptyLayer
      unwrapURI (Proxy @"map") p
        (dependsOn . MapLink)
        $ \path ->
          let ext = getExtension path in
          if | isOldStyle path ->
               complain "Old-Style inter-repository links (using {<placeholder>}) \
                        \cannot be used at rC3 2021; please use world:// instead \
                        \(cf. howto.rc3.world)."
             | ext == "tmx" ->
                 complain "Cannot use .tmx map format; use Tiled's json export instead."
             | ext /= "json" ->
                 complain "All exit links must link to .json files."
             | otherwise -> dependsOn . LocalMap $ path
    "exitSceneUrl" ->
      deprecatedUseInstead "exitUrl"
    "exitInstance" ->
      deprecatedUseInstead "exitUrl"
    "startLayer" -> do
      forbidEmptyLayer
      layer <- askContext
      unwrapBool p $ \case
        True  -> offersEntrypoint $ layerName layer
        False -> warn "property \"startLayer\" is useless if set to false."
    "silent" -> do
      isBool p
      uselessEmptyLayer
    "collides" ->
      unwrapBool p $ \case
        True  -> pure ()
        False -> warn "property \"collides\" set to 'false' is useless."
    "getBadge" -> complain "\"getBadge\" must be set on an \"objectgroup\" \
                           \ layer; it does not work on tile layers."
    "name" -> isUnsupported
    _ ->
        warn $ "unknown property type " <> prettyprint name
    where
      isForbidden = forbidProperty name
      requireProperty req = propertyRequiredBy req name
      isUnsupported = warn $ "property " <> name <> " is not (yet) supported by walint."
      deprecatedUseInstead instead =
        warn $ "property \"" <> name <> "\" is deprecated. Use \"" <> instead <> "\" instead."

      -- | this property can only be used on a layer that contains
      -- | at least one tile
      forbidEmptyLayer = do
        layer <- askContext
        when (layerIsEmpty layer)
          $ complain ("property " <> prettyprint name <> " should not be set on an empty layer.")

      -- | this layer is allowed, but also useless on a layer that contains no tiles
      uselessEmptyLayer = do
        layer <- askContext
        when (layerIsEmpty layer)
          $ warn ("property " <> prettyprint name <> " set on an empty layer is useless.")


-- | refuse doubled names in everything that's somehow a collection of names
refuseDoubledNames
  :: (HasName a, HasTypeName a)
  => (Foldable t, Functor t)
  => t a
  -> LintWriter b
refuseDoubledNames things = foldr folding base things (mempty,mempty)
  where
    -- this accumulates a function that complains about things it's
    -- already seen, except if they've already occured twice and then
    -- occur again …
    folding thing cont (seen, twice)
      | name `elem` seen && name `notElem` twice = do
        complain $ "cannot use " <> typeName (mkProxy thing)
                  <> " name \"" <> name <> "\" multiple times."
        cont (seen, S.insert name twice)
      | otherwise =
        cont (S.insert name seen, twice)
      where name = getName thing
    base _ = pure ()


---- General functions ----

unlessElement
  :: Foldable f
  => f a
  -> (a -> Bool)
  -> LintWriter b
  -> LintWriter b
unlessElement things op = unless (any op things)

unlessElementNamed :: (HasName a, Foldable f)
  => f a -> Text -> LintWriter b -> LintWriter b
unlessElementNamed things name =
  unlessElement things ((==) name . getName)

unlessHasProperty :: HasProperties a => Text -> LintWriter a -> LintWriter a
unlessHasProperty name linter =
  askContext >>= \ctxt ->
    unlessElementNamed (getProperties ctxt) name linter

-- | does this layer have the given property?
containsProperty :: Foldable t => t Property -> Text -> Bool
containsProperty props name = any
  (\(Property name' _) -> name' == name) props

-- | should the layers fulfilling the given predicate collide, then perform andthen.
whenLayerCollisions
  :: V.Vector Layer
  -> (Property -> Bool)
  -> (Set Collision -> LintWriter a)
  -> LintWriter a
whenLayerCollisions layers f andthen = do
  let collisions = layerOverlaps . V.filter (any f . getProperties) $ layers
  unless (null collisions)
    $ andthen collisions

----- Functions with concrete lint messages -----

-- | this property is forbidden and should not be used
forbidProperty :: Text -> LintWriter Layer
forbidProperty name = do
  forbid $ "property " <> prettyprint name <> " is disallowed."

propertyRequiredBy :: HasProperties a => Text -> Text -> LintWriter a
propertyRequiredBy req by =
  unlessHasProperty req
  $ complain $ "property " <> prettyprint req <>
               " is required by property " <> prettyprint by <> "."

-- | suggest some value for another property if that property does not
-- also already exist
suggestProperty :: Property -> LintWriter Layer
suggestProperty (Property name value) =
  unlessHasProperty name
  $ suggest $ "set property " <> prettyprint name <> " to \"" <> prettyprint value<>"\"."



---- Functions for adjusting the context -----


-- | set a property, overwriting whatever value it had previously
setProperty :: (IsProperty prop, HasProperties ctxt)
  => Text -> prop -> LintWriter ctxt
setProperty name value = adjust $ \ctxt ->
  flip adjustProperties ctxt
  $ \ps -> Just $ Property name (asProperty value) : filter sameName ps
  where sameName (Property name' _) = name /= name'

removeProperty :: HasProperties ctxt => Text -> LintWriter ctxt
removeProperty name = adjust $ \ctxt ->
  flip adjustProperties ctxt
  $ \ps -> Just $ filter (\(Property name' _) -> name' /= name) ps

naiveEscapeProperty :: HasProperties a =>  Property -> LintWriter a
naiveEscapeProperty prop@(Property name _) =
  unwrapString prop (setProperty name . naiveEscapeHTML)

---- "unwrappers" checking that a property has some type, then do something ----

-- | asserts that this property is a string, and unwraps it
unwrapString :: Property -> (Text -> LintWriter a) -> LintWriter a
unwrapString (Property name value) f = case value of
  StrProp str -> f str
  _ -> complain $ "type error: property "
               <> prettyprint name <> " should be of type string."


-- | asserts that this property is a boolean, and unwraps it
unwrapBool :: Property -> (Bool -> LintWriter a) -> LintWriter a
unwrapBool (Property name value) f = case value of
  BoolProp b -> f b
  _ -> complain $ "type error: property " <> prettyprint name
               <> " should be of type bool."

unwrapInt :: Property -> (Int -> LintWriter a) -> LintWriter a
unwrapInt (Property name value) f = case value of
  IntProp float -> f float
  _ -> complain $ "type error: property " <> prettyprint name
               <> " should be of type int."

unwrapFloat :: Property -> (Float -> LintWriter a) -> LintWriter a
unwrapFloat (Property name value) f = case value of
  FloatProp float -> f float
  _ -> complain $ "type error: property " <> prettyprint name
               <> " should be of type float."

unwrapPath :: Text -> (RelPath -> LintWriter a) -> LintWriter a
unwrapPath str f = case parsePath str of
  OkRelPath p@(Path up _ _) -> do
    depth <- askFileDepth
    if up <= depth
      then f p
      else complain $ "cannot acess paths \"" <> str <> "\" which is outside your repository."
  NotAPath -> complain $ "path \"" <> str <> "\" is invalid."
  AbsolutePath -> forbid "absolute paths are disallowed. Use world:// instead."
  UnderscoreMapLink -> forbid "map links using /_/ are disallowed. Use world:// instead."
  AtMapLink -> forbid "map links using /@/ are disallowed. Use world:// instead."

unwrapBadgeToken :: Text -> (BadgeToken -> LintWriter a) -> LintWriter a
unwrapBadgeToken str f = case parseToken str of
  Just a  -> f a
  Nothing -> complain "invalid badge token."


unwrapURI :: (KnownSymbol s, HasProperties a)
  => Proxy s -> Property -> (Text -> LintWriter a) -> (RelPath -> LintWriter a) -> LintWriter a
unwrapURI sym p@(Property name _) f g = unwrapString p $ \link -> do
  subst <- lintConfig configUriSchemas
  case applySubst sym subst link of
    Right uri -> do
      setProperty name uri
      f uri
    Left NotALink -> unwrapPath link g
    Left err -> complain $ case err of
      IsBlocked -> link <> " is a blocked site."
      InvalidLink -> link <> " is invalid."
      SchemaDoesNotExist schema ->
        "the URI schema " <> schema <> ":// does not exist."
      WrongScope schema allowed ->
        "the URI schema " <> schema <> ":// cannot be used in property \
        \\"" <> name <> "\"; allowed "
        <> (if length allowed == 1 then "is " else "are ")
        <> intercalate ", " (fmap (<> "://") allowed) <> "."

-- | just asserts that this is a string
isString :: Property -> LintWriter a
isString = flip unwrapString (const $ pure ())

-- | just asserts that this is a boolean
isBool :: Property -> LintWriter a
isBool = flip unwrapBool (const $ pure ())

isIntInRange :: Int -> Int -> Property -> LintWriter b
isIntInRange = isOrdInRange @Int unwrapInt

isOrdInRange :: (Ord a, Show a)
  => (Property -> (a -> LintWriter b) -> LintWriter b)
  -> a
  -> a
  -> Property
  -> LintWriter b
isOrdInRange unwrapa l r p@(Property name _) = unwrapa p $ \int ->
  if l < int && int < r then pure ()
  else complain $ "Property " <> prettyprint name <> " should be between "
               <> showText l <> " and " <> showText r<>"."