Skip to content

Latest commit

 

History

569 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DotProlog logo

A Prolog language implementation for .NET 10, written in C# 14.

The goal is a first-class Prolog experience on the .NET SDK, the way C# and F# have one: .dplproj projects, a plc compiler, dotnet prolog, dotnet new templates, and NativeAOT publishing.

Read the DotProlog documentation for tutorials, how-to guides, reference, and explanations of the design. New to Prolog — or to programming? Start with A Gentle Introduction to Prolog, a free beginner book in English and Russian whose examples all run on DotProlog.

Status: early, but usable. dotnet new prolog-console through dotnet publish -p:PublishAot=true works today, and dotnet test discovers Prolog tests under Microsoft.Testing.Platform. .dplproj predicate bodies compile to generated C# and ordinary CLR IL at build time; source consulted at run time compiles to bytecode for an AOT-safe VM. The standalone plc compiler in this checkout emits IL directly and can publish a native executable with NativeAOT. It can be packed and installed as DotProlog.Compiler.Cli; this new tool has not yet been published to NuGet.org. The packages have been on NuGet.org since 0.2.0 and the current release is 0.15.0: see CHANGELOG.md and COMPATIBILITY.md.

Hello, world

% samples/HelloProlog/hello.pl
:- initialization(main).

main :-
    greeting(Greeting),
    write(Greeting),
    nl.

greeting('Hello! World!').
$ dotnet run --project src/DotProlog.Tool -- run samples/HelloProlog/hello.pl
Hello! World!

Or, with just:

$ just hello

Analyze source without consulting it or executing directives:

$ dotnet run --project src/DotProlog.Tool -- lint --warnings-as-errors path/to/program.pl

The stable linter diagnostics cover singleton variables, repeated underscore-prefixed singleton markers, and double-quoted text read one way but used another — characters handed to a code conversion, or to a grammar that compares character codes. An opt-in --profile covington adds configurable source-layout checks. See Source linting for rules and exit codes.

Calling Prolog from C#

var engine = new PrologEngine();
engine.ConsultText("colour(red).  colour(green).  colour(blue).");

foreach (PrologSolution solution in engine.Query("colour(C)").Solutions())
{
    Console.WriteLine(solution["C"]);      // red, green, blue
}

bool ok = engine.Query("1 < 2").Prove();

Answers are produced on demand and marshalled into plain .NET objects as they arrive, so they stay valid after the query has moved on — and an unbounded goal is fine as long as you stop taking:

var first = engine.Query("between(1, 1000000000, X)").Solutions().Take(4);

Predicates can also be called directly with .NET values. This is the surface the generated .dplproj facades sit on:

var host = new PrologHost(engine.Machine);
PrologPredicate discount = host.Bind("discount", 3);

PrologValue[]? result = host.CallOnce(
    discount, PrologInput.Float(100.0), PrologInput.Integer(10), PrologInput.Output);

This works from F# and VB the same way. One engine runs one goal at a time and is not thread-safe.

Prolog as a referenced project

samples/PricingRules is a .dplproj holding ordinary ISO Prolog plus a contract declaring its .NET surface. samples/PricingConsole is a plain C# app that references it:

<ProjectReference Include="..\PricingRules\PricingRules.dplproj" />
% pricing.dpli — modes and determinism live here, so pricing.pl stays portable
:- clr_module('Pricing').
:- clr_export(discount/3, det, [in(price, float), in(percent, integer), out(result, float)]).
:- clr_export(in_catalogue/1, semidet, [in(item, atom)]).
:- clr_export(bundle/2, nondet, [in(items, list(atom)), out(bundle, list(atom))]).
IPricingModule pricing = PricingModule.Create();

pricing.Discount(100.0, 15);          // 85
pricing.InCatalogue("widget");        // true
foreach (var b in pricing.Bundle(["widget", "gadget"])) { /* streamed */ }

The facade is generated during the build, before the C# compiler runs. Nothing in the consuming code mentions the engine, a goal, or a term — which is why the same reference works from F# and VB with no extra work. That is checked rather than assumed: samples/PricingFSharp and samples/PricingVisualBasic consume the same .dplproj, and the integration test builds and runs all three.

