Back to Index Page

TinyAPL part 8: All About Rank, and a Web Interface

#tinyapl#apl#haskell

Madeline Vergani

This article counts for two! The first part is dedicated to new primitives that deal with array rank, the second to a completely new web interface for TinyAPL.

Like always, all code is available in a GitHub repository.

Updating to GHC 9.10.1

Not much to say, just changed all instances of base ^>=4.18.0.0 to base ^>=4.20.0.0 in tinyapl.cabal.

Fixing Roll

Last article I implemented Roll and ⎕seed, but had to scrap them last minute because it didn't compile on WASM! The fix is to roll our own global state for the generator instead of the default one, which uses a function that isn't supported by WASI.

module TinyAPL.Random
  ( random
  , randomR
  , setSeed )
where

import qualified System.Random as R
import Data.Time.Clock.POSIX
import System.IO.Unsafe
import Data.IORef
import Control.Monad.IO.Class

{-# NOINLINE theGenerator #-}
theGenerator :: IORef R.StdGen
theGenerator = unsafePerformIO $ getPOSIXTime >>= newIORef . R.mkStdGen . fromEnum

withTheGenerator :: (R.Random a, MonadIO m) => (R.StdGen -> (a, R.StdGen)) -> m a
withTheGenerator f = do
  gen <- liftIO $ readIORef theGenerator
  let (a, gen') = f gen
  liftIO $ writeIORef theGenerator gen'
  pure a

random :: (R.Random a, MonadIO m) => m a
random = withTheGenerator R.random

randomR :: (R.Random a, MonadIO m) => (a, a) -> m a
randomR = withTheGenerator . R.randomR

setSeed :: MonadIO m => Int -> m ()
setSeed s = liftIO $ writeIORef theGenerator $ R.mkStdGen s

Rank, Promote, Demote, Rerank

Rank is a function that returns the rank of an array:

rank :: MonadError Error m => Array -> m Natural
rank = pure . arrayRank

rank' :: MonadError Error m => Array -> m Array
rank' arr = scalar . Number . fromInteger . toInteger <$> rank arr

Promote increases the rank of an array by 1, introducing a leading axis of length 1:

promote :: MonadError Error m => Array -> m Array
promote arr = reshape (1 : map toInteger (arrayShape arr)) arr

Demote combines two leading axes by multiplying their length, and collapses vectors to scalars:

demote :: MonadError Error m => Array -> m Array
demote arr = case toInteger <$> arrayShape arr of
  [] -> pure arr
  [_] -> pure $ scalar $ head $ arrayContents arr
  (a:b:ss) -> reshape (a * b : ss) arr

Rerank ties Promote and Demote together, promoting or demoting until an array has the specified rank:

rerank :: MonadError Error m => Natural -> Array -> m Array
rerank n arr =
  if arrayRank arr == n then pure arr
  else if arrayRank arr > n then demote arr >>= rerank n
  else promote arr >>= rerank n

rerank' :: MonadError Error m => Array -> Array -> m Array
rerank' narr arr = do
  let err = DomainError "Rerank left argument must be a scalar natural"
  n <- asScalar err narr >>= asNumber err >>= asNat err
  rerank n arr

At Rank

At Rank (which you might know from other APLs as just "Rank") is a conjunction which appies a function to cells of one or two arguments of a specified rank. Read more about it on the APL wiki.

First, parsing the right operand:

parseRank :: MonadError Error m => Array -> m (Integer, Integer, Integer)
parseRank arr = do
  let err = DomainError "Rank or depth right operand must be a 1-, 2- or 3-element integer vector"
  v <- asVector err arr >>= mapM (asNumber err >=> asInt err)
  case v of
    [d] -> pure (d, d, d)
    [d, e] -> pure (e, d, e)
    [d, e, f] -> pure (d, e, f)
    _ -> throwError err

(You see a mention of "depth", At Depth is a planned primitive that acts similarly to At Rank but with, well, depth instead of rank.)

Monadic At Rank is implemented using a recursive strategy:

  • if the rank of the argument is zero, we can't go deeper, so the function is applied to the array as a whole
  • if the requested rank is greater than or equal to the array's rank, the function is applied to the array as a whole
  • if the rank is negative one (which means "apply to major cells"), the function is — of course — applied to major cells
  • otherwise, we recurse, applying At Rank on the major cells of the array and then building it back
atRank1 :: MonadError Error m => (Array -> m Array) -> Integer -> Array -> m Array
atRank1 f rank arr
  | arrayRank arr == 0 = f arr
  | rank >= 0 && toInteger (arrayRank arr) <= rank = f arr
  | rank >= 0 = fromMajorCells <$> mapM (atRank1 f rank) (majorCells arr)
  | rank == -1 = fromMajorCells <$> mapM f (majorCells arr)
  | otherwise = fromMajorCells <$> mapM (atRank1 f $ rank + 1) (majorCells arr)

atRank1' :: MonadError Error m => (Array -> m Array) -> Array -> Array -> m Array
atRank1' f r y = do
  (a, _, _) <- parseRank r
  atRank1 f a y

Dyadic At Rank could be implemented with a similar recursive strategy, but @dzaima suggests a simpler way:

atRank2 :: MonadError Error m => (Array -> Array -> m Array) -> (Integer, Integer) -> Array -> Array -> m Array
atRank2 f (ra, rb) a b = do
  -- @dzaima (in fact, {a b c←⌽3⍴⌽⍵⍵ ⋄ ↑(⊂⍤b⊢⍺) ⍺⍺¨ ⊂⍤c⊢⍵} is an impl of the dyadic case from the monadic one (with Dyalog's ↑ meaning))
  as <- atRank1 enclose' ra a
  bs <- atRank1 enclose' rb b
  each2 f as bs >>= atRank1 first 0

atRank2' :: MonadError Error m => (Array -> Array -> m Array) -> Array -> Array -> Array -> m Array
atRank2' f r x y = do
  (_, b, c) <- parseRank r
  atRank2 f (b, c) x y

⎕ts

⎕ts is a simple nilad that returns the current Unix timestamp.

ts = Nilad (Just $ scalar . Number . realToFrac <$> liftIO getPOSIXTime) Nothing (G.quad : "ts")

Compiling to WASM + JS FFI

The tinyapl.cabal file has a new executable entry, tinyapl-js, with some config options:

executable tinyapl-js
    import:           warnings
    main-is:          Main.hs

    build-depends:    base ^>=4.20.0.0
                    , ghc-experimental ^>=0.1.0.0
                    , tinyapl

    hs-source-dirs:   js/src
    default-language: Haskell2010

    ghc-options:      -W -no-hs-main -optl-mexec-model=reactor "-optl-Wl,--export=hs_start"

This only compiles under wasm32-wasi, because it depends on the JavaScript FFI and GHC.Wasm.Prim.

Next, we need to run a post-link script. I wrote a bash file for convenience which does everything, including copying support files:

#!/bin/bash

set -e

if [[ $PWD == */js ]]; then
	echo "build.sh must be ran in the root tinyapl directory"
	exit 1
fi

rm -rf js/dist
mkdir js/dist

echo "Compiling library"

wasm32-wasi-cabal build tinyapl-js

out_path=$(find dist-newstyle -name "*-js.wasm")

echo "Compiled, found $out_path"

"$(wasm32-wasi-ghc --print-libdir)"/post-link.mjs --input "$out_path" --output js/ghc_wasm_jsffi.js

echo "Post-linked, copying files"

cp $out_path js/dist/tinyapl-js.wasm

cp js/*.js js/dist
cp js/*.html js/dist

Syntax Highlighting

One of the components of the web interface which I'll describe later is syntax highlighting, so here's a module implementing that.

First, the color categories:

data Color
  = COther
  | CSyntax
  | CNumber
  | CString
  | CStringEscape
  | CArrayName
  | CPrimArray
  | CFunctionName
  | CPrimFunction
  | CAdverbName
  | CPrimAdverb
  | CConjunctionName
  | CPrimConjunction
  | CComment
  deriving (Enum, Show)

And the parser state:

data HState = HState
  { hColors :: [Color]
  , hStr :: String }

type HSt = State HState

The actual highlighter:

highlight :: String -> [Color]
highlight str = reverse $ hColors $ execState hl (HState [] str) where
  atEnd :: HSt Bool
  atEnd = do
    str <- gets hStr
    pure $ null str

  andNotAtEnd :: HSt Bool -> HSt Bool
  andNotAtEnd p = do
    e <- atEnd
    if e then pure False else p

  peek :: HSt Char
  peek = do
    str <- gets hStr
    pure $ case str of (s:_) -> s; [] -> chr 0

  advance :: HSt Char
  advance = do
    st <- get
    let ss = hStr st
    case ss of
      [] -> pure $ chr 0
      (s:ss') -> do
        put $ st{ hStr = ss' }
        pure s

  push :: Color -> HSt ()
  push c = do
    st@HState{ hColors = cs } <- get
    put $ st{ hColors = c : cs }

  pushMany :: [Color] -> HSt ()
  pushMany = mapM_ push

Highlighting strings is just consuming all characters until the closing quote, checking for escapes:

  string :: HSt ()
  string = do
    start <- advance
    let isString = start == G.stringDelimiter
    push CString
    whileM_ (andNotAtEnd $ (/= start) <$> peek) $ do
      c <- peek
      if isString && c == G.stringEscape then do
        advance
        push CStringEscape
        advance
        push CStringEscape
      else return ()
      advance
      push CString
    advance
    push CString

Numbers are pretty much the same thing, consuming while a number character is found:

  numberChars = ['0'..'9'] ++ [G.decimal, G.negative, G.exponent, G.imaginary, G.infinity]

  number :: HSt ()
  number = whileM_ (andNotAtEnd $ (`elem` numberChars) <$> peek) $ do
    advance
    push CNumber

Comments are just like strings, except no escapes:

  comment :: HSt ()
  comment = do
    advance
    push CComment
    whileM_ (andNotAtEnd $ (/= (snd G.inlineComment)) <$> peek) $ do
      advance
      push CComment
    advance
    push CComment

Identifiers consume while identifier characters are found and then decide what type of identifier it is based on the casing and underscores:

  identifier :: HSt ()
  identifier = do
    is <- whileM (andNotAtEnd $ (`elem` identifierChars) <$> peek) advance
    if head is `elem` ['a'..'z'] ++ [G.alpha, G.omega, G.quad, G.quadQuote, G.delta] then pushMany $ const CArrayName <$> is
    else if head is `elem` ['A'..'Z'] ++ [G.alphaBar, G.omegaBar, G.deltaBar, G.del] then pushMany $ const CFunctionName <$> is
    else if head is == '_' && last is == '_' then pushMany $ const CConjunctionName <$> is
    else if head is == '_' then pushMany $ const CAdverbName <$> is
    else pushMany $ const COther <$> is

And then to tie it all together:

  hl :: HSt ()
  hl = whileM_ (not <$> atEnd) $ do
    p <- peek
    if p `elem` numberChars then number
    else if p `elem` [G.stringDelimiter, G.charDelimiter] then string
    else if p `elem` identifierChars then identifier
    else if p == fst G.inlineComment then comment
    else if p `elem` G.syntax then advance >> push CSyntax
    else if p `elem` G.arrays then advance >> push CPrimArray
    else if p `elem` G.functions then advance >> push CPrimFunction
    else if p `elem` G.adverbs then advance >> push CPrimAdverb
    else if p `elem` G.conjunctions then advance >> push CPrimConjunction
    else advance >> push COther

Abstracting I/O

When the web frontend will want to run code, output with quads would currently be redirected to the JS console, and input would presumably just fail. To fix this, the Scope in the St monad has been replaced by a wider Context:

data Context = Context
  { contextScope :: Scope
  , contextQuads :: Quads
  , contextIn :: St String
  , contextOut :: String -> St ()
  , contextErr :: String -> St () }

type St = StateT Context (ExceptT Error IO)

All code has been changed to reflect this change, including not using standard Haskell IO but calling these functions instead.

The tinyapl-js Library

We're now ready to export our functions to the JavaScript world!

The way code is ran from the JS side, because we can't pass around classes, is by storing a list of contexts on the Haskell side and referring to them by index when running code from JavaScript.

{-# NOINLINE contexts #-}
contexts :: IORef [Context]
contexts = unsafePerformIO $ newIORef []

foreign import javascript safe "return await $1();" callInput :: JSVal -> IO JSString
foreign import javascript safe "await $1($2);" callOutput :: JSVal -> JSString -> IO ()

foreign export javascript "tinyapl_newContext" newContext :: JSVal -> JSVal -> JSVal -> IO Int

newContext :: JSVal -> JSVal -> JSVal -> IO Int
newContext input output error = do
  l <- length <$> readIORef contexts
  modifyIORef contexts (++ [Context
    { contextScope = Scope [] [] [] [] Nothing
    , contextQuads = core
    , contextIn = liftToSt $ fromJSString <$> callInput input
    , contextOut = \str -> liftToSt $ callOutput output $ toJSString str
    , contextErr = \str -> liftToSt $ callOutput error $ toJSString str }])
  return l

Next, the actual function that runs the code:

runCode :: Int -> String -> IO (String, Bool)
runCode contextId code = do
  context <- (!! contextId) <$> readIORef contexts
  let file = "<tinyapl js>"
  result <- runResult $ run file code context
  case result of
    Left err -> return (show err, False)
    Right (res, context') -> do
      modifyIORef contexts (setAt contextId context')
      return (show res, True)

foreign import javascript unsafe "return [$1, $2];" jsResultPair :: JSString -> Bool -> JSVal

foreign export javascript "tinyapl_runCode" runCodeJS :: Int -> JSString -> IO JSVal

runCodeJS :: Int -> JSString -> IO JSVal
runCodeJS contextId code = do
  (r, s) <- runCode contextId $ fromJSString code
  return $ jsResultPair (toJSString r) s

The frontend also needs a list of glyphs to create the language bar:

foreign export javascript "tinyapl_glyphsSyntax" glyphsSyntaxJS :: JSVal
foreign export javascript "tinyapl_glyphsIdentifiers" glyphsIdentifiersJS :: JSVal
foreign export javascript "tinyapl_glyphsArrays" glyphsArraysJS :: JSVal
foreign export javascript "tinyapl_glyphsFunctions" glyphsFunctionsJS :: JSVal
foreign export javascript "tinyapl_glyphsAdverbs" glyphsAdverbsJS :: JSVal
foreign export javascript "tinyapl_glyphsConjunctions" glyphsConjunctionsJS :: JSVal

glyphsSyntaxJS = toJSArray $ toJSChar <$> syntax
glyphsIdentifiersJS = toJSArray $ toJSChar <$> identifiers
glyphsArraysJS = toJSArray $ toJSChar <$> arrays
glyphsFunctionsJS = toJSArray $ toJSChar <$> functions
glyphsAdverbsJS = toJSArray $ toJSChar <$> adverbs
glyphsConjunctionsJS = toJSArray $ toJSChar <$> conjunctions

Finally, highlighting:

foreign export javascript "tinyapl_highlight" jsHighlight :: JSString -> JSVal

jsHighlight :: JSString -> JSVal
jsHighlight = toJSArray . map (intToVal . fromEnum). highlight . fromJSString

foreign export javascript "tinyapl_splitString" splitString :: JSString -> JSVal

splitString :: JSString -> JSVal
splitString = toJSArray . map toJSChar . fromJSString

foreign export javascript "tinyapl_hlOther" hlOther :: Int
foreign export javascript "tinyapl_hlSyntax" hlSyntax :: Int
foreign export javascript "tinyapl_hlNumber" hlNumber :: Int
foreign export javascript "tinyapl_hlString" hlString :: Int
foreign export javascript "tinyapl_hlStringEscape" hlStringEscape :: Int
foreign export javascript "tinyapl_hlArrayName" hlArrayName :: Int
foreign export javascript "tinyapl_hlPrimArray" hlPrimArray :: Int
foreign export javascript "tinyapl_hlFunctionName" hlFunctionName :: Int
foreign export javascript "tinyapl_hlPrimFunction" hlPrimFunction :: Int
foreign export javascript "tinyapl_hlAdverbName" hlAdverbName :: Int
foreign export javascript "tinyapl_hlPrimAdverb" hlPrimAdverb :: Int
foreign export javascript "tinyapl_hlConjunctionName" hlConjunctionName :: Int
foreign export javascript "tinyapl_hlPrimConjunction" hlPrimConjunction :: Int
foreign export javascript "tinyapl_hlComment" hlComment :: Int

hlOther = fromEnum COther
hlSyntax = fromEnum CSyntax
hlNumber = fromEnum CNumber
hlString = fromEnum CString
hlStringEscape = fromEnum CStringEscape
hlArrayName = fromEnum CArrayName
hlPrimArray = fromEnum CPrimArray
hlFunctionName = fromEnum CFunctionName
hlPrimFunction = fromEnum CPrimFunction
hlAdverbName = fromEnum CAdverbName
hlPrimAdverb = fromEnum CPrimAdverb
hlConjunctionName = fromEnum CConjunctionName
hlPrimConjunction = fromEnum CPrimConjunction
hlComment = fromEnum CComment

Now, we just need to write the other side of the code, which loads the compiled WASM and FFI script:

import { WASI, OpenFile, File, ConsoleStdout } from 'https://esm.run/@bjorn3/browser_wasi_shim@0.3.0';
import ghc_wasm_jsffi from './ghc_wasm_jsffi.js';

const args = [];
const env = [];
const files = [
	new OpenFile(new File([])), // stdin
	ConsoleStdout.lineBuffered(msg => console.log(`[WASI] ${msg}`)), // stdout
	ConsoleStdout.lineBuffered(msg => console.warn(`[WASI] ${msg}`)), // stderr
];
const options = {};
const wasi = new WASI(args, env, files, options);

const instanceExports = {};
const { instance } = await WebAssembly.instantiateStreaming(fetch("./tinyapl-js.wasm"), {
	wasi_snapshot_preview1: wasi.wasiImport,
	ghc_wasm_jsffi: ghc_wasm_jsffi(instanceExports),
});
Object.assign(instanceExports, instance.exports);

wasi.initialize(instance);

The rest of the tinyapl.js file just reexports all the functions from the Haskell code, here's an example:

/**
 * Run code in a context
 * @param {number} context Context ID
 * @param {string} code
 * @returns {Promise<[string, boolean]>} A pair containing the result of the code or the error and whether running succeeded
 */
export async function runCode(context, code) {
	const [result, success] = await instance.exports.tinyapl_runCode(context, code);
	return [result, Boolean(success)];
}

The Web Interface

The actual code for the web interface is probably out of scope for this post and maybe even uninteresting. As of now, the supported features are: a language bar, input using a prefix key, syntax highlighting, running from URL search params. It's also been tied in to the docs system by moving all doc pages to a /docs subdirectory and adding /run which lists all available versions of the interpreter. You can visit https://tinyapl.rubenverg.com/run/latest for the latest stable version and https://beta.tinyapl.rubenverg.com/run/latest for a preview version. Here's a screenshot of the interface showing off some features:

The TinyAPL web interface

If you want to run exactly the version from this post, it's available at https://tinyapl.rubenverg.com/run/0.5.0.