packages feed

tilia-0.0.1.0: src/Tilia/Doc/Combinators.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

-- | The vocabulary for writing printing code.
module Tilia.Doc.Combinators
  ( -- * Documents
    Doc,

    -- * Atoms
    txt,
    space,
    breakOrSpace,
    breakOrNothing,
    hardBreak,
    blankLine,
    Resume (..),
    verbatimBreak,
    verbatim,
    emptyAnchor,

    -- * Layout
    Layout (..),
    group,
    flat,
    broken,
    variant,
    located,
    fence,
    cppChoice,

    -- * Attachment
    Placement (..),
    attach,
    hangingIfSingleLine,

    -- * Indentation
    nest,
    indent,
    align,

    -- * Combining
    hsep,
    vsep,
    sepBy,
    joinedBy,
    punctuate,

    -- * Wrapping
    ClosingIndent (..),
    bracket,
    parens,
    parensWith,
    brackets,
    bracketsWith,
    braces,
    bananaWith,
    unboxed,
    unboxedWith,
    backticks,

    -- * Punctuation
    comma,
    commaSep,
    semi,

    -- * Conditionals
    includeWhen,
    includeUnless,
  )
where

import Data.List (intersperse)
import Data.Text (Text)
import Data.Text qualified as T
import Tilia.Doc.Internal
  ( Doc (..),
    Layout (..),
    Resume (..),
    groupLayout,
  )
import Tilia.Span (Span, isSingleLine)

----------------------------------------------------------------------------
-- Atoms

-- | A literal fragment of output.
--
-- The argument must not contain a line break; use 'hardBreak'. This is for
-- keywords, punctuation and names—anything whose spelling is fixed.
txt :: Text -> Doc
txt = DText

-- | A space. Repeated spaces collapse and a space before a line break is
-- dropped.
space :: Doc
space = DSpace

-- | A place the line may break. It becomes a line break if the enclosing
-- 'group' is broken, and a space if it is flat. This is the workhorse: it
-- is what lets one printer serve both layouts.
breakOrSpace :: Doc
breakOrSpace = DBreak

-- | A place the line may break, leaving nothing behind if it does not. For
-- the positions where the two layouts differ by a break rather than by a
-- space, such as immediately inside a bracket.
breakOrNothing :: Doc
breakOrNothing = DSoftBreak

-- | A line break, whatever the enclosing group decided.
hardBreak :: Doc
hardBreak = DHardBreak

-- | An empty line.
blankLine :: Doc
blankLine = hardBreak <> hardBreak

-- | A line break between two lines of text that is being reproduced.
--
-- Only for text that is being reproduced rather than laid out: the lines of
-- a block comment, of a multi-line string literal, of a quasi-quotation.
-- Unlike every other break this one collapses nothing, because an empty line
-- among those is the author's and not spacing.
verbatimBreak :: Resume -> Doc
verbatimBreak = DVerbatimBreak

-- | Text reproduced exactly, line breaks and all.
verbatim :: Text -> Doc
verbatim = sepBy (verbatimBreak AtMargin) . map txt . T.splitOn "\n"

-- | An anchor for a construct that contains nothing.
emptyAnchor :: Span -> Doc
emptyAnchor s = located s mempty

----------------------------------------------------------------------------
-- Layout

-- | Lay the document out as the input had it: flat if the construct was
-- written on one line, broken if it was spread across several.
group :: Span -> Doc -> Doc
group s = DGroup (groupLayout (Just s))

-- | Force flat layout.
flat :: Doc -> Doc
flat = DGroup Flat

-- | Force broken layout.
broken :: Doc -> Doc
broken = DGroup Broken

-- | Choose according to the layout the enclosing 'group' settled on.
--
-- Reach for this only when the two layouts differ by more than where the
-- breaks fall; when they differ only in that, 'breakOrSpace' and
-- 'breakOrNothing' already say so and read better.
variant ::
  -- | When flat
  Doc ->
  -- | When broken
  Doc ->
  Doc
variant = DVariant

-- | Record where the output is coming from in the input.
--
-- This has no effect on layout. It is provenance, kept so that later
-- passes—comment attachment above all—can ask which region of the input a
-- piece of the document corresponds to.
located :: Span -> Doc -> Doc
located = DLocated

