I/O Builtins¶
Clausal Prolog provides built-in predicates for formatted output and term-to-string conversion, and the seam (.seam) adds f-string interpolation. Whether you need to print debug output, format a table, or build strings from logic variables, the I/O builtins have you covered. For calling Python functions directly, see Python Integration.
Quick Example¶
write_text/1 and writeln_text/1 are the text writers — a string prints
as its characters. write/1 and writeln/1 are the ISO writers and print
a string as the list of characters it is ([H,e,l,l,o]). Pick by whether
you are producing human text or showing a term; the three families are laid
out below.
Output Predicates¶
Three families of writer¶
Clausal Prolog has three groups of writing predicates. Which one you want depends on whether you are producing human text, spelling out a term the ISO way, or showing a term so a reader can tell an atom from a string.
| Family | Predicates | A string prints as | After a comma |
|---|---|---|---|
| ISO | write/1, writeq/1, write_canonical/1, write_term/2 (and writeln/1, write_to_string/2, which are write/1's semantics under non-ISO names) |
the LIST of its characters — [a,b,c] |
nothing |
| Text | write_text/1, writeln_text/1, write_text_to_string/2 |
its text — abc |
a space |
| Display | print_term/1, term_to_string/2 |
the double-quoted form — "abc" |
a space |
The ISO family prints no whitespace after a comma — [a,b,c], f(a,b),
{k:v} — so its output is byte-comparable with other ISO systems. The text and
display families keep the engine's ", " display spacing.
The ISO writers: write/1, writeq/1, write_canonical/1¶
They differ in how much of the term's structure they spell out:
| Builtin | Quotes? | List syntax | Use it for |
|---|---|---|---|
write/1 |
No — an atom prints its bare spelling | Yes | ISO term output |
writeq/1 |
Yes — an atom is quoted when it needs it | Yes | ISO term output you can read back |
write_canonical/1 |
Yes | No — every list, string included, prints as the '.'/2 structure it denotes |
a form another Prolog can read back |
| Term | write |
writeq |
write_canonical |
|---|---|---|---|
atom foo |
foo |
foo |
foo |
atom 'foo bar' |
foo bar |
'foo bar' |
'foo bar' |
string "abc" |
[a,b,c] |
[a,b,c] |
'.'(a,'.'(b,'.'(c,[]))) |
char list [a, b] |
[a,b] |
[a,b] |
'.'(a,'.'(b,[])) |
[1, 2] |
[1,2] |
[1,2] |
'.'(1,'.'(2,[])) |
code list b"ab" |
[97,98] |
[97,98] |
'.'(97,'.'(98,[])) |
"" / [] / b"" |
[] |
[] |
[] |
foo(bar, "baz") |
foo(bar,[b,a,z]) |
foo(bar,[b,a,z]) |
foo(bar,'.'(b,'.'(a,'.'(z,[])))) |
'+'(1, 2) |
+(1,2) |
+(1,2) |
+(1,2) |
A string is the list of its characters, so all three spell it out; a b"…"
code list is the list of its code numbers and spells out the same way. The
three differ in quoting and in whether list syntax is used at all.
The '+'(1, 2) row is the quoted cell. A bare 1 + 2 written as an argument
in today's syntax is a runtime operator node, not that cell, and every writer
prints it in its source form, (1 + 2); see Operators.
write_canonical/1 additionally drops operator forms, so its output is the
'.'/2 structure itself; the string stays a compact str internally, the
writer only shows the cons structure.
# write/1: no quotes, a string is the list of characters it is, and there
# is no space after a comma -- the ISO family is byte-comparable with other
# ISO systems.
test("write is unquoted") <- (
write_to_string(foo(bar, "baz"), S),
S == "foo(bar,[b,a,z])"
) # nv
test("write spells a string out") <- (
write_to_string("ab", S1),
S1 == "[a,b]"
) # nv
# writeq/1 adds quoting and nothing else: an atom that would not read back
# as itself is single-quoted, and the string is still the char list.
test("writeq quotes an atom that needs it") <- (
write_to_string('foo bar', S3B), S3B == "foo bar",
term_to_string('foo bar', S3), S3 == "'foo bar'"
) # nv
# "", [] and b"" are one term, so every writer prints them alike.
test("the empty string writes as the empty list") <- (
write_to_string("", S5), S5 == "[]",
write_to_string([], S6), S6 == "[]",
term_to_string("", S7), S7 == "[]",
write_text_to_string([], S8), S8 == "[]"
) # nv
The text writers: write_text/1, writeln_text/1, write_text_to_string/2¶
These print a string as its text and a char list as the text it spells; an
atom prints its bare spelling, and every other term prints exactly as write/1
prints it. This is the engine's ~s, and it is where f-strings go:
| Term | write_text |
|---|---|
string "abc" |
abc |
char list [a, b] |
ab |
atom 'foo bar' |
foo bar |
[1, 2] |
[1, 2] |
code list b"ab" |
b'ab' |
"" / [] |
[] |
writeln_text(f"X is {X}") is the idiomatic way to print an interpolated line.
The display writers: print_term/1, term_to_string/2¶
A string prints in double quotes ("abc"), a char list as the string it is
("ab"), and an atom is quoted when it needs to be. Reach for these when you
need to tell an atom from a string in the output; the double-quoted spelling
is what Scryer's toplevel displays for the same term, and it is the one
write_term/2's double_quotes(true) selects. These two keep the display
comma spacing (f(a, "bc")) that the ISO family drops. print_term/1 adds a
newline.
write_term/2¶
write_term(Term, Options) is the ISO writer with its switches named. Both
Boolean options default to false, so an option-free call is exactly
write/1.
| Option | Meaning |
|---|---|
quoted(Bool) |
quote an atom that would not read back as itself ('foo bar') |
double_quotes(Bool) |
print a string (and the char list that is one) as "abc" rather than as [a,b,c] |
ignore_ops(Bool) |
accepted and inert — the write family here never prints operator forms to begin with |
numbervars(Bool) |
accepted and inert — there is no '$VAR'/1 convention here |
| Call | Output |
|---|---|
write_term("abc", []) |
[a,b,c] |
write_term("abc", [quoted(true)]) |
[a,b,c] |
write_term("abc", [quoted(true), double_quotes(true)]) |
"abc" |
write_term([a, b], []) |
[a,b] |
write_term([1, 2], []) |
[1,2] |
write_term('a b', [quoted(true)]) |
'a b' |
write_term(T, []) is exactly write/1 and write_term(T, [quoted(true)])
is exactly writeq/1. write_term(T, [quoted(true), double_quotes(true)])
gives a string the spelling print_term/1 and term_to_string/2 give it —
those two additionally keep the display comma spacing, which this writer,
being ISO, does not.
An unrecognised option raises domain_error(write_option, Opt); a non-list
Options raises type_error(list, Options); an unbound or partial one
([quoted(true) | _]) raises instantiation_error. Streams are out of
scope, so there is no write_term/3, and max_depth(N) is not supported.
# write_term/2 is the ISO writer with the switches named. Both Booleans
# default to FALSE, so an option-free call is exactly write/1: a string
# prints as the LIST of char atoms it is, with no space after the comma.
#
# write_term("abc", []) prints [a,b,c]
# write_term("abc", [quoted(true)]) prints [a,b,c]
# write_term("abc", [quoted(true), double_quotes(true)]) prints "abc"
# write_term('a b', [quoted(true)]) prints 'a b'
#
# write_term(T, []) IS write/1 and write_term(T, [quoted(true)]) IS writeq/1.
# The two-option form gives a string the SPELLING term_to_string/2 gives it
# -- pinned below through term_to_string/2, which additionally keeps the
# display comma spacing -- and then the calls are shown.
test("write_term option forms") <- (
term_to_string("abc", T1), T1 == "\"abc\"",
write_to_string("abc", T2), T2 == "[a,b,c]",
write_term("abc", [quoted(true), double_quotes(true)]), nl(),
write_term("abc", []), nl(),
write_term('a b', [quoted(true)])
) # nv
The newline and string forms of each family¶
| Builtin | Family | Newline? |
|---|---|---|
write/1, writeln/1, write_to_string/2 |
ISO (write) |
writeln only |
write_text/1, writeln_text/1, write_text_to_string/2 |
Text | writeln_text only |
print_term/1, term_to_string/2 |
Display | print_term only |
writeq/1 |
ISO, quoted | no |
write_canonical/1 |
canonical | no |
# The Clausal TEXT family -- write_text/1, writeln_text/1,
# write_text_to_string/2 -- prints a string as its TEXT and a char list as
# the text it spells. This is where f-strings go.
test("the text family prints a string as its text") <- (
write_text_to_string('a b', W1), W1 == "a b",
write_text_to_string("a b", W2), W2 == "a b",
write_text_to_string(['a', 'b'], W3), W3 == "ab",
write_text('a b'), write_text(" "), writeln_text("a b")
) # nv
# The Clausal DISPLAY family -- print_term/1 and term_to_string/2 -- gives a
# string the SPELLING write_term(T, [quoted(true), double_quotes(true)])
# gives it, and additionally keeps the ", " after a comma that the ISO
# family drops. An atom is quoted when it needs it and a string prints in
# double quotes, so the two kinds are distinguishable. A list of chars IS a
# string, so it prints as one.
test("print_term/1 and term_to_string/2 are the display family") <- (
term_to_string('a b', Q1), Q1 == "'a b'",
term_to_string("a b", Q2), Q2 == "\"a b\"",
term_to_string(['a', 'b'], Q3), Q3 == "\"ab\"",
writeq('a b'), nl(), print_term("a b")
) # nv
# write_canonical/1 spells the '.'/2 structure a string denotes:
# write_canonical("hi") prints '.'(h,'.'(i,[]))
# write_canonical([1, 2]) prints '.'(1,'.'(2,[]))
# write_canonical([]) prints []
test("write_canonical/1 spells the cons structure") <- (
write_canonical("hi"), nl(),
write_canonical([1, 2]), nl(),
write_canonical([])
) # nv
When to use which:
write_text/writeln_text— human-facing output and f-strings; text comes out bareprint_term/term_to_string— debugging: you can tell an atom from a stringwrite/writeq— ISO term output; a string spells itself outwrite_canonical— a form another Prolog can read backwrite_text+nl— when you need precise control over newlines
nl/0¶
write a newline character:
tab/1¶
write N spaces:
String Conversion¶
Both answer with a string, never with an atom.
write_text_to_string/2¶
write_text_to_string(Term, String) — unify String with the write_text/1
rendering of Term: text comes out bare. This is the one to build
human-readable text with.
-double_quotes(chars)
format_pair(K, V, S) <- write_text_to_string(K - V, S)
test("write text to string") <- (
format_pair('name', 'alice', S),
S == "name - alice"
)
write_to_string/2¶
write_to_string(Term, String) — unify String with the write/1 (ISO)
rendering of Term: unquoted, list syntax intact, and a string spelled out as
the char list it is.
-double_quotes(chars)
iso_form(X, S) <- write_to_string(X, S)
test("write to string is ISO") <- (
iso_form("ab", S),
S == "[a,b]"
)
term_to_string/2¶
term_to_string(Term, String) — unify String with the display
rendering of Term (write_term(Term, [quoted(true), double_quotes(true)])):
quoted, so an atom is distinguishable from a string.
-double_quotes(chars)
label(X, S) <- term_to_string(X, S)
test("term to string int") <- (label(42, S), S == "42")
test("term to string keeps the quotes") <- (label("hello", S2), S2 == "\"hello\"")
The three string forms side by side:
| Input | write_text_to_string |
write_to_string |
term_to_string |
|---|---|---|---|
42 |
"42" |
"42" |
"42" |
the atom hello |
"hello" |
"hello" |
"hello" |
the atom 'a b' |
"a b" |
"a b" |
"'a b'" |
the string "hi" |
"hi" |
"[h,i]" |
"\"hi\"" |
[1, 2] |
"[1, 2]" |
"[1,2]" |
"[1, 2]" |
Use write_text_to_string when building human-readable text. Use
term_to_string when you need to see which kind a value is; use
write_canonical/1 when you need a representation another Prolog can read
back.
F-String Support¶
In seam (.seam) files, f-strings build text with logic variable interpolation. Variables are automatically dereferenced before the f-string is evaluated, and a string interpolates as its text.
An f-string is a string: the same term a "..." literal is under the
module's -double_quotes mode — the chars string by default, and an atom
under -double_quotes(atom) (ruled 2026-09-28; it used to be an atom in every
mode). Compare it with a "..." literal:
-double_quotes(chars)
describe(NAME, AGE, S) <- (
S is f"Name: {NAME}, Age: {AGE}"
)
test("describe") <- (
describe("Alice", 30, S),
S is "Name: Alice, Age: 30"
)
test("an f-string is a string") <- (
describe("Alice", 30, S),
string(S)
)
Expressions in F-Strings¶
F-strings support arbitrary Python expressions inside {}:
-double_quotes(chars)
summarize(XS, S) <- (
length(XS, N),
S is f"List has {N} element(s)"
)
test("summarize") <- (
summarize([1, 2, 3], S),
S is "List has 3 element(s)"
)
Multi-Variable F-Strings¶
All logic variables referenced in the f-string are dereferenced:
-double_quotes(chars)
full_name(FIRST, LAST, S) <- (
S is f"{FIRST} {LAST}"
)
test("full name") <- (
full_name("Alice", "Smith", S),
S is "Alice Smith"
)
Deferred Evaluation¶
F-strings use deferred evaluation — the f-string is evaluated at search time, after variables are bound. This means f-strings work correctly with backtracking:
-double_quotes(chars)
color("red"),
color("green"),
color("blue"),
describe_color(S) <- (
color(C),
S is f"The color is {C}"
)
test("deferred f-string") <- (
describe_color(S),
S is "The color is red"
)
Formatting Patterns¶
Printing a List¶
String Building with term_to_string¶
-double_quotes(chars)
format_item(X, S) <- term_to_string(X, S)
test("format item") <- (
format_item(42, S),
S == "42"
)
Building Strings with foldl¶
A string is a list, so append/3 concatenates one — use it inside a foldl
closure. (+ is arithmetic, not concatenation: R == A + E on text raises
type_error(integer, "ab") from the CLP(ℤ) expression. == itself is fine on
strings — it is the + that has no text meaning.)
-double_quotes(chars)
concat_all(XS, RESULT) <- (
foldl(
((E, A, R) <- append(A, E, R)),
XS, "", RESULT
)
)
test("concat all") <- (
concat_all(["a", "b", "c"], R),
R is "abc"
)
Clause Inspection¶
| Builtin | Arity | Description |
|---|---|---|
listing |
1 | listing(Pred) — print all clauses of a predicate to stdout |
portray_clause |
1 | portray_clause(Term) — pretty-print a term with indentation |
Examples¶
fib(0, 0),
fib(1, 1),
digits([D, *T]) >> (digit(D), digits(T))
digits([D]) >> digit(D)
digit(D) >> [D]
# List all clauses for a predicate, by its Name/Arity indicator -- `fib/2`
# here is `/`, the arithmetic operator, applied to a name and an int; it is
# NOT data (see the paragraph below):
debug_fib <- listing(fib/2)
# Name//Arity (a DCG nonterminal indicator) names Name/(Arity+2):
debug_digits <- listing(digits // 1)
# Pretty-print a complex term:
show_deep(TERM) <- portray_clause(TERM)
listing/1 follows Scryer Prolog's contract (operator ruling 2026-09-25,
"do what Scryer does"). Its argument is a predicate indicator, Name/Arity
or Name//Arity:
- an unbound argument fails;
- an indicator naming no predicate, or a predicate with no clauses, fails;
- anything that is not an indicator -- a bare atom (
listing(fib)), a compound term (listing(color(R, H))), a string, a number -- raisestype_error(predicate_indicator, PI); - a malformed operand raises what
functor/3raises for it: an unbound name or arityinstantiation_error, a non-integer aritytype_error(integer, A), a negative onedomain_error(not_less_than_zero, A), a string nametype_error(atomic, N), a number nametype_error(atom, N).
The indicator has several representations: the cells ('/', Name, Arity) /
('//', Name, Arity) (reachable from Python/engine callers that already hold the name and arity as
data), and -- what a user-written foo/2 or foo // 2 actually compiles to
in .seam source -- a runtime Div / FloorDiv node, since / and //
are arithmetic operators and a structural (non-is) use stays a reified
operator term rather than data. It prints a header with clause count, then
each clause in head <- (body). format.
From Python, listing also takes a predicate handle (listing(mod.fib)
lists every arity the module defines under that name, and prints
"% name/arity — no clauses" for an empty one) or a builtin
("% name/arity — builtin").
The indicator finds an IMPORTED predicate as well as a local one: an
-import_from binds the exporter's predicate, so listing(qq/1) and
listing('qq'/1) from the importer print exactly what listing(qq/1)
prints in the exporting module.
Var Display¶
Logic variables have __str__ and __format__ methods (in the C extension) that auto-deref for display:
- Bound var: displays the bound value
- Unbound var: displays
_N(unique numeric ID)
This means f"{X}" and write_text(X) show the value if bound, or a placeholder if unbound. This works in both .seam files and Python code:
from clausal.logic.variables import Var, Trail, unify
v = Var()
print(f"Unbound: {v}") # _42 (placeholder)
trail = Trail()
unify(v, "hello", trail)
print(f"Bound: {v}") # hello
Gotchas¶
write/1prints a string as[a,b,c], not asabc. It is the ISO writer, and a string is a list of characters. For human text — and for f-strings — usewrite_text/1/writeln_text/1.write/1does NOT quote;print_term/1andterm_to_string/2do. If your output has unwanted quotes, switch to the text writers.write_text/1cannot tell an atom from a string — both print bare. Useprint_term/1when the distinction matters,write_canonical/1when you need to see the list structure a string denotes.- F-strings evaluate at search time, not at parse time. An f-string with an unbound variable will show the Var placeholder (
_N), not raise an error. - nl/0 takes no arguments —
nl()notnl(1). Usetab(N)for spacing.
Test coverage
Tests are in tests/test_io.py and tests/test_listing.py.
- Var display:
__str__,__format__, bound/unbound, nested - write/writeln/print_term: atoms, numbers, strings, compounds, lists, vars
- nl/tab: output formatting
- write_to_string/term_to_string: term conversion to string
- F-string integration: variable interpolation, multiple vars, expressions
- listing/1: facts, rules, no-clauses, predicate handles, error handling
- portray_clause/1: simple terms, lists, nested structures, unbound vars
See also: Python Integration — using ++() escape for Python calls inside logic goals.
See also: Lambdas — goal closures used with foldl and other higher-order predicates.