The generated surface follows Microsoft's F# component design guidelines for "vanilla .NET" libraries: the TryGetValue pattern for semidet predicates with outputs, a named record rather than a tuple for several outputs, collection interfaces rather than concrete types, null checks at the boundary, and a CancellationToken on anything that streams. A predicate whose name reads poorly in C# can be renamed in the contract, the equivalent of F#'s [<CompiledName>]:

:- clr_export(nrev/2, det, [in(l, list(atom)), out(r, list(atom))], 'NaiveReverse').
$ dotnet run --project samples/PricingConsole
100 less 15% = 85
total 1200 is gold
widget in catalogue: True
bundles of [widget, gadget]:
  [widget, gadget]
  [widget]
  [gadget]
  []

Standalone compiler: Prolog → IL → NativeAOT

The plc project emits an executable .NET assembly directly, without generating C# or invoking Roslyn. Run it from this checkout with the .NET 10 SDK:

# Emit managed IL and an isolated publishing project.
dotnet run --project src/DotProlog.Compiler.Cli -- \
  samples/HelloProlog/hello.pl --output artifacts/hello-il

dotnet artifacts/hello-il/PrologProgram.dll

# Or compile and publish native code in one command (host platform by default).
dotnet run --project src/DotProlog.Compiler.Cli -- \
  samples/HelloProlog/hello.pl --output artifacts/hello-native --aot

To install plc from a locally built package, run these commands from the repository root:

dotnet pack src/DotProlog.Compiler.Cli -c Release -o artifacts/compiler
dotnet tool install DotProlog.Compiler.Cli --add-source artifacts/compiler \
  --tool-path artifacts/compiler-tools --version 0.15.0
./artifacts/compiler-tools/plc --version
./artifacts/compiler-tools/plc samples/HelloProlog/hello.pl --output artifacts/installed-hello --aot

Use the version produced by dotnet pack if you change the repository version. For a global installation, replace --tool-path artifacts/compiler-tools with --global; the command is then plc from any directory on your tool PATH. A project-local installation also works with dotnet new tool-manifest followed by dotnet tool install --local and the same package, source, and version arguments. The package includes all four DotProlog libraries needed by emitted programs; the installed compiler needs .NET 10, and --aot also needs the SDK and native build tools.

The native executable is artifacts/hello-native/native/PrologProgram (PrologProgram.exe on Windows). Publishing needs the .NET SDK and the platform's NativeAOT prerequisites. Running the published executable needs neither an installed .NET runtime nor Prolog. Cross-OS publishing is not supported by this command; use a build host for each target OS.

  • Pass source files in load order. Applications start with their :- initialization(...) goals.
  • --mode modern|strict-iso and --flag double_quotes=codes|chars|atom|string select source semantics.
  • --rid <RID> chooses the native target with --aot; it defaults to the host RID.
  • --version prints the compiler package version, including any prerelease suffix.
  • --output must name a new directory. Existing outputs are never overwritten.
  • The managed output includes the required DotProlog DLLs and PrologProgram.csproj. Its overridden CoreCompile target supplies the already-emitted IL to the SDK. You can publish it later with dotnet publish artifacts/hello-il/PrologProgram.csproj -c Release -r <RID>.
  • Compiled predicates are static IL methods sharing the existing machine. Runtime consult/1 and assertions continue to use its bytecode path. The bundled standard library also retains its existing runtime initialization; this is not a bytecode-free runtime.
  • This compiler builds console applications from .pl files. .dplproj, .dpli facades, and Prolog test projects continue to use the existing SDK backend. Portable PDBs are not emitted yet.

Compiler exit codes are 64 for usage errors, 65 for source diagnostics, 70 for I/O or host errors, and 130 for cancellation or publishing timeout. Native publishing failures propagate dotnet's exit code and retain the emitted managed artifacts. Generated applications return 0 on success, 1 when initialization fails, the requested halt/1 code, or 70 for an uncaught Prolog exception. The native publishing timeout is 15 minutes; Ctrl+C terminates the child process tree.