-- | Fence prevents comments inside from floating out and attaching to
-- elements they are not supposed to attach to.
fence :: Span -> Doc -> Doc
fence = DFence

-- | Alternatives the preprocessor chooses between.
cppChoice ::
  -- | One alternative per directive, each directive as written after its
  -- hash
  [(Text, Doc)] ->
  -- | What holds when none of them applies
  Doc ->
  Doc
cppChoice branches fallback
  | all (silent . snd) branches && silent fallback = DEmpty
  | otherwise = DCppChoice branches (if silent fallback then DEmpty else fallback)

-- | Does this document put nothing at all on the page?
silent :: Doc -> Bool
silent = \case
  DEmpty -> True
  DCat a b -> silent a && silent b
  DNest _ d -> silent d
  DAlign d -> silent d
  DGroup _ d -> silent d
  DLocated _ d -> silent d
  DFence _ d -> silent d
  DVariant flatD brokenD -> silent flatD && silent brokenD
  _ -> False

----------------------------------------------------------------------------
-- Attachment

-- | Whether a construct absorbs its own line break.
data Placement
  = -- | The preceding construct breaks and indents.
    Normal
  | -- | The construct is handed the rest of the line and breaks itself.
    Hanging
  deriving (Eq, Show)

-- | Join a body to whatever precedes it, according to its 'Placement'.
attach :: Placement -> Doc -> Doc
attach Hanging body = space <> body
attach Normal body = breakOrSpace <> indent body

-- | 'Hanging' if the span was a single line in the input, 'Normal'
-- otherwise.
--
-- A handful of constructs hang only when what comes before their own break
-- was written on one line—a lambda whose parameters ran on, for instance,
-- would leave the body indented under nothing legible. Those constructs
-- consult the input, exactly as 'group' does, and this is the shared
-- spelling of that question so that it reads as policy rather than as a
-- special case repeated in each classifier.
hangingIfSingleLine :: Span -> Placement
hangingIfSingleLine s = if isSingleLine s then Hanging else Normal

----------------------------------------------------------------------------
-- Indentation

-- | Indent by the given number of steps, relative to the current level.
nest :: Int -> Doc -> Doc
nest = DNest

-- | Indent by one step.
indent :: Doc -> Doc
indent = DNest 1

-- | Indent to the column the line has already reached, so that a broken
-- construct lines up under its own beginning rather than under the start of
-- the line.
align :: Doc -> Doc
align = DAlign

----------------------------------------------------------------------------
-- Combining

-- | Concatenate, separated by 'space'.
hsep :: [Doc] -> Doc
hsep = sepBy space

-- | Concatenate, separated by 'hardBreak'.
vsep :: [Doc] -> Doc
vsep = sepBy hardBreak

-- | Concatenate, separated by the given document.
sepBy :: Doc -> [Doc] -> Doc
sepBy s = mconcat . intersperse s

-- | The token that joins two parts of a construct: a space, the token, and
-- then the place the line may break.
joinedBy :: Text -> Doc
joinedBy t = space <> txt t <> breakOrSpace

-- | Append the separator to every element but the last.
--
-- For the cases where the separator has to travel with the element rather
-- than sit between elements, such as a trailing comma that must stay on the
-- line above a break.
punctuate :: Doc -> [Doc] -> [Doc]
punctuate _ [] = []
punctuate _ [x] = [x]
punctuate s (x : xs) = (x <> s) : punctuate s xs

----------------------------------------------------------------------------
-- Wrapping

-- | Surround with the given opening and closing documents, adding nothing
-- of its own.
enclose ::
  -- | Opening bracket
  Doc ->
  -- | Closing bracket
  Doc ->
  -- | Body
  Doc ->
  Doc
enclose open close body = open <> body <> close

-- | Where the closing bracket of a broken bracket pair goes.
data ClosingIndent
  = -- | Back out to the level the opening bracket is on.
    Outdented
  | -- | Kept one step in.
    Indented
  deriving (Eq, Show)

