Documentation/Chapters

Text and formatting

On this page

STRING is valid UTF-8 text. Its contents are immutable: an operation returns a new string, and a variable can be assigned that new value. This keeps text operations predictable even when other code has read access to the original.

Positions count Unicode scalars

text = "Aé🌍"
PRINT LEN(text)
PRINT ByteCount(text)
PRINT MID(text, 1, 1)
PRINT text[-1]
PRINT text[0:2]

The length is 3 Unicode scalar values but 7 UTF-8 bytes. Positions are zero-based; negative positions count backward from the end. A slice includes its start and excludes its finish. A visible character can contain multiple scalars, such as a letter followed by a combining accent. Scalar positions are not display columns or grapheme-cluster positions. Use explicit byte APIs for binary protocols.

LEFT, MID, RIGHT, search, trim, case conversion, padding and replacement are provided by the string reference . $ aliases such as MID$ exist, but arbitrary dollar-suffixed variable names are not supported.

Write text across several source lines

Triple double quotes introduce a block literal when the opening quotes are immediately followed by a newline. This is useful for SQL, templates, messages and sample data that would otherwise need many quoted fragments.

name = "Ada"
message = DEDENT TRIM f"""
    Hello, {name}!
        Ticket: {42:04d}
"""
PRINT message

Output:

Hello, Ada!
    Ticket: 0042

The raw block preserves indentation and original newline bytes. DEDENT removes the exact common space/tab prefix of nonblank content lines. TRIM removes only boundary newline sequences; it does not strip horizontal whitespace. Together, they dedent first and then trim. Either modifier order is accepted, but duplicate modifiers are errors. Ordinary TRIM(text) remains a separate whitespace-trimming function.