Repository layout

Path What it is
src/DotProlog.Syntax Lexer, ISO operator table, operator-precedence reader, diagnostics
src/DotProlog.Runtime Tagged terms, heap, trail, choice points, bytecode VM, builtins
src/DotProlog.Compiler Clause analysis, source linting, bytecode lowering, consult and embedding API
src/DotProlog.CodeGen.CSharp .dpli contract reader, facade and entry-point generators, and the Prolog-to-C# predicate emitter
src/DotProlog.CodeGen.IL Direct managed PE/IL emission and static program installation
src/DotProlog.Compiler.Cli Standalone plc compiler and NativeAOT publishing driver
src/DotProlog.Build.Tasks MSBuild task that runs the generator
src/DotProlog.Sdk The DotProlog.Sdk MSBuild SDK package
src/DotProlog.Templates dotnet new prolog-console, prolog-lib, and prolog-test
src/DotProlog.Testing Microsoft.Testing.Platform host for Prolog tests
src/DotProlog.Tool The dotnet prolog command
tests/ Unit tests per component, plus end-to-end execution tests
benchmarks/ BenchmarkDotNet suite for the reader, compiler, and engine
docs/ Diátaxis documentation, built with MkDocs
samples/HelloProlog The Hello World sample
samples/PricingRules A .dplproj: Prolog rules plus their .dpli contract
samples/PricingConsole, samples/PricingFSharp, samples/PricingVisualBasic C#, F#, and VB apps referencing it
samples/GreetingApp A Prolog application built from a .dplproj
samples/GreymereAdventure A complete fantasy text adventure written in Prolog
samples/PricingTests Prolog tests in a .dplproj, run by DotProlog.Testing
samples/TextGrammar A .dplproj whose DCGs walk double-quoted text as characters
samples/NaturalLanguage Natural-language parsing and question answering over character lists
samples/AotAcceptance The NativeAOT acceptance sample

Common tasks

Documentation tasks require uv. The project pins Python 3.14 and the complete documentation environment in uv.lock; run uv sync --locked --only-group docs once to prepare it.

just build          # build everything
just test           # run every test project
just format         # format all C# with CSharpier
just format-check   # fail if anything is unformatted
just docs           # build the MkDocs site with strict link validation
just docs-serve     # preview the documentation with live reload
just check          # format-check + docs + build + test
just run FILE.pl    # consult and run a Prolog file
just bench '*'      # run the benchmark suite

See the justfile for the underlying dotnet and uv commands.

How it executes

Build-time backends and runtime consultation share one reader, loader, and clause compiler:

SDK build-time Prolog    : reader -> loader -> bytecode -> generated C# -> Roslyn -> IL -> JIT/NativeAOT
Standalone plc          : reader -> loader -> bytecode -> direct IL emission -> JIT/NativeAOT
Runtime consult / assert : reader -> loader -> bytecode -> AOT-compatible bytecode VM

A .dplproj takes the first path: its predicates become direct-threaded C# blocks at build time, so generated applications, facades, and test hosts neither embed nor consult their source. plc lowers the same portable compiler model directly into IL blocks. Runtime consultation never emits CLR IL, so it stays valid inside a NativeAOT process — runtime-loaded predicates execute as bytecode and are not turned into new machine code. Both drive the same heap, trail, and choice-point state, so compiled and consulted predicates call each other freely.

That is verified, not assumed. samples/AotAcceptance publishes to a self-contained native executable with no managed assemblies beside it, then at run time consults a .pl file it has never seen, enumerates solutions, asserts and retracts clauses, and catches an ISO error — with zero trimming or AOT warnings in the build. CI runs it on Windows, Linux, and macOS:

$ DOTPROLOG_RUN_AOT_TESTS=1 dotnet test --project tests/Integration

Dynamic predicates use the logical update view, so a goal sees exactly the clauses that existed when it started:

:- dynamic p/1.
p(1).
p(2).

% [1,2] — the asserted clauses are not visible to the goal that asserted them
?- findall(X, (p(X), assertz(p(9))), L).

