Files
akbasic/CMakeLists.txt
Andrew Kesterson 9d99d0a67e Draw: implement the BASIC 7.0 graphics verbs
GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all
against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so
src/runtime_graphics.c includes no SDL and the whole group is testable in a build
with no SDL on the machine.

The reference lists every one of these as unimplemented, so the semantics come
from Commodore BASIC 7.0 rather than from a port, and four places where a modern
renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than
silently substituted:

- CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is
  deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation,
  so the primitive could serve only the fully-defaulted call, and a shape that
  changed character depending on whether the radii happened to be equal would be
  worse than one uniformly a polygon.
- SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels,
  because a value's string is a fixed 256 bytes and a region is a device surface.
  GSHAPE refuses a string without that prefix instead of parsing whatever digits
  it finds and pasting an unrelated slot.
- BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which
  would make a filled box a seventh argument.
- GRAPHIC stores its mode and honours only the one consequence that means
  anything here -- mode 0 is text -- while still refusing an out-of-range mode,
  since that is a typo worth catching.

PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than
success. The device gives up when its span stack runs out having filled *part* of
the region, and a program that cannot tell that happened cannot recover from it.
Note the shape of that handler: HANDLE sets handled = true on the context, so a
FAIL_RETURN from inside the HANDLE block hands the caller something already
marked handled, whose FINISH_LOGIC then declines to pass it up and releases it --
the error disappears and PAINT reports success. Flag inside the block, raise
after FINISH.

COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up
before a host has lent it a renderer.

Adds a second golden corpus under tests/language/. The corpus in
deps/basicinterpret is a submodule and nothing here may add files to it, but
goal 2's new verbs still need the .bas/.txt half of their coverage. Registered
under local_ so a failure names which corpus it came from. What it can cover is
limited -- these verbs draw rather than print -- so the behaviour that reaches a
device is asserted against tests/mockdevice.h instead.

65/65 ctest, clean under -Wall -Wextra, doxygen clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00

348 lines
13 KiB
CMake

