moonbase


    Thirteen encodings with one shape. Every package exposes the same five functions in the same order, so reading one is reading all of them.

    let data = b"moonbase"
    let pem = "bW9vbmJhc2U=\n" // a PEM body, newline and all

    @base64.encode(data[:]) // "bW9vbmJhc2U="
    @base64.encode(data[:], kind=Url, padding=false) // "bW9vbmJhc2U"
    @base64.encode_bytes(data[:]) // the ASCII bytes, no String on the way
    @base64.decode("bW9vbmJhc2U=") // b"moonbase"
    @base64.decode_lossy(pem) // b"moonbase"

    #Install

    moon add moonbitstack/moonbase

    Then import only the encodings you use. An application that needs base64 does not link base58's big-integer core.

    // moon.pkg
    import {
    "moonbitstack/moonbase/base64",
    }

    #The five faces

    FaceSignatureFor
    encode(BytesView, …) -> Stringthe ordinary case
    encode_bytes(BytesView, …) -> Byteswire formats that hold bytes — an HTTP header, a gRPC metadata value — with no round trip through String
    decode(StringView, …) -> Bytes raise Malformedinput you want checked
    decode_bytes(BytesView, …) -> Bytes raise Malformedthe same, for callers who never had a string
    decode_lossy(StringView, …) -> Bytesa PEM body full of newlines, a fingerprint full of colons

    Variants are a named argument, not another function name:

    @base32.encode(data[:], kind=Hex) // "DLNMURJ2C5PMA==="
    @base32.encode(data[:], kind=Crockford) // "DNQPYVK2C5SPA"
    @base16.encode(data[:], kind=Upper) // "6D6F6F6E62617365"
    @base64.encode(data[:], padding=false) // "bW9vbmJhc2U"
    @base85.encode(b"\x00\xff\x01\xfe"[:], kind=Z85) // "0ri90"

    Decoding reads either padding by default. base32 and base64 take padding to require or forbid the =, and strict to refuse an incomplete run of = and spare bits that are not zero, so a format that compares or signs the text accepts only the spelling encode writes:

    @base64.decode("Zg==", padding=true, strict=true) // b"f"
    @base64.decode("Zh==", strict=true) // raises Padding(at=1)
    @base64.decode("Zg==", padding=false) // raises Padding(at=2)

    #Streaming

    The bit-packing families — base2, base8, base16, base32, base45, base64, base85 — have a sink() and a source(). Feed them in whatever chunks the transport hands you; the bytes that come out are the bytes one call would have produced.

    let sink = @base64.sink()
    sink.write(b"moon"[:])
    sink.write(b"base"[:])
    sink.finish() // "bW9vbmJhc2U="

    let source = @base64.source()
    source.write("bW9vbm") // six characters are four bytes and four bits
    source.drain() // b"moon" — the leftover bits stay behind
    source.write("Jhc2U=")
    source.finish() // b"base"

    drain() hands over what is already decided and keeps the rest; finish() flushes the tail. A failure names the index in the whole input, counted across every chunk, so a split never moves an error to a place the caller cannot find.

    The big-integer families — base10, base36, base58, base62 — have no streaming face on purpose: their first digit depends on the last byte, so there is nothing to emit until the input ends.

    #Whole numbers

    base36, base58 and base62 add encode_int / decode_int, for values where leading zeros have no meaning.

    @base36.encode_int(123456789) // "21i3v9"
    @base62.encode_int(123456789) // "8M0kX"
    @base36.decode_int("21i3v9") // 123456789

    Anything larger goes through BigInt, on any alphabet:

    @moonbase.encode_big(@bigint.BigInt::from_int(123456789), hex) // "75bcd15"
    @moonbase.decode_big("75bcd15", hex) // the same number

    #Failures say where

    A malformed input names the index it broke at, instead of a silent None.

    try {
    @base64.decode("Zm*vYg==")
    } catch {
    failure => match failure {
    Bad(at~, char~) => println("bad " + char.to_string() + " at " + at.to_string())
    Truncated(at~) => println("truncated at " + at.to_string())
    Padding(at~) => println("padding at " + at.to_string())
    Checksum(at~) => println("checksum at " + at.to_string())
    }
    } noraise {
    _ => println("decoded")
    }

    Want the old "just tell me it failed"? try? @base64.decode(s) gives you a Result.

    #Custom alphabets

    The positional core is public, so any base-N system is two lines.

    let crockford = @moonbase.Alphabet::new("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
    @moonbase.encode(data[:], crockford) // "6TVVFDSH62WV5"

    Alphabet::new raises when the alphabet is wrong — a repeated character, a non-ASCII character, or fewer than two digits — because it often arrives from configuration. Alphabet::of is the same thing for literals in your own source, and aborts instead.

    #Self-identifying strings

    multibase puts one character in front of any of the above, so the string says how to read itself.

    @multibase.encode(data[:], base=Base58Btc) // "zKJfFLz7PRba"
    @multibase.detect("zKJfFLz7PRba") // Some(Base58Btc)
    @multibase.decode("zKJfFLz7PRba") // b"moonbase"

    bech32 is the other way round: it makes the prefix part of the checksum, so a typo in the prefix is as visible as a typo anywhere else.

    @bech32.encode("moonbase", data[:]) // "moonbase1d4hk7mnzv9ek2s84pra"
    @bech32.encode("moonbase", data[:], kind=Bech32m) // "moonbase1d4hk7mnzv9ek29m9dxl"
    @bech32.decode_words("moonbase1d4hk7mnzv9ek2s84pra") // { hrp: "moonbase", words: … }

    #The thirteen encodings

    PackageSpecificationNotes
    base2multibase 0one bit a character; eight characters a byte
    base8multibase 7three bits a character — a bit packing, not an octal of a number
    base10multibase 9the bytes as one decimal integer, so a leading zero byte stays a leading 0
    base16RFC 4648 §8byte-aligned, so leading zeros survive; encode_colons writes fingerprints
    base32RFC 4648 §6, §7kind=Hex keeps byte order under sorting; Crockford and Z for people
    base36multibase k / Knumbers and identifiers
    base45RFC 9285two bytes in three characters a QR code's alphanumeric mode can hold; no padding
    base58multibase zFlickr and Ripple alphabets, plus encode_check / decode_check
    base62—digits, upper, lower; short identifiers
    base64RFC 4648 §4, §5kind=Url for tokens and file names; decode_any reads either alphabet
    base85Adobe, ZeroMQ RFC 32, RFC 1924kind=Ascii85, kind=Z85, kind=Ipv6 — four bytes in five characters, three ways
    bech32BIP 173, BIP 350a human-readable prefix, five-bit groups and a BCH checksum; kind=Bech32m for version 1 and up
    multibasemultiformatsone prefix character, from 0 for base2 to a rocket for base256emoji

    Every package passes its own specification's vectors: RFC 4648 §10, RFC 9285 Appendix, ZeroMQ RFC 32, RFC 1924 §5, the full BIP 173 and BIP 350 valid/invalid lists, and multiformats' tests/*.csv fixture files. scripts/gen_vectors.py regenerates what can be generated and checks the rest against a reference implementation.

    #Examples

    moon run src/examples/minimal --target wasm-gc moon run src/examples/tour --target wasm-gc moon run src/examples/integration --target wasm-gc

    #Licence

    Apache-2.0.

    BadAlphabet

    pub(all) suberror BadAlphabet {
    NotAscii(at~ : Int, char~ : Char)
    Repeated(at~ : Int, char~ : Char)
    TooFew(count~ : Int)
    } derive(Eq,
    Debug
    )

    Why an alphabet could not be built.

    Separate from [Malformed] on purpose: a malformed input is the caller's data, a malformed alphabet is the caller's code.

    BadAlphabet::equal

    fn BadAlphabet::equal(BadAlphabet, BadAlphabet) -> Bool

    BadAlphabet::not_equal

    fn BadAlphabet::not_equal(x : BadAlphabet, y : BadAlphabet) -> Bool

    Malformed

    pub(all) suberror Malformed {
    Bad(at~ : Int, char~ : Char)
    Truncated(at~ : Int)
    Padding(at~ : Int)
    Checksum(at~ : Int)
    } derive(Eq,
    Debug
    )

    Why a string could not be decoded, and where.

    Decoding fails for four reasons and each of them names the offending index, because "it did not decode" is not enough to fix a malformed document. The old spelling returned None and said nothing; callers who only want that much can still write try? decode(s).

    Malformed::equal

    fn Malformed::equal(Malformed, Malformed) -> Bool

    Malformed::not_equal

    fn Malformed::not_equal(x : Malformed, y : Malformed) -> Bool

    Alphabet

    pub struct Alphabet {
    digits : Array[Char]
    lookup : Array[Int]
    base : Int
    }

    An ordered set of digit characters defining a positional numeral system. The character at index i is the digit of value i, so the first character is always the zero digit. Build one with [Alphabet::new] and hand it to [encode] / [decode]; the base58 and base62 packages are thin wrappers that pin a specific alphabet.

    Alphabet::base

    fn Alphabet::base(self : Alphabet) -> Int

    The radix of this alphabet — the number of distinct digits.

    Alphabet::char_of

    fn Alphabet::char_of(self : Alphabet, v : Int) -> Char

    The character for digit value v, where 0 <= v < base.

    Alphabet::new

    fn Alphabet::new(chars : String) -> Alphabet raise BadAlphabet

    Build an [Alphabet] from its ordered digit characters, e.g. Alphabet::new("0123456789abcdef") for lowercase hexadecimal.

    Fails rather than aborts: an alphabet often comes from configuration, and a library has no business killing the program over a value it was handed.

    Alphabet::of

    fn Alphabet::of(chars : String) -> Alphabet

    An alphabet written as a literal in source, where a mistake is a programming error rather than bad input.

    This is the one place the library still aborts: new exists for alphabets that arrive at runtime, and this one for the six that ship with the library and are covered by its tests.

    Alphabet::value_of

    fn Alphabet::value_of(self : Alphabet, c : Char) -> Int?

    The digit value of c in this alphabet, or None if c is not one of its characters.

    Sink

    pub struct Sink {
    // private fields
    }

    A streaming encoder: bytes go in a chunk at a time, text comes out as it is produced.

    The one-shot [encode] needs the whole payload before it starts, which is fine for a token and wrong for a file or a socket. A Sink carries the half-finished character between chunks, so feeding b"foo" then b"bar" gives the same string as feeding b"foobar" — the tests check that against random splits, not just the obvious one.

    let sink = @base64.sink()
    sink.write(b"foo"[:])
    sink.write(b"bar"[:])
    sink.finish() // "Zm9vYmFy"

    This is the bit-packing engine, so it serves base2, base8, base16, base32, base45, base64 and base85, each of which decides its next character from the next few bits.

    The positional encodings (base10, base36, base58, base62) have no streaming face at all, and that is a decision rather than an omission: they read the payload as one big-endian integer, so the first digit of the answer depends on the last byte of the input. Nothing could be emitted before the whole payload had arrived, which is what encode already waits for. A face that only ever emitted at finish would add a name, not a capability, so those four take a whole value through encode_big / decode_big.

    Sink::drain

    fn Sink::drain(self : Sink) -> String

    Take what has been produced so far and forget it, for a caller writing to a socket who does not want to hold the whole output in memory.

    The half-finished character stays behind, so the next write picks up where this one stopped.

    Sink::finish

    fn Sink::finish(self : Sink) -> String

    Close the sink: flush the trailing bits, add the padding, and return everything that has not been drained yet.

    The trailing partial group is shifted up and filled with zero bits, exactly as the one-shot encoder does it. Writing after this call is a mistake with no defined result.

    Sink::new

    fn Sink::new(bits : Int, digits : Array[Char], padding : Bool) -> Sink

    A sink that emits one character per bits bits, drawn from digits.

    padding fills the last group with =, the way RFC 4648 does; the encodings that do not pad pass false.

    Sink::write

    fn Sink::write(self : Sink, input : BytesView) -> Unit

    Feed the next chunk. Nothing is emitted until there are enough bits for a whole character, so a chunk may produce no output at all.

    Source

    pub struct Source {
    // private fields
    }

    A streaming decoder: text goes in a chunk at a time, bytes come out.

    The mirror of [Sink]. A character may straddle two chunks, so the decoder keeps the bits it cannot yet turn into a byte; and because it remembers how many characters it has seen, a failure found in the third chunk still names the index it would have had if the whole input had arrived at once.

    Source::drain

    fn Source::drain(self : Source) -> Bytes

    Take the bytes decoded so far and forget them.

    Source::finish

    fn Source::finish(self : Source) -> Bytes raise Malformed

    Close the source and return every byte that has not been drained.

    Fails if the input ends in the middle of a group — a whole character that cannot become a byte — with the same Truncated the one-shot decoder raises.

    Source::new

    fn Source::new(bits : Int, lookup : Array[Int], lossy : Bool) -> Source

    A source that reads one character per bits bits out of lookup.

    lossy skips anything outside the alphabet instead of failing, the way decode_lossy does.

    Source::write

    fn Source::write(self : Source, input : StringView) -> Unit raise Malformed

    Feed the next chunk of text. Fails at the first character that is not in the alphabet, naming the index it has in the whole input rather than in the chunk.

    decode

    fn decode(input : StringView, alphabet : Alphabet) -> Bytes raise Malformed

    Decode a string over alphabet back to the original bytes.

    Fails at the first character outside the alphabet, naming where it was. Round-trips with [encode].

    decode_big

    fn decode_big(input : StringView, alphabet : Alphabet) ->
    BigInt
    raise Malformed

    Read a whole number of any size in this base.

    Fails at the first character outside the alphabet, naming where it was, the same way decode_int does.

    decode_bytes

    fn decode_bytes(input : BytesView, alphabet : Alphabet) -> Bytes raise Malformed

    Decode from ASCII bytes, for callers who never had a string.

    decode_int

    fn decode_int(input : StringView, alphabet : Alphabet) -> UInt64 raise Malformed

    Read a whole number in this base.

    decode_lossy

    fn decode_lossy(input : StringView, alphabet : Alphabet) -> Bytes

    Decode, skipping anything outside the alphabet.

    Never fails, which is what a payload wrapped in newlines or punctuation needs. What it cannot do is tell you the input was wrong.

    encode

    fn encode(input : BytesView, alphabet : Alphabet) -> String

    Encode raw bytes to a string over alphabet.

    Leading zero bytes map to leading zero-digit characters; the rest is read as a big-endian integer and rewritten in the target base. Round-trips with [decode].

    encode_big

    fn encode_big(value :
    BigInt
    , alphabet : Alphabet) -> String

    Write a whole number of any size in this base.

    encode_int stops at a UInt64, which is where most ids stop too — but a hash, a committee size or a Snowflake-style identifier is wider than that, and a positional encoding has no reason to be. Like encode_int this keeps no leading zeros: a number has none to keep.

    A sign is not part of any alphabet here, so a negative value is encoded as its magnitude. Ask with value.compare_int(0) first if the sign can vary.

    encode_bytes

    fn encode_bytes(input : BytesView, alphabet : Alphabet) -> Bytes

    The same encoding, as the ASCII bytes it would be written with.

    encode_int

    fn encode_int(value : UInt64, alphabet : Alphabet) -> String

    Write a whole number in this base, without the leading-zero rule that the byte-oriented faces keep.

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io