Use f""" for interpolation and plain triple quotes for literal braces. Layout processing happens before interpolation, so indentation inside a substituted value is preserved. Interpolation expressions themselves must remain single-line. The opening and closing delimiter lines’ whitespace matters: closing indentation is content unless removed by the layout rules. Tabs are literal characters, independent of the editor’s display width.

The newline after the opener distinguishes a block from existing doubled-quote syntax: """word""" still means an ordinary string containing quoted word. Six consecutive quotes inside a block represent three literal quotes. No backslash escapes are added. Blocks work in arguments, checked constant defaults and FORMAT options under the usual constant-expression rules. The multiline-string reference specifies blank lines, quote runs and CR/LF preservation. The IDE highlights blocks across scrolling and edits without treating their contents as new declarations.

words = SPLIT("red,green,blue", ",")
PRINT LEN(words)
PRINT JOIN(words, " / ")
PRINT UPPER("hello")
PRINT TRIM("  BASIC  ")
PRINT LEN(SPLIT("Aé"))

SPLIT with a nonempty separator splits on that exact text. SPLIT with no separator, or an empty separator, produces one string per scalar; it is not whitespace splitting. Empty input produces an empty list in that form. For line processing, use the dedicated LINES API and its documented line-ending behavior.

Exact string equality compares the encoded text; it does not silently normalize Unicode or apply locale rules. UPPER/LOWER and case folding are distinct from locale-aware collation. To compare text for an application’s identifiers, choose and document the normalization/case policy you require.

Format output

name = "Ada"
count = 42
price = 12.3456
PRINT f"Hello, {name}!"
PRINT f"Count: {count:04d}"
PRINT f"Price: {price:.2f}"
PRINT f"{{literal braces}}"

The f must immediately precede the opening quote. Expressions inside braces evaluate once from left to right. A colon introduces a static format specification. Common presentations are d for decimal integers, x for hexadecimal, f for fixed decimals, e for scientific notation and % for percentages. Width is a minimum; .2f requests two fractional decimal places. Strings support alignment and a scalar precision limit.

Specifications support <, >, ^ alignment, fill, sign, base prefixes, zero padding and grouping. Read the included format-string reference for the complete mini-language. Dynamic nested width/precision, debug =, custom record formatting and locale presentation are not implemented. Invalid specifications are compiler errors, including in unused functions.

For repeated construction, use StringBuilder and append operations from the buffer reference rather than repeatedly concatenating an ever larger prefix. Only initialized content is visible; reserving capacity is not creating text.

Regular expressions

pattern = CompileRegex("[0-9]+")
IF LET found = RegexFind(pattern, "item 42") THEN
    PRINT found.Text
    PRINT found.Start
END IF
PRINT RegexReplace("[0-9]+", "item 42", "number")

Compile once when using a pattern repeatedly. The compiled Regex is an owned resource, while matches and captured text are independent values. String-pattern overloads compile on each call. The engine is now implemented in BASIL itself; no Rust runtime or separate regex library is needed. The default Native dialect retains the pinned Rust-regex syntax baseline: Unicode classes and named captures are supported, but lookaround and backreferences reject. Choose the EcmaScript dialect explicitly for those constructs.

PRINT RegexIsMatch("^([a-z]+)-\1$", "blue-blue", Dialect=RegexDialect.EcmaScript)
PRINT RegexIsMatch("^(?!admin$)[a-z]+$", "user", Dialect=RegexDialect.EcmaScript)
PRINT RegexIsMatch("^ada$", "ADA", Flags="i")

Dialect changes pattern matching, not replacement syntax. Both dialects expand $1, ${name} and $$; these are separate from BASIL interpolation. EcmaScript is the stateless ECMAScript 2025 Unicode-mode pattern profile, not a JavaScript RegExp object. Flags d/g/v/y are rejected. Its backreferences have no linear-time guarantee. Configure RegexLimits for untrusted work; limits measure logical work, memory and results rather than wall-clock deadlines.

TRY
    limits = RegexLimits {MaxPatternBytes=3}
    pattern = CompileRegex("abcd", Limits=limits)
CATCH exhausted AS RegexLimitError
    PRINT "Pattern limit reached"
END TRY

All regex operations declare RegexError, including searches with a compiled pattern. A limit failure is an error, never FALSE, NONE or a partial success. Syntax and unsupported-profile failures have specific error types too. Normal compiler scheduling and cancellation apply during native regex work. The regex reference explains captures, empty matches, flags and every limit.

Convert external encodings

A STRING always contains valid UTF-8. A legacy file is bytes until you explicitly decode it; opening a file does not guess its encoding. Use ReadBinaryFile with DecodeText, then EncodeText before writing external bytes. No import is required.

bytes = EncodeText("café", TextEncoding.Windows1252)
PRINT LEN(bytes)
PRINT DecodeText(bytes, TextEncoding.Windows1252)
result = EncodeTextWithInfo("a€😀", TextEncoding.ASCII, TextErrorPolicy.Replace)
PRINT DecodeUtf8(result.Bytes)
PRINT result.Info.Replacements

Strict conversion is the default: malformed bytes and unrepresentable characters raise TextEncodingError. Replace is an explicit lossy policy: decoding inserts U+FFFD; legacy encoding inserts ?. WithInfo returns output plus counters, so a successful replacement need not silently hide lost characters. Error Offset and Length count source bytes, including UTF-8 bytes when encoding a STRING; they are not the scalar indexes used by ordinary string operations.

TRY
    text = DecodeText([BYTE(255)], TextEncoding.UTF8)
CATCH err AS TextEncodingError
    PRINT err.Offset
    PRINT err.Length
END TRY

Profiles cover UTF-8, explicit-endian UTF-16/32, ASCII, Latin1, Windows1252, CP437, ShiftJIS, Big5, EUCKR and separate GB18030_2000/GB18030_2022 versions. Choose the version required by your data: these are precise mapping profiles, not aliases for every regional Windows codec. No detection, normalization or newline conversion is implicit. The reference lists the exact profiles.

BOM handling is explicit. Decode defaults to Preserve; Consume removes a matching initial BOM and Require demands it. Neither changes the selected byte order. Encode defaults to Omit; Emit writes one initial BOM, even for empty text. These BOM options reject legacy encodings. maxOutputBytes defaults to 64 MiB per call or chunk and counts emitted bytes; it does not bound an entire stream.

Decode text arriving in chunks

A network read can split a character between packets. A TextDecoder retains the unfinished sequence until the next chunk rather than treating each read as a complete document.

decoder = NewTextDecoder(TextEncoding.UTF16LE)
first = DecodeTextChunk(BYREF decoder, [BYTE(65)])
PRINT LEN(first)
PRINT DecodeTextChunk(BYREF decoder, [BYTE(0)], final=TRUE)
PRINT DecoderInfo(decoder).Finished

FinishTextDecoder supplies the final empty chunk when the input ends separately. NewTextEncoder, EncodeTextChunk and FinishTextEncoder provide the other direction. A converter finishes exactly once; later chunk/finish calls fail. Converters are copyable opaque values: copying one forks its state independently. BYREF gives exclusive access while advancing it. A recoverable chunk failure leaves that state unchanged and returns no partial chunk; already written earlier chunks remain written. Info snapshots contain cumulative byte counters, replacement counts and the first replacement offset. Keep an application-level total limit when processing untrusted streams.

From the BASIL documentation. View source ↗

Find your way around BASIL

Type to search the manual.