Skip to content

Conversation

@jjtolton
Copy link

@jjtolton jjtolton commented Nov 6, 2025

Summary

Update: After discussion and reading comments in the PR thread, I've been persuaded that -t is an overall more flexible and elegant solution. This PR now implements a custom toplevel flag instead of the original --halt-on-error and --always-halt flags.

This PR adds a -t GOAL flag that allows users to specify a custom toplevel predicate to run instead of the default REPL. This provides a more flexible and Prolog-idiomatic solution to the original problem of preventing scripts from hanging.

Motivation

Problem: Shell Scripts Get Stuck

Shell scripts using Scryer Prolog can get stuck waiting for user input when errors occur or when programs complete without calling halt/0:

Scenario 1: Errors drop into REPL

#!/bin/bash scryer-prolog -g "undefined_predicate" echo "This line may never execute"

Scenario 2: Programs without explicit halt drop into REPL

#!/bin/bash scryer-prolog my_script.pl # No halt/0 in script echo "This line may never execute"

This is problematic in CI/CD pipelines, automated testing, cron jobs, Docker containers, and batch processing.

Solution: Custom Toplevel with -t

The -t flag allows you to specify any arity-0 predicate as the toplevel:

Basic usage - exit instead of REPL:

scryer-prolog -t halt my_script.pl # Exits with code 0 after loading, never enters REPL

With goals:

scryer-prolog -g "run_tests" -t halt # Runs goal, then halts instead of entering REPL

Custom exit codes:

% my_script.pl my_toplevel :- (run_tests -> halt(0) ; halt(1)).
scryer-prolog -t my_toplevel my_script.pl # Custom logic for exit behavior

Why -t is Better

Compared to the original --halt-on-error / --always-halt approach:

  1. More flexible: Can define custom toplevel behavior, not just halt
  2. Prolog-idiomatic: Follows natural Prolog conventions for toplevel customization
  3. Simpler implementation: Single flag instead of two interdependent flags
  4. More powerful: Can implement complex exit logic in Prolog
  5. Backward compatible: Default behavior (REPL) unchanged when flag not used

Changes

Modified Files

  1. src/toplevel.pl

    • Added custom_toplevel/1 dynamic predicate
    • Added gather_toplevel/2 to process -t flag arguments
    • Modified start_toplevel/0 to check for custom toplevel or default to REPL
    • Updated help text to document -t flag
    • Fixed bug where -t argument was incorrectly processed as a filename
  2. src/tests/custom_toplevel.pl (new)

    • Integration tests for custom toplevel functionality
    • Tests for -t halt behavior
    • Tests for custom user-defined toplevels
  3. tests/scryer/cli/src_tests/custom_toplevel_tests.toml (new)

    • CLI test configuration
    • Verifies -t halt prevents REPL entry

Usage Examples

Basic Usage

# Normal behavior (enters REPL) scryer-prolog my_file.pl # Exit after loading without entering REPL scryer-prolog -t halt my_file.pl # Run goal and exit scryer-prolog -g "write('Hello')" -t halt

In CI/CD Pipeline

#!/bin/bash set -e scryer-prolog -g "compile_project" -t halt scryer-prolog -g "run_tests" -t halt scryer-prolog -g "check_coverage" -t halt echo "All checks passed!"

Custom Toplevel Logic

% test_runner.pl custom_exit :- write('Running tests...'), nl, (run_all_tests -> write('✓ All tests passed'), nl, halt(0) ; write('✗ Tests failed'), nl, halt(1) ).
scryer-prolog -t custom_exit test_runner.pl

In Makefile

test:	scryer-prolog -g "run_all_tests" -t halt build:	scryer-prolog -t halt build.pl .PHONY: test build

Testing

All tests pass, including:

✅ Comprehensive Prolog integration tests in src/tests/custom_toplevel.pl
✅ CLI tests in tests/scryer/cli/src_tests/custom_toplevel_tests.toml
-t halt exits cleanly without entering REPL
-t custom_predicate calls user-defined predicates
✅ Custom toplevels can set exit codes via halt/1
-g goals work correctly with -t flag
✅ Files are loaded before custom toplevel is invoked
✅ Bug fix: -t argument no longer incorrectly processed as filename