-- | Surround with a bracket pair that opens up when broken.
--
-- Flat, this is @open body close@ with nothing added. Broken, the opening
-- bracket keeps the first line of the body company and the rest of the body
-- lines up under it, with the closing bracket alone on the last line:
--
-- > ( first,
-- >   second
-- > )
bracket ::
  -- | Opening bracket
  Text ->
  -- | Closing bracket
  Text ->
  -- | Body
  Doc ->
  Doc
bracket = bracketWith Outdented

-- | 'bracket', with a say in where the closing bracket goes.
bracketWith ::
  -- | Where the closing bracket goes
  ClosingIndent ->
  -- | Opening bracket
  Text ->
  -- | Closing bracket
  Text ->
  -- | Body
  Doc ->
  Doc
bracketWith closing open close body =
  -- The pair is aligned as a whole so that the closing bracket comes back
  -- out to the column the opening one is on, wherever on its line that was.
  align $
    txt open
      <> variant body (space <> align body <> hardBreak)
      <> nest (closingSteps closing) (txt close)

-- | Surround with a bracket pair whose brackets are held off the body.
--
-- For the brackets that are more than one character wide—@(#@, @(|@—where
-- running the body up against them makes both harder to pick out, and where
-- an operator beginning with @#@ would lex as part of the bracket. Broken,
-- the body goes on its own indented lines.
spacedBracket ::
  -- | Where the closing bracket goes
  ClosingIndent ->
  -- | Opening bracket
  Text ->
  -- | Closing bracket
  Text ->
  -- | Body
  Doc ->
  Doc
spacedBracket closing open close body =
  align $
    txt open
      <> variant (space <> body <> space) (hardBreak <> indent body <> hardBreak)
      <> nest (closingSteps closing) (txt close)

closingSteps :: ClosingIndent -> Int
closingSteps = \case
  Outdented -> 0
  Indented -> 1

-- | @(@ and @)@.
parens :: Doc -> Doc
parens = bracket "(" ")"

-- | @(@ and @)@, with a say in where the closing bracket goes.
parensWith ::
  -- | Where the closing parenthesis goes
  ClosingIndent ->
  -- | Body
  Doc ->
  Doc
parensWith closing = bracketWith closing "(" ")"

-- | @[@ and @]@.
brackets :: Doc -> Doc
brackets = bracket "[" "]"

-- | @[@ and @]@, with a say in where the closing bracket goes.
bracketsWith ::
  -- | Where the closing bracket goes
  ClosingIndent ->
  -- | Body
  Doc ->
  Doc
bracketsWith closing = bracketWith closing "[" "]"

-- | @{@ and @}@.
braces :: Doc -> Doc
braces = bracket "{" "}"

-- | @(|@ and @|)@, from arrow notation, with a say in where the closing
-- bracket goes.
bananaWith ::
  -- | Where the closing banana goes
  ClosingIndent ->
  -- | Body
  Doc ->
  Doc
bananaWith closing = spacedBracket closing "(|" "|)"

-- | @(#@ and @#)@, for unboxed tuples and sums.
unboxed :: Doc -> Doc
unboxed = unboxedWith Outdented

-- | @(#@ and @#)@, with a say in where the closing bracket goes.
unboxedWith ::
  -- | Where the closing bracket goes
  ClosingIndent ->
  -- | Body
  Doc ->
  Doc
unboxedWith closing = spacedBracket closing "(#" "#)"

-- | Surround with backticks.
backticks :: Doc -> Doc
backticks = enclose (txt "`") (txt "`")

----------------------------------------------------------------------------
-- Punctuation

-- | @,@.
comma :: Doc
comma = txt ","

-- | @;@.
semi :: Doc
semi = txt ";"

-- | Separate by a comma and a 'breakOrSpace', so that a broken list puts each
-- element on its own line with the comma left behind on the one above.
commaSep :: [Doc] -> Doc
commaSep = sepBy (comma <> breakOrSpace)

----------------------------------------------------------------------------
-- Conditionals

-- | The document if the condition holds, nothing otherwise.
includeWhen :: Bool -> Doc -> Doc
includeWhen b d = if b then d else mempty

-- | The document unless the condition holds.
includeUnless :: Bool -> Doc -> Doc
includeUnless b = includeWhen (not b)