rzk-0.8.0: src/Rzk/Format.hs
{-|
Module : Formatter
Description : This module defines the formatter for rzk files.
The formatter is designed in a way that can be consumed both by the CLI and the
LSP server.
-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# OPTIONS_GHC -Wno-orphans #-}
module Rzk.Format (
FormattingEdit (FormattingEdit),
formatTextEdits,
format, formatDocument, formatFile, formatFileWrite,
isWellFormatted, isWellFormattedFile,
normalizeTabs,
) where
import Data.List (sort)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Language.Rzk.Syntax (resolveLayout,
tryExtractMarkdownCodeBlocks)
import Language.Rzk.Syntax.Lex (Posn (Pn), Tok (..),
TokSymbol (TokSymbol), Token (PT),
tokens)
-- | All indices are 1-based (as received from the lexer)
-- Note: LSP uses 0-based indices
data FormattingEdit = FormattingEdit Int Int Int Int T.Text
deriving (Eq, Ord, Show)
-- TODO: more patterns, e.g. for identifiers and literals
pattern Symbol :: T.Text -> Tok
pattern Symbol s <- TK (TokSymbol s _)
pattern Token :: T.Text -> Int -> Int -> Token
pattern Token s line col <- PT (Pn _ line col) (Symbol s)
-- pattern TokenSym :: String -> Int -> Int -> Token
-- pattern TokenSym s line col <- PT (Pn _ line col) (Symbol s)
pattern TokenIdent :: T.Text -> Int -> Int -> Token
pattern TokenIdent s line col <- PT (Pn _ line col) (T_VarIdentToken s)
data FormatState = FormatState
{ parensDepth :: Int -- ^ The level of parentheses nesting
, letDepth :: Int -- ^ The level of #let nesting
, definingName :: Bool -- ^ After #define, in name or assumptions (to detect the : for the type)
, lambdaArrow :: Bool -- ^ After a lambda '\', in the parameters (to leave its -> on the same line)
, eqBraceDepth :: Int -- ^ Depth inside =_{ ... }; 0 = not inside, 1 = at top level after =_{
, eqBraceOnOwnLine :: Bool -- ^ True if the current =_{ started at the beginning of its line (after spaces)
, allTokens :: [Token] -- ^ The full array of tokens after resolving the layout
}
-- | Replace every tab character with a single space.
-- Call this before 'formatTextEdits' so that the lexer and edit positions
-- are consistent (BNFC column counts are undefined when the source contains tabs).
normalizeTabs :: T.Text -> T.Text
normalizeTabs = T.replace "\t" " "
formatTextEdits :: T.Text -> [FormattingEdit]
formatTextEdits contents =
case resolveLayout True (tokens rzkBlocks) of
Left _err -> [] -- TODO: log error (in a CLI and LSP friendly way)
Right allToks -> go (initialState {allTokens = allToks}) allToks
where
initialState = FormatState { parensDepth = 0, letDepth = 0, definingName = False, lambdaArrow = False, eqBraceDepth = 0, eqBraceOnOwnLine = False, allTokens = [] }
incParensDepth s = s { parensDepth = parensDepth s + 1 }
decParensDepth s = s { parensDepth = 0 `max` (parensDepth s - 1) }
rzkBlocks = tryExtractMarkdownCodeBlocks "rzk" contents
contentLines line = T.lines rzkBlocks !! (line - 1) -- Sorry
lineTokensBefore toks line col = filter isBefore toks
where
isBefore (PT (Pn _ l c) _) = l == line && c < col
isBefore _ = False
unicodeTokens :: [(T.Text, T.Text)]
unicodeTokens =
[ ("->", "→")
, ("|->", "↦")
, ("===", "≡")
, ("<=", "≤")
, ("/\\", "∧")
, ("\\/", "∨")
, ("Sigma", "Σ")
, ("∑", "Σ")
, ("*_1", "*₁")
, ("0_2", "0₂")
, ("1_2", "1₂")
, ("*", "×")
, ("_b", "♭")
, ("_#", "♯")
, ("_op", "ᵒᵖ")
, ("flip_op", "flipᵒᵖ")
, ("unflip_op", "unflipᵒᵖ")
, ("inv_op", "invᵒᵖ")
, ("uninv_op", "uninvᵒᵖ")
]
go :: FormatState -> [Token] -> [FormattingEdit]
go _ [] = []
go s (Token "#lang" langLine langCol : Token "rzk-1" rzkLine rzkCol : tks)
-- Tab characters would break column counts (BNFC uses next multiple of 8); call 'format'
-- or pass 'normalizeTabs' output to 'formatTextEdits' so input is tab-free.
= edits ++ go s tks
where
edits = map snd $ filter fst
-- Remove extra spaces before #lang
[ (langCol > 1, FormattingEdit langLine 1 langLine langCol "")
-- Remove extra spaces between #lang and rzk-1
, (rzkLine > langLine || rzkCol > langCol + 5 + 1,
FormattingEdit langLine (langCol + 5) rzkLine rzkCol " ")
]
go s (Token "#postulate" _ _ : tks) = go (s {definingName = True}) tks
go s (Token "#define" defLine defCol : TokenIdent _name nameLine nameCol : tks)
= edits ++ go (s {definingName = True}) tks
where
edits = map snd $ filter fst
-- Remove any space before #define
[ (defCol > 1, FormattingEdit defLine 1 defLine defCol "")
-- Ensure exactly one space after #define
, (nameLine /= defLine || nameCol > defCol + 7 + 1,
FormattingEdit defLine (defCol + 7) nameLine nameCol " ")
]
-- #def is an alias for #define
go s (Token "#def" line col : tks) = go s (PT (Pn 0 line col) (TK (TokSymbol "#define" 0)):tks)
-- TODO: similarly for other commands
go s (Token "let" _ _ : tks) = go (s { letDepth = letDepth s + 1 }) tks
go s (Token "in" _ _ : tks) = go (s { letDepth = max 0 (letDepth s - 1) }) tks
-- Ensure exactly one space after the first open paren of a line
go s (Token "(" line col : tks)
| precededBySingleCharOnly && spacesAfter /= 1 && not isLastNonSpaceChar
= FormattingEdit line spaceCol line (spaceCol + spacesAfter) " "
: go (incParensDepth s) tks
-- Remove extra spaces if it's not the first open paren on a new line
| not precededBySingleCharOnly && spacesAfter > 0
= FormattingEdit line spaceCol line (spaceCol + spacesAfter) ""
: go (incParensDepth s) tks
| otherwise = go (incParensDepth s) tks
-- TODO: Split after 80 chars
where
spaceCol = col + 1
lineContent = contentLines line
precededBySingleCharOnly = all isPunctuation (lineTokensBefore (allTokens s) line col)
singleCharUnicodeTokens = filter (\(_, unicode) -> T.length unicode == 1) unicodeTokens
punctuations :: [T.Text]
punctuations = concat
[ map fst singleCharUnicodeTokens -- ASCII sequences will be converted soon
, map snd singleCharUnicodeTokens
, ["(", ":", ",", "="]
]
isPunctuation (Token tk _ _) = tk `elem` punctuations
isPunctuation _ = False
spacesAfter = T.length $ T.takeWhile (== ' ') (T.drop col lineContent)
isLastNonSpaceChar = T.all (== ' ') (T.drop col lineContent)
-- Remove any space before the closing paren
go s (Token ")" line col : tks)
= edits ++ go (decParensDepth s) tks
where
lineContent = contentLines line
isFirstNonSpaceChar = T.all (== ' ') (T.take (col - 1) lineContent)
spacesBefore = T.length $ T.takeWhile (== ' ') (T.reverse $ T.take (col - 1) lineContent)
edits = map snd $ filter fst
[ (not isFirstNonSpaceChar && spacesBefore > 0,
FormattingEdit line (col - spacesBefore) line col "")
]
-- Enter =_{ ... } (identity type with explicit type): track depth for newline-after-} rule (style guide)
-- Only apply newline-after-} when the outermost =_{ is on its own line. Increment depth for nesting.
go s (Token "=_{" line col : tks)
= go (s { eqBraceDepth = eqBraceDepth s + 1, eqBraceOnOwnLine = if eqBraceDepth s == 0 then isOnOwnLine else eqBraceOnOwnLine s }) tks
where
lineContent = contentLines line
isOnOwnLine = T.all (== ' ') (T.take (col - 1) lineContent)
-- Count braces inside =_{ ... } so we detect the closing }
go s (Token "{" _line _col : tks)
= go (s { eqBraceDepth = if eqBraceDepth s > 0 then eqBraceDepth s + 1 else 0 }) tks
-- Newline after the closing } of =_{ ... } only when =_{ was on its own line (style guide)
go s (Token "}" line col : tks)
= eqBraceEdits ++ go s' tks
where
closingEqBrace = eqBraceDepth s == 1
onOwnLine = eqBraceOnOwnLine s
s' | eqBraceDepth s > 0 = s { eqBraceDepth = eqBraceDepth s - 1, eqBraceOnOwnLine = if eqBraceDepth s == 1 then False else eqBraceOnOwnLine s }
| otherwise = s
lineContent = contentLines line
braceEndCol = col + 1 -- 1-based column right after "}"
afterBrace = T.drop (braceEndCol - 1) lineContent
spacesAfter = T.length $ T.takeWhile (== ' ') afterBrace
isLastOnLine = T.all (== ' ') afterBrace
eqBraceEdits
| closingEqBrace && onOwnLine && not isLastOnLine
= [ FormattingEdit line braceEndCol line (braceEndCol + spacesAfter) "\n " ]
| closingEqBrace && onOwnLine && isLastOnLine
= let nextLine = if line < length (T.lines rzkBlocks) then contentLines (line + 1) else ""
spacesNextLine = T.length $ T.takeWhile (== ' ') nextLine
in if spacesNextLine /= 2 then [ FormattingEdit (line + 1) 1 (line + 1) (1 + spacesNextLine) " " ] else []
| otherwise
= []
-- line break before : (only the top-level one) and one space after
go s (Token ":" line col : tks)
| isDefinitionTypeSeparator = typeSepEdits ++ go (s {definingName = False}) tks
| otherwise = normalEdits ++ go s tks
where
isDefinitionTypeSeparator = parensDepth s == 0 && letDepth s == 0 && definingName s
lineContent = contentLines line
isFirstNonSpaceChar = T.all (== ' ') (T.take (col - 1) lineContent)
isLastNonSpaceChar = T.all (== ' ') (T.drop col lineContent)
spacesBefore = T.length $ T.takeWhile (== ' ') (T.reverse $ T.take (col - 1) lineContent)
spaceCol = col + 1
spacesAfter = T.length $ T.takeWhile (== ' ') (T.drop col lineContent)
typeSepEdits = map snd $ filter fst
-- Ensure line break before : (and remove any spaces before)
[ (not isFirstNonSpaceChar, FormattingEdit line (col - spacesBefore) line col "\n ")
-- Ensure 2 spaces before : (if already on a new line)
, (isFirstNonSpaceChar && spacesBefore /= 2, FormattingEdit line 1 line col " ")
-- Ensure 1 space after
, (not isLastNonSpaceChar && spacesAfter /= 1,
FormattingEdit line spaceCol line (spaceCol + spacesAfter) " ")
]
-- When colon is on its own line inside parentheses, align with parameter name on previous line (issue #215)
prevLine = if line > 1 then contentLines (line - 1) else ""
paramNameCol =
case T.findIndex (== '(') prevLine of
Just parenIdx ->
let afterParen = T.drop (parenIdx + 1) prevLine
spacesAfterParen = T.length (T.takeWhile (== ' ') afterParen)
in parenIdx + 2 + spacesAfterParen -- 1-based column of first char of param name
Nothing -> 2 -- fallback: 1 space (column 2)
alignSpaces = T.replicate (paramNameCol - 1) " "
normalEdits = map snd $ filter fst
-- Inside parens with colon on its own line: align with param name; else 1 space before :
[ (isFirstNonSpaceChar && parensDepth s > 0 && spacesBefore /= paramNameCol - 1,
FormattingEdit line 1 line col alignSpaces)
, (not isFirstNonSpaceChar && spacesBefore /= 1,
FormattingEdit line (col - spacesBefore) line col " ")
-- 1 space after
, (not isLastNonSpaceChar && spacesAfter /= 1,
FormattingEdit line spaceCol line (spaceCol + spacesAfter) " ")
]
-- Line break before := (only the top-level one) and one space after
go s (Token ":=" line col : tks)
| letDepth s == 0 = defEdits ++ edits ++ go s tks
| otherwise = edits ++ go s tks
where
lineContent = contentLines line
isFirstNonSpaceChar = T.all (== ' ') (T.take (col - 1) lineContent)
spacesAfter = T.length $ T.takeWhile (== ' ') (T.drop (col + 1) lineContent)
spacesBefore = T.length $ T.takeWhile (== ' ') (T.reverse $ T.take (col - 1) lineContent)
defEdits = map snd $ filter fst
-- Ensure line break before `:=` for #def
[ (not isFirstNonSpaceChar, FormattingEdit line (col - spacesBefore) line col "\n ")
-- Ensure 2 spaces before `:=` (if already on a new line)
, (isFirstNonSpaceChar && spacesBefore /= 2,
FormattingEdit line 1 line col " ")
]
edits = map snd $ filter fst
-- Ensure exactly one space after
[ (T.length lineContent > col + 2 && spacesAfter /= 1,
FormattingEdit line (col + 2) line (col + 2 + spacesAfter) " ")
]
-- One space after \
go s (Token "\\" line col : tks)
= edits ++ go (s { lambdaArrow = True }) tks
where
lineContent = contentLines line
spacesAfter = T.length $ T.takeWhile (== ' ') (T.drop col lineContent)
isLastNonSpaceChar = T.all (== ' ') (T.drop col lineContent)
spaceCol = col + 1
edits = map snd $ filter fst
[ (not isLastNonSpaceChar && spacesAfter /= 1,
FormattingEdit line spaceCol line (spaceCol + spacesAfter) " ")
]
-- Reset any state necessary after finishing a command
go s (Token ";" _ _ : tks) = go s tks
-- One space (or new line) around binary operators and replace ASCII w/ unicode
go s (Token tk line col : tks) = edits ++ go s' tks
where
s' | isArrow = s { lambdaArrow = False } -- reset flag after reaching the arrow
| otherwise = s
isArrow = tk `elem` ["->", "→"]
lineContent = contentLines line
spacesBefore = T.length $ T.takeWhile (== ' ') (T.reverse $ T.take (col - 1) lineContent)
spacesAfter = T.length $ T.takeWhile (== ' ') (T.drop (col + T.length tk - 1) lineContent)
isFirstNonSpaceChar = T.all (== ' ') (T.take (col - 1) lineContent)
isLastNonSpaceChar = T.all (== ' ') (T.drop (col + T.length tk - 1) lineContent)
prevLine
| line > 0 = contentLines (line - 1)
| otherwise = ""
nextLine
| line + 1 < length (T.lines rzkBlocks) = contentLines (line + 1)
| otherwise = ""
spacesNextLine = T.length $ T.takeWhile (== ' ') nextLine
edits = spaceEdits ++ unicodeEdits
spaceEdits
| tk `elem` ["->", "→", ",", "*", "×", "="] = concatMap snd $ filter fst
-- Ensure exactly one space before (unless first char in line, or about to move to next line)
[ (not isFirstNonSpaceChar && spacesBefore /= 1 && not isLastNonSpaceChar,
[FormattingEdit line (col - spacesBefore) line col " "])
-- Ensure exactly one space after (unless last char in line)
, (not isLastNonSpaceChar && spacesAfter /= 1,
[FormattingEdit line (col + T.length tk) line (col + T.length tk + spacesAfter) " "])
-- If last char in line, move it to next line (except for lambda arrow)
, (isLastNonSpaceChar && not (lambdaArrow s),
-- This is split into 2 edits to avoid possible overlap with unicode replacement
-- 1. Add a new line (with relevant spaces) before the token
[ FormattingEdit line (col - spacesBefore) line col $
"\n" <> T.replicate (2 `max` (spacesNextLine - (spacesNextLine `min` 2))) " "
-- 2. Replace the new line and spaces after the token with a single space
, FormattingEdit line (col + T.length tk) (line + 1) (spacesNextLine + 1) " "
])
-- If lambda -> is first char in line, move it to the previous line
, (isFirstNonSpaceChar && isArrow && lambdaArrow s,
[FormattingEdit (line - 1) (T.length prevLine + 1) line (col + T.length tk + spacesAfter) $
" " <> tk <> "\n" <> T.replicate spacesBefore " "])
]
| otherwise = []
unicodeEdits
| Just unicodeToken <- tk `lookup` unicodeTokens =
[ FormattingEdit line col line (col + T.length tk) unicodeToken
]
| otherwise = []
go s (_:tks) = go s tks
-- Adapted from https://hackage.haskell.org/package/lsp-types-2.1.0.0/docs/Language-LSP-Protocol-Types.html#g:7
applyTextEdit :: FormattingEdit -> T.Text -> T.Text
applyTextEdit (FormattingEdit sl sc el ec newText) oldText =
let (_, afterEnd) = splitAtPos (el-1, ec -1) oldText
(beforeStart, _) = splitAtPos (sl-1, sc-1) oldText
in mconcat [beforeStart, newText, afterEnd]
where
splitAtPos :: (Int, Int) -> T.Text -> (T.Text, T.Text)
splitAtPos (l, c) t = let index = c + startLineIndex l t in T.splitAt index t
startLineIndex :: Int -> T.Text -> Int
startLineIndex 0 _ = 0
startLineIndex line t' =
case T.findIndex (=='\n') t' of
Just i -> i + 1 + startLineIndex (line - 1) (T.drop (i + 1) t')
Nothing -> T.length t'
applyTextEdits :: [FormattingEdit] -> T.Text -> T.Text
applyTextEdits edits contents = foldr applyTextEdit contents (sort edits)
-- | Format Rzk code, returning the formatted version.
-- Tabs are normalized to single spaces before formatting.
format :: T.Text -> T.Text
format contents =
let normalized = normalizeTabs contents
in applyTextEdits (formatTextEdits normalized) normalized
-- | Same as 'format'. Use this when replacing the entire document (e.g. from
-- the language server), so that tab normalization and all formatting rules
-- are applied correctly instead of applying incremental edits to tabbed source.
formatDocument :: T.Text -> T.Text
formatDocument = format
-- | Format Rzk code from a file
formatFile :: FilePath -> IO T.Text
formatFile path = do
contents <- T.readFile path
return (format contents)
-- | Format the file and write the result back to the file.
formatFileWrite :: FilePath -> IO ()
formatFileWrite path = formatFile path >>= T.writeFile path
-- | Check if the given Rzk source code is well formatted.
-- This is useful for automation tasks.
-- Tabs are normalized to single spaces before checking.
isWellFormatted :: T.Text -> Bool
isWellFormatted src = null (formatTextEdits (normalizeTabs src))
-- | Same as 'isWellFormatted', but reads the source code from a file.
isWellFormattedFile :: FilePath -> IO Bool
isWellFormattedFile path = do
contents <- T.readFile path
return (isWellFormatted contents)