Related

Resolves #3146

Implementation Notes

The implementation uses a dynamic predicate custom_toplevel/1 that is set during argument processing. The start_toplevel/0 predicate checks for a custom toplevel and calls it using user:call/1, otherwise defaults to the standard REPL.

This approach is clean, efficient, and follows Prolog conventions for customizing REPL behavior.


Co-Authored-By: J.J.'s Robot jjtolton@gmail.com

@jjtolton jjtolton force-pushed the error-termination-flag branch from 2402a21 to ccc2ff1 Compare November 6, 2025 02:43
@jjtolton jjtolton marked this pull request as ready for review November 6, 2025 03:48
jjtolton added a commit to jjtolton/scryer-prolog that referenced this pull request Nov 6, 2025
Updated BLEEDING.md and README.md to document the new --always-halt flag that was added alongside --halt-on-error in PR mthom#3147. Changes to BLEEDING.md: - Expanded section for PR mthom#3147 to cover both flags - Added behavior comparison table showing all scenarios - Added practical examples for CI/CD, Makefiles, and batch processing - Clarified the problem both flags solve Changes to README.md: - Updated PR mthom#3147 description to mention both flags - Added example showing combined usage The --always-halt flag solves the common problem of programs dropping into the REPL after successful execution when they don't explicitly call halt/0, making Scryer fully script-safe when combined with --halt-on-error. Co-Authored-By: J.J.'s Robot <jjtolton@gmail.com>
@UWN
Copy link

UWN commented Nov 6, 2025

Does this mean that --always-halt masks errors?

@jjtolton
Copy link
Author

jjtolton commented Nov 6, 2025

Does this mean that --always-halt masks errors?

No, --always-halt is invoked only when the repl would be invoked. So, in the case of error, after the error was printed and it would drop into the repl. Otherwise, when the repl would normally be invoked after the other work of the process.

@triska
Copy link
Contributor

triska commented Nov 6, 2025

Please see @bakaq's comment and pointer about this: #3146 (comment)

@triska
Copy link
Contributor

triska commented Nov 7, 2025

Thank you a lot, can you please remove the now no longer needed intermediate commits?

@jjtolton jjtolton changed the title Add --halt-on-error flag for safer scripting Add -t flag for custom toplevel (replaces --halt-on-error) Nov 7, 2025
@jjtolton jjtolton force-pushed the error-termination-flag branch from 829bb22 to c69a352 Compare November 7, 2025 01:19
@jjtolton
Copy link
Author

jjtolton commented Nov 7, 2025

Thank you a lot, can you please remove the now no longer needed intermediate commits?

I don't mind but are we not squash merging?

@jjtolton jjtolton force-pushed the error-termination-flag branch 2 times, most recently from 006597d to d56f604 Compare November 7, 2025 04:06
@triska
Copy link
Contributor

triska commented Nov 7, 2025

are we not squash merging?

Thank you a lot! Individual git commits tend to be meaningfully separated, combining them ignores information that is otherwise present, and adding unrelated commits makes other operations (such as tracing the development history) harder.

@Skgland
Copy link
Contributor

Skgland commented Nov 7, 2025

Is there some way for the toplevel (-t) to tell whether an initialisation goal (-g) failed so that it can exit with a different exit code in case of failure. Something like

alt_repl :- successful_init -> halt ; halt(1).
@triska
Copy link
Contributor

triska commented Nov 7, 2025

@Skgland: Good point. I think a fact like g_caused_exception(Goal, Exception). could be asserted here:

write_term(Exception, [double_quotes(DQ)]), nl % halt?

@triska
Copy link
Contributor

triska commented Nov 7, 2025

Maybe a good topic for a separate PR?

@jjtolton
Copy link
Author

jjtolton commented Nov 8, 2025

I would be happy to include it here as my entire purpose for this was "halt on error", and knowing if the exit code was non-zero is important. With -t halt, it works but the exit code is always 0.