The engine owns its control state: heap, trail, environment stack, choice-point stack, and argument registers are plain arrays, and Prolog calls are jumps inside a single dispatch loop. Prolog recursion depth therefore does not consume CLR stack, and failure is a return value rather than an exception. Last-call optimisation makes tail recursion run at constant stack depth.

Language modes

A program runs in one of two modes, fixed when its engine is created:

Mode Surface "abc" reads as A character is
modern (default) ISO Parts 1–3 plus the documented extensions and a few documented modifications, SWI-Prolog-aligned [a,b,c] a Unicode code point
strict-iso Only the ISO/IEC 13211 Parts 1–3 surface [97,98,99] a UTF-16 code unit
?- "abc" = [L|Ls].
   L = a, Ls = [b,c].

ISO makes the initial double_quotes value implementation defined. chars is what Scryer, Trealla, ichiban, Flowlog, and Trilog use, and it lets a grammar over text look like the text it parses. A program written for code lists keeps working with one override: double_quotes=codes in the DotPrologFlags project property, --flag double_quotes=codes on the command line, or :- set_prolog_flag(double_quotes, codes). at the top of a file.

$ dotnet prolog run --mode strict-iso program.pl
$ dotnet prolog run --flag double_quotes=codes legacy.pl

A .dplproj selects its mode with <DotPrologLanguageMode>. See the language guide for details.

What the language supports today

Area Predicates
Terms atoms, variables, unbounded integers, rationals, floats, strings, lists, double-quoted character lists, structures
Control ,/2, ;/2, ->/2, *->/2, \+/1, !/0, call/1..8, once/1, repeat/0, ignore/1, not/1, true/0, fail/0
Exceptions throw/1, catch/3, with catchable ISO error/2 terms
All solutions findall/3,4, bagof/3, setof/3, forall/2, call_nth/2, countall/2, aggregate_all/3,4 and aggregate/3,4 (count, bag, set, sum, max, min)
Database assertz/1, asserta/1, retract/1, clause/2, retractall/1, abolish/1, :- dynamic
Ranges between/3, with inf as an open upper bound
Loading consult/1, ensure_loaded/1 at run time
Unification =/2, \=/2
Arithmetic ISO-oriented evaluable functors over unbounded integers, rationals (1r3, rdiv/2), and floats; is/2, =:=/2, =\=/2, </2, >/2, =</2, >=/2
Standard order ==/2, \==/2, @</2, @>/2, @=</2, @>=/2, compare/3
Term inspection functor/3, arg/3, =../2, copy_term/2, term_variables/2, numbervars/3, variant/2, ?=/2, setarg/3, nb_setarg/3
Type tests var/1, nonvar/1, atom/1, number/1, integer/1, float/1, rational/1, string/1, atomic/1, compound/1, callable/1, is_list/1, ground/1
Text atom_length/2, atom_chars/2, atom_codes/2, number_chars/2, number_codes/2, char_code/2, atom_number/2, atom_concat/3, sub_atom/5, atomic_list_concat/2,3, upcase_atom/2, downcase_atom/2, char_type/2, code_type/2
Strings atom_string/2, string_chars/2, string_codes/2, string_concat/3, string_length/2, number_string/2, string_to_atom/2, term_string/2, sub_string/5, split_string/4, string_code/3, string_lower/2, string_upper/2
Lists length/2, append/3, member/2, memberchk/2, nth0/3,4, nth1/3,4, last/2, reverse/2, select/3, selectchk/3, subtract/3, intersection/3, union/3, delete/3, list_to_set/2, permutation/2, flatten/2, numlist/3, sum_list/2, max_list/2, min_list/2, max_member/2, min_member/2, pairs_keys_values/3, pairs_keys/2, pairs_values/2, transpose_pairs/2
Higher order maplist/2..8, foldl/4..6, include/3, exclude/3, partition/4
Sorting sort/2, sort/4, msort/2, keysort/2, predsort/3
Ordered sets list_to_ord_set/2, ord_empty/1, ord_memberchk/2, ord_subset/2, ord_disjoint/2, ord_union/2,3, ord_intersection/2,3, ord_subtract/3, ord_add_element/3, ord_del_element/3
Assocs AVL association lists: empty_assoc/1, put_assoc/4, get_assoc/3, list_to_assoc/2, ord_list_to_assoc/2, assoc_to_list/2, assoc_to_keys/2, assoc_to_values/2, min_assoc/3, max_assoc/3, del_assoc/4
Validation must_be/2, is_of_type/2, and the library(error) raisers from instantiation_error/1 to syntax_error/1
Global variables nb_setval/2, nb_getval/2, b_setval/2, b_getval/2, engine-scoped
Coroutining freeze/2, frozen/2
Cleanup setup_call_cleanup/3, call_cleanup/2
Integers succ/2, plus/3
Output write/1,2, writeq/1,2, print/1,2, writeln/1, write_canonical/1,2, write_term/2,3, nl/0,1, format/1,2,3, tab/1,2, print_message/2 with message_hook/3
Operators op/3, current_op/3
Grammars -->/2 with {}/1, !, \+//1, ->//2, call//1, phrase//1, semicontexts and pushback lists; phrase/2, phrase/3; Name//Arity indicators
Streams open/3,4 text and binary streams, close/1,2, configurable EOF actions, current_stream/1, stream_property/2, set_stream_position/2, current-stream selection, EOF inspection, flushing
Reading term, character, character-code, and byte input/output; read_term_from_atom/3, term_to_atom/2, atom_to_term/3; char_conversion/2, current_char_conversion/2
Modules ISO interfaces and bodies with module/1, body/1, export/import/re-export, metapredicate/1, reflection, and Module:Goal; Quintus-style declarations in Modern mode
Directives :- Goal, :- initialization(Goal), SWI's :- initialization(Goal, When) in Modern mode, halt/0, halt/1

