packages feed

wireform-proto-0.2.0.0: src/Proto/Google/Protobuf/FieldMask/Util.hs

{-# LANGUAGE ScopedTypeVariables #-}

{- | Utility functions for @google.protobuf.FieldMask@.

Provides set operations (union, intersection), path normalization,
containment checks, construction helpers, validation against a message
schema, and an @allFieldMask@ constructor — mirroring utilities found in
Go (@fieldmaskpb@), Java (@com.google.protobuf.util.FieldMaskUtil@),
and C++ (@google::protobuf::util::FieldMaskUtil@).
-}
module Proto.Google.Protobuf.FieldMask.Util (
  -- * Construction
  fromPaths,
  toPaths,
  allFieldMask,

  -- * Set operations
  union,
  intersection,
  subtractMask,

  -- * Normalization
  normalize,

  -- * Querying
  contains,
  isEmpty,

  -- * Validation
  isValid,

  -- * Path utilities
  canonicalForm,
  toCamelCase,
  toSnakeCase,
) where

import Data.Char (isUpper, toLower, toUpper)
import Data.Map.Strict qualified as Map
import Data.Proxy (Proxy)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import Proto.Google.Protobuf.FieldMask (FieldMask (..), defaultFieldMask)
import Proto.Schema (FieldDescriptor (..), ProtoMessage (..), SomeFieldDescriptor (..))


-- | Construct a 'FieldMask' from a list of paths.
fromPaths :: [Text] -> FieldMask
fromPaths ps = defaultFieldMask {fieldMaskPaths = V.fromList ps}


-- | Extract the list of paths from a 'FieldMask'.
toPaths :: FieldMask -> [Text]
toPaths = V.toList . fieldMaskPaths


-- | Construct a 'FieldMask' covering all top-level fields of a message.
allFieldMask :: forall a. ProtoMessage a => Proxy a -> FieldMask
allFieldMask p =
  let descs = protoFieldDescriptors p
      names = fmap (\(SomeField fd) -> fdName fd) (Map.elems descs)
  in defaultFieldMask {fieldMaskPaths = V.fromList names}


{- | Union of two 'FieldMask' values (all paths from both).

The result is normalized: sorted, deduplicated, and sub-paths are
removed when a parent path is present.
-}
union :: FieldMask -> FieldMask -> FieldMask
union a b =
  normalize $
    defaultFieldMask
      { fieldMaskPaths = fieldMaskPaths a V.++ fieldMaskPaths b
      }


{- | Intersection of two 'FieldMask' values.

A path is included if it appears in both masks, or if one mask contains
a parent path that covers the other's more specific path.
-}
intersection :: FieldMask -> FieldMask -> FieldMask
intersection a b =
  let setPaths fm = Set.fromList (V.toList (fieldMaskPaths fm))
      sa = setPaths a
      sb = setPaths b
      result =
        Set.filter (\p -> containedIn p sb) sa
          `Set.union` Set.filter (\p -> containedIn p sa) sb
  in defaultFieldMask {fieldMaskPaths = V.fromList (Set.toAscList result)}
  where
    containedIn path pathSet =
      Set.member path pathSet || hasAncestor path pathSet

    hasAncestor path pathSet =
      any (\candidate -> isProperPrefix candidate path) (Set.toList pathSet)


{- | Subtract one 'FieldMask' from another. The result contains paths from
the first mask that are not covered by the second.
-}
subtractMask :: FieldMask -> FieldMask -> FieldMask
subtractMask a b =
  let sb = Set.fromList (V.toList (fieldMaskPaths b))
      filtered = V.filter (\p -> not (coveredBy p sb)) (fieldMaskPaths a)
  in defaultFieldMask {fieldMaskPaths = filtered}
  where
    coveredBy path pathSet =
      Set.member path pathSet
        || any (\candidate -> isProperPrefix candidate path) (Set.toList pathSet)


{- | Normalize a 'FieldMask': sort paths, remove duplicates, and remove
paths that are sub-paths of another entry.

For example, @[\"a.b\", \"a\", \"c\"]@ normalizes to @[\"a\", \"c\"]@.
-}
normalize :: FieldMask -> FieldMask
normalize fm =
  let sorted = Set.toAscList (Set.fromList (V.toList (fieldMaskPaths fm)))
      pruned = removeRedundant sorted
  in defaultFieldMask {fieldMaskPaths = V.fromList pruned}


removeRedundant :: [Text] -> [Text]
removeRedundant = go Set.empty
  where
    go _ [] = []
    go ancestors (p : ps)
      | any (\a -> isProperPrefix a p) (Set.toList ancestors) = go ancestors ps
      | otherwise = p : go (Set.insert p ancestors) ps


isProperPrefix :: Text -> Text -> Bool
isProperPrefix prefix path =
  T.isPrefixOf prefix path
    && T.length prefix < T.length path
    && T.index path (T.length prefix) == '.'


{- | Check whether a 'FieldMask' contains a specific path, accounting for
parent-path coverage.

@contains (fromPaths [\"a\"]) \"a.b\"@ is 'True' because @\"a\"@ covers
all sub-paths.
-}
contains :: FieldMask -> Text -> Bool
contains fm path =
  V.any (\p -> p == path || isProperPrefix p path) (fieldMaskPaths fm)


-- | Is the 'FieldMask' empty (no paths)?
isEmpty :: FieldMask -> Bool
isEmpty = V.null . fieldMaskPaths


{- | Validate a 'FieldMask' against a message's schema. Each top-level
path segment must correspond to a known field name.
-}
isValid :: forall a. ProtoMessage a => Proxy a -> FieldMask -> Bool
isValid p fm =
  let descs = protoFieldDescriptors p
      validNames = Set.fromList (fmap (\(SomeField fd) -> fdName fd) (Map.elems descs))
  in V.all (\path -> topLevel path `Set.member` validNames) (fieldMaskPaths fm)
  where
    topLevel path = case T.breakOn "." path of
      (first, _) -> first


-- | Produce a canonical text form: sorted, deduplicated, sub-paths removed.
canonicalForm :: FieldMask -> Text
canonicalForm = T.intercalate "," . toPaths . normalize


{- | Convert a snake_case field path to lowerCamelCase (for JSON mapping).

@\"foo_bar.baz_qux\"@ becomes @\"fooBar.bazQux\"@.
-}
toCamelCase :: Text -> Text
toCamelCase = T.intercalate "." . fmap segmentToCamel . T.splitOn "."
  where
    segmentToCamel seg =
      let parts = T.splitOn "_" seg
      in case parts of
           [] -> ""
           (first : rest) -> first <> T.concat (fmap capitalize rest)
    capitalize t
      | T.null t = t
      | otherwise = T.cons (toUpper (T.head t)) (T.tail t)


{- | Convert a lowerCamelCase field path to snake_case (from JSON mapping).

@\"fooBar.bazQux\"@ becomes @\"foo_bar.baz_qux\"@.
-}
toSnakeCase :: Text -> Text
toSnakeCase = T.intercalate "." . fmap segmentToSnake . T.splitOn "."
  where
    segmentToSnake = T.concatMap $ \c ->
      if isUpper c
        then T.pack ['_', toLower c]
        else T.singleton c