jjtolton added a commit to jjtolton/scryer-prolog that referenced this pull request Nov 8, 2025
When a goal throws an exception during initialization (-g flag), the system now asserts g_caused_exception(Goal, Exception) in the user module. This allows custom toplevels (-t flag) to check if an error occurred and handle it appropriately. Example usage: scryer-prolog -g "throw(error)" -t check_error Where check_error can be: :- dynamic(g_caused_exception/2). check_error :- ( g_caused_exception(_, E) -> write('Error: '), write(E), nl, halt(1) ; halt(0) ). This enables scripts to use custom toplevels for sophisticated error handling and exit code logic. Addresses: mthom#3147 (comment) Co-Authored-By: J.J.'s Robot <jjtolton@gmail.com>
% Helper predicates for testing custom toplevel functionality

success_toplevel :-
write('SUCCESS_TOPLEVEL_EXECUTED'), nl,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

format/2 and strings seem a bit more suitable for such ephemeral data, as in:

format("SUCCESS_...~n", []) 
@jjtolton
Copy link
Author

jjtolton commented Nov 9, 2025

These (generated) scripts having been working well (per @triska 's suggestion, and good suggestion @Skgland for the error term)

#!/usr/bin/env bash ### ~/bin/scryer-prolog ### # trace if environment variable TRACE is set if [[ "${TRACE-0}" == "1" ]]; then set -o xtrace; fi SCRYER_BIN="/home/jay/programs/scryer-prolog/target/release/scryer-prolog" HELPER_FILE="/home/jay/programs/scryer-prolog/.scryer_wrapper_helper.pl" function main() { # Check if -t flag is present in arguments local has_t_flag=false for arg in "$@"; do if [[ "$arg" == "-t" ]]; then has_t_flag=true break fi done # If no -t flag, add default error-checking toplevel if [[ "$has_t_flag" == "false" ]]; then exec "$SCRYER_BIN" "$HELPER_FILE" -t scryer_wrapper_check_error "$@" else exec "$SCRYER_BIN" "$@" fi } if [ "$0" = "${BASH_SOURCE[@]}" ]; then # exit on error set -o errexit # exit on undefined variable set -o nounset # exit on error in pipeline set -o pipefail main "$@" fi
% .scryer_wrapper_helper.pl % Helper file for scryer-prolog wrapper script % Checks if a -g goal threw an exception and exits with appropriate code :- dynamic(g_caused_exception/2). scryer_wrapper_check_error :- ( g_caused_exception(_Goal, _Exception) -> halt(1) ; halt(0) ).
@jjtolton jjtolton force-pushed the error-termination-flag branch 3 times, most recently from d61ec03 to c99db11 Compare November 9, 2025 17:05
@triska
Copy link
Contributor

triska commented Nov 9, 2025

Awesome, this is starting to look very good, thank you a lot!

Regarding the dynamic/1 directive, I think that should be in toplevel.pl? For the time being (until we have more experience and find a better interface), a custom toplevel can refer to the fact with '$toplevel':g_caused_exception(..., ...), does that work?

@triska
Copy link
Contributor

triska commented Nov 9, 2025

Thank you a lot, it looks very good! I noticed only one small remaining issue: The commit message of the first commit (fba0be7) currently still speaks of "Replace halt flags", and "Remove --halt-on-error", which were never present in Scryer.

jjtolton added a commit to jjtolton/scryer-prolog that referenced this pull request Nov 9, 2025
When a goal throws an exception during initialization (-g flag), the system now asserts g_caused_exception(Goal, Exception) in the user module. This allows custom toplevels (-t flag) to check if an error occurred and handle it appropriately. Example usage: scryer-prolog -g "throw(error)" -t check_error Where check_error can be: :- dynamic(g_caused_exception/2). check_error :- ( g_caused_exception(_, E) -> write('Error: '), write(E), nl, halt(1) ; halt(0) ). This enables scripts to use custom toplevels for sophisticated error handling and exit code logic. Addresses: mthom#3147 (comment) Co-Authored-By: J.J.'s Robot <jjtolton@gmail.com>
@jjtolton jjtolton force-pushed the error-termination-flag branch from b00a8fb to e95692d Compare November 9, 2025 17:31
@triska
Copy link
Contributor

triska commented Nov 9, 2025

How come 655fd94 is now part of this PR?

@jjtolton
Copy link
Author

jjtolton commented Nov 9, 2025

bc I still don't know how to rebase. stand by

How come 655fd94 is now part of this PR?

jjtolton added a commit to jjtolton/scryer-prolog that referenced this pull request Nov 9, 2025
When a goal throws an exception during initialization (-g flag), the system now asserts g_caused_exception(Goal, Exception) in the user module. This allows custom toplevels (-t flag) to check if an error occurred and handle it appropriately. Example usage: scryer-prolog -g "throw(error)" -t check_error Where check_error can be: :- dynamic(g_caused_exception/2). check_error :- ( g_caused_exception(_, E) -> write('Error: '), write(E), nl, halt(1) ; halt(0) ). This enables scripts to use custom toplevels for sophisticated error handling and exit code logic. Addresses: mthom#3147 (comment) Co-Authored-By: J.J.'s Robot <jjtolton@gmail.com>
@jjtolton jjtolton force-pushed the error-termination-flag branch from e95692d to 25d9c6e Compare November 9, 2025 17:34
@triska
Copy link
Contributor

triska commented Nov 9, 2025

The -i flag is useful in such cases: One can specify for example $ git rebase -i origin/master, and then interactively select, reorder and also reword and combine commits.

@bakaq
Copy link
Contributor

bakaq commented Nov 9, 2025

Honestly, doing a git rebase without the -i flag is almost always a bad idea.

jjtolton and others added 7 commits November 9, 2025 12:44
- Add -t FLAG to specify custom toplevel (arity 0 predicate) - Default toplevel is 'repl' if -t is not specified - Using `-t halt` achieves original goal of guaranteed termination - Custom toplevels enable flexible exit strategies (e.g., server mode) - Update help text to document -t flag Examples: scryer-prolog -t halt program.pl # Exits after execution scryer-prolog -t my_repl program.pl # Custom REPL scryer-prolog program.pl # Default REPL Co-Authored-By: J.J.'s Robot <noreply@example.com>
- Create Prolog integration tests in src/tests/custom_toplevel.pl - Add CLI test configuration in tests/scryer/cli/src_tests/custom_toplevel_tests.toml - Tests verify: * -t halt terminates after initialization * Custom toplevels can be user-defined predicates * Toplevel receives control after initialization completes * Default behavior is REPL when no -t specified - All tests pass successfully Following TESTING_GUIDE.md three-layer testing approach: - Layer 2: Prolog integration tests with test_framework - Layer 3: CLI snapshot tests with .toml configuration Co-Authored-By: J.J.'s Robot <noreply@example.com>
Fixed issue where `scryer-prolog -t halt` would try to load "halt.pl" as a file instead of just using halt as the custom toplevel. The bug was caused by an extra clause `delegate_task([], []).` that would return control to the calling context instead of continuing to start_toplevel. This caused the argument processing in delegate_task to continue and treat the already-consumed toplevel argument as a filename. Removing this clause ensures that delegate_task([], Goals0) always proceeds to load initialization files and start the toplevel, fixing the double-processing bug. Co-Authored-By: J.J.'s Robot <jjtolton@gmail.com>
When a goal throws an exception during initialization (-g flag), the system now asserts g_caused_exception(Goal, Exception) in the user module. This allows custom toplevels (-t flag) to check if an error occurred and handle it appropriately. Example usage: scryer-prolog -g "throw(error)" -t check_error Where check_error can be: :- dynamic(g_caused_exception/2). check_error :- ( g_caused_exception(_, E) -> write('Error: '), write(E), nl, halt(1) ; halt(0) ). This enables scripts to use custom toplevels for sophisticated error handling and exit code logic. Addresses: mthom#3147 (comment) Co-Authored-By: J.J.'s Robot <jjtolton@gmail.com>
Following TESTING_GUIDE.md, added tests at layers 2 and 3: Layer 2 - Prolog Integration Tests (src/tests/custom_toplevel.pl): - Test that g_caused_exception/2 is not asserted when no exception occurs - Test that g_caused_exception/2 can be checked from custom toplevel - Added check_for_exception/0 helper predicate for testing Layer 3 - CLI Tests (tests/scryer/cli/src_tests/custom_toplevel.md): - Test g_caused_exception/2 with exception thrown - Test g_caused_exception/2 with no exception - Test g_caused_exception/2 with error/2 terms - Added test helper predicates in fixtures/toplevel_test_helper.pl All tests pass successfully. Co-Authored-By: J.J.'s Robot <jjtolton@gmail.com>
- Add dynamic directive in toplevel.pl with other module-level directives - Update test files to reference it as '':g_caused_exception/2 - Remove redundant dynamic directives from test files - All tests passing
@jjtolton jjtolton force-pushed the error-termination-flag branch from 25d9c6e to e7c288f Compare November 9, 2025 17:44
@jjtolton
Copy link
Author

jjtolton commented Nov 9, 2025

(debating if I should tell them I used -i and still failed miserably -- no, best to keep it to myself)

Test that files are loaded before toplevel runs

```trycmd
$ scryer-prolog -f --no-add-history tests/scryer/cli/fixtures/toplevel_test_helper.pl -t write_and_exit
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The -t flag must be stated before any files is loaded. The convention is that switches after file names are application-specific, and switches before the files are Scryer-specific. This is currently not enforced, but will be necessary to follow in the future, when applications become more complex and need their own options (such as application-specific -t switches).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I much prefer the -- de-facto convention that is followed by basically all commands that need to pass arguments to something "inside". In fact, Scryer already supports this. Example:

:- use_module(library(os)). :- use_module(library(format)). main :- argv(A), portray_clause(A), halt.
$ scryer-prolog a.pl -f -g main -- --inner-a 1 --inner-b 2 ["--inner-a","1","--inner-b","2"]. 

This makes Scryer more in line with the rest of the CLI ecosystem. Having an seemingly arbitrary restriction on the order of the arguments like this seems dated and clunky, though I can see the benefit if it is to be more compatible with other Prolog implmentations' CLI interfaces, and it isn't too bad if we do allow the -- too. I think most people would put the filename last naturally anyway.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please let's follow the convention, the advantages will become apparent with more complex applications that need dedicated switches.

Scryer is by far not the only application that requires arguments in this order, it is not arbitrary and also not dated. For example:

$ git log Cargo.lock --stat fatal: option '--stat' must come before non-option arguments 
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using git as an example of a "not dated" CLI is audacious. It's definitely not old, but it being clunky is one of the big reasons that Jujutsu is a thing.

I agree that we should probably enforce this, but it will definitely feel dated.

Test that g_caused_exception/2 is asserted when -g goal throws exception

```trycmd
$ scryer-prolog -f --no-add-history tests/scryer/cli/fixtures/toplevel_test_helper.pl -g "throw(test_error)" -t check_exception_halt_1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, please place Scryer-specific options (in this case: -g and -t) before files that are loaded.

Test that g_caused_exception/2 is not asserted when -g goal succeeds

```trycmd
$ scryer-prolog -f --no-add-history tests/scryer/cli/fixtures/toplevel_test_helper.pl -g "write('Success')" -t check_exception_halt_0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here, please reorder the arguments, thank you a lot!

@triska
Copy link
Contributor

triska commented Nov 9, 2025

Thank you a lot, I added a few comments regarding the ordering of command-line options: Scryer-specific flags should be stated before Prolog files. The positions after Prolog files are for application-specific options.

The custom_toplevel.pl unit tests were trivial and didn't actually test the functionality. All real testing is done via comprehensive CLI tests in tests/scryer/cli/src_tests/custom_toplevel.md
@jjtolton jjtolton force-pushed the error-termination-flag branch from 2781921 to 5357ecf Compare November 9, 2025 17:56
Per maintainer feedback, switches must come before files to follow the convention: switches before files are Scryer-specific, switches after files are application-specific.
Copy link
Contributor

@triska triska left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks awesome, this will be a very useful feature in many situations, thank you a lot!

write(' -g, --goal GOAL '),
write('Run the query GOAL'), nl,
write(' -t GOAL '),
write('Use GOAL as custom toplevel (arity 0 predicate)'), nl,
Copy link
Contributor

@triska triska Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a goal, the invoked predicate can have any arity.

For instance, if you only need an error code and not the type of error, you can use:

$ scryer-prolog -t 'halt(1)' -g run,halt your_file.pl 

Does that work in your scripts? In that way, you do not have to implement a custom toplevel for this particular purpose.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

5 participants