Control constructs are compiled in place inside a clause body, so cut scopes the way ISO specifies: opaque in the condition of if-then-else, transparent in its branches, clause-scoped elsewhere. A bootstrap library written in Prolog makes the same constructs reachable when a goal is assembled at run time and passed to call/1.

sign(N, S) :- ( N < 0 -> S = negative ; N =:= 0 -> S = zero ; S = positive ).

safe_divide(X, Y, R) :-
    catch(R is X / Y, error(evaluation_error(zero_divisor), _), R = undefined).

item(1).
item(2).
item(3).

squares(L) :- findall(S, (item(N), S is N * N), L).   % L = [1,4,9]

Every error the engine raises is a catchable error(Formal, Context) term, so existence_error, type_error, instantiation_error, and evaluation_error can all be handled in Prolog rather than aborting the run. halt/1 validates its status before terminating: variables and non-integers remain catchable input errors instead of being converted to exit code zero. compare/3 likewise distinguishes an invalid output type from an atom outside the order domain. arg/3 distinguishes an uninstantiated compound term and a negative index from its ordinary zero-or-out-of-range failure cases. =../2 distinguishes partial lists from malformed list terms, requires an atomic one-element construction list, and shares the advertised arity-255 limit with functor/3. current_op/3 validates bound priority, specifier, and name filters before enumeration. Its solutions come from the operator-table snapshot current when the goal starts, even if op/3 changes the live table between solutions. clause/2 retains the Part 1 private-procedure rule for ordinary source, while ISO module bodies expose their static and dynamic clauses through the Part 2 calling context. Attempts to retract a static procedure raise a catchable modification permission error.

report(Rows) :-
    forall(member(Name-Qty, Rows), format("~w~t~12|~t~d~4+~n", [Name, Qty])).

total(Rows, Total) :- aggregate_all(sum(Q), member(_-Q, Rows), Total).

initials(Name, Initial) :- sub_atom(Name, 0, 1, _, Initial).

Terms are written in operator notation, and what writeq/1 produces reads back as the same term — brackets and spacing are decided by priority and by whether two adjacent tokens would otherwise lex as one. write_canonical/1 and write_term/2 with ignore_ops(true) opt out.

