diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1552b0a..93a9836 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,11 @@ name: CI # Trigger the workflow on push or pull request, but only for the master branch -on: - pull_request: - push: - branches: [master] +# TODO use this trigger once deploy tested +# on: +# pull_request: +# push: +# branches: [master] jobs: cabal: @@ -23,8 +24,9 @@ jobs: - "8.4.4" - "8.6.5" - "8.8.4" - - "8.10.4" - - "9.0.1" + - "8.10.7" + - "9.0.2" + - "9.2.4" steps: - uses: actions/checkout@v2 @@ -46,11 +48,24 @@ jobs: - name: Build run: | cabal update - cabal build all --enable-tests --enable-benchmarks --write-ghc-environment-files=always --flags="buildexample" + # TODO: We have a problem where cabal is not able to come up with a + # build plan on GHC-9.2 because the ./cabal.project file defines both + # pretty-simple, and ./web as packages. ./web uses a version of jsaddle + # that doesn't seem to work yet on GHC-9.2. It doesn't seem possible + # to tell cabal to just ignore the web package, and only run the solver + # for pretty-simple. + # + # This hacky workaround just deletes the cabal.project file, so that + # cabal doesn't realize there is another package in ./web. + # + # This workaround can likely be removed when we move to a more recent + # version of jsaddle. + rm ./cabal.project + cabal build package:pretty-simple --enable-tests --enable-benchmarks --write-ghc-environment-files=always --flags="buildexample" - name: Test run: | - cabal test all --enable-tests + cabal test package:pretty-simple --enable-tests stack: name: stack / ubuntu-latest @@ -83,3 +98,15 @@ jobs: run: | stack test + nix-build-web: + name: Nix build GHCJS web + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: cachix/install-nix-action@v16 + with: + extra_nix_config: | + trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= miso-haskell.cachix.org-1:6N2DooyFlZOHUfJtAx1Q09H0P5XXYzoxxQYiwn6W1e8= + substituters = https://cache.nixos.org/ https://miso-haskell.cachix.org + - name: Build web + run: nix-build ./web diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..007fa4c --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,27 @@ +# TODO use this trigger once tested +# on: +# push: +# branches: +# - master +on: + pull_request: + +jobs: + deploy: + name: Nix build and deploy GHCJS web app + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: cachix/install-nix-action@v16 + with: + extra_nix_config: | + trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= miso-haskell.cachix.org-1:6N2DooyFlZOHUfJtAx1Q09H0P5XXYzoxxQYiwn6W1e8= haskell-pretty-simple.cachix.org-1:AWHkzPidwcDzWUIUjKcx/PYgud2OBAa9SNUEoIOsATY= + substituters = https://cache.nixos.org/ https://miso-haskell.cachix.org https://haskell-pretty-simple.cachix.org + - name: Build + run: | + nix-build ./web + cp -rL result result-no-symlinks + - name: Deploy + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: result-no-symlinks/bin/web.jsexe diff --git a/.gitignore b/.gitignore index 096abdd..e77c9d4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ cabal.sandbox.config *.hp *.eventlog .stack-work/ +web/result diff --git a/cabal.project b/cabal.project new file mode 100644 index 0000000..3288465 --- /dev/null +++ b/cabal.project @@ -0,0 +1,12 @@ +packages: + . + web + +constraints: miso +jsaddle + +-- https://github.com/ghcjs/jsaddle/pull/133 +source-repository-package + type: git + location: https://github.com/avanov/jsaddle + tag: 838a2133f15085c5b6b75bdd0647ac5033c59380 + subdir: jsaddle jsaddle-warp diff --git a/src/Text/Pretty/Simple/Internal/Printer.hs b/src/Text/Pretty/Simple/Internal/Printer.hs index 8185989..8567fe9 100644 --- a/src/Text/Pretty/Simple/Internal/Printer.hs +++ b/src/Text/Pretty/Simple/Internal/Printer.hs @@ -195,9 +195,11 @@ hCheckTTY h options = liftIO $ conv <$> tty -- suitable for passing to any /prettyprinter/ backend. -- Used by 'Simple.pString' etc. layoutString :: OutputOptions -> String -> SimpleDocStream Style -layoutString opts = - annotateStyle opts - . removeTrailingWhitespace +layoutString opts = annotateStyle opts . layoutStringAbstract opts + +layoutStringAbstract :: OutputOptions -> String -> SimpleDocStream Annotation +layoutStringAbstract opts = + removeTrailingWhitespace . layoutSmart defaultLayoutOptions {layoutPageWidth = AvailablePerLine (outputOptionsPageWidth opts) 1} . indent (outputOptionsInitialIndent opts) @@ -302,6 +304,7 @@ data Annotation | Quote | String | Num + deriving (Eq, Show) -- | Replace non-printable characters with hex escape sequences. -- diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..8861034 --- /dev/null +++ b/web/README.md @@ -0,0 +1,16 @@ + +# pretty-simple Web Page + +This directory contains an interactive web page that can be compiled with GHCJS +to show how `pretty-simple` works. + +This Haskell package can be built with GHCJS with the command `nix-build`. +You'll need Nix [installed](https://nixos.org/download.html) for this to work. +You'll also need to setup the Miso Nix cache, as explained +[here](https://github.com/cdepillabout/pretty-simple/pull/117#issuecomment-1258023974). + +This Haskell package can also be built with GHC with the command `cabal build web`. +Running this executable with `cabal run web` will start a web server listening on +`0.0.0.0:8000`. You should be able to see the web page by opening + in a web browser. _Note_ that you will need to run +`cabal run web` within this current directory. diff --git a/web/default.nix b/web/default.nix new file mode 100644 index 0000000..710a433 --- /dev/null +++ b/web/default.nix @@ -0,0 +1,40 @@ +with (import + (builtins.fetchTarball { + url = + "https://github.com/dmjio/miso/archive/refs/tags/1.8.3.tar.gz"; + sha256 = "0kcr5agbcynm003zj70yfkhsc169ahdcp9pkyr795p5mc3ykycjl"; + }) +{ }); +#TODO we can remove all these patches once we're not stuck on such old tools +# unfortunately GHCJS 8.10.7 has serious performance issues: https://github.com/dmjio/miso/pull/693 +let + hp = pkgs.haskell.packages.ghcjs86.override { + all-cabal-hashes = builtins.fetchurl { + url = + "https://github.com/commercialhaskell/all-cabal-hashes/archive/ead1bd926a1b10b04a5c07c8f15827091fa98b38.tar.gz"; + sha256 = "15i7ia241wb3s9f6l9n2bqldb4ii73xrj49rfr02q43iqbmdjddv"; + }; + }; + prettyprinter = hp.callHackage "prettyprinter" "1.7.0" { }; + prettyprinter-ansi-terminal = + hp.callHackage "prettyprinter-ansi-terminal" "1.1.2" { + prettyprinter = prettyprinter; + }; + app = hp.callCabal2nix "web" ./. { + prettyprinter = prettyprinter; + pretty-simple = hp.callCabal2nix "pretty-simple" ./.. { + prettyprinter = prettyprinter; + prettyprinter-ansi-terminal = prettyprinter-ansi-terminal; + }; + }; +in +pkgs.buildEnv { + name = "pretty-simple-web"; + paths = [ + app + (pkgs.runCommand "css" { } '' + mkdir -p $out/bin/web.jsexe + cp ${./style.css} $out/bin/web.jsexe/style.css + '') + ]; +} diff --git a/web/src/Main.hs b/web/src/Main.hs new file mode 100644 index 0000000..7eebaee --- /dev/null +++ b/web/src/Main.hs @@ -0,0 +1,222 @@ +{-# LANGUAGE CPP #-} + +module Main (main) where + +import Miso hiding (go, set) + +#ifndef __GHCJS__ +import Language.Javascript.JSaddle.Warp as JSaddle +import qualified Network.Wai.Handler.Warp as Warp +import qualified Network.Wai as Wai +import qualified Network.Wai.Application.Static as Wai +import Network.WebSockets (defaultConnectionOptions) +#endif + +import Control.Monad.State (evalState, gets, modify) +import Data.Generics.Labels () +import qualified Data.Map.Strict as Map +import qualified Data.Text as T +import GHC.Generics (Generic) +import Lens.Micro (over, set) +import Miso.String (MisoString, fromMisoString, ms, toLower) +import qualified Miso.String as Miso +import Prettyprinter.Render.Util.SimpleDocTree (SimpleDocTree (..), treeForm) +import Text.Pretty.Simple (OutputOptions, StringOutputStyle (..), defaultOutputOptionsNoColor) +import Text.Pretty.Simple.Internal (Annotation (..), layoutStringAbstract) + +#ifndef __GHCJS__ +runApp :: JSM () -> IO () +runApp f = do + putStrLn "Web server running on 0.0.0.0:8000..." + Warp.runSettings (Warp.setPort 8000 $ Warp.setTimeout 3600 Warp.defaultSettings) + =<< JSaddle.jsaddleOr defaultConnectionOptions (f >> syncPoint) app + where + app :: Wai.Application + app req = + case Wai.pathInfo req of + ["style.css"] -> Wai.staticApp (Wai.defaultWebAppSettings ".") req + _ -> JSaddle.jsaddleApp req +#else +runApp :: IO () -> IO () +runApp app = app +#endif + +data Model = Model + { inputText :: MisoString + , outputOptions :: OutputOptions + } + deriving (Show, Eq, Generic) + +data Action + = NoOp + | Log MisoString + | TextEntered MisoString + | OptsChanged (OutputOptions -> OutputOptions) + +main :: IO () +main = runApp $ startApp App{..} + where + initialAction = NoOp + model = + Model + { inputText = "" + , outputOptions = defaultOutputOptionsNoColor + } + update = updateModel + view = viewModel + events = defaultEvents + subs = [] + mountPoint = Nothing -- mount at `body` + logLevel = Off + +updateModel :: Action -> Model -> Effect Action Model +updateModel = \case + NoOp -> noEff + Log t -> (<# (consoleLog t >> pure NoOp)) + TextEntered t -> noEff . set #inputText t + OptsChanged f -> noEff . over #outputOptions f + +viewModel :: Model -> View Action +viewModel m = + div_ + [class_ "root"] + [ div_ + [class_ "input"] + [ textArea [class_ "input-text"] TextEntered "" + , selectMenu + [class_ "input-choose"] + (maybe NoOp TextEntered) + Log + ( ("Use example...", Nothing) + : map + (\x -> (x, Just x)) + examples + ) + ] + , div_ + [class_ "opts"] + [ checkBox [] (setOpts #outputOptionsCompact) "Compact" + , checkBox [] (setOpts #outputOptionsCompactParens) "Compact parentheses" + , slider [] (0, 10) (setOpts #outputOptionsIndentAmount) "Indentation" + , slider [] (0, 20) (setOpts #outputOptionsInitialIndent) "Initial indent" + , slider [] (1, 240) (setOpts #outputOptionsPageWidth) "Page width" + , div_ + [] + [ text "Non-printable characters" + , selectMenu + [] + (setOpts #outputOptionsStringStyle) + Log + [ ("Escape", EscapeNonPrintable) + , ("Don't escape", DoNotEscapeNonPrintable) + , ("Literal", Literal) + ] + ] + ] + , pPrintStringHtml [class_ "output"] (outputOptions m) . fromMisoString $ inputText m + , link_ + [ rel_ "stylesheet" + , type_ "text/css" + , href_ "style.css" + ] + ] + where + setOpts l = OptsChanged . set l + +data ParensLevel + = Parens0 + | Parens1 + | Parens2 + deriving (Eq, Show, Bounded, Enum) + +-- TODO ideally, we'd reuse `layoutString`, and just map over its result, but `annotateStyle` crashes on GHCJS 8.6: +-- https://github.com/ghcjs/ghcjs/issues/794 +pPrintStringHtml :: [Attribute act] -> OutputOptions -> String -> View act +pPrintStringHtml as opts = renderHtml as . treeForm . annotateWithIndentation . layoutStringAbstract opts + where + annotateWithIndentation = + flip evalState (prev Parens0) . traverse \ann -> + (++ [Class "annotation", toClassName @Annotation ann]) <$> case ann of + Open -> modify next *> g + Close -> g <* modify prev + Comma -> g + _ -> pure [] + where + g = gets (pure . toClassName @ParensLevel) + toClassName :: Show a => a -> Class + toClassName = Class . toLower . ms . show + +examples :: [MisoString] +examples = + [ "Foo 3 \"hello\" 'a'" + , "[Foo [(),()] \"hello\" 'b']" + , "Bar {barInt = 1, barA = [10,11], barList = [Foo 1.1 \"\" 'a',Foo 2.2 \"hello\" 'b']}" + , "Baz {unBaz = [\"\\29483\",\"\\29356\",\"\\12516\\12462\"]}" + , "AST [] [Def ((3,1),(5,30)) (Id \"fact'\" \"fact'\") [] (Forall ((3,9),(3,26)) [((Id \"n\" \"n_0\"),KPromote (TyCon (Id \"Nat\" \"Nat\")))])]" + , "[(\"id\",123),(\"state\",1),(\"pass\",1),(\"tested\",100),(\"time\",12345)]" + , "2019-02-18 20:56:24.265489 UTC" + , "192.168.0.1:8000" + , "A @\"type\" 1" + , "2+2" + , "1.0e-2" + , "\"this string has non-printable characters: \\b and \\t\"" + ] + +{- Wrappers around HTML elements -} + +checkBox :: [Attribute action] -> (Bool -> action) -> MisoString -> View action +checkBox as f t = + label_ + as + [ text t + , input_ [type_ "checkbox", onChecked $ f . unChecked] + ] + where + unChecked (Checked b) = b + +slider :: [Attribute action] -> (Int, Int) -> (Int -> action) -> MisoString -> View action +slider as (min', max') f t = + label_ + as + [ text t + , input_ + [ type_ "range" + , min_ $ ms min' + , max_ $ ms max' + , onInput $ f . fromMisoString + ] + ] + +selectMenu :: [Attribute action] -> (a -> action) -> (MisoString -> action) -> [(MisoString, a)] -> View action +selectMenu as f e items = + select_ (onChange (\s -> maybe (e $ "selectMenu: unrecognised value: " <> s) f $ Map.lookup s stringToItem) : as) $ + map (option_ [] . pure . text . fst) items + where + stringToItem = Map.fromList items + +textArea :: [Attribute action] -> (MisoString -> action) -> MisoString -> View action +textArea as f t = textarea_ (onInput f : as) [text t] + +{- Util -} + +-- | Safe, wrapping around, as in 'relude' +next, prev :: (Eq a, Bounded a, Enum a) => a -> a +next e + | e == maxBound = minBound + | otherwise = succ e +prev e + | e == minBound = maxBound + | otherwise = pred e + +newtype Class = Class {unClass :: MisoString} + +renderHtml :: [Attribute action] -> SimpleDocTree [Class] -> View action +renderHtml as = + let go = \case + STEmpty -> [text ""] + STChar c -> [text $ ms $ T.singleton c] + STText _ t -> [text $ ms t] + STLine i -> [br_ [], text $ ms $ T.replicate i $ T.singleton ' '] + STAnn cs content -> [span_ [class_ $ Miso.unwords $ map unClass cs] $ go content] + STConcat contents -> foldMap go contents + in pre_ as . go diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..f2401da --- /dev/null +++ b/web/style.css @@ -0,0 +1,93 @@ +textarea, input, button, select, option { /* don't use the OS/browser style */ + font-family: inherit; + font-size: inherit; + text-align: inherit; + color: inherit; +} +input[type='checkbox'] { + height: 2rem; + width: 2rem; +} +input[type='range'] { + width: 7rem; +} +select { + padding: 0.5rem; + width: 10rem; +} + +:root { + font-size: large; + --ui-color: #2578bd; +} + +body { + background-color: #2c2f33; + accent-color: var(--ui-color); + caret-color: var(--ui-color); + font-family: Helvetica, Arial, sans-serif; +} + +.root { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; + padding: 1rem; +} + +.input { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.input-text { + height: 6rem; + width: 100%; + resize: none; +} + +.opts { + user-select: none; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 1.5rem; +} +.opts > * { + color: var(--ui-color); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + text-align: center; +} + +.output { + color: white; /* for unannotated text e.g. data constructors */ + margin-top: 0; + margin-bottom: 0; +} +.annotation { + font-weight: bold; +} +.parens0 { + color: #f15acc +} +.parens1 { + color: #fffb88; +} +.parens2 { + color: #54c7e0; +} +.quote { + color: #ffffff; +} +.string { + color: #2578bd; +} +.num { + color: #83e377; +} diff --git a/web/web.cabal b/web/web.cabal new file mode 100644 index 0000000..8beb369 --- /dev/null +++ b/web/web.cabal @@ -0,0 +1,49 @@ +cabal-version: 2.4 +name: web +version: 0.1.0.0 +category: Web +build-type: Simple + +executable web + main-is: Main.hs + hs-source-dirs: src + build-depends: + containers, + generic-lens, + microlens, + miso ^>= 1.8, + mtl, + pretty-simple, + prettyprinter ^>= 1.7, + text, + if !impl(ghcjs) + build-depends: + base, + jsaddle, + jsaddle-warp, + transformers, + wai, + wai-app-static, + warp, + websockets, + else + build-depends: + base ^>= 4.12, + ghc-options: + -Wall + ghcjs-options: + -dedupe + default-language: Haskell2010 + default-extensions: + BlockArguments + DeriveGeneric + EmptyCase + FlexibleContexts + GADTs + LambdaCase + OverloadedLabels + OverloadedStrings + RankNTypes + RecordWildCards + TupleSections + TypeApplications