cmake_minimum_required(VERSION 3.10)
# The single source of truth for the version, in the shape libakstdlib and
# libakgl both use. It flows into the library SOVERSION and nothing else spells
# a version number.
#
# 0.x on purpose: TODO.md section 12 records eleven defects carried over from the
# Go reference that are deliberately reproduced and not yet fixed, so the
# language surface is not being promised yet.
project(akbasic VERSION 0.1.0 LANGUAGES C)
# Pre-1.0 the ABI may break on a minor bump, so the soname carries MAJOR.MINOR.
# At 1.0 this becomes ${PROJECT_VERSION_MAJOR} alone.
if(PROJECT_VERSION_MAJOR EQUAL 0)
set(AKBASIC_SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}")
else()
set(AKBASIC_SOVERSION "${PROJECT_VERSION_MAJOR}")
endif()
include(CTest)
include(GNUInstallDirs)
option(AKBASIC_WITH_AKGL "Build the libakgl-backed text sink and link SDL3" OFF)
option(AKBASIC_BUILD_EXAMPLES "Build the embedding example in examples/" ON)
option(AKBASIC_COVERAGE "Instrument the build with gcov coverage counters" OFF)
option(AKBASIC_SANITIZE "Build with ASan + UBSan" OFF)
# ---------------------------------------------------------------------------
# Dependencies
#
# libakgl vendors its own copies of libakerror and libakstdlib and guards every
# dependency with if(NOT TARGET ...), so akerror::akerror and akstdlib::akstdlib
# must exist *before* add_subdirectory(deps/libakgl) or the targets are declared
# twice.
#
# All three dependencies register their CTest tests unconditionally. Pulled in
# EXCLUDE_FROM_ALL their test binaries are never built, so each one would land in
# our suite as "Not Run" and fail. CMake has no way to un-register a test and
# set_tests_properties cannot reach across directory scopes, so add_test() and
# set_tests_properties() are shadowed for the duration of the add_subdirectory()
# calls. Note libakstdlib carries the same shadow but only arms it when *it* is
# top-level, so it does nothing for us -- this one has to wrap all three.
#
# libakerror additionally namespaces its `mutation` target when embedded but not
# its `coverage` target (deps/libakerror/CMakeLists.txt:194 vs :172), so a
# coverage build collides on the `coverage` target and fails to configure at all.
# Rename the dependency's on the way past. Remove this once libakerror applies
# the same CMAKE_SOURCE_DIR test to `coverage` that it already applies to
# `mutation` -- filed in deps/libakstdlib/TODO.md section 2.3.
# ---------------------------------------------------------------------------
set(AKBASIC_SUPPRESS_ADD_TEST TRUE)
function(add_test)
if(NOT AKBASIC_SUPPRESS_ADD_TEST)
_add_test(${ARGV})
endif()
endfunction()
function(set_tests_properties)
if(NOT AKBASIC_SUPPRESS_ADD_TEST)
_set_tests_properties(${ARGV})
endif()
endfunction()
function(add_custom_target _name)
if(AKBASIC_SUPPRESS_ADD_TEST AND _name STREQUAL "coverage")
_add_custom_target(akerror_coverage ${ARGN})
else()
_add_custom_target(${ARGV})
endif()
endfunction()
add_subdirectory(deps/libakerror EXCLUDE_FROM_ALL)
add_subdirectory(deps/libakstdlib EXCLUDE_FROM_ALL)
if(AKBASIC_WITH_AKGL)
add_subdirectory(deps/libakgl EXCLUDE_FROM_ALL)
endif()
set(AKBASIC_SUPPRESS_ADD_TEST FALSE)
# ---------------------------------------------------------------------------
# Instrumentation, applied per target so it never leaks into an exported
# interface.
# ---------------------------------------------------------------------------
function(akbasic_instrument _target)
if(AKBASIC_COVERAGE)
target_compile_options(${_target} PRIVATE --coverage -O0 -g)
target_link_options(${_target} PRIVATE --coverage)
endif()
if(AKBASIC_SANITIZE)
target_compile_options(${_target} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer -g)
target_link_options(${_target} PRIVATE -fsanitize=address,undefined)
endif()
endfunction()
# ---------------------------------------------------------------------------
# The interpreter library. Everything here must be free of SDL, free of any
# process-terminating call, and buildable with no libakgl present.
# ---------------------------------------------------------------------------
set(AKBASIC_SOURCES
src/convert.c
src/environment.c
src/error.c
src/grammar.c
src/graphics_tables.c
src/parser.c
src/parser_commands.c
src/runtime.c
src/runtime_commands.c
src/runtime_functions.c
src/runtime_graphics.c
src/scanner.c
src/sink_stdio.c
src/symtab.c
src/value.c
src/variable.c
src/verbs.c
)
add_library(akbasic ${AKBASIC_SOURCES})
add_library(akbasic::akbasic ALIAS akbasic)
target_include_directories(akbasic PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}/>
)
target_compile_options(akbasic PRIVATE -Wall -Wextra)
target_link_libraries(akbasic PUBLIC akstdlib::akstdlib akerror::akerror)
target_link_libraries(akbasic PRIVATE m)
set_target_properties(akbasic PROPERTIES
VERSION ${PROJECT_VERSION}
SOVERSION ${AKBASIC_SOVERSION}
)
akbasic_instrument(akbasic)
# The libakgl-backed text sink, kept in its own target so the core library and
# the whole golden suite build with no SDL present.
if(AKBASIC_WITH_AKGL)
add_library(akbasic_akgl src/sink_akgl.c)
target_compile_options(akbasic_akgl PRIVATE -Wall -Wextra)
target_link_libraries(akbasic_akgl PUBLIC akbasic akgl)
akbasic_instrument(akbasic_akgl)
endif()
# ---------------------------------------------------------------------------
# The standalone driver. This is the only place FINISH_NORETURN may appear.
# ---------------------------------------------------------------------------
add_executable(basic src/main.c)
target_compile_options(basic PRIVATE -Wall -Wextra)
target_link_libraries(basic PRIVATE akbasic)
if(AKBASIC_WITH_AKGL)
target_link_libraries(basic PRIVATE akbasic_akgl)
target_compile_definitions(basic PRIVATE AKBASIC_HAVE_AKGL=1)
endif()
akbasic_instrument(basic)
# The embedding example. It is built by default and registered as a test so the
# code README.md quotes cannot rot: a signature change breaks the build.
if(AKBASIC_BUILD_EXAMPLES)
foreach(_example IN ITEMS embed hostvars)
add_executable(akbasic_example_${_example} examples/${_example}.c)
target_compile_options(akbasic_example_${_example} PRIVATE -Wall -Wextra)
target_link_libraries(akbasic_example_${_example} PRIVATE akbasic)
akbasic_instrument(akbasic_example_${_example})
_add_test(NAME example_${_example} COMMAND akbasic_example_${_example})
endforeach()
endif()
# ---------------------------------------------------------------------------
# Tests.
#
# Three lists, and two of them invert the meaning of "Passed", exactly as
# libakstdlib does:
# AKBASIC_TESTS must exit 0
# AKBASIC_WILL_FAIL_TESTS abort by design
# AKBASIC_KNOWN_FAILING_TESTS assert the *correct* contract for a defect
# recorded in TODO.md and are expected to fail.
# When one starts passing CTest reports
# "unexpectedly passed" -- that is the cue to move
# it into AKBASIC_TESTS along with the fix.
#
# Test executables are named akbasic_test_<name>, never test_<name>: a
# dependency's targets are created even under EXCLUDE_FROM_ALL, and libakstdlib
# already ships test_version. The CTest name stays bare, so `ctest -R value`
# still selects it.
# ---------------------------------------------------------------------------
set(AKBASIC_TESTS
convert
devices
environment_scope
error_codes
grammar_leaves
graphics_verbs
parser_commands
parser_expressions
runtime_evaluate
runtime_verbs
scanner_tokens
sink_stdio
symtab
value_arithmetic
value_bitwise
value_compare
variable_subscript
verbs_table
version_check
)
set(AKBASIC_WILL_FAIL_TESTS
)
set(AKBASIC_KNOWN_FAILING_TESTS
known_reference_defects
)
foreach(_test IN LISTS AKBASIC_TESTS AKBASIC_WILL_FAIL_TESTS AKBASIC_KNOWN_FAILING_TESTS)
add_executable(akbasic_test_${_test} tests/${_test}.c)
target_compile_options(akbasic_test_${_test} PRIVATE -Wall -Wextra)
target_link_libraries(akbasic_test_${_test} PRIVATE akbasic)
target_include_directories(akbasic_test_${_test} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/tests")
akbasic_instrument(akbasic_test_${_test})
_add_test(NAME ${_test} COMMAND akbasic_test_${_test})
endforeach()
if(AKBASIC_TESTS OR AKBASIC_WILL_FAIL_TESTS OR AKBASIC_KNOWN_FAILING_TESTS)
_set_tests_properties(
${AKBASIC_TESTS} ${AKBASIC_WILL_FAIL_TESTS} ${AKBASIC_KNOWN_FAILING_TESTS}
PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" TIMEOUT 30
)
endif()
if(AKBASIC_WILL_FAIL_TESTS OR AKBASIC_KNOWN_FAILING_TESTS)
_set_tests_properties(
${AKBASIC_WILL_FAIL_TESTS} ${AKBASIC_KNOWN_FAILING_TESTS}
PROPERTIES WILL_FAIL TRUE
)
endif()
# The golden-file suite. It drives deps/basicinterpret/tests/**/*.bas in place --
# the corpus is a submodule and copying it guarantees drift -- and byte-compares
# stdout against the sibling .txt. One CTest case per .bas so a failure names the
# file.
file(GLOB_RECURSE AKBASIC_GOLDEN_CASES
RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}/deps/basicinterpret"
"${CMAKE_CURRENT_SOURCE_DIR}/deps/basicinterpret/tests/*.bas"
)
foreach(_case IN LISTS AKBASIC_GOLDEN_CASES)
string(REGEX REPLACE "^tests/" "" _name "${_case}")
string(REGEX REPLACE "\\.bas$" "" _name "${_name}")
string(REPLACE "/" "_" _name "${_name}")
_add_test(
NAME golden_${_name}
COMMAND ${CMAKE_COMMAND}
-DBASIC=$<TARGET_FILE:basic>
-DCASE=${CMAKE_CURRENT_SOURCE_DIR}/deps/basicinterpret/${_case}
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/golden.cmake
)
endforeach()
if(AKBASIC_GOLDEN_CASES)
set(AKBASIC_GOLDEN_NAMES)
foreach(_case IN LISTS AKBASIC_GOLDEN_CASES)
string(REGEX REPLACE "^tests/" "" _name "${_case}")
string(REGEX REPLACE "\\.bas$" "" _name "${_name}")
string(REPLACE "/" "_" _name "${_name}")
list(APPEND AKBASIC_GOLDEN_NAMES golden_${_name})
endforeach()
_set_tests_properties(${AKBASIC_GOLDEN_NAMES} PROPERTIES TIMEOUT 30)
endif()
# The local golden corpus, for verbs the reference never implemented.
#
# It has to be separate: the corpus above is a submodule and nothing in this
# repository may add files to it, but goal 2's new verbs still need the .bas/.txt
# half of their coverage. Registered under local_ so a failure says at a glance
# which corpus it came from.
#
# Note what this can and cannot cover. The graphics and sound verbs draw and play
# rather than print, so what a golden file sees of them is their *refusals* and
# whatever a program can PRINT about the state they changed. The behaviour that
# reaches a device is asserted against tests/mockdevice.h instead.
file(GLOB_RECURSE AKBASIC_LOCAL_CASES
RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}/tests"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/language/*.bas"
)
set(AKBASIC_LOCAL_NAMES)
foreach(_case IN LISTS AKBASIC_LOCAL_CASES)
string(REGEX REPLACE "^language/" "" _name "${_case}")
string(REGEX REPLACE "\\.bas$" "" _name "${_name}")
string(REPLACE "/" "_" _name "${_name}")
_add_test(
NAME local_${_name}
COMMAND ${CMAKE_COMMAND}
-DBASIC=$<TARGET_FILE:basic>
-DCASE=${CMAKE_CURRENT_SOURCE_DIR}/tests/${_case}
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/golden.cmake
)
list(APPEND AKBASIC_LOCAL_NAMES local_${_name})
endforeach()
if(AKBASIC_LOCAL_NAMES)
_set_tests_properties(${AKBASIC_LOCAL_NAMES} PROPERTIES TIMEOUT 30)
endif()
# ---------------------------------------------------------------------------
# Mutation testing.
#
# Coverage says which lines ran; mutation testing says whether anything would
# have noticed if they were wrong. That distinction matters more here than in an
# ordinary C library: the akerror control-flow macros expand at their call sites,
# so gcov attributes ATTEMPT/CATCH/PASS to the caller and cannot really see them.
# Mutation testing is the only thing that checks them at all.
#
# This target runs the whole akbasic-owned src/ tree and is slow -- upwards of an
# hour. CI runs a narrower, faster set with a --threshold gate; see
# .gitea/workflows/ci.yaml.
#
# Namespaced when embedded in another project, for the same reason the coverage
# targets in the dependencies are: a sibling may well ship a `mutation` target.
find_package(Python3 COMPONENTS Interpreter)
if(Python3_FOUND)
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
set(AKBASIC_MUTATION_TARGET mutation)
else()
set(AKBASIC_MUTATION_TARGET akbasic_mutation)
endif()
add_custom_target(${AKBASIC_MUTATION_TARGET}
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/scripts/mutation_test.py
--source-root ${CMAKE_CURRENT_SOURCE_DIR}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
USES_TERMINAL
COMMENT "Running mutation tests (breaks the library, expects tests to fail)"
)
endif()
install(TARGETS akbasic basic
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)
install(DIRECTORY include/akbasic DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})