:- op(700, xfx, likes).

fact(alice likes bob).      % read with the operator declared just above

?- fact(F), write(F).       % alice likes bob
?- write_canonical(1+2*3).  % +(1,*(2,3))

An :- op/3 directive takes effect while the file is still being read, so the file that declares an operator can use it. The reader and the writer share one table per engine, so nothing leaks between two engines in the same process.

Predicates written in Prolog rather than C# live in a standard library that every engine loads at construction, which costs about 220 µs. A consulted file that defines one of them replaces it outright, so a program is free to write its own member/2 without inheriting extra solutions.

Strings are a distinct term type: string(S) is true of one, atom/1 is false, and the standard order places them between numbers and atoms as SWI-Prolog 10 does. A string is interned beside the atom text it shares, so unification is integer identity and a string survives every detached copy. Double-quoted text reads as a list of characters by default (see Language modes), and the string library reads such lists as text wherever SWI-Prolog's does, so split_string("a,b", ",", "", Parts) works on it directly. Reading "..." as strings instead is set_prolog_flag(double_quotes, string) — or the DotPrologFlags project property — away, outside strict ISO mode.

bagof/3 and setof/3 group their solutions by whichever of the goal's variables are free — those the caller can still see — and offer one group per binding of them. A variable is made existential with ^/2, and one occurring only under \+ is never free, since negation proves a goal but cannot bind anything. Both fail when the goal has no solutions, where findall/3 returns [].

class(peter, a).  class(ann, b).  class(pat, a).

?- bagof(N, class(N, C), L).     % C = a, L = [peter,pat] ;  C = b, L = [ann]
?- bagof(N, C^class(N, C), L).   % L = [peter,ann,pat]

A definite clause grammar is translated into ordinary clauses when it is loaded — each non-terminal gains the list before it and the list after it — so nothing about the engine knows that grammars exist. {Goal} escapes to a plain goal, ! is a cut, \+ consumes nothing, and a pushback list rewrites the input that the rules after it will see.

digits([D|T]) --> digit(D), digits(T).
digits([D])   --> digit(D).
digit(D)      --> [D], { char_type(D, digit(_)) }.

number(N)     --> digits(Ds), { number_chars(N, Ds) }.

?- phrase(number(N), "427", Rest).   % N = 427, Rest = []

phrase/2 and phrase/3 walk the control constructs themselves, so a body assembled at run time works as well as one written as a rule. A rule asserted with assertz/1 is translated too.

Text streams carry terms and characters, while binary streams carry bytes. read/1 pulls text a line at a time until the lexer finds a clause terminator, so a term can be read from a console as soon as it is complete rather than after the input ends — a full stop inside 'a. b', inside 3.14, or inside =.. is not one. An incomplete clause at end of input is a catchable syntax_error(unexpected_end_of_file) rather than a silently truncated term. When the char_conversion flag is on, mappings installed by char_conversion/2 are applied to unquoted input before tokenization; quoted text, escapes, and primitive character input remain raw. An input stream's eof_action(error|eof_code|reset) controls reads after its first EOF marker, and close/2 supports forced best-effort cleanup. open/3,4 rejects bound output arguments and alias collisions before it touches the requested source/sink, and rejects non-source/sink terms with domain_error(source_sink, Culprit). Host-invalid pathname atoms remain inside that same catchable domain boundary. Stream permission errors preserve that alias or handle as the culprit, and malformed handles cannot wrap to another live stream. Bound character-input targets are validated before any character is consumed. Character, code, and byte predicates apply ISO error priority when their stream and value arguments are both invalid. Open, close, stream positioning, and term I/O likewise validate option shape, domains, and option semantics before stream lookup in the standard order. Host reader and writer failures remain recoverable through catch/3 as system_error; closing an already-disposed host stream follows the same policy, with force(true) remaining best-effort. write_term/2,3 supports the ISO quoted, ignore_ops, numbervars, and variable_names options. Boolean options are validated exactly, and variable_names/1 uses the leftmost applicable name without binding its terms. read_term/2,3 rejects malformed options before consuming the next term, and integer literals of any length read to their exact unbounded value. It also enforces the advertised max_arity of 255 while reading, raising representation_error(max_arity) before constructing a larger compound. Oversized float literals raise syntax_error(float_overflow) instead of becoming IEEE infinity; underflow rounds to signed zero.

