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. # # **Only one project in a tree may shadow add_test(), and this is that project.** # CMake exposes an overridden command as `_name` and chains exactly one level: a # second override rebinds `_add_test` to the first override and the builtin # becomes unreachable to everyone, so our own registrations recurse until CMake # stops at "Maximum recursion depth of 1000 exceeded". libakgl 0.3.0 briefly # shadowed it unconditionally and this is exactly what happened; the top-level # guard it used to have is back, and libakstdlib has always had one. # --------------------------------------------------------------------------- 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) # libakgl 0.3.0 adds its own vendored SDL, SDL_image, SDL_mixer, SDL_ttf and # jansson whether or not it is top-level, so there is nothing to do here but # add it. Before that it went down a find_package path when embedded and # required all five installed on the system, and this block declared them by # hand first -- filed as libakgl API-gap item 5 and resolved there. # # akerror::akerror and akstdlib::akstdlib still have to exist first: libakgl # guards every dependency with if(NOT TARGET ...), which is what stops its # vendored copies of those two being declared a second time. 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/audio_tables.c src/environment.c src/error.c src/grammar.c src/graphics_tables.c src/parser.c src/parser_commands.c src/play.c src/runtime.c src/runtime_audio.c src/runtime_commands.c src/runtime_functions.c src/runtime_graphics.c src/runtime_housekeeping.c src/runtime_input.c src/scanner.c src/sink_stdio.c src/sink_tee.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 $ $ ) 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/audio_akgl.c src/graphics_akgl.c src/input_akgl.c src/sink_akgl.c ) target_compile_options(akbasic_akgl PRIVATE -Wall -Wextra) target_link_libraries(akbasic_akgl PUBLIC akbasic akgl) akbasic_instrument(akbasic_akgl) # The SDL host, in its own target so the separation goal 3 rests on is visible # in the build graph and not only in a comment: akbasic is the interpreter, # akbasic_akgl is the adaptors that draw through somebody else's renderer, and # this is the one thing in the repository that creates a window. A game # embedding the interpreter links the first two and not this. add_library(akbasic_frontend src/frontend_akgl.c) target_compile_options(akbasic_frontend PRIVATE -Wall -Wextra) target_link_libraries(akbasic_frontend PUBLIC akbasic_akgl) akbasic_instrument(akbasic_frontend) 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_frontend) # The reference's own font, at the reference's own size. Its main.go opens # "./fonts/C64_Pro_Mono-STYLE.ttf", which only ever resolved from its own # source directory; compiled in, it resolves from anywhere, and AKBASIC_FONT # overrides it at runtime for an installed copy. Vendored into assets/fonts/ # rather than reached for in the submodule -- see assets/fonts/PROVENANCE.md, # which also carries the licence question that came with it. target_compile_definitions(basic PRIVATE AKBASIC_HAVE_AKGL=1 AKBASIC_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf") 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_, never test_: 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 audio_verbs devices environment_scope error_codes grammar_leaves graphics_verbs hostvars housekeeping_verbs input_verbs numeric_contract parser_commands parser_expressions runtime_evaluate runtime_verbs scanner_tokens sink_stdio sink_tee symtab value_arithmetic value_bitwise value_compare variable_subscript verbs_table version_check ) set(AKBASIC_WILL_FAIL_TESTS ) # Empty, and worth keeping declared rather than deleted -- libakstdlib left its # AKSL_KNOWN_FAILING_TESTS in place for the same reason when 0.2.0 cleared it. # The next defect that has to be reproduced before it can be fixed goes here, and # the machinery to do that should not have to be reinvented. set(AKBASIC_KNOWN_FAILING_TESTS ) 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() # The akgl-backed suite. Its own list because it is the only test here that links # SDL, and because it can only exist when AKBASIC_WITH_AKGL is on -- the whole # point of the backend records is that everything else builds without it. # # It runs under the dummy video and audio drivers with a software renderer, # following deps/libakgl/tests/draw.c: a 128x128 target read back with # SDL_RenderReadPixels, so it needs no display and no offscreen harness. if(AKBASIC_WITH_AKGL) add_executable(akbasic_test_akgl_backends tests/akgl_backends.c) target_compile_options(akbasic_test_akgl_backends PRIVATE -Wall -Wextra) target_link_libraries(akbasic_test_akgl_backends PRIVATE akbasic_akgl) target_include_directories(akbasic_test_akgl_backends PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/tests") # libakgl's monospaced fixture font, borrowed rather than copied: a second copy # of a 10 KB binary is a second thing to keep in step for no benefit. target_compile_definitions(akbasic_test_akgl_backends PRIVATE AKBASIC_TEST_FONT="${CMAKE_CURRENT_SOURCE_DIR}/deps/libakgl/tests/assets/akgl_test_mono.ttf") akbasic_instrument(akbasic_test_akgl_backends) _add_test(NAME akgl_backends COMMAND akbasic_test_akgl_backends) _set_tests_properties(akgl_backends PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" TIMEOUT 60 ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy") # The frontend suite. Separate from the one above because it tests the *host* # rather than the adaptors -- it is the only test that links akbasic_frontend, # and the only one that opens a window and pumps events. It uses the # reference's own Commodore font, because proving the drawn text is in that # font is part of what TODO.md section 3 asks for. add_executable(akbasic_test_akgl_frontend tests/akgl_frontend.c) target_compile_options(akbasic_test_akgl_frontend PRIVATE -Wall -Wextra) target_link_libraries(akbasic_test_akgl_frontend PRIVATE akbasic_frontend) target_include_directories(akbasic_test_akgl_frontend PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/tests") target_compile_definitions(akbasic_test_akgl_frontend PRIVATE AKBASIC_TEST_C64_FONT="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf") akbasic_instrument(akbasic_test_akgl_frontend) _add_test(NAME akgl_frontend COMMAND akbasic_test_akgl_frontend) _set_tests_properties(akgl_frontend PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" TIMEOUT 60 ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software") # The keyboard test, and the only one in the repository that uses a real X # server rather than the dummy driver. Note the deliberate absence of an # ENVIRONMENT line: forcing the dummy driver here would defeat the whole point. # # Everything else synthesises SDL events, which tests the code *downstream* of # SDL and cannot test the code upstream of it. That gap shipped a bug -- the # frontend never called SDL_StartTextInput(), so SDL emitted no text-input # events, the line editor dropped every keystroke, and the suite stayed green # because it was pushing those events itself. This one drives xdotool at a # focused window, so X11, SDL's composition and the window manager are all in # the path. # # Needs xdotool and script(1), and it **steals keyboard focus** for a few # seconds. Skipped rather than failed when it cannot run -- headless, no # xdotool, no window manager -- because none of those means the answer is no. # AKBASIC_SKIP_INTERACTIVE=1 skips it while you are using the machine. find_program(XDOTOOL_EXECUTABLE xdotool) if(NOT XDOTOOL_EXECUTABLE) message(STATUS "xdotool not found: the akgl_typing test will skip. " "Install it to cover the real keyboard path.") endif() _add_test(NAME akgl_typing COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tests/akgl_typing.sh $) _set_tests_properties(akgl_typing PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" TIMEOUT 180 SKIP_RETURN_CODE 77) endif() 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 reference's own corpus, byte-compared against the sibling .txt. One CTest # case per .bas so a failure names the file. # # It used to be driven in place out of deps/basicinterpret, on the reasoning that # copying a submodule's corpus guarantees drift. That reasoning was sound and it # has been overruled deliberately: the Go dependency is being deprecated, and a # build that cannot run its acceptance suite without cloning the implementation # it replaced is not finished. The copy is byte-identical to # basicinterpreter@d76162c and tests/reference/README.md records # where it came from and what the drift now costs. file(GLOB_RECURSE AKBASIC_GOLDEN_CASES RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}/tests/reference" "${CMAKE_CURRENT_SOURCE_DIR}/tests/reference/*.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=$ -DCASE=${CMAKE_CURRENT_SOURCE_DIR}/tests/reference/${_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) # An AKGL build of `basic` opens a window, and forty-one of them is not what # anybody running the suite wanted. The dummy driver produces the same stdout, # which is the only thing a golden case compares. if(AKBASIC_WITH_AKGL) _set_tests_properties(${AKBASIC_GOLDEN_NAMES} PROPERTIES ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software") endif() endif() # The local golden corpus, for verbs the reference never implemented. # # Still separate now that the reference's corpus lives in this repository too, # and the reason changed rather than went away: tests/reference/ is a *record* of # what the Go implementation did and nothing in it should ever be edited to suit # this one, while tests/language/ is ours to change. Registered under local_ so a # failure says at a glance which of the two it came from -- and so a diff that # touches tests/reference/ stands out as the thing it is. # # 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" ) # # One family of local cases is driver-specific and has to be. A case named # no_device.bas asserts that a verb needing a device refuses politely when it was # given none, which is true of the stdio driver and deliberately false of the # AKGL one -- attaching the three device backends is the whole point of # AKBASIC_WITH_AKGL, and TODO.md section 8 item 1 asks for exactly that change in # observable behaviour. Skipping them there is not a coverage hole: the refusal # path is asserted directly against the backend records in tests/devices.c, which # both configurations build. # set(AKBASIC_LOCAL_NAMES) foreach(_case IN LISTS AKBASIC_LOCAL_CASES) if(AKBASIC_WITH_AKGL AND _case MATCHES "no_device\\.bas$") continue() endif() 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=$ -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) if(AKBASIC_WITH_AKGL) _set_tests_properties(${AKBASIC_LOCAL_NAMES} PROPERTIES ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software") endif() 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})