main :-
    read_term(T, []),
    ( T == end_of_file -> true
    ; format("got ~q~n", [T]), main ).

?- with_output_to(atom(A), write(1+2)).      % A = '1+2'
?- open('data.pl', read, S), read(S, T), close(S).

Machine.Output remains what an embedding host sets, and it is user_output specifically — a program that redirects itself with set_output/1 or with_output_to/2 cannot detach the host from the stream it handed in. halt/0 flushes and closes whatever the program opened.

A standard module has an interface followed by zero or more, possibly non-contiguous bodies. The interface owns exports, re-exports, metapredicate declarations, and the initial operator, character-conversion, and flag state for every body:

:- module(shapes).
:- export(describe/1).
:- end_module(shapes).

:- body(shapes).
describe(N) :- helper(N).        % shapes' own helper/1
helper(N) :- write(area(N)).
:- end_body(shapes).
:- module(drawing).
:- export(draw/1).
:- end_module(drawing).

:- body(drawing).
:- import(shapes, describe/1).
draw(N) :- describe(N).
:- end_body(drawing).

?- drawing:draw(4).              % area(4)
?- shapes:helper(9).             % area(9), explicit qualification

An exported predicate is also given its plain name when nothing else has claimed it, which is what lets a generated facade, dotnet prolog run, and an embedding host call it without knowing modules exist. A goal that is only known at run time carries its module and is resolved when called, so a closure handed to a metapredicate finds the predicate it meant. :- metapredicate(run(:)) both declares and exports a Part 2 metapredicate. current_module/1, current_predicate/1, and predicate_property/2 inspect the calling module's visible database. Modern mode also retains the compatibility declarations module/2, use_module/1,2, and meta_predicate/1; StrictIso rejects those spellings in favor of the standard interface/body representation.

Nothing in the engine knows modules exist: resolution is a rewrite performed while loading.

A control term assembled at run time and passed to call/1 is lowered to VM bytecode through the same control-construct compiler used for source clauses. Its cut is transparent within that meta-called goal and opaque to the caller, as ISO specifies. A non-callable term in a compiled clause body remains a runtime error: it is catchable if execution reaches it, while normal control flow can leave it unevaluated. call/2..8 checks the arity of the resulting goal; arity 255 is supported and 256 raises the catchable representation_error(max_arity).

DotProlog's StrictIso mode implements ISO/IEC 13211-1:1995 with Technical Corrigenda 1–3, ISO/IEC 13211-2:2000 modules, and ISO/IEC TS 13211-3:2025 definite clause grammars. The repository's 608-case Part 1 corpus and all 763 applicable declarations in the independent pinned Logtalk corpus pass across consulted bytecode, generated C#, both cross-path directions, and NativeAOT. Parts 2 and 3 have licensed-text traceability and focused managed, generated, and NativeAOT coverage. This is not a claim of SWI-Prolog compatibility. See COMPATIBILITY.md and the Part 1 and Parts 2 and 3 traceability ledgers.

Diagnostics

Diagnostic identifiers are stable and product-specific: DPL0xxx from the reader, DPL1xxx from the compiler, DPL2xxx from .dpli contracts and code generation, and DPL3xxx from the linter.

hello.pl(4,12): error DPL0005: Expected '.' to end the clause but found 'b'.

Starting a project

$ dotnet new install DotProlog.Templates
$ dotnet new prolog-console -n HelloProlog
$ dotnet run --project HelloProlog
Hello from Prolog on .NET!

prolog-console builds a Prolog program as a .NET application; prolog-lib builds a rule set as a typed library for C#, F#, and VB; and prolog-test creates a Prolog test executable that runs under dotnet test. A project pins the DotProlog SDK on the element itself:

<Project Sdk="Microsoft.NET.Sdk">
  <Sdk Name="DotProlog.Sdk" Version="0.15.0" />

A .dplproj publishes with NativeAOT like any other project:

$ dotnet publish HelloProlog -c Release -r osx-arm64 -p:PublishAot=true

The packages are published on NuGet.org as DotProlog.*, starting at 0.2.0 and currently at 0.15.0. The commands above are also verified against a local feed built by dotnet pack, so they work before a version ships.

Testing Prolog

Create a test project with dotnet new prolog-test. Any zero-arity predicate named test_* is a test — it passes if it can be proved:

test_tier_boundaries :-
    tier(1000, gold),
    tier(999, silver).
$ dotnet new prolog-test -n PricingTests
$ cd PricingTests
$ dotnet test --project PricingTests.dplproj
Test run summary: Passed!
  total: 2
  failed: 0
  succeeded: 2

Each test runs in a fresh engine, so one cannot see clauses another asserted.

The .NET 10 SDK selects Microsoft.Testing.Platform through global.json. The standalone prolog-test template includes that setting. A solution containing Prolog tests should keep the same test.runner setting in its solution-root global.json; this repository does so for its mixed xUnit and Prolog test suite.

Building from source

$ git clone https://github.com/kidoz/dotprolog.git
$ cd dotprolog
$ just check          # format-check, docs, build, and test

.NET SDK 10.0 or later is all that building and testing need; everything else restores from NuGet. The documentation step in just check also needs uv.

Releasing

Packages are DotProlog.*: DotProlog.Runtime, DotProlog.Syntax, DotProlog.Compiler, DotProlog.Testing, DotProlog.Tool, DotProlog.Sdk, and DotProlog.Templates. DotProlog.Tool is a pointer package: the executable ships in the per-RID DotProlog.Tool.linux-x64, DotProlog.Tool.osx-arm64, and DotProlog.Tool.win-x64 packages, with DotProlog.Tool.any as the portable fallback, so eleven IDs reach the feed in all. Every assembly-bearing package carries Source Link and a symbol package, so a debugger can step from a package into the exact commit it was built from.

just pack        # every package into ./artifacts, with SHA256SUMS

Prolog.NET, which this project's brief originally proposed, is taken on NuGet by an unrelated WAM-based .NET Prolog. DotProlog.* is what shipped instead.

Releasing is a tag. .github/workflows/release.yml runs on v*, re-runs the format, build, and test gates against the tagged commit on all three platforms, checks the tag agrees with VersionPrefix, publishes native binaries for Linux, Windows, and macOS, packs with checksums and an SBOM, opens a GitHub release whose notes are the changelog section for that version, and only then pushes to NuGet — from a separate job in a release environment, so a required reviewer can stand between the tag and the feed.

The pinned independent ISO corpus is a CI-only step, run on one platform per push to main. Let CI go green on the commit you intend to tag rather than relying on the release run to cover it.

Two things have to agree before tagging, and the workflow fails rather than guessing if they do not: the tag must match VersionPrefix, and the changelog must contain a heading for that exact version, because the release notes are extracted from it.

# 1. Move the version's changelog heading from "unreleased" to today's date, and add its link ref.
# 2. Set VersionPrefix in Directory.Build.props if the version is changing.
# 3. Commit and push, wait for CI, then:
git tag v0.15.0 && git push origin v0.15.0

The publication job authenticates by trusted publishing: it exchanges the workflow's OIDC token for a short-lived nuget.org key, so no API key is stored, and the NUGET_USER secret names the account whose policy authorises the push.

A version whose GitHub release succeeded but whose packages never reached the feed can be finished without re-tagging — run the workflow manually and give it the version, and it publishes the packages already attached to that release. Re-running the original tag run would not help, because a re-run replays the workflow file as it was at the tag.

Licence

MIT — see LICENSE.

Author: Aleksandr Pavlov <ckidoz@gmail.com>

About

Managed Prolog implementation in C# for .NET 10 — Prolog as an SDK-style project language and an embeddable, NativeAOT-ready engine for .NET applications

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages